Maksymilian Jankowski commited on
Commit
93f5fba
·
1 Parent(s): 3c91104

subscriptions

Browse files
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