Skip to content

Commit 7ebc8f5

Browse files
committed
Merge branch 'main' into rel
2 parents 6e1483e + 8deb514 commit 7ebc8f5

File tree

10 files changed

+140
-128
lines changed

10 files changed

+140
-128
lines changed

CHANGELOG.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@ CHANGELOG
66
.. This is included by docs/developer/changelog.rst
77
88
9+
Version v5.27.0
10+
---------------
11+
12+
This release fixed an error preventing new Publishers from connecting with Stripe for payouts.
13+
14+
:Date: October 2, 2025
15+
16+
* @dependabot[bot]: Bump django from 5.2.6 to 5.2.7 in /requirements (#1075)
17+
* @davidfischer: Update the publisher stripe connect workflow (#1073)
18+
19+
920
Version v5.26.1
1021
---------------
1122

adserver/forms.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1439,9 +1439,7 @@ def __init__(self, *args, **kwargs):
14391439
)
14401440
)
14411441
elif settings.STRIPE_CONNECT_CLIENT_ID:
1442-
connect_url = reverse(
1443-
"publisher_stripe_oauth_connect", args=[self.instance.slug]
1444-
)
1442+
connect_url = reverse("publisher_stripe_connect", args=[self.instance.slug])
14451443
stripe_block = HTML(
14461444
format_html(
14471445
"<a href='{}' target='_blank' class='btn btn-sm btn-outline-info mb-4'>"

adserver/tests/test_publisher_dashboard.py

Lines changed: 45 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from django.test.client import RequestFactory
77
from django.urls import reverse
88
from django_dynamic_fixture import get
9+
from django.conf import settings
910

1011
from ..constants import CLICKS
1112
from ..constants import PAID_CAMPAIGN
@@ -323,7 +324,7 @@ def test_publisher_payout_detail(self):
323324
self.assertContains(resp, "$2.50")
324325

325326
def test_publisher_stripe_connect(self):
326-
url = reverse("publisher_stripe_oauth_connect", args=[self.publisher1.slug])
327+
url = reverse("publisher_stripe_connect", args=[self.publisher1.slug])
327328

328329
# Anonymous - redirect to login
329330
resp = self.client.get(url)
@@ -338,60 +339,63 @@ def test_publisher_stripe_connect(self):
338339
self.assertContains(resp, "Stripe is not configured")
339340

340341
with override_settings(STRIPE_CONNECT_CLIENT_ID="ca_XXXXXXXXXXX"):
341-
resp = self.client.get(url)
342-
self.assertEqual(resp.status_code, 302)
343-
self.assertTrue(
344-
resp["location"].startswith(
345-
"https://connect.stripe.com/express/oauth/authorize"
346-
)
347-
)
348-
349-
self.assertTrue("stripe_state" in self.client.session)
350-
self.assertTrue("stripe_connect_publisher" in self.client.session)
351-
self.assertEqual(
352-
self.client.session["stripe_connect_publisher"], self.publisher1.slug
353-
)
342+
with (
343+
mock.patch("stripe.Account.create") as account_create,
344+
mock.patch("stripe.AccountLink.create") as account_link_create,
345+
):
346+
account_create.return_value = mock.MagicMock()
347+
account_create.return_value.stripe_id = "acct_12345"
348+
account_link_create.return_value = mock.MagicMock()
349+
account_link_create.return_value.url = "http://stripe.com/onboarding"
350+
351+
resp = self.client.get(url)
352+
self.assertEqual(resp.status_code, 302)
353+
self.assertEqual(resp["location"], "http://stripe.com/onboarding")
354+
354355

355356
def test_publisher_stripe_return(self):
356-
connect_url = reverse(
357-
"publisher_stripe_oauth_connect", args=[self.publisher1.slug]
358-
)
359-
url = reverse("publisher_stripe_oauth_return")
357+
url = reverse("publisher_stripe_return", args=[self.publisher1.slug])
358+
359+
# Anonymous - redirect to login
360+
resp = self.client.get(url)
361+
self.assertEqual(resp.status_code, 302)
362+
self.assertTrue(resp["location"].startswith("/accounts/login/"))
360363

361364
self.user.publishers.add(self.publisher1)
362365
self.client.force_login(self.user)
363366

364367
# Didn't setup state beforehand
365368
resp = self.client.get(url, follow=True)
366369
self.assertEqual(resp.status_code, 200)
367-
self.assertContains(resp, "There was a problem connecting your Stripe account")
370+
self.assertContains(resp, "Stripe is not configured")
368371

369-
# Do Stripe connect - which sets up session state
370372
with override_settings(STRIPE_CONNECT_CLIENT_ID="ca_XXXXXXXXXXX"):
371-
self.client.get(connect_url)
372-
373-
self.assertTrue("stripe_state" in self.client.session)
374-
self.assertTrue("stripe_connect_publisher" in self.client.session)
375-
self.assertEqual(
376-
self.client.session["stripe_connect_publisher"], self.publisher1.slug
377-
)
378-
379-
with mock.patch("stripe.OAuth.token") as oauth_token_create:
380-
account_id = "uid_XXXXX"
381-
oauth_token_create.return_value = {"stripe_user_id": account_id}
382-
383-
url += "?code=XXXXX&state={}".format(self.client.session["stripe_state"])
384-
373+
# No account ID
385374
resp = self.client.get(url, follow=True)
386375
self.assertEqual(resp.status_code, 200)
387-
self.assertContains(resp, "Successfully connected your Stripe account")
388-
389-
# These get deleted
390-
self.assertFalse("stripe_state" in self.client.session)
391-
self.assertFalse("stripe_connect_publisher" in self.client.session)
376+
self.assertContains(resp, "There was a problem connecting your Stripe account")
377+
378+
# Update account ID on the session
379+
session = self.client.session
380+
session["stripe_account_id"] = "acct_12345"
381+
session.save()
382+
self.client.cookies[settings.SESSION_COOKIE_NAME] = session.session_key
383+
384+
# Mock out the Stripe account retrieve call
385+
with (
386+
mock.patch("stripe.Account.retrieve") as account_retrieve,
387+
mock.patch("djstripe.models.Account.sync_from_stripe_data") as account_sync,
388+
# This is on the redirected settings page after Stripe is connected
389+
mock.patch("stripe.Account.create_login_link") as _,
390+
):
391+
account_retrieve.return_value = mock.MagicMock()
392+
account_retrieve.return_value.details_submitted = True
393+
account_sync.return_value = None
394+
395+
resp = self.client.get(url, follow=True)
396+
self.assertEqual(resp.status_code, 200)
397+
self.assertContains(resp, "Successfully connected your Stripe account")
392398

393-
self.publisher1.refresh_from_db()
394-
self.assertEqual(self.publisher1.stripe_connected_account_id, account_id)
395399

396400
def test_authorized_users(self):
397401
url = reverse(

adserver/urls.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@
5454
from .views import PublisherPlacementReportView
5555
from .views import PublisherReportView
5656
from .views import PublisherSettingsView
57-
from .views import PublisherStripeOauthConnectView
57+
from .views import PublisherStripeConnectView
58+
from .views import PublisherStripeReturnView
5859
from .views import StaffAdvertiserReportView
5960
from .views import StaffGeoReportView
6061
from .views import StaffKeywordReportView
@@ -65,7 +66,6 @@
6566
from .views import dashboard
6667
from .views import do_not_track
6768
from .views import do_not_track_policy
68-
from .views import publisher_stripe_oauth_return
6969

7070

7171
urlpatterns = [
@@ -352,14 +352,14 @@
352352
name="publisher_embed",
353353
),
354354
path(
355-
r"publisher/<slug:publisher_slug>/oauth/stripe/connect/",
356-
PublisherStripeOauthConnectView.as_view(),
357-
name="publisher_stripe_oauth_connect",
355+
r"publisher/<slug:publisher_slug>/stripe/connect/",
356+
PublisherStripeConnectView.as_view(),
357+
name="publisher_stripe_connect",
358358
),
359359
path(
360-
r"publisher/oauth/stripe/return/",
361-
publisher_stripe_oauth_return,
362-
name="publisher_stripe_oauth_return",
360+
r"publisher/<slug:publisher_slug>/stripe/return/",
361+
PublisherStripeReturnView.as_view(),
362+
name="publisher_stripe_return",
363363
),
364364
path(
365365
r"publisher/<slug:publisher_slug>/fallback-ads/",

adserver/views.py

Lines changed: 69 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@
3535
from django.urls import reverse
3636
from django.urls import reverse_lazy
3737
from django.utils import timezone
38-
from django.utils.crypto import get_random_string
3938
from django.utils.translation import gettext_lazy as _
4039
from django.views import View
4140
from django.views.generic import CreateView
@@ -2332,7 +2331,7 @@ def get_success_url(self):
23322331
)
23332332

23342333

2335-
class PublisherStripeOauthConnectView(
2334+
class PublisherStripeConnectView(
23362335
PublisherAdminAccessMixin, UserPassesTestMixin, RedirectView
23372336
):
23382337
"""Redirect the user to the correct Stripe connect URL for the publisher."""
@@ -2347,92 +2346,92 @@ def get_redirect_url(self, *args, **kwargs):
23472346

23482347
publisher = self.get_object()
23492348

2350-
# Save a state nonce to verify that the Stripe oauth flow can't be replayed or forged
2351-
stripe_state = get_random_string(30)
2352-
self.request.session["stripe_state"] = stripe_state
2353-
self.request.session["stripe_connect_publisher"] = publisher.slug
2354-
2355-
params = {
2356-
"client_id": settings.STRIPE_CONNECT_CLIENT_ID,
2357-
# "suggested_capabilities[]": "transfers",
2358-
"stripe_user[email]": self.request.user.email,
2359-
"state": stripe_state,
2360-
"redirect_uri": self.request.build_absolute_uri(
2361-
reverse("publisher_stripe_oauth_return")
2362-
),
2363-
}
2364-
return f"https://connect.stripe.com/express/oauth/authorize?{urllib.parse.urlencode(params)}"
2349+
# https://docs.stripe.com/api/accounts/create
2350+
account = stripe.Account.create(type="express")
2351+
2352+
# Users get redirected back to this page if the link expired, etc.
2353+
refresh_url = self.request.build_absolute_uri(
2354+
reverse("publisher_settings", args=[publisher.slug])
2355+
)
2356+
# Users get redirected back to this page after completing the Stripe onboarding flow
2357+
# This doesn’t mean that all information has been collected or that there are no outstanding requirements on the account.
2358+
# This only means the flow was entered and exited properly.
2359+
# The return URL is also used if the user clicks "back to EthicalAds" in the Stripe flow.
2360+
self.request.session["stripe_account_id"] = account.stripe_id
2361+
return_url = self.request.build_absolute_uri(
2362+
reverse("publisher_stripe_return", args=[publisher.slug])
2363+
)
2364+
2365+
account_link = stripe.AccountLink.create(
2366+
account=account.stripe_id,
2367+
refresh_url=refresh_url,
2368+
return_url=return_url,
2369+
type="account_onboarding",
2370+
)
2371+
2372+
return account_link.url
23652373

23662374
def get_object(self, queryset=None): # pylint: disable=unused-argument
23672375
return get_object_or_404(Publisher, slug=self.kwargs["publisher_slug"])
23682376

23692377

2370-
@login_required
2371-
def publisher_stripe_oauth_return(request):
2372-
"""Handle the oauth return flow from Stripe - save the account on the publisher."""
2373-
# A stripe token we passed when setup started - needs to be double checked
2374-
state = request.GET.get("state", "")
2375-
oauth_code = request.GET.get("code", "")
2378+
class PublisherStripeReturnView(
2379+
PublisherAdminAccessMixin, UserPassesTestMixin, RedirectView
2380+
):
2381+
"""
2382+
Stripe redirects to here after a publisher goes through the connect flow.
23762383
2377-
if request.user.is_staff:
2378-
publishers = Publisher.objects.all()
2379-
else:
2380-
publishers = request.user.publishers.all()
2384+
Just a note that we still need to check that the user completed the flow successfully.
2385+
See: https://docs.stripe.com/connect/express-accounts#return-user
2386+
"""
23812387

2382-
publisher = publishers.filter(
2383-
slug=request.session.get("stripe_connect_publisher", "")
2384-
).first()
2388+
model = Publisher
2389+
permanent = False
23852390

2386-
if state == request.session.get("stripe_state") and publisher:
2387-
response = None
2388-
log.debug(
2389-
"Using stripe auth code to connect publisher account. Publisher = [%s]",
2390-
publisher,
2391-
)
2392-
try:
2393-
response = stripe.OAuth.token(
2394-
grant_type="authorization_code", code=oauth_code
2391+
def get_redirect_url(self, *args, **kwargs):
2392+
if not settings.STRIPE_CONNECT_CLIENT_ID:
2393+
messages.error(self.request, _("Stripe is not configured"))
2394+
return reverse("dashboard-home")
2395+
2396+
publisher = self.get_object()
2397+
account_id = self.request.session.get("stripe_account_id", "")
2398+
2399+
if not account_id:
2400+
messages.error(
2401+
self.request, _("There was a problem connecting your Stripe account")
23952402
)
2396-
except stripe.oauth_error.OAuthError:
2397-
log.error("Invalid Stripe authorization code: %s", oauth_code)
2398-
except Exception:
2399-
log.error("An unknown Stripe error occurred.")
2403+
log.error("No account returned from Stripe.")
2404+
return reverse("dashboard-home")
24002405

2401-
if response:
2402-
connected_account_id = response["stripe_user_id"]
2406+
# Get the account from Stripe to verify the connect workflow completed
2407+
account = stripe.Account.retrieve(account_id)
24032408

2404-
try:
2405-
# Retrieve the Stripe connected account and save it on the publisher
2406-
publisher.djstripe_account = Account.sync_from_stripe_data(
2407-
stripe.Account.retrieve(connected_account_id)
2408-
)
2409-
except stripe.error.StripeError:
2410-
log.exception(
2411-
"Stripe returned a connected account, but it could not be retrieved."
2412-
)
2409+
# This is the field we need to check to see if the account is fully set up
2410+
# https://docs.stripe.com/connect/express-accounts#return-user
2411+
if account.details_submitted:
2412+
publisher.djstripe_account = Account.sync_from_stripe_data(account)
24132413

24142414
# Deprecated field
2415-
publisher.stripe_connected_account_id = connected_account_id
2415+
publisher.stripe_connected_account_id = account_id
24162416

24172417
publisher.payout_method = PAYOUT_STRIPE
24182418
publisher.save()
2419-
messages.success(request, _("Successfully connected your Stripe account"))
2420-
2421-
# Delete saved stripe state
2422-
del request.session["stripe_state"]
2423-
del request.session["stripe_connect_publisher"]
2419+
messages.success(
2420+
self.request, _("Successfully connected your Stripe account")
2421+
)
2422+
else:
2423+
messages.warning(
2424+
self.request,
2425+
_(
2426+
"The Stripe setup wasn't completed. "
2427+
"You can resume the process at any time or try a different payout method."
2428+
),
2429+
)
24242430

2425-
return redirect(reverse("publisher_main", args=[publisher.slug]))
2426-
else:
2427-
log.warning(
2428-
"Stripe state or publisher do not check out. State = [%s], Publisher = [%s]",
2429-
state,
2430-
publisher,
2431-
)
2431+
return reverse("publisher_settings", args=[publisher.slug])
24322432

2433-
messages.error(request, _("There was a problem connecting your Stripe account"))
2434-
log.error("There was a problem connecting a Stripe account.")
2435-
return redirect(reverse("dashboard-home"))
2433+
def get_object(self, queryset=None): # pylint: disable=unused-argument
2434+
return get_object_or_404(Publisher, slug=self.kwargs["publisher_slug"])
24362435

24372436

24382437
class PublisherPayoutListView(PublisherAccessMixin, UserPassesTestMixin, ListView):

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ethical-ad-server",
3-
"version": "5.26.1",
3+
"version": "5.27.0",
44
"description": "",
55
"main": "index.js",
66
"engines": {

requirements/base.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ cryptography==45.0.6
5454
# via fido2
5555
dj-stripe==2.8.4
5656
# via -r base.in
57-
django==5.2.6
57+
django==5.2.7
5858
# via
5959
# -r base.in
6060
# crispy-bootstrap4

0 commit comments

Comments
 (0)