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
40 changes: 14 additions & 26 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,28 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python: ["3.10"]
node: ["16"]
python: ["3.13"]
node: ["18"]
browser: ["chrome", "firefox"]

steps:
- uses: actions/checkout@v3

- name: Install Python ${{ matrix.python }}
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}

- name: Run 'poetry install'
run: |
pip install poetry
poetry install

- name: Install Chrome Webdriver
if: ${{ matrix.browser == 'chrome' }}
run: |
python3 -m venv venv
source venv/bin/activate

pip install seleniumbase
sudo apt-get install -y google-chrome-stable
seleniumbase install chromedriver

deactivate
rm -rf venv
poetry run seleniumbase install chromedriver
- name: Install Firefox Webdriver
if: ${{ matrix.browser == 'firefox' }}
run: |
Expand All @@ -55,22 +54,10 @@ jobs:
' | sudo tee /etc/apt/preferences.d/mozilla-firefox > /dev/null
sudo apt-get -y install firefox


python3 -m venv venv
source venv/bin/activate

sudo apt-get install -y firefox

pip install seleniumbase
seleniumbase install geckodriver
poetry run seleniumbase install geckodriver

deactivate
rm -rf venv
- name: Run 'poetry install'
run: |
pip install poetry
poetry config virtualenvs.create false
poetry install

- name: Install Node ${{ matrix.node }}
uses: actions/setup-node@v1
Expand All @@ -83,12 +70,13 @@ jobs:
run: yarn build
- name: Check if 'black' has been run
run:
black --exclude 'migrations' --check .
poetry run black --exclude 'migrations' --check .
- name: Run 'pytest'
env:
SELENIUM_WEBDRIVER: ${{ matrix.browser }}
ENABLE_GEOCACHE_TEST: '1'
run: pytest --timeout=300
DJANGO_SETTINGS_MODULE: 'MemberManagement.test_settings'
run: poetry run pytest -n 4 --time-limit=300

smoke:
name: Docker Smoke Test
Expand Down
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"python.formatting.provider": "black",
"python.testing.pytestArgs": [
"."
".",
"-n 2"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ RUN git describe --always > /PORTAL_VERSION
RUN echo "Saved version file containing '$(cat /PORTAL_VERSION)'"

# image for building node dependencies
FROM node:16-alpine AS frontend
FROM node:18-alpine AS frontend

RUN apk add --no-cache \
git
Expand All @@ -23,7 +23,7 @@ ADD assets/ /app/assets/
RUN yarn build

# image for python
FROM python:3.10-alpine
FROM python:3.13-alpine

# Install binary python dependencies
RUN apk add --no-cache \
Expand Down
9 changes: 9 additions & 0 deletions MemberManagement/docker_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,12 @@
# Donation receipts
PDF_RENDER_SERVER = os.environ.get("PDF_RENDER_SERVER")
SIGNATURE_IMAGE = os.path.join(BASE_DIR, os.environ.setdefault("SIGNATURE_IMAGE", ""))


STRIPE_CONTRIBUTOR_PRICE_ID = os.environ.get("STRIPE_CONTRIBUTOR_PRICE_ID")
STRIPE_PATRON_PRICE_ID = os.environ.get("STRIPE_PATRON_PRICE_ID")
STRIPE_STARTER_PRICE_ID = os.environ.get("STRIPE_STARTER_PRICE_ID")

STRIPE_CONTRIBUTOR_PRODUCT_ID = os.environ.get("STRIPE_CONTRIBUTOR_PRODUCT_ID")
STRIPE_PATRON_PRODUCT_ID = os.environ.get("STRIPE_PATRON_PRODUCT_ID")
STRIPE_STARTER_PRODUCT_ID = os.environ.get("STRIPE_STARTER_PRODUCT_ID")
10 changes: 10 additions & 0 deletions MemberManagement/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,3 +251,13 @@
pass

SITE_ID = 1

# Product IDs, used for reconciling local data from Stripe data
STRIPE_CONTRIBUTOR_PRODUCT_ID = "prod_SUs3ndpRDNqw9C"
STRIPE_PATRON_PRODUCT_ID = "prod_SUs4SFxhRtJgV5"
STRIPE_STARTER_PRODUCT_ID = "prod_S7cQEP8uPJu675"

# Exact price IDs, used for signups
STRIPE_CONTRIBUTOR_PRICE_ID = "price_1Rc03OK8wO5tRpJk4crjyiAj"
STRIPE_PATRON_PRICE_ID = "price_1Rc05JK8wO5tRpJkX3EvIcnA"
STRIPE_STARTER_PRICE_ID = "price_1RDNBKK8wO5tRpJk4y3CLUAS"
3 changes: 0 additions & 3 deletions MemberManagement/tests/test_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,6 @@
+ [
"registry_vote",
"setup_membership",
"setup_subscription",
"update_subscription",
"view_payments",
]
)

Expand Down
23 changes: 21 additions & 2 deletions alumni/fields/tier.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from MemberManagement import settings

from .custom import CustomTextChoiceField

__all__ = ["TierField"]
Expand All @@ -10,18 +12,29 @@ class TierField(CustomTextChoiceField):
CONTRIBUTOR = "co"
PATRON = "pa"

CHOICES = (
CHOICES = [
(CONTRIBUTOR, "Contributor – Standard membership for 39€ p.a."),
(STARTER, "Starter – Free Membership for 0€ p.a."),
(PATRON, "Patron – Premium membership for 249€ p.a."),
)
]

STRIPE_IDS = {
CONTRIBUTOR: "contributor-membership",
STARTER: "starter-membership",
PATRON: "patron-membership",
}

STRIPE_ID_TO_TIER = {
# Legacy IDs
"contributor-membership": CONTRIBUTOR,
"patron-membership": PATRON,
"starter-membership": STARTER,
# New IDs
settings.STRIPE_CONTRIBUTOR_PRODUCT_ID: CONTRIBUTOR,
settings.STRIPE_PATRON_PRODUCT_ID: PATRON,
settings.STRIPE_STARTER_PRODUCT_ID: STARTER,
}

@staticmethod
def get_description(value):
for k, v in TierField.CHOICES:
Expand All @@ -31,3 +44,9 @@ def get_description(value):
@staticmethod
def get_stripe_id(value):
return TierField.STRIPE_IDS[value]

@staticmethod
def get_tier_from_stripe_id(stripe_id: str) -> str:
"""Maps a Stripe plan ID to a membership tier."""

return TierField.STRIPE_ID_TO_TIER.get(stripe_id, "Unknown")
65 changes: 2 additions & 63 deletions payments/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,69 +26,8 @@ class Meta:


class PaymentMethodForm(forms.Form):
payment_type = forms.ChoiceField(choices=PaymentTypeField.CHOICES)
source_id = forms.CharField(widget=forms.HiddenInput(), required=False)
card_token = forms.CharField(widget=forms.HiddenInput(), required=False)

def clean(self) -> Any:
cleaned_data = self.cleaned_data

# extract source id
if "source_id" in cleaned_data:
source_id = cleaned_data["source_id"]
else:
source_id = None
cleaned_data["source_id"] = source_id
source_is_blank = source_id == "" or source_id is None

# extract card id
if "card_token" in cleaned_data:
card_token = cleaned_data["card_token"]
else:
card_token = None
cleaned_data["card_token"] = card_token
card_is_blank = card_token == "" or card_token is None

if source_is_blank and card_is_blank:
raise forms.ValidationError(
"Either a Source ID or a Card Token must be given"
)

if (not source_is_blank) and (not card_is_blank):
raise forms.ValidationError(
"Exactly one of Source ID and Card Token must be given"
)

return cleaned_data

def attach_to_customer(self, customer: str) -> [Optional[bool], Optional[str]]:
source_id = self.cleaned_data["source_id"]
token = self.cleaned_data["card_token"]
return stripewrapper.update_payment_method(customer, source_id, token)
pass


class CancellablePaymentMethodForm(PaymentMethodForm):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason for this class? Both it and the parent class are empty. Wouldn't one be enough?

go_to_starter = forms.CharField(widget=forms.HiddenInput(), required=False)

def clean(self) -> Any:
cleaned_data = self.cleaned_data

# if 'go to starter' is set, go to starter instead
if "go_to_starter" in cleaned_data:
if cleaned_data["go_to_starter"] == "true":
return cleaned_data
else:
cleaned_data["go_to_starter"] = ""

return super().clean()

@property
def user_go_to_starter(self) -> bool:
return self.cleaned_data["go_to_starter"] == "true"

def attach_to_customer(self, customer: str) -> [Optional[bool], Optional[str]]:
# if go to starter was set, don't do anything
if self.cleaned_data["go_to_starter"]:
return True, None

return super().attach_to_customer(customer)
pass
39 changes: 39 additions & 0 deletions payments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,45 @@ def start_new_subscription(
member=alumni, start=start, end=end, subscription=subscription, tier=tier
)

@classmethod
def sync_from_stripe(cls, alumni: Alumni) -> Optional[SubscriptionInformation]:
"""Syncs the SubscriptionInformation object with data from Stripe."""
membership = alumni.membership

if not membership or not membership.customer:
return None

# Retrieve the Stripe subscription for the customer
stripe_subscriptions, err = stripewrapper.get_subscriptions_for_customer(
membership.customer
)

if not stripe_subscriptions:
return None

# Assume the first subscription is the active one
stripe_subscription = stripe_subscriptions[0]

# Update or create the SubscriptionInformation object
subscription_info, created = cls.objects.update_or_create(
member=alumni,
subscription=stripe_subscription["id"],
defaults={
"start": datetime.utcfromtimestamp(stripe_subscription["start_date"]),
"end": (
datetime.utcfromtimestamp(stripe_subscription["end_date"])
if stripe_subscription["end_date"]
else None
),
"tier": TierField.get_tier_from_stripe_id(
stripe_subscription["plan"]["id"]
),
"external": False,
},
)

return subscription_info


class PaymentIntent(models.Model):
stripe_id = models.CharField(max_length=256)
Expand Down
27 changes: 25 additions & 2 deletions payments/stripewrapper.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations
from alumni.models import Alumni
from typing import TypeVar, Optional, Callable, List, Any, Dict
from typing import TypeVar, Optional, Callable, List, Any, Dict, Tuple

import stripe as stripeapi
from raven.contrib.django.raven_compat.models import client
Expand Down Expand Up @@ -35,7 +35,7 @@ def _safe(

def _as_safe_operation(
f: Callable[..., T]
) -> Callable[..., (Optional[T], Optional[str])]:
) -> Callable[..., Tuple[Optional[T], Optional[str]]]:
"""Wraps a function with _safe"""

def _wrapper(*args, **kwargs):
Expand Down Expand Up @@ -319,3 +319,26 @@ def make_stripe_event(
stripe: stripeapi, payload: str, sig_header: str, endpoint_secret: str
):
return stripe.Webhook.construct_event(payload, sig_header, endpoint_secret)


@_as_safe_operation
def get_customer_portal_url(stripe: stripeapi, customer_id, return_url) -> str:
session = stripe.billing_portal.Session.create(
customer=customer_id, return_url=return_url
)
return session.url


@_as_safe_operation
def get_subscriptions_for_customer(stripe: stripeapi, customer_id: str):
"""Retrieve all subscriptions for a given customer."""
subscriptions = stripe.Subscription.list(customer=customer_id)
return [
{
"id": sub.id,
"start_date": sub.start_date,
"end_date": sub.ended_at,
"plan": {"id": sub.plan.id},
}
for sub in subscriptions.auto_paging_iter()
]
Loading
Loading