Spaces:
Paused
Paused
Maksymilian Jankowski commited on
Commit ·
93f5fba
1
Parent(s): 3c91104
subscriptions
Browse files- main.py +3 -109
- routers/payments.py +472 -0
- subscription_integration_example.py +193 -0
- subscription_setup.md +259 -0
main.py
CHANGED
|
@@ -12,9 +12,9 @@ import random
|
|
| 12 |
from io import BytesIO
|
| 13 |
from urllib.parse import unquote
|
| 14 |
from fastapi.responses import StreamingResponse, JSONResponse
|
| 15 |
-
import stripe
|
| 16 |
from typing import Optional
|
| 17 |
-
from routers import user_models
|
|
|
|
| 18 |
|
| 19 |
load_dotenv()
|
| 20 |
|
|
@@ -55,6 +55,7 @@ app = FastAPI(
|
|
| 55 |
|
| 56 |
# Include routers
|
| 57 |
app.include_router(user_models.router)
|
|
|
|
| 58 |
|
| 59 |
origins = [
|
| 60 |
"https://www.3dai.co.uk",
|
|
@@ -111,12 +112,7 @@ class PlaceOrderRequest(BaseModel):
|
|
| 111 |
payment_status: str = "pending"
|
| 112 |
transaction_id: str = None
|
| 113 |
|
| 114 |
-
class CreatePaymentIntentRequest(BaseModel):
|
| 115 |
-
plan: str
|
| 116 |
|
| 117 |
-
class ConfirmPaymentRequest(BaseModel):
|
| 118 |
-
payment_intent_id: str
|
| 119 |
-
plan: str
|
| 120 |
|
| 121 |
# Auth endpoints
|
| 122 |
@app.post("/auth/signup", tags=["Authentication"])
|
|
@@ -703,109 +699,7 @@ async def purchase_credits(request: PurchaseCreditsRequest, current_user: User =
|
|
| 703 |
supabase.from_("User_Credit_Account").insert({"user_id": current_user.id, "num_of_available_gens": new_credits}).execute()
|
| 704 |
return {"message": "Credits purchased successfully.", "total_credits": new_credits}
|
| 705 |
|
| 706 |
-
# Stripe Payment endpoints
|
| 707 |
-
@app.post("/payment/create-payment-intent", tags=["Payment"])
|
| 708 |
-
async def create_payment_intent(
|
| 709 |
-
request: CreatePaymentIntentRequest,
|
| 710 |
-
current_user: User = Depends(get_current_active_user)
|
| 711 |
-
):
|
| 712 |
-
"""
|
| 713 |
-
Create a Stripe payment intent for purchasing credits.
|
| 714 |
-
"""
|
| 715 |
-
try:
|
| 716 |
-
# Define plan pricing (only one plan for now)
|
| 717 |
-
plan_pricing = {
|
| 718 |
-
"credits_15": {"credits": 15, "amount": 300, "currency": "gbp"} # £3.00 in pence
|
| 719 |
-
}
|
| 720 |
-
|
| 721 |
-
if request.plan not in plan_pricing:
|
| 722 |
-
raise HTTPException(status_code=400, detail="Invalid plan selected")
|
| 723 |
-
|
| 724 |
-
plan = plan_pricing[request.plan]
|
| 725 |
-
|
| 726 |
-
# Create payment intent
|
| 727 |
-
intent = stripe.PaymentIntent.create(
|
| 728 |
-
amount=plan["amount"],
|
| 729 |
-
currency=plan["currency"],
|
| 730 |
-
metadata={
|
| 731 |
-
"user_id": current_user.id,
|
| 732 |
-
"plan": request.plan,
|
| 733 |
-
"credits": plan["credits"]
|
| 734 |
-
}
|
| 735 |
-
)
|
| 736 |
-
|
| 737 |
-
return {
|
| 738 |
-
"client_secret": intent.client_secret,
|
| 739 |
-
"amount": plan["amount"],
|
| 740 |
-
"currency": plan["currency"],
|
| 741 |
-
"credits": plan["credits"]
|
| 742 |
-
}
|
| 743 |
-
|
| 744 |
-
except stripe.error.StripeError as e:
|
| 745 |
-
raise HTTPException(status_code=400, detail=str(e))
|
| 746 |
-
except Exception as e:
|
| 747 |
-
logging.error(f"Failed to create payment intent: {str(e)}")
|
| 748 |
-
raise HTTPException(status_code=500, detail="Failed to create payment intent")
|
| 749 |
|
| 750 |
-
@app.post("/payment/confirm-payment", tags=["Payment"])
|
| 751 |
-
async def confirm_payment(
|
| 752 |
-
request: ConfirmPaymentRequest,
|
| 753 |
-
current_user: User = Depends(get_current_active_user)
|
| 754 |
-
):
|
| 755 |
-
"""
|
| 756 |
-
Confirm payment and add credits to user account.
|
| 757 |
-
"""
|
| 758 |
-
try:
|
| 759 |
-
# Retrieve the payment intent to verify it succeeded
|
| 760 |
-
intent = stripe.PaymentIntent.retrieve(request.payment_intent_id)
|
| 761 |
-
|
| 762 |
-
if intent.status != "succeeded":
|
| 763 |
-
raise HTTPException(status_code=400, detail="Payment has not succeeded")
|
| 764 |
-
|
| 765 |
-
# Verify the payment belongs to the current user
|
| 766 |
-
if intent.metadata.get("user_id") != current_user.id:
|
| 767 |
-
raise HTTPException(status_code=403, detail="Payment does not belong to current user")
|
| 768 |
-
|
| 769 |
-
# Check if this payment has already been processed
|
| 770 |
-
existing_record = supabase.from_("Credit_Order_History").select("*").eq("transaction_id", request.payment_intent_id).execute()
|
| 771 |
-
if existing_record.data:
|
| 772 |
-
return {"message": "Payment already processed", "total_credits": None}
|
| 773 |
-
|
| 774 |
-
# Get plan details from metadata
|
| 775 |
-
credits_to_add = int(intent.metadata.get("credits", 0))
|
| 776 |
-
amount_paid = intent.amount / 100 # Convert from pence to pounds
|
| 777 |
-
|
| 778 |
-
# Record the purchase in history
|
| 779 |
-
supabase.from_("Credit_Order_History").insert({
|
| 780 |
-
"user_id": current_user.id,
|
| 781 |
-
"price": amount_paid,
|
| 782 |
-
"number_of_generations": credits_to_add,
|
| 783 |
-
"order_date": "now()",
|
| 784 |
-
"payment_status": "paid",
|
| 785 |
-
"transaction_id": request.payment_intent_id
|
| 786 |
-
}).execute()
|
| 787 |
-
|
| 788 |
-
# Update user credits
|
| 789 |
-
credit = supabase.from_("User_Credit_Account").select("num_of_available_gens").eq("user_id", current_user.id).single().execute()
|
| 790 |
-
current_credits = credit.data["num_of_available_gens"] if credit.data else 0
|
| 791 |
-
new_credits = current_credits + credits_to_add
|
| 792 |
-
|
| 793 |
-
if credit.data:
|
| 794 |
-
supabase.from_("User_Credit_Account").update({"num_of_available_gens": new_credits}).eq("user_id", current_user.id).execute()
|
| 795 |
-
else:
|
| 796 |
-
supabase.from_("User_Credit_Account").insert({"user_id": current_user.id, "num_of_available_gens": new_credits}).execute()
|
| 797 |
-
|
| 798 |
-
return {
|
| 799 |
-
"message": "Credits added successfully",
|
| 800 |
-
"credits_added": credits_to_add,
|
| 801 |
-
"total_credits": new_credits
|
| 802 |
-
}
|
| 803 |
-
|
| 804 |
-
except stripe.error.StripeError as e:
|
| 805 |
-
raise HTTPException(status_code=400, detail=str(e))
|
| 806 |
-
except Exception as e:
|
| 807 |
-
logging.error(f"Failed to confirm payment: {str(e)}")
|
| 808 |
-
raise HTTPException(status_code=500, detail="Failed to confirm payment")
|
| 809 |
|
| 810 |
# User dashboard endpoints
|
| 811 |
@app.get("/user/profile", tags=["User"])
|
|
|
|
| 12 |
from io import BytesIO
|
| 13 |
from urllib.parse import unquote
|
| 14 |
from fastapi.responses import StreamingResponse, JSONResponse
|
|
|
|
| 15 |
from typing import Optional
|
| 16 |
+
from routers import user_models, payments
|
| 17 |
+
import stripe
|
| 18 |
|
| 19 |
load_dotenv()
|
| 20 |
|
|
|
|
| 55 |
|
| 56 |
# Include routers
|
| 57 |
app.include_router(user_models.router)
|
| 58 |
+
app.include_router(payments.router)
|
| 59 |
|
| 60 |
origins = [
|
| 61 |
"https://www.3dai.co.uk",
|
|
|
|
| 112 |
payment_status: str = "pending"
|
| 113 |
transaction_id: str = None
|
| 114 |
|
|
|
|
|
|
|
| 115 |
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
# Auth endpoints
|
| 118 |
@app.post("/auth/signup", tags=["Authentication"])
|
|
|
|
| 699 |
supabase.from_("User_Credit_Account").insert({"user_id": current_user.id, "num_of_available_gens": new_credits}).execute()
|
| 700 |
return {"message": "Credits purchased successfully.", "total_credits": new_credits}
|
| 701 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 702 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 703 |
|
| 704 |
# User dashboard endpoints
|
| 705 |
@app.get("/user/profile", tags=["User"])
|
routers/payments.py
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
from auth import get_current_active_user, User, supabase
|
| 4 |
+
import stripe
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Optional, Dict, Any
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
router = APIRouter(
|
| 12 |
+
prefix="/payment",
|
| 13 |
+
tags=["Payment"]
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
# Pydantic models
|
| 17 |
+
class CreatePaymentIntentRequest(BaseModel):
|
| 18 |
+
plan: str
|
| 19 |
+
|
| 20 |
+
class ConfirmPaymentRequest(BaseModel):
|
| 21 |
+
payment_intent_id: str
|
| 22 |
+
plan: str
|
| 23 |
+
|
| 24 |
+
class CreateSubscriptionRequest(BaseModel):
|
| 25 |
+
plan: str
|
| 26 |
+
|
| 27 |
+
class CancelSubscriptionRequest(BaseModel):
|
| 28 |
+
subscription_id: str
|
| 29 |
+
|
| 30 |
+
class UpdateSubscriptionRequest(BaseModel):
|
| 31 |
+
subscription_id: str
|
| 32 |
+
new_plan: str
|
| 33 |
+
|
| 34 |
+
# Subscription plan configurations
|
| 35 |
+
SUBSCRIPTION_PLANS = {
|
| 36 |
+
"basic": {
|
| 37 |
+
"credits_per_month": 50,
|
| 38 |
+
"price_gbp": 9.99,
|
| 39 |
+
"stripe_price_id": "price_1Rg7PzG37ntLhOSq6Rq1HhRN",
|
| 40 |
+
"name": "Basic Plan",
|
| 41 |
+
"description": "50 credits per month"
|
| 42 |
+
},
|
| 43 |
+
"pro": {
|
| 44 |
+
"credits_per_month": 150,
|
| 45 |
+
"price_gbp": 24.99,
|
| 46 |
+
"stripe_price_id": "price_1Rg7QqG37ntLhOSqzCIx5njr",
|
| 47 |
+
"name": "Pro Plan",
|
| 48 |
+
"description": "150 credits per month"
|
| 49 |
+
},
|
| 50 |
+
"premium": {
|
| 51 |
+
"credits_per_month": 300,
|
| 52 |
+
"price_gbp": 39.99,
|
| 53 |
+
"stripe_price_id": "price_1Rg7RJG37ntLhOSqTHd9LeVU",
|
| 54 |
+
"name": "Premium Plan",
|
| 55 |
+
"description": "300 credits per month"
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
# Stripe Payment endpoints
|
| 60 |
+
@router.post("/create-payment-intent")
|
| 61 |
+
async def create_payment_intent(
|
| 62 |
+
request: CreatePaymentIntentRequest,
|
| 63 |
+
current_user: User = Depends(get_current_active_user)
|
| 64 |
+
):
|
| 65 |
+
"""
|
| 66 |
+
Create a Stripe payment intent for purchasing credits.
|
| 67 |
+
"""
|
| 68 |
+
try:
|
| 69 |
+
# Define plan pricing (only one plan for now)
|
| 70 |
+
plan_pricing = {
|
| 71 |
+
"credits_15": {"credits": 15, "amount": 300, "currency": "gbp"} # £3.00 in pence
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
if request.plan not in plan_pricing:
|
| 75 |
+
raise HTTPException(status_code=400, detail="Invalid plan selected")
|
| 76 |
+
|
| 77 |
+
plan = plan_pricing[request.plan]
|
| 78 |
+
|
| 79 |
+
# Create payment intent
|
| 80 |
+
intent = stripe.PaymentIntent.create(
|
| 81 |
+
amount=plan["amount"],
|
| 82 |
+
currency=plan["currency"],
|
| 83 |
+
metadata={
|
| 84 |
+
"user_id": current_user.id,
|
| 85 |
+
"plan": request.plan,
|
| 86 |
+
"credits": plan["credits"]
|
| 87 |
+
}
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
return {
|
| 91 |
+
"client_secret": intent.client_secret,
|
| 92 |
+
"amount": plan["amount"],
|
| 93 |
+
"currency": plan["currency"],
|
| 94 |
+
"credits": plan["credits"]
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
except HTTPException as e:
|
| 98 |
+
# Re-raise custom HTTP errors such as "Invalid plan selected" without modification
|
| 99 |
+
raise e
|
| 100 |
+
except stripe.error.StripeError as e:
|
| 101 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 102 |
+
except Exception as e:
|
| 103 |
+
logging.error(f"Failed to create payment intent: {str(e)}")
|
| 104 |
+
raise HTTPException(status_code=500, detail="Failed to create payment intent")
|
| 105 |
+
|
| 106 |
+
@router.post("/confirm-payment")
|
| 107 |
+
async def confirm_payment(
|
| 108 |
+
request: ConfirmPaymentRequest,
|
| 109 |
+
current_user: User = Depends(get_current_active_user)
|
| 110 |
+
):
|
| 111 |
+
"""
|
| 112 |
+
Confirm payment and add credits to user account.
|
| 113 |
+
"""
|
| 114 |
+
try:
|
| 115 |
+
# Retrieve the payment intent to verify it succeeded
|
| 116 |
+
intent = stripe.PaymentIntent.retrieve(request.payment_intent_id)
|
| 117 |
+
|
| 118 |
+
if intent.status != "succeeded":
|
| 119 |
+
raise HTTPException(status_code=400, detail="Payment has not succeeded")
|
| 120 |
+
|
| 121 |
+
# Verify the payment belongs to the current user
|
| 122 |
+
if intent.metadata.get("user_id") != current_user.id:
|
| 123 |
+
raise HTTPException(status_code=403, detail="Payment does not belong to current user")
|
| 124 |
+
|
| 125 |
+
# Check if this payment has already been processed
|
| 126 |
+
existing_record = supabase.from_("Credit_Order_History").select("*").eq("transaction_id", request.payment_intent_id).execute()
|
| 127 |
+
if existing_record.data:
|
| 128 |
+
return {"message": "Payment already processed", "total_credits": None}
|
| 129 |
+
|
| 130 |
+
# Get plan details from metadata
|
| 131 |
+
credits_to_add = int(intent.metadata.get("credits", 0))
|
| 132 |
+
amount_paid = intent.amount / 100 # Convert from pence to pounds
|
| 133 |
+
|
| 134 |
+
# Record the purchase in history
|
| 135 |
+
supabase.from_("Credit_Order_History").insert({
|
| 136 |
+
"user_id": current_user.id,
|
| 137 |
+
"price": amount_paid,
|
| 138 |
+
"number_of_generations": credits_to_add,
|
| 139 |
+
"order_date": "now()",
|
| 140 |
+
"payment_status": "paid",
|
| 141 |
+
"transaction_id": request.payment_intent_id
|
| 142 |
+
}).execute()
|
| 143 |
+
|
| 144 |
+
# Update user credits
|
| 145 |
+
credit = supabase.from_("User_Credit_Account").select("num_of_available_gens").eq("user_id", current_user.id).single().execute()
|
| 146 |
+
current_credits = credit.data["num_of_available_gens"] if credit.data else 0
|
| 147 |
+
new_credits = current_credits + credits_to_add
|
| 148 |
+
|
| 149 |
+
if credit.data:
|
| 150 |
+
supabase.from_("User_Credit_Account").update({"num_of_available_gens": new_credits}).eq("user_id", current_user.id).execute()
|
| 151 |
+
else:
|
| 152 |
+
supabase.from_("User_Credit_Account").insert({"user_id": current_user.id, "num_of_available_gens": new_credits}).execute()
|
| 153 |
+
|
| 154 |
+
return {
|
| 155 |
+
"message": "Credits added successfully",
|
| 156 |
+
"credits_added": credits_to_add,
|
| 157 |
+
"total_credits": new_credits
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
except stripe.error.StripeError as e:
|
| 161 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 162 |
+
except Exception as e:
|
| 163 |
+
logging.error(f"Failed to confirm payment: {str(e)}")
|
| 164 |
+
raise HTTPException(status_code=500, detail="Failed to confirm payment")
|
| 165 |
+
|
| 166 |
+
# Subscription endpoints
|
| 167 |
+
@router.get("/subscription-plans")
|
| 168 |
+
async def get_subscription_plans():
|
| 169 |
+
"""
|
| 170 |
+
Get available subscription plans.
|
| 171 |
+
"""
|
| 172 |
+
return {"plans": SUBSCRIPTION_PLANS}
|
| 173 |
+
|
| 174 |
+
@router.post("/create-subscription")
|
| 175 |
+
async def create_subscription(
|
| 176 |
+
request: CreateSubscriptionRequest,
|
| 177 |
+
current_user: User = Depends(get_current_active_user)
|
| 178 |
+
):
|
| 179 |
+
"""
|
| 180 |
+
Create a new subscription for the user.
|
| 181 |
+
"""
|
| 182 |
+
try:
|
| 183 |
+
if request.plan not in SUBSCRIPTION_PLANS:
|
| 184 |
+
raise HTTPException(status_code=400, detail="Invalid subscription plan")
|
| 185 |
+
|
| 186 |
+
plan = SUBSCRIPTION_PLANS[request.plan]
|
| 187 |
+
|
| 188 |
+
# Check if user already has an active subscription
|
| 189 |
+
existing_sub = supabase.from_("User_Subscriptions").select("*").eq("user_id", current_user.id).eq("status", "active").execute()
|
| 190 |
+
if existing_sub.data:
|
| 191 |
+
raise HTTPException(status_code=400, detail="User already has an active subscription")
|
| 192 |
+
|
| 193 |
+
# Get or create Stripe customer
|
| 194 |
+
customer_record = supabase.from_("Stripe_Customers").select("*").eq("user_id", current_user.id).execute()
|
| 195 |
+
|
| 196 |
+
if customer_record.data:
|
| 197 |
+
customer_id = customer_record.data[0]["stripe_customer_id"]
|
| 198 |
+
else:
|
| 199 |
+
# Create new Stripe customer
|
| 200 |
+
user_data = supabase.from_("User").select("*").eq("user_id", current_user.id).execute()
|
| 201 |
+
customer = stripe.Customer.create(
|
| 202 |
+
email=current_user.email,
|
| 203 |
+
metadata={"user_id": current_user.id}
|
| 204 |
+
)
|
| 205 |
+
customer_id = customer.id
|
| 206 |
+
|
| 207 |
+
# Store customer ID in database
|
| 208 |
+
supabase.from_("Stripe_Customers").insert({
|
| 209 |
+
"user_id": current_user.id,
|
| 210 |
+
"stripe_customer_id": customer_id
|
| 211 |
+
}).execute()
|
| 212 |
+
|
| 213 |
+
# Create subscription with proper setup for Stripe Elements
|
| 214 |
+
# Use expand to get both setup_intent and payment_intent depending on what Stripe creates
|
| 215 |
+
subscription = stripe.Subscription.create(
|
| 216 |
+
customer=customer_id,
|
| 217 |
+
items=[{"price": plan["stripe_price_id"]}],
|
| 218 |
+
payment_behavior="default_incomplete",
|
| 219 |
+
payment_settings={"save_default_payment_method": "on_subscription"},
|
| 220 |
+
expand=["latest_invoice.confirmation_secret", "pending_setup_intent"],
|
| 221 |
+
metadata={
|
| 222 |
+
"user_id": current_user.id,
|
| 223 |
+
"plan": request.plan
|
| 224 |
+
}
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
# ----------------- Persist subscription locally -----------------
|
| 228 |
+
subscription_data = {
|
| 229 |
+
"user_id": current_user.id,
|
| 230 |
+
"stripe_subscription_id": subscription.id,
|
| 231 |
+
"plan": request.plan,
|
| 232 |
+
"status": subscription.status,
|
| 233 |
+
"created_at": datetime.now().isoformat(),
|
| 234 |
+
}
|
| 235 |
+
cps = getattr(subscription, "current_period_start", None)
|
| 236 |
+
cpe = getattr(subscription, "current_period_end", None)
|
| 237 |
+
if cps:
|
| 238 |
+
subscription_data["current_period_start"] = datetime.fromtimestamp(cps).isoformat()
|
| 239 |
+
if cpe:
|
| 240 |
+
subscription_data["current_period_end"] = datetime.fromtimestamp(cpe).isoformat()
|
| 241 |
+
supabase.from_("User_Subscriptions").insert(subscription_data).execute()
|
| 242 |
+
|
| 243 |
+
# ----------------- Retrieve the client secret ------------------
|
| 244 |
+
# For API version 2025-03-31 ("Basil") the PaymentIntent secret is
|
| 245 |
+
# located at invoice.confirmation_secret.client_secret. If the
|
| 246 |
+
# subscription starts with a £0 trial Stripe instead provides a
|
| 247 |
+
# SetupIntent whose secret is on pending_setup_intent.client_secret.
|
| 248 |
+
client_secret: Optional[str] = None
|
| 249 |
+
if subscription.latest_invoice and getattr(subscription.latest_invoice, "confirmation_secret", None):
|
| 250 |
+
client_secret = subscription.latest_invoice.confirmation_secret.client_secret
|
| 251 |
+
elif getattr(subscription, "pending_setup_intent", None):
|
| 252 |
+
client_secret = subscription.pending_setup_intent.client_secret
|
| 253 |
+
|
| 254 |
+
if not client_secret:
|
| 255 |
+
logging.error(
|
| 256 |
+
"Unable to retrieve Stripe client secret for subscription %s", subscription.id
|
| 257 |
+
)
|
| 258 |
+
raise HTTPException(status_code=500, detail="Failed to retrieve payment client secret")
|
| 259 |
+
|
| 260 |
+
return {
|
| 261 |
+
"subscription_id": subscription.id,
|
| 262 |
+
"client_secret": client_secret,
|
| 263 |
+
"status": subscription.status,
|
| 264 |
+
"plan": request.plan,
|
| 265 |
+
"price": plan["price_gbp"],
|
| 266 |
+
"currency": "gbp",
|
| 267 |
+
"credits_per_month": plan["credits_per_month"],
|
| 268 |
+
"price_per_credit": round(plan["price_gbp"] / plan["credits_per_month"], 2)
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
except stripe.error.StripeError as e:
|
| 272 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 273 |
+
except Exception as e:
|
| 274 |
+
logging.error(f"Failed to create subscription: {str(e)}")
|
| 275 |
+
raise HTTPException(status_code=500, detail="Failed to create subscription")
|
| 276 |
+
|
| 277 |
+
@router.get("/subscription")
|
| 278 |
+
async def get_user_subscription(current_user: User = Depends(get_current_active_user)):
|
| 279 |
+
"""
|
| 280 |
+
Get user's current subscription details.
|
| 281 |
+
"""
|
| 282 |
+
try:
|
| 283 |
+
subscription_record = supabase.from_("User_Subscriptions").select("*").eq("user_id", current_user.id).order("created_at", desc=True).limit(1).execute()
|
| 284 |
+
|
| 285 |
+
if not subscription_record.data:
|
| 286 |
+
return {"subscription": None}
|
| 287 |
+
|
| 288 |
+
subscription_data = subscription_record.data[0]
|
| 289 |
+
|
| 290 |
+
# Get latest status from Stripe
|
| 291 |
+
stripe_subscription = stripe.Subscription.retrieve(subscription_data["stripe_subscription_id"])
|
| 292 |
+
|
| 293 |
+
# Update local status if different
|
| 294 |
+
if stripe_subscription.status != subscription_data["status"]:
|
| 295 |
+
update_data = {"status": stripe_subscription.status}
|
| 296 |
+
|
| 297 |
+
# Only update period dates if they exist (None for incomplete subscriptions)
|
| 298 |
+
cps = getattr(stripe_subscription, "current_period_start", None)
|
| 299 |
+
cpe = getattr(stripe_subscription, "current_period_end", None)
|
| 300 |
+
if cps:
|
| 301 |
+
update_data["current_period_start"] = datetime.fromtimestamp(cps).isoformat()
|
| 302 |
+
if cpe:
|
| 303 |
+
update_data["current_period_end"] = datetime.fromtimestamp(cpe).isoformat()
|
| 304 |
+
|
| 305 |
+
supabase.from_("User_Subscriptions").update(update_data).eq("id", subscription_data["id"]).execute()
|
| 306 |
+
|
| 307 |
+
subscription_data["status"] = stripe_subscription.status
|
| 308 |
+
|
| 309 |
+
return {
|
| 310 |
+
"subscription": {
|
| 311 |
+
**subscription_data,
|
| 312 |
+
"plan_details": SUBSCRIPTION_PLANS.get(subscription_data["plan"])
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
except stripe.error.StripeError as e:
|
| 317 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 318 |
+
except Exception as e:
|
| 319 |
+
logging.error(f"Failed to get subscription: {str(e)}")
|
| 320 |
+
raise HTTPException(status_code=500, detail="Failed to get subscription")
|
| 321 |
+
|
| 322 |
+
@router.post("/cancel-subscription")
|
| 323 |
+
async def cancel_subscription(
|
| 324 |
+
request: CancelSubscriptionRequest,
|
| 325 |
+
current_user: User = Depends(get_current_active_user)
|
| 326 |
+
):
|
| 327 |
+
"""
|
| 328 |
+
Cancel user's subscription.
|
| 329 |
+
"""
|
| 330 |
+
try:
|
| 331 |
+
# Verify subscription belongs to user
|
| 332 |
+
subscription_record = supabase.from_("User_Subscriptions").select("*").eq("stripe_subscription_id", request.subscription_id).eq("user_id", current_user.id).execute()
|
| 333 |
+
|
| 334 |
+
if not subscription_record.data:
|
| 335 |
+
raise HTTPException(status_code=404, detail="Subscription not found")
|
| 336 |
+
|
| 337 |
+
# Cancel subscription in Stripe
|
| 338 |
+
stripe.Subscription.modify(
|
| 339 |
+
request.subscription_id,
|
| 340 |
+
cancel_at_period_end=True
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
# Update local record
|
| 344 |
+
supabase.from_("User_Subscriptions").update({
|
| 345 |
+
"status": "cancel_at_period_end"
|
| 346 |
+
}).eq("stripe_subscription_id", request.subscription_id).execute()
|
| 347 |
+
|
| 348 |
+
return {"message": "Subscription will be canceled at the end of the current period"}
|
| 349 |
+
|
| 350 |
+
except stripe.error.StripeError as e:
|
| 351 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 352 |
+
except Exception as e:
|
| 353 |
+
logging.error(f"Failed to cancel subscription: {str(e)}")
|
| 354 |
+
raise HTTPException(status_code=500, detail="Failed to cancel subscription")
|
| 355 |
+
|
| 356 |
+
@router.post("/webhook")
|
| 357 |
+
async def stripe_webhook(request: Request):
|
| 358 |
+
"""
|
| 359 |
+
Handle Stripe webhook events for subscription management.
|
| 360 |
+
"""
|
| 361 |
+
payload = await request.body()
|
| 362 |
+
sig_header = request.headers.get("stripe-signature")
|
| 363 |
+
|
| 364 |
+
# You'll need to set this in your environment variables
|
| 365 |
+
endpoint_secret = os.getenv("STRIPE_WEBHOOK_SECRET")
|
| 366 |
+
|
| 367 |
+
if not endpoint_secret:
|
| 368 |
+
raise HTTPException(status_code=500, detail="Webhook secret not configured")
|
| 369 |
+
|
| 370 |
+
try:
|
| 371 |
+
# Recreate the Stripe event
|
| 372 |
+
try:
|
| 373 |
+
event = stripe.Webhook.construct_event(payload, sig_header, endpoint_secret)
|
| 374 |
+
except ValueError:
|
| 375 |
+
raise HTTPException(status_code=400, detail="Invalid payload")
|
| 376 |
+
except stripe.error.SignatureVerificationError:
|
| 377 |
+
raise HTTPException(status_code=400, detail="Invalid signature")
|
| 378 |
+
|
| 379 |
+
# Handle subscription events
|
| 380 |
+
if event["type"] == "invoice.payment_succeeded":
|
| 381 |
+
await handle_payment_succeeded(event["data"]["object"])
|
| 382 |
+
elif event["type"] == "invoice.payment_failed":
|
| 383 |
+
await handle_payment_failed(event["data"]["object"])
|
| 384 |
+
elif event["type"] == "customer.subscription.deleted":
|
| 385 |
+
await handle_subscription_deleted(event["data"]["object"])
|
| 386 |
+
elif event["type"] == "customer.subscription.updated":
|
| 387 |
+
await handle_subscription_updated(event["data"]["object"])
|
| 388 |
+
|
| 389 |
+
return {"status": "success"}
|
| 390 |
+
|
| 391 |
+
except Exception as e:
|
| 392 |
+
print(f"Webhook error: {str(e)}")
|
| 393 |
+
raise HTTPException(status_code=500, detail="Webhook processing failed")
|
| 394 |
+
|
| 395 |
+
async def handle_payment_succeeded(invoice):
|
| 396 |
+
"""Handle successful subscription payment."""
|
| 397 |
+
subscription_id = invoice["subscription"]
|
| 398 |
+
|
| 399 |
+
# Get subscription details
|
| 400 |
+
subscription = stripe.Subscription.retrieve(subscription_id)
|
| 401 |
+
user_id = subscription.metadata.get("user_id")
|
| 402 |
+
plan = subscription.metadata.get("plan")
|
| 403 |
+
|
| 404 |
+
if not user_id or not plan:
|
| 405 |
+
print("Missing user_id or plan in subscription metadata")
|
| 406 |
+
return
|
| 407 |
+
|
| 408 |
+
# Add credits to user account
|
| 409 |
+
plan_details = SUBSCRIPTION_PLANS.get(plan)
|
| 410 |
+
if plan_details:
|
| 411 |
+
credits_to_add = plan_details["credits_per_month"]
|
| 412 |
+
|
| 413 |
+
# Update user credits
|
| 414 |
+
credit_record = supabase.from_("User_Credit_Account").select("*").eq("user_id", user_id).execute()
|
| 415 |
+
|
| 416 |
+
if credit_record.data:
|
| 417 |
+
current_credits = credit_record.data[0]["num_of_available_gens"]
|
| 418 |
+
new_credits = current_credits + credits_to_add
|
| 419 |
+
|
| 420 |
+
supabase.from_("User_Credit_Account").update({
|
| 421 |
+
"num_of_available_gens": new_credits
|
| 422 |
+
}).eq("user_id", user_id).execute()
|
| 423 |
+
else:
|
| 424 |
+
supabase.from_("User_Credit_Account").insert({
|
| 425 |
+
"user_id": user_id,
|
| 426 |
+
"num_of_available_gens": credits_to_add
|
| 427 |
+
}).execute()
|
| 428 |
+
|
| 429 |
+
# Record the subscription payment
|
| 430 |
+
supabase.from_("Subscription_Payment_History").insert({
|
| 431 |
+
"user_id": user_id,
|
| 432 |
+
"subscription_id": subscription_id,
|
| 433 |
+
"invoice_id": invoice["id"],
|
| 434 |
+
"amount": invoice["amount_paid"] / 100, # Convert from pence
|
| 435 |
+
"credits_added": credits_to_add,
|
| 436 |
+
"payment_date": datetime.fromtimestamp(invoice["created"]).isoformat(),
|
| 437 |
+
"status": "paid"
|
| 438 |
+
}).execute()
|
| 439 |
+
|
| 440 |
+
async def handle_payment_failed(invoice):
|
| 441 |
+
"""Handle failed subscription payment."""
|
| 442 |
+
subscription_id = invoice["subscription"]
|
| 443 |
+
|
| 444 |
+
# Update subscription status
|
| 445 |
+
supabase.from_("User_Subscriptions").update({
|
| 446 |
+
"status": "past_due"
|
| 447 |
+
}).eq("stripe_subscription_id", subscription_id).execute()
|
| 448 |
+
|
| 449 |
+
async def handle_subscription_deleted(subscription):
|
| 450 |
+
"""Handle subscription cancellation."""
|
| 451 |
+
user_id = subscription.metadata.get("user_id")
|
| 452 |
+
|
| 453 |
+
# Update subscription status
|
| 454 |
+
supabase.from_("User_Subscriptions").update({
|
| 455 |
+
"status": "canceled",
|
| 456 |
+
"canceled_at": datetime.now().isoformat()
|
| 457 |
+
}).eq("stripe_subscription_id", subscription["id"]).execute()
|
| 458 |
+
|
| 459 |
+
async def handle_subscription_updated(subscription):
|
| 460 |
+
"""Handle subscription updates."""
|
| 461 |
+
# Update subscription details in database
|
| 462 |
+
update_data = {"status": subscription["status"]}
|
| 463 |
+
|
| 464 |
+
# Only update period dates if they exist (None for incomplete subscriptions)
|
| 465 |
+
cps = subscription.get("current_period_start")
|
| 466 |
+
cpe = subscription.get("current_period_end")
|
| 467 |
+
if cps:
|
| 468 |
+
update_data["current_period_start"] = datetime.fromtimestamp(cps).isoformat()
|
| 469 |
+
if cpe:
|
| 470 |
+
update_data["current_period_end"] = datetime.fromtimestamp(cpe).isoformat()
|
| 471 |
+
|
| 472 |
+
supabase.from_("User_Subscriptions").update(update_data).eq("stripe_subscription_id", subscription["id"]).execute()
|
subscription_integration_example.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Example: Integrating Subscriptions with Existing Credit System
|
| 2 |
+
# Add these functions to your main.py or create a separate utils module
|
| 3 |
+
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from auth import supabase
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
async def check_user_access_permissions(user_id: str) -> dict:
|
| 9 |
+
"""
|
| 10 |
+
Enhanced function to check if user can access AI generation features.
|
| 11 |
+
Checks both one-time credits and active subscriptions.
|
| 12 |
+
"""
|
| 13 |
+
try:
|
| 14 |
+
# Check for active subscription first
|
| 15 |
+
subscription_record = supabase.from_("User_Subscriptions").select("*").eq("user_id", user_id).eq("status", "active").execute()
|
| 16 |
+
|
| 17 |
+
has_active_subscription = bool(subscription_record.data)
|
| 18 |
+
|
| 19 |
+
# Get current credits
|
| 20 |
+
credit_record = supabase.from_("User_Credit_Account").select("num_of_available_gens").eq("user_id", user_id).execute()
|
| 21 |
+
current_credits = credit_record.data[0]["num_of_available_gens"] if credit_record.data else 0
|
| 22 |
+
|
| 23 |
+
# Determine access level
|
| 24 |
+
if has_active_subscription:
|
| 25 |
+
subscription = subscription_record.data[0]
|
| 26 |
+
return {
|
| 27 |
+
"can_generate": True,
|
| 28 |
+
"access_type": "subscription",
|
| 29 |
+
"subscription_plan": subscription["plan"],
|
| 30 |
+
"credits": current_credits,
|
| 31 |
+
"subscription_active": True,
|
| 32 |
+
"period_end": subscription["current_period_end"]
|
| 33 |
+
}
|
| 34 |
+
elif current_credits > 0:
|
| 35 |
+
return {
|
| 36 |
+
"can_generate": True,
|
| 37 |
+
"access_type": "credits",
|
| 38 |
+
"subscription_plan": None,
|
| 39 |
+
"credits": current_credits,
|
| 40 |
+
"subscription_active": False,
|
| 41 |
+
"period_end": None
|
| 42 |
+
}
|
| 43 |
+
else:
|
| 44 |
+
return {
|
| 45 |
+
"can_generate": False,
|
| 46 |
+
"access_type": None,
|
| 47 |
+
"subscription_plan": None,
|
| 48 |
+
"credits": 0,
|
| 49 |
+
"subscription_active": False,
|
| 50 |
+
"period_end": None
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
except Exception as e:
|
| 54 |
+
logging.error(f"Error checking user access: {str(e)}")
|
| 55 |
+
return {
|
| 56 |
+
"can_generate": False,
|
| 57 |
+
"access_type": None,
|
| 58 |
+
"subscription_plan": None,
|
| 59 |
+
"credits": 0,
|
| 60 |
+
"subscription_active": False,
|
| 61 |
+
"period_end": None,
|
| 62 |
+
"error": str(e)
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
async def check_and_decrement_credits_enhanced(user_id: str) -> bool:
|
| 66 |
+
"""
|
| 67 |
+
Enhanced version of your existing credit checking function.
|
| 68 |
+
Prioritizes subscription access over one-time credits.
|
| 69 |
+
"""
|
| 70 |
+
try:
|
| 71 |
+
access_info = await check_user_access_permissions(user_id)
|
| 72 |
+
|
| 73 |
+
if not access_info["can_generate"]:
|
| 74 |
+
return False
|
| 75 |
+
|
| 76 |
+
if access_info["access_type"] == "subscription":
|
| 77 |
+
# User has active subscription - allow generation without decrementing credits
|
| 78 |
+
# Credits accumulate for subscription users as a bonus
|
| 79 |
+
logging.info(f"User {user_id} using subscription access ({access_info['subscription_plan']})")
|
| 80 |
+
return True
|
| 81 |
+
|
| 82 |
+
elif access_info["access_type"] == "credits":
|
| 83 |
+
# User using one-time credits - decrement as before
|
| 84 |
+
current_credits = access_info["credits"]
|
| 85 |
+
if current_credits > 0:
|
| 86 |
+
new_credits = current_credits - 1
|
| 87 |
+
supabase.from_("User_Credit_Account").update({"num_of_available_gens": new_credits}).eq("user_id", user_id).execute()
|
| 88 |
+
logging.info(f"User {user_id} used 1 credit. Remaining: {new_credits}")
|
| 89 |
+
return True
|
| 90 |
+
|
| 91 |
+
return False
|
| 92 |
+
|
| 93 |
+
except Exception as e:
|
| 94 |
+
logging.error(f"Error in credit check: {str(e)}")
|
| 95 |
+
return False
|
| 96 |
+
|
| 97 |
+
# New endpoint to add to main.py
|
| 98 |
+
from fastapi import Depends
|
| 99 |
+
from auth import get_current_active_user, User
|
| 100 |
+
|
| 101 |
+
@app.get("/user/access-info", tags=["User"])
|
| 102 |
+
async def get_user_access_info(current_user: User = Depends(get_current_active_user)):
|
| 103 |
+
"""
|
| 104 |
+
Get comprehensive user access information including subscription and credits.
|
| 105 |
+
"""
|
| 106 |
+
access_info = await check_user_access_permissions(current_user.id)
|
| 107 |
+
return access_info
|
| 108 |
+
|
| 109 |
+
@app.get("/user/subscription-status", tags=["User"])
|
| 110 |
+
async def get_subscription_status(current_user: User = Depends(get_current_active_user)):
|
| 111 |
+
"""
|
| 112 |
+
Get detailed subscription status for the user.
|
| 113 |
+
"""
|
| 114 |
+
try:
|
| 115 |
+
# Get subscription details
|
| 116 |
+
subscription_record = supabase.from_("User_Subscriptions").select("*").eq("user_id", current_user.id).order("created_at", desc=True).limit(1).execute()
|
| 117 |
+
|
| 118 |
+
if not subscription_record.data:
|
| 119 |
+
return {
|
| 120 |
+
"has_subscription": False,
|
| 121 |
+
"subscription": None,
|
| 122 |
+
"next_billing_date": None,
|
| 123 |
+
"can_upgrade": True
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
subscription = subscription_record.data[0]
|
| 127 |
+
|
| 128 |
+
# Get payment history
|
| 129 |
+
payment_history = supabase.from_("Subscription_Payment_History").select("*").eq("user_id", current_user.id).order("payment_date", desc=True).limit(5).execute()
|
| 130 |
+
|
| 131 |
+
return {
|
| 132 |
+
"has_subscription": subscription["status"] in ["active", "cancel_at_period_end"],
|
| 133 |
+
"subscription": subscription,
|
| 134 |
+
"next_billing_date": subscription["current_period_end"],
|
| 135 |
+
"can_upgrade": subscription["status"] == "active",
|
| 136 |
+
"recent_payments": payment_history.data,
|
| 137 |
+
"subscription_plan_details": SUBSCRIPTION_PLANS.get(subscription["plan"]) if subscription["plan"] in SUBSCRIPTION_PLANS else None
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
except Exception as e:
|
| 141 |
+
logging.error(f"Error getting subscription status: {str(e)}")
|
| 142 |
+
raise HTTPException(status_code=500, detail="Failed to get subscription status")
|
| 143 |
+
|
| 144 |
+
# Usage tracking for subscription users
|
| 145 |
+
async def track_subscription_usage(user_id: str, feature_used: str):
|
| 146 |
+
"""
|
| 147 |
+
Track usage for subscription users (for analytics and potential usage limits).
|
| 148 |
+
"""
|
| 149 |
+
try:
|
| 150 |
+
# Get user's active subscription
|
| 151 |
+
subscription_record = supabase.from_("User_Subscriptions").select("*").eq("user_id", user_id).eq("status", "active").execute()
|
| 152 |
+
|
| 153 |
+
if subscription_record.data:
|
| 154 |
+
subscription_id = subscription_record.data[0]["stripe_subscription_id"]
|
| 155 |
+
|
| 156 |
+
# Log usage (you might want to create a separate Usage_Tracking table)
|
| 157 |
+
usage_data = {
|
| 158 |
+
"user_id": user_id,
|
| 159 |
+
"subscription_id": subscription_id,
|
| 160 |
+
"feature_used": feature_used,
|
| 161 |
+
"timestamp": datetime.now().isoformat(),
|
| 162 |
+
"billing_period_start": subscription_record.data[0]["current_period_start"],
|
| 163 |
+
"billing_period_end": subscription_record.data[0]["current_period_end"]
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
# You can log this to a separate table or your existing logging system
|
| 167 |
+
logging.info(f"Subscription usage tracked: {usage_data}")
|
| 168 |
+
|
| 169 |
+
except Exception as e:
|
| 170 |
+
logging.error(f"Error tracking subscription usage: {str(e)}")
|
| 171 |
+
|
| 172 |
+
# Updated version of your existing generation endpoints
|
| 173 |
+
# Replace your existing check_and_decrement_credits calls with check_and_decrement_credits_enhanced
|
| 174 |
+
|
| 175 |
+
# Example for one of your endpoints:
|
| 176 |
+
@app.post("/req-img-to-3d-enhanced", tags=["Image-to-3D"])
|
| 177 |
+
async def req_img_to_3d_enhanced(
|
| 178 |
+
image: UploadFile = File(...),
|
| 179 |
+
settings: Settings = Depends(get_settings),
|
| 180 |
+
current_user: User = Depends(get_current_active_user)
|
| 181 |
+
):
|
| 182 |
+
"""
|
| 183 |
+
Enhanced version that works with both subscriptions and credits.
|
| 184 |
+
"""
|
| 185 |
+
# Check access permissions
|
| 186 |
+
if not await check_and_decrement_credits_enhanced(current_user.id):
|
| 187 |
+
raise HTTPException(status_code=402, detail="Insufficient credits or no active subscription")
|
| 188 |
+
|
| 189 |
+
# Track usage for subscription users
|
| 190 |
+
await track_subscription_usage(current_user.id, "image_to_3d")
|
| 191 |
+
|
| 192 |
+
# Rest of your existing logic...
|
| 193 |
+
# ... existing image processing code ...
|
subscription_setup.md
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Monthly Subscriptions Setup Guide
|
| 2 |
+
|
| 3 |
+
This guide will help you set up monthly subscriptions for your 3DAI API backend.
|
| 4 |
+
|
| 5 |
+
## 1. Database Schema Updates
|
| 6 |
+
|
| 7 |
+
You'll need to create the following tables in your Supabase database:
|
| 8 |
+
|
| 9 |
+
### User_Subscriptions Table
|
| 10 |
+
```sql
|
| 11 |
+
CREATE TABLE "User_Subscriptions" (
|
| 12 |
+
id SERIAL PRIMARY KEY,
|
| 13 |
+
user_id UUID NOT NULL REFERENCES auth.users(id),
|
| 14 |
+
stripe_subscription_id VARCHAR(255) UNIQUE NOT NULL,
|
| 15 |
+
plan VARCHAR(50) NOT NULL,
|
| 16 |
+
status VARCHAR(50) NOT NULL,
|
| 17 |
+
current_period_start TIMESTAMP WITH TIME ZONE,
|
| 18 |
+
current_period_end TIMESTAMP WITH TIME ZONE,
|
| 19 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
| 20 |
+
canceled_at TIMESTAMP WITH TIME ZONE
|
| 21 |
+
);
|
| 22 |
+
|
| 23 |
+
-- Add indexes for better performance
|
| 24 |
+
CREATE INDEX idx_user_subscriptions_user_id ON "User_Subscriptions"(user_id);
|
| 25 |
+
CREATE INDEX idx_user_subscriptions_status ON "User_Subscriptions"(status);
|
| 26 |
+
CREATE INDEX idx_user_subscriptions_stripe_id ON "User_Subscriptions"(stripe_subscription_id);
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
### Stripe_Customers Table
|
| 30 |
+
```sql
|
| 31 |
+
CREATE TABLE "Stripe_Customers" (
|
| 32 |
+
id SERIAL PRIMARY KEY,
|
| 33 |
+
user_id UUID NOT NULL REFERENCES auth.users(id),
|
| 34 |
+
stripe_customer_id VARCHAR(255) UNIQUE NOT NULL,
|
| 35 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
| 36 |
+
);
|
| 37 |
+
|
| 38 |
+
-- Add index
|
| 39 |
+
CREATE INDEX idx_stripe_customers_user_id ON "Stripe_Customers"(user_id);
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
### Subscription_Payment_History Table
|
| 43 |
+
```sql
|
| 44 |
+
CREATE TABLE "Subscription_Payment_History" (
|
| 45 |
+
id SERIAL PRIMARY KEY,
|
| 46 |
+
user_id UUID NOT NULL REFERENCES auth.users(id),
|
| 47 |
+
subscription_id VARCHAR(255) NOT NULL,
|
| 48 |
+
invoice_id VARCHAR(255) UNIQUE NOT NULL,
|
| 49 |
+
amount DECIMAL(10,2) NOT NULL,
|
| 50 |
+
credits_added INTEGER NOT NULL,
|
| 51 |
+
payment_date TIMESTAMP WITH TIME ZONE NOT NULL,
|
| 52 |
+
status VARCHAR(50) NOT NULL,
|
| 53 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
| 54 |
+
);
|
| 55 |
+
|
| 56 |
+
-- Add indexes
|
| 57 |
+
CREATE INDEX idx_subscription_payment_history_user_id ON "Subscription_Payment_History"(user_id);
|
| 58 |
+
CREATE INDEX idx_subscription_payment_history_subscription_id ON "Subscription_Payment_History"(subscription_id);
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
## 2. Stripe Configuration
|
| 62 |
+
|
| 63 |
+
### Create Products and Prices in Stripe
|
| 64 |
+
|
| 65 |
+
You'll need to create products and prices in your Stripe dashboard or via API:
|
| 66 |
+
|
| 67 |
+
```bash
|
| 68 |
+
# Using Stripe CLI (install from https://stripe.com/docs/stripe-cli)
|
| 69 |
+
|
| 70 |
+
# Create Basic Plan Product
|
| 71 |
+
stripe products create \
|
| 72 |
+
--name="Basic Plan" \
|
| 73 |
+
--description="50 credits per month"
|
| 74 |
+
|
| 75 |
+
# Create Basic Plan Price (replace prod_xxx with your product ID)
|
| 76 |
+
stripe prices create \
|
| 77 |
+
--unit-amount=999 \
|
| 78 |
+
--currency=gbp \
|
| 79 |
+
--recurring-interval=month \
|
| 80 |
+
--product=prod_xxx
|
| 81 |
+
|
| 82 |
+
# Create Pro Plan Product
|
| 83 |
+
stripe products create \
|
| 84 |
+
--name="Pro Plan" \
|
| 85 |
+
--description="150 credits per month"
|
| 86 |
+
|
| 87 |
+
# Create Pro Plan Price
|
| 88 |
+
stripe prices create \
|
| 89 |
+
--unit-amount=2499 \
|
| 90 |
+
--currency=gbp \
|
| 91 |
+
--recurring-interval=month \
|
| 92 |
+
--product=prod_xxx
|
| 93 |
+
|
| 94 |
+
# Create Premium Plan Product
|
| 95 |
+
stripe products create \
|
| 96 |
+
--name="Premium Plan" \
|
| 97 |
+
--description="300 credits per month"
|
| 98 |
+
|
| 99 |
+
# Create Premium Plan Price
|
| 100 |
+
stripe prices create \
|
| 101 |
+
--unit-amount=3999 \
|
| 102 |
+
--currency=gbp \
|
| 103 |
+
--recurring-interval=month \
|
| 104 |
+
--product=prod_xxx
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
### Update Price IDs
|
| 108 |
+
|
| 109 |
+
After creating the prices, update the `SUBSCRIPTION_PLANS` in `routers/payments.py` with your actual Stripe price IDs:
|
| 110 |
+
|
| 111 |
+
```python
|
| 112 |
+
SUBSCRIPTION_PLANS = {
|
| 113 |
+
"basic": {
|
| 114 |
+
"credits_per_month": 50,
|
| 115 |
+
"price_gbp": 9.99,
|
| 116 |
+
"stripe_price_id": "price_1234567890", # Replace with actual price ID
|
| 117 |
+
"name": "Basic Plan",
|
| 118 |
+
"description": "50 credits per month"
|
| 119 |
+
},
|
| 120 |
+
# ... update other plans
|
| 121 |
+
}
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
## 3. Environment Variables
|
| 125 |
+
|
| 126 |
+
Add these new environment variables to your `.env` file:
|
| 127 |
+
|
| 128 |
+
```env
|
| 129 |
+
# Existing variables...
|
| 130 |
+
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
|
| 131 |
+
STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key
|
| 132 |
+
|
| 133 |
+
# New webhook secret (you'll get this when setting up the webhook)
|
| 134 |
+
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
## 4. Webhook Configuration
|
| 138 |
+
|
| 139 |
+
### Set up Stripe Webhook
|
| 140 |
+
|
| 141 |
+
1. Go to your Stripe Dashboard → Developers → Webhooks
|
| 142 |
+
2. Click "Add endpoint"
|
| 143 |
+
3. Set the endpoint URL to: `https://your-domain.com/payment/webhook`
|
| 144 |
+
4. Select these events:
|
| 145 |
+
- `invoice.payment_succeeded`
|
| 146 |
+
- `invoice.payment_failed`
|
| 147 |
+
- `customer.subscription.deleted`
|
| 148 |
+
- `customer.subscription.updated`
|
| 149 |
+
5. Copy the webhook secret and add it to your environment variables
|
| 150 |
+
|
| 151 |
+
## 5. API Endpoints
|
| 152 |
+
|
| 153 |
+
Your subscription system now includes these new endpoints:
|
| 154 |
+
|
| 155 |
+
### Get Available Plans
|
| 156 |
+
```
|
| 157 |
+
GET /payment/subscription-plans
|
| 158 |
+
```
|
| 159 |
+
|
| 160 |
+
### Create Subscription
|
| 161 |
+
```
|
| 162 |
+
POST /payment/create-subscription
|
| 163 |
+
Content-Type: application/json
|
| 164 |
+
Authorization: Bearer <token>
|
| 165 |
+
|
| 166 |
+
{
|
| 167 |
+
"plan": "basic"
|
| 168 |
+
}
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
### Get User's Subscription
|
| 172 |
+
```
|
| 173 |
+
GET /payment/subscription
|
| 174 |
+
Authorization: Bearer <token>
|
| 175 |
+
```
|
| 176 |
+
|
| 177 |
+
### Cancel Subscription
|
| 178 |
+
```
|
| 179 |
+
POST /payment/cancel-subscription
|
| 180 |
+
Content-Type: application/json
|
| 181 |
+
Authorization: Bearer <token>
|
| 182 |
+
|
| 183 |
+
{
|
| 184 |
+
"subscription_id": "sub_1234567890"
|
| 185 |
+
}
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
### Webhook Endpoint
|
| 189 |
+
```
|
| 190 |
+
POST /payment/webhook
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
## 6. Frontend Integration Example
|
| 194 |
+
|
| 195 |
+
Here's a basic example of how to integrate subscriptions in your frontend:
|
| 196 |
+
|
| 197 |
+
```javascript
|
| 198 |
+
// Create subscription
|
| 199 |
+
const createSubscription = async (plan) => {
|
| 200 |
+
const response = await fetch('/payment/create-subscription', {
|
| 201 |
+
method: 'POST',
|
| 202 |
+
headers: {
|
| 203 |
+
'Content-Type': 'application/json',
|
| 204 |
+
'Authorization': `Bearer ${userToken}`
|
| 205 |
+
},
|
| 206 |
+
body: JSON.stringify({ plan })
|
| 207 |
+
});
|
| 208 |
+
|
| 209 |
+
const { client_secret } = await response.json();
|
| 210 |
+
|
| 211 |
+
// Use Stripe.js to confirm payment
|
| 212 |
+
const { error } = await stripe.confirmPayment({
|
| 213 |
+
elements,
|
| 214 |
+
clientSecret: client_secret,
|
| 215 |
+
confirmParams: {
|
| 216 |
+
return_url: 'https://your-domain.com/subscription-success'
|
| 217 |
+
}
|
| 218 |
+
});
|
| 219 |
+
};
|
| 220 |
+
|
| 221 |
+
// Get user's subscription status
|
| 222 |
+
const getSubscription = async () => {
|
| 223 |
+
const response = await fetch('/payment/subscription', {
|
| 224 |
+
headers: {
|
| 225 |
+
'Authorization': `Bearer ${userToken}`
|
| 226 |
+
}
|
| 227 |
+
});
|
| 228 |
+
|
| 229 |
+
const { subscription } = await response.json();
|
| 230 |
+
return subscription;
|
| 231 |
+
};
|
| 232 |
+
```
|
| 233 |
+
|
| 234 |
+
## 7. Testing
|
| 235 |
+
|
| 236 |
+
1. Use Stripe test mode for development
|
| 237 |
+
2. Use test card numbers from Stripe documentation
|
| 238 |
+
3. Test webhook events using Stripe CLI:
|
| 239 |
+
```bash
|
| 240 |
+
stripe listen --forward-to localhost:8000/payment/webhook
|
| 241 |
+
```
|
| 242 |
+
|
| 243 |
+
## 8. Production Considerations
|
| 244 |
+
|
| 245 |
+
1. **Security**: Ensure webhook signature verification is enabled
|
| 246 |
+
2. **Monitoring**: Set up logging for subscription events
|
| 247 |
+
3. **Error Handling**: Implement retry logic for failed webhook processing
|
| 248 |
+
4. **Credits Management**: Consider implementing credit rollover policies
|
| 249 |
+
5. **Prorations**: Handle plan changes with appropriate prorations
|
| 250 |
+
6. **Tax Handling**: Implement tax calculation if required for your jurisdiction
|
| 251 |
+
|
| 252 |
+
## 9. Additional Features to Consider
|
| 253 |
+
|
| 254 |
+
- **Plan Upgrades/Downgrades**: Allow users to change subscription plans
|
| 255 |
+
- **Pause/Resume**: Allow temporary subscription pauses
|
| 256 |
+
- **Annual Plans**: Add yearly subscription options with discounts
|
| 257 |
+
- **Usage Limits**: Implement soft/hard limits based on subscription tier
|
| 258 |
+
- **Email Notifications**: Send emails for subscription events
|
| 259 |
+
- **Admin Dashboard**: Create admin interface for subscription management
|