diff --git a/memberportal/api_billing/admin.py b/memberportal/api_billing/admin.py new file mode 100644 index 00000000..aa2e951a --- /dev/null +++ b/memberportal/api_billing/admin.py @@ -0,0 +1,42 @@ +from django.contrib import admin + +from .models import PaymentPlanSwitchOperation + + +@admin.register(PaymentPlanSwitchOperation) +class PaymentPlanSwitchOperationAdmin(admin.ModelAdmin): + list_display = ( + "profile", + "current_plan", + "target_plan", + "status", + "attempt_count", + "updated_at", + ) + list_filter = ("status",) + search_fields = ( + "profile__user__email", + "stripe_subscription_id", + "idempotency_key", + ) + readonly_fields = ( + "profile", + "current_plan", + "target_plan", + "stripe_subscription_id", + "idempotency_key", + "status", + "attempt_count", + "last_error", + "created_at", + "updated_at", + ) + + @admin.action(description="Discard selected failed plan switches") + def discard_failed_operations(self, request, queryset): + queryset.filter(status=PaymentPlanSwitchOperation.STATUS_FAILED).delete() + + actions = ("discard_failed_operations",) + + def has_delete_permission(self, request, obj=None): + return obj is None or obj.status == PaymentPlanSwitchOperation.STATUS_FAILED diff --git a/memberportal/api_billing/migrations/0002_paymentplanswitchoperation.py b/memberportal/api_billing/migrations/0002_paymentplanswitchoperation.py new file mode 100644 index 00000000..f2e8a8cd --- /dev/null +++ b/memberportal/api_billing/migrations/0002_paymentplanswitchoperation.py @@ -0,0 +1,74 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("api_admin_tools", "0013_paymentplan_description"), + ("api_billing", "0001_initial"), + ("constance", "0003_drop_pickle"), + ("profile", "0033_alter_inductionproviderstate_provider"), + ] + + operations = [ + migrations.CreateModel( + name="PaymentPlanSwitchOperation", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("stripe_subscription_id", models.CharField(max_length=100)), + ("idempotency_key", models.CharField(max_length=255, unique=True)), + ( + "status", + models.CharField( + choices=[("pending", "Pending")], + default="pending", + max_length=16, + ), + ), + ("attempt_count", models.PositiveIntegerField(default=0)), + ("last_error", models.TextField(blank=True, default="")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "current_plan", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="switch_operations_from", + to="api_admin_tools.paymentplan", + ), + ), + ( + "profile", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="payment_plan_switch_operation", + to="profile.profile", + ), + ), + ( + "target_plan", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="switch_operations_to", + to="api_admin_tools.paymentplan", + ), + ), + ], + options={ + "indexes": [ + models.Index( + fields=["status", "updated_at"], + name="api_billing_status_55ab81_idx", + ) + ], + }, + ), + ] diff --git a/memberportal/api_billing/migrations/0003_paymentplanswitchoperation_failed.py b/memberportal/api_billing/migrations/0003_paymentplanswitchoperation_failed.py new file mode 100644 index 00000000..88b67616 --- /dev/null +++ b/memberportal/api_billing/migrations/0003_paymentplanswitchoperation_failed.py @@ -0,0 +1,19 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api_billing", "0002_paymentplanswitchoperation"), + ] + + operations = [ + migrations.AlterField( + model_name="paymentplanswitchoperation", + name="status", + field=models.CharField( + choices=[("pending", "Pending"), ("failed", "Failed")], + default="pending", + max_length=16, + ), + ), + ] diff --git a/memberportal/api_billing/models.py b/memberportal/api_billing/models.py index 179608df..b11b7c7b 100644 --- a/memberportal/api_billing/models.py +++ b/memberportal/api_billing/models.py @@ -16,3 +16,49 @@ class ProcessedStripeEvent(models.Model): class Meta: verbose_name = "Processed Stripe Event" verbose_name_plural = "Processed Stripe Events" + + +class PaymentPlanSwitchOperation(models.Model): + """Durable state for a Stripe subscription Price replacement. + + The row is created before the Stripe mutation. If the request dies after + Stripe succeeds but before Profile is saved, the periodic reconciliation + task can inspect Stripe and finish the local update using this record. + """ + + STATUS_PENDING = "pending" + STATUS_FAILED = "failed" + STATUS_CHOICES = ( + (STATUS_PENDING, "Pending"), + (STATUS_FAILED, "Failed"), + ) + + profile = models.OneToOneField( + "profile.Profile", + on_delete=models.CASCADE, + related_name="payment_plan_switch_operation", + ) + current_plan = models.ForeignKey( + "api_admin_tools.PaymentPlan", + on_delete=models.PROTECT, + related_name="switch_operations_from", + ) + target_plan = models.ForeignKey( + "api_admin_tools.PaymentPlan", + on_delete=models.PROTECT, + related_name="switch_operations_to", + ) + stripe_subscription_id = models.CharField(max_length=100) + idempotency_key = models.CharField(max_length=255, unique=True) + status = models.CharField( + max_length=16, + choices=STATUS_CHOICES, + default=STATUS_PENDING, + ) + attempt_count = models.PositiveIntegerField(default=0) + last_error = models.TextField(blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + indexes = [models.Index(fields=["status", "updated_at"])] diff --git a/memberportal/api_billing/plan_switch.py b/memberportal/api_billing/plan_switch.py new file mode 100644 index 00000000..f611c282 --- /dev/null +++ b/memberportal/api_billing/plan_switch.py @@ -0,0 +1,277 @@ +import logging +import uuid + +import stripe +from django.db import transaction +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.response import Response +from sentry_sdk import capture_exception + +from api_admin_tools.models import PaymentPlan +from profile.models import Profile + +from .models import PaymentPlanSwitchOperation + +logger = logging.getLogger("billing") +PRORATION_BEHAVIOR = "create_prorations" +MAX_RECOVERY_ATTEMPTS = 5 + + +class PaymentPlanSwitchValidationError(Exception): + def __init__(self, message, response_status=status.HTTP_409_CONFLICT): + super().__init__(message) + self.message = message + self.response_status = response_status + + +def _stripe_value(resource, name, default=None): + if isinstance(resource, dict): + return resource.get(name, default) + return getattr(resource, name, default) + + +def _subscription_items(subscription): + items = _stripe_value(subscription, "items") + return _stripe_value(items, "data", []) or [] + + +def _price_id(item): + price = _stripe_value(item, "price") + if isinstance(price, str): + return price + return _stripe_value(price, "id") + + +def _price_signature(price): + recurring = _stripe_value(price, "recurring") or {} + return ( + _stripe_value(recurring, "interval"), + _stripe_value(recurring, "interval_count"), + str(_stripe_value(price, "currency", "")).lower(), + ) + + +def _local_signature(plan): + return (plan.interval, plan.interval_count, plan.currency.lower()) + + +def _error_response(message, response_status): + return Response( + {"success": False, "message": message}, + status=response_status, + ) + + +def _record_operation_error(operation_id, error): + message = str(error)[:4000] + try: + with transaction.atomic(): + operation = PaymentPlanSwitchOperation.objects.select_for_update().get( + pk=operation_id + ) + operation.attempt_count += 1 + operation.last_error = message + if operation.attempt_count >= MAX_RECOVERY_ATTEMPTS: + operation.status = PaymentPlanSwitchOperation.STATUS_FAILED + operation.save(update_fields=["attempt_count", "last_error", "status"]) + except Exception as record_error: + capture_exception(record_error) + return _error_response( + "billing.planSwitchRecoveryPending", + status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + +def _discard_operation(operation_id): + PaymentPlanSwitchOperation.objects.filter(pk=operation_id).delete() + + +def _apply_operation(operation_id): + with transaction.atomic(): + operation = ( + PaymentPlanSwitchOperation.objects.select_for_update() + .select_related("target_plan") + .get(pk=operation_id) + ) + profile = Profile.objects.select_for_update().get(pk=operation.profile_id) + + if profile.membership_plan_id == operation.target_plan_id: + operation.delete() + return operation.target_plan + + if ( + profile.membership_plan_id != operation.current_plan_id + or profile.stripe_subscription_id != operation.stripe_subscription_id + ): + raise RuntimeError( + "Profile changed while recovering payment plan switch " + f"{operation_id}." + ) + + profile.membership_plan_id = operation.target_plan_id + profile.save(update_fields=["membership_plan"]) + target_plan = operation.target_plan + operation.delete() + return target_plan + + +def process_payment_plan_switch(operation_id): + """Complete or retry one durable payment-plan switch operation. + + Stripe idempotency makes retrying the same mutation safe after a lost + response. If Stripe already has the target Price, only the local finalize + transaction runs. + """ + try: + operation = PaymentPlanSwitchOperation.objects.select_related( + "current_plan", "target_plan" + ).get(pk=operation_id) + except PaymentPlanSwitchOperation.DoesNotExist: + return True + + try: + subscription = stripe.Subscription.retrieve(operation.stripe_subscription_id) + if _stripe_value(subscription, "status") != "active": + raise PaymentPlanSwitchValidationError( + "billing.planSwitchSubscriptionInactive" + ) + + items = _subscription_items(subscription) + if len(items) != 1: + raise PaymentPlanSwitchValidationError( + "billing.planSwitchSubscriptionInvalid" + ) + + subscription_item = items[0] + current_stripe_price_id = _price_id(subscription_item) + if current_stripe_price_id == operation.target_plan.stripe_id: + _apply_operation(operation_id) + return True + + if current_stripe_price_id != operation.current_plan.stripe_id: + raise PaymentPlanSwitchValidationError("billing.planSwitchOutOfSync") + + current_stripe_price = stripe.Price.retrieve(operation.current_plan.stripe_id) + target_stripe_price = stripe.Price.retrieve(operation.target_plan.stripe_id) + current_signature = _price_signature(current_stripe_price) + target_signature = _price_signature(target_stripe_price) + if ( + current_signature != _local_signature(operation.current_plan) + or target_signature != _local_signature(operation.target_plan) + or current_signature != target_signature + ): + raise PaymentPlanSwitchValidationError("billing.planSwitchPriceMismatch") + + modified_subscription = stripe.Subscription.modify( + operation.stripe_subscription_id, + items=[ + { + "id": _stripe_value(subscription_item, "id"), + "price": operation.target_plan.stripe_id, + } + ], + proration_behavior=PRORATION_BEHAVIOR, + idempotency_key=operation.idempotency_key, + ) + modified_items = _subscription_items(modified_subscription) + if ( + _stripe_value(modified_subscription, "status") != "active" + or len(modified_items) != 1 + or _price_id(modified_items[0]) != operation.target_plan.stripe_id + ): + raise RuntimeError("Stripe did not confirm the requested plan switch.") + + _apply_operation(operation_id) + return True + except PaymentPlanSwitchValidationError as error: + _discard_operation(operation_id) + return error + except stripe.error.StripeError as error: + capture_exception(error) + _record_operation_error(operation_id, error) + return False + except Exception as error: + capture_exception(error) + _record_operation_error(operation_id, error) + return False + + +def switch_payment_plan(request): + plan_id = request.data.get("planId") + if not plan_id: + return _error_response( + "billing.planSwitchPlanRequired", status.HTTP_400_BAD_REQUEST + ) + + target_plan = get_object_or_404( + PaymentPlan.objects.select_related("member_tier"), + pk=plan_id, + visible=True, + member_tier__visible=True, + ) + idempotency_token = request.headers.get("Idempotency-Key") or uuid.uuid4().hex + idempotency_key = f"plan-switch-{request.user.profile.pk}-{idempotency_token[:200]}" + + with transaction.atomic(): + profile = Profile.objects.select_for_update().get(pk=request.user.profile.pk) + existing_operation = PaymentPlanSwitchOperation.objects.filter( + profile=profile + ).first() + if existing_operation: + message = ( + "billing.planSwitchRecoveryFailed" + if existing_operation.status == PaymentPlanSwitchOperation.STATUS_FAILED + else "billing.planSwitchRecoveryPending" + ) + return _error_response(message, status.HTTP_409_CONFLICT) + + if profile.state_locked: + return _error_response("billing.stateLocked", status.HTTP_403_FORBIDDEN) + if ( + profile.state != "active" + or profile.subscription_status != "active" + or not profile.membership_plan + or not profile.stripe_subscription_id + ): + return _error_response( + "billing.planSwitchActiveOnly", status.HTTP_409_CONFLICT + ) + + current_plan = profile.membership_plan + if current_plan.pk == target_plan.pk: + return _error_response( + "billing.planSwitchSamePlan", status.HTTP_409_CONFLICT + ) + if _local_signature(current_plan) != _local_signature(target_plan): + return _error_response( + "billing.planSwitchIntervalMismatch", status.HTTP_400_BAD_REQUEST + ) + + operation = PaymentPlanSwitchOperation.objects.create( + profile=profile, + current_plan=current_plan, + target_plan=target_plan, + stripe_subscription_id=profile.stripe_subscription_id, + idempotency_key=idempotency_key, + ) + + result = process_payment_plan_switch(operation.pk) + if result is True: + return Response({"success": True, "plan": target_plan.get_object()}) + if isinstance(result, PaymentPlanSwitchValidationError): + return _error_response(result.message, result.response_status) + + operation_status = ( + PaymentPlanSwitchOperation.objects.filter(pk=operation.pk) + .values_list("status", flat=True) + .first() + ) + if operation_status == PaymentPlanSwitchOperation.STATUS_FAILED: + return _error_response( + "billing.planSwitchRecoveryFailed", status.HTTP_503_SERVICE_UNAVAILABLE + ) + + return _error_response( + "billing.planSwitchRecoveryPending", status.HTTP_503_SERVICE_UNAVAILABLE + ) diff --git a/memberportal/api_billing/tasks.py b/memberportal/api_billing/tasks.py index ea00a5d8..5ad21887 100644 --- a/memberportal/api_billing/tasks.py +++ b/memberportal/api_billing/tasks.py @@ -1,7 +1,11 @@ from datetime import timedelta from django.utils import timezone from membermatters.celeryapp import app -from api_billing.models import ProcessedStripeEvent +from api_billing.models import ( + PaymentPlanSwitchOperation, + ProcessedStripeEvent, +) +from api_billing.plan_switch import process_payment_plan_switch import logging logger = logging.getLogger("celery:api_billing") @@ -18,6 +22,11 @@ def setup_periodic_tasks(sender, **kwargs): cleanup_processed_stripe_events.s(), name="cleanup_processed_stripe_events", ) + sender.add_periodic_task( + timedelta(minutes=5), + reconcile_pending_payment_plan_switches.s(), + name="reconcile_pending_payment_plan_switches", + ) @app.task @@ -25,3 +34,12 @@ def cleanup_processed_stripe_events(): cutoff = timezone.now() - timedelta(days=EVENT_RETENTION_DAYS) deleted, _ = ProcessedStripeEvent.objects.filter(processed_at__lt=cutoff).delete() logger.info(f"Deleted {deleted} expired Stripe webhook dedup rows") + + +@app.task +def reconcile_pending_payment_plan_switches(): + operation_ids = PaymentPlanSwitchOperation.objects.filter( + status=PaymentPlanSwitchOperation.STATUS_PENDING + ).values_list("pk", flat=True) + for operation_id in operation_ids: + process_payment_plan_switch(operation_id) 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..72286862 --- /dev/null +++ b/memberportal/api_billing/tests/test_plan_switch.py @@ -0,0 +1,489 @@ +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 api_billing.models import PaymentPlanSwitchOperation +from api_billing.plan_switch import ( + MAX_RECOVERY_ATTEMPTS, + _apply_operation, + _price_id, + _record_operation_error, + _stripe_value, + process_payment_plan_switch, +) +from profile.models import Profile, User + +ORIGINAL_PROFILE_SAVE = Profile.save + + +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), + ) + ] + ), + ) + + @staticmethod + def price(interval="month", interval_count=1, currency="aud"): + return SimpleNamespace( + active=True, + currency=currency, + recurring=SimpleNamespace( + interval=interval, + interval_count=interval_count, + ), + ) + + def switch(self, **headers): + return self.client.post( + "/api/billing/myplan/switch/", + {"planId": self.target_plan.pk}, + format="json", + **headers, + ) + + def make_operation(self, status=PaymentPlanSwitchOperation.STATUS_PENDING): + return PaymentPlanSwitchOperation.objects.create( + profile=self.profile, + current_plan=self.current_plan, + target_plan=self.target_plan, + stripe_subscription_id=self.profile.stripe_subscription_id, + idempotency_key=f"plan-switch-helper-{self.profile.pk}-{status}", + status=status, + ) + + def expected_error(self, request): + with self.assertLogs("django.request", level="WARNING"): + return request() + + @patch("api_billing.plan_switch.stripe.Price.retrieve") + @patch("api_billing.plan_switch.stripe.Subscription.modify") + @patch("api_billing.plan_switch.stripe.Subscription.retrieve") + def test_switches_compatible_plan_on_existing_subscription( + self, retrieve, modify, price_retrieve + ): + 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 + price_retrieve.side_effect = [self.price(), self.price()] + + response = self.switch(HTTP_IDEMPOTENCY_KEY="switch-test-key") + + self.assertEqual(response.status_code, 200, response.data) + 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.plan_switch.stripe.Subscription.modify") + @patch("api_billing.plan_switch.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.expected_error(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.plan_switch.stripe.Subscription.retrieve" + ) as retrieve: + response = self.expected_error(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.plan_switch.stripe.Price.retrieve") + @patch("api_billing.plan_switch.stripe.Subscription.modify") + @patch("api_billing.plan_switch.stripe.Subscription.retrieve") + def test_stripe_failure_does_not_change_local_plan( + self, retrieve, modify, price_retrieve + ): + retrieve.return_value = self.subscription(self.current_plan.stripe_id) + price_retrieve.side_effect = [self.price(), self.price()] + modify.side_effect = stripe.error.StripeError("plan switch failed") + + response = self.expected_error(self.switch) + + self.assertEqual(response.status_code, 503) + self.assertEqual(response.data["message"], "billing.planSwitchRecoveryPending") + self.profile.refresh_from_db() + self.assertEqual(self.profile.membership_plan_id, self.current_plan.pk) + self.assertTrue( + PaymentPlanSwitchOperation.objects.filter(profile=self.profile).exists() + ) + + @patch("profile.models.Profile.save", autospec=True) + @patch("api_billing.plan_switch.stripe.Price.retrieve") + @patch("api_billing.plan_switch.stripe.Subscription.modify") + @patch("api_billing.plan_switch.stripe.Subscription.retrieve") + def test_recovers_when_local_finalize_fails_after_stripe_success( + self, retrieve, modify, price_retrieve, profile_save + ): + retrieve.return_value = self.subscription(self.current_plan.stripe_id) + modify.return_value = self.subscription(self.target_plan.stripe_id) + price_retrieve.side_effect = [self.price(), self.price()] + + save_calls = 0 + + def fail_once(instance, *args, **kwargs): + nonlocal save_calls + if save_calls == 0: + save_calls += 1 + raise RuntimeError("database unavailable") + return ORIGINAL_PROFILE_SAVE(instance, *args, **kwargs) + + profile_save.side_effect = fail_once + + response = self.expected_error(self.switch) + + self.assertEqual(response.status_code, 503, response.data) + operation = PaymentPlanSwitchOperation.objects.get(profile=self.profile) + self.profile.refresh_from_db() + self.assertEqual(self.profile.membership_plan_id, self.current_plan.pk) + + profile_save.side_effect = ORIGINAL_PROFILE_SAVE + retrieve.return_value = self.subscription(self.target_plan.stripe_id) + recovered = process_payment_plan_switch(operation.pk) + if not recovered: + self.fail( + PaymentPlanSwitchOperation.objects.get(pk=operation.pk).last_error + ) + self.assertTrue(recovered) + + self.profile.refresh_from_db() + self.assertEqual(self.profile.membership_plan_id, self.target_plan.pk) + self.assertFalse( + PaymentPlanSwitchOperation.objects.filter(pk=operation.pk).exists() + ) + + @patch("api_billing.plan_switch.stripe.Price.retrieve") + @patch("api_billing.plan_switch.stripe.Subscription.retrieve") + def test_rejects_subscription_that_is_not_active_in_stripe( + self, retrieve, price_retrieve + ): + retrieve.return_value = self.subscription( + self.current_plan.stripe_id, status="past_due" + ) + + response = self.expected_error(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) + + def test_rejects_plan_under_hidden_tier(self): + hidden_tier = MemberTier.objects.create( + name="Hidden", + description="Hidden membership", + stripe_id="prod_hidden", + visible=False, + ) + hidden_plan = PaymentPlan.objects.create( + name="Hidden Monthly", + description="Hidden Monthly", + stripe_id="price_hidden_monthly", + member_tier=hidden_tier, + currency="aud", + cost=2000, + interval_count=1, + interval="month", + visible=True, + ) + + response = self.expected_error( + lambda: self.client.post( + "/api/billing/myplan/switch/", + {"planId": hidden_plan.pk}, + format="json", + ) + ) + + self.assertEqual(response.status_code, 404) + + @patch("api_billing.plan_switch.stripe.Price.retrieve") + @patch("api_billing.plan_switch.stripe.Subscription.modify") + @patch("api_billing.plan_switch.stripe.Subscription.retrieve") + def test_rejects_stale_stripe_price_metadata( + self, retrieve, modify, price_retrieve + ): + retrieve.return_value = self.subscription(self.current_plan.stripe_id) + price_retrieve.side_effect = [self.price(), self.price(interval="year")] + + response = self.expected_error(self.switch) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.data["message"], "billing.planSwitchPriceMismatch") + modify.assert_not_called() + + @patch("api_billing.plan_switch.stripe.Price.retrieve") + @patch("api_billing.plan_switch.stripe.Subscription.modify") + @patch("api_billing.plan_switch.stripe.Subscription.retrieve") + def test_marks_operation_failed_after_bounded_recovery_attempts( + self, retrieve, modify, price_retrieve + ): + retrieve.return_value = self.subscription(self.current_plan.stripe_id) + price_retrieve.return_value = self.price() + modify.side_effect = stripe.error.StripeError("permanent Stripe failure") + + response = self.expected_error(self.switch) + self.assertEqual(response.status_code, 503) + operation = PaymentPlanSwitchOperation.objects.get(profile=self.profile) + + for _ in range(MAX_RECOVERY_ATTEMPTS - 1): + self.assertFalse(process_payment_plan_switch(operation.pk)) + + operation.refresh_from_db() + self.assertEqual(operation.status, PaymentPlanSwitchOperation.STATUS_FAILED) + self.assertEqual(operation.attempt_count, MAX_RECOVERY_ATTEMPTS) + + blocked_response = self.expected_error(self.switch) + self.assertEqual(blocked_response.status_code, 409) + self.assertEqual( + blocked_response.data["message"], "billing.planSwitchRecoveryFailed" + ) + + def test_stripe_helpers_accept_dict_and_string_objects(self): + self.assertEqual(_stripe_value({"id": "price_1"}, "id"), "price_1") + self.assertEqual(_price_id({"price": "price_1"}), "price_1") + + def test_process_missing_operation_is_already_complete(self): + self.assertTrue(process_payment_plan_switch(999999)) + + def test_apply_operation_is_idempotent_when_profile_already_target(self): + operation = self.make_operation() + self.profile.membership_plan = self.target_plan + self.profile.save(update_fields=["membership_plan"]) + + result = _apply_operation(operation.pk) + + self.assertEqual(result.pk, self.target_plan.pk) + self.assertFalse( + PaymentPlanSwitchOperation.objects.filter(pk=operation.pk).exists() + ) + + def test_apply_operation_rejects_profile_drift(self): + operation = self.make_operation() + self.profile.stripe_subscription_id = "sub_changed" + self.profile.save(update_fields=["stripe_subscription_id"]) + + with self.assertRaisesMessage(RuntimeError, "Profile changed"): + _apply_operation(operation.pk) + + self.assertTrue( + PaymentPlanSwitchOperation.objects.filter(pk=operation.pk).exists() + ) + + @patch("api_billing.plan_switch.capture_exception") + @patch("api_billing.plan_switch.PaymentPlanSwitchOperation.save") + def test_record_operation_error_tolerates_database_failure( + self, save, capture_exception + ): + operation = self.make_operation() + save.side_effect = RuntimeError("database unavailable") + + response = _record_operation_error(operation.pk, RuntimeError("stripe")) + + self.assertEqual(response.status_code, 503) + capture_exception.assert_called_once() + + @patch("api_billing.plan_switch.stripe.Price.retrieve") + @patch("api_billing.plan_switch.stripe.Subscription.modify") + @patch("api_billing.plan_switch.stripe.Subscription.retrieve") + def test_unconfirmed_stripe_update_is_retried( + self, retrieve, modify, price_retrieve + ): + operation = self.make_operation() + retrieve.return_value = self.subscription(self.current_plan.stripe_id) + price_retrieve.return_value = self.price() + modify.return_value = self.subscription(self.current_plan.stripe_id) + + self.assertFalse(process_payment_plan_switch(operation.pk)) + operation.refresh_from_db() + self.assertEqual(operation.attempt_count, 1) + self.assertEqual(operation.status, PaymentPlanSwitchOperation.STATUS_PENDING) + + def test_switch_rejects_missing_plan_id(self): + response = self.expected_error( + lambda: self.client.post("/api/billing/myplan/switch/", {}, format="json") + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["message"], "billing.planSwitchPlanRequired") + + def test_switch_rejects_locked_member(self): + self.profile.state_locked = True + self.profile.save(update_fields=["state_locked"]) + + response = self.expected_error(self.switch) + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.data["message"], "billing.stateLocked") + + def test_switch_rejects_same_plan(self): + response = self.expected_error( + lambda: self.client.post( + "/api/billing/myplan/switch/", + {"planId": self.current_plan.pk}, + format="json", + ) + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.data["message"], "billing.planSwitchSamePlan") + + def test_switch_rejects_existing_pending_and_failed_operations(self): + pending = self.make_operation() + pending_response = self.expected_error(self.switch) + self.assertEqual(pending_response.status_code, 409) + self.assertEqual( + pending_response.data["message"], "billing.planSwitchRecoveryPending" + ) + + pending.delete() + self.make_operation(status=PaymentPlanSwitchOperation.STATUS_FAILED) + failed_response = self.expected_error(self.switch) + self.assertEqual(failed_response.status_code, 409) + self.assertEqual( + failed_response.data["message"], "billing.planSwitchRecoveryFailed" + ) + + @patch("api_billing.plan_switch.stripe.Subscription.retrieve") + def test_process_rejects_unexpected_subscription_item_count(self, retrieve): + operation = self.make_operation() + retrieve.return_value = SimpleNamespace( + status="active", items=SimpleNamespace(data=[]) + ) + + result = process_payment_plan_switch(operation.pk) + + self.assertIsInstance(result, Exception) + self.assertEqual(result.message, "billing.planSwitchSubscriptionInvalid") + self.assertFalse( + PaymentPlanSwitchOperation.objects.filter(pk=operation.pk).exists() + ) + + @patch("api_billing.plan_switch.stripe.Subscription.retrieve") + def test_process_rejects_unexpected_current_stripe_price(self, retrieve): + operation = self.make_operation() + retrieve.return_value = self.subscription("price_unexpected") + + result = process_payment_plan_switch(operation.pk) + + self.assertIsInstance(result, Exception) + self.assertEqual(result.message, "billing.planSwitchOutOfSync") + self.assertFalse( + PaymentPlanSwitchOperation.objects.filter(pk=operation.pk).exists() + ) + + @patch("api_billing.plan_switch.process_payment_plan_switch") + def test_switch_reports_terminal_recovery_failure(self, process): + def fail_and_mark_failed(operation_id): + PaymentPlanSwitchOperation.objects.filter(pk=operation_id).update( + status=PaymentPlanSwitchOperation.STATUS_FAILED + ) + return False + + process.side_effect = fail_and_mark_failed + response = self.expected_error(self.switch) + + self.assertEqual(response.status_code, 503) + self.assertEqual(response.data["message"], "billing.planSwitchRecoveryFailed") diff --git a/memberportal/api_billing/tests/test_tasks.py b/memberportal/api_billing/tests/test_tasks.py new file mode 100644 index 00000000..a33e8e48 --- /dev/null +++ b/memberportal/api_billing/tests/test_tasks.py @@ -0,0 +1,107 @@ +from datetime import timedelta +from unittest.mock import call, patch + +from django.test import TestCase +from django.utils import timezone + +from api_admin_tools.models import MemberTier, PaymentPlan +from api_billing.models import PaymentPlanSwitchOperation, ProcessedStripeEvent +from api_billing.tasks import ( + EVENT_RETENTION_DAYS, + cleanup_processed_stripe_events, + reconcile_pending_payment_plan_switches, +) +from profile.models import Profile, User + + +class BillingTaskTests(TestCase): + def make_profile_and_plans(self): + suffix = Profile.objects.count() + 1 + user = User.objects.create_user( + f"billing-task-{suffix}@example.test", + password="test-password", + ) + profile = Profile.objects.create( + user=user, + first_name="Billing", + last_name="Task", + state="active", + stripe_subscription_id=f"sub_billing_task_{suffix}", + subscription_status="active", + ) + tier = MemberTier.objects.create( + name=f"Billing Task Tier {suffix}", + description=f"Billing task tier {suffix}", + stripe_id=f"prod_billing_task_{suffix}", + ) + current_plan = PaymentPlan.objects.create( + name=f"Billing Task Current {suffix}", + description=f"Billing task current {suffix}", + stripe_id=f"price_billing_task_current_{suffix}", + member_tier=tier, + currency="aud", + cost=2000, + interval_count=1, + interval="month", + ) + target_plan = PaymentPlan.objects.create( + name=f"Billing Task Target {suffix}", + description=f"Billing task target {suffix}", + stripe_id=f"price_billing_task_target_{suffix}", + member_tier=tier, + currency="aud", + cost=3000, + interval_count=1, + interval="month", + ) + return profile, current_plan, target_plan + + def test_cleanup_removes_only_expired_processed_events(self): + now = timezone.now() + expired = ProcessedStripeEvent.objects.create( + event_id="evt_expired", + event_type="invoice.paid", + ) + recent = ProcessedStripeEvent.objects.create( + event_id="evt_recent", + event_type="invoice.paid", + ) + ProcessedStripeEvent.objects.filter(pk=expired.pk).update( + processed_at=now - timedelta(days=EVENT_RETENTION_DAYS + 1) + ) + ProcessedStripeEvent.objects.filter(pk=recent.pk).update( + processed_at=now - timedelta(days=EVENT_RETENTION_DAYS - 1) + ) + + with patch("api_billing.tasks.timezone.now", return_value=now): + cleanup_processed_stripe_events() + + self.assertFalse(ProcessedStripeEvent.objects.filter(pk=expired.pk).exists()) + self.assertTrue(ProcessedStripeEvent.objects.filter(pk=recent.pk).exists()) + + @patch("api_billing.tasks.process_payment_plan_switch") + def test_reconciliation_processes_pending_operations_only(self, process): + profile, current_plan, target_plan = self.make_profile_and_plans() + pending = PaymentPlanSwitchOperation.objects.create( + profile=profile, + current_plan=current_plan, + target_plan=target_plan, + stripe_subscription_id=profile.stripe_subscription_id, + idempotency_key="plan-switch-pending-task-test", + status=PaymentPlanSwitchOperation.STATUS_PENDING, + ) + + failed_profile, failed_current, failed_target = self.make_profile_and_plans() + failed = PaymentPlanSwitchOperation.objects.create( + profile=failed_profile, + current_plan=failed_current, + target_plan=failed_target, + stripe_subscription_id=failed_profile.stripe_subscription_id, + idempotency_key="plan-switch-failed-task-test", + status=PaymentPlanSwitchOperation.STATUS_FAILED, + ) + + reconcile_pending_payment_plan_switches() + + process.assert_called_once_with(pending.pk) + self.assertNotIn(call(failed.pk), process.call_args_list) 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 b0c0ef8c..f9fa79fd 100644 --- a/memberportal/api_billing/views.py +++ b/memberportal/api_billing/views.py @@ -13,6 +13,7 @@ # from api_admin_tools.models import from .models import ProcessedStripeEvent +from .plan_switch import switch_payment_plan from rest_framework import status, permissions from rest_framework.response import Response @@ -808,6 +809,42 @@ 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") + + @classmethod + def _price_signature(cls, price): + recurring = cls._stripe_value(price, "recurring") or {} + return ( + cls._stripe_value(recurring, "interval"), + cls._stripe_value(recurring, "interval_count"), + str(cls._stripe_value(price, "currency", "")).lower(), + ) + + def post(self, request): + return switch_payment_plan(request) + + def _no_plan_response(user): user.log_event("Member tried to modify nonexistant membership plan.", "stripe") return Response( diff --git a/memberportal/profile/migrations/0029_migrate_email_backend_configuration.py b/memberportal/profile/migrations/0029_migrate_email_backend_configuration.py index e299626f..d3f677f6 100644 --- a/memberportal/profile/migrations/0029_migrate_email_backend_configuration.py +++ b/memberportal/profile/migrations/0029_migrate_email_backend_configuration.py @@ -112,6 +112,7 @@ class Migration(migrations.Migration): dependencies = [ ("profile", "0028_profile_memberdoc_url"), ("database", "0002_auto_20190129_2304"), + ("constance", "0003_drop_pickle"), ] operations = [ 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 19794a0b..8ae51434 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 778e758f..77283e6f 100644 --- a/src-frontend/src/boot/sentry.js +++ b/src-frontend/src/boot/sentry.js @@ -1,48 +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. - showReportDialog: true, - }); -} +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/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/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 @@ diff --git a/src-frontend/src/services/api.ts b/src-frontend/src/services/api.ts new file mode 100644 index 00000000..74b2e8eb --- /dev/null +++ b/src-frontend/src/services/api.ts @@ -0,0 +1,16 @@ +import axios, { type AxiosInstance } from 'axios'; + +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', +}); + +export { api }; diff --git a/src-frontend/src/services/i18n.ts b/src-frontend/src/services/i18n.ts new file mode 100644 index 00000000..93f3c975 --- /dev/null +++ b/src-frontend/src/services/i18n.ts @@ -0,0 +1,15 @@ +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, +}); diff --git a/src-frontend/src/services/sentry.js b/src-frontend/src/services/sentry.js new file mode 100644 index 00000000..5cb5b2be --- /dev/null +++ b/src-frontend/src/services/sentry.js @@ -0,0 +1,44 @@ +import * as Sentry from '@sentry/vue'; +import { version } from '../../package.json'; + +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. + showReportDialog: true, + }); +} + +export function configureSentry(app, router) { + vueApp = app; + vueRouter = router; +} diff --git a/src-frontend/src/store/modules/adminTools.js b/src-frontend/src/store/modules/adminTools.js index da854a86..fa85415c 100644 --- a/src-frontend/src/store/modules/adminTools.js +++ b/src-frontend/src/store/modules/adminTools.js @@ -1,6 +1,6 @@ // import Vue from "vue"; -import { i18n } from 'boot/i18n'; -import { api } from 'boot/axios'; +import { i18n } from 'src/services/i18n'; +import { api } from 'src/services/api'; export default { namespaced: true, diff --git a/src-frontend/src/store/modules/config.js b/src-frontend/src/store/modules/config.js index ce9ee754..da112262 100644 --- a/src-frontend/src/store/modules/config.js +++ b/src-frontend/src/store/modules/config.js @@ -1,5 +1,5 @@ -import { api } from 'boot/axios'; -import { initSentry } from 'boot/sentry'; +import { api } from 'src/services/api'; +import { initSentry } from 'src/services/sentry'; export default { namespaced: true, diff --git a/src-frontend/src/store/modules/profile.js b/src-frontend/src/store/modules/profile.js index e4481420..98282754 100644 --- a/src-frontend/src/store/modules/profile.js +++ b/src-frontend/src/store/modules/profile.js @@ -1,7 +1,7 @@ import dayjs from 'dayjs'; import idleTimeout from 'idle-timeout'; import { Platform } from 'quasar'; -import { api } from 'src/boot/axios'; +import { api } from 'src/services/api'; // import Vue from "vue"; const getDefaultState = () => ({ diff --git a/src-frontend/src/store/modules/tools.ts b/src-frontend/src/store/modules/tools.ts index c5d36dbd..7b4f1b7f 100644 --- a/src-frontend/src/store/modules/tools.ts +++ b/src-frontend/src/store/modules/tools.ts @@ -1,6 +1,6 @@ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck -import { api } from 'boot/axios'; +import { api } from 'src/services/api'; import { MetricsApi, MetricsApiSchema } from 'types/api/metrics'; export default {