Skip to content

Commit

Permalink
User membership pages
Browse files Browse the repository at this point in the history
  • Loading branch information
rebkwok committed Jun 8, 2024
1 parent 03db4ad commit 4029a62
Show file tree
Hide file tree
Showing 9 changed files with 219 additions and 29 deletions.
2 changes: 1 addition & 1 deletion booking/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ def clean(self):

class ChooseMembershipForm(forms.Form):
membership = forms.ModelChoiceField(
queryset=Membership.objects.all(),
queryset=Membership.objects.filter(active=True),
widget=forms.RadioSelect,
)
agree_to_terms = forms.BooleanField(required=True, label="Please tick to confirm that you understand and agree that by setting up a membership, your payment details will be held by Stripe and collected on a recurring basis")
Expand Down
21 changes: 21 additions & 0 deletions booking/migrations/0098_alter_usermembership_user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 4.2.13 on 2024-06-08 08:28

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('booking', '0097_remove_usermembership_customer_id_booking_membership_and_more'),
]

operations = [
migrations.AlterField(
model_name='usermembership',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to=settings.AUTH_USER_MODEL),
),
]
43 changes: 32 additions & 11 deletions booking/models/membership_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from django.db import models
from django.utils.text import slugify
from django.utils import timezone

from booking.models import EventType
from stripe_payments.utils import StripeConnector
Expand Down Expand Up @@ -43,6 +44,7 @@ def generate_stripe_product_id(self):

def save(self, *args, **kwargs):
stripe_client = StripeConnector()
price_changed = False
if not self.id:
self.stripe_product_id = self.generate_stripe_product_id()
# create stripe product with price
Expand All @@ -62,6 +64,7 @@ def save(self, *args, **kwargs):
price_id = stripe_client.get_or_create_stripe_price(self.stripe_product_id, self.price)
self.stripe_price_id = price_id
changed = True
price_changed = True
if self.name != presaved.name or self.description != presaved.description or self.active != presaved.active:
changed = True
if changed:
Expand All @@ -76,6 +79,14 @@ def save(self, *args, **kwargs):
# beyond this month (with stripe_client.update_subscription_price())
super().save(*args, **kwargs)

if price_changed:
for user_membership in self.user_memberships.filter(end_date__isnull=True):
if user_membership.is_active():
stripe_client.update_subscription_price(
subscription_id=user_membership.subscription_id, new_price_id=self.stripe_price_id
)



class MembershipItem(models.Model):
"""
Expand Down Expand Up @@ -110,8 +121,13 @@ class UserMembership(models.Model):
A user can have at most 1 active UserMemberships for any one period
start date now or 1st of next month; pricing charges on 25th
Note that there may be multiple UserMemberships with the same subscription_id (
if membership plan changes)
A user can have more than one UserMembership, but only one that is current at any one time
There should only be one UserMembershp
subscription_id should be unique on UserMembership. If plan (membership) changes (on user request), the current
subscription is cancelled and a new subscription is created. Changes to the price of a membership will
result in a subscription update webhook event, but don't change anything on the UserMembership.
status refers to current status on Stripe of subscription
To verify if this membership is valid, check start/end dates as well as subscription status
Expand All @@ -126,25 +142,23 @@ class UserMembership(models.Model):
status = incomplete; once setup intent succeeds, updates to active
e.g. 3) User upgrades to Membership B
Creates/updates a schedule for this subscription ID
Set end date of this UserMembership to to 1st of next month (checks on class date will check it is < end date (exclusive))
Create new UserMembership with same subscripiton ID and user, for Membership B, start 1st next month
Move all bookings for next both from previous UserMembership to new one.
Cancel this subscription from end of current period, set end date to 1st of next month (checks on class date will check it is < end date (exclusive))
Create new UserMembership and a new stripe subscription (using the payment method from the previous one), for Membership B, start 1st next month
Move all bookings for next month from previous UserMembership to new one.
Check for unpaid bookings for next month and apply new UserMembership
e.g. 4) User downgrades to Membership C
Creates/updates a schedule for this subscription ID
Set end date of this UserMembership to 1st of next month
Create new UserMembership with same subscripiton ID and user, for Membership C, start 1st next month
Cancel this subscription from end of current period, set end date to 1st of next month (checks on class date will check it is < end date (exclusive))
Create new UserMembership and a new stripe subscription (using the payment method from the previous one), for Membership C, start 1st next month
Move all bookings for next both from previous UserMembership to new one where possible. Mark any that
can't be applied (due to reduced usage) to unpaid (ordered by event date).
e.g. 5) User cancels membership
Create schedule to run the rest of this month and then cancel subscription.
Cancel subscription from end of period.
Set end date of this UserMembership to to 1st of next month
(webhook will set status when it actually changes)
"""
membership = models.ForeignKey(Membership, on_delete=models.CASCADE)
membership = models.ForeignKey(Membership, related_name="user_memberships", on_delete=models.CASCADE)
user = models.ForeignKey("auth.User", related_name="memberships", on_delete=models.CASCADE)

# Membership dates. End date is None for ongoing memberships.
Expand All @@ -164,6 +178,10 @@ class UserMembership(models.Model):
"canceled": "Cancelled",
"unpaid": "Unpaid",
}

class Meta:
ordering = ("-start_date",)

def __str__(self) -> str:
return f"{self.user} - {self.membership.name}"

Expand All @@ -174,6 +192,9 @@ def is_active(self):
if self.subscription_status == "past_due":
# past_due is allowed until the 28th of the month (after which past due subscriptions get cancelled)
return datetime.now().day <= 28
# Subscription can be in cancelled state but still active until the end of the month
if self.subscription_status == "canceled" and self.end_date is not None and timezone.now() < self.end_date:
return True
return False

def valid_for_event(self, event):
Expand Down
126 changes: 126 additions & 0 deletions booking/tests/test_membership_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,132 @@ def test_membership_change_price(mocked_responses, seller):
assert membership.stripe_price_id == "price_2"


def test_membership_change_price_with_user_memberships(mocked_responses, seller):
# created initial product
mocked_responses.post(
"https://api.stripe.com/v1/products",
body=json.dumps(
{
"object": "product",
"url": "/v1/product",
"id": "memb-1",
"name": "membership 1",
"description": "a membership",
"default_price": "price_1"
}
),
status=200,
content_type="application/json",
)
# gets list of matching product prices
mocked_responses.get(
"https://api.stripe.com/v1/prices",
body=json.dumps(
{
"object": "list",
"url": "/v1/prices",
"data": [],
}
),
status=200,
content_type="application/json",
)
# create new price
mocked_responses.post(
"https://api.stripe.com/v1/prices",
body=json.dumps(
{
"object": "price",
"url": "/v1/prices",
"id": "price_2",
}
),
status=200,
content_type="application/json",
)
# update product to set price as default
mocked_responses.post(
"https://api.stripe.com/v1/products/memb-1",
body=json.dumps(
{
"object": "product",
"url": "/v1/product",
"id": "memb-1",
"name": "membership 1",
"description": "a membership",
"default_price": "price_2"
}
),
status=200,
content_type="application/json",
)

# update subscriptions to add schedule to change price
# get the subscription to check if it has a schedule
mocked_responses.get(
"https://api.stripe.com/v1/subscriptions/subsc-1",
body=json.dumps(
{
"object": "subscription",
"url": "/v1/subscription",
"id": "subsc-1",
"schedule": None,
}
),
status=200,
content_type="application/json",
)

# create the schedule
mocked_responses.post(
"https://api.stripe.com/v1/subscription_schedules",
body=json.dumps(
{
"object": "subscription_schedule",
"url": "/v1/subscription_schedules",
"id": "sub_sched-1",
"subscription": "subsc-1",
"end_behavior": "release",
"phases": [
{
"start_date": datetime(2024, 6, 25).timestamp(),
"end_date": datetime(2024, 7, 25).timestamp(),
"items": [{"price": 2000, "quantity": 1}]
}
]
}
),
status=200,
content_type="application/json",
)
# update the schedule
mocked_responses.post(
"https://api.stripe.com/v1/subscription_schedules/sub_sched-1",
body=json.dumps(
{
"object": "subscription_schedule",
"url": "/v1/subscription_schedules",
"id": "sub_sched-1",
"subscription": "subsc-1"
}
),
status=200,
content_type="application/json",
)


membership = baker.make(Membership, name="memb 1", description="a membership", price=10)
assert membership.stripe_product_id == "memb-1"
assert membership.stripe_price_id == "price_1"

baker.make(UserMembership, membership=membership, subscription_status="active", subscription_id="subsc-1")
membership.price = 20
membership.save()

assert membership.stripe_product_id == "memb-1"
assert membership.stripe_price_id == "price_2"


def test_membership_change_name(mocked_responses, seller):
# created initial product
mocked_responses.post(
Expand Down
20 changes: 16 additions & 4 deletions booking/views/membership_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ def membership_create(request):
has_membership = request.user.memberships.filter(subscription_status__in=["active", "past_due"]).exists()
form = ChooseMembershipForm()
# TODO add discount voucher option
return TemplateResponse(request, "booking/membership_create.html", {"form": form, "has_membership": has_membership})
return TemplateResponse(
request,
"booking/membership_create.html",
{"form": form, "has_membership": has_membership, "memberships": Membership.objects.filter(active=True)})


@require_http_methods(['POST'])
Expand Down Expand Up @@ -87,9 +90,9 @@ def stripe_subscription_checkout(request):
"stripe_account": client.connected_account_id,
"stripe_api_key": settings.STRIPE_PUBLISHABLE_KEY,
"stripe_return_url": request.build_absolute_uri(reverse("stripe_payments:stripe_subscribe_complete")),
"client_secret": None,
"backdate": None,
"amount": None,
"client_secret": "",
"backdate": 0,
"amount": "",
}
subscription_id = request.POST.get("subscription_id")
if subscription_id:
Expand Down Expand Up @@ -125,6 +128,7 @@ def stripe_subscription_checkout(request):
amount_to_charge_now = 0

context.update({
"creating": True,
"membership": membership,
"customer_id": customer_id,
"backdate": backdate,
Expand All @@ -138,6 +142,14 @@ def stripe_subscription_checkout(request):
@require_http_methods(['GET'])
def subscription_status(request, subscription_id):
user_membership = get_object_or_404(UserMembership, user=request.user, subscription_id=subscription_id)
client = StripeConnector()
subscription = client.get_subscription(subscription_id)
if subscription.status != user_membership.subscription_status:
user_membership.subscription_status = subscription.status
if subscription.cancel_at:
user_membership.end_date = datetime.fromtimestamp(subscription.cancel_at)
user_membership.save()

this_month = datetime.now().month
next_month = (this_month + 1 - 12) % 12

Expand Down
5 changes: 5 additions & 0 deletions templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -71,20 +71,25 @@
<a class="dropdown-item d-none d-sm-block" href="{% url 'booking:buy_gift_voucher' %}">Gift Vouchers</a>
<a class="dropdown-item d-none d-sm-block" href="{% url 'booking:ticketed_events' %}">Tickets</a>
<a class="dropdown-item d-none d-sm-block" href="{% url 'nonregistered_disclaimer_form' %}">Event Disclaimer</a>
{% if request.user.is_authenticated %}
<span role="separator" class="divider d-none d-sm-block"></span>
<a class="dropdown-item d-none d-sm-block" href="{% url 'booking:bookings' %}">My bookings</a>
<a class="dropdown-item d-none d-sm-block" href="{% url 'booking:purchased_tutorials' %}">My tutorials</a>
<a class="dropdown-item d-none d-sm-block" href="{% url 'membership_list' %}">My memberships</a>
<a class="dropdown-item d-none d-sm-blockk" href="{% url 'booking:block_list' %}">My blocks</a>
<a class="dropdown-item d-none d-sm-block" href="{% url 'booking:booking_history' %}">Booking history</a>
<a class="dropdown-item d-none d-sm-block" href="{% url 'booking:ticket_bookings' %}">My ticket bookings</a>
<a class="dropdown-item d-none d-sm-block" href="{% url 'booking:ticket_booking_history' %}">Ticket booking history</a>
{% endif %}
</li>

{% if request.user.is_authenticated %}
<li class="nav-item dropdown d-block d-md-none">
<a class="nav-link dropdown-toggle" href="#" id="navbarAccountDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">My Activity</a>
<div class="dropdown-menu" aria-labelledby="navbarAccountDropdown">
<a class="dropdown-item" href="{% url 'booking:bookings' %}">My bookings</a>
<a class="dropdown-item" href="{% url 'booking:purchased_tutorials' %}">My tutorials</a>
<a class="dropdown-item" href="{% url 'membership_list' %}">My memberships</a>
<a class="dropdown-item" href="{% url 'booking:block_list' %}">My blocks</a>
<a class="dropdown-item" href="{% url 'booking:booking_history' %}">Booking history</a>
<a class="dropdown-item" href="{% url 'booking:ticket_bookings' %}">My ticket bookings</a>
Expand Down
19 changes: 9 additions & 10 deletions templates/booking/membership_create.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,21 @@
<div class="card card-wm">
<div class="card-header">
<h2 class="card-title">Memberships</h2>
</div>
</div>
<div class="card-body">
<h4>Memerships available:</h4>
{% for membership in memberships %}
<dt>{{ membership.name }}</dt>
<dd>{{ membership.description }}<br/>
£{{ membership.price }}{% if show_vat %} (incl VAT){% endif %}
<dd>
{% endfor %}

{% if has_membership %}
<p>You already have an active membership. View <a href="{% url 'membership_list' %}">your memberships</a> to update or cancel.</p>
{% else %}
<p>Select a membership to add.</p>

<h4>Memerships available:</h4>
{% for membership in memberships %}
<p>{{ membership.name }}</p>
<p>{{ membership.description }}</p>
<ul>
<li>Cost: £{{ membership.price }}{% if show_vat %} (incl VAT){% endif %}</li>
</ul>
{% endfor %}

<form method="post" action="{% url 'membership_checkout' %}">
{% csrf_token %}
<div class="form-group">
Expand Down
Loading

0 comments on commit 4029a62

Please sign in to comment.