Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,6 @@ localdev/google.token

# Claude
.claude/

# drf-lint cross-file index cache
.drf_lint_cache.json
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -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
---------------

Expand Down
18 changes: 18 additions & 0 deletions b2b_ecommerce/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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"]
Expand Down
42 changes: 42 additions & 0 deletions b2b_ecommerce/views_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
154 changes: 154 additions & 0 deletions docs/configure_stripe.md
Original file line number Diff line number Diff line change
@@ -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://<your-host>/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.
Loading
Loading