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/.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 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/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/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/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: 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..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: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: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" ] 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..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() @@ -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..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", @@ -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..29de6121f7 100644 --- a/uv.lock +++ b/uv.lock @@ -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]] @@ -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" }, @@ -2094,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" }, @@ -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"