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. diff --git a/memberportal/api_admin_tools/migrations/0011_alter_paymentplan_interval.py b/memberportal/api_admin_tools/migrations/0011_alter_paymentplan_interval.py new file mode 100644 index 00000000..7a37a1bd --- /dev/null +++ b/memberportal/api_admin_tools/migrations/0011_alter_paymentplan_interval.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.25 on 2024-06-16 14:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("api_admin_tools", "0010_auto_20230729_2031"), + ] + + operations = [ + migrations.AlterField( + model_name="paymentplan", + name="interval", + field=models.CharField( + choices=[("Month", "month"), ("Week", "week"), ("Day", "day")], + max_length=10, + ), + ), + ] diff --git a/memberportal/api_admin_tools/models.py b/memberportal/api_admin_tools/models.py index 01e5d5d3..d47331df 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 = [] @@ -35,7 +35,7 @@ def get_object(self): class PaymentPlan(ExportModelOperationsMixin("payment-plan"), models.Model): """A Membership Plan that specifies how a member is billed for a member tier.""" - BILLING_PERIODS = [("Months", "month"), ("Weeks", "week"), ("Days", "days")] + BILLING_PERIODS = [("Month", "month"), ("Week", "week"), ("Day", "day")] id = models.AutoField(primary_key=True) name = models.CharField("Name", max_length=50) @@ -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 { diff --git a/memberportal/api_admin_tools/urls.py b/memberportal/api_admin_tools/urls.py index 9bdd7007..36e5ddff 100644 --- a/memberportal/api_admin_tools/urls.py +++ b/memberportal/api_admin_tools/urls.py @@ -63,25 +63,39 @@ views.MemberbucksDevices.as_view(), name="MemberbucksDevices", ), - path("api/admin/tiers/", views.MemberTiers.as_view(), name="ManageMemberTiers"), + 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", + ), + 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 4540542b..21fab742 100644 --- a/memberportal/api_admin_tools/views.py +++ b/memberportal/api_admin_tools/views.py @@ -1,29 +1,31 @@ +import json + +import stripe 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 +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,32 +589,42 @@ def put(self, request, member_id): return Response() -class MemberTiers(StripeAPIView): +class ManageMembershipTier(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 membership tier. + post: creates a new membership tier. + put: updates a membership tier. + delete: deletes a membership 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 = [] + + for tier in MemberTier.objects.all(): + formatted_tiers.append(self.get_tier(tier)) - return Response(formatted_tiers) + return Response(formatted_tiers) def post(self, request): body = request.data @@ -629,46 +641,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 +660,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) @@ -689,33 +669,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, + } - 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, - } - ) + 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) + + if tier_id: + try: + formatted_plans = [] - return Response(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) def post(self, request, tier_id=None): if tier_id is not None: @@ -735,7 +738,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"], @@ -746,34 +749,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 @@ -785,7 +761,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) @@ -825,6 +801,8 @@ def get(self, request, member_id): "cancelAt": s.cancel_at, "cancelAtPeriodEnd": s.cancel_at_period_end, "startDate": s.start_date, + "membershipTier": member.profile.membership_plan.member_tier.get_object(), + "membershipPlan": member.profile.membership_plan.get_object(), } else: billing_info["subscription"] = None @@ -912,3 +890,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/api_billing/views.py b/memberportal/api_billing/views.py index 435c7e03..b61c6326 100644 --- a/memberportal/api_billing/views.py +++ b/memberportal/api_billing/views.py @@ -1,4 +1,5 @@ from asgiref.sync import sync_to_async +from django.http import HttpRequest from profile.models import Profile from access.models import Doors, Interlock @@ -184,7 +185,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,105 +205,85 @@ 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): - current_plan = request.user.profile.membership_plan - new_plan = PaymentPlan.objects.get(pk=plan_id) + def create_subscription( + self, request: HttpRequest, new_plan: PaymentPlan, attempts: int = 0 + ): + attempts += 1 - if current_plan: - return Response({"success": False}, status=status.HTTP_409_CONFLICT) + if attempts > 3: + request.user.log_event( + "Too many attempts while creating subscription.", + "stripe", + "", + ) + return Response( + { + "success": False, + "message": None, + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) - def create_subscription(attempts=0): - attempts += 1 + try: + return stripe.Subscription.create( + customer=request.user.profile.stripe_customer_id, + items=[ + {"price": new_plan.stripe_id}, + ], + ) + + except stripe.error.InvalidRequestError as e: + capture_exception(e) + error = e.json_body.get("error") - if attempts > 3: + if ( + error["code"] == "resource_missing" + and "default payment method" in error["message"] + ): request.user.log_event( - "Too many attempts while creating subscription.", + "InvalidRequestError (missing default payment method) from Stripe while creating subscription.", "stripe", - "", + error, + ) + + # try to set the default and try again + stripe.Customer.modify( + request.user.profile.stripe_customer_id, + invoice_settings={ + "default_payment_method": request.user.profile.stripe_payment_method_id, + }, ) + + return self.create_subscription(attempts) + + if ( + error["code"] == "resource_missing" + and "a similar object exists in live mode" in error["message"] + ): + request.user.log_event( + "InvalidRequestError (used test key with production object) from Stripe while " + "creating subscription.", + "stripe", + error, + ) + return Response( { "success": False, - "message": None, + "message": error["message"], }, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) - try: - return stripe.Subscription.create( - customer=request.user.profile.stripe_customer_id, - items=[ - {"price": new_plan.stripe_id}, - ], - ) - - except stripe.error.InvalidRequestError as e: - capture_exception(e) - error = e.json_body.get("error") - - if ( - error["code"] == "resource_missing" - and "default payment method" in error["message"] - ): - request.user.log_event( - "InvalidRequestError (missing default payment method) from Stripe while creating subscription.", - "stripe", - error, - ) - - # try to set the default and try again - stripe.Customer.modify( - request.user.profile.stripe_customer_id, - invoice_settings={ - "default_payment_method": request.user.profile.stripe_payment_method_id, - }, - ) - - return create_subscription(attempts) - - if ( - error["code"] == "resource_missing" - and "a similar object exists in live mode" in error["message"] - ): - request.user.log_event( - "InvalidRequestError (used test key with production object) from Stripe while " - "creating subscription.", - "stripe", - error, - ) - - return Response( - { - "success": False, - "message": error["message"], - }, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - - else: - request.user.log_event( - "InvalidRequestError from Stripe while creating subscription.", - "stripe", - error, - ) - return Response( - { - "success": False, - "message": None, - }, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - - except Exception as e: + else: request.user.log_event( "InvalidRequestError from Stripe while creating subscription.", "stripe", - e, + error, ) - capture_exception(e) return Response( { "success": False, @@ -311,7 +292,29 @@ def create_subscription(attempts=0): status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) - new_subscription = create_subscription() + except Exception as e: + request.user.log_event( + "InvalidRequestError from Stripe while creating subscription.", + "stripe", + e, + ) + capture_exception(e) + return Response( + { + "success": False, + "message": None, + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + def post(self, request, plan_id): + current_plan = request.user.profile.membership_plan + new_plan = PaymentPlan.objects.get(pk=plan_id) + + if current_plan: + return Response({"success": False}, status=status.HTTP_409_CONFLICT) + + new_subscription = self.create_subscription(request, new_plan) try: if new_subscription.status == "active": @@ -487,7 +490,7 @@ class SubscriptionInfo(StripeAPIView): def get(self, request): current_plan = request.user.profile.membership_plan - if not current_plan: + if not current_plan or not request.user.profile.stripe_subscription_id: return Response({"success": False}) else: @@ -502,6 +505,8 @@ def get(self, request): "cancelAt": s.cancel_at, "cancelAtPeriodEnd": s.cancel_at_period_end, "startDate": s.start_date, + "membershipTier": request.user.profile.membership_plan.member_tier.get_object(), + "membershipPlan": request.user.profile.membership_plan.get_object(), } return Response({"success": True, "subscription": subscription}) @@ -510,7 +515,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): @@ -527,14 +532,62 @@ def post(self, request, resume): ) else: - # this will modify the subscription to automatically cancel at the end of the current payment period - if resume: + if resume and not request.user.profile.stripe_subscription_id: + request.user.log_event( + "Member tried to resume a payment plan that doesn't exist - creating it.", + "stripe", + ) + new_subscription = PaymentPlanSignup().create_subscription( + request, current_plan + ) + + try: + if new_subscription.status == "active": + request.user.profile.stripe_subscription_id = ( + new_subscription.id + ) + request.user.profile.subscription_status = "active" + request.user.profile.save() + + request.user.log_event( + "Successfully created subscription in Stripe.", + "stripe", + "", + ) + + return Response({"success": True}) + + elif new_subscription.status == "incomplete": + # if we got here, that means the subscription wasn't successfully created + request.user.log_event( + f"Failed to create subscription in Stripe with status {new_subscription.status}.", + "stripe", + "", + ) + + return Response( + {"success": True, "message": "signup.subscriptionFailed"} + ) + + else: + request.user.log_event( + f"Failed to create subscription in Stripe with status {new_subscription.status}.", + "stripe", + "", + ) + return Response({"success": True}) + + except KeyError as e: + capture_exception(e) + return new_subscription or e + + elif resume: modified_subscription = stripe.Subscription.modify( request.user.profile.stripe_subscription_id, cancel_at_period_end=False, ) - if modified_subscription.cancel_at_period_end == False: + if not modified_subscription.cancel_at_period_end: request.user.profile.subscription_status = "active" request.user.profile.save() subject = f"{request.user.get_full_name()} resumed their cancelling membership plan." diff --git a/memberportal/api_general/views.py b/memberportal/api_general/views.py index 39cbccbc..bba878b2 100644 --- a/memberportal/api_general/views.py +++ b/memberportal/api_general/views.py @@ -379,7 +379,7 @@ def get(self, request): "lastName": p.last_name, "screenName": p.screen_name, "phone": p.phone, - "memberStatus": p.get_state_display(), + "memberStatus": p.state, "vehicleRegistrationPlate": p.vehicle_registration_plate, "lastInduction": p.last_induction, "lastSeen": p.last_seen, 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/memberportal/profile/models.py b/memberportal/profile/models.py index fee4edb1..8b852ada 100644 --- a/memberportal/profile/models.py +++ b/memberportal/profile/models.py @@ -526,7 +526,7 @@ def get_basic_profile(self): "full": self.get_full_name(), }, "phone": self.phone, - "state": self.get_state_display(), + "state": self.state, "vehicleRegistrationPlate": self.vehicle_registration_plate, "rfid": self.rfid, "memberBucks": { diff --git a/src-frontend/src/boot/routeGuards.ts b/src-frontend/src/boot/routeGuards.ts index 35a75203..cc7d7c25 100644 --- a/src-frontend/src/boot/routeGuards.ts +++ b/src-frontend/src/boot/routeGuards.ts @@ -12,9 +12,10 @@ export default boot(({ router, store }) => { } if ( - store.getters['profile/profile']?.memberStatus === 'Needs Induction' && + store.getters['profile/profile']?.memberStatus === 'noob' && to.name !== 'membershipPlan' && to.name !== 'webcams' && + to.name !== 'billing' && store.getters['config/features']?.enableMembershipPayments && to.meta.admin !== true ) { @@ -51,7 +52,7 @@ export default boot(({ router, store }) => { if ( to.meta.memberOnly && to.name !== 'webcams' && - store.getters['profile/profile'].memberStatus !== 'Active' + store.getters['profile/profile'].memberStatus !== 'active' ) return next({ name: 'Error403MemberOnly' }); diff --git a/src-frontend/src/components/AccessList.vue b/src-frontend/src/components/AccessList.vue index f5867bed..5b32da17 100644 --- a/src-frontend/src/components/AccessList.vue +++ b/src-frontend/src/components/AccessList.vue @@ -14,7 +14,7 @@
- {{ $t('access.doors') }} + {{ $tc('access.door', 2) }}
@@ -65,7 +65,7 @@ - {{ $t('access.interlocks') }} + {{ $tc('access.interlock', 2) }} diff --git a/src-frontend/src/components/AdminTools/ManageMember.vue b/src-frontend/src/components/AdminTools/ManageMember.vue index 29a69084..e3db98c7 100644 --- a/src-frontend/src/components/AdminTools/ManageMember.vue +++ b/src-frontend/src/components/AdminTools/ManageMember.vue @@ -31,7 +31,7 @@ :class="{ 'q-px-sm': $q.screen.xs, 'q-px-lg': !$q.screen.xs }" > - {{ $t('adminTools.sendSms') }} + {{ $t('adminTools.sendSms') }} @@ -278,11 +277,18 @@ - {{ selectedMember.state }} + {{ + $t( + `adminTools.memberStatusString.${selectedMember.state}` + ) + }} @@ -412,7 +418,7 @@ @@ -878,23 +884,123 @@ -
-
+
+
{{ $t('adminTools.subscriptionInfo') }}
+ + + + + {{ $t(`adminTools.membershipTier`) }} + + + {{ $t(`adminTools.billingPlan`) }} + + + {{ $t(`adminTools.billingCycleAnchor`) }} + + {{ $t(`adminTools.startDate`) }} + + {{ $t(`adminTools.currentPeriodEnd`) }} + + + + + + + + {{ + billing.subscription.membershipTier.name + }} + + + {{ + $t('paymentPlans.intervalDescription', { + currency: + billing.subscription.membershipPlan.currency.toUpperCase(), + amount: $n( + billing.subscription.membershipPlan.cost / 100, + 'currency', + siteLocaleCurrency + ), + interval: $tc( + `paymentPlans.interval.${billing.subscription.membershipPlan.interval.toLowerCase()}`, + billing.subscription.membershipPlan.intervalAmount + ), + }) + }} + + + {{ formatDate(billing.subscription.billingCycleAnchor) }} + + + {{ formatDate(billing.subscription.startDate) }} + + + {{ formatDate(billing.subscription.currentPeriodEnd) }} + + + + + + - - {{ billing.subscription.status }} + + {{ + $t( + `adminTools.subscriptionStatusString.${billing.subscription.status}` + ) + }} {{ $t(`adminTools.subscriptionStatus`) }} @@ -935,7 +1041,7 @@ - + {{ formatDate(billing.subscription.cancelAt) }} @@ -946,7 +1052,7 @@ - + {{ billing.subscription.cancelAtPeriodEnd }} @@ -968,11 +1074,59 @@ {{ $t('adminTools.billingInfo') }}
+ + + + + {{ $t(`memberbucks.lastPurchase`) }} + + + {{ $t(`memberbucks.cardExpiry`) }} + + {{ $t(`memberbucks.last4`) }} + + + + + +
+ {{ this.formatWhen(billing?.memberbucks.lastPurchase) }} + + {{ + this.formatDate(billing?.memberbucks.lastPurchase) + }} + +
+
+ {{ $t('error.noValue') }} +
+ + + {{ + billing?.memberbucks.stripe_card_expiry || + $t('error.noValue') + }} + + + {{ + billing?.memberbucks.stripe_card_last_digits || + $t('error.noValue') + }} + + + +
+ @@ -1024,95 +1178,99 @@
-
- -
-
- {{ $t('adminTools.memberbucksTransactions') }} -
- - -