mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2026-08-01 00:08:45 -04:00
* Add new default light and dark themes to match AdventureLog styles * Allow different theme for site and maps Fixes #1130 * Update settings page to use translation keys for currency and map style descriptions * Add global flag to CollectionItineraryItemSerializer and implement map view persistence in frontend * Update appVersion to v0.12.0-main-052126 * Enhance permissions for shared collections and restrict publicity changes to owners only * Update appVersion to v0.12.1-beta-052126 * Add new localization strings and descriptions for various languages - Updated Spanish (es.json) with new map style and preferred currency descriptions. - Enhanced French (fr.json) with additional details for Strava activities and currency descriptions. - Added new strings in Hungarian (hu.json) for map style and preferred currency. - Included additional details in Italian (it.json) for Strava activities and currency descriptions. - Updated Japanese (ja.json) with new strings for map style and preferred currency. - Enhanced Korean (ko.json) with additional details for Strava activities and currency descriptions. - Added new strings in Dutch (nl.json) for map style and preferred currency. - Updated Norwegian (no.json) with new strings for map style and preferred currency. - Enhanced Polish (pl.json) with additional details for Strava activities and currency descriptions. - Updated Brazilian Portuguese (pt-br.json) with new map style and preferred currency descriptions. - Added new strings in Romanian (ro.json) for additional details and currency descriptions. - Enhanced Russian (ru.json) with new strings for map style and preferred currency. - Updated Slovak (sk.json) with additional details for map style and preferred currency. - Enhanced Swedish (sv.json) with new strings for map style and preferred currency. - Added new strings in Turkish (tr.json) for map style and preferred currency. - Updated Ukrainian (uk.json) with new strings for map style and preferred currency. - Enhanced Chinese (zh.json) with additional details for map style and preferred currency. * feat: Implement cloud billing system with subscription management - Added billing app with models, serializers, views, and admin configuration for Subscription. - Integrated Stripe for payment processing and subscription management. - Created middleware for cloud access control based on user subscriptions. - Developed frontend components for subscription status and billing management. - Updated settings to include cloud mode and Stripe configuration. - Added routes for billing and subscription management in the API. - Implemented user subscription creation and management on user registration and checkout. * feat: Implement media storage limit and usage tracking - Added media storage limit configuration in settings. - Introduced utility functions for managing media storage limits and usage. - Updated models to delete media files using storage backend. - Enhanced serializers to build media URLs dynamically. - Implemented media usage tracking in user views and added endpoint for fetching media usage. - Updated frontend to display media storage usage and limits. - Added translations for media storage related strings in multiple languages. * feat: Implement conditional throttling for various external services and update settings for rate limits
304 lines
11 KiB
Python
304 lines
11 KiB
Python
from datetime import datetime, timezone as dt_timezone
|
|
|
|
import stripe
|
|
from django.conf import settings
|
|
from django.utils import timezone
|
|
from django.utils.decorators import method_decorator
|
|
from django.views.decorators.csrf import csrf_exempt
|
|
from rest_framework import status
|
|
from rest_framework.permissions import IsAuthenticated
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
|
|
from cloud.utils import get_or_create_subscription
|
|
from .models import Subscription
|
|
from .serializers import SubscriptionSerializer
|
|
|
|
|
|
def _get_billing_email(user):
|
|
try:
|
|
from allauth.account.models import EmailAddress
|
|
except Exception:
|
|
return user.email or None
|
|
|
|
primary = EmailAddress.objects.filter(user=user, primary=True).first()
|
|
if primary and primary.email:
|
|
return primary.email
|
|
|
|
any_email = EmailAddress.objects.filter(user=user).order_by("id").first()
|
|
if any_email and any_email.email:
|
|
return any_email.email
|
|
|
|
return user.email or None
|
|
|
|
|
|
def _find_existing_customer_id(billing_email, user_id):
|
|
if billing_email:
|
|
try:
|
|
existing = stripe.Customer.list(email=billing_email, limit=1)
|
|
except stripe.error.StripeError:
|
|
existing = None
|
|
|
|
if existing:
|
|
existing_data = existing.get("data", [])
|
|
if existing_data:
|
|
return existing_data[0].get("id")
|
|
|
|
if user_id:
|
|
try:
|
|
query = f"metadata['user_id']:'{user_id}'"
|
|
results = stripe.Customer.search(query=query, limit=1)
|
|
except stripe.error.StripeError:
|
|
results = None
|
|
|
|
if results:
|
|
results_data = results.get("data", [])
|
|
if results_data:
|
|
return results_data[0].get("id")
|
|
|
|
return None
|
|
|
|
|
|
def _map_stripe_status(stripe_status: str) -> str:
|
|
mapping = {
|
|
"trialing": Subscription.STATUS_TRIAL,
|
|
"active": Subscription.STATUS_ACTIVE,
|
|
"past_due": Subscription.STATUS_PAST_DUE,
|
|
"unpaid": Subscription.STATUS_PAST_DUE,
|
|
"canceled": Subscription.STATUS_CANCELED,
|
|
"incomplete_expired": Subscription.STATUS_CANCELED,
|
|
}
|
|
return mapping.get(stripe_status, Subscription.STATUS_PAST_DUE)
|
|
|
|
|
|
def _apply_stripe_subscription(subscription: Subscription, stripe_subscription: dict) -> None:
|
|
subscription.status = _map_stripe_status(stripe_subscription.get("status", ""))
|
|
subscription.stripe_subscription_id = stripe_subscription.get("id", "")
|
|
stripe_customer_id = stripe_subscription.get("customer")
|
|
if stripe_customer_id:
|
|
subscription.stripe_customer_id = stripe_customer_id
|
|
|
|
subscription.cancel_at_period_end = bool(stripe_subscription.get("cancel_at_period_end", False))
|
|
|
|
current_period_end = stripe_subscription.get("current_period_end")
|
|
if current_period_end:
|
|
subscription.current_period_ends_at = datetime.fromtimestamp(
|
|
current_period_end, tz=dt_timezone.utc
|
|
)
|
|
|
|
trial_end = stripe_subscription.get("trial_end")
|
|
if trial_end:
|
|
subscription.trial_ends_at = datetime.fromtimestamp(trial_end, tz=dt_timezone.utc)
|
|
|
|
subscription.save()
|
|
|
|
|
|
class SubscriptionStatusView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def get(self, request):
|
|
subscription = get_or_create_subscription(request.user)
|
|
serializer = SubscriptionSerializer(subscription)
|
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
|
|
|
|
class CreateCheckoutSessionView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def post(self, request):
|
|
if not settings.CLOUD_MODE:
|
|
return Response(
|
|
{"detail": "Cloud billing is disabled for this instance."},
|
|
status=status.HTTP_404_NOT_FOUND,
|
|
)
|
|
|
|
if not settings.STRIPE_SECRET_KEY or not settings.STRIPE_PRICE_ID:
|
|
return Response(
|
|
{"detail": "Stripe is not configured."},
|
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
)
|
|
|
|
subscription = get_or_create_subscription(request.user)
|
|
if subscription.status == Subscription.STATUS_ACTIVE:
|
|
return Response(
|
|
{"detail": "Subscription already active."},
|
|
status=status.HTTP_409_CONFLICT,
|
|
)
|
|
|
|
stripe.api_key = settings.STRIPE_SECRET_KEY
|
|
|
|
customer_id = subscription.stripe_customer_id
|
|
billing_email = _get_billing_email(request.user)
|
|
if not customer_id:
|
|
customer_id = _find_existing_customer_id(billing_email, str(request.user.id))
|
|
if customer_id:
|
|
subscription.stripe_customer_id = customer_id
|
|
subscription.save()
|
|
|
|
if not customer_id:
|
|
customer = stripe.Customer.create(
|
|
email=billing_email or None,
|
|
metadata={"user_id": str(request.user.id)},
|
|
)
|
|
customer_id = customer.get("id")
|
|
if customer_id:
|
|
subscription.stripe_customer_id = customer_id
|
|
subscription.save()
|
|
|
|
if customer_id and billing_email:
|
|
try:
|
|
stripe.Customer.modify(customer_id, email=billing_email)
|
|
except stripe.error.StripeError:
|
|
pass
|
|
|
|
subscription_data = {
|
|
"metadata": {"user_id": str(request.user.id)},
|
|
}
|
|
|
|
if subscription.trial_ends_at and subscription.trial_ends_at > timezone.now():
|
|
subscription_data["trial_end"] = int(subscription.trial_ends_at.timestamp())
|
|
|
|
checkout_params = {
|
|
"mode": "subscription",
|
|
"line_items": [{"price": settings.STRIPE_PRICE_ID, "quantity": 1}],
|
|
"success_url": f"{settings.FRONTEND_URL}/subscribe?success=1",
|
|
"cancel_url": f"{settings.FRONTEND_URL}/subscribe?canceled=1",
|
|
"client_reference_id": str(request.user.id),
|
|
"subscription_data": subscription_data,
|
|
}
|
|
|
|
if customer_id:
|
|
checkout_params["customer"] = customer_id
|
|
elif billing_email:
|
|
checkout_params["customer_email"] = billing_email
|
|
|
|
try:
|
|
session = stripe.checkout.Session.create(**checkout_params)
|
|
except stripe.error.StripeError as exc:
|
|
return Response(
|
|
{"detail": str(exc)},
|
|
status=status.HTTP_502_BAD_GATEWAY,
|
|
)
|
|
|
|
return Response({"url": session.url}, status=status.HTTP_200_OK)
|
|
|
|
|
|
class CreatePortalSessionView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def post(self, request):
|
|
if not settings.CLOUD_MODE:
|
|
return Response(
|
|
{"detail": "Cloud billing is disabled for this instance."},
|
|
status=status.HTTP_404_NOT_FOUND,
|
|
)
|
|
|
|
if not settings.STRIPE_SECRET_KEY:
|
|
return Response(
|
|
{"detail": "Stripe is not configured."},
|
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
)
|
|
|
|
subscription = get_or_create_subscription(request.user)
|
|
if not subscription.stripe_customer_id:
|
|
return Response(
|
|
{"detail": "No Stripe customer exists for this account."},
|
|
status=status.HTTP_409_CONFLICT,
|
|
)
|
|
|
|
stripe.api_key = settings.STRIPE_SECRET_KEY
|
|
|
|
try:
|
|
session = stripe.billing_portal.Session.create(
|
|
customer=subscription.stripe_customer_id,
|
|
return_url=f"{settings.FRONTEND_URL}/subscribe",
|
|
)
|
|
except stripe.error.StripeError as exc:
|
|
return Response(
|
|
{"detail": str(exc)},
|
|
status=status.HTTP_502_BAD_GATEWAY,
|
|
)
|
|
|
|
return Response({"url": session.url}, status=status.HTTP_200_OK)
|
|
|
|
|
|
@method_decorator(csrf_exempt, name="dispatch")
|
|
class StripeWebhookView(APIView):
|
|
authentication_classes = []
|
|
permission_classes = []
|
|
|
|
def post(self, request):
|
|
if not settings.STRIPE_SECRET_KEY or not settings.STRIPE_WEBHOOK_SECRET:
|
|
return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
|
|
|
stripe.api_key = settings.STRIPE_SECRET_KEY
|
|
payload = request.body
|
|
sig_header = request.META.get("HTTP_STRIPE_SIGNATURE", "")
|
|
|
|
try:
|
|
event = stripe.Webhook.construct_event(
|
|
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
|
|
)
|
|
except ValueError:
|
|
return Response(status=status.HTTP_400_BAD_REQUEST)
|
|
except stripe.error.SignatureVerificationError:
|
|
return Response(status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
event_type = event.get("type")
|
|
data = event.get("data", {}).get("object", {})
|
|
|
|
if event_type == "checkout.session.completed":
|
|
user_id = data.get("client_reference_id")
|
|
customer_id = data.get("customer")
|
|
subscription_id = data.get("subscription")
|
|
|
|
subscription = None
|
|
if user_id:
|
|
subscription = Subscription.objects.filter(user_id=user_id).first()
|
|
if subscription is None and customer_id:
|
|
subscription = Subscription.objects.filter(stripe_customer_id=customer_id).first()
|
|
|
|
if subscription:
|
|
if customer_id:
|
|
subscription.stripe_customer_id = customer_id
|
|
if subscription_id:
|
|
subscription.stripe_subscription_id = subscription_id
|
|
|
|
if subscription_id:
|
|
try:
|
|
stripe_subscription = stripe.Subscription.retrieve(subscription_id)
|
|
except stripe.error.StripeError:
|
|
stripe_subscription = None
|
|
|
|
if stripe_subscription:
|
|
_apply_stripe_subscription(subscription, stripe_subscription)
|
|
else:
|
|
subscription.save()
|
|
else:
|
|
subscription.save()
|
|
|
|
if event_type in (
|
|
"customer.subscription.created",
|
|
"customer.subscription.updated",
|
|
"customer.subscription.deleted",
|
|
):
|
|
stripe_subscription = data
|
|
subscription_id = stripe_subscription.get("id")
|
|
customer_id = stripe_subscription.get("customer")
|
|
user_id = stripe_subscription.get("metadata", {}).get("user_id")
|
|
|
|
subscription = None
|
|
if subscription_id:
|
|
subscription = Subscription.objects.filter(
|
|
stripe_subscription_id=subscription_id
|
|
).first()
|
|
if subscription is None and customer_id:
|
|
subscription = Subscription.objects.filter(stripe_customer_id=customer_id).first()
|
|
if subscription is None and user_id:
|
|
subscription = Subscription.objects.filter(user_id=user_id).first()
|
|
|
|
if subscription:
|
|
_apply_stripe_subscription(subscription, stripe_subscription)
|
|
|
|
return Response(status=status.HTTP_200_OK)
|