From 7eda7ab4d77f45e962856ce7f183557df9508194 Mon Sep 17 00:00:00 2001 From: Jaimyn Mayer Date: Sat, 15 Jun 2024 15:59:23 +1000 Subject: [PATCH 01/15] refactored/combined the two redundant ManageMemberTier views --- memberportal/api_admin_tools/urls.py | 2 +- memberportal/api_admin_tools/views.py | 86 ++++++++++----------------- 2 files changed, 33 insertions(+), 55 deletions(-) diff --git a/memberportal/api_admin_tools/urls.py b/memberportal/api_admin_tools/urls.py index 9bdd7007..d818ce1f 100644 --- a/memberportal/api_admin_tools/urls.py +++ b/memberportal/api_admin_tools/urls.py @@ -63,7 +63,7 @@ views.MemberbucksDevices.as_view(), name="MemberbucksDevices", ), - path("api/admin/tiers/", views.MemberTiers.as_view(), name="ManageMemberTiers"), + path("api/admin/tiers/", views.ManageMemberTier.as_view(), name="ManageMemberTier"), path( "api/admin/tiers//", views.ManageMemberTier.as_view(), diff --git a/memberportal/api_admin_tools/views.py b/memberportal/api_admin_tools/views.py index 4540542b..5ccdfe97 100644 --- a/memberportal/api_admin_tools/views.py +++ b/memberportal/api_admin_tools/views.py @@ -587,32 +587,42 @@ def put(self, request, member_id): return Response() -class MemberTiers(StripeAPIView): +class ManageMemberTier(StripeAPIView): """ - get: gets a list of all membership plans. - post: creates a new membership plan. - put: updates a new membership plan. - delete: a membership plan. + get: gets a member tier. + put: updates a member tier. + delete: deletes a member tier. """ permission_classes = (permissions.IsAdminUser,) - def get(self, request): - tiers = MemberTier.objects.all() - formatted_tiers = [] + def get_tier(self, tier: MemberTier): + return { + "id": tier.id, + "name": tier.name, + "description": tier.description, + "visible": tier.visible, + "featured": tier.featured, + "stripeId": tier.stripe_id, + } - for tier in tiers: - formatted_tiers.append( - { - "id": tier.id, - "name": tier.name, - "description": tier.description, - "visible": tier.visible, - "featured": tier.featured, - } - ) + def get(self, request, tier_id=None): + + if tier_id: + try: + tier = MemberTier.objects.get(pk=tier_id) + return Response(self.get_tier(tier)) + + except MemberTier.DoesNotExist as e: + return Response(status=status.HTTP_404_NOT_FOUND) + + else: + formatted_tiers = [] - return Response(formatted_tiers) + for tier in MemberTier.objects.all(): + formatted_tiers.append(self.get_tier(tier)) + + return Response(formatted_tiers) def post(self, request): body = request.data @@ -629,46 +639,14 @@ def post(self, request): stripe_id=product.id, ) - return Response() + return Response(self.get_tier(tier)) except stripe.error.AuthenticationError: return Response( {"success": False, "message": "error.stripeNotConfigured"}, - status=status.HTTP_502_BAD_GATEWAY, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) - def delete(self, request): - return Response() - - -class ManageMemberTier(StripeAPIView): - """ - get: gets a member tier. - put: updates a member tier. - delete: deletes a member tier. - """ - - permission_classes = (permissions.IsAdminUser,) - - def get(self, request, tier_id): - body = request.data - - try: - tier = MemberTier.objects.get(pk=tier_id) - - except MemberTier.DoesNotExist as e: - return Response(status=status.HTTP_404_NOT_FOUND) - - formatted_tier = { - "id": tier.id, - "name": tier.name, - "description": tier.description, - "visible": tier.visible, - "featured": tier.featured, - } - - return Response(formatted_tier) - def put(self, request, tier_id): body = request.data @@ -680,7 +658,7 @@ def put(self, request, tier_id): tier.featured = body["featured"] tier.save() - return Response() + return Response(self.get_tier(tier)) def delete(self, request, tier_id): tier = MemberTier.objects.get(pk=tier_id) From 8c188ba54e6901c72c0215d3afe4eca5903088d7 Mon Sep 17 00:00:00 2001 From: Jaimyn Mayer Date: Sat, 15 Jun 2024 17:54:43 +1000 Subject: [PATCH 02/15] made membership and payment plan names useful in admin interface --- memberportal/api_admin_tools/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/memberportal/api_admin_tools/models.py b/memberportal/api_admin_tools/models.py index 01e5d5d3..15df9b84 100644 --- a/memberportal/api_admin_tools/models.py +++ b/memberportal/api_admin_tools/models.py @@ -14,7 +14,7 @@ class MemberTier(ExportModelOperationsMixin("kiosk"), models.Model): featured = models.BooleanField("Is this plan featured?", default=False) def __str__(self): - return self.name + return f"{self.name}{' (hidden)' if not self.visible else ''}{' (featured)' if self.featured else ''} - Stripe ID: {self.stripe_id}" def get_object(self): plans = [] @@ -54,7 +54,7 @@ class PaymentPlan(ExportModelOperationsMixin("payment-plan"), models.Model): interval = models.CharField(choices=BILLING_PERIODS, max_length=10) def __str__(self): - return self.name + return f"{self.name} {self.member_tier.name}{' (hidden)' if not self.visible else ''} - Stripe ID: {self.stripe_id}" def get_object(self): return { From d98b91a776e450e2cbe9e820a469102a79f76aed Mon Sep 17 00:00:00 2001 From: Jaimyn Mayer Date: Sat, 15 Jun 2024 17:55:12 +1000 Subject: [PATCH 03/15] refactored and fixed payment plan api --- memberportal/api_admin_tools/urls.py | 22 ++-- memberportal/api_admin_tools/views.py | 141 +++++++++++++------------- memberportal/api_billing/views.py | 6 +- 3 files changed, 85 insertions(+), 84 deletions(-) diff --git a/memberportal/api_admin_tools/urls.py b/memberportal/api_admin_tools/urls.py index d818ce1f..c1fb0e8e 100644 --- a/memberportal/api_admin_tools/urls.py +++ b/memberportal/api_admin_tools/urls.py @@ -63,25 +63,29 @@ views.MemberbucksDevices.as_view(), name="MemberbucksDevices", ), - path("api/admin/tiers/", views.ManageMemberTier.as_view(), name="ManageMemberTier"), + path( + "api/admin/tiers/", + views.ManageMembershipTier.as_view(), + name="ManageMembershipTier", + ), path( "api/admin/tiers//", - views.ManageMemberTier.as_view(), - name="ManageMemberTier", + views.ManageMembershipTier.as_view(), + name="ManageMembershipTier", ), path( "api/admin/tiers//plans/", - views.ManageMemberTierPlans.as_view(), - name="GetPlans", + views.ManageMembershipTierPlan.as_view(), + name="ManageMembershipTierPlan", ), path( "api/admin/plans/", - views.ManageMemberTierPlans.as_view(), - name="ManagePlans", + views.ManageMembershipTierPlan.as_view(), + name="ManageMembershipTierPlan", ), path( "api/admin/plans//", - views.ManageMemberTierPlans.as_view(), - name="ManagePlan", + views.ManageMembershipTierPlan.as_view(), + name="ManageMembershipTierPlan", ), ] diff --git a/memberportal/api_admin_tools/views.py b/memberportal/api_admin_tools/views.py index 5ccdfe97..20f85bb0 100644 --- a/memberportal/api_admin_tools/views.py +++ b/memberportal/api_admin_tools/views.py @@ -1,29 +1,30 @@ +import json + +import stripe from asgiref.sync import async_to_sync from channels.layers import get_channel_layer +from constance import config +from django.db.models import F, Sum, Value, CharField, Count, Max +from django.db.models.functions import Concat +from django.db.utils import OperationalError +from rest_framework import permissions +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework_api_key.permissions import HasAPIKey +from sentry_sdk import capture_exception +from sentry_sdk import capture_message -from profile.models import User, UserEventLog -from access.models import DoorLog, InterlockLog from access import models -from .models import MemberTier, PaymentPlan +from access.models import DoorLog, InterlockLog from memberbucks.models import ( MemberBucks, MemberbucksProductPurchaseLog, ) -from constance import config -from services.emails import send_email_to_admin +from profile.models import User, UserEventLog from services import sms -import json -import stripe -from sentry_sdk import capture_message -from rest_framework_api_key.permissions import HasAPIKey -from rest_framework import permissions -from rest_framework.response import Response -from rest_framework import status -from rest_framework.views import APIView -from django.db.models import F, Count, Sum, Value, CharField, Count, Max -from django.db.models.functions import Concat -from django.db.utils import OperationalError -from sentry_sdk import capture_exception +from services.emails import send_email_to_admin +from .models import MemberTier, PaymentPlan class StripeAPIView(APIView): @@ -587,11 +588,12 @@ def put(self, request, member_id): return Response() -class ManageMemberTier(StripeAPIView): +class ManageMembershipTier(StripeAPIView): """ - get: gets a member tier. - put: updates a member tier. - delete: deletes a member tier. + get: gets a membership tier. + post: creates a new membership tier. + put: updates a membership tier. + delete: deletes a membership tier. """ permission_classes = (permissions.IsAdminUser,) @@ -607,7 +609,6 @@ def get_tier(self, tier: MemberTier): } def get(self, request, tier_id=None): - if tier_id: try: tier = MemberTier.objects.get(pk=tier_id) @@ -667,33 +668,56 @@ def delete(self, request, tier_id): return Response() -class ManageMemberTierPlans(StripeAPIView): +class ManageMembershipTierPlan(StripeAPIView): """ - post: creates a new member tier plan. + get: gets an individual or a list of payment plans. + post: creates a new payment plan. + """ permission_classes = (permissions.IsAdminUser,) - def get(self, request, tier_id): - plans = PaymentPlan.objects.filter(member_tier=tier_id) - formatted_plans = [] + def get_plan(self, plan: PaymentPlan): + return { + "id": plan.id, + "name": plan.name, + "stripeId": plan.stripe_id, + "memberTier": plan.member_tier.id, + "visible": plan.visible, + "currency": plan.currency, + "cost": plan.cost / 100, # convert to dollars + "intervalCount": plan.interval_count, + "interval": plan.interval, + } + + def get(self, request, plan_id=None, tier_id=None): + if plan_id: + try: + plan = PaymentPlan.objects.get(pk=plan_id) + return Response(self.get_plan(plan)) + + except PaymentPlan.DoesNotExist as e: + return Response(status=status.HTTP_404_NOT_FOUND) - for plan in plans: - formatted_plans.append( - { - "id": plan.id, - "name": plan.name, - "stripeId": plan.stripe_id, - "memberTier": plan.member_tier.id, - "visible": plan.visible, - "currency": plan.currency, - "cost": plan.cost / 100, # convert to dollars - "intervalCount": plan.interval_count, - "interval": plan.interval, - } - ) + if tier_id: + try: + formatted_plans = [] + + for plan in PaymentPlan.objects.filter(member_tier=tier_id): + formatted_plans.append(self.get_plan(plan)) + + return Response(formatted_plans) + + except PaymentPlan.DoesNotExist as e: + return Response(status=status.HTTP_404_NOT_FOUND) + + else: + formatted_plans = [] + + for plan in PaymentPlan.objects.all(): + formatted_plans.append(self.get_plan(plan)) - return Response(formatted_plans) + return Response(formatted_plans) def post(self, request, tier_id=None): if tier_id is not None: @@ -713,7 +737,7 @@ def post(self, request, tier_id=None): product=member_tier.stripe_id, ) - PaymentPlan.objects.create( + plan = PaymentPlan.objects.create( name=body["name"], stripe_id=stripe_plan.id, member_tier_id=body["memberTier"], @@ -724,34 +748,7 @@ def post(self, request, tier_id=None): interval=body["interval"], ) - return Response() - - -class ManageMemberTierPlan(StripeAPIView): - """ - get: gets a member tier plan. - put: updates a member tier plan. - delete: deletes a member tier plan. - """ - - permission_classes = (permissions.IsAdminUser,) - - def get(self, request, plan_id): - body = request.data - - plan = PaymentPlan.objects.get(pk=plan_id) - - formatted_plan = { - "id": plan.id, - "name": plan.name, - "member_tier": plan.member_tier, - "visible": plan.visible, - "cost": plan.cost, - "interval_count": plan.interval_count, - "interval": plan.interval, - } - - return Response(formatted_plan) + return Response(self.get_plan(plan)) def put(self, request, plan_id): body = request.data @@ -763,7 +760,7 @@ def put(self, request, plan_id): plan.cost = body["cost"] plan.save() - return Response() + return Response(self.get_plan(plan)) def delete(self, request, plan_id): plan = PaymentPlan.objects.get(pk=plan_id) diff --git a/memberportal/api_billing/views.py b/memberportal/api_billing/views.py index 435c7e03..35f9bc7e 100644 --- a/memberportal/api_billing/views.py +++ b/memberportal/api_billing/views.py @@ -184,7 +184,7 @@ def delete(self, request): class MemberTiers(StripeAPIView): """ - get: gets a list of all membership plans. + get: gets a list of all membership tiers. """ def get(self, request): @@ -204,7 +204,7 @@ def get(self, request): class PaymentPlanSignup(StripeAPIView): """ - post: attempts to sign the member up to a new membership plan. + post: attempts to sign the member up to a new payment plan. """ def post(self, request, plan_id): @@ -510,7 +510,7 @@ def get(self, request): class PaymentPlanResumeCancel(StripeAPIView): """ - post: attempts to cancel a member's membership plan. + post: attempts to cancel a member's payment plan. """ def post(self, request, resume): From 8cd5225b97d702dd7a6f69e75075f0009eacbfd5 Mon Sep 17 00:00:00 2001 From: Jaimyn Mayer Date: Sat, 15 Jun 2024 18:01:34 +1000 Subject: [PATCH 04/15] fixed can't submit more than one create payment plan forms --- src-frontend/src/components/AdminTools/ManageTier.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-frontend/src/components/AdminTools/ManageTier.vue b/src-frontend/src/components/AdminTools/ManageTier.vue index 4de0062d..7e6acf15 100644 --- a/src-frontend/src/components/AdminTools/ManageTier.vue +++ b/src-frontend/src/components/AdminTools/ManageTier.vue @@ -445,7 +445,7 @@ export default defineComponent({ error: false, success: false, name: '', - memberTier: '', + memberTier: this.$route.params.planId.toString(), stripeId: '', visible: true, currency: 'aud', From e19283f52e32e2ed2733913f4af84258c0151f8d Mon Sep 17 00:00:00 2001 From: Jaimyn Mayer Date: Sun, 16 Jun 2024 21:34:00 +1000 Subject: [PATCH 05/15] Added settings API --- memberportal/api_admin_tools/urls.py | 10 ++++ memberportal/api_admin_tools/views.py | 49 +++++++++++++++++++ .../membermatters/constance_config.py | 2 +- .../src/components/AdminTools/ManageTier.vue | 3 +- 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/memberportal/api_admin_tools/urls.py b/memberportal/api_admin_tools/urls.py index c1fb0e8e..36e5ddff 100644 --- a/memberportal/api_admin_tools/urls.py +++ b/memberportal/api_admin_tools/urls.py @@ -88,4 +88,14 @@ views.ManageMembershipTierPlan.as_view(), name="ManageMembershipTierPlan", ), + path( + "api/admin/settings/", + views.ManageSettings.as_view(), + name="ManageSettings", + ), + path( + "api/admin/settings//", + views.ManageSettings.as_view(), + name="ManageSettings", + ), ] diff --git a/memberportal/api_admin_tools/views.py b/memberportal/api_admin_tools/views.py index 20f85bb0..56ba5a91 100644 --- a/memberportal/api_admin_tools/views.py +++ b/memberportal/api_admin_tools/views.py @@ -4,6 +4,7 @@ from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from constance import config +from constance.backends.database.models import Constance as ConstanceSetting from django.db.models import F, Sum, Value, CharField, Count, Max from django.db.models.functions import Concat from django.db.utils import OperationalError @@ -887,3 +888,51 @@ def get(self, request, member_id): } return Response(logs) + + +class ManageSettings(APIView): + """ + get: This method gets a constance setting value or values. + put: This method updates a constance setting value. + """ + + permission_classes = (permissions.IsAdminUser,) + + def get_setting(self, setting): + return { + "key": setting.key, + "value": setting.value, + } + + def get(self, request, setting_key=None): + if setting_key: + try: + setting = ConstanceSetting.objects.get(key=setting_key) + return Response(self.get_setting(setting)) + + except ConstanceSetting.DoesNotExist as e: + return Response(status=status.HTTP_404_NOT_FOUND) + + else: + settings = [] + + for setting in ConstanceSetting.objects.all(): + settings.append(self.get_setting(setting)) + + return Response(settings) + + def put(self, request, setting_key=None): + if not setting_key: + return Response(status=status.HTTP_400_BAD_REQUEST) + + body = request.data + + try: + setting = ConstanceSetting.objects.get(key=setting_key) + setting.value = body["value"] + setting.save() + + return Response(self.get_setting(setting)) + + except ConstanceSetting.DoesNotExist as e: + return Response(status=status.HTTP_404_NOT_FOUND) diff --git a/memberportal/membermatters/constance_config.py b/memberportal/membermatters/constance_config.py index ec22c9ac..407104dc 100644 --- a/memberportal/membermatters/constance_config.py +++ b/memberportal/membermatters/constance_config.py @@ -323,7 +323,7 @@ "The sender ID (either a phone number or alpha numeric sender ID you can send from).", ), "SMS_FOOTER": ( - "From Brisbane Makerspace.", + "From Example Makerspace.", "An optional footer to append to all SMS messages (such as 'from xyz org.'", ), "SMS_MESSAGES": ( diff --git a/src-frontend/src/components/AdminTools/ManageTier.vue b/src-frontend/src/components/AdminTools/ManageTier.vue index 7e6acf15..1de030c4 100644 --- a/src-frontend/src/components/AdminTools/ManageTier.vue +++ b/src-frontend/src/components/AdminTools/ManageTier.vue @@ -426,7 +426,8 @@ export default defineComponent({ }, // eslint-disable-next-line @typescript-eslint/no-explicit-any managePlan(evt: InputEvent, row: any) { - this.$router.push({ name: 'managePlan', params: { planId: row.id } }); + // TODO: make a MM UI to manage payment plans + // this.$router.push({ name: 'managePlan', params: { planId: row.id } }); }, resetForm() { this.form = { From 94fae1a805ab23669f69db3cccf4119b4e625940 Mon Sep 17 00:00:00 2001 From: Jaimyn Mayer Date: Sun, 16 Jun 2024 21:34:13 +1000 Subject: [PATCH 06/15] Add docs about local Stripe webhooks --- memberportal/README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/memberportal/README.md b/memberportal/README.md index 4c7d31af..f333194c 100644 --- a/memberportal/README.md +++ b/memberportal/README.md @@ -111,6 +111,28 @@ Starting development server at http://127.0.0.1:8000/ Now that the backend API is running, you can head over to the [frontend](/frontend) folder and follow those instructions to get the frontend UI running. +## Stripe Webhooks +If you want to test Stripe webhooks you can use the stripe CLI to forward webhooks to your local dev server. +To do so, you will need to install the stripe CLI and login to your stripe account. +Click [here](https://dashboard.stripe.com/test/webhooks/create?endpoint_location=local) for detailed instructions from +Stripe. + +Once you're set up, run the following command to forward webhooks to your local dev server: + +```bash +stripe listen --skip-verify --events invoice.paid,invoice.payment_failed,customer.subscription.deleted --forward-to localhost:8080/api/billing/stripe-webhook/ +``` + +Finally, check that you're running the frontend proxy on port 8080 and configure the signing secret in the Constance +settings. +You can find the local signing secret in the command line output after running `stripe listen` above. +It will start like this `whsec_...` + +You can also trigger common events like `invoice.paid` using the CLI like this: +```bash +stripe trigger invoice.paid +``` + ## Linter As explained below, this projects uses a linter (called "Black") to fix common errors, and to enforce consistent code style/standards. From 6f80f54d29d5ad2c208fe6e757d22cc9244fcd1d Mon Sep 17 00:00:00 2001 From: Jaimyn Mayer Date: Sun, 16 Jun 2024 22:33:04 +1000 Subject: [PATCH 07/15] fixed credit card component min width --- src-frontend/src/components/CreditCard.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-frontend/src/components/CreditCard.vue b/src-frontend/src/components/CreditCard.vue index 2cfbe1d0..f79670e4 100644 --- a/src-frontend/src/components/CreditCard.vue +++ b/src-frontend/src/components/CreditCard.vue @@ -1,7 +1,7 @@