From a64fda16b572daa46290e15c8afe59330435f3e5 Mon Sep 17 00:00:00 2001 From: Rechner Fox <659028+rechner@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:17:59 -0700 Subject: [PATCH 1/5] feat(billing): Add payment plan switching --- memberportal/api_billing/tests/__init__.py | 0 .../api_billing/tests/test_plan_switch.py | 177 +++++++++++++++ memberportal/api_billing/urls.py | 5 + memberportal/api_billing/views.py | 203 ++++++++++++++++++ .../src/components/Billing/PlanCard.vue | 6 + src-frontend/src/i18n/en-AU/index.ts | 27 +++ src-frontend/src/pages/MembershipPlan.vue | 117 ++++++++++ 7 files changed, 535 insertions(+) create mode 100644 memberportal/api_billing/tests/__init__.py create mode 100644 memberportal/api_billing/tests/test_plan_switch.py diff --git a/memberportal/api_billing/tests/__init__.py b/memberportal/api_billing/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/memberportal/api_billing/tests/test_plan_switch.py b/memberportal/api_billing/tests/test_plan_switch.py new file mode 100644 index 00000000..3a5ef320 --- /dev/null +++ b/memberportal/api_billing/tests/test_plan_switch.py @@ -0,0 +1,177 @@ +from types import SimpleNamespace +from unittest.mock import patch + +import stripe +from django.test import TestCase +from rest_framework.test import APIClient + +from api_admin_tools.models import MemberTier, PaymentPlan +from profile.models import Profile, User + + +class PaymentPlanSwitchTests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + "plan-switch@example.test", + password="test-password", + ) + self.tier = MemberTier.objects.create( + name="Standard", + description="Standard membership", + stripe_id="prod_standard", + ) + self.current_plan = self.create_plan( + name="Standard Monthly", + stripe_id="price_standard_monthly", + ) + self.target_plan = self.create_plan( + name="Premium Monthly", + stripe_id="price_premium_monthly", + ) + self.profile = Profile.objects.create( + user=self.user, + first_name="Plan", + last_name="Switcher", + state="active", + membership_plan=self.current_plan, + stripe_customer_id="cus_plan_switch", + stripe_subscription_id="sub_plan_switch", + subscription_status="active", + billing_method="card", + ) + self.client.force_authenticate(self.user) + + def create_plan(self, name, stripe_id, **kwargs): + return PaymentPlan.objects.create( + name=name, + description=name, + stripe_id=stripe_id, + member_tier=self.tier, + currency="aud", + cost=2000, + interval_count=1, + interval="month", + **kwargs, + ) + + @staticmethod + def subscription(price_id, status="active"): + return SimpleNamespace( + id="sub_plan_switch", + status=status, + items=SimpleNamespace( + data=[ + SimpleNamespace( + id="si_plan_switch", + price=SimpleNamespace(id=price_id), + ) + ] + ), + ) + + def switch(self, **headers): + return self.client.post( + "/api/billing/myplan/switch/", + {"planId": self.target_plan.pk}, + format="json", + **headers, + ) + + @patch("api_billing.views.stripe.Subscription.modify") + @patch("api_billing.views.stripe.Subscription.retrieve") + def test_switches_compatible_plan_on_existing_subscription(self, retrieve, modify): + current_subscription = self.subscription(self.current_plan.stripe_id) + updated_subscription = self.subscription(self.target_plan.stripe_id) + retrieve.return_value = current_subscription + modify.return_value = updated_subscription + + response = self.switch(HTTP_IDEMPOTENCY_KEY="switch-test-key") + + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data["success"]) + self.profile.refresh_from_db() + self.assertEqual(self.profile.membership_plan_id, self.target_plan.pk) + self.assertEqual(self.profile.stripe_subscription_id, "sub_plan_switch") + self.assertEqual(self.profile.billing_method, "card") + retrieve.assert_called_once_with("sub_plan_switch") + modify.assert_called_once_with( + "sub_plan_switch", + items=[{"id": "si_plan_switch", "price": self.target_plan.stripe_id}], + proration_behavior="create_prorations", + idempotency_key=f"plan-switch-{self.profile.pk}-switch-test-key", + ) + + @patch("api_billing.views.stripe.Subscription.modify") + @patch("api_billing.views.stripe.Subscription.retrieve") + def test_rejects_interval_or_currency_mismatch_without_stripe_call( + self, retrieve, modify + ): + for field, value in ( + ("interval", "year"), + ("interval_count", 3), + ("currency", "usd"), + ): + with self.subTest(field=field): + setattr(self.target_plan, field, value) + self.target_plan.save(update_fields=[field]) + + response = self.switch() + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data["message"], "billing.planSwitchIntervalMismatch" + ) + retrieve.assert_not_called() + modify.assert_not_called() + + setattr(self.target_plan, field, getattr(self.current_plan, field)) + self.target_plan.save(update_fields=[field]) + + def test_rejects_non_active_subscription_states_without_stripe_call(self): + for state in ("pending", "cancelling"): + with self.subTest(state=state): + self.profile.subscription_status = state + self.profile.save(update_fields=["subscription_status"]) + + with patch( + "api_billing.views.stripe.Subscription.retrieve" + ) as retrieve: + response = self.switch() + + self.assertEqual(response.status_code, 409) + self.assertEqual( + response.data["message"], "billing.planSwitchActiveOnly" + ) + retrieve.assert_not_called() + + self.profile.subscription_status = "active" + self.profile.save(update_fields=["subscription_status"]) + + @patch("api_billing.views.stripe.Subscription.modify") + @patch("api_billing.views.stripe.Subscription.retrieve") + def test_stripe_failure_does_not_change_local_plan(self, retrieve, modify): + retrieve.return_value = self.subscription(self.current_plan.stripe_id) + modify.side_effect = stripe.error.StripeError("plan switch failed") + + response = self.switch() + + self.assertEqual(response.status_code, 503) + self.assertEqual(response.data["message"], "billing.stripeError") + self.profile.refresh_from_db() + self.assertEqual(self.profile.membership_plan_id, self.current_plan.pk) + + @patch("api_billing.views.stripe.Subscription.retrieve") + def test_rejects_subscription_that_is_not_active_in_stripe(self, retrieve): + retrieve.return_value = self.subscription( + self.current_plan.stripe_id, status="past_due" + ) + + response = self.switch() + + self.assertEqual(response.status_code, 409) + self.assertEqual( + response.data["message"], "billing.planSwitchSubscriptionInactive" + ) + self.profile.refresh_from_db() + self.assertEqual(self.profile.membership_plan_id, self.current_plan.pk) diff --git a/memberportal/api_billing/urls.py b/memberportal/api_billing/urls.py index 054e7739..f3ca6de5 100644 --- a/memberportal/api_billing/urls.py +++ b/memberportal/api_billing/urls.py @@ -17,6 +17,11 @@ views.PaymentPlanSignup.as_view(), name="PaymentPlanSignup", ), + path( + "api/billing/myplan/switch/", + views.PaymentPlanSwitch.as_view(), + name="PaymentPlanSwitch", + ), path( "api/billing/myplan/resume/", views.PaymentPlanResume.as_view(), diff --git a/memberportal/api_billing/views.py b/memberportal/api_billing/views.py index 5b4950d7..8e167902 100644 --- a/memberportal/api_billing/views.py +++ b/memberportal/api_billing/views.py @@ -781,6 +781,209 @@ def get(self, request): return Response({"success": False}) +class PaymentPlanSwitch(StripeAPIView): + """Switches an active member to another compatible payment plan.""" + + PRORATION_BEHAVIOR = "create_prorations" + + @staticmethod + def _stripe_value(resource, name, default=None): + if isinstance(resource, dict): + return resource.get(name, default) + return getattr(resource, name, default) + + @classmethod + def _subscription_items(cls, subscription): + items = cls._stripe_value(subscription, "items") + return cls._stripe_value(items, "data", []) or [] + + @classmethod + def _price_id(cls, item): + price = cls._stripe_value(item, "price") + if isinstance(price, str): + return price + return cls._stripe_value(price, "id") + + def post(self, request): + plan_id = request.data.get("planId") + if not plan_id: + return Response( + {"success": False, "message": "billing.planSwitchPlanRequired"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + target_plan = get_object_or_404(PaymentPlan, pk=plan_id, visible=True) + + with transaction.atomic(): + locked_profile = Profile.objects.select_for_update().get( + pk=request.user.profile.pk + ) + + if locked_profile.state_locked: + return Response( + {"success": False, "message": "billing.stateLocked"}, + status=status.HTTP_403_FORBIDDEN, + ) + + if ( + locked_profile.state != "active" + or locked_profile.subscription_status != "active" + or not locked_profile.membership_plan + or not locked_profile.stripe_subscription_id + ): + return Response( + {"success": False, "message": "billing.planSwitchActiveOnly"}, + status=status.HTTP_409_CONFLICT, + ) + + current_plan = locked_profile.membership_plan + if current_plan.pk == target_plan.pk: + return Response( + {"success": False, "message": "billing.planSwitchSamePlan"}, + status=status.HTTP_409_CONFLICT, + ) + + current_signature = ( + current_plan.interval, + current_plan.interval_count, + current_plan.currency.lower(), + ) + target_signature = ( + target_plan.interval, + target_plan.interval_count, + target_plan.currency.lower(), + ) + if current_signature != target_signature: + return Response( + { + "success": False, + "message": "billing.planSwitchIntervalMismatch", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + subscription = stripe.Subscription.retrieve( + locked_profile.stripe_subscription_id + ) + except stripe.error.StripeError as error: + capture_exception(error) + request.user.log_event( + "Stripe error while retrieving subscription for plan switch.", + "stripe", + str(error), + ) + return Response( + {"success": False, "message": "billing.stripeError"}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + if self._stripe_value(subscription, "status") != "active": + return Response( + { + "success": False, + "message": "billing.planSwitchSubscriptionInactive", + }, + status=status.HTTP_409_CONFLICT, + ) + + subscription_items = self._subscription_items(subscription) + if len(subscription_items) != 1: + request.user.log_event( + "Cannot switch a membership subscription with an unexpected item count.", + "stripe", + {"subscription": locked_profile.stripe_subscription_id}, + ) + return Response( + { + "success": False, + "message": "billing.planSwitchSubscriptionInvalid", + }, + status=status.HTTP_409_CONFLICT, + ) + + subscription_item = subscription_items[0] + subscription_item_id = self._stripe_value(subscription_item, "id") + if not subscription_item_id: + return Response( + { + "success": False, + "message": "billing.planSwitchSubscriptionInvalid", + }, + status=status.HTTP_409_CONFLICT, + ) + + if self._price_id(subscription_item) != current_plan.stripe_id: + request.user.log_event( + "Local membership plan does not match Stripe during plan switch.", + "stripe", + { + "subscription": locked_profile.stripe_subscription_id, + "local_price": current_plan.stripe_id, + "stripe_price": self._price_id(subscription_item), + }, + ) + return Response( + {"success": False, "message": "billing.planSwitchOutOfSync"}, + status=status.HTTP_409_CONFLICT, + ) + + idempotency_token = ( + request.headers.get("Idempotency-Key") or uuid.uuid4().hex + ) + try: + modified_subscription = stripe.Subscription.modify( + locked_profile.stripe_subscription_id, + items=[ + { + "id": subscription_item_id, + "price": target_plan.stripe_id, + } + ], + proration_behavior=self.PRORATION_BEHAVIOR, + idempotency_key=( + f"plan-switch-{locked_profile.pk}-{idempotency_token[:200]}" + ), + ) + except stripe.error.StripeError as error: + capture_exception(error) + request.user.log_event( + "Stripe error while switching membership plan.", + "stripe", + str(error), + ) + return Response( + {"success": False, "message": "billing.stripeError"}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + modified_items = self._subscription_items(modified_subscription) + if ( + self._stripe_value(modified_subscription, "status") != "active" + or len(modified_items) != 1 + or self._price_id(modified_items[0]) != target_plan.stripe_id + ): + request.user.log_event( + "Stripe did not confirm the requested membership plan switch.", + "stripe", + {"subscription": locked_profile.stripe_subscription_id}, + ) + return Response( + {"success": False, "message": "billing.planSwitchUnconfirmed"}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + locked_profile.membership_plan = target_plan + locked_profile.save(update_fields=["membership_plan"]) + request.user.log_event( + "Successfully switched membership payment plan.", + "stripe", + {"plan_id": target_plan.pk}, + ) + + return Response({"success": True, "plan": target_plan.get_object()}) + + def _no_plan_response(user): user.log_event("Member tried to modify nonexistant membership plan.", "stripe") return Response( diff --git a/src-frontend/src/components/Billing/PlanCard.vue b/src-frontend/src/components/Billing/PlanCard.vue index fa682191..a891580c 100644 --- a/src-frontend/src/components/Billing/PlanCard.vue +++ b/src-frontend/src/components/Billing/PlanCard.vue @@ -30,6 +30,7 @@
@@ -57,6 +58,11 @@ export default defineComponent({ required: false, default: false, }, + disabled: { + type: Boolean, + required: false, + default: false, + }, }, methods: { selectPlan() { diff --git a/src-frontend/src/i18n/en-AU/index.ts b/src-frontend/src/i18n/en-AU/index.ts index 96e9c1f8..c7d6672e 100644 --- a/src-frontend/src/i18n/en-AU/index.ts +++ b/src-frontend/src/i18n/en-AU/index.ts @@ -663,6 +663,19 @@ export default { paymentMethodInvoice: 'Manual Renewal', subscriptionInfo: 'Subscription Info', subscriptionUnavailable: 'Could not load subscription details right now.', + switchTitle: 'Change payment plan', + switchDescription: + 'Choose another plan with the same billing interval. Your current billing method will stay the same.', + switchConfirmTitle: 'Change payment plan?', + switchConfirmDescription: + 'Your plan will change to {plan} now. Any price difference will be included on your next invoice.', + switchSuccess: 'Payment plan updated', + switchSuccessDescription: + 'Your payment plan has been changed successfully.', + switchFailed: 'Payment plan change failed', + switchUnavailable: 'Could not load alternative payment plans right now.', + switchNoOptions: + 'There are no other payment plans with the same billing interval.', accountOnlyWarning: "Your profile is currently set to 'account only'. This is because you skipped this process last time. You're welcome to continue using this account for our online services, or you can signup to become a member below. ", profileAccountOnlyWarning: @@ -878,6 +891,20 @@ export default { 'New membership subscriptions are currently closed.', stripeError: "Something went wrong talking to our payment provider. Please try again in a moment, or contact us if it doesn't clear up.", + planSwitchPlanRequired: 'Please choose a payment plan.', + planSwitchActiveOnly: + 'Payment plans can only be changed for an active membership.', + planSwitchSamePlan: 'You are already using that payment plan.', + planSwitchIntervalMismatch: + 'You can only switch to a payment plan with the same billing interval and currency.', + planSwitchSubscriptionInactive: + 'Your Stripe subscription is not active, so the payment plan cannot be changed.', + planSwitchSubscriptionInvalid: + 'Your subscription could not be changed because its billing configuration is invalid.', + planSwitchOutOfSync: + 'Your payment plan is out of sync with Stripe. Please contact us for help.', + planSwitchUnconfirmed: + 'Stripe did not confirm the payment plan change. Please contact us for help.', }, pendingInvoices: { title: 'Pending Invoices', diff --git a/src-frontend/src/pages/MembershipPlan.vue b/src-frontend/src/pages/MembershipPlan.vue index 0ca3d203..76b485fe 100644 --- a/src-frontend/src/pages/MembershipPlan.vue +++ b/src-frontend/src/pages/MembershipPlan.vue @@ -38,6 +38,41 @@ + +
{{ $t('paymentPlans.switchSuccess') }}
+

{{ $t('paymentPlans.switchSuccessDescription') }}

+
+ +
+
+ {{ $t('paymentPlans.switchTitle') }} +
+

{{ $t('paymentPlans.switchDescription') }}

+ +
+ +
+ + {{ $t('paymentPlans.switchUnavailable') }} + +
+ {{ $t('paymentPlans.switchNoOptions') }} +
+
+ +
+
+ { + const currentPlan = this.membershipPlan; + const currentCurrency = currentPlan.currency.toLowerCase(); + this.availablePlans = result.data + .flatMap((tier) => tier.plans || []) + .filter( + (plan) => + plan.id !== currentPlan.id && + plan.interval === currentPlan.interval && + plan.intervalAmount === currentPlan.intervalAmount && + plan.currency.toLowerCase() === currentCurrency, + ); + }) + .catch(() => { + this.switchPlansUnavailable = true; + this.availablePlans = []; + }) + .finally(() => { + this.loadingSwitchPlans = false; + }); + }, + switchPlan(plan) { + this.$q + .dialog({ + title: this.$t('paymentPlans.switchConfirmTitle'), + message: this.$t('paymentPlans.switchConfirmDescription', { + plan: plan.name, + }), + cancel: this.$t('button.back'), + persistent: true, + }) + .onOk(() => { + this.switchingPlan = true; + this.$axios + .post('/api/billing/myplan/switch/', { planId: plan.id }) + .then(async (result) => { + if (result.data.success) { + this.planSwitchSuccess = true; + await this.getProfile(); + this.getSubscriptionInfo(); + this.loadSwitchablePlans(); + } else { + this.$q.dialog({ + title: this.$t('paymentPlans.switchFailed'), + message: result.data.message + ? this.$t(result.data.message) + : this.$t('error.contactUs'), + }); + } + }) + .catch(() => { + this.$q.dialog({ + title: this.$t('paymentPlans.switchFailed'), + message: this.$t('error.contactUs'), + }); + }) + .finally(() => { + this.switchingPlan = false; + }); + }); + }, cancelPlan() { this.$q .dialog({ @@ -337,6 +453,7 @@ export default defineComponent({ async mounted() { await this.getProfile(); this.getSubscriptionInfo(); + this.loadSwitchablePlans(); }, }); From bff83610bcd2296071d47b10545ebdb8da2f2821 Mon Sep 17 00:00:00 2001 From: Rechner Fox <659028+rechner@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:18:35 -0700 Subject: [PATCH 2/5] Add SE translation --- src-frontend/src/i18n/sv-SE/index.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src-frontend/src/i18n/sv-SE/index.ts b/src-frontend/src/i18n/sv-SE/index.ts index d0445c1d..c46bf4fc 100644 --- a/src-frontend/src/i18n/sv-SE/index.ts +++ b/src-frontend/src/i18n/sv-SE/index.ts @@ -643,6 +643,19 @@ export default { subscriptionInfo: 'Abonnemangs-info', subscriptionUnavailable: 'Kunde inte läsa in abonnemangsuppgifterna just nu.', + switchTitle: 'Byt betalningsalternativ', + switchDescription: + 'Välj ett annat alternativ med samma faktureringsintervall. Din nuvarande betalmetod förblir oförändrad.', + switchConfirmTitle: 'Byta betalningsalternativ?', + switchConfirmDescription: + 'Ditt betalningsalternativ ändras nu till {plan}. Eventuell prisskillnad inkluderas på din nästa faktura.', + switchSuccess: 'Betalningsalternativ uppdaterat', + switchSuccessDescription: 'Ditt betalningsalternativ har ändrats.', + switchFailed: 'Det gick inte att ändra betalningsalternativ', + switchUnavailable: + 'Kunde inte läsa in alternativa betalningsalternativ just nu.', + switchNoOptions: + 'Det finns inga andra betalningsalternativ med samma faktureringsintervall.', accountOnlyWarning: "Din profil är för närvarande 'endast konto'. Detta beror på att du skippade denna process förra gången. Du är välkommen att forsätta använda detta konto i sin nuvarande form, eller så kan du registrera dig för att bli medlem nedan. ", profileAccountOnlyWarning: @@ -855,6 +868,20 @@ export default { newSubscriptionsDisabled: 'Registrering av nya medlemskap är stängda.', stripeError: 'Något gick fel i kommunikationen med vår betalleverantör. Försök igen om en stund eller kontakta oss om det inte löser sig.', + planSwitchPlanRequired: 'Välj ett betalningsalternativ.', + planSwitchActiveOnly: + 'Betalningsalternativ kan endast ändras för ett aktivt medlemskap.', + planSwitchSamePlan: 'Du använder redan detta betalningsalternativ.', + planSwitchIntervalMismatch: + 'Du kan endast byta till ett betalningsalternativ med samma faktureringsintervall och valuta.', + planSwitchSubscriptionInactive: + 'Ditt Stripe-abonnemang är inte aktivt, så betalningsalternativet kan inte ändras.', + planSwitchSubscriptionInvalid: + 'Ditt abonnemang kunde inte ändras eftersom dess faktureringskonfiguration är ogiltig.', + planSwitchOutOfSync: + 'Ditt betalningsalternativ är inte synkroniserat med Stripe. Kontakta oss för hjälp.', + planSwitchUnconfirmed: + 'Stripe bekräftade inte ändringen av betalningsalternativ. Kontakta oss för hjälp.', }, pendingInvoices: { title: 'Väntande fakturor', From 1138c13baed078d9774858d88fb8a61a3dd2af6b Mon Sep 17 00:00:00 2001 From: Rechner Fox <659028+rechner@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:39:36 -0700 Subject: [PATCH 3/5] refactor(frontend): Migrate to ES modules, fix compiler warnings - Add "type": "module" to package.json to enable ES module support - Extract axios, i18n, and Sentry configuration into src/services/api.ts service module - Lazy-load apex charts library --- src-frontend/.gitignore | 3 ++ src-frontend/package.json | 1 + src-frontend/quasar.config.js | 22 +++++---- src-frontend/src/App.vue | 2 +- src-frontend/src/boot/axios.ts | 15 +------ src-frontend/src/boot/i18n.ts | 16 +------ src-frontend/src/boot/sentry.js | 45 +------------------ .../components/Account/RegistrationCard.vue | 2 +- .../src/components/AdminTools/ManageTier.vue | 2 +- .../src/components/AdminTools/TiersList.vue | 2 +- .../Billing/SignupRequiredSteps.vue | 2 +- src-frontend/src/components/CreditCard.vue | 6 +-- src-frontend/src/components/MetricsGraph.vue | 2 + .../src/pages/AdminTools/SignupPreview.vue | 2 +- src-frontend/src/services/api.ts | 16 +++++++ src-frontend/src/services/i18n.ts | 15 +++++++ src-frontend/src/services/sentry.js | 43 ++++++++++++++++++ src-frontend/src/store/modules/adminTools.js | 4 +- src-frontend/src/store/modules/config.js | 4 +- src-frontend/src/store/modules/profile.js | 2 +- src-frontend/src/store/modules/tools.ts | 2 +- 21 files changed, 109 insertions(+), 99 deletions(-) create mode 100644 src-frontend/src/services/api.ts create mode 100644 src-frontend/src/services/i18n.ts create mode 100644 src-frontend/src/services/sentry.js diff --git a/src-frontend/.gitignore b/src-frontend/.gitignore index 553e1345..97b4a9b1 100644 --- a/src-frontend/.gitignore +++ b/src-frontend/.gitignore @@ -2,6 +2,9 @@ .thumbs.db node_modules +# Generated Quasar config diagnostics +quasar.config.js.temporary.compiled.*.js + # Quasar core related directories .quasar /dist diff --git a/src-frontend/package.json b/src-frontend/package.json index 1aefdaf9..a458a3ce 100644 --- a/src-frontend/package.json +++ b/src-frontend/package.json @@ -3,6 +3,7 @@ "version": "3.8.0", "description": "The MemberMatters frontend", "productName": "MemberMatters", + "type": "module", "author": "Jaimyn Mayer ", "private": true, "scripts": { diff --git a/src-frontend/quasar.config.js b/src-frontend/quasar.config.js index be0ec0fe..d28b3f6e 100644 --- a/src-frontend/quasar.config.js +++ b/src-frontend/quasar.config.js @@ -9,10 +9,13 @@ // Configuration for your app // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js -const { configure } = require('quasar/wrappers'); -const path = require('path'); +import { configure } from 'quasar/wrappers'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -module.exports = configure(function (ctx) { +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default configure(function (ctx) { return { eslint: { warnings: true, @@ -25,15 +28,7 @@ module.exports = configure(function (ctx) { // app boot file (/src/boot) // --> boot files are part of "main.js" // https://v2.quasar.dev/quasar-cli-vite/boot-files - boot: [ - 'vuex', - 'sentry', - 'i18n', - 'axios', - 'routeGuards', - 'capacitor', - 'apexcharts', - ], + boot: ['vuex', 'sentry', 'i18n', 'axios', 'routeGuards', 'capacitor'], // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css css: ['app.scss'], @@ -75,6 +70,9 @@ module.exports = configure(function (ctx) { // Emit separate source-map files for GlitchTip. viteConf.build = viteConf.build || {}; viteConf.build.sourcemap = 'hidden'; + // ApexCharts is lazy-loaded by the Stats route; allow the intentionally + // large chart/vendor chunks to stay below the 1 MiB budget. + viteConf.build.chunkSizeWarningLimit = 1024; viteConf.resolve = viteConf.resolve || {}; viteConf.resolve.tsconfigPaths = true; diff --git a/src-frontend/src/App.vue b/src-frontend/src/App.vue index daa6b13c..ff7d490b 100644 --- a/src-frontend/src/App.vue +++ b/src-frontend/src/App.vue @@ -16,7 +16,7 @@ import { defineComponent } from 'vue'; import { setCssVar, Platform } from 'quasar'; import KioskSettings from '@components/Settings.vue'; import LoginCard from '@components/LoginCard.vue'; -import { api } from 'boot/axios'; +import { api } from 'src/services/api'; setCssVar('dark', '#313131'); diff --git a/src-frontend/src/boot/axios.ts b/src-frontend/src/boot/axios.ts index 99893cb9..aede187e 100644 --- a/src-frontend/src/boot/axios.ts +++ b/src-frontend/src/boot/axios.ts @@ -1,20 +1,7 @@ -import axios, { AxiosInstance } from 'axios'; import { Platform } from 'quasar'; import { boot } from 'quasar/wrappers'; import store from '../store'; - -declare module '@vue/runtime-core' { - interface ComponentCustomProperties { - $axios: AxiosInstance; - } -} - -const api = axios.create({ - baseURL: import.meta.env.apiBaseUrl || '', - withCredentials: true, - xsrfCookieName: 'csrftoken', - xsrfHeaderName: 'X-CSRFTOKEN', -}); +import { api } from '../services/api'; export default boot(({ app }) => { // This interceptor adds the JWT to the request if it exists (ie mobile app) diff --git a/src-frontend/src/boot/i18n.ts b/src-frontend/src/boot/i18n.ts index b361963c..8a36d16a 100644 --- a/src-frontend/src/boot/i18n.ts +++ b/src-frontend/src/boot/i18n.ts @@ -1,19 +1,5 @@ import { boot } from 'quasar/wrappers'; -import { createI18n } from 'vue-i18n'; - -import messages from '../i18n'; -import numberFormats from '../i18n/numberFormats'; - -export const i18n = createI18n({ - legacy: false, - globalInjection: true, - locale: navigator.language, - fallbackLocale: 'en-AU', - numberFormats, - messages, - fallbackWarn: false, - missingWarn: false, -}); +import { i18n } from '../services/i18n'; export default boot(({ app }) => { // Set i18n instance on app diff --git a/src-frontend/src/boot/sentry.js b/src-frontend/src/boot/sentry.js index 0a7eeaca..77283e6f 100644 --- a/src-frontend/src/boot/sentry.js +++ b/src-frontend/src/boot/sentry.js @@ -1,47 +1,6 @@ import { boot } from 'quasar/wrappers'; -import * as Sentry from '@sentry/vue'; -import { version } from '../../package.json'; - -// The Vue app and router are captured here at boot time so that -// initSentry() (called later, once the DSN arrives from /api/config/) -// can wire up the Vue error handler and router instrumentation. -let vueApp = null; -let vueRouter = null; -let initialised = false; - -/** - * Initialise the Sentry browser client. - * - * @param {string} dsn DSN from the backend config endpoint. - * @param {object} [options] - * @param {string} [options.environment] Deploy environment (e.g. Staging). - * @param {object} [options.tags] Extra tags to attach to every event. - */ -export function initSentry(dsn, { environment, tags = {} } = {}) { - if (initialised || !dsn || import.meta.env.DEV) { - return; - } - initialised = true; - - Sentry.init({ - app: vueApp, - dsn, - environment: environment || 'UNKNOWN', - // Prefer the CI-injected release (commit SHA) so it matches the uploaded source maps. - release: import.meta.env.sentryRelease || version, - integrations: vueRouter - ? [Sentry.browserTracingIntegration({ router: vueRouter })] - : [], - initialScope: { - tags, - }, - tracesSampleRate: 0.01, // Capture 1% of transactions for performance monitoring. - autoSessionTracking: false, // GlitchTip does not support sessions. - tracePropagationTargets: ['localhost', /^\//], // Only send traces for our own app's requests. - }); -} +import { configureSentry } from '../services/sentry'; export default boot(({ app, router }) => { - vueApp = app; - vueRouter = router; + configureSentry(app, router); }); diff --git a/src-frontend/src/components/Account/RegistrationCard.vue b/src-frontend/src/components/Account/RegistrationCard.vue index 108bd08b..4082104f 100644 --- a/src-frontend/src/components/Account/RegistrationCard.vue +++ b/src-frontend/src/components/Account/RegistrationCard.vue @@ -235,7 +235,7 @@ import { mapGetters } from 'vuex'; import formMixin from '../../mixins/formMixin'; import icons from '../../icons'; import { defineComponent } from 'vue'; -import { i18n } from '../../boot/i18n'; +import { i18n } from 'src/services/i18n'; import { parsePhoneNumberFromString, type CountryCode, diff --git a/src-frontend/src/components/AdminTools/ManageTier.vue b/src-frontend/src/components/AdminTools/ManageTier.vue index 71406438..83e5e15f 100644 --- a/src-frontend/src/components/AdminTools/ManageTier.vue +++ b/src-frontend/src/components/AdminTools/ManageTier.vue @@ -348,7 +348,7 @@ import { defineComponent } from 'vue'; import { useStore } from 'vuex'; import { AxiosResponse } from 'axios'; -import { api } from 'boot/axios'; +import { api } from 'src/services/api'; import icons from '../../icons'; import formatMixin from '../../mixins/formatMixin'; import formMixin from '../../mixins/formMixin'; diff --git a/src-frontend/src/components/AdminTools/TiersList.vue b/src-frontend/src/components/AdminTools/TiersList.vue index 83ca0332..a69d09b4 100644 --- a/src-frontend/src/components/AdminTools/TiersList.vue +++ b/src-frontend/src/components/AdminTools/TiersList.vue @@ -134,7 +134,7 @@ import { useStore } from 'vuex'; import icons from '../../icons'; import formatMixin from '../../mixins/formatMixin'; import formMixin from '../../mixins/formMixin'; -import { api } from 'boot/axios'; +import { api } from 'src/services/api'; export default defineComponent({ name: 'TiersList', diff --git a/src-frontend/src/components/Billing/SignupRequiredSteps.vue b/src-frontend/src/components/Billing/SignupRequiredSteps.vue index 6f24c168..dc557de2 100644 --- a/src-frontend/src/components/Billing/SignupRequiredSteps.vue +++ b/src-frontend/src/components/Billing/SignupRequiredSteps.vue @@ -280,7 +280,7 @@ import { defineComponent } from 'vue'; import { mapGetters, mapActions } from 'vuex'; import icons from '@icons'; -import { api } from 'boot/axios'; +import { api } from 'src/services/api'; import TermsAcceptanceCard from '@components/Billing/TermsAcceptanceCard.vue'; export default defineComponent({ diff --git a/src-frontend/src/components/CreditCard.vue b/src-frontend/src/components/CreditCard.vue index f79670e4..cdc0c9fb 100644 --- a/src-frontend/src/components/CreditCard.vue +++ b/src-frontend/src/components/CreditCard.vue @@ -36,9 +36,9 @@