From e01a30cf8e3906114ed2d193a4fd559d27831588 Mon Sep 17 00:00:00 2001 From: Muhammad Anas <88967643+Anas12091101@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:02:35 +0500 Subject: [PATCH 1/6] feat(ecommerce): add Stripe as a second payment gateway (#4065) * 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) * 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//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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * docs: update readme --------- Co-authored-by: Claude Opus 5 (1M context) --- README.md | 2 + b2b_ecommerce/views.py | 18 + b2b_ecommerce/views_test.py | 42 + docs/configure_stripe.md | 154 ++++ drf_lint_baseline.json | 12 +- ecommerce/admin.py | 56 ++ ecommerce/api.py | 725 +++++++++++++++++- ecommerce/constants.py | 54 ++ .../commands/resolve_pending_orders.py | 387 ++++++++++ .../commands/resolve_pending_orders_test.py | 411 ++++++++++ .../migrations/0045_order_gateway_type.py | 22 + .../0046_order_stripe_checkout_session_id.py | 23 + ecommerce/models.py | 23 + ecommerce/serializers.py | 15 +- ecommerce/serializers_test.py | 15 +- ecommerce/stripe_api_test.py | 679 ++++++++++++++++ ecommerce/stripe_views_test.py | 120 +++ ecommerce/urls.py | 15 + ecommerce/views.py | 123 ++- mitxpro/features.py | 2 + mitxpro/settings.py | 2 + mitxpro/utils.py | 3 + mitxpro/utils_test.py | 3 + pyproject.toml | 1 + static/js/containers/App.js | 5 + .../js/containers/pages/CheckoutPage_test.js | 64 ++ .../js/containers/pages/CheckoutResultPage.js | 165 ++++ .../pages/CheckoutResultPage_test.js | 68 ++ .../containers/pages/b2b/B2BPurchasePage.js | 21 + static/js/flow/declarations.js | 1 + static/js/lib/queries/ecommerce.js | 11 + static/js/lib/urls.js | 2 + uv.lock | 51 +- 33 files changed, 3226 insertions(+), 69 deletions(-) create mode 100644 docs/configure_stripe.md create mode 100644 ecommerce/management/commands/resolve_pending_orders.py create mode 100644 ecommerce/management/commands/resolve_pending_orders_test.py create mode 100644 ecommerce/migrations/0045_order_gateway_type.py create mode 100644 ecommerce/migrations/0046_order_stripe_checkout_session_id.py create mode 100644 ecommerce/stripe_api_test.py create mode 100644 ecommerce/stripe_views_test.py create mode 100644 static/js/containers/pages/CheckoutResultPage.js create mode 100644 static/js/containers/pages/CheckoutResultPage_test.js diff --git a/README.md b/README.md index 54a5fbc8cc..5d6fc34488 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ We are using a couple of feature flags for xPRO. All these feature flags are lis # Documentation - [Wagtail API for Course and Program Metadata](cms/README.md) +- [Configuring Stripe for local development](docs/configure_stripe.md) +- [Configuring Digital Credentials](docs/configure_digital_credentials.md) # Optional Setup diff --git a/b2b_ecommerce/views.py b/b2b_ecommerce/views.py index 45e162b687..d58bd22fe5 100644 --- a/b2b_ecommerce/views.py +++ b/b2b_ecommerce/views.py @@ -9,8 +9,10 @@ from django.db import transaction from django.http.response import Http404 from django.urls import reverse +from mitol.olposthog.features import is_enabled from rest_framework.exceptions import ValidationError from rest_framework.generics import get_object_or_404 +from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView @@ -27,6 +29,7 @@ from ecommerce.serializers import FullProductVersionSerializer from ecommerce.utils import make_checkout_url from hubspot_xpro.task_helpers import sync_hubspot_b2b_deal +from mitxpro import features from mitxpro.utils import make_csv_http_response from users.models import User @@ -52,6 +55,21 @@ def post( Create a new unfulfilled Order from the user's basket and return information used to submit to CyberSource. """ + if not is_enabled(features.ENABLE_B2B_PURCHASING, default=True): + # Kill switch for new bulk purchases, flippable without a deploy. + # It closes the door on *new* orders only: anyone who already paid + # can still reach their enrollment codes and order status. + log.info("B2BCheckoutView: bulk purchasing is disabled, refusing checkout") + return Response( + { + "errors": [ + "Bulk purchasing is temporarily unavailable. Please contact " + "customer support for more information." + ] + }, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + try: num_seats = request.data["num_seats"] email = request.data["email"] diff --git a/b2b_ecommerce/views_test.py b/b2b_ecommerce/views_test.py index a6e3d40be2..ff65b9407d 100644 --- a/b2b_ecommerce/views_test.py +++ b/b2b_ecommerce/views_test.py @@ -515,3 +515,45 @@ def test_coupon_view_missing_param(client, key): response = client.get(f"{reverse('b2b-coupon-view')}?{urlencode(params)}") assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json() == {"errors": [f"Missing parameter {key}"]} + + +@pytest.mark.parametrize("flag_enabled", [True, False]) +def test_checkout_respects_the_purchasing_kill_switch(client, mocker, flag_enabled): + """ + Bulk purchasing can be switched off without a deploy, because it runs on + the payment processor being retired. + """ + mocker.patch("b2b_ecommerce.views.is_enabled", return_value=flag_enabled) + + resp = client.post(reverse("b2b-checkout"), {}) + + if flag_enabled: + # The request is rubbish, but it got past the switch and into validation. + assert resp.status_code != status.HTTP_503_SERVICE_UNAVAILABLE + else: + assert resp.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + assert "temporarily unavailable" in resp.json()["errors"][0] + + +@pytest.mark.parametrize("flag_enabled", [True, False]) +def test_kill_switch_does_not_block_enrollment_codes(client, mocker, flag_enabled): + """ + Switching purchasing off must not strand anyone who already paid: the + enrollment codes and order status stay reachable either way. + """ + mocker.patch("b2b_ecommerce.views.is_enabled", return_value=flag_enabled) + coupon_version = CouponVersionFactory.create() + order = B2BOrderFactory.create( + coupon_payment_version=coupon_version.payment_version, + status=B2BOrder.FULFILLED, + ) + + codes_resp = client.get( + reverse("b2b-enrollment-codes", kwargs={"hash": order.unique_id}) + ) + status_resp = client.get( + reverse("b2b-order-status", kwargs={"hash": order.unique_id}) + ) + + assert codes_resp.status_code == status.HTTP_200_OK + assert status_resp.status_code == status.HTTP_200_OK diff --git a/docs/configure_stripe.md b/docs/configure_stripe.md new file mode 100644 index 0000000000..f8276c80e6 --- /dev/null +++ b/docs/configure_stripe.md @@ -0,0 +1,154 @@ +### Stripe + +Stripe is one of the two payment gateways xPRO can use for B2C checkout. It is +chosen per user by a feature flag; anyone the flag is off for keeps using +CyberSource. + +Everything below runs in Stripe's test mode. No real money moves. + +#### 1. Get a Stripe test account + +Sign up at [dashboard.stripe.com/register](https://dashboard.stripe.com/register). +It is free and takes a minute. You do not need to activate payments or provide +business details to use test mode. + +Use your own account rather than sharing one. Test data stays yours, and each +account gets its own webhook secret, so nobody's local testing interferes with +anyone else's. + +In the dashboard, make sure **Test mode** is on, then go to **Developers → API +keys** and copy the **Secret key**. It starts with `sk_test_`. + +#### 2. Add settings + +Add these to your `.env` file: + +``` +MITOL_PAYMENT_GATEWAY_STRIPE_API_KEY=sk_test_... +FEATURE_xpro-stripe-payments=True +``` + +| Setting | Value | Notes | +| -------------------------------------- | ------------------------- | ------------------------------------------------------------------------- | +| `MITOL_PAYMENT_GATEWAY_STRIPE_API_KEY` | `sk_test_...` | Your Stripe secret key from step 1. | +| `FEATURE_xpro-stripe-payments` | `True`, `False` (default) | Sends your checkouts to Stripe. Without it, checkout goes to CyberSource. | + +`xpro-stripe-payments` is a PostHog flag. Locally you usually have no PostHog +value for it, and `is_enabled()` then falls back to `settings.FEATURES`, which +is built from the `FEATURE_`-prefixed environment variables — so setting it in +`.env` is enough either way. + +Keep the flag's exact name after the prefix, hyphens included. The fallback +looks the flag up by its PostHog name, and `settings.FEATURES` uses whatever +follows `FEATURE_` verbatim, so the upper-case form you may expect +(`FEATURE_ENABLE_STRIPE_PAYMENTS`) creates a key nothing reads and silently +leaves you on CyberSource. + +Restart the app after editing `.env`, and again after pulling new code: +the source is mounted into the container but the running server does not +reload it, so a change can appear on disk while the app still serves the +old version. + +``` +docker-compose restart web +``` + +#### 3. Install the Stripe CLI + +On macOS: + +``` +brew install stripe/stripe-cli/stripe +``` + +For other platforms see +[Install the Stripe CLI](https://docs.stripe.com/stripe-cli/install). + +Then connect it to your account: + +``` +stripe login +``` + +That opens your browser to confirm a pairing code. + +#### 4. Forward webhooks to your machine + +Stripe sends nothing back to the browser after payment. The order is only +fulfilled when the `checkout.session.completed` webhook arrives — without it +your order stays in `created` and the learner is never enrolled. + +Stripe cannot reach your machine directly, so the CLI forwards events for you: + +``` +stripe listen --forward-to localhost:8053/api/checkout/stripe-webhook/ +``` + +Leave this running in its own terminal while you test. + +It prints a signing secret starting with `whsec_`. Webhook secrets are read from +the database rather than from settings, so store it once — either through Django +admin (**Payment Gateway → Stripe webhook secrets**, superusers only), or in a +shell (`secret_name` is just a label for your own reference): + +``` +docker-compose run --rm web ./manage.py shell +``` + +```python +from mitol.payment_gateway.models import StripeWebhookSecret, StripeWebhookSecretRoute + +secret = StripeWebhookSecret.objects.create( + secret_name="local stripe listen", # pragma: allowlist secret + webhook_secret="whsec_...", # pragma: allowlist secret + is_active=True, +) +StripeWebhookSecretRoute.objects.create(secret=secret, url_name="stripe-webhook") +``` + +If webhooks later start failing with `401`, the stored secret no longer matches +the one `stripe listen` is printing — update the row. + +#### 5. Place a test payment + +Go to checkout and pay with Stripe's +[test card](https://docs.stripe.com/testing) `4242 4242 4242 4242`, any future +expiry such as `12/34`, and any three-digit CVC. + +After paying you land on a page that waits for the webhook, then forwards you to +your dashboard. You should see the enrollment there, and the event in the +`stripe listen` terminal. + +#### Production setup + +There is no `stripe listen` in a deployed environment. Instead, register the +endpoint once per environment in the Stripe dashboard, under **Developers → +Webhooks → Add endpoint**: + +- **URL**: `https:///api/checkout/stripe-webhook/` +- **Events**: `checkout.session.completed`, `checkout.session.expired`, + `checkout.session.async_payment_succeeded`, + `checkout.session.async_payment_failed` + +Stripe shows the signing secret **once**, when the endpoint is created. Store it +in Django admin under **Payment Gateway → Stripe webhook secrets** (restricted to +superusers), adding a route with the url name `stripe-webhook` on the same form. + +Without that row every webhook fails signature validation and returns `401`, so +learners are charged and their orders stay in `created`. It is worth confirming +the secret is present before enabling the Stripe flag for anyone. + +To rotate a secret, add the new one and untick `is_active` on the old one. No +deploy is needed. + +#### Recovering a stuck order + +If a webhook never arrives, the order sits in `created`. `resolve_pending_orders` +asks the gateway what actually happened and makes our records match: + +``` +docker-compose run --rm web ./manage.py resolve_pending_orders --order xpro-b2c-dev-1 +docker-compose run --rm web ./manage.py resolve_pending_orders --all +``` + +It only reports what it would do; pass `--commit` to apply the changes. diff --git a/drf_lint_baseline.json b/drf_lint_baseline.json index 2757a8e88e..1f7496288d 100644 --- a/drf_lint_baseline.json +++ b/drf_lint_baseline.json @@ -15,13 +15,13 @@ "ecommerce/serializers.py:1085:8:ORM002", "ecommerce/serializers.py:1086:8:ORM001", "ecommerce/serializers.py:1140:18:ORM002", - "ecommerce/serializers.py:1175:28:ORM002", - "ecommerce/serializers.py:1177:20:ORM002", + "ecommerce/serializers.py:1176:28:ORM002", + "ecommerce/serializers.py:1178:20:ORM002", "ecommerce/serializers.py:117:22:ORM001", - "ecommerce/serializers.py:1193:20:ORM001", - "ecommerce/serializers.py:1203:26:ORM001", - "ecommerce/serializers.py:1205:25:ORM001", - "ecommerce/serializers.py:1250:28:ORM002", + "ecommerce/serializers.py:1194:20:ORM001", + "ecommerce/serializers.py:1204:26:ORM001", + "ecommerce/serializers.py:1206:25:ORM001", + "ecommerce/serializers.py:1251:28:ORM002", "ecommerce/serializers.py:273:35:ORM002", "ecommerce/serializers.py:278:16:ORM001", "ecommerce/serializers.py:282:32:ORM002", diff --git a/ecommerce/admin.py b/ecommerce/admin.py index 37cf888f9c..807d4ecf4f 100644 --- a/ecommerce/admin.py +++ b/ecommerce/admin.py @@ -4,6 +4,10 @@ from django.contrib import admin from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError +from mitol.payment_gateway.models import ( + StripeWebhookSecret, + StripeWebhookSecretRoute, +) from courses.models import Course from ecommerce.models import ( @@ -638,3 +642,55 @@ class TaxRateAdmin(admin.ModelAdmin): list_display = ("id", "country_code", "tax_rate", "tax_rate_name", "active") search_fields = ("country_code", "tax_rate_name", "tax_rate") model = TaxRate + + +class StripeWebhookSecretRouteInline(admin.TabularInline): + """Routes are edited with their secret: one without the other does nothing""" + + model = StripeWebhookSecretRoute + extra = 1 + + +@admin.register(StripeWebhookSecret) +class StripeWebhookSecretAdmin(admin.ModelAdmin): + """ + Admin for the Stripe webhook signing secret. + + The payment gateway verifies every incoming Stripe webhook against this + secret, so without a row here webhooks are rejected and paid orders are + never fulfilled. Each environment has its own value, shown once on the + endpoint's page in the Stripe dashboard. + + This is registered so the secret can be set without a shell on the server. + Seeing it needs the payment_gateway permissions, which are granted to nobody + by default, so in practice that means a superuser. It is a verification + secret -- it cannot move money -- but it is never shown in full in the list + regardless. + """ + + model = StripeWebhookSecret + inlines = [StripeWebhookSecretRouteInline] + list_display = ["id", "secret_name", "is_active", "masked_secret", "routes_list"] + list_filter = ["is_active"] + search_fields = ["secret_name"] + readonly_fields = ["created_on", "updated_on"] + + def get_queryset(self, request): + """ + Show inactive secrets too. + + The model's default manager filters them out, which would leave a + rotated-out secret invisible and impossible to re-activate here. + """ + return StripeWebhookSecret.all_objects.get_queryset() + + @admin.display(description="Secret") + def masked_secret(self, obj): + """Enough to match against Stripe, without putting it on screen in full""" + secret = obj.webhook_secret or "" + return f"{secret[:9]}\u2026{secret[-4:]}" if secret else "" + + @admin.display(description="Routes") + def routes_list(self, obj): + """A secret with no route is never consulted, so surface that here""" + return ", ".join(obj.routes.values_list("url_name", flat=True)) or "NONE" diff --git a/ecommerce/api.py b/ecommerce/api.py index 2bdcaf7434..5f37b75eb5 100644 --- a/ecommerce/api.py +++ b/ecommerce/api.py @@ -5,6 +5,7 @@ import decimal import hashlib import hmac +import json import logging import re import uuid @@ -21,6 +22,18 @@ from django.http import HttpRequest from django.urls import reverse from ipware import get_client_ip +from mitol.olposthog.features import is_enabled +from mitol.payment_gateway.api import CartItem as GatewayCartItem +from mitol.payment_gateway.api import Order as GatewayOrder +from mitol.payment_gateway.api import PaymentGateway +from mitol.payment_gateway.constants import ( + MITOL_PAYMENT_GATEWAY_CYBERSOURCE, + MITOL_PAYMENT_GATEWAY_STRIPE, + STRIPE_CHECKOUT_SESSION_STATUS_EXPIRED, + STRIPE_CHECKOUT_SESSION_STATUS_OPEN, + STRIPE_PAYMENT_STATUS_NPR, + STRIPE_PAYMENT_STATUS_PAID, +) from rest_framework.exceptions import ValidationError import sheets.tasks @@ -36,9 +49,20 @@ from courses.utils import is_program_text_id from ecommerce.constants import ( CYBERSOURCE_DECISION_ACCEPT, + CYBERSOURCE_DECISION_DECLINE, CYBERSOURCE_DECISION_CANCEL, DISCOUNT_TYPE_DOLLARS_OFF, DISCOUNT_TYPE_PERCENT_OFF, + STRIPE_CARD_BRAND_TO_CYBERSOURCE_CODE, + STRIPE_CHECKOUT_STATUS_CANCELLED, + STRIPE_CHECKOUT_STATUS_ERROR, + STRIPE_CHECKOUT_STATUS_PAID, + STRIPE_CHECKOUT_STATUS_PENDING, + STRIPE_INTENT_STATUS_CANCELED, + STRIPE_INTENT_STATUS_PROCESSING, + STRIPE_INTENT_STATUS_REQUIRES_ACTION, + STRIPE_INTENT_STATUS_REQUIRES_CONFIRMATION, + STRIPE_INTENT_STATUS_REQUIRES_PAYMENT_METHOD, ) from ecommerce.exceptions import EcommerceException from ecommerce.mail_api import send_ecommerce_order_receipt @@ -69,6 +93,7 @@ from ecommerce.utils import positive_or_zero from hubspot_xpro.task_helpers import sync_hubspot_deal from maxmind.api import ip_to_country_code +from mitxpro import features from mitxpro.utils import case_insensitive_equal, first_or_none, now_in_utc log = logging.getLogger(__name__) @@ -242,6 +267,60 @@ def sign_cybersource_payload(payload): return {**payload, "signature": generate_cybersource_sa_signature(payload)} +# Sentinel so callers can pass "no coupon" without it being mistaken for +# "not supplied" and re-queried. +_COUPON_VERSION_UNSET = object() + + +def get_order_coupon_version(order): + """ + Get the CouponVersion redeemed against an order, if any. + + Args: + order (Order): An order + + Returns: + CouponVersion or None + """ + coupon_redemption = CouponRedemption.objects.filter(order=order).first() + return coupon_redemption.coupon_version if coupon_redemption is not None else None + + +CENTS_PER_UNIT = 100 + + +def round_to_cents(value): + """ + Round a monetary value to two decimal places. + + Every amount handed to a gateway or written to a receipt goes through here, + so the rounding rule lives in one place rather than being restated at each + call site. + + Args: + value (decimal.Decimal or int or str): A monetary value + + Returns: + decimal.Decimal: The value rounded to cents + """ + return decimal.Decimal(value).quantize(decimal.Decimal("0.01")) + + +def stripe_minor_units_to_amount(minor_units): + """ + Convert a Stripe amount in minor units to a decimal amount. + + Stripe reports money in the currency's smallest unit (cents for USD). + + Args: + minor_units (int or None): An amount as Stripe reports it + + Returns: + decimal.Decimal: The amount in major units, rounded to cents + """ + return round_to_cents(decimal.Decimal(minor_units or 0) / CENTS_PER_UNIT) + + def _generate_cybersource_sa_payload(*, order, receipt_url, cancel_url, ip_address): """ Generates a payload dict to send to CyberSource for Secure Acceptance @@ -260,10 +339,7 @@ def _generate_cybersource_sa_payload(*, order, receipt_url, cancel_url, ip_addre # length of 255. At the moment none of these fields should go over that, due to database # constraints or other reasons - coupon_redemption = CouponRedemption.objects.filter(order=order).first() - coupon_version = ( - coupon_redemption.coupon_version if coupon_redemption is not None else None - ) + coupon_version = get_order_coupon_version(order) line_items = {} total = 0 @@ -281,51 +357,19 @@ def _generate_cybersource_sa_payload(*, order, receipt_url, cancel_url, ip_addre line_items[f"item_{i}_quantity"] = line.quantity line_items[f"item_{i}_sku"] = product_version.product.content_object.id line_items[f"item_{i}_tax_amount"] = str( - decimal.Decimal(product_price_dict["tax_assessed"]).quantize( - decimal.Decimal("0.01") - ) + round_to_cents(product_price_dict["tax_assessed"]) ) line_items[f"item_{i}_unit_price"] = str(product_price_dict["price"]) total += product_price_dict["price"] total_tax_assessed += product_price_dict["tax_assessed"] - # At the moment there should only be one line - product_version = order.lines.first().product_version - product = product_version.product - content_object = product.content_object - readable_id = get_readable_id(content_object) - - merchant_fields = { - "merchant_defined_data1": f"{product.content_type.app_label} | {product.content_type.name}", - "merchant_defined_data2": readable_id, - "merchant_defined_data3": "1", - } - - if coupon_version is not None: - merchant_fields["merchant_defined_data4"] = coupon_version.coupon.coupon_code - merchant_fields["merchant_defined_data5"] = ( # company name - coupon_version.payment_version.company.name - if coupon_version.payment_version.company - else "" - ) - merchant_fields["merchant_defined_data6"] = ( - coupon_version.payment_version.payment_transaction or "" - ) - merchant_fields["merchant_defined_data7"] = ( - coupon_version.payment_version.payment_type or "" - ) + merchant_fields = _generate_merchant_fields(order, coupon_version) return { "access_key": settings.CYBERSOURCE_ACCESS_KEY, - "amount": str( - decimal.Decimal(total + total_tax_assessed).quantize( - decimal.Decimal("0.01") - ) - ), - "tax_amount": str( - decimal.Decimal(total_tax_assessed).quantize(decimal.Decimal("0.01")) - ), + "amount": str(round_to_cents(total + total_tax_assessed)), + "tax_amount": str(round_to_cents(total_tax_assessed)), "consumer_id": order.purchaser.username, "currency": "USD", "locale": "en-us", @@ -365,6 +409,607 @@ def generate_cybersource_sa_payload(*, order, receipt_url, cancel_url, ip_addres ) +def get_gateway_type_for_user(user): + """ + Determine which payment gateway a user's checkout should go through. + + The PostHog flag is the only control: on for a user means Stripe, off or + absent means CyberSource. Selection is per-user so a gateway can be turned + on for a subset of users, and turning the flag off everywhere returns the + whole site to CyberSource. + + Args: + user (User): The purchasing user + + Returns: + str: A gateway name from mitol.payment_gateway.constants + """ + if is_enabled( + features.ENABLE_STRIPE_PAYMENTS, default=False, opt_unique_id=str(user.id) + ): + return MITOL_PAYMENT_GATEWAY_STRIPE + + return MITOL_PAYMENT_GATEWAY_CYBERSOURCE + + +def _generate_stripe_cart_items(order, coupon_version=_COUPON_VERSION_UNSET): + """ + Map an order's lines onto the payment gateway library's CartItem dataclass + for a Stripe checkout session. + + Prices and tax are calculated exactly as they are for CyberSource: we work + out the tax ourselves and hand the gateway the pre-tax price plus the tax + as `taxable`. The Stripe backend charges `unitprice + taxable` with an + inclusive tax behaviour, so Stripe never recalculates it. + + Args: + order (Order): An order + coupon_version (CouponVersion or None): The coupon applied to the order. + Looked up from the order when not supplied; pass None to say + explicitly that no coupon applies. + + Returns: + list of GatewayCartItem: The cart items for the checkout session + """ + if coupon_version is _COUPON_VERSION_UNSET: + coupon_version = get_order_coupon_version(order) + + cart_items = [] + for line in order.lines.all(): + product_version = line.product_version + product_price_dict = get_product_version_price_with_discount_tax( + coupon_version=coupon_version, + product_version=product_version, + tax_rate=order.tax_rate, + ) + content_type = product_version.product.content_type + cart_items.append( + GatewayCartItem( + code=f"{content_type.app_label} | {content_type.name}", + name=str(product_version.description)[:254], + sku=str(product_version.product.content_object.id), + # Deliberately 1, not line.quantity. Order.total_price_paid and + # the CyberSource `amount` are both computed from the unit price + # without multiplying by quantity, so passing the quantity here + # would have Stripe charge a multiple of what the learner agreed + # to and what their receipt shows. + quantity=1, + unitprice=product_price_dict["price"], + taxable=round_to_cents(product_price_dict["tax_assessed"]), + ) + ) + + return cart_items + + +def _generate_merchant_fields(order, coupon_version=_COUPON_VERSION_UNSET): + """ + Build the merchant-defined data for an order. + + Both gateways send the same fields: CyberSource takes them in the Secure + Acceptance payload, Stripe stores them as checkout session metadata. + + Args: + order (Order): An order + coupon_version (CouponVersion or None): The coupon applied to the order. + Looked up from the order when not supplied; pass None to say + explicitly that no coupon applies. + + Returns: + dict: merchant defined data fields + """ + if coupon_version is _COUPON_VERSION_UNSET: + coupon_version = get_order_coupon_version(order) + + product_version = order.lines.first().product_version + product = product_version.product + readable_id = get_readable_id(product.content_object) + + merchant_fields = { + "merchant_defined_data1": f"{product.content_type.app_label} | {product.content_type.name}", + "merchant_defined_data2": readable_id, + "merchant_defined_data3": "1", + } + + if coupon_version is not None: + merchant_fields["merchant_defined_data4"] = coupon_version.coupon.coupon_code + merchant_fields["merchant_defined_data5"] = ( # company name + coupon_version.payment_version.company.name + if coupon_version.payment_version.company + else "" + ) + merchant_fields["merchant_defined_data6"] = ( + coupon_version.payment_version.payment_transaction or "" + ) + merchant_fields["merchant_defined_data7"] = ( + coupon_version.payment_version.payment_type or "" + ) + + return merchant_fields + + +def start_stripe_checkout(*, order, receipt_url, cancel_url, ip_address=None): + """ + Create a Stripe checkout session for an order. + + The session ID is stored on the order before the learner is redirected. If + we never hear back from Stripe, that ID is the only way to reconcile or + refund the payment later. + + Args: + order (Order): An order + receipt_url (str): Where Stripe returns the learner after checkout + cancel_url (str): Where Stripe returns the learner if they cancel + ip_address (str): The user's IP address + + Returns: + dict: payload/url/method, in the same shape CheckoutView already returns + """ + coupon_version = get_order_coupon_version(order) + cart_items = _generate_stripe_cart_items(order, coupon_version=coupon_version) + + # Charging the wrong amount is the worst thing that can go wrong here, so + # check the total we're about to send against the order before sending it. + cart_total = round_to_cents( + sum( + (decimal.Decimal(item.unitprice) + decimal.Decimal(item.taxable)) + * item.quantity + for item in cart_items + ) + ) + expected_total = round_to_cents( + decimal.Decimal(order.total_price_paid) + * (1 + (decimal.Decimal(order.tax_rate or 0) / 100)) + ) + if cart_total != expected_total: + # Not fatal: prices are recomputed from the current product version at + # checkout time, so an order created before a price change legitimately + # diverges from its stored total -- CyberSource behaves the same way. + # Worth shouting about, because the other way to land here is a bug in + # the cart mapping, and that would charge the wrong amount. + log.warning( + "start_stripe_checkout: cart total %s for order %s does not match the " + "stored order total %s", + cart_total, + order.reference_number, + expected_total, + ) + + gateway_order = GatewayOrder( + username=order.purchaser.username, + email=order.purchaser.email, + ip_address=ip_address, + reference=order.reference_number, + items=cart_items, + ) + + payload = PaymentGateway.start_payment( + MITOL_PAYMENT_GATEWAY_STRIPE, + gateway_order, + receipt_url, + cancel_url, + merchant_fields=_generate_merchant_fields(order, coupon_version=coupon_version), + ) + + # Hand back a plain dict rather than a StripeObject: this payload is + # serialised into the checkout API response. + session = stripe_object_to_dict(payload["payload"]) + + # The checkout page reads `reference_number` off this payload to tag its + # GTM purchase event. CyberSource's payload carries it; a Stripe session + # calls it `client_reference_id`, so without this the event would go out + # untagged and the purchase would be unattributable in analytics. + session["reference_number"] = order.reference_number + + payload["payload"] = session + checkout_session_id = session.get("id") if session else None + if checkout_session_id: + # An unpaid order is reused across checkout attempts, so a learner who + # goes back and starts again gets a second session. Both stay payable + # until they expire, which is a route to being charged twice, so retire + # the previous one. The order's session ID is updated first: the expiry + # webhook looks orders up by session ID, and this way the expired + # session no longer matches anything and can't fail the live order. + # Swap the session ID under a row lock. Reading the previous value off + # the in-memory instance would race: two concurrent checkouts would + # both read the same "previous", each would expire it, and both new + # sessions would stay payable -- exactly the double-charge this exists + # to prevent. Serialized, the second checkout sees the first's session + # as previous and retires it, leaving one live session. + with transaction.atomic(): + locked_order = Order.objects.select_for_update().get(id=order.id) + previous_session_id = locked_order.stripe_checkout_session_id + locked_order.stripe_checkout_session_id = checkout_session_id + locked_order.save(update_fields=["stripe_checkout_session_id"]) + if previous_session_id and previous_session_id != checkout_session_id: + expire_stripe_checkout_session(previous_session_id) + else: + log.error( + "start_stripe_checkout: no checkout session ID returned for order %s", + order.reference_number, + ) + + return payload + + +def stripe_object_to_dict(stripe_object): + """ + Convert a Stripe API object into a plain dict. + + The Stripe SDK returns StripeObject instances, which don't behave like + dicts for attribute access and can't be stored in a JSONField. Everything + downstream of the API boundary works with plain dicts instead. + + Args: + stripe_object: A StripeObject, dict, or None + + Returns: + dict or None + """ + if stripe_object is None or isinstance(stripe_object, str): + return None + if hasattr(stripe_object, "to_dict_recursive"): + return stripe_object.to_dict_recursive() + if hasattr(stripe_object, "to_dict"): + return json.loads(json.dumps(stripe_object.to_dict(), default=str)) + return stripe_object + + +def expire_stripe_checkout_session(checkout_session_id): + """ + Expire a checkout session so it can no longer be paid. + + Used when a learner starts checkout again on the same order: without this + the earlier session stays payable and they could be charged twice. + + Args: + checkout_session_id (str): The Stripe checkout session ID + """ + gateway = PaymentGateway.get_gateway_class(MITOL_PAYMENT_GATEWAY_STRIPE) + try: + gateway.stripe_client.v1.checkout.sessions.expire(checkout_session_id) + except Exception: # noqa: BLE001 + # Already expired, already paid, or Stripe is unhappy. Not worth + # failing the new checkout over, but we want to see it. + log.exception( + "expire_stripe_checkout_session: could not expire session %s", + checkout_session_id, + ) + + +def get_stripe_checkout_session_status(checkout_session_id): + """ + Work out the real state of a Stripe checkout session. + + `checkout.session.completed` only means the learner finished the checkout + flow. For payment methods with delayed notification (ACH, SEPA, and the + like) the session completes while the payment is still processing, and it + can fail afterwards, so the event type alone is not safe to fulfil on. This + re-pulls the session with the PaymentIntent expanded and reduces the two + together to a single state. + + Args: + checkout_session_id (str): The Stripe checkout session ID + + Returns: + dict: `status` (one of the STRIPE_CHECKOUT_STATUS_* values), plus + `session` and `payment_intent` for callers that need the detail + """ + gateway = PaymentGateway.get_gateway_class(MITOL_PAYMENT_GATEWAY_STRIPE) + session = stripe_object_to_dict( + gateway.stripe_client.v1.checkout.sessions.retrieve( + checkout_session_id, + {"expand": ["payment_intent", "payment_intent.latest_charge"]}, + ) + ) + payment_intent = session.get("payment_intent") + if isinstance(payment_intent, str): + payment_intent = None + + session_status = session.get("status") + payment_status = session.get("payment_status") + intent_status = payment_intent.get("status") if payment_intent else None + + if session_status == STRIPE_CHECKOUT_SESSION_STATUS_EXPIRED: + status_value = STRIPE_CHECKOUT_STATUS_CANCELLED + elif intent_status == STRIPE_INTENT_STATUS_CANCELED: + status_value = STRIPE_CHECKOUT_STATUS_CANCELLED + elif payment_status in (STRIPE_PAYMENT_STATUS_PAID, STRIPE_PAYMENT_STATUS_NPR): + status_value = STRIPE_CHECKOUT_STATUS_PAID + elif intent_status in ( + STRIPE_INTENT_STATUS_PROCESSING, + STRIPE_INTENT_STATUS_REQUIRES_ACTION, + STRIPE_INTENT_STATUS_REQUIRES_CONFIRMATION, + ): + # Delayed payment methods sit here until the bank confirms. + status_value = STRIPE_CHECKOUT_STATUS_PENDING + elif intent_status == STRIPE_INTENT_STATUS_REQUIRES_PAYMENT_METHOD: + # The payment was attempted and did not go through. + status_value = STRIPE_CHECKOUT_STATUS_ERROR + elif session_status == STRIPE_CHECKOUT_SESSION_STATUS_OPEN: + status_value = STRIPE_CHECKOUT_STATUS_PENDING + else: + status_value = STRIPE_CHECKOUT_STATUS_ERROR + + return { + "status": status_value, + "session": session, + "payment_intent": payment_intent, + } + + +def stripe_data_to_receipt_data(session, payment_intent=None, checkout_status=None): + """ + Translate Stripe checkout data into the receipt keys the app already reads. + + The receipt page and the receipt email both read CyberSource-style `req_*` + keys. Rather than teach them about Stripe, we write those same keys at the + point the webhook lands, and keep the raw Stripe objects alongside so + nothing is lost. + + Args: + session (dict): The Stripe checkout session + payment_intent (dict): The expanded PaymentIntent, if available + checkout_status (str): The resolved checkout status, used to record a + decision that matches what actually happened + + Returns: + dict: Receipt data using the existing req_* keys + """ + # A receipt is stored for failures too -- CyberSource does the same, and it + # is the audit trail for "what did the processor tell us" -- so the decision + # has to reflect the real outcome. Writing ACCEPT on a failed payment would + # leave contradictory records. + decision = { + STRIPE_CHECKOUT_STATUS_PAID: CYBERSOURCE_DECISION_ACCEPT, + STRIPE_CHECKOUT_STATUS_CANCELLED: CYBERSOURCE_DECISION_CANCEL, + STRIPE_CHECKOUT_STATUS_ERROR: CYBERSOURCE_DECISION_DECLINE, + }.get(checkout_status, CYBERSOURCE_DECISION_ACCEPT) + total = session.get("amount_total") + total_details = session.get("total_details") or {} + tax_amount = total_details.get("amount_tax") or 0 + customer_details = session.get("customer_details") or {} + + # Newer Stripe API versions dropped `payment_intent.charges` in favour of + # `latest_charge`; fall back to the old shape for older versions. + charge = None + if payment_intent: + latest_charge = payment_intent.get("latest_charge") + if isinstance(latest_charge, dict): + charge = latest_charge + else: + charges = (payment_intent.get("charges") or {}).get("data") or [] + charge = charges[0] if charges else None + + # The receipt page and email read the CyberSource name keys. Stripe gives a + # single full name, so split it the way a billing form would. Without this + # neither key exists and the receipt renders the purchaser's name as "None". + billing_name = (customer_details.get("name") or "").strip() + forename, _, surname = billing_name.partition(" ") + + payment_method_details = (charge or {}).get("payment_method_details") or {} + # Don't assume cards: ACH and other methods are available to learners, and + # writing "card" for a bank payment would put a lie on the receipt. + payment_method_type = payment_method_details.get("type", "") + card = payment_method_details.get("card") or {} + + receipt_data = { + "req_reference_number": session.get("client_reference_id"), + "req_amount": str(stripe_minor_units_to_amount(total)), + "req_tax_amount": str(stripe_minor_units_to_amount(tax_amount)), + "req_currency": (session.get("currency") or "usd").upper(), + "req_bill_to_email": customer_details.get("email"), + "req_bill_to_forename": forename, + "req_bill_to_surname": surname.strip(), + "req_payment_method": payment_method_type, + "req_card_number": f"xxxxxxxxxxxx{card['last4']}" if card.get("last4") else "", + "req_card_type": STRIPE_CARD_BRAND_TO_CYBERSOURCE_CODE.get( + card.get("brand", ""), "" + ), + "req_transaction_uuid": session.get("id"), + "decision": decision, + # The raw objects, so nothing Stripe told us is thrown away. + "stripe_checkout_session": session, + "stripe_payment_intent": payment_intent, + } + + for key in ("merchant_defined_data1", "merchant_defined_data2"): + value = (session.get("metadata") or {}).get(key) + if value: + receipt_data[key] = value + + return receipt_data + + +def fulfill_stripe_order(checkout_session_id): + """ + Fulfil the order behind a Stripe checkout session. + + Safe to call more than once, and safe to call concurrently: the order row + is locked before its status is read, so a duplicate webhook delivery waits + for the first to commit and then quietly finds the order already fulfilled. + Stripe delivers events at least once and retries anything that isn't a 2xx, + so this has to be a no-op rather than an error the second time around. + + Args: + checkout_session_id (str): The Stripe checkout session ID + + Returns: + Order or None: The order, or None if no matching order exists + """ + status_info = get_stripe_checkout_session_status(checkout_session_id) + session = status_info["session"] + reference_number = session.get("client_reference_id") + + try: + order = Order.objects.get_by_reference_number(reference_number) + except Order.DoesNotExist: + log.error( + "fulfill_stripe_order: no order matching reference number %s (session %s)", + reference_number, + checkout_session_id, + ) + return None + + receipt_data = stripe_data_to_receipt_data( + session, status_info["payment_intent"], checkout_status=status_info["status"] + ) + + with transaction.atomic(): + locked_order = Order.objects.select_for_update().get(id=order.id) + + if locked_order.status in (Order.FULFILLED, Order.FAILED, Order.REFUNDED): + # All three are terminal. A failed order is never reused for + # checkout (a new attempt creates a new order), and a refunded one + # must not be quietly re-fulfilled by a late redelivery, which + # would re-enroll a learner whose money has been returned. Writing + # another receipt per redelivery would also pile up duplicates. + log.info( + "fulfill_stripe_order: order %s is already %s, ignoring " + "duplicate delivery for session %s", + locked_order.reference_number, + locked_order.status, + checkout_session_id, + ) + return locked_order + + if status_info["status"] == STRIPE_CHECKOUT_STATUS_PENDING: + # A delayed payment method that hasn't cleared. Leave the order + # alone; checkout.session.async_payment_succeeded brings us back. + log.info( + "fulfill_stripe_order: session %s for order %s is still pending " + "payment, leaving the order unfulfilled", + checkout_session_id, + locked_order.reference_number, + ) + return locked_order + + receipt = Receipt.objects.create(data=receipt_data) + receipt.order = locked_order + receipt.save() + + if status_info["status"] != STRIPE_CHECKOUT_STATUS_PAID: + locked_order.status = Order.FAILED + locked_order.save() + return locked_order + + # Enroll the learner *before* marking the order fulfilled, and do both + # while still holding the row lock. Two things depend on that: + # + # - if enrollment fails, the transaction rolls back and the order stays + # unfulfilled, so Stripe's retry re-runs the whole thing. Marking it + # fulfilled first would leave a paying learner unenrolled with the + # retry short-circuiting on the fulfilled order. + # - a concurrent duplicate delivery blocks on the lock until this + # commits, then sees FULFILLED and stops. Releasing the lock before + # the transition would let both deliveries enroll and send receipts. + # + # create_run_enrollments is idempotent, so a retry is safe. + complete_order(locked_order) + + locked_order.status = Order.FULFILLED + locked_order.save() + + # Outside the transaction: sending mail isn't rollback-able, and we only + # want it once the fulfilled state is actually committed. + send_ecommerce_order_receipt( + order=locked_order, + cyber_source_provided_email=receipt_data.get("req_bill_to_email"), + ) + + # CRM sync is best-effort: a HubSpot outage must not fail the webhook, for + # the same reason. + try: + sync_hubspot_deal(locked_order) + except Exception: # noqa: BLE001 + log.exception( + "fulfill_stripe_order: HubSpot sync failed for order %s; fulfilment " + "itself is unaffected", + locked_order.reference_number, + ) + + locked_order.save_and_log(None) + + return locked_order + + +def cancel_stripe_order(checkout_session_id, *, reason=""): + """ + Mark the order behind an expired or failed checkout session as failed. + + Like fulfilment this is safe to run twice; an order that already reached a + terminal state is left alone. + + Args: + checkout_session_id (str): The Stripe checkout session ID + reason (str): Why the session ended, for the log + + Returns: + Order or None: The order, or None if no matching order exists + """ + order = Order.objects.filter(stripe_checkout_session_id=checkout_session_id).first() + + if order is None: + # Expected for superseded sessions: when a learner restarts checkout, + # the order's session ID is replaced and the old session is expired, + # so its expiry event matches nothing. Not an error. + log.info( + "cancel_stripe_order: no order for checkout session %s (most " + "likely a superseded session)", + checkout_session_id, + ) + return None + + if order.status != Order.CREATED: + # Checked before fetching the session so a redelivery for an order that + # is already settled costs nothing at Stripe. + log.info( + "cancel_stripe_order: order %s is already in state %s, ignoring", + order.reference_number, + order.status, + ) + return order + + # CyberSource records a receipt for a declined payment too, so do the same + # here: what the processor told us is the audit trail, and it should not + # depend on which gateway took the order. Fetched before the lock is taken, + # to keep a network call out of the transaction. + status_info = get_stripe_checkout_session_status(checkout_session_id) + receipt_data = stripe_data_to_receipt_data( + status_info["session"], + status_info["payment_intent"], + checkout_status=STRIPE_CHECKOUT_STATUS_CANCELLED, + ) + + with transaction.atomic(): + locked_order = Order.objects.select_for_update().get(id=order.id) + + if locked_order.status != Order.CREATED: + log.info( + "cancel_stripe_order: order %s is already in state %s, ignoring", + locked_order.reference_number, + locked_order.status, + ) + return locked_order + + receipt = Receipt.objects.create(data=receipt_data) + receipt.order = locked_order + receipt.save() + + locked_order.status = Order.FAILED + locked_order.save() + + log.info( + "cancel_stripe_order: order %s marked failed (session %s)%s", + locked_order.reference_number, + checkout_session_id, + f": {reason}" if reason else "", + ) + locked_order.save_and_log(None) + + return locked_order + + def latest_coupon_version(coupon): """ Get the most recent CouponVersion for a coupon diff --git a/ecommerce/constants.py b/ecommerce/constants.py index c83440e84e..a5ca4cbed8 100644 --- a/ecommerce/constants.py +++ b/ecommerce/constants.py @@ -3,6 +3,9 @@ # From secure acceptance documentation, under API reply fields: # http://apps.cybersource.com/library/documentation/dev_guides/Secure_Acceptance_SOP/Secure_Acceptance_SOP.pdf CYBERSOURCE_DECISION_ACCEPT = "ACCEPT" +# reason_code 100 is the only success code; anything else is a failure of +# some kind. https://developer.cybersource.com/library/documentation/sbc/reason_codes/reason_codes.html +CYBERSOURCE_REASON_CODE_ACCEPTED = "100" CYBERSOURCE_DECISION_DECLINE = "DECLINE" CYBERSOURCE_DECISION_REVIEW = "REVIEW" CYBERSOURCE_DECISION_ERROR = "ERROR" @@ -55,3 +58,54 @@ COUPON_ADD_PERMISSION = "ecommerce.add_coupon" COUPON_UPDATE_PERMISSION = "ecommerce.change_coupon" + +# Stripe event types we act on. `completed` only means the learner finished the +# checkout flow -- for payment methods with delayed notification the payment can +# still be processing -- so the async events matter as much as the first one. +STRIPE_EVENT_CHECKOUT_SESSION_COMPLETED = "checkout.session.completed" +STRIPE_EVENT_CHECKOUT_SESSION_EXPIRED = "checkout.session.expired" +STRIPE_EVENT_CHECKOUT_SESSION_ASYNC_PAYMENT_SUCCEEDED = ( + "checkout.session.async_payment_succeeded" +) +STRIPE_EVENT_CHECKOUT_SESSION_ASYNC_PAYMENT_FAILED = ( + "checkout.session.async_payment_failed" +) + +STRIPE_FULFILL_EVENTS = [ + STRIPE_EVENT_CHECKOUT_SESSION_COMPLETED, + STRIPE_EVENT_CHECKOUT_SESSION_ASYNC_PAYMENT_SUCCEEDED, +] +STRIPE_CANCEL_EVENTS = [ + STRIPE_EVENT_CHECKOUT_SESSION_EXPIRED, + STRIPE_EVENT_CHECKOUT_SESSION_ASYNC_PAYMENT_FAILED, +] + +# PaymentIntent statuses. The payment gateway library has constants for the +# checkout session and payment statuses, but not for these. +# https://docs.stripe.com/payments/paymentintents/lifecycle +STRIPE_INTENT_STATUS_CANCELED = "canceled" +STRIPE_INTENT_STATUS_PROCESSING = "processing" +STRIPE_INTENT_STATUS_REQUIRES_ACTION = "requires_action" +STRIPE_INTENT_STATUS_REQUIRES_CONFIRMATION = "requires_confirmation" +STRIPE_INTENT_STATUS_REQUIRES_PAYMENT_METHOD = "requires_payment_method" + +# Overall states a Stripe checkout session is collapsed into. The session's own +# status fields don't tell the whole story -- the PaymentIntent carries the +# detail of the payment itself -- so both are considered together. +STRIPE_CHECKOUT_STATUS_PAID = "paid" +STRIPE_CHECKOUT_STATUS_PENDING = "pending" +STRIPE_CHECKOUT_STATUS_CANCELLED = "cancelled" +STRIPE_CHECKOUT_STATUS_ERROR = "error" + +# Stripe reports card brands by name; the receipt serializer looks brands up in +# CYBERSOURCE_CARD_TYPES by numeric code. Translate at write time so the receipt +# page and email keep working unchanged. +STRIPE_CARD_BRAND_TO_CYBERSOURCE_CODE = { + "visa": "001", + "mastercard": "002", + "amex": "003", + "discover": "004", + "diners": "005", + "jcb": "007", + "unionpay": "062", +} diff --git a/ecommerce/management/commands/resolve_pending_orders.py b/ecommerce/management/commands/resolve_pending_orders.py new file mode 100644 index 0000000000..df232f0e0d --- /dev/null +++ b/ecommerce/management/commands/resolve_pending_orders.py @@ -0,0 +1,387 @@ +""" +Resolves orders that are stuck in the created state. + +A gateway tells us a payment succeeded out of band -- Stripe by webhook, +CyberSource by a server-to-server POST -- and that message can fail to arrive: +the app might have been down, the endpoint misconfigured, or the retries +exhausted. When that happens the learner has paid and the order sits in +`created` forever, because nothing else retries. + +Stripe retries webhooks for a while, so a stuck Stripe order means those retries +ran out. CyberSource documents no retry at all for the merchant POST, so a +single failed delivery is likely permanent. Both end up in the same place. + +This asks the gateway what actually happened to the order and makes our records +match -- fulfilling it if the payment went through, failing it if it didn't, and +leaving it alone if the payment is still in flight. + +Arguments: +* --order - a single order (ex: xpro-b2c-dev-123, or 123) +* --all - every stuck order, on either gateway +* --commit - actually apply the changes + +This reports what it would do 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. +""" + +import logging +import operator +from collections import defaultdict + +from django.core.management import BaseCommand, CommandError +from django.db.models import Q +from mitol.payment_gateway.api import PaymentGateway +from mitol.payment_gateway.constants import ( + MITOL_PAYMENT_GATEWAY_CYBERSOURCE, + MITOL_PAYMENT_GATEWAY_STRIPE, +) + +from ecommerce.api import ( + cancel_stripe_order, + fulfill_order, + fulfill_stripe_order, + get_stripe_checkout_session_status, +) +from ecommerce.constants import ( + CYBERSOURCE_DECISION_ACCEPT, + CYBERSOURCE_DECISION_DECLINE, + CYBERSOURCE_REASON_CODE_ACCEPTED, + STRIPE_CHECKOUT_STATUS_CANCELLED, + STRIPE_CHECKOUT_STATUS_ERROR, + STRIPE_CHECKOUT_STATUS_PAID, + STRIPE_CHECKOUT_STATUS_PENDING, +) +from ecommerce.exceptions import ParseException +from ecommerce.models import Order + +log = logging.getLogger(__name__) + +# CyberSource's search endpoint takes a batch, so ask for the orders in chunks +# rather than one request per order. +CYBERSOURCE_SEARCH_BATCH_SIZE = 20 + + +def _normalize_cybersource_payload(payload): + """ + Make the library's transaction payload look like a Secure Acceptance reply. + + fulfill_order decides fulfilled-vs-failed from `decision`, expecting the + word CyberSource puts in a merchant POST ("ACCEPT", "DECLINE", ...). The + payment gateway library builds its payload from the Transaction Details + API instead and writes the numeric reason code into `decision` -- "100" on + success. Passed through as-is, a paid order would compare "100" != "ACCEPT" + and be marked failed. Derive the word from the reason code, as MITx Online + does. The raw reason code is kept alongside. + + The same payload leaves `req_card_type` and `req_card_number` empty, so a + receipt written from it shows no card details. That is a limit of the + Transaction Details data the library maps, not something we can fill in. + """ + reason_code = str(payload.get("reason_code", "")).strip() + decision = ( + CYBERSOURCE_DECISION_ACCEPT + if reason_code == CYBERSOURCE_REASON_CODE_ACCEPTED + else CYBERSOURCE_DECISION_DECLINE + ) + return {**payload, "decision": decision} + + +class Command(BaseCommand): + """ + Resolves orders whose payment confirmation never arrived. + """ + + help = "Resolves orders that are stuck in the created state." + + def add_arguments(self, parser) -> None: + # Exactly one of these, enforced by argparse: naming an order and + # also passing --all is a mistake worth refusing rather than guessing + # at, since the two mean very different amounts of money. + target = parser.add_mutually_exclusive_group(required=True) + target.add_argument( + "--order", + type=str, + help=( + "The order to resolve, as a reference number " + "(ex: xpro-b2c-dev-123) or a plain order ID (ex: 123)." + ), + ) + target.add_argument( + "--all", action="store_true", help="Resolve all stuck orders." + ) + + parser.add_argument( + "--commit", + action="store_true", + help="Apply the changes. Without this the command only reports.", + ) + + def get_orders(self, reference_number, *, process_all): + """ + Find the orders to work on. + + Only orders still in `created` are candidates. Stripe orders addtionally + need a checkout session recorded: without that ID there is nothing to + ask Stripe about. CyberSource orders are looked up by reference number, + so they need nothing extra. + """ + # `__in=[None, ""]` would not match NULL in SQL, so spell the two + # empty cases out. + stripe_without_session = Q(gateway_type=MITOL_PAYMENT_GATEWAY_STRIPE) & ( + 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) + + @staticmethod + def _resolve_identifier(identifier): + """ + Find the order named on the command line. + + Accepts a reference number or a bare order ID: the ID is what an + operator reading the database has to hand, the reference number is what + the gateway reports. + """ + if identifier.isdigit(): + order = Order.objects.filter(id=int(identifier)).first() + else: + try: + order = Order.objects.get_by_reference_number(identifier) + except Order.DoesNotExist: + order = None + except ParseException as exc: + # Raised for a reference number from another environment, or one + # that doesn't end in an order ID. Without this it escapes as a + # traceback instead of telling the operator what to fix. + raise CommandError( # noqa: TRY003 + f"{identifier} is not a valid order reference number: {exc}" # noqa: EM102 + ) from None + + if order is None: + raise CommandError(f"No order found for {identifier}.") # noqa: EM102, TRY003 + + return order + + def _find_cybersource_payloads(self, orders): + """ + Ask CyberSource what happened to each order. + + Returns a dict of reference number to the CyberSource-shaped payload, + which is the same shape the merchant POST would have delivered. Orders + with no transaction found are left out: the learner reached the payment + page and never paid, which is an abandoned checkout rather than a stuck + order, and is indistinguishable from one in our own database. + """ + payloads = {} + if not orders: + return payloads + + gateway = PaymentGateway.get_gateway_class(MITOL_PAYMENT_GATEWAY_CYBERSOURCE) + reference_numbers = [order.reference_number for order in orders] + found = defaultdict(list) + + for start in range(0, len(reference_numbers), CYBERSOURCE_SEARCH_BATCH_SIZE): + batch = reference_numbers[start : start + CYBERSOURCE_SEARCH_BATCH_SIZE] + # Deliberately not PaymentGateway.find_and_get_transactions: it + # iterates `results.items()` and then indexes the dict with the + # resulting tuple, so it raises KeyError whenever the search + # actually finds something. These two calls are what it wraps. + for ( + transaction_id, + reference_number, + submitted, + ) in gateway.find_transactions(batch, len(batch)): + found[reference_number].append((submitted, transaction_id)) + + for reference_number, transactions in found.items(): + detailed = [] + for submitted, transaction_id in transactions: + _response, payload = gateway.get_transaction_details(transaction_id) + detailed.append((submitted, _normalize_cybersource_payload(payload))) + + # An unpaid order is reused across checkout attempts, so one + # reference number can carry several transactions -- a decline + # followed by a successful retry is exactly what this command + # exists to rescue. find_transactions promises no ordering, so + # choose deliberately rather than keeping whichever arrived last: + # an accepted transaction wins, and the most recent breaks a tie. + # Taking the last one would mark a genuinely paid order failed. + accepted = [ + entry + for entry in detailed + if entry[1].get("decision") == CYBERSOURCE_DECISION_ACCEPT + ] + submitted_at = operator.itemgetter(0) + payloads[reference_number] = max(accepted or detailed, key=submitted_at)[1] + + return payloads + + def _resolve_cybersource(self, orders, *, dry_run): + """Resolve CyberSource orders by replaying the reply we never received""" + resolved = 0 + errors = 0 + + try: + payloads = self._find_cybersource_payloads(orders) + except Exception: + # The batch lookup runs before the per-order loop, so a failure + # here -- bad gateway credentials, CyberSource unreachable -- would + # otherwise escape as a traceback and take the Stripe orders down + # with it. Report it and let the rest of the run continue. + log.exception("Could not query CyberSource for pending transactions") + self.stderr.write( + f"Could not reach CyberSource; skipped {len(orders)} order(s). " + "Check the MITOL_PAYMENT_GATEWAY_CYBERSOURCE_* settings." + ) + return 0, len(orders) + + for order in orders: + try: + payload = payloads.get(order.reference_number) + + if payload is None: + self.stdout.write( + f"{order.reference_number}: no CyberSource transaction, " + "likely an abandoned checkout, leaving alone" + ) + continue + + decision = payload.get("decision") + + if dry_run: + action = ( + "fulfill" if decision == CYBERSOURCE_DECISION_ACCEPT else "fail" + ) + self.stdout.write( + f"{order.reference_number}: would {action} " + f"(CyberSource says {decision})" + ) + resolved += 1 + continue + + # fulfill_order is the same path the merchant POST takes: it records + # the receipt, moves the order to fulfilled or failed based on the + # decision, and enrolls the learner when it succeeded. + fulfill_order(payload) + + if decision == CYBERSOURCE_DECISION_ACCEPT: + self.stdout.write( + self.style.SUCCESS(f"{order.reference_number}: fulfilled") + ) + else: + self.stdout.write( + f"{order.reference_number}: marked failed ({decision})" + ) + + resolved += 1 + except Exception: + # Keep going: with --all, one unresolvable order must not + # strand every other stuck learner behind it. + errors += 1 + log.exception("Failed to resolve %s", order.reference_number) + self.stderr.write(f"{order.reference_number}: errored, skipped") + + return resolved, errors + + def _resolve_stripe(self, orders, *, dry_run): + """Resolve Stripe orders by asking about their checkout session""" + resolved = 0 + errors = 0 + + for order in orders: + try: + session_id = order.stripe_checkout_session_id + status_info = get_stripe_checkout_session_status(session_id) + state = status_info["status"] + + if state == STRIPE_CHECKOUT_STATUS_PENDING: + # A delayed payment method that hasn't cleared yet. Stripe will + # still send async_payment_succeeded, so leave it be. + self.stdout.write( + f"{order.reference_number}: payment still in progress, leaving alone" + ) + continue + + if dry_run: + action = ( + "fulfill" if state == STRIPE_CHECKOUT_STATUS_PAID else "fail" + ) + self.stdout.write( + f"{order.reference_number}: would {action} (Stripe says {state})" + ) + resolved += 1 + continue + + if state == STRIPE_CHECKOUT_STATUS_PAID: + fulfill_stripe_order(session_id) + self.stdout.write( + self.style.SUCCESS(f"{order.reference_number}: fulfilled") + ) + elif state in ( + STRIPE_CHECKOUT_STATUS_CANCELLED, + STRIPE_CHECKOUT_STATUS_ERROR, + ): + cancel_stripe_order(session_id, reason=f"resolved from {state}") + self.stdout.write( + f"{order.reference_number}: marked failed ({state})" + ) + + resolved += 1 + except Exception: + errors += 1 + log.exception("Failed to resolve %s", order.reference_number) + self.stderr.write(f"{order.reference_number}: errored, skipped") + + return resolved, errors + + def handle(self, *args, **kwargs): # noqa: ARG002 + dry_run = not kwargs["commit"] + orders = self.get_orders(kwargs["order"], process_all=kwargs["all"]) + + if not orders: + # Nothing stuck is the healthy state, not a failure -- exiting + # non-zero here would make this unusable on a schedule. A named + # order that doesn't exist is a different matter and still errors, + # in get_orders. + self.stdout.write("No stuck orders found.") + return + + stripe_orders = [ + order + for order in orders + if order.gateway_type == MITOL_PAYMENT_GATEWAY_STRIPE + ] + cybersource_orders = [ + order + for order in orders + if order.gateway_type != MITOL_PAYMENT_GATEWAY_STRIPE + ] + + resolved, errors = self._resolve_stripe(stripe_orders, dry_run=dry_run) + cs_resolved, cs_errors = self._resolve_cybersource( + cybersource_orders, dry_run=dry_run + ) + resolved += cs_resolved + errors += cs_errors + + if dry_run: + self.stdout.write( + self.style.WARNING( + f"Would resolve {resolved} order(s). Pass --commit to apply." + ) + ) + else: + self.stdout.write(f"Resolved {resolved} order(s).") + + if errors: + # Exit non-zero so a scheduled run surfaces the failures, but only + # after everything resolvable has been dealt with. + raise CommandError(f"{errors} order(s) could not be resolved.") # noqa: EM102, TRY003 diff --git a/ecommerce/management/commands/resolve_pending_orders_test.py b/ecommerce/management/commands/resolve_pending_orders_test.py new file mode 100644 index 0000000000..92529595ee --- /dev/null +++ b/ecommerce/management/commands/resolve_pending_orders_test.py @@ -0,0 +1,411 @@ +"""Tests for the resolve_pending_orders command""" + +from io import StringIO + +import pytest +from django.core.management import CommandError, call_command +from mitol.payment_gateway.constants import MITOL_PAYMENT_GATEWAY_STRIPE + +from ecommerce.constants import ( + STRIPE_CHECKOUT_STATUS_CANCELLED, + STRIPE_CHECKOUT_STATUS_PAID, + STRIPE_CHECKOUT_STATUS_PENDING, +) +from ecommerce.factories import LineFactory, OrderFactory +from ecommerce.models import Order + +pytestmark = pytest.mark.django_db + +COMMAND = "resolve_pending_orders" + + +@pytest.fixture +def stuck_order(): + """An order whose webhook never arrived: paid at Stripe, still `created` here""" + order = OrderFactory.create( + status=Order.CREATED, + gateway_type=MITOL_PAYMENT_GATEWAY_STRIPE, + stripe_checkout_session_id="cs_test_123", + ) + LineFactory.create(order=order) + return order + + +def _patch_status(mocker, status_value): + """Make Stripe report a given state for the session""" + return mocker.patch( + "ecommerce.management.commands.resolve_pending_orders.get_stripe_checkout_session_status", + return_value={"status": status_value, "session": {}, "payment_intent": None}, + ) + + +@pytest.mark.parametrize( + "kwargs", + [ + pytest.param({}, id="neither"), + pytest.param({"order": "xpro-b2c-dev-1", "all": True}, id="both"), + ], +) +def test_requires_exactly_one_of_order_or_all(kwargs): + """ + The command shouldn't guess at what to operate on, and letting --all + silently win would apply changes to every pending order when the operator + named a single one. + """ + with pytest.raises(CommandError): + call_command(COMMAND, **kwargs) + + +def test_unknown_reference_number_is_an_error(): + """A typo in the reference number should say so, not silently do nothing""" + with pytest.raises(CommandError): + call_command(COMMAND, order="xpro-b2c-dev-999999") + + +def test_nothing_stuck_is_not_an_error(): + """ + Nothing stuck is the healthy state. Exiting non-zero would make this + unusable on a schedule. + """ + call_command(COMMAND, all=True) + + +def test_paid_session_fulfills_the_order(mocker, stuck_order): + """The main case: Stripe took the money, we never heard, so fulfil it now""" + _patch_status(mocker, STRIPE_CHECKOUT_STATUS_PAID) + fulfill = mocker.patch( + "ecommerce.management.commands.resolve_pending_orders.fulfill_stripe_order" + ) + + call_command(COMMAND, order=stuck_order.reference_number, commit=True) + + fulfill.assert_called_once_with("cs_test_123") + + +def test_cancelled_session_fails_the_order(mocker, stuck_order): + """If the payment never happened, the order shouldn't sit pending forever""" + _patch_status(mocker, STRIPE_CHECKOUT_STATUS_CANCELLED) + cancel = mocker.patch( + "ecommerce.management.commands.resolve_pending_orders.cancel_stripe_order" + ) + + call_command(COMMAND, order=stuck_order.reference_number, commit=True) + + assert cancel.call_count == 1 + + +def test_pending_payment_is_left_alone(mocker, stuck_order): + """ + A delayed payment that hasn't cleared is not stuck -- Stripe will still send + async_payment_succeeded, so the command must not pre-empt it. + """ + _patch_status(mocker, STRIPE_CHECKOUT_STATUS_PENDING) + fulfill = mocker.patch( + "ecommerce.management.commands.resolve_pending_orders.fulfill_stripe_order" + ) + cancel = mocker.patch( + "ecommerce.management.commands.resolve_pending_orders.cancel_stripe_order" + ) + + call_command(COMMAND, order=stuck_order.reference_number, commit=True) + + assert fulfill.call_count == 0 + assert cancel.call_count == 0 + + +def test_reports_without_changing_anything_by_default(mocker, stuck_order): + """ + Fulfilling enrolls a learner and emails them a receipt, so the command + reports and stops unless --commit is passed. + """ + _patch_status(mocker, STRIPE_CHECKOUT_STATUS_PAID) + fulfill = mocker.patch( + "ecommerce.management.commands.resolve_pending_orders.fulfill_stripe_order" + ) + + call_command(COMMAND, order=stuck_order.reference_number) + + assert fulfill.call_count == 0 + stuck_order.refresh_from_db() + assert stuck_order.status == Order.CREATED + + +def test_ignores_already_finished_orders(mocker, stuck_order): + """ + --all picks up stuck orders on both gateways, and nothing that has already + reached a final state. + """ + cybersource_order = OrderFactory.create(status=Order.CREATED) + OrderFactory.create( + status=Order.FULFILLED, + gateway_type=MITOL_PAYMENT_GATEWAY_STRIPE, + stripe_checkout_session_id="cs_test_done", + ) + OrderFactory.create(status=Order.FULFILLED) + patched = _patch_status(mocker, STRIPE_CHECKOUT_STATUS_PAID) + mocker.patch( + "ecommerce.management.commands.resolve_pending_orders.fulfill_stripe_order" + ) + gateway = _patch_cybersource(mocker, []) + + call_command(COMMAND, all=True, commit=True) + + # Only the stuck Stripe order was looked up at Stripe. + assert patched.call_count == 1 + assert patched.call_args.args[0] == stuck_order.stripe_checkout_session_id + # And only the stuck CyberSource order was searched for. + assert gateway.find_transactions.call_args.args[0] == [ + cybersource_order.reference_number + ] + + +@pytest.fixture +def cybersource_stuck_order(): + """ + An order whose merchant POST never arrived: paid at CyberSource, still + `created` here. CyberSource is the model default, so nothing to set. + """ + order = OrderFactory.create(status=Order.CREATED) + LineFactory.create(order=order) + return order + + +def _library_payload(refno, reason_code): + """ + A payload shaped the way the payment gateway library actually returns it. + + Note `decision` carries the numeric reason code, not a word -- that is what + the library does, and it is why the command has to normalize it. A test + using a tidy {"decision": "ACCEPT"} payload would pass against code that + marks every paid order failed. + """ + return { + "decision": reason_code, + "reason_code": reason_code, + "req_reference_number": refno, + "req_card_type": "", + "req_card_number": "", + } + + +def _patch_cybersource(mocker, transactions, payload=None): + """ + Stand in for the CyberSource gateway. + + `transactions` is what the search returns: (transaction id, reference + number, submitted at) rows, the same shape find_transactions produces. + """ + gateway = mocker.Mock() + gateway.find_transactions.return_value = transactions + gateway.get_transaction_details.return_value = (mocker.Mock(), payload) + mocker.patch( + "ecommerce.management.commands.resolve_pending_orders.PaymentGateway.get_gateway_class", + return_value=gateway, + ) + return gateway + + +def test_paid_cybersource_transaction_fulfills_the_order( + mocker, cybersource_stuck_order +): + """ + The CyberSource equivalent of the main case: the money was taken, the + merchant POST never landed, so replay it now. + """ + refno = cybersource_stuck_order.reference_number + _patch_cybersource( + mocker, + [["7883514374536923204011", refno, "2026-09-02"]], + _library_payload(refno, "100"), + ) + fulfill = mocker.patch( + "ecommerce.management.commands.resolve_pending_orders.fulfill_order" + ) + + call_command(COMMAND, order=refno, commit=True) + + fulfill.assert_called_once() + replayed = fulfill.call_args.args[0] + assert replayed["req_reference_number"] == refno + # The library's "100" must arrive at fulfill_order as the word it expects, + # or determine_order_status_change fails a paid order. + assert replayed["decision"] == "ACCEPT" + assert replayed["reason_code"] == "100" + + +def test_declined_cybersource_transaction_still_replays( + mocker, cybersource_stuck_order +): + """ + A declined payment is resolved too: fulfill_order records the receipt and + moves the order to failed, so it stops sitting in `created`. + """ + refno = cybersource_stuck_order.reference_number + _patch_cybersource( + mocker, + [["788351437453692320401", refno, "2026-09-02"]], + _library_payload(refno, "203"), + ) + fulfill = mocker.patch( + "ecommerce.management.commands.resolve_pending_orders.fulfill_order" + ) + + call_command(COMMAND, order=refno, commit=True) + + fulfill.assert_called_once() + assert fulfill.call_args.args[0]["decision"] == "DECLINE" + + +def test_cybersource_order_with_no_transaction_is_left_alone( + mocker, cybersource_stuck_order +): + """ + No transaction means the learner never paid -- an abandoned checkout, which + looks identical to a stuck order in our own database. Don't touch it. + """ + _patch_cybersource(mocker, []) + fulfill = mocker.patch( + "ecommerce.management.commands.resolve_pending_orders.fulfill_order" + ) + + call_command(COMMAND, order=cybersource_stuck_order.reference_number, commit=True) + + fulfill.assert_not_called() + + +def test_cybersource_dry_run_reports_the_right_action(mocker, cybersource_stuck_order): + """ + Without --commit the command reports and leaves the order alone -- and + the report has to say "fulfill" for a paid order, not "fail". + """ + refno = cybersource_stuck_order.reference_number + _patch_cybersource( + mocker, + [["7883514374536923204011", refno, "2026-09-02"]], + _library_payload(refno, "100"), + ) + fulfill = mocker.patch( + "ecommerce.management.commands.resolve_pending_orders.fulfill_order" + ) + out = StringIO() + + call_command(COMMAND, order=refno, stdout=out) + + fulfill.assert_not_called() + cybersource_stuck_order.refresh_from_db() + assert cybersource_stuck_order.status == Order.CREATED + assert f"{refno}: would fulfill" in out.getvalue() + + +def test_does_not_use_the_broken_library_helper(mocker, cybersource_stuck_order): + """ + PaymentGateway.find_and_get_transactions raises KeyError as soon as the + search finds anything, so this command must not call it. + """ + refno = cybersource_stuck_order.reference_number + gateway = _patch_cybersource( + mocker, + [["7883514374536923204011", refno, "2026-09-02"]], + _library_payload(refno, "100"), + ) + mocker.patch("ecommerce.management.commands.resolve_pending_orders.fulfill_order") + + call_command(COMMAND, order=refno, commit=True) + + gateway.find_and_get_transactions.assert_not_called() + gateway.find_transactions.assert_called_once() + + +def test_accepts_a_plain_order_id(mocker, cybersource_stuck_order): + """An operator reading the database has the ID, not the reference number""" + refno = cybersource_stuck_order.reference_number + payload = { + "decision": "ACCEPT", + "req_reference_number": refno, + "reason_code": "100", + } + _patch_cybersource( + mocker, [["788351437453692320401", refno, "2026-09-02"]], payload + ) + fulfill = mocker.patch( + "ecommerce.management.commands.resolve_pending_orders.fulfill_order" + ) + + call_command(COMMAND, order=str(cybersource_stuck_order.id), commit=True) + + fulfill.assert_called_once_with(payload) + + +def test_malformed_reference_number_explains_itself(): + """ + A reference number from another environment raises ParseException inside + the lookup. The operator should be told what is wrong, not shown a + traceback. + """ + with pytest.raises(CommandError) as exc: + call_command(COMMAND, order="not-a-reference-number") + + assert "not a valid order reference number" in str(exc.value) + + +def test_one_bad_order_does_not_abandon_the_rest(mocker, cybersource_stuck_order): + """With --all, an order that blows up must not strand the others""" + other = OrderFactory.create(status=Order.CREATED) + LineFactory.create(order=other) + payloads = { + cybersource_stuck_order.reference_number: { + "decision": "ACCEPT", + "req_reference_number": cybersource_stuck_order.reference_number, + }, + other.reference_number: { + "decision": "ACCEPT", + "req_reference_number": other.reference_number, + }, + } + mocker.patch( + "ecommerce.management.commands.resolve_pending_orders.Command._find_cybersource_payloads", + return_value=payloads, + ) + fulfill = mocker.patch( + "ecommerce.management.commands.resolve_pending_orders.fulfill_order", + side_effect=[Exception("boom"), None], + ) + + # Still exits non-zero, so a scheduled run notices. + with pytest.raises(CommandError): + call_command(COMMAND, all=True, commit=True, stderr=StringIO()) + + # Both were attempted: the failure did not stop the loop. + assert fulfill.call_count == 2 + + +def test_prefers_the_accepted_transaction(mocker, cybersource_stuck_order): + """ + A reused order can have a declined attempt and a later successful one. + Whichever the search returns last, the paid transaction must win, or a + genuinely paid order gets marked failed. + """ + refno = cybersource_stuck_order.reference_number + declined = {"reason_code": "481", "req_reference_number": refno} + accepted = {"reason_code": "100", "req_reference_number": refno} + gateway = mocker.Mock() + # Accepted first, declined last -- the order that used to lose. + gateway.find_transactions.return_value = [ + ["tx-accepted", refno, "2026-09-02T10:00:00Z"], + ["tx-declined", refno, "2026-09-02T09:00:00Z"], + ] + gateway.get_transaction_details.side_effect = [ + (mocker.Mock(), accepted), + (mocker.Mock(), declined), + ] + mocker.patch( + "ecommerce.management.commands.resolve_pending_orders.PaymentGateway.get_gateway_class", + return_value=gateway, + ) + fulfill = mocker.patch( + "ecommerce.management.commands.resolve_pending_orders.fulfill_order" + ) + + call_command(COMMAND, order=refno, commit=True) + + assert fulfill.call_args.args[0]["decision"] == "ACCEPT" diff --git a/ecommerce/migrations/0045_order_gateway_type.py b/ecommerce/migrations/0045_order_gateway_type.py new file mode 100644 index 0000000000..ba953cca12 --- /dev/null +++ b/ecommerce/migrations/0045_order_gateway_type.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.16 on 2026-08-20 10:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("ecommerce", "0044_only_sellable_products"), + ] + + operations = [ + migrations.AddField( + model_name="order", + name="gateway_type", + field=models.CharField( + choices=[("CyberSource", "CyberSource"), ("Stripe", "Stripe")], + default="CyberSource", + help_text="The payment gateway that processed this order.", + max_length=30, + ), + ), + ] diff --git a/ecommerce/migrations/0046_order_stripe_checkout_session_id.py b/ecommerce/migrations/0046_order_stripe_checkout_session_id.py new file mode 100644 index 0000000000..74eabc4f02 --- /dev/null +++ b/ecommerce/migrations/0046_order_stripe_checkout_session_id.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.16 on 2026-08-20 11:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("ecommerce", "0045_order_gateway_type"), + ] + + operations = [ + migrations.AddField( + model_name="order", + name="stripe_checkout_session_id", + field=models.CharField( + blank=True, + db_index=True, + help_text="The Stripe checkout session this order was sent to, recorded before the learner is redirected so the payment can be reconciled later.", + max_length=255, + null=True, + ), + ), + ] diff --git a/ecommerce/models.py b/ecommerce/models.py index e4a2be96f2..55ade222d5 100644 --- a/ecommerce/models.py +++ b/ecommerce/models.py @@ -26,6 +26,10 @@ validate_amount, ) from mail.constants import MAILGUN_EVENT_CHOICES +from mitol.payment_gateway.constants import ( + MITOL_PAYMENT_GATEWAY_CYBERSOURCE, + MITOL_PAYMENT_GATEWAY_STRIPE, +) from mitxpro.models import ( AuditableModel, AuditModel, @@ -362,6 +366,25 @@ class Order(OrderAbstract, AuditableModel): max_digits=6, decimal_places=4, null=True, blank=True, default=0 ) tax_rate_name = models.CharField(max_length=100, null=True, default="VAT") # noqa: DJ001 + stripe_checkout_session_id = models.CharField( + max_length=255, + blank=True, + null=True, + db_index=True, + help_text=( + "The Stripe checkout session this order was sent to, recorded before " + "the learner is redirected so the payment can be reconciled later." + ), + ) + gateway_type = models.CharField( + max_length=30, + choices=[ + (MITOL_PAYMENT_GATEWAY_CYBERSOURCE, MITOL_PAYMENT_GATEWAY_CYBERSOURCE), + (MITOL_PAYMENT_GATEWAY_STRIPE, MITOL_PAYMENT_GATEWAY_STRIPE), + ], + default=MITOL_PAYMENT_GATEWAY_CYBERSOURCE, + help_text="The payment gateway that processed this order.", + ) objects = OrderManager() diff --git a/ecommerce/serializers.py b/ecommerce/serializers.py index 21ece0b27f..2e360b5adf 100644 --- a/ecommerce/serializers.py +++ b/ecommerce/serializers.py @@ -1160,13 +1160,14 @@ def get_receipt(self, instance): data["payment_method"] = receipt.data["req_payment_method"] if "req_bill_to_email" in receipt.data: data["bill_to_email"] = receipt.data["req_bill_to_email"] - if ( - "req_bill_to_forename" in receipt.data - or "req_bill_to_surname" in receipt.data - ): - data["name"] = ( - f"{receipt.data.get('req_bill_to_forename')} {receipt.data.get('req_bill_to_surname')}" - ) + name_parts = [ + receipt.data.get("req_bill_to_forename"), + receipt.data.get("req_bill_to_surname"), + ] + if any(name_parts): + # Join only the parts we have; interpolating a missing half put + # a literal "None" on the receipt. + data["name"] = " ".join(part for part in name_parts if part) return data return None diff --git a/ecommerce/serializers_test.py b/ecommerce/serializers_test.py index deadf5bf29..8bd1509b8f 100644 --- a/ecommerce/serializers_test.py +++ b/ecommerce/serializers_test.py @@ -443,6 +443,8 @@ def test_coupon_payment_version_serializer(): "req_bill_to_forename": "XYZ", "req_bill_to_surname": "ABC", }, + # One half only: must render "Cher", not "Cher None". + {"req_bill_to_forename": "Cher"}, {}, ], ) @@ -516,10 +518,15 @@ def test_serialize_order_receipt(receipt_data): "bill_to_email": receipt.data["req_bill_to_email"] # noqa: SIM401 if "req_bill_to_email" in receipt.data else None, - "name": f"{receipt.data.get('req_bill_to_forename')} {receipt.data.get('req_bill_to_surname')}" - if "req_bill_to_forename" in receipt.data - or "req_bill_to_surname" in receipt.data - else None, + "name": " ".join( + part + for part in ( + receipt.data.get("req_bill_to_forename"), + receipt.data.get("req_bill_to_surname"), + ) + if part + ) + or None, } if receipt else None, diff --git a/ecommerce/stripe_api_test.py b/ecommerce/stripe_api_test.py new file mode 100644 index 0000000000..8003ea8f4e --- /dev/null +++ b/ecommerce/stripe_api_test.py @@ -0,0 +1,679 @@ +"""Tests for the Stripe payment gateway integration""" + +import decimal + +import pytest + +from ecommerce.api import ( + _generate_cybersource_sa_payload, + _generate_stripe_cart_items, + cancel_stripe_order, + fulfill_stripe_order, + get_gateway_type_for_user, + get_stripe_checkout_session_status, + start_stripe_checkout, + stripe_data_to_receipt_data, +) +from ecommerce.constants import ( + CYBERSOURCE_DECISION_CANCEL, + STRIPE_CHECKOUT_STATUS_CANCELLED, + STRIPE_CHECKOUT_STATUS_ERROR, + STRIPE_CHECKOUT_STATUS_PAID, + STRIPE_CHECKOUT_STATUS_PENDING, +) +from ecommerce.factories import LineFactory, OrderFactory +from ecommerce.models import Order +from mitol.payment_gateway.constants import ( + MITOL_PAYMENT_GATEWAY_CYBERSOURCE, + MITOL_PAYMENT_GATEWAY_STRIPE, +) + +pytestmark = pytest.mark.django_db + + +def _checkout_session(**kwargs): + """Build a Stripe checkout session payload for tests""" + session = { + "id": "cs_test_123", + "object": "checkout.session", + "status": "complete", + "payment_status": "paid", + "client_reference_id": None, + "amount_total": 10000, + "currency": "usd", + "customer_details": {"email": "learner@example.com", "name": "Ada Lovelace"}, + "total_details": {"amount_tax": 0}, + "metadata": {}, + "payment_intent": { + "id": "pi_test_123", + "status": "succeeded", + "latest_charge": { + "id": "ch_test_123", + "payment_method_details": {"card": {"brand": "visa", "last4": "4242"}}, + }, + }, + } + session.update(kwargs) + return session + + +@pytest.fixture +def order_with_line(): + """An order with a single line, as B2C orders always have""" + order = OrderFactory.create(status=Order.CREATED) + LineFactory.create(order=order) + return order + + +class TestGatewaySelection: + """The per-user gateway choice""" + + @pytest.mark.parametrize( + ("flag_enabled", "expected"), + [ + (True, MITOL_PAYMENT_GATEWAY_STRIPE), + (False, MITOL_PAYMENT_GATEWAY_CYBERSOURCE), + ], + ) + def test_gateway_selection(self, mocker, user, flag_enabled, expected): + """The flag is the only control: off or absent means CyberSource""" + mocker.patch("ecommerce.api.is_enabled", return_value=flag_enabled) + + assert get_gateway_type_for_user(user) == expected + + def test_flag_is_evaluated_per_user(self, mocker, user): + """The user's ID is passed to the flag so rollout can be gradual""" + patched = mocker.patch("ecommerce.api.is_enabled", return_value=False) + + get_gateway_type_for_user(user) + + assert patched.call_args.kwargs["opt_unique_id"] == str(user.id) + + +class TestCartMapping: + """Mapping an order onto the gateway's cart items""" + + def test_total_matches_cybersource(self, order_with_line): + """ + The amount sent to Stripe must equal the amount CyberSource would have + been sent for the same order. This is the invariant that stops the + migration from charging people a different price. + """ + cybersource_payload = _generate_cybersource_sa_payload( + order=order_with_line, + receipt_url="http://example.com/receipt", + cancel_url="http://example.com/cancel", + ip_address="127.0.0.1", + ) + cart_items = _generate_stripe_cart_items(order_with_line) + stripe_total = sum( + (decimal.Decimal(item.unitprice) + decimal.Decimal(item.taxable)) + * item.quantity + for item in cart_items + ).quantize(decimal.Decimal("0.01")) + + assert stripe_total == decimal.Decimal(cybersource_payload["amount"]) + + def test_tax_is_passed_separately(self, order_with_line): + """Tax we calculated is handed over as `taxable`, not folded into the price""" + order_with_line.tax_rate = decimal.Decimal("10.0000") + order_with_line.save() + + cart_items = _generate_stripe_cart_items(order_with_line) + + assert len(cart_items) == 1 + assert cart_items[0].taxable > 0 + expected_tax = ( + decimal.Decimal(cart_items[0].unitprice) * decimal.Decimal("0.10") + ).quantize(decimal.Decimal("0.01")) + assert cart_items[0].taxable == expected_tax + + def test_quantity_is_not_multiplied(self, order_with_line): + """ + Quantity stays at 1: order totals are computed from the unit price + without it, so multiplying here would overcharge. + """ + line = order_with_line.lines.first() + line.quantity = 3 + line.save() + + cart_items = _generate_stripe_cart_items(order_with_line) + + assert all(item.quantity == 1 for item in cart_items) + + +class TestCheckoutPayload: + """What the checkout API hands back to the frontend""" + + def test_payload_carries_the_reference_number(self, mocker, order_with_line): + """ + The checkout page tags its GTM purchase event with `reference_number`. + A Stripe session calls it `client_reference_id`, so we add the key the + frontend already reads -- CyberSource's payload has it too. + """ + mocker.patch( + "ecommerce.api.PaymentGateway.start_payment", + return_value={ + "payload": {"id": "cs_test_123", "client_reference_id": "x"}, + "url": "https://checkout.stripe.com/c/pay/cs_test_123", + "method": "GET", + }, + ) + + response = start_stripe_checkout( + order=order_with_line, + receipt_url="http://example.com/receipt", + cancel_url="http://example.com/cancel", + ) + + assert ( + response["payload"]["reference_number"] == order_with_line.reference_number + ) + + +class TestSupersededSessions: + """Starting checkout again on the same order""" + + def test_previous_session_is_expired(self, mocker, order_with_line): + """ + An unpaid order is reused across attempts, so a learner who goes back + and starts again gets a second session. Both would stay payable, which + is a route to being charged twice. + """ + order_with_line.stripe_checkout_session_id = "cs_test_old" + order_with_line.save() + mocker.patch( + "ecommerce.api.PaymentGateway.start_payment", + return_value={ + "payload": {"id": "cs_test_new"}, + "url": "https://checkout.stripe.com/c/pay/cs_test_new", + "method": "GET", + }, + ) + expire = mocker.patch("ecommerce.api.expire_stripe_checkout_session") + + start_stripe_checkout( + order=order_with_line, + receipt_url="http://example.com/receipt", + cancel_url="http://example.com/cancel", + ) + + expire.assert_called_once_with("cs_test_old") + order_with_line.refresh_from_db() + assert order_with_line.stripe_checkout_session_id == "cs_test_new" + + def test_swap_reads_the_stored_session_not_the_instance( + self, mocker, order_with_line + ): + """ + The previous session must come from the database under the lock, not + from the in-memory instance: a stale instance would expire an already + superseded session and leave the truly-previous one payable. + """ + # Simulate another checkout having landed after this instance was read. + Order.objects.filter(id=order_with_line.id).update( + stripe_checkout_session_id="cs_test_concurrent" + ) + # The in-memory instance still says None. + assert order_with_line.stripe_checkout_session_id is None + + mocker.patch( + "ecommerce.api.PaymentGateway.start_payment", + return_value={ + "payload": {"id": "cs_test_new"}, + "url": "https://checkout.stripe.com/c/pay/cs_test_new", + "method": "GET", + }, + ) + expire = mocker.patch("ecommerce.api.expire_stripe_checkout_session") + + start_stripe_checkout( + order=order_with_line, + receipt_url="http://example.com/receipt", + cancel_url="http://example.com/cancel", + ) + + # It expired what was actually stored, not what the instance believed. + expire.assert_called_once_with("cs_test_concurrent") + + def test_nothing_to_expire_on_a_first_attempt(self, mocker, order_with_line): + """No previous session means nothing to retire""" + mocker.patch( + "ecommerce.api.PaymentGateway.start_payment", + return_value={ + "payload": {"id": "cs_test_new"}, + "url": "https://checkout.stripe.com/c/pay/cs_test_new", + "method": "GET", + }, + ) + expire = mocker.patch("ecommerce.api.expire_stripe_checkout_session") + + start_stripe_checkout( + order=order_with_line, + receipt_url="http://example.com/receipt", + cancel_url="http://example.com/cancel", + ) + + assert expire.call_count == 0 + + +class TestSessionStatus: + """Collapsing a checkout session into a single state""" + + @pytest.mark.parametrize( + ("session_kwargs", "expected"), + [ + ({}, STRIPE_CHECKOUT_STATUS_PAID), + ( + {"payment_status": "no_payment_required"}, + STRIPE_CHECKOUT_STATUS_PAID, + ), + ( + { + "payment_status": "unpaid", + "payment_intent": {"id": "pi", "status": "processing"}, + }, + STRIPE_CHECKOUT_STATUS_PENDING, + ), + ( + {"status": "expired", "payment_status": "unpaid"}, + STRIPE_CHECKOUT_STATUS_CANCELLED, + ), + ( + { + "payment_status": "unpaid", + "payment_intent": {"id": "pi", "status": "requires_payment_method"}, + }, + STRIPE_CHECKOUT_STATUS_ERROR, + ), + ], + ) + def test_status_is_derived_from_session_and_intent( + self, mocker, session_kwargs, expected + ): + """ + `checkout.session.completed` alone doesn't mean paid: for delayed + payment methods the session completes while the PaymentIntent is still + processing, so both are considered. + """ + session = _checkout_session(**session_kwargs) + gateway = mocker.patch("ecommerce.api.PaymentGateway.get_gateway_class") + gateway.return_value.stripe_client.v1.checkout.sessions.retrieve.return_value = session + + result = get_stripe_checkout_session_status("cs_test_123") + + assert result["status"] == expected + + +class TestReceiptMapping: + """Translating Stripe data into the receipt keys the app already reads""" + + def test_writes_existing_req_keys(self): + """The receipt page and email read req_* keys, so we write those""" + session = _checkout_session( + client_reference_id="xpro-b2c-dev-1", + amount_total=12345, + total_details={"amount_tax": 345}, + ) + + receipt_data = stripe_data_to_receipt_data(session, session["payment_intent"]) + + assert receipt_data["req_reference_number"] == "xpro-b2c-dev-1" + assert receipt_data["req_amount"] == "123.45" + assert receipt_data["req_card_number"] == "xxxxxxxxxxxx4242" + # The serializer looks brands up in CYBERSOURCE_CARD_TYPES by code. + assert receipt_data["req_card_type"] == "001" + assert receipt_data["req_tax_amount"] == "3.45" + assert receipt_data["req_bill_to_email"] == "learner@example.com" + # Stripe gives one full name; the receipt reads two keys. Without + # these the receipt rendered the purchaser as "None". + assert receipt_data["req_bill_to_forename"] == "Ada" + assert receipt_data["req_bill_to_surname"] == "Lovelace" + assert receipt_data["decision"] == "ACCEPT" + + @pytest.mark.parametrize( + ("name", "forename", "surname"), + [ + ("Ada Lovelace", "Ada", "Lovelace"), + ("Mary Jane Watson", "Mary", "Jane Watson"), + ("Cher", "Cher", ""), + (" Ada Lovelace ", "Ada", "Lovelace"), + (None, "", ""), + ], + ) + def test_billing_name_is_split_for_the_receipt(self, name, forename, surname): + """Stripe's single name becomes the forename/surname pair receipts read""" + session = _checkout_session( + customer_details={"email": "learner@example.com", "name": name} + ) + + receipt_data = stripe_data_to_receipt_data(session, session["payment_intent"]) + + assert receipt_data["req_bill_to_forename"] == forename + assert receipt_data["req_bill_to_surname"] == surname + + @pytest.mark.parametrize( + ("payment_method_details", "expected_method", "expected_card"), + [ + ( + {"type": "card", "card": {"brand": "visa", "last4": "4242"}}, + "card", + "xxxxxxxxxxxx4242", + ), + # ACH is enabled for learners, so the receipt must not claim "card". + ( + {"type": "us_bank_account", "us_bank_account": {"last4": "6789"}}, + "us_bank_account", + "", + ), + ], + ) + def test_payment_method_is_taken_from_the_charge( + self, payment_method_details, expected_method, expected_card + ): + """The receipt records what the learner actually paid with""" + session = _checkout_session( + payment_intent={ + "id": "pi_test_123", + "status": "succeeded", + "latest_charge": { + "id": "ch_test_123", + "payment_method_details": payment_method_details, + }, + } + ) + + receipt_data = stripe_data_to_receipt_data(session, session["payment_intent"]) + + assert receipt_data["req_payment_method"] == expected_method + assert receipt_data["req_card_number"] == expected_card + + def test_keeps_the_raw_stripe_objects(self): + """Nothing Stripe told us is thrown away""" + session = _checkout_session() + + receipt_data = stripe_data_to_receipt_data(session, session["payment_intent"]) + + assert receipt_data["stripe_checkout_session"] == session + assert receipt_data["stripe_payment_intent"]["id"] == "pi_test_123" + + +class TestReceiptSerialization: + """The receipt page and email have to keep working unchanged""" + + def test_card_brand_survives_into_the_receipt(self, order_with_line): + """ + `OrderReceiptSerializer` looks the card type up in + CYBERSOURCE_CARD_TYPES by numeric code, so a raw Stripe brand like + "visa" would silently drop off the receipt. + """ + from ecommerce.models import Receipt + from ecommerce.serializers import OrderReceiptSerializer + + session = _checkout_session( + client_reference_id=order_with_line.reference_number + ) + receipt_data = stripe_data_to_receipt_data(session, session["payment_intent"]) + Receipt.objects.create(data=receipt_data, order=order_with_line) + + serialized = OrderReceiptSerializer(order_with_line).data + + assert serialized["receipt"]["card_type"] == "Visa" + assert serialized["receipt"]["card_number"] == "xxxxxxxxxxxx4242" + + +class TestReceiptDecision: + """The stored decision has to match what actually happened""" + + @pytest.mark.parametrize( + ("checkout_status", "expected_decision"), + [ + (STRIPE_CHECKOUT_STATUS_PAID, "ACCEPT"), + (STRIPE_CHECKOUT_STATUS_CANCELLED, "CANCEL"), + (STRIPE_CHECKOUT_STATUS_ERROR, "DECLINE"), + ], + ) + def test_decision_reflects_the_outcome(self, checkout_status, expected_decision): + """ + A receipt is stored for failures too, so recording ACCEPT on a failed + payment would leave contradictory records behind. + """ + session = _checkout_session() + + receipt_data = stripe_data_to_receipt_data( + session, session["payment_intent"], checkout_status=checkout_status + ) + + assert receipt_data["decision"] == expected_decision + + +class TestFulfillment: + """Fulfilling an order from a webhook""" + + @staticmethod + def _patch_status(mocker, order, status_value): + """Patch the session lookup to return a given state for this order""" + session = _checkout_session(client_reference_id=order.reference_number) + return mocker.patch( + "ecommerce.api.get_stripe_checkout_session_status", + return_value={ + "status": status_value, + "session": session, + "payment_intent": session["payment_intent"], + }, + ) + + def test_paid_session_fulfills(self, order_with_line, mocker): + """A paid session fulfils the order and sends the receipt""" + mocker.patch("ecommerce.api.complete_order") + mocker.patch("ecommerce.api.sync_hubspot_deal") + send_receipt = mocker.patch("ecommerce.api.send_ecommerce_order_receipt") + + self._patch_status(mocker, order_with_line, STRIPE_CHECKOUT_STATUS_PAID) + + fulfill_stripe_order("cs_test_123") + + order_with_line.refresh_from_db() + assert order_with_line.status == Order.FULFILLED + assert send_receipt.call_count == 1 + + def test_pending_session_does_not_fulfill(self, order_with_line, mocker): + """ + A delayed payment that hasn't cleared leaves the order alone -- we wait + for checkout.session.async_payment_succeeded instead of enrolling + someone whose bank transfer might still fail. + """ + complete = mocker.patch("ecommerce.api.complete_order") + mocker.patch("ecommerce.api.sync_hubspot_deal") + + self._patch_status(mocker, order_with_line, STRIPE_CHECKOUT_STATUS_PENDING) + + fulfill_stripe_order("cs_test_123") + + order_with_line.refresh_from_db() + assert order_with_line.status == Order.CREATED + assert complete.call_count == 0 + + def test_failed_session_marks_order_failed(self, order_with_line, mocker): + """A payment that didn't go through fails the order""" + mocker.patch("ecommerce.api.complete_order") + mocker.patch("ecommerce.api.sync_hubspot_deal") + + self._patch_status(mocker, order_with_line, STRIPE_CHECKOUT_STATUS_ERROR) + + fulfill_stripe_order("cs_test_123") + + order_with_line.refresh_from_db() + assert order_with_line.status == Order.FAILED + + def test_redelivered_failure_does_not_pile_up_receipts( + self, order_with_line, mocker + ): + """ + FAILED is terminal too: a failed order is never reused, so a + redelivered failure event must not write another receipt each time. + """ + from ecommerce.models import Receipt + + mocker.patch("ecommerce.api.complete_order") + mocker.patch("ecommerce.api.sync_hubspot_deal") + + with_status = self._patch_status( + mocker, order_with_line, STRIPE_CHECKOUT_STATUS_ERROR + ) + fulfill_stripe_order("cs_test_123") + fulfill_stripe_order("cs_test_123") + assert with_status.call_count == 2 + + order_with_line.refresh_from_db() + assert order_with_line.status == Order.FAILED + assert Receipt.objects.filter(order=order_with_line).count() == 1 + + def test_duplicate_delivery_is_a_no_op(self, order_with_line, mocker): + """ + Stripe delivers events at least once and retries anything that isn't a + 2xx, so a repeat delivery must not enroll the learner twice or raise. + """ + complete = mocker.patch("ecommerce.api.complete_order") + mocker.patch("ecommerce.api.sync_hubspot_deal") + send_receipt = mocker.patch("ecommerce.api.send_ecommerce_order_receipt") + + self._patch_status(mocker, order_with_line, STRIPE_CHECKOUT_STATUS_PAID) + + fulfill_stripe_order("cs_test_123") + fulfill_stripe_order("cs_test_123") + + order_with_line.refresh_from_db() + assert order_with_line.status == Order.FULFILLED + assert complete.call_count == 1 + assert send_receipt.call_count == 1 + + def test_crm_failure_does_not_block_enrollment(self, order_with_line, mocker): + """ + A HubSpot outage must not stop the learner being enrolled. The order is + committed as fulfilled before this point, so an exception here would + make Stripe retry, and the retry short-circuits on the already-fulfilled + order -- leaving someone who paid without their enrollment. + """ + complete = mocker.patch("ecommerce.api.complete_order") + mocker.patch("ecommerce.api.send_ecommerce_order_receipt") + mocker.patch( + "ecommerce.api.sync_hubspot_deal", side_effect=Exception("hubspot down") + ) + + self._patch_status(mocker, order_with_line, STRIPE_CHECKOUT_STATUS_PAID) + + fulfill_stripe_order("cs_test_123") + + order_with_line.refresh_from_db() + assert order_with_line.status == Order.FULFILLED + assert complete.call_count == 1 + + def test_unknown_reference_is_handled(self, mocker): + """A session for an order we don't have doesn't blow up the webhook""" + session = _checkout_session(client_reference_id="xpro-b2c-dev-999999") + mocker.patch( + "ecommerce.api.get_stripe_checkout_session_status", + return_value={ + "status": STRIPE_CHECKOUT_STATUS_PAID, + "session": session, + "payment_intent": None, + }, + ) + + assert fulfill_stripe_order("cs_test_123") is None + + +class TestCancellation: + """Expired and failed sessions""" + + def test_expired_session_fails_the_order(self, mocker, order_with_line): + """An expired checkout session marks the order failed""" + order_with_line.stripe_checkout_session_id = "cs_test_123" + order_with_line.save() + # Cancelling now records a receipt, so it reads the session back. + mocker.patch( + "ecommerce.api.get_stripe_checkout_session_status", + return_value={ + "status": STRIPE_CHECKOUT_STATUS_CANCELLED, + "session": {"client_reference_id": order_with_line.reference_number}, + "payment_intent": None, + }, + ) + + cancel_stripe_order("cs_test_123", reason="checkout.session.expired") + + order_with_line.refresh_from_db() + assert order_with_line.status == Order.FAILED + + def test_does_not_touch_a_fulfilled_order(self, order_with_line): + """A late failure event can't undo a fulfilled order""" + order_with_line.stripe_checkout_session_id = "cs_test_123" + order_with_line.status = Order.FULFILLED + order_with_line.save() + + cancel_stripe_order("cs_test_123") + + order_with_line.refresh_from_db() + assert order_with_line.status == Order.FULFILLED + + +def test_fulfill_leaves_a_refunded_order_alone(mocker, order_with_line): + """ + A refunded order is terminal. A late redelivery must not re-fulfil it and + re-enroll a learner whose money has been returned. + """ + stripe_order = order_with_line + stripe_order.status = Order.REFUNDED + stripe_order.gateway_type = MITOL_PAYMENT_GATEWAY_STRIPE + stripe_order.stripe_checkout_session_id = "cs_test_refunded" + stripe_order.save() + mocker.patch( + "ecommerce.api.get_stripe_checkout_session_status", + return_value={ + "status": STRIPE_CHECKOUT_STATUS_PAID, + "session": {"client_reference_id": stripe_order.reference_number}, + "payment_intent": None, + }, + ) + complete = mocker.patch("ecommerce.api.complete_order") + + result = fulfill_stripe_order(stripe_order.stripe_checkout_session_id) + + stripe_order.refresh_from_db() + assert stripe_order.status == Order.REFUNDED + assert result.status == Order.REFUNDED + complete.assert_not_called() + + +def test_cancel_records_a_receipt(mocker, order_with_line): + """ + CyberSource stores a receipt for a declined payment, so a failed Stripe + payment should leave the same audit trail. + """ + stripe_order = order_with_line + stripe_order.gateway_type = MITOL_PAYMENT_GATEWAY_STRIPE + stripe_order.stripe_checkout_session_id = "cs_test_cancelled" + stripe_order.save() + mocker.patch( + "ecommerce.api.get_stripe_checkout_session_status", + return_value={ + "status": STRIPE_CHECKOUT_STATUS_CANCELLED, + "session": { + "client_reference_id": stripe_order.reference_number, + "customer_details": {"email": "learner@example.com"}, + }, + "payment_intent": None, + }, + ) + + cancel_stripe_order( + stripe_order.stripe_checkout_session_id, reason="checkout.session.expired" + ) + + stripe_order.refresh_from_db() + assert stripe_order.status == Order.FAILED + receipt = stripe_order.receipt_set.first() + assert receipt is not None + # The decision reflects what actually happened, not a blanket ACCEPT. + assert receipt.data["decision"] == CYBERSOURCE_DECISION_CANCEL diff --git a/ecommerce/stripe_views_test.py b/ecommerce/stripe_views_test.py new file mode 100644 index 0000000000..1a52c2c7f5 --- /dev/null +++ b/ecommerce/stripe_views_test.py @@ -0,0 +1,120 @@ +"""Tests for the Stripe webhook endpoint + +These cover the public boundary itself -- signature rejection and event +routing -- which the API-level tests can't catch. +""" + +import pytest +from django.urls import reverse +from rest_framework import status + +from ecommerce.constants import ( + STRIPE_EVENT_CHECKOUT_SESSION_ASYNC_PAYMENT_FAILED, + STRIPE_EVENT_CHECKOUT_SESSION_ASYNC_PAYMENT_SUCCEEDED, + STRIPE_EVENT_CHECKOUT_SESSION_COMPLETED, + STRIPE_EVENT_CHECKOUT_SESSION_EXPIRED, +) + +pytestmark = pytest.mark.django_db + + +class FakeEventData: + def __init__(self, session): + self.object = session + + +class FakeEvent: + """Stands in for a stripe.Event, which is not a plain dict""" + + def __init__(self, event_type, session_id="cs_test_123"): + self.id = "evt_test_123" + self.type = event_type + self.data = FakeEventData({"id": session_id}) + + +def _patch_validation(mocker, event): + return mocker.patch( + "ecommerce.views.PaymentGateway.validate_processor_response", + return_value=event, + ) + + +def test_invalid_signature_is_rejected(client, mocker): + """An unsigned or wrongly-signed payload must not reach fulfillment""" + mocker.patch( + "ecommerce.views.PaymentGateway.validate_processor_response", + side_effect=Exception("bad signature"), + ) + fulfill = mocker.patch("ecommerce.views.fulfill_stripe_order") + + resp = client.post(reverse("stripe-webhook"), {}, content_type="application/json") + + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + assert fulfill.call_count == 0 + + +@pytest.mark.parametrize( + "event_type", + [ + STRIPE_EVENT_CHECKOUT_SESSION_COMPLETED, + STRIPE_EVENT_CHECKOUT_SESSION_ASYNC_PAYMENT_SUCCEEDED, + ], +) +def test_fulfillment_events_are_routed(client, mocker, event_type): + """Both the immediate and the delayed success events fulfil""" + _patch_validation(mocker, FakeEvent(event_type)) + fulfill = mocker.patch("ecommerce.views.fulfill_stripe_order") + mocker.patch("ecommerce.views.cancel_stripe_order") + + resp = client.post(reverse("stripe-webhook"), {}, content_type="application/json") + + assert resp.status_code == status.HTTP_200_OK + fulfill.assert_called_once_with("cs_test_123") + + +@pytest.mark.parametrize( + "event_type", + [ + STRIPE_EVENT_CHECKOUT_SESSION_EXPIRED, + STRIPE_EVENT_CHECKOUT_SESSION_ASYNC_PAYMENT_FAILED, + ], +) +def test_cancellation_events_are_routed(client, mocker, event_type): + """Expiry and delayed failure both fail the order""" + _patch_validation(mocker, FakeEvent(event_type)) + mocker.patch("ecommerce.views.fulfill_stripe_order") + cancel = mocker.patch("ecommerce.views.cancel_stripe_order") + + resp = client.post(reverse("stripe-webhook"), {}, content_type="application/json") + + assert resp.status_code == status.HTTP_200_OK + assert cancel.call_count == 1 + + +def test_unhandled_events_return_200(client, mocker): + """ + Anything we don't act on still gets a 200 -- returning an error would have + Stripe retry an event we were never going to process. + """ + _patch_validation(mocker, FakeEvent("payment_intent.created")) + fulfill = mocker.patch("ecommerce.views.fulfill_stripe_order") + cancel = mocker.patch("ecommerce.views.cancel_stripe_order") + + resp = client.post(reverse("stripe-webhook"), {}, content_type="application/json") + + assert resp.status_code == status.HTTP_200_OK + assert fulfill.call_count == 0 + assert cancel.call_count == 0 + + +def test_event_without_a_session_id_is_ignored(client, mocker): + """A malformed event shouldn't 500 and make Stripe retry it forever""" + event = FakeEvent(STRIPE_EVENT_CHECKOUT_SESSION_COMPLETED) + event.data.object = {} + _patch_validation(mocker, event) + fulfill = mocker.patch("ecommerce.views.fulfill_stripe_order") + + resp = client.post(reverse("stripe-webhook"), {}, content_type="application/json") + + assert resp.status_code == status.HTTP_200_OK + assert fulfill.call_count == 0 diff --git a/ecommerce/urls.py b/ecommerce/urls.py index 90ebec9c35..f3d0f303b5 100644 --- a/ecommerce/urls.py +++ b/ecommerce/urls.py @@ -3,6 +3,8 @@ from django.urls import include, path, re_path from rest_framework.routers import SimpleRouter +from mitxpro.views import index + from ecommerce.views import ( BasketView, CheckoutView, @@ -11,6 +13,8 @@ PromoCouponView, OrderFulfillmentView, OrderReceiptView, + StripeOrderStatusView, + StripeWebhookView, ProductViewSet, ProgramRunsViewSet, bulk_assignment_csv_view, @@ -40,6 +44,17 @@ OrderReceiptView.as_view(), name="order_receipt_api", ), + path( + "api/checkout/stripe-webhook/", + StripeWebhookView.as_view(), + name="stripe-webhook", + ), + path( + "api/checkout/stripe-status/", + StripeOrderStatusView.as_view(), + name="stripe-order-status", + ), + path("checkout/result/", index, name="stripe-checkout-result"), path("api/basket/", BasketView.as_view(), name="basket_api"), path("api/coupons/", CouponListView.as_view(), name="coupon_api"), path("api/promo_coupons/", PromoCouponView.as_view(), name="promo_coupons_api"), diff --git a/ecommerce/views.py b/ecommerce/views.py index cad1506446..587dadf4f8 100644 --- a/ecommerce/views.py +++ b/ecommerce/views.py @@ -2,15 +2,21 @@ import json import logging -from urllib.parse import urljoin +from urllib.parse import quote_plus, urljoin from django.conf import settings from django.core.exceptions import PermissionDenied from django.db.models import Count, Q, OuterRef, Subquery, Prefetch from django.http import Http404 from django.shortcuts import render +from django.urls import reverse from django_filters import rest_framework as filters from ipware import get_client_ip +from mitol.payment_gateway.api import PaymentGateway +from mitol.payment_gateway.constants import ( + MITOL_PAYMENT_GATEWAY_CYBERSOURCE, + MITOL_PAYMENT_GATEWAY_STRIPE, +) from rest_framework import status from rest_framework.authentication import SessionAuthentication, TokenAuthentication from rest_framework.generics import ( @@ -28,16 +34,23 @@ from b2b_ecommerce.models import B2BOrder from courses.models import Course, CourseRun, Program, ProgramRun from ecommerce.api import ( + cancel_stripe_order, complete_order, create_or_update_unfulfilled_order, fulfill_order, + fulfill_stripe_order, generate_cybersource_sa_payload, + get_gateway_type_for_user, make_receipt_url, + start_stripe_checkout, + stripe_object_to_dict, validate_basket_for_checkout, ) from ecommerce.constants import ( COUPON_ADD_PERMISSION, COUPON_UPDATE_PERMISSION, + STRIPE_CANCEL_EVENTS, + STRIPE_FULFILL_EVENTS, ) from sheets.constants import ( COUPON_PRODUCT_ASSIGNMENT_ADD_PERMISSION, @@ -216,6 +229,8 @@ def post( text_id = validated_basket.product_version.product.content_object.text_id receipt_url = make_receipt_url(base_url=base_url, readable_id=text_id) user_ip, _ = get_client_ip(request) + # Where either gateway sends the learner if they abandon payment. + cancel_url = urljoin(base_url, "checkout/") if order.total_price_paid == 0: # If price is $0, don't bother going to CyberSource, just mark as fulfilled @@ -243,9 +258,38 @@ def post( url = receipt_url send_ecommerce_order_receipt(order) method = "GET" + elif get_gateway_type_for_user(request.user) == MITOL_PAYMENT_GATEWAY_STRIPE: + # Stripe hosts the payment page, so all we get back is a URL to send + # the learner to. Nothing is posted back to us afterwards -- the + # order is fulfilled when the webhook arrives -- so the success URL + # carries the checkout session ID for the interstitial to poll on. + success_url = urljoin(base_url, reverse("stripe-checkout-result")) + success_url = ( + f"{success_url}?session_id={{CHECKOUT_SESSION_ID}}" + f"&purchased={quote_plus(text_id)}" + ) + + order.gateway_type = MITOL_PAYMENT_GATEWAY_STRIPE + order.save() + + stripe_response = start_stripe_checkout( + order=order, + receipt_url=success_url, + cancel_url=cancel_url, + ip_address=user_ip, + ) + payload = stripe_response["payload"] + url = stripe_response["url"] + method = stripe_response["method"] else: + # Record the gateway on this path too. An unpaid order is reused + # across attempts, so one that started on Stripe and is finished on + # CyberSource -- after the flag is turned off, say -- would + # otherwise keep the wrong label. + order.gateway_type = MITOL_PAYMENT_GATEWAY_CYBERSOURCE + order.save() + # This generates a signed payload which is submitted as an HTML form to CyberSource - cancel_url = urljoin(base_url, "checkout/") payload = generate_cybersource_sa_payload( order=order, receipt_url=receipt_url, @@ -291,6 +335,81 @@ def post(self, request, *args, **kwargs): # noqa: ARG002 return Response(status=status.HTTP_200_OK) +class StripeWebhookView(APIView): + """ + Receives Stripe checkout events. + + Only Stripe should reach this. Instead of authenticating, the request's + signature is checked against the webhook secrets held by the payment + gateway. Stripe delivers events at least once and retries anything that + isn't a 2xx, so every handler below has to be safe to run twice. + """ + + authentication_classes = () + permission_classes = () + + def post(self, request, *args, **kwargs): # noqa: ARG002 + """Handle an incoming Stripe event.""" + try: + event = PaymentGateway.validate_processor_response( + MITOL_PAYMENT_GATEWAY_STRIPE, request + ) + except Exception: + log.exception("StripeWebhookView: could not validate the Stripe payload") + return Response( + "Unable to validate request.", status=status.HTTP_401_UNAUTHORIZED + ) + + # event.data.object is a StripeObject, which doesn't support .get() + checkout_session = stripe_object_to_dict(event.data.object) or {} + checkout_session_id = checkout_session.get("id") + + if not checkout_session_id: + log.error( + "StripeWebhookView: event %s carried no checkout session ID", event.id + ) + return Response(status=status.HTTP_200_OK) + + if event.type in STRIPE_FULFILL_EVENTS: + fulfill_stripe_order(checkout_session_id) + elif event.type in STRIPE_CANCEL_EVENTS: + cancel_stripe_order(checkout_session_id, reason=event.type) + else: + log.info("StripeWebhookView: ignoring unhandled event type %s", event.type) + + return Response(status=status.HTTP_200_OK) + + +class StripeOrderStatusView(APIView): + """ + Reports whether the order behind a checkout session has been fulfilled yet. + + The interstitial polls this while it waits for the webhook to land. + """ + + authentication_classes = (SessionAuthentication, TokenAuthentication) + permission_classes = (IsAuthenticated,) + + def get(self, request, *args, **kwargs): # noqa: ARG002 + """Return the order status for a checkout session.""" + checkout_session_id = request.query_params.get("session_id") + if not checkout_session_id: + return Response( + {"error": "session_id is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + order = Order.objects.filter( + stripe_checkout_session_id=checkout_session_id, + purchaser=request.user, + ).first() + + if order is None: + raise Http404 + + return Response({"status": order.status, "reference": order.reference_number}) + + class OrderReceiptView(RetrieveAPIView): """ View for fetching receipt against an order. diff --git a/mitxpro/features.py b/mitxpro/features.py index 06a9763ce4..8da029363f 100644 --- a/mitxpro/features.py +++ b/mitxpro/features.py @@ -3,3 +3,5 @@ DIGITAL_CREDENTIALS = "xpro-digital-credentials" ENABLE_ENTERPRISE = "xpro-enterprise" ENROLLMENT_WELCOME_EMAIL = "xpro-enrollment-welcome-email" +ENABLE_STRIPE_PAYMENTS = "xpro-stripe-payments" +ENABLE_B2B_PURCHASING = "xpro-b2b-purchasing" diff --git a/mitxpro/settings.py b/mitxpro/settings.py index 40b633702a..ac81ee037f 100644 --- a/mitxpro/settings.py +++ b/mitxpro/settings.py @@ -36,6 +36,7 @@ "mitol.common.settings.webpack", "mitol.digitalcredentials.settings", "mitol.olposthog.settings.olposthog", + "mitol.payment_gateway.settings", ) ENVIRONMENT = get_string( @@ -206,6 +207,7 @@ "mitol.oauth_toolkit_extensions.apps.OAuthToolkitExtensionsApp", "mitol.authentication.apps.TransitionalAuthenticationApp", "mitol.olposthog.apps.OlPosthog", + "mitol.payment_gateway.apps.PaymentGatewayApp", "health_check", ) # Only include the seed data app if this isn't running in prod diff --git a/mitxpro/utils.py b/mitxpro/utils.py index 53bddbe0a8..566fc4fbaf 100644 --- a/mitxpro/utils.py +++ b/mitxpro/utils.py @@ -599,6 +599,9 @@ def get_js_settings(request: HttpRequest): "digital_credentials_supported_runs": settings.DIGITAL_CREDENTIALS_SUPPORTED_RUNS, "is_tax_applicable": is_tax_applicable(request), "enable_enterprise": is_enabled(features.ENABLE_ENTERPRISE, default=False), + "enable_b2b_purchasing": is_enabled( + features.ENABLE_B2B_PURCHASING, default=True + ), "posthog_api_token": settings.POSTHOG_PROJECT_API_KEY, "posthog_api_host": settings.POSTHOG_API_HOST, } diff --git a/mitxpro/utils_test.py b/mitxpro/utils_test.py index de99559006..b8ce3a6a48 100644 --- a/mitxpro/utils_test.py +++ b/mitxpro/utils_test.py @@ -164,6 +164,8 @@ def test_get_field_names(): "tax_rate", "tax_rate_name", "tax_country_code", + "gateway_type", + "stripe_checkout_session_id", } @@ -500,6 +502,7 @@ def posthog_is_enabled_side_effect(*args, **kwargs): "digital_credentials_supported_runs": settings.DIGITAL_CREDENTIALS_SUPPORTED_RUNS, "is_tax_applicable": is_tax_applicable(request), "enable_enterprise": False, + "enable_b2b_purchasing": False, "posthog_api_token": settings.POSTHOG_PROJECT_API_KEY, "posthog_api_host": settings.POSTHOG_API_HOST, } diff --git a/pyproject.toml b/pyproject.toml index ee6c87a1ee..568d7d4d8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ dependencies = [ "opentelemetry-instrumentation-redis>=0.52b0", "opentelemetry-instrumentation-requests>=0.52b0", "mitol-django-olposthog>=2026.3.6,<2027", + "mitol-django-payment-gateway==2026.8.10", "newrelic>=10.0.0,<11", "pdftotext>=3.0.0,<4", "psycopg2==2.9.12", diff --git a/static/js/containers/App.js b/static/js/containers/App.js index 57f805c883..3cbc3f7d40 100644 --- a/static/js/containers/App.js +++ b/static/js/containers/App.js @@ -17,6 +17,7 @@ import Header from "../components/Header"; import PrivateRoute from "../components/PrivateRoute"; import CheckoutPage from "./pages/CheckoutPage"; +import CheckoutResultPage from "./pages/CheckoutResultPage"; import DashboardPage from "./pages/DashboardPage"; import ReceiptPage from "./pages/ReceiptPage"; import LoginPages from "./pages/login/LoginPages"; @@ -102,6 +103,10 @@ export class App extends React.Component { path={urljoin(match.url, String(routes.register))} component={RegisterPages} /> + { assert.isTrue(window.location.toString().endsWith(url)); }); + describe("GTM purchase tracking", () => { + const url = "/a/b/c/"; + + afterEach(() => { + delete global.dataLayer; + SETTINGS.gtmTrackingID = null; + }); + + const submitCheckout = async (payload) => { + const { inner } = await renderPage(); + helper.handleRequestStub.withArgs("/api/checkout/", "POST").returns({ + body: { url, payload, method: "GET" }, + status: 200, + }); + const actions = { + setSubmitting: helper.sandbox.stub(), + setErrors: helper.sandbox.stub(), + }; + await inner.find("CheckoutForm").prop("onSubmit")({ runs: {} }, actions); + }; + + it("tags the purchase event with the order reference number", async () => { + // Stripe's payload calls this `client_reference_id`; the server adds + // `reference_number` so the event stays attributable. Without it the + // purchase would be reported to analytics unidentified. + SETTINGS.gtmTrackingID = "GTM-FAKE"; + const pushStub = helper.sandbox.stub(); + global.dataLayer = { push: pushStub }; + + await submitCheckout({ reference_number: "xpro-b2c-dev-42" }); + + sinon.assert.calledOnce(pushStub); + const event = pushStub.firstCall.args[0]; + assert.equal(event.event, "purchase"); + assert.equal(event.referenceNumber, "xpro-b2c-dev-42"); + }); + + it("redirects through the GTM callback when tracking is on", async () => { + // The redirect to the payment page lives inside eventCallback, so GTM + // sits between the learner and checkout. Every Stripe purchase takes + // this path, so it must not be broken silently. + SETTINGS.gtmTrackingID = "GTM-FAKE"; + const pushStub = helper.sandbox.stub(); + global.dataLayer = { push: pushStub }; + + await submitCheckout({ reference_number: "xpro-b2c-dev-42" }); + + const event = pushStub.firstCall.args[0]; + assert.isFunction(event.eventCallback); + assert.equal(event.eventTimeout, 2000); + }); + + it("redirects directly when tracking is off", async () => { + SETTINGS.gtmTrackingID = null; + const pushStub = helper.sandbox.stub(); + global.dataLayer = { push: pushStub }; + + await submitCheckout({ reference_number: "xpro-b2c-dev-42" }); + + sinon.assert.notCalled(pushStub); + assert.isTrue(window.location.toString().endsWith(url)); + }); + }); + it("fails to check out because basket API failed to validate", async () => { const { inner } = await renderPage(); const errors = ["something went wrong"]; diff --git a/static/js/containers/pages/CheckoutResultPage.js b/static/js/containers/pages/CheckoutResultPage.js new file mode 100644 index 0000000000..acd10a8423 --- /dev/null +++ b/static/js/containers/pages/CheckoutResultPage.js @@ -0,0 +1,165 @@ +// @flow +import React from "react"; +import { connect } from "react-redux"; +import { compose } from "redux"; +import { connectRequest, requestAsync } from "redux-query"; +import qs from "query-string"; + +import queries from "../../lib/queries"; +import { routes } from "../../lib/urls"; +import { wait } from "../../lib/util"; + +import type { Location } from "react-router"; + +type Props = { + orderStatus: ?Object, + location: Location, + fetchOrderStatus: (sessionId: string) => Promise<*>, +}; +type State = { + timedOut: boolean, +}; + +// Stripe sends nothing to this page; the order is fulfilled by a webhook that +// usually lands a moment after the learner gets back here. Poll until it does, +// then move them on. Give up after a while rather than spinning forever -- the +// order is still recoverable server-side, and a stuck spinner helps nobody. +// +// The interval is a load trade-off: every checkout in progress polls this, so +// the rate is kept to 20/min per learner while still covering a 90 second wait. +const NUM_MILLIS_PER_POLL = 3000; +const MAX_ATTEMPTS = 30; + +export class CheckoutResultPage extends React.Component { + state = { + timedOut: false, + }; + + componentDidMount() { + this.poll(); + } + + componentWillUnmount() { + this.unmounted = true; + } + + unmounted = false; + + sessionId = () => + String(qs.parse(this.props.location.search).session_id || ""); + + receiptUrl = () => { + const purchased = qs.parse(this.props.location.search).purchased; + return purchased + ? `${routes.dashboard}?status=purchased&purchased=${encodeURIComponent( + String(purchased), + )}` + : routes.dashboard; + }; + + // Driven by a timer rather than by successful entity updates: if the first + // status request fails there is no update to react to, and a learner who has + // paid would sit here forever. Request errors are swallowed so a transient + // failure doesn't break the chain. + poll = async () => { + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + if (this.unmounted) { + return; + } + + const { orderStatus } = this.props; + + if (orderStatus && orderStatus.status === "fulfilled") { + window.location = this.receiptUrl(); + return; + } + + if (orderStatus && orderStatus.status === "failed") { + return; + } + + await wait(NUM_MILLIS_PER_POLL); + + try { + await this.props.fetchOrderStatus(this.sessionId()); + } catch (e) { + // Keep polling: a transient error shouldn't strand a paid learner. + } + } + + if (!this.unmounted) { + // Stop polling, but say so. Delayed payment methods legitimately stay + // unconfirmed for far longer than this, and leaving "completing your + // purchase" on screen forever implies something is still happening. + this.setState({ timedOut: true }); + } + }; + + render() { + const { orderStatus } = this.props; + const { timedOut } = this.state; + const failed = orderStatus && orderStatus.status === "failed"; + + return ( +
+ {failed ? ( + +

We couldn’t complete your payment

+

+ You have not been charged. Please try again, or contact support if + the problem continues. +

+ Return to your dashboard +
+ ) : timedOut ? ( + +

Your payment is still being confirmed

+

+ This is normal for some payment methods, such as bank transfers, + which can take a few days to clear. We’ll email you as soon + as it’s confirmed and your enrollment is ready — you + don’t need to pay again or stay on this page. +

+ Go to your dashboard +
+ ) : ( + +

Completing your purchase…

+

+ We’re confirming your payment and finalizing your + enrollment. This usually takes a few seconds, though some payment + methods — bank transfers in particular — can take + longer. We’ll email you once it’s confirmed. +

+ Continue to your dashboard +
+ )} +
+ ); + } +} + +const mapStateToProps = (state) => ({ + orderStatus: state.entities.stripe_order_status, +}); + +// connectRequest's own forceRequest() dispatches without returning the promise, +// so awaiting it resolves immediately: polls overlap, and because the reducer +// is last-write-wins a slow response landing after a newer one overwrites it. +// Dispatching requestAsync ourselves yields a real promise, so each poll waits +// for its own round trip -- and the try/catch around it can actually fire. +const mapDispatchToProps = (dispatch) => ({ + fetchOrderStatus: (sessionId: string) => + dispatch(requestAsync(queries.ecommerce.stripeOrderStatus(sessionId))), +}); + +const mapPropsToConfig = (props) => [ + queries.ecommerce.stripeOrderStatus( + String(qs.parse(props.location.search).session_id || ""), + ), +]; + +export default compose( + connect(mapStateToProps, mapDispatchToProps), + connectRequest(mapPropsToConfig), +)(CheckoutResultPage); diff --git a/static/js/containers/pages/CheckoutResultPage_test.js b/static/js/containers/pages/CheckoutResultPage_test.js new file mode 100644 index 0000000000..1b27cd4dc2 --- /dev/null +++ b/static/js/containers/pages/CheckoutResultPage_test.js @@ -0,0 +1,68 @@ +// @flow +import { assert } from "chai"; + +import CheckoutResultPage, { + CheckoutResultPage as InnerCheckoutResultPage, +} from "./CheckoutResultPage"; +import IntegrationTestHelper from "../../util/integration_test_helper"; + +describe("CheckoutResultPage", () => { + let helper, renderPage; + + beforeEach(() => { + helper = new IntegrationTestHelper(); + renderPage = helper.configureHOCRenderer( + CheckoutResultPage, + InnerCheckoutResultPage, + { + entities: { stripe_order_status: null }, + }, + { + location: { + search: + "?session_id=cs_test_123&purchased=course-v1%3ATestX%2BB1%2BR1", + }, + }, + ); + }); + + afterEach(() => { + helper.cleanup(); + }); + + it("tells the learner we're finishing up while the webhook lands", async () => { + const { inner } = await renderPage({ + entities: { stripe_order_status: { status: "created" } }, + }); + + assert.include(inner.text(), "Completing your purchase"); + }); + + it("shows an error if the payment failed", async () => { + const { inner } = await renderPage({ + entities: { stripe_order_status: { status: "failed" } }, + }); + + assert.include(inner.text(), "couldn’t complete your payment"); + }); + + it("says so when polling gives up rather than spinning forever", async () => { + // Delayed payment methods legitimately stay unconfirmed for longer than + // we poll, so the page has to explain itself instead of implying progress. + const { inner } = await renderPage({ + entities: { stripe_order_status: { status: "created" } }, + }); + inner.setState({ timedOut: true }); + + assert.include(inner.text(), "still being confirmed"); + assert.notInclude(inner.text(), "Completing your purchase"); + }); + + it("sends the learner to their dashboard once the order is fulfilled", async () => { + await renderPage({ + entities: { stripe_order_status: { status: "fulfilled" } }, + }); + + assert.include(window.location.toString(), "status=purchased"); + }); +}); diff --git a/static/js/containers/pages/b2b/B2BPurchasePage.js b/static/js/containers/pages/b2b/B2BPurchasePage.js index 8c0a7c62b1..a6cec680c5 100644 --- a/static/js/containers/pages/b2b/B2BPurchasePage.js +++ b/static/js/containers/pages/b2b/B2BPurchasePage.js @@ -1,4 +1,5 @@ // @flow +/* global SETTINGS: false */ import React from "react"; import { connect } from "react-redux"; import { compose } from "redux"; @@ -114,6 +115,26 @@ export class B2BPurchasePage extends React.Component { productId = productId.replace(/\ /g, "+"); } + // Explicit false only: the flag defaults to on, so a missing value must + // mean "available" rather than silently hiding the page. + if (SETTINGS.enable_b2b_purchasing === false) { + // Kill switch for new bulk purchases, flippable without a deploy. + // Anyone who already bought codes can still reach them through their + // receipt link. + return ( +
+

Bulk purchasing is temporarily unavailable

+

+ We’re unable to take new bulk enrollment code orders at the + moment. If you already purchased codes, your receipt link still + works. Please contact{" "} + customer support{" "} + for more information. +

+
+ ); + } + return ( {isLoading ? ( diff --git a/static/js/flow/declarations.js b/static/js/flow/declarations.js index 11e77bb54c..e236844554 100644 --- a/static/js/flow/declarations.js +++ b/static/js/flow/declarations.js @@ -16,6 +16,7 @@ declare type Settings = { digital_credentials_supported_runs: Array, is_tax_applicable: boolean, enable_enterprise: boolean, + enable_b2b_purchasing: boolean, posthog_api_token: ?string, posthog_api_host: ?string }; diff --git a/static/js/lib/queries/ecommerce.js b/static/js/lib/queries/ecommerce.js index 80b2c17e00..1b242811ed 100755 --- a/static/js/lib/queries/ecommerce.js +++ b/static/js/lib/queries/ecommerce.js @@ -162,6 +162,17 @@ export default { ...DEFAULT_POST_OPTIONS, }, }), + stripeOrderStatus: (sessionId: string) => ({ + queryKey: "stripeOrderStatus", + url: `/api/checkout/stripe-status/?session_id=${encodeURIComponent(sessionId)}`, + transform: (json: Object) => ({ + stripe_order_status: json, + }), + update: { + stripe_order_status: (prev: Object, next: Object) => next, + }, + force: true, + }), b2bOrderStatus: (orderHash: string) => ({ queryKey: "b2bOrderStatus", url: `/api/b2b/orders/${orderHash}/status/`, diff --git a/static/js/lib/urls.js b/static/js/lib/urls.js index 27c70e2022..899ebf95d0 100644 --- a/static/js/lib/urls.js +++ b/static/js/lib/urls.js @@ -44,6 +44,8 @@ export const routes = { checkout: "/checkout/", + checkoutResult: "/checkout/result/", + ecommerceAdmin: include("/ecommerce/admin/", { index: "", coupons: "coupons/", diff --git a/uv.lock b/uv.lock index c3a81f2160..44ffe8cb51 100644 --- a/uv.lock +++ b/uv.lock @@ -7,18 +7,18 @@ resolution-markers = [ ] [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-09-01T10:06:12.61495Z" exclude-newer-span = "P7D" [options.exclude-newer-package] -mitol-django-authentication = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -mitol-django-common = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -mitol-django-hubspot-api = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -mitol-django-mail = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -mitol-django-oauth-toolkit-extensions = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -mitol-django-observability = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -mitol-django-olposthog = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } -mitol-drf-lint = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } +mitol-django-hubspot-api = { timestamp = "2026-09-08T10:06:12.615062Z", span = "PT0S" } +mitol-drf-lint = { timestamp = "2026-09-08T10:06:12.615064Z", span = "PT0S" } +mitol-django-observability = { timestamp = "2026-09-08T10:06:12.615064Z", span = "PT0S" } +mitol-django-authentication = { timestamp = "2026-09-08T10:06:12.614999Z", span = "PT0S" } +mitol-django-olposthog = { timestamp = "2026-09-08T10:06:12.615064Z", span = "PT0S" } +mitol-django-common = { timestamp = "2026-09-08T10:06:12.615062Z", span = "PT0S" } +mitol-django-mail = { timestamp = "2026-09-08T10:06:12.615063Z", span = "PT0S" } +mitol-django-oauth-toolkit-extensions = { timestamp = "2026-09-08T10:06:12.615063Z", span = "PT0S" } [[package]] name = "amqp" @@ -1580,7 +1580,7 @@ name = "jinxed" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ansicon" }, + { name = "ansicon", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/20/d0/59b2b80e7a52d255f9e0ad040d2e826342d05580c4b1d7d7747cfb8db731/jinxed-1.3.0.tar.gz", hash = "sha256:1593124b18a41b7a3da3b078471442e51dbad3d77b4d4f2b0c26ab6f7d660dbf", size = 80981, upload-time = "2024-07-31T22:39:18.854Z" } wheels = [ @@ -1989,6 +1989,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/28/bb08ac8a36b5e4258866f04a9781461d3cc62d2ebda477b83c5b565ced21/mitol_django_olposthog-2026.3.6-py3-none-any.whl", hash = "sha256:9aaf4bcd396eb803a2e4130ad5892bed03ba50f56b211657a08da242cb15ba21", size = 9065, upload-time = "2026-03-06T15:17:12.759Z" }, ] +[[package]] +name = "mitol-django-payment-gateway" +version = "2026.8.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cybersource-rest-client-python" }, + { name = "django" }, + { name = "django-stubs" }, + { name = "mitol-django-common" }, + { name = "stripe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/4a/6d33900c78c4112dad6fcedc4a04c04d9b29c1bdbf97ca9add2700394e05/mitol_django_payment_gateway-2026.8.10.tar.gz", hash = "sha256:a65da7a7c55fb05cb1f5b6731e198ed46d3b90cfec94ee1909927d046d349486", size = 21478, upload-time = "2026-08-10T20:40:24.352Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/55/2179d87a7f5539756e140d0ddfcfbfb4c967658e238f711611594a6c07a6/mitol_django_payment_gateway-2026.8.10-py3-none-any.whl", hash = "sha256:626a47e43a6fc0ae8ddad163e6c811f2237cdb314ddbfc16ea89bbd1aa7f0b30", size = 28840, upload-time = "2026-08-10T20:40:23.361Z" }, +] + [[package]] name = "mitol-drf-lint" version = "2026.4.2" @@ -2040,6 +2056,7 @@ dependencies = [ { name = "mitol-django-oauth-toolkit-extensions" }, { name = "mitol-django-observability" }, { name = "mitol-django-olposthog" }, + { name = "mitol-django-payment-gateway" }, { name = "newrelic" }, { name = "opentelemetry-instrumentation-celery" }, { name = "opentelemetry-instrumentation-django" }, @@ -2127,6 +2144,7 @@ requires-dist = [ { name = "mitol-django-oauth-toolkit-extensions", specifier = "==2025.3.17" }, { name = "mitol-django-observability", specifier = ">=2026.8.19" }, { name = "mitol-django-olposthog", specifier = ">=2026.3.6,<2027" }, + { name = "mitol-django-payment-gateway", specifier = "==2026.8.10" }, { name = "newrelic", specifier = ">=10.0.0,<11" }, { name = "opentelemetry-instrumentation-celery", specifier = ">=0.52b0" }, { name = "opentelemetry-instrumentation-django", specifier = ">=0.52b0" }, @@ -3479,6 +3497,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/cb/e1da7e340586a078404c7e4328bfefc930867ace8a9a55916fd220cf9547/standard_imghdr-3.13.0-py3-none-any.whl", hash = "sha256:30a1bff5465605bb496f842a6ac3cc1f2131bf3025b0da28d4877d6d4b7cc8e9", size = 4639, upload-time = "2024-10-30T16:01:13.829Z" }, ] +[[package]] +name = "stripe" +version = "15.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/e7/1d33faefbd3752b17207ff57933ac677ed5538f83ff2ab38195adf18027a/stripe-15.6.0.tar.gz", hash = "sha256:0e6bb67863bc3d1d805394a6a12dd1aaedf638511f175ec4c3612e4dc8f952d0", size = 1562255, upload-time = "2026-08-27T04:10:43.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/28/aabe12c5440748a65c91e751a7ed038a41655343cd7e6d55b0640f76b2a1/stripe-15.6.0-py3-none-any.whl", hash = "sha256:6d668d25f863fc4ac73811f387a2dab27d3b8c344261c76c05cc61ba6b9edc09", size = 2217116, upload-time = "2026-08-27T04:10:41.248Z" }, +] + [[package]] name = "structlog" version = "25.5.0" From 254071912cf98842e7ad62ce8847b87db87515b6 Mon Sep 17 00:00:00 2001 From: Arslan Ashraf <34372316+arslanashraf7@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:39:08 +0500 Subject: [PATCH 2/6] chore: refresh drf-lint baseline for new ORM003-ORM006 rules (#4092) 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 --- .gitignore | 3 ++ drf_lint_baseline.json | 112 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 99d5f96eb4..2a3f7f9f4b 100644 --- a/.gitignore +++ b/.gitignore @@ -121,3 +121,6 @@ localdev/google.token # Claude .claude/ + +# drf-lint cross-file index cache +.drf_lint_cache.json diff --git a/drf_lint_baseline.json b/drf_lint_baseline.json index 1f7496288d..0dfab810d6 100644 --- a/drf_lint_baseline.json +++ b/drf_lint_baseline.json @@ -1,36 +1,93 @@ [ "authentication/serializers.py:100:34:ORM001", + "courses/serializers.py:241:14:ORM003", + "courses/serializers.py:249:26:ORM003", + "courses/serializers.py:253:16:ORM004", + "courses/serializers.py:255:21:ORM003", "courses/serializers.py:260:29:ORM002", "courses/serializers.py:267:51:ORM002", + "courses/serializers.py:274:23:ORM006", "courses/serializers.py:346:38:ORM002", + "courses/serializers.py:360:30:ORM003", + "courses/serializers.py:371:63:ORM003", + "courses/serializers.py:381:30:ORM003", "courses/serializers.py:392:26:ORM002", "courses/serializers.py:394:25:ORM002", + "courses/serializers.py:400:23:ORM006", + "courses/serializers.py:408:12:ORM005", + "courses/serializers.py:452:16:ORM006", + "courses/serializers.py:453:19:ORM006", "courses/serializers.py:462:16:ORM001", + "courses/serializers.py:475:15:ORM006", + "courses/serializers.py:476:16:ORM006", + "courses/serializers.py:476:43:ORM006", + "courses/serializers.py:500:15:ORM006", + "courses/serializers.py:500:46:ORM006", "courses/serializers.py:508:16:ORM001", + "courses/serializers.py:519:11:ORM006", + "courses/serializers.py:522:19:ORM006", + "courses/serializers.py:523:20:ORM006", + "courses/serializers.py:523:47:ORM006", + "courses/serializers.py:542:59:ORM006", + "courses/serializers.py:568:26:ORM005", "courses/serializers.py:83:19:ORM002", + "courses/serializers.py:88:15:ORM003", + "courses/serializers.py:95:12:ORM005", "ecommerce/serializers.py:1000:25:ORM002", + "ecommerce/serializers.py:1015:15:ORM006", "ecommerce/serializers.py:1019:15:ORM002", "ecommerce/serializers.py:1023:15:ORM002", "ecommerce/serializers.py:1079:8:ORM001", + "ecommerce/serializers.py:107:22:ORM006", "ecommerce/serializers.py:1085:8:ORM002", "ecommerce/serializers.py:1086:8:ORM001", + "ecommerce/serializers.py:1108:33:ORM006", + "ecommerce/serializers.py:1112:15:ORM006", + "ecommerce/serializers.py:112:20:ORM006", "ecommerce/serializers.py:1140:18:ORM002", "ecommerce/serializers.py:1176:28:ORM002", "ecommerce/serializers.py:1178:20:ORM002", "ecommerce/serializers.py:117:22:ORM001", + "ecommerce/serializers.py:1182:36:ORM004", + "ecommerce/serializers.py:118:24:ORM006", "ecommerce/serializers.py:1194:20:ORM001", "ecommerce/serializers.py:1204:26:ORM001", "ecommerce/serializers.py:1206:25:ORM001", "ecommerce/serializers.py:1251:28:ORM002", + "ecommerce/serializers.py:1258:46:ORM006", + "ecommerce/serializers.py:131:25:ORM006", + "ecommerce/serializers.py:142:25:ORM006", + "ecommerce/serializers.py:151:25:ORM006", + "ecommerce/serializers.py:192:22:ORM006", + "ecommerce/serializers.py:192:51:ORM006", + "ecommerce/serializers.py:259:15:ORM006", + "ecommerce/serializers.py:264:19:ORM004", + "ecommerce/serializers.py:264:41:ORM006", + "ecommerce/serializers.py:268:15:ORM004", + "ecommerce/serializers.py:268:37:ORM006", + "ecommerce/serializers.py:272:11:ORM006", + "ecommerce/serializers.py:272:39:ORM006", "ecommerce/serializers.py:273:35:ORM002", "ecommerce/serializers.py:278:16:ORM001", + "ecommerce/serializers.py:280:40:ORM006", "ecommerce/serializers.py:282:32:ORM002", + "ecommerce/serializers.py:289:12:ORM004", "ecommerce/serializers.py:290:27:ORM001", + "ecommerce/serializers.py:305:15:ORM006", + "ecommerce/serializers.py:337:21:ORM004", "ecommerce/serializers.py:345:12:ORM001", + "ecommerce/serializers.py:354:12:ORM004", "ecommerce/serializers.py:357:24:ORM002", "ecommerce/serializers.py:363:12:ORM002", + "ecommerce/serializers.py:368:24:ORM004", + "ecommerce/serializers.py:377:31:ORM004", "ecommerce/serializers.py:380:24:ORM001", + "ecommerce/serializers.py:409:29:ORM004", + "ecommerce/serializers.py:420:29:ORM004", "ecommerce/serializers.py:425:31:ORM002", + "ecommerce/serializers.py:427:33:ORM004", + "ecommerce/serializers.py:430:36:ORM004", + "ecommerce/serializers.py:434:33:ORM004", "ecommerce/serializers.py:472:21:ORM001", "ecommerce/serializers.py:474:16:ORM002", "ecommerce/serializers.py:476:16:ORM002", @@ -44,11 +101,64 @@ "ecommerce/serializers.py:530:12:ORM001", "ecommerce/serializers.py:535:16:ORM002", "ecommerce/serializers.py:555:16:ORM001", + "ecommerce/serializers.py:574:56:ORM004", + "ecommerce/serializers.py:59:15:ORM006", + "ecommerce/serializers.py:632:8:ORM004", "ecommerce/serializers.py:634:12:ORM002", + "ecommerce/serializers.py:63:15:ORM006", + "ecommerce/serializers.py:710:31:ORM004", + "ecommerce/serializers.py:714:20:ORM004", + "ecommerce/serializers.py:715:12:ORM004", "ecommerce/serializers.py:763:15:ORM001", + "ecommerce/serializers.py:764:21:ORM006", "ecommerce/serializers.py:772:12:ORM002", + "ecommerce/serializers.py:83:15:ORM006", + "hubspot_xpro/serializers.py:110:15:ORM004", + "hubspot_xpro/serializers.py:110:41:ORM006", + "hubspot_xpro/serializers.py:114:11:ORM006", + "hubspot_xpro/serializers.py:115:19:ORM006", + "hubspot_xpro/serializers.py:120:15:ORM006", + "hubspot_xpro/serializers.py:124:11:ORM006", + "hubspot_xpro/serializers.py:125:39:ORM006", + "hubspot_xpro/serializers.py:189:11:ORM006", + "hubspot_xpro/serializers.py:190:33:ORM006", + "hubspot_xpro/serializers.py:194:11:ORM006", + "hubspot_xpro/serializers.py:199:11:ORM006", + "hubspot_xpro/serializers.py:200:22:ORM006", + "hubspot_xpro/serializers.py:206:11:ORM006", + "hubspot_xpro/serializers.py:207:19:ORM006", + "hubspot_xpro/serializers.py:211:11:ORM006", + "hubspot_xpro/serializers.py:212:27:ORM006", + "hubspot_xpro/serializers.py:218:11:ORM006", + "hubspot_xpro/serializers.py:219:34:ORM006", "hubspot_xpro/serializers.py:273:35:ORM001", "hubspot_xpro/serializers.py:281:36:ORM001", "hubspot_xpro/serializers.py:282:23:ORM002", - "hubspot_xpro/serializers.py:288:15:ORM001" + "hubspot_xpro/serializers.py:288:15:ORM001", + "hubspot_xpro/serializers.py:306:25:ORM004", + "hubspot_xpro/serializers.py:312:15:ORM004", + "hubspot_xpro/serializers.py:313:27:ORM004", + "hubspot_xpro/serializers.py:314:28:ORM004", + "hubspot_xpro/serializers.py:319:25:ORM004", + "hubspot_xpro/serializers.py:325:18:ORM004", + "hubspot_xpro/serializers.py:331:25:ORM004", + "hubspot_xpro/serializers.py:337:18:ORM004", + "hubspot_xpro/serializers.py:343:25:ORM004", + "hubspot_xpro/serializers.py:351:25:ORM004", + "hubspot_xpro/serializers.py:359:25:ORM004", + "hubspot_xpro/serializers.py:367:25:ORM004", + "hubspot_xpro/serializers.py:373:25:ORM004", + "hubspot_xpro/serializers.py:420:26:ORM003", + "hubspot_xpro/serializers.py:427:26:ORM003", + "hubspot_xpro/serializers.py:48:11:ORM006", + "hubspot_xpro/serializers.py:49:39:ORM006", + "hubspot_xpro/serializers.py:54:15:ORM006", + "hubspot_xpro/serializers.py:56:15:ORM004", + "hubspot_xpro/serializers.py:56:41:ORM006", + "hubspot_xpro/serializers.py:60:15:ORM006", + "hubspot_xpro/serializers.py:64:15:ORM006", + "hubspot_xpro/serializers.py:68:15:ORM006", + "users/serializers.py:172:15:ORM006", + "users/serializers.py:176:15:ORM006", + "users/serializers.py:270:19:ORM004" ] From 096ed625c102e43a99ed0af05ebdbfbbb8590e2a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:49:55 +0500 Subject: [PATCH 3/6] [pre-commit.ci] pre-commit autoupdate (#4087) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.16.5 → v0.16.6](https://github.com/astral-sh/ruff-pre-commit/compare/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> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7c16099799..9b5aabf9b7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,7 +51,7 @@ repos: - --exclude-files - "_test.js$" - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.16.5" + rev: "v0.16.6" hooks: - id: ruff-format - id: ruff From 21269d56c6a955dc80c45dc7c59e52d09d104548 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:03:14 +0500 Subject: [PATCH 4/6] fix(deps): update dependency boto3 to v1.43.83 (#4095) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 34 +++++++++++++++++----------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 568d7d4d8e..b063658ab4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "Pillow==10.4.0", "PyNaCl==1.6.2", "beautifulsoup4==4.15.0", - "boto3==1.43.78", + "boto3==1.43.83", "celery==5.6.3", "celery-redbeat==2.3.3", "cybersource-rest-client-python==0.0.78", diff --git a/uv.lock b/uv.lock index 44ffe8cb51..29de6121f7 100644 --- a/uv.lock +++ b/uv.lock @@ -7,18 +7,18 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-09-01T10:06:12.61495Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P7D" [options.exclude-newer-package] -mitol-django-hubspot-api = { timestamp = "2026-09-08T10:06:12.615062Z", span = "PT0S" } -mitol-drf-lint = { timestamp = "2026-09-08T10:06:12.615064Z", span = "PT0S" } -mitol-django-observability = { timestamp = "2026-09-08T10:06:12.615064Z", span = "PT0S" } -mitol-django-authentication = { timestamp = "2026-09-08T10:06:12.614999Z", span = "PT0S" } -mitol-django-olposthog = { timestamp = "2026-09-08T10:06:12.615064Z", span = "PT0S" } -mitol-django-common = { timestamp = "2026-09-08T10:06:12.615062Z", span = "PT0S" } -mitol-django-mail = { timestamp = "2026-09-08T10:06:12.615063Z", span = "PT0S" } -mitol-django-oauth-toolkit-extensions = { timestamp = "2026-09-08T10:06:12.615063Z", span = "PT0S" } +mitol-django-authentication = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } +mitol-django-common = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } +mitol-django-hubspot-api = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } +mitol-django-mail = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } +mitol-django-oauth-toolkit-extensions = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } +mitol-django-observability = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } +mitol-django-olposthog = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } +mitol-drf-lint = { timestamp = "0001-01-01T00:00:00Z", span = "PT0S" } [[package]] name = "amqp" @@ -174,30 +174,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.43.78" +version = "1.43.83" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/be/55/e026c943f7f1ed6d2f5e6035713f21233bbe9ee975008662dc64ca0d4ced/boto3-1.43.78.tar.gz", hash = "sha256:2fa59116e298171ef59e7600a8be6c01177faef8af4b9a4314b7a57a04009ada", size = 112679, upload-time = "2026-08-21T19:37:36.84Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/30/96cf7d324e75cd2c349e2911ae2334d987fb8525d551495a2ef6758c26f3/boto3-1.43.83.tar.gz", hash = "sha256:6413d6e99f716af5d333a732db140e4b3359cac005a1271b11777b6d9ca82194", size = 112686, upload-time = "2026-08-28T19:35:52.273Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/55/288b1f0d987e15db0417f5ea43da564bfa042063f508c259eec893134628/boto3-1.43.78-py3-none-any.whl", hash = "sha256:893f06a171469618e17de78dc927aca6e74fcf45a70d2c5918e9ac9919e96cc9", size = 140028, upload-time = "2026-08-21T19:37:35.358Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4b/843f30dc77324778efa90c4169d503963668760b8d8abdf26a61bd090141/boto3-1.43.83-py3-none-any.whl", hash = "sha256:73a3564f737d4516625964eee709a498fa98ccee6aca929febad2b0b5fbeae1e", size = 140025, upload-time = "2026-08-28T19:35:49.228Z" }, ] [[package]] name = "botocore" -version = "1.43.78" +version = "1.43.89" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/71/490aaa384855bf3b69405ded52ae77a0e5f4eeb2165044fa65466c4d3a73/botocore-1.43.78.tar.gz", hash = "sha256:e8238d22c1e1342025d75d2e33d154a375e7caad0fc67f77d77faa2d82668b94", size = 15982895, upload-time = "2026-08-21T19:37:32.402Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/06/f63fb1befdf77af18539fb24ea01f2da0f13965ed5de091061708ac96416/botocore-1.43.89.tar.gz", hash = "sha256:f0574942970742657b0e0716cf08c2dfe6bef8e6de5fbb7081c3424e262b4cca", size = 16074206, upload-time = "2026-09-04T19:24:52.464Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/5b/b7c5c22767c0e8eb4bb2bd0a972d77bd0be3dd9908e15d4487094a31fa4b/botocore-1.43.78-py3-none-any.whl", hash = "sha256:ddd020493235e264b3bd12606f239a3d3b2dd7cfb1d25a0691061183c290c228", size = 15677214, upload-time = "2026-08-21T19:37:28.894Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9d/96f9dee6d12eedf1c2b4264eefd59c7ac8cac10daadb9a7bfccc9ee881c6/botocore-1.43.89-py3-none-any.whl", hash = "sha256:d7211220c815427fe71225acc6909e4ab5dfab3b03770e72fd16cf9eb86b3d1a", size = 15768272, upload-time = "2026-09-04T19:24:49.769Z" }, ] [[package]] @@ -1580,7 +1580,7 @@ name = "jinxed" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ansicon", marker = "sys_platform == 'win32'" }, + { name = "ansicon" }, ] sdist = { url = "https://files.pythonhosted.org/packages/20/d0/59b2b80e7a52d255f9e0ad040d2e826342d05580c4b1d7d7747cfb8db731/jinxed-1.3.0.tar.gz", hash = "sha256:1593124b18a41b7a3da3b078471442e51dbad3d77b4d4f2b0c26ab6f7d660dbf", size = 80981, upload-time = "2024-07-31T22:39:18.854Z" } wheels = [ @@ -2111,7 +2111,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "beautifulsoup4", specifier = "==4.15.0" }, - { name = "boto3", specifier = "==1.43.78" }, + { name = "boto3", specifier = "==1.43.83" }, { name = "celery", specifier = "==5.6.3" }, { name = "celery-redbeat", specifier = "==2.3.3" }, { name = "cybersource-rest-client-python", specifier = "==0.0.78" }, From 33f1fc230590062f7e9042fee73bb3471ee0f8c3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:07:47 +0500 Subject: [PATCH 5/6] chore(deps): update nginx docker tag to v1.31.4 (#4094) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index f76f1bbfa2..66a04532ec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,7 +32,7 @@ services: - "6379" nginx: - image: nginx:1.31.3@sha256:5a88c9c45479443d7be2eadc894b4ed0a9801bae03d97a5760ae13b5c2005942 + image: nginx:1.31.4@sha256:b34848eff6db786b6b1282d3a9c3fd0b5563dfb6d261df4923378b419e0d24f0 ports: - "8053:8053" links: From f54c2216a09e5f584bf418a814f1a5109fae277b Mon Sep 17 00:00:00 2001 From: Doof Date: Mon, 14 Sep 2026 13:21:23 +0000 Subject: [PATCH 6/6] Release 0.199.0 --- RELEASE.rst | 9 +++++++++ mitxpro/settings.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index 3481019f08..7c388723af 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,15 @@ Release Notes ============= +Version 0.199.0 +--------------- + +- chore(deps): update nginx docker tag to v1.31.4 (#4094) +- fix(deps): update dependency boto3 to v1.43.83 (#4095) +- [pre-commit.ci] pre-commit autoupdate (#4087) +- chore: refresh drf-lint baseline for new ORM003-ORM006 rules (#4092) +- feat(ecommerce): add Stripe as a second payment gateway (#4065) + Version 0.198.2 --------------- diff --git a/mitxpro/settings.py b/mitxpro/settings.py index ac81ee037f..87d3c8bc02 100644 --- a/mitxpro/settings.py +++ b/mitxpro/settings.py @@ -26,7 +26,7 @@ from mitxpro.celery_utils import OffsettingSchedule from mitxpro.sentry import init_sentry -VERSION = "0.198.2" +VERSION = "0.199.0" env.reset()