Skip to content

Commit a0d7046

Browse files
authored
Feat/use stripe customer portal (#185)
* Dynamic return URL * feat(payments): Use Stripe Checkout to collect payment information * Extract Stripe ID to tier map * Implement feedback and remove dead code * fix poetry build * update build * update lxml * operate in package mode * poetry run black * fix black formatting * use time limit * tool:pytest * migrate pytest options * fix pytest options * add django settings module env * add django selenium test * add pytest-django * update versions * Use bundled poetry * Update poetry * add pytest-env * actually use xdist * update a bunch of tests
1 parent 15c8daf commit a0d7046

26 files changed

Lines changed: 1816 additions & 1853 deletions

.github/workflows/main.yml

Lines changed: 14 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,28 @@ jobs:
88
runs-on: ubuntu-latest
99
strategy:
1010
matrix:
11-
python: ["3.10"]
12-
node: ["16"]
11+
python: ["3.13"]
12+
node: ["18"]
1313
browser: ["chrome", "firefox"]
1414

1515
steps:
1616
- uses: actions/checkout@v3
1717

1818
- name: Install Python ${{ matrix.python }}
19-
uses: actions/setup-python@v4
19+
uses: actions/setup-python@v5
2020
with:
2121
python-version: ${{ matrix.python }}
22+
23+
- name: Run 'poetry install'
24+
run: |
25+
pip install poetry
26+
poetry install
27+
2228
- name: Install Chrome Webdriver
2329
if: ${{ matrix.browser == 'chrome' }}
2430
run: |
25-
python3 -m venv venv
26-
source venv/bin/activate
27-
28-
pip install seleniumbase
2931
sudo apt-get install -y google-chrome-stable
30-
seleniumbase install chromedriver
31-
32-
deactivate
33-
rm -rf venv
32+
poetry run seleniumbase install chromedriver
3433
- name: Install Firefox Webdriver
3534
if: ${{ matrix.browser == 'firefox' }}
3635
run: |
@@ -55,22 +54,10 @@ jobs:
5554
' | sudo tee /etc/apt/preferences.d/mozilla-firefox > /dev/null
5655
sudo apt-get -y install firefox
5756
58-
59-
python3 -m venv venv
60-
source venv/bin/activate
61-
6257
sudo apt-get install -y firefox
6358
64-
pip install seleniumbase
65-
seleniumbase install geckodriver
59+
poetry run seleniumbase install geckodriver
6660
67-
deactivate
68-
rm -rf venv
69-
- name: Run 'poetry install'
70-
run: |
71-
pip install poetry
72-
poetry config virtualenvs.create false
73-
poetry install
7461
7562
- name: Install Node ${{ matrix.node }}
7663
uses: actions/setup-node@v1
@@ -83,12 +70,13 @@ jobs:
8370
run: yarn build
8471
- name: Check if 'black' has been run
8572
run:
86-
black --exclude 'migrations' --check .
73+
poetry run black --exclude 'migrations' --check .
8774
- name: Run 'pytest'
8875
env:
8976
SELENIUM_WEBDRIVER: ${{ matrix.browser }}
9077
ENABLE_GEOCACHE_TEST: '1'
91-
run: pytest --timeout=300
78+
DJANGO_SETTINGS_MODULE: 'MemberManagement.test_settings'
79+
run: poetry run pytest -n 4 --time-limit=300
9280

9381
smoke:
9482
name: Docker Smoke Test

.vscode/settings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
{
22
"python.formatting.provider": "black",
33
"python.testing.pytestArgs": [
4-
"."
4+
".",
5+
"-n 2"
56
],
67
"python.testing.unittestEnabled": false,
78
"python.testing.pytestEnabled": true,

Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ RUN git describe --always > /PORTAL_VERSION
55
RUN echo "Saved version file containing '$(cat /PORTAL_VERSION)'"
66

77
# image for building node dependencies
8-
FROM node:16-alpine AS frontend
8+
FROM node:18-alpine AS frontend
99

1010
RUN apk add --no-cache \
1111
git
@@ -23,7 +23,7 @@ ADD assets/ /app/assets/
2323
RUN yarn build
2424

2525
# image for python
26-
FROM python:3.10-alpine
26+
FROM python:3.13-alpine
2727

2828
# Install binary python dependencies
2929
RUN apk add --no-cache \

MemberManagement/docker_settings.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,12 @@
8888
# Donation receipts
8989
PDF_RENDER_SERVER = os.environ.get("PDF_RENDER_SERVER")
9090
SIGNATURE_IMAGE = os.path.join(BASE_DIR, os.environ.setdefault("SIGNATURE_IMAGE", ""))
91+
92+
93+
STRIPE_CONTRIBUTOR_PRICE_ID = os.environ.get("STRIPE_CONTRIBUTOR_PRICE_ID")
94+
STRIPE_PATRON_PRICE_ID = os.environ.get("STRIPE_PATRON_PRICE_ID")
95+
STRIPE_STARTER_PRICE_ID = os.environ.get("STRIPE_STARTER_PRICE_ID")
96+
97+
STRIPE_CONTRIBUTOR_PRODUCT_ID = os.environ.get("STRIPE_CONTRIBUTOR_PRODUCT_ID")
98+
STRIPE_PATRON_PRODUCT_ID = os.environ.get("STRIPE_PATRON_PRODUCT_ID")
99+
STRIPE_STARTER_PRODUCT_ID = os.environ.get("STRIPE_STARTER_PRODUCT_ID")

MemberManagement/settings.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,3 +251,13 @@
251251
pass
252252

253253
SITE_ID = 1
254+
255+
# Product IDs, used for reconciling local data from Stripe data
256+
STRIPE_CONTRIBUTOR_PRODUCT_ID = "prod_SUs3ndpRDNqw9C"
257+
STRIPE_PATRON_PRODUCT_ID = "prod_SUs4SFxhRtJgV5"
258+
STRIPE_STARTER_PRODUCT_ID = "prod_S7cQEP8uPJu675"
259+
260+
# Exact price IDs, used for signups
261+
STRIPE_CONTRIBUTOR_PRICE_ID = "price_1Rc03OK8wO5tRpJk4crjyiAj"
262+
STRIPE_PATRON_PRICE_ID = "price_1Rc05JK8wO5tRpJkX3EvIcnA"
263+
STRIPE_STARTER_PRICE_ID = "price_1RDNBKK8wO5tRpJk4y3CLUAS"

MemberManagement/tests/test_access.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,6 @@
3333
+ [
3434
"registry_vote",
3535
"setup_membership",
36-
"setup_subscription",
37-
"update_subscription",
38-
"view_payments",
3936
]
4037
)
4138

alumni/fields/tier.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
from MemberManagement import settings
4+
35
from .custom import CustomTextChoiceField
46

57
__all__ = ["TierField"]
@@ -10,18 +12,29 @@ class TierField(CustomTextChoiceField):
1012
CONTRIBUTOR = "co"
1113
PATRON = "pa"
1214

13-
CHOICES = (
15+
CHOICES = [
1416
(CONTRIBUTOR, "Contributor – Standard membership for 39€ p.a."),
1517
(STARTER, "Starter – Free Membership for 0€ p.a."),
1618
(PATRON, "Patron – Premium membership for 249€ p.a."),
17-
)
19+
]
1820

1921
STRIPE_IDS = {
2022
CONTRIBUTOR: "contributor-membership",
2123
STARTER: "starter-membership",
2224
PATRON: "patron-membership",
2325
}
2426

27+
STRIPE_ID_TO_TIER = {
28+
# Legacy IDs
29+
"contributor-membership": CONTRIBUTOR,
30+
"patron-membership": PATRON,
31+
"starter-membership": STARTER,
32+
# New IDs
33+
settings.STRIPE_CONTRIBUTOR_PRODUCT_ID: CONTRIBUTOR,
34+
settings.STRIPE_PATRON_PRODUCT_ID: PATRON,
35+
settings.STRIPE_STARTER_PRODUCT_ID: STARTER,
36+
}
37+
2538
@staticmethod
2639
def get_description(value):
2740
for k, v in TierField.CHOICES:
@@ -31,3 +44,9 @@ def get_description(value):
3144
@staticmethod
3245
def get_stripe_id(value):
3346
return TierField.STRIPE_IDS[value]
47+
48+
@staticmethod
49+
def get_tier_from_stripe_id(stripe_id: str) -> str:
50+
"""Maps a Stripe plan ID to a membership tier."""
51+
52+
return TierField.STRIPE_ID_TO_TIER.get(stripe_id, "Unknown")

payments/forms.py

Lines changed: 2 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -26,69 +26,8 @@ class Meta:
2626

2727

2828
class PaymentMethodForm(forms.Form):
29-
payment_type = forms.ChoiceField(choices=PaymentTypeField.CHOICES)
30-
source_id = forms.CharField(widget=forms.HiddenInput(), required=False)
31-
card_token = forms.CharField(widget=forms.HiddenInput(), required=False)
32-
33-
def clean(self) -> Any:
34-
cleaned_data = self.cleaned_data
35-
36-
# extract source id
37-
if "source_id" in cleaned_data:
38-
source_id = cleaned_data["source_id"]
39-
else:
40-
source_id = None
41-
cleaned_data["source_id"] = source_id
42-
source_is_blank = source_id == "" or source_id is None
43-
44-
# extract card id
45-
if "card_token" in cleaned_data:
46-
card_token = cleaned_data["card_token"]
47-
else:
48-
card_token = None
49-
cleaned_data["card_token"] = card_token
50-
card_is_blank = card_token == "" or card_token is None
51-
52-
if source_is_blank and card_is_blank:
53-
raise forms.ValidationError(
54-
"Either a Source ID or a Card Token must be given"
55-
)
56-
57-
if (not source_is_blank) and (not card_is_blank):
58-
raise forms.ValidationError(
59-
"Exactly one of Source ID and Card Token must be given"
60-
)
61-
62-
return cleaned_data
63-
64-
def attach_to_customer(self, customer: str) -> [Optional[bool], Optional[str]]:
65-
source_id = self.cleaned_data["source_id"]
66-
token = self.cleaned_data["card_token"]
67-
return stripewrapper.update_payment_method(customer, source_id, token)
29+
pass
6830

6931

7032
class CancellablePaymentMethodForm(PaymentMethodForm):
71-
go_to_starter = forms.CharField(widget=forms.HiddenInput(), required=False)
72-
73-
def clean(self) -> Any:
74-
cleaned_data = self.cleaned_data
75-
76-
# if 'go to starter' is set, go to starter instead
77-
if "go_to_starter" in cleaned_data:
78-
if cleaned_data["go_to_starter"] == "true":
79-
return cleaned_data
80-
else:
81-
cleaned_data["go_to_starter"] = ""
82-
83-
return super().clean()
84-
85-
@property
86-
def user_go_to_starter(self) -> bool:
87-
return self.cleaned_data["go_to_starter"] == "true"
88-
89-
def attach_to_customer(self, customer: str) -> [Optional[bool], Optional[str]]:
90-
# if go to starter was set, don't do anything
91-
if self.cleaned_data["go_to_starter"]:
92-
return True, None
93-
94-
return super().attach_to_customer(customer)
33+
pass

payments/models.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,45 @@ def start_new_subscription(
358358
member=alumni, start=start, end=end, subscription=subscription, tier=tier
359359
)
360360

361+
@classmethod
362+
def sync_from_stripe(cls, alumni: Alumni) -> Optional[SubscriptionInformation]:
363+
"""Syncs the SubscriptionInformation object with data from Stripe."""
364+
membership = alumni.membership
365+
366+
if not membership or not membership.customer:
367+
return None
368+
369+
# Retrieve the Stripe subscription for the customer
370+
stripe_subscriptions, err = stripewrapper.get_subscriptions_for_customer(
371+
membership.customer
372+
)
373+
374+
if not stripe_subscriptions:
375+
return None
376+
377+
# Assume the first subscription is the active one
378+
stripe_subscription = stripe_subscriptions[0]
379+
380+
# Update or create the SubscriptionInformation object
381+
subscription_info, created = cls.objects.update_or_create(
382+
member=alumni,
383+
subscription=stripe_subscription["id"],
384+
defaults={
385+
"start": datetime.utcfromtimestamp(stripe_subscription["start_date"]),
386+
"end": (
387+
datetime.utcfromtimestamp(stripe_subscription["end_date"])
388+
if stripe_subscription["end_date"]
389+
else None
390+
),
391+
"tier": TierField.get_tier_from_stripe_id(
392+
stripe_subscription["plan"]["id"]
393+
),
394+
"external": False,
395+
},
396+
)
397+
398+
return subscription_info
399+
361400

362401
class PaymentIntent(models.Model):
363402
stripe_id = models.CharField(max_length=256)

payments/stripewrapper.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22
from alumni.models import Alumni
3-
from typing import TypeVar, Optional, Callable, List, Any, Dict
3+
from typing import TypeVar, Optional, Callable, List, Any, Dict, Tuple
44

55
import stripe as stripeapi
66
from raven.contrib.django.raven_compat.models import client
@@ -35,7 +35,7 @@ def _safe(
3535

3636
def _as_safe_operation(
3737
f: Callable[..., T]
38-
) -> Callable[..., (Optional[T], Optional[str])]:
38+
) -> Callable[..., Tuple[Optional[T], Optional[str]]]:
3939
"""Wraps a function with _safe"""
4040

4141
def _wrapper(*args, **kwargs):
@@ -319,3 +319,26 @@ def make_stripe_event(
319319
stripe: stripeapi, payload: str, sig_header: str, endpoint_secret: str
320320
):
321321
return stripe.Webhook.construct_event(payload, sig_header, endpoint_secret)
322+
323+
324+
@_as_safe_operation
325+
def get_customer_portal_url(stripe: stripeapi, customer_id, return_url) -> str:
326+
session = stripe.billing_portal.Session.create(
327+
customer=customer_id, return_url=return_url
328+
)
329+
return session.url
330+
331+
332+
@_as_safe_operation
333+
def get_subscriptions_for_customer(stripe: stripeapi, customer_id: str):
334+
"""Retrieve all subscriptions for a given customer."""
335+
subscriptions = stripe.Subscription.list(customer=customer_id)
336+
return [
337+
{
338+
"id": sub.id,
339+
"start_date": sub.start_date,
340+
"end_date": sub.ended_at,
341+
"plan": {"id": sub.plan.id},
342+
}
343+
for sub in subscriptions.auto_paging_iter()
344+
]

0 commit comments

Comments
 (0)