From 1275ac0fbe50b2df731c162e1fce5d8bc09f314c Mon Sep 17 00:00:00 2001 From: sankalpaacharya Date: Sun, 3 Aug 2025 19:17:42 +0530 Subject: [PATCH 01/72] ci: replace --no-frozen-lockfile with --frozen-lockfile --- .github/workflows/main.yml | 3 +-- frontend/.prettierignore | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 25c9361c..e19bc39b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -31,8 +31,7 @@ jobs: - name: Install dependencies run: | - pnpm install --no-frozen-lockfile - pnpm install + pnpm install --frozen-lockfile - name: Run ESLint with autofix run: pnpm lint:fix diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 911c6a8d..d1c85a7e 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -2,4 +2,5 @@ node_modules .next public .husky -../docs \ No newline at end of file +../docs +pnpm-lock.yaml From b46e9cacfb52ce875a8d2d01752d8e927864ca7e Mon Sep 17 00:00:00 2001 From: sankalpaacharya Date: Sun, 3 Aug 2025 18:29:56 +0530 Subject: [PATCH 02/72] perf: parallelize cloudinary upload and summary generation --- backend/app/services/file_service.py | 76 ++++++++++------------------ 1 file changed, 26 insertions(+), 50 deletions(-) diff --git a/backend/app/services/file_service.py b/backend/app/services/file_service.py index 64f442a5..07355933 100644 --- a/backend/app/services/file_service.py +++ b/backend/app/services/file_service.py @@ -36,78 +36,59 @@ async def upload_file_service( ) -> dict: """ Upload a file to Cloudinary, generate embeddings, and store metadata in MongoDB and ChromaDB. - Args: file (UploadFile): The file to upload user_id (str): The ID of the user uploading the file conversation_id (str, optional): The conversation ID to associate with the file - Returns: dict: File metadata including file_id and url - Raises: HTTPException: If file upload fails """ - # Validate inputs early if not file.filename: logger.error("Missing filename in file upload") - raise HTTPException( - status_code=400, detail="Invalid file name. Filename is required." - ) - + raise HTTPException(status_code=400, detail="Invalid file name. Filename is required.") if not file.content_type: logger.error("Missing content_type in file upload") - raise HTTPException( - status_code=400, detail="Invalid file type. Content type is required." - ) + raise HTTPException(status_code=400, detail="Invalid file type. Content type is required.") file_id = str(uuid.uuid4()) public_id = f"file_{file_id}_{file.filename.replace(' ', '_')}" try: - # Read file content once content = await file.read() - file_size = len(content) - if file.size and file.size > 10 * 1024 * 1024: # 10 MB limit + file_size = len(content) + if file_size > 10 * 1024 * 1024: logger.error("File size exceeds the 10 MB limit") - raise HTTPException( - status_code=400, detail="File size exceeds the 10 MB limit" - ) - - # Start file description generation and upload in parallel - summary_task = asyncio.create_task( - generate_file_summary( - file_content=content, - content_type=file.content_type, - filename=file.filename, - ) - ) + raise HTTPException(status_code=400, detail="File size exceeds the 10 MB limit") - # Upload to Cloudinary - upload_result = cloudinary.uploader.upload( - io.BytesIO(content), + cloudinary_task = asyncio.to_thread( + cloudinary.uploader.upload, + io.BytesIO(content), resource_type="auto", public_id=public_id, overwrite=True, ) + summary_task = generate_file_summary( + file_content=content, + content_type=file.content_type, + filename=file.filename, + ) + + upload_result, summary_result = await asyncio.gather( + cloudinary_task, + summary_task, + ) + file_url = upload_result.get("secure_url") if not file_url: logger.error("Missing secure_url in Cloudinary upload response") - raise HTTPException( - status_code=500, detail="Invalid response from file upload service" - ) - - logger.info(f"File uploaded to Cloudinary: {file_url}") + raise HTTPException(status_code=500, detail="Invalid response from file upload service") - # Wait for description generation to complete - file_summary = await summary_task + summary, formatted_file_content = _process_file_summary(summary_result) - # Process file description - summary, formatted_file_content = _process_file_summary(file_summary) - - # Create metadata object current_time = datetime.now(timezone.utc) file_metadata = { "file_id": file_id, @@ -122,11 +103,10 @@ async def upload_file_service( "created_at": current_time, "updated_at": current_time, } - if conversation_id: - file_metadata["conversation_id"] = ( - conversation_id # Store in MongoDB and ChromaDB concurrently - ) + file_metadata["conversation_id"] = conversation_id + + # Store in DB (Mongo + Chroma) await asyncio.gather( _store_in_mongodb(file_metadata), _store_in_chromadb( @@ -135,11 +115,9 @@ async def upload_file_service( filename=file.filename, content_type=file.content_type, conversation_id=conversation_id, - file_description=file_summary, + file_description=summary_result, ), - ) # Cache invalidation handled by the CacheInvalidator decorator - - logger.info(f"File uploaded successfully. ID: {file_id}, URL: {file_url}") + ) return { "file_id": file_id, @@ -150,13 +128,11 @@ async def upload_file_service( } except HTTPException: - # Re-raise HTTP exceptions without wrapping raise except Exception as e: logger.error(f"Failed to upload file: {str(e)}", exc_info=True) raise HTTPException(status_code=500, detail=f"Failed to upload file: {str(e)}") - def _process_file_summary( file_summary: str | list[DocumentSummaryModel] | DocumentSummaryModel, ) -> tuple: From 9d392558dea0bcfd48ffc97a8d19e703c91d4774 Mon Sep 17 00:00:00 2001 From: sankalpaacharya Date: Sun, 3 Aug 2025 20:02:18 +0530 Subject: [PATCH 03/72] ci: upgrade pnpm from v8 to v10.12.2 --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 25c9361c..389a83e7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,7 +20,7 @@ jobs: - uses: pnpm/action-setup@v2 with: - version: 8 + version: 10.12.2 - name: Use Node.js uses: actions/setup-node@v3 From c4199c696499fcbcb5e549861bb1574b95fcb1ba Mon Sep 17 00:00:00 2001 From: Aryan Date: Wed, 6 Aug 2025 20:44:30 +0530 Subject: [PATCH 04/72] feat: update privacy policy to include Google user data handling and protection measures --- frontend/src/app/(landing)/privacy/page.tsx | 47 +++++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/(landing)/privacy/page.tsx b/frontend/src/app/(landing)/privacy/page.tsx index 6117a41e..c58969d5 100644 --- a/frontend/src/app/(landing)/privacy/page.tsx +++ b/frontend/src/app/(landing)/privacy/page.tsx @@ -119,7 +119,15 @@ const PrivacyPolicy = () => {
  • Authentication Services: If you use third-party authentication services (e.g., Google, GitHub), we may receive - basic profile information; + basic profile information such as your name, email address, and + profile picture; +
  • +
  • + Google User Data: When you connect Google + services, we may access and collect data from your Google account + including but not limited to email, calendar events, contacts, and + documents as authorized by you through Google's OAuth consent + process;
  • Analytics Providers: Information from third-party @@ -154,6 +162,12 @@ const PrivacyPolicy = () => { Processing and responding to your requests and interactions with the AI assistant;
  • +
  • + Google User Data Processing: Using Google user + data solely to provide and improve our AI assistant functionality, + including processing emails, calendar events, and documents to + provide relevant assistance and responses; +
  • Personalizing your experience and delivering relevant content and recommendations; @@ -226,8 +240,10 @@ const PrivacyPolicy = () => {

    We do not sell, rent, or lease your personal information to third - parties. However, we may share your information in the following - limited circumstances: + parties.{" "} + We do not sell Google user data to third parties.{" "} + However, we may share your information in the following limited + circumstances:

    @@ -244,6 +260,13 @@ const PrivacyPolicy = () => {
  • Analytics and monitoring services;
  • Security and fraud prevention services.
  • +

    + Google User Data: We only share Google user data + with service providers who are necessary for providing our AI + assistant functionality and who have agreed to appropriate data + protection measures. We do not transfer Google user data to third + parties for advertising or other unrelated purposes. +

    3.2 Legal Requirements @@ -345,6 +368,12 @@ const PrivacyPolicy = () => { Incident response procedures to address potential security breaches.

  • +
  • + Google User Data Protection: Enhanced security + measures for Google user data including restricted access on a + need-to-know basis, secure API connections, and compliance with + Google's security requirements. +
  • However, no method of transmission over the internet or electronic @@ -434,11 +463,21 @@ const PrivacyPolicy = () => { Support communications: Retained for up to 3 years for quality assurance and legal compliance. +

  • + Google User Data: Retained only as long as + necessary to provide our services or as required by law. You can + request deletion of your Google user data at any time through your + account settings or by contacting us directly. +
  • We may retain certain information for longer periods when required by law or for legitimate business purposes such as fraud prevention - and security. + and security.{" "} + + Google user data is deleted when no longer necessary for providing + our AI assistant services. +

    From 3ab5b736c2e7804b53fd777fa80676ee6e5a097c Mon Sep 17 00:00:00 2001 From: Aryan Date: Wed, 6 Aug 2025 20:52:41 +0530 Subject: [PATCH 05/72] refactor: simplify onboarding process by removing country-related fields and validations --- backend/app/models/user_models.py | 40 +--------- backend/app/services/onboarding_service.py | 16 +--- frontend/src/app/(main)/onboarding/page.tsx | 2 - frontend/src/features/auth/api/authApi.ts | 3 - .../components/OnboardingComplete.tsx | 5 +- .../onboarding/components/OnboardingInput.tsx | 52 ++++++++----- .../features/onboarding/constants/index.ts | 33 +------- .../onboarding/hooks/useOnboarding.ts | 75 ++++++++----------- .../src/features/onboarding/types/index.ts | 1 - 9 files changed, 75 insertions(+), 152 deletions(-) diff --git a/backend/app/models/user_models.py b/backend/app/models/user_models.py index 9ba2b6ac..b7fb6abf 100644 --- a/backend/app/models/user_models.py +++ b/backend/app/models/user_models.py @@ -112,16 +112,9 @@ class OnboardingRequest(BaseModel): name: str = Field( ..., min_length=1, max_length=100, description="User's preferred name" ) - country: str = Field( - ..., min_length=2, max_length=2, description="ISO 3166-1 alpha-2 country code" - ) profession: str = Field( ..., min_length=1, max_length=50, description="User's profession" ) - response_style: str = Field(..., description="Preferred response style") - instructions: Optional[str] = Field( - None, max_length=500, description="Custom instructions" - ) @field_validator("name") @classmethod @@ -135,41 +128,16 @@ def validate_name(cls, v): ) return v - @field_validator("country") - @classmethod - def validate_country(cls, v): - # Ensure country code is uppercase and valid format - v = v.upper().strip() - if not re.match(r"^[A-Z]{2}$", v): - raise ValueError( - "Country must be a valid ISO 3166-1 alpha-2 code (e.g., US, GB, DE)" - ) - return v - @field_validator("profession") @classmethod def validate_profession(cls, v): v = v.strip() if not v: raise ValueError("Profession cannot be empty") - return v - - @field_validator("response_style") - @classmethod - def validate_response_style(cls, v): - valid_styles = {"brief", "detailed", "casual", "professional"} - # Allow custom response styles (anything that's not in the predefined list) - if v not in valid_styles and len(v.strip()) == 0: - raise ValueError("Response style cannot be empty") - return v - - @field_validator("instructions") - @classmethod - def validate_instructions(cls, v): - if v is not None: - v = v.strip() - if len(v) > 500: - raise ValueError("Custom instructions must be 500 characters or less") + if not re.match(r"^[a-zA-Z\s\-\.]+$", v): + raise ValueError( + "Profession can only contain letters, spaces, hyphens, and periods" + ) return v diff --git a/backend/app/services/onboarding_service.py b/backend/app/services/onboarding_service.py index f88b511f..bd56bdc1 100644 --- a/backend/app/services/onboarding_service.py +++ b/backend/app/services/onboarding_service.py @@ -39,20 +39,12 @@ async def complete_onboarding( # Convert string ID to ObjectId user_object_id = ObjectId(user_id) - # Prepare onboarding preferences - custom_instructions = ( - onboarding_data.instructions.strip() - if onboarding_data.instructions - else None - ) - if custom_instructions == "": - custom_instructions = None - + # Prepare onboarding preferences with default values for settings page preferences = OnboardingPreferences( - country=onboarding_data.country, + country=None, # Will be set later from timezone detection or settings profession=onboarding_data.profession, - response_style=onboarding_data.response_style, - custom_instructions=custom_instructions, + response_style="casual", # Default response style + custom_instructions=None, ) # Prepare onboarding data diff --git a/frontend/src/app/(main)/onboarding/page.tsx b/frontend/src/app/(main)/onboarding/page.tsx index d31ac260..370e99bb 100644 --- a/frontend/src/app/(main)/onboarding/page.tsx +++ b/frontend/src/app/(main)/onboarding/page.tsx @@ -15,7 +15,6 @@ export default function Onboarding() { messagesEndRef, inputRef, handleChipSelect, - handleCountrySelect, handleProfessionSelect, handleProfessionInputChange, handleInputChange, @@ -50,7 +49,6 @@ export default function Onboarding() { onboardingState={onboardingState} onSubmit={handleSubmit} onInputChange={handleInputChange} - onCountrySelect={handleCountrySelect} onProfessionSelect={handleProfessionSelect} onProfessionInputChange={handleProfessionInputChange} inputRef={inputRef} diff --git a/frontend/src/features/auth/api/authApi.ts b/frontend/src/features/auth/api/authApi.ts index 7d51ac22..19821434 100644 --- a/frontend/src/features/auth/api/authApi.ts +++ b/frontend/src/features/auth/api/authApi.ts @@ -64,10 +64,7 @@ export const authApi = { // Complete onboarding completeOnboarding: async (onboardingData: { name: string; - country: string; profession: string; - response_style: string; - instructions?: string | null; }): Promise<{ success: boolean; message: string; user?: UserInfo }> => { return apiService.post("/oauth/onboarding", onboardingData, { successMessage: "Welcome! Your preferences have been saved.", diff --git a/frontend/src/features/onboarding/components/OnboardingComplete.tsx b/frontend/src/features/onboarding/components/OnboardingComplete.tsx index b2ffd51e..2f9d3dcb 100644 --- a/frontend/src/features/onboarding/components/OnboardingComplete.tsx +++ b/frontend/src/features/onboarding/components/OnboardingComplete.tsx @@ -20,9 +20,8 @@ export const OnboardingComplete = ({ onLetsGo }: OnboardingCompleteProps) => { diff --git a/frontend/src/features/onboarding/components/OnboardingInput.tsx b/frontend/src/features/onboarding/components/OnboardingInput.tsx index 46779059..d5291d96 100644 --- a/frontend/src/features/onboarding/components/OnboardingInput.tsx +++ b/frontend/src/features/onboarding/components/OnboardingInput.tsx @@ -2,8 +2,8 @@ import { Autocomplete, AutocompleteItem } from "@heroui/autocomplete"; import { Button } from "@heroui/button"; import { Input } from "@heroui/input"; import { Kbd } from "@heroui/react"; +import { useRef, useEffect } from "react"; -import { CountrySelector } from "@/components/country-selector"; import { SentIcon } from "@/components/shared/icons"; import { cn } from "@/lib/utils"; @@ -14,7 +14,6 @@ interface OnboardingInputProps { onboardingState: OnboardingState; onSubmit: (e: React.FormEvent) => void; onInputChange: (value: string) => void; - onCountrySelect: (countryCode: string | null) => void; onProfessionSelect: (professionKey: React.Key | null) => void; onProfessionInputChange: (value: string) => void; inputRef: React.RefObject; @@ -24,31 +23,50 @@ export const OnboardingInput = ({ onboardingState, onSubmit, onInputChange, - onCountrySelect, onProfessionSelect, onProfessionInputChange, inputRef, }: OnboardingInputProps) => { + const autocompleteRef = useRef(null); + const currentQuestion = onboardingState.currentQuestionIndex < questions.length ? questions[onboardingState.currentQuestionIndex] : null; + // Focus the appropriate input when question changes + useEffect(() => { + if ( + !onboardingState.isProcessing && + !onboardingState.hasAnsweredCurrentQuestion + ) { + setTimeout(() => { + if (currentQuestion?.fieldName === FIELD_NAMES.PROFESSION) { + // Focus the autocomplete input + const autocompleteInput = document.querySelector( + '[data-slot="input"]', + ) as HTMLInputElement; + if (autocompleteInput) { + autocompleteInput.focus(); + } + } else { + // Focus regular input + inputRef.current?.focus(); + } + }, 500); + } + }, [ + onboardingState.currentQuestionIndex, + onboardingState.isProcessing, + onboardingState.hasAnsweredCurrentQuestion, + currentQuestion?.fieldName, + inputRef, + ]); + if (!currentQuestion) return null; const renderInput = () => { switch (currentQuestion.fieldName) { - case FIELD_NAMES.COUNTRY: - return ( - - ); - case FIELD_NAMES.PROFESSION: return ( { const router = useRouter(); + const user = useUser(); const [onboardingState, setOnboardingState] = useState({ messages: [], currentQuestionIndex: 0, currentInputs: { text: "", - selectedCountry: null, selectedProfession: null, }, userResponses: {}, @@ -35,13 +35,25 @@ export const useOnboarding = () => { scrollToBottom(); }, [onboardingState.messages]); + // Auto-focus input when a new question appears + useEffect(() => { + if ( + !onboardingState.isProcessing && + !onboardingState.hasAnsweredCurrentQuestion + ) { + setTimeout(() => { + inputRef.current?.focus(); + }, 500); + } + }, [ + onboardingState.currentQuestionIndex, + onboardingState.isProcessing, + onboardingState.hasAnsweredCurrentQuestion, + ]); + const getDisplayText = useCallback( (fieldName: string, value: string): string => { switch (fieldName) { - case FIELD_NAMES.COUNTRY: - return ( - countries.find((c: Country) => c.code === value)?.name || value - ); case FIELD_NAMES.PROFESSION: return ( professionOptions.find((p) => p.value === value)?.label || value @@ -84,7 +96,6 @@ export const useOnboarding = () => { newState.currentInputs = { text: "", - selectedCountry: null, selectedProfession: null, }; @@ -161,18 +172,6 @@ export const useOnboarding = () => { ], ); - const handleCountrySelect = useCallback( - (countryCode: string | null) => { - if (onboardingState.isProcessing || !countryCode) return; - - // Ensure country code is uppercase for consistency - const normalizedCode = countryCode.toUpperCase(); - const countryName = getDisplayText("country", normalizedCode); - submitResponse(countryName, normalizedCode); - }, - [onboardingState.isProcessing, submitResponse, getDisplayText], - ); - const handleProfessionSelect = useCallback( (professionKey: React.Key | null) => { if ( @@ -228,31 +227,15 @@ export const useOnboarding = () => { const currentQuestion = questions[onboardingState.currentQuestionIndex]; const { fieldName } = currentQuestion; - if ( - fieldName === FIELD_NAMES.COUNTRY && - onboardingState.currentInputs.selectedCountry - ) { - handleCountrySelect(onboardingState.currentInputs.selectedCountry); - } else if ( - fieldName !== FIELD_NAMES.COUNTRY && - fieldName !== FIELD_NAMES.PROFESSION - ) { - if (fieldName === FIELD_NAMES.INSTRUCTIONS) { - submitResponse( - onboardingState.currentInputs.text.trim() || - "No specific instructions", - "", - ); - } else if (onboardingState.currentInputs.text.trim()) { + if (fieldName !== FIELD_NAMES.PROFESSION) { + if (onboardingState.currentInputs.text.trim()) { submitResponse(onboardingState.currentInputs.text.trim()); } } }, [ onboardingState.currentQuestionIndex, - onboardingState.currentInputs.selectedCountry, onboardingState.currentInputs.text, - handleCountrySelect, submitResponse, ], ); @@ -266,7 +249,7 @@ export const useOnboarding = () => { setOnboardingState((prev) => ({ ...prev, isProcessing: true })); // Validate required fields - const requiredFields = ["name", "country", "profession", "responseStyle"]; + const requiredFields = ["name", "profession"]; const missingFields = requiredFields.filter( (field) => !onboardingState.userResponses[field], ); @@ -279,13 +262,9 @@ export const useOnboarding = () => { } // Prepare the onboarding data - const instructions = onboardingState.userResponses.instructions?.trim(); const onboardingData = { name: onboardingState.userResponses.name.trim(), - country: onboardingState.userResponses.country.toUpperCase(), // Ensure uppercase profession: onboardingState.userResponses.profession, - response_style: onboardingState.userResponses.responseStyle, - instructions: instructions || null, }; // Send onboarding data to backend with retry logic @@ -311,7 +290,6 @@ export const useOnboarding = () => { } if (response?.success) { - // Navigate to the main chat page router.push("/c"); } else { @@ -349,6 +327,10 @@ export const useOnboarding = () => { useEffect(() => { const firstQuestion = questions[0]; + + // Pre-populate the user's name from Gmail if available + const userName = user.name || ""; + setOnboardingState((prev) => ({ ...prev, messages: [ @@ -358,15 +340,18 @@ export const useOnboarding = () => { content: firstQuestion.question, }, ], + currentInputs: { + ...prev.currentInputs, + text: firstQuestion.fieldName === FIELD_NAMES.NAME ? userName : "", + }, })); - }, []); + }, [user.name]); return { onboardingState, messagesEndRef, inputRef, handleChipSelect, - handleCountrySelect, handleProfessionSelect, handleProfessionInputChange, handleInputChange, diff --git a/frontend/src/features/onboarding/types/index.ts b/frontend/src/features/onboarding/types/index.ts index 927f0361..6df6438b 100644 --- a/frontend/src/features/onboarding/types/index.ts +++ b/frontend/src/features/onboarding/types/index.ts @@ -17,7 +17,6 @@ export interface OnboardingState { currentQuestionIndex: number; currentInputs: { text: string; - selectedCountry: string | null; selectedProfession: string | null; }; userResponses: Record; From 605786c9ff28f2b58e2b2f87196829f2b8133f00 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Wed, 6 Aug 2025 15:34:07 +0000 Subject: [PATCH 06/72] ci(auto-fix): Apply ESLint formatting --- frontend/src/features/onboarding/components/OnboardingInput.tsx | 2 +- frontend/src/features/pricing/components/PricingPage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/features/onboarding/components/OnboardingInput.tsx b/frontend/src/features/onboarding/components/OnboardingInput.tsx index d5291d96..894116c0 100644 --- a/frontend/src/features/onboarding/components/OnboardingInput.tsx +++ b/frontend/src/features/onboarding/components/OnboardingInput.tsx @@ -2,7 +2,7 @@ import { Autocomplete, AutocompleteItem } from "@heroui/autocomplete"; import { Button } from "@heroui/button"; import { Input } from "@heroui/input"; import { Kbd } from "@heroui/react"; -import { useRef, useEffect } from "react"; +import { useEffect,useRef } from "react"; import { SentIcon } from "@/components/shared/icons"; import { cn } from "@/lib/utils"; diff --git a/frontend/src/features/pricing/components/PricingPage.tsx b/frontend/src/features/pricing/components/PricingPage.tsx index 39403eb7..2345051d 100644 --- a/frontend/src/features/pricing/components/PricingPage.tsx +++ b/frontend/src/features/pricing/components/PricingPage.tsx @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useState } from "react"; import { Chip } from "@heroui/chip"; import { Tab, Tabs } from "@heroui/tabs"; +import { useEffect, useState } from "react"; import type { Plan } from "@/features/pricing/api/pricingApi"; import { pricingApi } from "@/features/pricing/api/pricingApi"; From e1f21761e6eb5a28736f9535e2d90e3d533c7197 Mon Sep 17 00:00:00 2001 From: Aryan Date: Wed, 6 Aug 2025 21:37:12 +0530 Subject: [PATCH 07/72] feat: enhance subscription management and webhook processing - Update subscription status checks to ensure only active subscriptions are considered. - Add cleanup tasks for abandoned subscriptions and reconciliation of payment statuses. - Implement webhook event tracking for idempotency and error handling. - Introduce new models for webhook events and enhance Razorpay client integration. - Improve user interface components to reflect accurate subscription states. --- backend/app/api/v1/router/payments.py | 2 +- backend/app/arq_worker.py | 48 ++++- backend/app/db/mongodb/collections.py | 1 + backend/app/models/payment_models.py | 20 +-- backend/app/models/webhook_models.py | 26 +++ backend/app/services/payments/client.py | 2 +- .../app/services/payments/subscriptions.py | 8 +- backend/app/services/payments/verification.py | 46 +++-- backend/app/services/payments/webhooks.py | 74 +++++++- backend/app/tasks/subscription_cleanup.py | 165 ++++++++++++++++++ .../pricing/components/PricingCards.tsx | 1 + .../features/pricing/hooks/usePaymentFlow.ts | 4 +- .../SubscriptionActivationBanner.tsx | 9 +- 13 files changed, 358 insertions(+), 48 deletions(-) create mode 100644 backend/app/models/webhook_models.py create mode 100644 backend/app/tasks/subscription_cleanup.py diff --git a/backend/app/api/v1/router/payments.py b/backend/app/api/v1/router/payments.py index 9ee8703c..6fc5902e 100644 --- a/backend/app/api/v1/router/payments.py +++ b/backend/app/api/v1/router/payments.py @@ -130,7 +130,7 @@ async def sync_subscription_endpoint( from app.db.mongodb.collections import subscriptions_collection subscription = await subscriptions_collection.find_one( - {"user_id": user_id, "status": {"$in": ["active", "created", "paused"]}} + {"user_id": user_id, "status": "active", "paid_count": {"$gt": 0}} ) if not subscription: diff --git a/backend/app/arq_worker.py b/backend/app/arq_worker.py index 096ea930..c48b7e96 100644 --- a/backend/app/arq_worker.py +++ b/backend/app/arq_worker.py @@ -12,6 +12,10 @@ from app.config.settings import settings from app.langchain.llm.client import init_llm from app.services.reminder_service import process_reminder_task +from app.tasks.subscription_cleanup import ( + cleanup_abandoned_subscriptions, + reconcile_subscription_payments, +) async def startup(ctx: dict): @@ -46,7 +50,30 @@ async def startup(ctx: dict): GraphManager.set_graph(built_graph, graph_name="reminder_processing") -async def shutdown(ctx: dict): +async def cleanup_abandoned_subscriptions_task(ctx: dict) -> str: + """ARQ task wrapper for cleaning up abandoned subscriptions.""" + try: + result = await cleanup_abandoned_subscriptions() + message = f"Subscription cleanup completed. Status: {result['status']}, Cleaned: {result.get('cleaned_up_count', 0)}" + logger.info(message) + return message + except Exception as e: + error_msg = f"Failed to cleanup abandoned subscriptions: {str(e)}" + logger.error(error_msg) + raise + + +async def reconcile_subscription_payments_task(ctx: dict) -> str: + """ARQ task wrapper for reconciling subscription payments.""" + try: + result = await reconcile_subscription_payments() + message = f"Payment reconciliation completed. Status: {result['status']}, Reconciled: {result.get('reconciled_count', 0)}, Deactivated: {result.get('deactivated_count', 0)}" + logger.info(message) + return message + except Exception as e: + error_msg = f"Failed to reconcile subscription payments: {str(e)}" + logger.error(error_msg) + raise """ARQ worker shutdown function.""" logger.info("ARQ worker shutting down...") @@ -195,6 +222,12 @@ async def renew_gmail_watch_subscriptions(ctx: dict) -> str: return await renew_function(ctx, max_concurrent=15) +async def shutdown(ctx: dict): + """ARQ worker shutdown function.""" + logger.info("ARQ worker shutting down...") + # Clean up any resources if needed + + class WorkerSettings: """ ARQ worker settings configuration. @@ -208,6 +241,8 @@ class WorkerSettings: cleanup_expired_reminders, check_inactive_users, renew_gmail_watch_subscriptions, + cleanup_abandoned_subscriptions_task, + reconcile_subscription_payments_task, ] cron_jobs = [ cron( @@ -228,6 +263,17 @@ class WorkerSettings: minute=0, # At the start of the hour second=0, # At the start of the minute ), + cron( + cleanup_abandoned_subscriptions_task, + minute={0, 30}, # Every 30 minutes + second=0, + ), + cron( + reconcile_subscription_payments_task, + hour=1, # At 1 AM daily + minute=0, + second=0, + ), ] on_startup = startup on_shutdown = shutdown diff --git a/backend/app/db/mongodb/collections.py b/backend/app/db/mongodb/collections.py index 2dd46447..4c8eab47 100644 --- a/backend/app/db/mongodb/collections.py +++ b/backend/app/db/mongodb/collections.py @@ -28,6 +28,7 @@ plans_collection = mongodb_instance.get_collection("subscription_plans") subscriptions_collection = mongodb_instance.get_collection("subscriptions") payments_collection = mongodb_instance.get_collection("payments") +webhook_events_collection = mongodb_instance.get_collection("webhook_events") # Usage usage_snapshots_collection = mongodb_instance.get_collection("usage_snapshots") diff --git a/backend/app/models/payment_models.py b/backend/app/models/payment_models.py index ff3c08d3..ea9ed046 100644 --- a/backend/app/models/payment_models.py +++ b/backend/app/models/payment_models.py @@ -36,16 +36,16 @@ class PaymentStatus(str, Enum): class SubscriptionStatus(str, Enum): - """Subscription status.""" - - CREATED = "created" - AUTHENTICATED = "authenticated" - ACTIVE = "active" - PAUSED = "paused" - HALTED = "halted" - CANCELLED = "cancelled" - COMPLETED = "completed" - EXPIRED = "expired" + """Subscription status with clear definitions.""" + + CREATED = "created" # Just created, no payment yet + AUTHENTICATED = "authenticated" # Authenticated but not paid + ACTIVE = "active" # Active with successful payment + PAUSED = "paused" # Temporarily paused + HALTED = "halted" # Halted due to payment failure + CANCELLED = "cancelled" # Cancelled by user or system + COMPLETED = "completed" # Completed all billing cycles + EXPIRED = "expired" # Expired subscription class PaymentMethod(str, Enum): diff --git a/backend/app/models/webhook_models.py b/backend/app/models/webhook_models.py new file mode 100644 index 00000000..285889f7 --- /dev/null +++ b/backend/app/models/webhook_models.py @@ -0,0 +1,26 @@ +""" +Webhook event tracking for idempotency. +""" + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + + +class WebhookEventDB(BaseModel): + """Database model for tracking processed webhook events.""" + + id: Optional[str] = Field(None, alias="_id") + event_id: str + event_type: str + razorpay_entity_id: str # subscription_id or payment_id + processed_at: datetime + payload_hash: str # SHA256 hash of payload for deduplication + status: str # "processed", "failed", "retrying" + retry_count: int = 0 + error_message: Optional[str] = None + + class Config: + allow_population_by_field_name = True + json_encoders = {datetime: lambda v: v.isoformat()} diff --git a/backend/app/services/payments/client.py b/backend/app/services/payments/client.py index 7d30af52..4975b246 100644 --- a/backend/app/services/payments/client.py +++ b/backend/app/services/payments/client.py @@ -38,7 +38,7 @@ def __init__(self): key_secret = settings.RAZORPAY_KEY_SECRET # Initialize client - self.client = razorpay.Client(auth=(key_id, key_secret)) + self.client: razorpay.Client = razorpay.Client(auth=(key_id, key_secret)) # Auto-detect test mode based on key prefix self.is_test_mode = key_id.startswith("rzp_test_") diff --git a/backend/app/services/payments/subscriptions.py b/backend/app/services/payments/subscriptions.py index 363f93d3..2d218f5d 100644 --- a/backend/app/services/payments/subscriptions.py +++ b/backend/app/services/payments/subscriptions.py @@ -131,9 +131,9 @@ async def create_subscription( async def get_user_subscription_status(user_id: str) -> UserSubscriptionStatus: """Get user's current subscription status.""" try: - # Get user's active subscription + # Get user's active subscription (only truly active ones with payment) subscription = await subscriptions_collection.find_one( - {"user_id": user_id, "status": {"$in": ["active", "paused", "created"]}} + {"user_id": user_id, "status": "active", "paid_count": {"$gt": 0}} ) if not subscription: @@ -344,9 +344,9 @@ async def cancel_subscription( ) -> Dict[str, str]: """Cancel user's subscription with proper cleanup.""" try: - # Get current subscription + # Get current active subscription (only paid ones) subscription = await subscriptions_collection.find_one( - {"user_id": user_id, "status": {"$in": ["active", "created"]}} + {"user_id": user_id, "status": "active", "paid_count": {"$gt": 0}} ) if not subscription: diff --git a/backend/app/services/payments/verification.py b/backend/app/services/payments/verification.py index fc588bd5..4e0710d2 100644 --- a/backend/app/services/payments/verification.py +++ b/backend/app/services/payments/verification.py @@ -132,30 +132,40 @@ async def verify_payment( logger.error(f"Failed to store payment in database: {e}") raise HTTPException(status_code=500, detail="Failed to save payment") - # If this payment is for a subscription, activate the subscription + # If this payment is for a subscription, activate it atomically if callback_data.razorpay_subscription_id: try: - result = await subscriptions_collection.update_one( - { - "razorpay_subscription_id": callback_data.razorpay_subscription_id - }, - { - "$set": { - "status": "active", - "paid_count": 1, - "updated_at": datetime.now(timezone.utc), - } - }, - ) - subscription_activated = result.modified_count > 0 - if subscription_activated: - logger.info( - f"Activated subscription: {callback_data.razorpay_subscription_id}" + # Ensure only successful, captured payments activate subscriptions + if razorpay_payment.get( + "status" + ) == "captured" and razorpay_payment.get("captured"): + result = await subscriptions_collection.update_one( + { + "razorpay_subscription_id": callback_data.razorpay_subscription_id, + "status": "created", # Only activate if still in created status + }, + { + "$set": { + "status": "active", + "paid_count": 1, + "updated_at": datetime.now(timezone.utc), + } + }, ) + subscription_activated = result.modified_count > 0 + if subscription_activated: + logger.info( + f"Activated subscription: {callback_data.razorpay_subscription_id}" + ) + else: + logger.warning( + f"Subscription not found or already activated: {callback_data.razorpay_subscription_id}" + ) else: logger.warning( - f"No subscription found to activate: {callback_data.razorpay_subscription_id}" + f"Payment not captured, not activating subscription: {callback_data.razorpay_payment_id}" ) + subscription_activated = False except Exception as e: logger.error( f"Failed to activate subscription {callback_data.razorpay_subscription_id}: {e}" diff --git a/backend/app/services/payments/webhooks.py b/backend/app/services/payments/webhooks.py index 7b530e10..6eb3417b 100644 --- a/backend/app/services/payments/webhooks.py +++ b/backend/app/services/payments/webhooks.py @@ -2,37 +2,95 @@ Webhook event processing for Razorpay. """ +import hashlib from datetime import datetime, timezone from typing import Any, Dict from fastapi import HTTPException from app.config.loggers import general_logger as logger -from app.db.mongodb.collections import payments_collection, subscriptions_collection +from app.db.mongodb.collections import ( + payments_collection, + subscriptions_collection, + webhook_events_collection, +) from app.models.payment_models import WebhookEvent +from app.models.webhook_models import WebhookEventDB from app.utils.payments_utils import timestamp_to_datetime from .client import razorpay_service async def process_webhook(event: WebhookEvent) -> Dict[str, str]: - """Process Razorpay webhook events with proper error handling.""" + """Process Razorpay webhook events with idempotency.""" + event_type = event.event + entity = event.payload.get("payment", event.payload.get("subscription", {})) + entity_id = entity.get("id") + try: - event_type = event.event - entity = event.payload.get("payment", event.payload.get("subscription", {})) - entity_id = entity.get("id") + # Generate payload hash for idempotency + payload_str = f"{event_type}:{entity_id}:{entity.get('status', '')}" + payload_hash = hashlib.sha256(payload_str.encode()).hexdigest() logger.info(f"Processing webhook event: {event_type} for entity: {entity_id}") + # Check if we've already processed this exact event + existing_event = await webhook_events_collection.find_one( + { + "$or": [ + {"event_id": f"{event_type}_{entity_id}"}, + {"payload_hash": payload_hash}, + ] + } + ) + + if existing_event and existing_event.get("status") == "processed": + logger.info(f"Event already processed: {event_type}_{entity_id}") + return {"status": "already_processed", "event": event_type} + + # Record the webhook event + webhook_event_doc = WebhookEventDB( + _id=None, + event_id=f"{event_type}_{entity_id}", + event_type=event_type, + razorpay_entity_id=entity_id, + processed_at=datetime.now(timezone.utc), + payload_hash=payload_hash, + status="processing", + ) + + await webhook_events_collection.insert_one( + webhook_event_doc.dict(by_alias=True, exclude={"id"}) + ) + + # Process the webhook if event_type.startswith("payment."): - return await _process_payment_webhook(event_type, entity) + result = await _process_payment_webhook(event_type, entity) elif event_type.startswith("subscription."): - return await _process_subscription_webhook(event_type, entity) + result = await _process_subscription_webhook(event_type, entity) else: logger.warning(f"Unhandled webhook event type: {event_type}") - return {"status": "ignored", "message": "Event type not handled"} + result = {"status": "ignored", "message": "Event type not handled"} + + # Mark as processed + await webhook_events_collection.update_one( + {"event_id": f"{event_type}_{entity_id}"}, {"$set": {"status": "processed"}} + ) + + logger.info(f"Webhook processed successfully: {event_type}") + return result except Exception as e: + # Mark as failed + event_id = f"{event_type}_{entity_id}" if entity_id else f"{event_type}_unknown" + await webhook_events_collection.update_one( + {"event_id": event_id}, + { + "$set": {"status": "failed", "error_message": str(e)}, + "$inc": {"retry_count": 1}, + }, + ) + logger.error(f"Error processing webhook event {event.event}: {e}") raise HTTPException(status_code=500, detail="Webhook processing failed") diff --git a/backend/app/tasks/subscription_cleanup.py b/backend/app/tasks/subscription_cleanup.py new file mode 100644 index 00000000..bce8c085 --- /dev/null +++ b/backend/app/tasks/subscription_cleanup.py @@ -0,0 +1,165 @@ +""" +Subscription cleanup and reconciliation tasks. +""" + +from datetime import datetime, timezone, timedelta +from typing import Dict, Any + +from app.config.loggers import general_logger as logger +from app.db.mongodb.collections import subscriptions_collection +from app.services.payments.client import razorpay_service + + +async def cleanup_abandoned_subscriptions() -> Dict[str, Any]: + """ + Clean up abandoned subscriptions that were created but never paid. + Removes subscriptions in 'created' status older than 30 minutes. + """ + try: + cutoff_time = datetime.now(timezone.utc) - timedelta(minutes=30) + + # Find abandoned subscriptions + abandoned_subscriptions = subscriptions_collection.find( + {"status": "created", "paid_count": 0, "created_at": {"$lt": cutoff_time}} + ) + + cleanup_count = 0 + async for subscription in abandoned_subscriptions: + try: + # Cancel in Razorpay if it exists + razorpay_subscription_id = subscription.get("razorpay_subscription_id") + if razorpay_subscription_id: + try: + razorpay_service.client.subscription.cancel( + razorpay_subscription_id + ) + logger.info( + f"Cancelled Razorpay subscription: {razorpay_subscription_id}" + ) + except Exception as e: + logger.warning( + f"Failed to cancel Razorpay subscription {razorpay_subscription_id}: {e}" + ) + + # Remove from our database + await subscriptions_collection.delete_one({"_id": subscription["_id"]}) + cleanup_count += 1 + + logger.info(f"Cleaned up abandoned subscription: {subscription['_id']}") + + except Exception as e: + logger.error( + f"Failed to cleanup subscription {subscription.get('_id')}: {e}" + ) + continue + + result = { + "status": "completed", + "cleaned_up_count": cleanup_count, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + if cleanup_count > 0: + logger.info(f"Cleaned up {cleanup_count} abandoned subscriptions") + + return result + + except Exception as e: + logger.error(f"Error during subscription cleanup: {e}") + return { + "status": "failed", + "error": str(e), + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + +async def reconcile_subscription_payments() -> Dict[str, Any]: + """ + Reconcile subscription payments with Razorpay. + Syncs payment status for subscriptions that might be out of sync. + """ + try: + # Find active subscriptions that might need reconciliation + suspect_subscriptions = subscriptions_collection.find( + { + "status": "active", + "paid_count": 0, # Active but no payments recorded + "created_at": {"$gte": datetime.now(timezone.utc) - timedelta(days=7)}, + } + ) + + reconciled_count = 0 + deactivated_count = 0 + + async for subscription in suspect_subscriptions: + try: + razorpay_subscription_id = subscription.get("razorpay_subscription_id") + if not razorpay_subscription_id: + continue + + # Fetch latest status from Razorpay + razorpay_subscription = razorpay_service.client.subscription.fetch( + razorpay_subscription_id + ) + + razorpay_status = razorpay_subscription.get("status") + razorpay_paid_count = razorpay_subscription.get("paid_count", 0) + + # If Razorpay shows no payments, deactivate subscription + if razorpay_paid_count == 0 and razorpay_status != "active": + await subscriptions_collection.update_one( + {"_id": subscription["_id"]}, + { + "$set": { + "status": "cancelled", + "updated_at": datetime.now(timezone.utc), + } + }, + ) + deactivated_count += 1 + logger.info( + f"Deactivated unpaid subscription: {subscription['_id']}" + ) + + # If Razorpay shows payments, update our records + elif razorpay_paid_count > 0: + await subscriptions_collection.update_one( + {"_id": subscription["_id"]}, + { + "$set": { + "paid_count": razorpay_paid_count, + "status": razorpay_status, + "updated_at": datetime.now(timezone.utc), + } + }, + ) + reconciled_count += 1 + logger.info(f"Reconciled subscription: {subscription['_id']}") + + except Exception as e: + logger.error( + f"Failed to reconcile subscription {subscription.get('_id')}: {e}" + ) + continue + + result = { + "status": "completed", + "reconciled_count": reconciled_count, + "deactivated_count": deactivated_count, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + if reconciled_count > 0 or deactivated_count > 0: + logger.info( + f"Reconciliation completed: {reconciled_count} reconciled, {deactivated_count} deactivated" + ) + + return result + + except Exception as e: + logger.error(f"Error during subscription reconciliation: {e}") + return { + "status": "failed", + "error": str(e), + "timestamp": datetime.now(timezone.utc).isoformat(), + } diff --git a/frontend/src/features/pricing/components/PricingCards.tsx b/frontend/src/features/pricing/components/PricingCards.tsx index da966ebd..66fc9f2f 100644 --- a/frontend/src/features/pricing/components/PricingCards.tsx +++ b/frontend/src/features/pricing/components/PricingCards.tsx @@ -82,6 +82,7 @@ export function PricingCards({ } const isCurrentPlan = subscriptionStatus?.current_plan?.id === plan.id; + // Only consider truly active subscriptions (not just created ones) const hasActiveSubscription = subscriptionStatus?.is_subscribed && subscriptionStatus?.subscription?.status === "active"; diff --git a/frontend/src/features/pricing/hooks/usePaymentFlow.ts b/frontend/src/features/pricing/hooks/usePaymentFlow.ts index f9ad420a..0550394e 100644 --- a/frontend/src/features/pricing/hooks/usePaymentFlow.ts +++ b/frontend/src/features/pricing/hooks/usePaymentFlow.ts @@ -219,8 +219,8 @@ export const usePaymentFlow = () => { isProcessing: false, }); - toast.info("Payment cancelled", { - duration: 3000, + toast.info("Payment was cancelled. Your subscription was not activated.", { + duration: 4000, }); }, []); diff --git a/frontend/src/features/subscription/components/SubscriptionActivationBanner.tsx b/frontend/src/features/subscription/components/SubscriptionActivationBanner.tsx index cadb4841..cdeec4e7 100644 --- a/frontend/src/features/subscription/components/SubscriptionActivationBanner.tsx +++ b/frontend/src/features/subscription/components/SubscriptionActivationBanner.tsx @@ -11,13 +11,16 @@ export function SubscriptionActivationBanner() { const [lastChecked, setLastChecked] = useState(null); useEffect(() => { - // Check if we should show the activation banner - // Show if subscription exists but is not active (pending activation) + // Only show banner for subscriptions that are created but payment is being processed + // Don't show for cancelled or failed payments const subscription = subscriptionStatus?.subscription; const shouldShow = subscription && subscription.status === "created" && - !subscriptionStatus.is_subscribed; + !subscriptionStatus.is_subscribed && + // Only show if subscription was created recently (within last hour) + subscription.created_at && + new Date(subscription.created_at).getTime() > Date.now() - 3600000; if (shouldShow && lastChecked !== subscription?.id) { setShowBanner(true); From 3c95a51654625cac7aa86bafdc1bf0e025b55aa1 Mon Sep 17 00:00:00 2001 From: Dhruv Maradiya Date: Thu, 7 Aug 2025 01:04:05 +0530 Subject: [PATCH 08/72] refactor: update datetime handling and remove unused apiService --- backend/app/config/token_repository.py | 9 +- backend/app/db/redis.py | 13 ++- backend/app/models/support_models.py | 7 +- frontend/src/features/chat/api/chatApi.ts | 1 + .../src/features/chat/hooks/useChatStream.ts | 10 ++- frontend/src/services/apiService.ts | 88 ------------------- frontend/src/services/index.ts | 1 - 7 files changed, 26 insertions(+), 103 deletions(-) delete mode 100644 frontend/src/services/apiService.ts diff --git a/backend/app/config/token_repository.py b/backend/app/config/token_repository.py index 05cddb65..27bfb484 100644 --- a/backend/app/config/token_repository.py +++ b/backend/app/config/token_repository.py @@ -9,7 +9,7 @@ """ import json -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional from app.config.loggers import token_repository_logger as logger @@ -84,7 +84,7 @@ def _get_token_expiration(self, token_data: dict) -> datetime: return datetime.now() + timedelta(seconds=expires_in) except (ValueError, TypeError): logger.warning(f"Invalid expires_in: {expires_in}, using default") - return datetime.utcnow() + timedelta(seconds=3600) + return datetime.now(timezone.utc) + timedelta(seconds=3600) async def store_token( self, user_id: str, provider: str, token_data: Dict[str, Any] @@ -217,11 +217,6 @@ async def get_token( } ) - # Log token status for debugging - logger.debug( - f"Token expiry status - is_expired: {oauth_token.is_expired()}, will_renew: {renew_if_expired}" - ) - # Check if token is expired if renew_if_expired and oauth_token.is_expired(): # Token is expired, attempt to refresh it diff --git a/backend/app/db/redis.py b/backend/app/db/redis.py index db5906b8..d538318f 100644 --- a/backend/app/db/redis.py +++ b/backend/app/db/redis.py @@ -3,10 +3,9 @@ from typing import Any import redis.asyncio as redis -from pydantic import BaseModel - from app.config.loggers import redis_logger as logger from app.config.settings import settings +from pydantic import BaseModel ONE_YEAR_TTL = 31_536_000 ONE_HOUR_TTL = 3600 @@ -90,6 +89,16 @@ async def delete(self, key: str): except Exception as e: logger.error(f"Error deleting Redis key {key}: {e}") + @property + def client(self): + """ + Get the Redis client instance. + """ + if not self.redis: + self.redis = redis.from_url(self.redis_url, decode_responses=True) + logger.info("Re-initialized Redis connection.") + + return self.redis # Initialize the Redis cache redis_cache = RedisCache() diff --git a/backend/app/models/support_models.py b/backend/app/models/support_models.py index c31c49a6..2af243ad 100644 --- a/backend/app/models/support_models.py +++ b/backend/app/models/support_models.py @@ -1,10 +1,10 @@ """Support request models for the GAIA API.""" -from datetime import datetime +from datetime import datetime, timezone from enum import Enum from typing import Any, Dict, List, Optional -from pydantic import BaseModel, Field, EmailStr +from pydantic import BaseModel, EmailStr, Field class SupportRequestType(str, Enum): @@ -40,7 +40,8 @@ class SupportAttachment(BaseModel): content_type: str = Field(..., description="MIME type of the file") file_url: Optional[str] = Field(None, description="URL to access the file") uploaded_at: datetime = Field( - default_factory=datetime.utcnow, description="Upload timestamp" + default_factory=lambda: datetime.now(timezone.utc), + description="Upload timestamp", ) diff --git a/frontend/src/features/chat/api/chatApi.ts b/frontend/src/features/chat/api/chatApi.ts index 76b3f741..c6d7d145 100644 --- a/frontend/src/features/chat/api/chatApi.ts +++ b/frontend/src/features/chat/api/chatApi.ts @@ -186,6 +186,7 @@ export const chatApi = { await fetchEventSource( `${process.env.NEXT_PUBLIC_API_BASE_URL}chat-stream`, { + openWhenHidden: true, method: "POST", headers: { "Content-Type": "application/json", diff --git a/frontend/src/features/chat/hooks/useChatStream.ts b/frontend/src/features/chat/hooks/useChatStream.ts index 75cc96a7..bbaf8f58 100644 --- a/frontend/src/features/chat/hooks/useChatStream.ts +++ b/frontend/src/features/chat/hooks/useChatStream.ts @@ -141,8 +141,14 @@ export const useChatStream = () => { resetLoadingText(); if (refs.current.newConversation.id) { - // && !refs.current.convoMessages[0]?.conversation_id - router.push(`/c/${refs.current.newConversation.id}`); + // If a new conversation was created, update the URL and fetch conversations + // Using replaceState to avoid reloading the page that would happen with pushState + // Reloading results in fetching conversations again hence the flickering + window.history.replaceState( + {}, + "", + `/c/${refs.current.newConversation.id}`, + ); fetchConversations(); } diff --git a/frontend/src/services/apiService.ts b/frontend/src/services/apiService.ts deleted file mode 100644 index 34b078bc..00000000 --- a/frontend/src/services/apiService.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { - EventSourceMessage, - fetchEventSource, -} from "@microsoft/fetch-event-source"; - -import { chatApi } from "@/features/chat/api/chatApi"; -import { MessageType } from "@/types/features/convoTypes"; -import { FileData } from "@/types/shared"; - -export const ApiService = { - fetchMessages: async (conversationId: string) => { - if (!conversationId) return; - try { - return await chatApi.fetchMessages(conversationId); - } catch (error) { - console.error( - `Error fetching messages for conversation ${conversationId}:`, - error, - ); - throw error; - } - }, - - deleteAllConversations: async () => { - try { - await chatApi.deleteAllConversations(); - } catch (error) { - console.error("Error deleting all conversations:", error); - throw error; - } - }, - - fetchChatStream: async ( - inputText: string, - convoMessages: MessageType[], - conversationId: string | null, - onMessage: (event: EventSourceMessage) => void, - onClose: () => void, - onError: (err: Error) => void, - fileData: FileData[] = [], // Updated to accept FileData instead of fileIds - ) => { - const controller = new AbortController(); - - // Extract fileIds from fileData for backward compatibility - const fileIds = fileData.map((file) => file.fileId); - - await fetchEventSource( - `${process.env.NEXT_PUBLIC_API_BASE_URL}chat-stream`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "text/event-stream", - "x-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone, - }, - credentials: "include", - signal: controller.signal, - body: JSON.stringify({ - conversation_id: conversationId, - message: inputText, - fileIds, // For backward compatibility - fileData, // Send complete file data - messages: convoMessages - .slice(-30) - .filter(({ response }) => response.trim().length > 0) - .map(({ type, response }, _index, _array) => ({ - role: type === "bot" ? "assistant" : type, - content: response, - })), - }), - onmessage(event) { - onMessage(event); - - if (event.data === "[DONE]") { - onClose(); - controller.abort(); - return; - } - }, - onclose() { - onClose(); - controller.abort(); - }, - onerror: onError, - }, - ); - }, -}; diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts index 70f84f97..2aa63eae 100644 --- a/frontend/src/services/index.ts +++ b/frontend/src/services/index.ts @@ -3,4 +3,3 @@ */ export * from "./api/index"; -export * from "./apiService"; From 2434176a6aed0a8ea6e80d3dee0291732e3d4ab7 Mon Sep 17 00:00:00 2001 From: Aryan Date: Thu, 7 Aug 2025 04:42:02 +0530 Subject: [PATCH 09/72] Refactor payment handling and UI components - Replaced native button with custom Button component in BlogList and PricingPage for consistency. - Introduced PaymentSuccessPage to handle payment verification and success feedback. - Updated pricing API to streamline subscription creation and payment verification. - Removed Razorpay integration and replaced it with Dodo Payments for subscription handling. - Simplified error handling and state management in payment flow. - Enhanced SubscriptionSettings to disable cancellation option and guide users to contact support. - Cleaned up unused hooks and components related to Razorpay. --- backend/app/api/v1/router/payments.py | 180 +---- backend/app/api/v1/router/usage.py | 4 +- backend/app/arq_worker.py | 96 +-- backend/app/config/settings.py | 5 +- backend/app/db/mongodb/indexes.py | 23 +- backend/app/decorators/rate_limiting.py | 6 +- backend/app/middleware/tiered_rate_limiter.py | 4 +- backend/app/models/payment_models.py | 197 ++---- backend/app/models/webhook_models.py | 26 - backend/app/services/payment_service.py | 239 +++++++ backend/app/services/payments/__init__.py | 27 - backend/app/services/payments/client.py | 97 --- backend/app/services/payments/core.py | 29 - backend/app/services/payments/plans.py | 87 --- .../app/services/payments/subscriptions.py | 613 ------------------ backend/app/services/payments/verification.py | 223 ------- backend/app/services/payments/webhooks.py | 287 -------- backend/app/tasks/subscription_cleanup.py | 165 ----- backend/app/utils/payments_utils.py | 88 --- backend/pyproject.toml | 2 +- backend/scripts/dodo_setup.py | 278 ++++++++ backend/scripts/razorpay_setup.py | 297 --------- docs/configuration/environment-variables.mdx | 4 - frontend/package.json | 1 + frontend/pnpm-lock.yaml | 137 ++++ frontend/src/app/(landing)/blog/page.tsx | 8 +- .../app/(landing)/payment/success/page.tsx | 132 ++++ .../src/features/pricing/api/pricingApi.ts | 128 +--- .../pricing/components/PaymentSummary.tsx | 10 - .../pricing/components/PricingCard.tsx | 63 +- .../pricing/components/PricingCards.tsx | 11 +- .../pricing/components/PricingPage.tsx | 10 +- .../features/pricing/hooks/useDodoPayments.ts | 51 ++ .../pricing/hooks/usePaymentErrorHandling.ts | 177 ----- .../features/pricing/hooks/usePaymentFlow.ts | 251 +------ .../src/features/pricing/hooks/usePricing.ts | 101 ++- .../src/features/pricing/hooks/useRazorpay.ts | 133 ---- .../components/SubscriptionSettings.tsx | 55 +- 38 files changed, 1191 insertions(+), 3054 deletions(-) delete mode 100644 backend/app/models/webhook_models.py create mode 100644 backend/app/services/payment_service.py delete mode 100644 backend/app/services/payments/__init__.py delete mode 100644 backend/app/services/payments/client.py delete mode 100644 backend/app/services/payments/core.py delete mode 100644 backend/app/services/payments/plans.py delete mode 100644 backend/app/services/payments/subscriptions.py delete mode 100644 backend/app/services/payments/verification.py delete mode 100644 backend/app/services/payments/webhooks.py delete mode 100644 backend/app/tasks/subscription_cleanup.py delete mode 100644 backend/app/utils/payments_utils.py create mode 100644 backend/scripts/dodo_setup.py delete mode 100644 backend/scripts/razorpay_setup.py create mode 100644 frontend/src/app/(landing)/payment/success/page.tsx create mode 100644 frontend/src/features/pricing/hooks/useDodoPayments.ts delete mode 100644 frontend/src/features/pricing/hooks/usePaymentErrorHandling.ts delete mode 100644 frontend/src/features/pricing/hooks/useRazorpay.ts diff --git a/backend/app/api/v1/router/payments.py b/backend/app/api/v1/router/payments.py index 6fc5902e..a99a3d33 100644 --- a/backend/app/api/v1/router/payments.py +++ b/backend/app/api/v1/router/payments.py @@ -1,194 +1,93 @@ """ -Clean payment and subscription router for Razorpay integration. +Clean payment router for Dodo Payments integration. +Single service approach - simple and maintainable. """ import json from typing import List from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel from app.api.v1.dependencies.oauth_dependencies import get_current_user from app.config.loggers import general_logger as logger from app.middleware.rate_limiter import limiter from app.models.payment_models import ( CreateSubscriptionRequest, - PaymentCallbackRequest, - PaymentResponse, + PaymentVerificationResponse, PlanResponse, - SubscriptionResponse, - UpdateSubscriptionRequest, UserSubscriptionStatus, - WebhookEvent, -) -from app.services.payments import ( - cancel_subscription, - create_subscription, - get_plans, - get_user_subscription_status, - process_webhook, - razorpay_service, - sync_subscription_from_razorpay, - update_subscription, - verify_payment, ) +from app.services.payment_service import payment_service + -# Initialize router router = APIRouter() -# Core Payment Flow Endpoints -@router.get( - "/plans", response_model=List[PlanResponse], summary="Get subscription plans" -) -@limiter.limit("30/minute") # Allow 30 plan fetches per minute +@router.get("/plans", response_model=List[PlanResponse]) +@limiter.limit("30/minute") async def get_plans_endpoint(request: Request, active_only: bool = True): """Get all available subscription plans.""" - return await get_plans(active_only=active_only) + return await payment_service.get_plans(active_only=active_only) -@router.post( - "/subscriptions", - response_model=SubscriptionResponse, - summary="Create subscription", -) -@limiter.limit("5/minute") # Allow 5 subscription creations per minute +@router.post("/subscriptions") +@limiter.limit("5/minute") async def create_subscription_endpoint( request: Request, subscription_data: CreateSubscriptionRequest, current_user: dict = Depends(get_current_user), ): - """Create a new subscription for the authenticated user.""" - user_id = current_user.get("user_id") - if not user_id: - raise HTTPException(status_code=401, detail="Authentication required") - - return await create_subscription(user_id, subscription_data) - - -@router.get( - "/subscriptions/status", - response_model=UserSubscriptionStatus, - summary="Get subscription status", -) -@limiter.limit("60/minute") # Allow 60 status checks per minute -async def get_subscription_status_endpoint( - request: Request, - current_user: dict = Depends(get_current_user), -): - """Get the current subscription status for the authenticated user.""" - user_id = current_user.get("user_id") - if not user_id: - raise HTTPException(status_code=401, detail="Authentication required") - - return await get_user_subscription_status(user_id) - - -@router.put( - "/subscriptions", response_model=SubscriptionResponse, summary="Update subscription" -) -@limiter.limit("10/minute") # Allow 10 subscription updates per minute -async def update_subscription_endpoint( - request: Request, - subscription_data: UpdateSubscriptionRequest, - current_user: dict = Depends(get_current_user), -): - """Update the user's current subscription.""" + """Create a new subscription and return payment link.""" user_id = current_user.get("user_id") if not user_id: raise HTTPException(status_code=401, detail="Authentication required") - return await update_subscription(user_id, subscription_data) - - -@router.delete("/subscriptions", summary="Cancel subscription") -@limiter.limit("5/minute") # Allow 5 cancellations per minute -async def cancel_subscription_endpoint( - request: Request, - cancel_at_cycle_end: bool = True, - current_user: dict = Depends(get_current_user), -): - """Cancel the user's current subscription.""" - user_id = current_user.get("user_id") - if not user_id: - raise HTTPException(status_code=401, detail="Authentication required") - - return await cancel_subscription(user_id, cancel_at_cycle_end) + return await payment_service.create_subscription( + user_id, subscription_data.product_id, subscription_data.quantity + ) -@router.post("/subscriptions/sync", summary="Sync subscription data from Razorpay") -@limiter.limit("5/minute") # Allow 5 sync operations per minute -async def sync_subscription_endpoint( +@router.post("/verify-payment", response_model=PaymentVerificationResponse) +@limiter.limit("20/minute") +async def verify_payment_endpoint( request: Request, current_user: dict = Depends(get_current_user), ): - """Sync subscription data from Razorpay to fix missing date fields.""" + """Verify if user's payment has been completed.""" user_id = current_user.get("user_id") if not user_id: raise HTTPException(status_code=401, detail="Authentication required") - # Get user's current subscription - from app.db.mongodb.collections import subscriptions_collection - - subscription = await subscriptions_collection.find_one( - {"user_id": user_id, "status": "active", "paid_count": {"$gt": 0}} - ) + result = await payment_service.verify_payment_completion(user_id) + return PaymentVerificationResponse(**result) - if not subscription: - raise HTTPException(status_code=404, detail="No active subscription found") - razorpay_subscription_id = subscription["razorpay_subscription_id"] - - success = await sync_subscription_from_razorpay(razorpay_subscription_id) - - if success: - return { - "message": "Subscription synced successfully", - "subscription_id": razorpay_subscription_id, - } - else: - raise HTTPException(status_code=500, detail="Failed to sync subscription") - - -@router.post("/verify", response_model=PaymentResponse, summary="Verify payment") -@limiter.limit("20/minute") # Allow 20 payment verifications per minute -async def verify_payment_endpoint( +@router.get("/subscription-status", response_model=UserSubscriptionStatus) +@limiter.limit("60/minute") +async def get_subscription_status_endpoint( request: Request, - callback_data: PaymentCallbackRequest, current_user: dict = Depends(get_current_user), ): - """Verify a payment after user completes the payment process.""" + """Get user's current subscription status.""" user_id = current_user.get("user_id") if not user_id: raise HTTPException(status_code=401, detail="Authentication required") - return await verify_payment(user_id, callback_data) + return await payment_service.get_user_subscription_status(user_id) -# Webhook endpoint (no rate limiting for webhooks from Razorpay) -@router.post("/webhooks/razorpay", summary="Handle Razorpay webhooks") -async def razorpay_webhook_endpoint(request: Request): - """Handle incoming webhooks from Razorpay.""" +@router.post("/webhooks/dodo") +async def handle_dodo_webhook(request: Request): + """Handle incoming webhooks from Dodo Payments.""" try: - # Get raw body and signature body = await request.body() - signature = request.headers.get("X-Razorpay-Signature") - - if not signature: - raise HTTPException(status_code=400, detail="Missing signature") - - # Verify webhook signature - if not razorpay_service.verify_webhook_signature(body, signature): - raise HTTPException(status_code=400, detail="Invalid signature") - - # Parse webhook data webhook_data = json.loads(body.decode("utf-8")) - event = WebhookEvent(**webhook_data) - # Process the webhook - result = await process_webhook(event) + result = await payment_service.handle_webhook(webhook_data) - logger.info(f"Webhook processed successfully: {event.event}") - return result + logger.info(f"Webhook processed: {result}") + return {"status": "success", "result": result} except json.JSONDecodeError: logger.error("Invalid JSON in webhook payload") @@ -196,18 +95,3 @@ async def razorpay_webhook_endpoint(request: Request): except Exception as e: logger.error(f"Error processing webhook: {e}") raise HTTPException(status_code=500, detail="Webhook processing failed") - - -# Essential config endpoint -@router.get("/config", summary="Get payment configuration") -@limiter.limit("120/minute") # Allow frequent config fetches -async def get_payment_config(request: Request): - """Get payment configuration for frontend integration.""" - from app.config.settings import settings - - return { - "razorpay_key_id": settings.RAZORPAY_KEY_ID, - "currency": "USD", - "company_name": "GAIA", - "theme_color": "#00bbff", - } diff --git a/backend/app/api/v1/router/usage.py b/backend/app/api/v1/router/usage.py index e2b73185..072b866d 100644 --- a/backend/app/api/v1/router/usage.py +++ b/backend/app/api/v1/router/usage.py @@ -16,7 +16,7 @@ get_reset_time, RateLimitPeriod, ) -from app.services.payments.subscriptions import get_user_subscription_status +from app.services.payment_service import payment_service from app.models.payment_models import PlanType from app.decorators.rate_limiting import tiered_limiter @@ -33,7 +33,7 @@ async def get_usage_summary(user: dict = Depends(get_current_user)) -> Dict[str, raise HTTPException(status_code=400, detail="User ID not found") # Get user subscription - subscription = await get_user_subscription_status(user_id) + subscription = await payment_service.get_user_subscription_status(user_id) user_plan = subscription.plan_type or PlanType.FREE # Get real-time usage data directly from Redis diff --git a/backend/app/arq_worker.py b/backend/app/arq_worker.py index c48b7e96..6ff8cc79 100644 --- a/backend/app/arq_worker.py +++ b/backend/app/arq_worker.py @@ -12,10 +12,10 @@ from app.config.settings import settings from app.langchain.llm.client import init_llm from app.services.reminder_service import process_reminder_task -from app.tasks.subscription_cleanup import ( - cleanup_abandoned_subscriptions, - reconcile_subscription_payments, -) +# from app.tasks.subscription_cleanup import ( +# cleanup_abandoned_subscriptions, +# reconcile_subscription_payments, +# ) async def startup(ctx: dict): @@ -50,37 +50,37 @@ async def startup(ctx: dict): GraphManager.set_graph(built_graph, graph_name="reminder_processing") -async def cleanup_abandoned_subscriptions_task(ctx: dict) -> str: - """ARQ task wrapper for cleaning up abandoned subscriptions.""" - try: - result = await cleanup_abandoned_subscriptions() - message = f"Subscription cleanup completed. Status: {result['status']}, Cleaned: {result.get('cleaned_up_count', 0)}" - logger.info(message) - return message - except Exception as e: - error_msg = f"Failed to cleanup abandoned subscriptions: {str(e)}" - logger.error(error_msg) - raise - - -async def reconcile_subscription_payments_task(ctx: dict) -> str: - """ARQ task wrapper for reconciling subscription payments.""" - try: - result = await reconcile_subscription_payments() - message = f"Payment reconciliation completed. Status: {result['status']}, Reconciled: {result.get('reconciled_count', 0)}, Deactivated: {result.get('deactivated_count', 0)}" - logger.info(message) - return message - except Exception as e: - error_msg = f"Failed to reconcile subscription payments: {str(e)}" - logger.error(error_msg) - raise - """ARQ worker shutdown function.""" - logger.info("ARQ worker shutting down...") - - # Clean up any resources - startup_time = ctx.get("startup_time", 0) - runtime = asyncio.get_event_loop().time() - startup_time - logger.info(f"ARQ worker ran for {runtime:.2f} seconds") +# async def cleanup_abandoned_subscriptions_task(ctx: dict) -> str: +# """ARQ task wrapper for cleaning up abandoned subscriptions.""" +# try: +# result = await cleanup_abandoned_subscriptions() +# message = f"Subscription cleanup completed. Status: {result['status']}, Cleaned: {result.get('cleaned_up_count', 0)}" +# logger.info(message) +# return message +# except Exception as e: +# error_msg = f"Failed to cleanup abandoned subscriptions: {str(e)}" +# logger.error(error_msg) +# raise + + +# async def reconcile_subscription_payments_task(ctx: dict) -> str: +# """ARQ task wrapper for reconciling subscription payments.""" +# try: +# result = await reconcile_subscription_payments() +# message = f"Payment reconciliation completed. Status: {result['status']}, Reconciled: {result.get('reconciled_count', 0)}, Deactivated: {result.get('deactivated_count', 0)}" +# logger.info(message) +# return message +# except Exception as e: +# error_msg = f"Failed to reconcile subscription payments: {str(e)}" +# logger.error(error_msg) +# raise +# """ARQ worker shutdown function.""" +# logger.info("ARQ worker shutting down...") + +# # Clean up any resources +# startup_time = ctx.get("startup_time", 0) +# runtime = asyncio.get_event_loop().time() - startup_time +# logger.info(f"ARQ worker ran for {runtime:.2f} seconds") async def process_reminder(ctx: dict, reminder_id: str) -> str: @@ -241,8 +241,8 @@ class WorkerSettings: cleanup_expired_reminders, check_inactive_users, renew_gmail_watch_subscriptions, - cleanup_abandoned_subscriptions_task, - reconcile_subscription_payments_task, + # cleanup_abandoned_subscriptions_task, + # reconcile_subscription_payments_task, ] cron_jobs = [ cron( @@ -263,17 +263,17 @@ class WorkerSettings: minute=0, # At the start of the hour second=0, # At the start of the minute ), - cron( - cleanup_abandoned_subscriptions_task, - minute={0, 30}, # Every 30 minutes - second=0, - ), - cron( - reconcile_subscription_payments_task, - hour=1, # At 1 AM daily - minute=0, - second=0, - ), + # cron( + # cleanup_abandoned_subscriptions_task, + # minute={0, 30}, # Every 30 minutes + # second=0, + # ), + # cron( + # reconcile_subscription_payments_task, + # hour=1, # At 1 AM daily + # minute=0, + # second=0, + # ), ] on_startup = startup on_shutdown = shutdown diff --git a/backend/app/config/settings.py b/backend/app/config/settings.py index b7ffa2e9..af5212d0 100644 --- a/backend/app/config/settings.py +++ b/backend/app/config/settings.py @@ -134,9 +134,8 @@ class Settings(BaseSettings): # Code Execution E2B_API_KEY: str - # Razorpay Configuration - RAZORPAY_KEY_ID: str - RAZORPAY_KEY_SECRET: str + # Dodo Payments Configuration + DODO_PAYMENTS_API_KEY: str @computed_field # type: ignore @property diff --git a/backend/app/db/mongodb/indexes.py b/backend/app/db/mongodb/indexes.py index bd5420c3..3cd12b55 100644 --- a/backend/app/db/mongodb/indexes.py +++ b/backend/app/db/mongodb/indexes.py @@ -31,6 +31,7 @@ todos_collection, usage_snapshots_collection, users_collection, + webhook_events_collection, ) @@ -425,25 +426,23 @@ async def create_payment_indexes(): await asyncio.gather( # Payment indexes payments_collection.create_index("user_id"), - payments_collection.create_index("razorpay_payment_id", unique=True), - payments_collection.create_index("subscription_id", sparse=True), - payments_collection.create_index("order_id", sparse=True), + payments_collection.create_index("dodo_subscription_id", sparse=True), payments_collection.create_index("status"), payments_collection.create_index([("user_id", 1), ("created_at", -1)]), - # Subscription indexes + payments_collection.create_index("webhook_verified"), + # Subscription indexes - updated for Dodo schema subscriptions_collection.create_index("user_id"), - subscriptions_collection.create_index( - "razorpay_subscription_id", unique=True - ), - subscriptions_collection.create_index("plan_id"), + subscriptions_collection.create_index("dodo_subscription_id", unique=True), + subscriptions_collection.create_index("product_id"), subscriptions_collection.create_index("status"), subscriptions_collection.create_index([("user_id", 1), ("status", 1)]), - subscriptions_collection.create_index("current_end", sparse=True), - subscriptions_collection.create_index("charge_at", sparse=True), - # Plan indexes - plans_collection.create_index("razorpay_plan_id", unique=True), + subscriptions_collection.create_index([("user_id", 1), ("created_at", -1)]), + subscriptions_collection.create_index("webhook_verified"), + # Plans indexes - updated for subscription_plans collection plans_collection.create_index("is_active"), + plans_collection.create_index("dodo_product_id", sparse=True), plans_collection.create_index([("is_active", 1), ("amount", 1)]), + plans_collection.create_index([("name", 1), ("duration", 1)]), ) logger.info("Created payment indexes") diff --git a/backend/app/decorators/rate_limiting.py b/backend/app/decorators/rate_limiting.py index 0b936734..ff60d8af 100644 --- a/backend/app/decorators/rate_limiting.py +++ b/backend/app/decorators/rate_limiting.py @@ -16,7 +16,7 @@ tiered_limiter, RateLimitExceededException, ) -from app.services.payments.subscriptions import get_user_subscription_status +from app.services.payment_service import payment_service from app.models.payment_models import PlanType from app.config.loggers import app_logger from app.db.redis import redis_cache @@ -205,7 +205,7 @@ async def wrapper(*args, **kwargs): raise HTTPException(status_code=401, detail="User ID not found") # Get user subscription - subscription = await get_user_subscription_status(user_id) + subscription = await payment_service.get_user_subscription_status(user_id) user_plan = subscription.plan_type or PlanType.FREE # Check rate limits before executing function @@ -292,7 +292,7 @@ async def _get_cached_subscription(user_id: str): app_logger.debug(f"Cache lookup failed for user {user_id}: {str(e)}") # Fetch and cache - subscription = await get_user_subscription_status(user_id) + subscription = await payment_service.get_user_subscription_status(user_id) # Cache for 5 minutes try: diff --git a/backend/app/middleware/tiered_rate_limiter.py b/backend/app/middleware/tiered_rate_limiter.py index 436394db..c590ddbf 100644 --- a/backend/app/middleware/tiered_rate_limiter.py +++ b/backend/app/middleware/tiered_rate_limiter.py @@ -22,7 +22,7 @@ async def analyze_file(user: dict = Depends(get_current_user)): from app.db.redis import redis_cache from app.models.payment_models import PlanType -from app.services.payments.subscriptions import get_user_subscription_status +from app.services.payment_service import payment_service from app.config.rate_limits import ( RateLimitPeriod, get_limits_for_plan, @@ -278,7 +278,7 @@ async def wrapper(*args, **kwargs): raise HTTPException(status_code=401, detail="User ID not found") # Get user subscription - subscription = await get_user_subscription_status(user_id) + subscription = await payment_service.get_user_subscription_status(user_id) user_plan = subscription.plan_type or PlanType.FREE # Check rate limits before executing function diff --git a/backend/app/models/payment_models.py b/backend/app/models/payment_models.py index ea9ed046..13ad14e7 100644 --- a/backend/app/models/payment_models.py +++ b/backend/app/models/payment_models.py @@ -1,5 +1,5 @@ """ -Payment and subscription related models for Razorpay integration. +Payment and subscription related models for Dodo Payments integration. """ from datetime import datetime @@ -27,37 +27,24 @@ class PaymentStatus(str, Enum): """Payment status.""" PENDING = "pending" - AUTHORIZED = "authorized" - PAID = "paid" - CAPTURED = "captured" - FAILED = "failed" - REFUNDED = "refunded" + ACTIVE = "active" + ON_HOLD = "on_hold" CANCELLED = "cancelled" + FAILED = "failed" + EXPIRED = "expired" class SubscriptionStatus(str, Enum): """Subscription status with clear definitions.""" - CREATED = "created" # Just created, no payment yet - AUTHENTICATED = "authenticated" # Authenticated but not paid + PENDING = "pending" # Payment link created, waiting for payment ACTIVE = "active" # Active with successful payment - PAUSED = "paused" # Temporarily paused - HALTED = "halted" # Halted due to payment failure + ON_HOLD = "on_hold" # Temporarily paused CANCELLED = "cancelled" # Cancelled by user or system - COMPLETED = "completed" # Completed all billing cycles + FAILED = "failed" # Payment failed EXPIRED = "expired" # Expired subscription -class PaymentMethod(str, Enum): - """Payment methods.""" - - CARD = "card" - NETBANKING = "netbanking" - WALLET = "wallet" - UPI = "upi" - EMI = "emi" - - class Currency(str, Enum): """Supported currencies.""" @@ -80,47 +67,10 @@ class CreatePlanRequest(BaseModel): class CreateSubscriptionRequest(BaseModel): - """Request model for creating a subscription.""" + """Simplified request model for creating a subscription - backend handles security.""" - plan_id: str = Field(..., description="Plan ID to subscribe to") + product_id: str = Field(..., description="Product ID to subscribe to") quantity: int = Field(1, description="Quantity of subscriptions") - customer_notify: bool = Field(True, description="Whether to notify customer") - addons: Optional[List[Dict[str, Any]]] = Field( - default_factory=list, description="Add-ons" - ) - notes: Optional[Dict[str, str]] = Field(default_factory=dict, description="Notes") - - -class CreatePaymentRequest(BaseModel): - """Request model for creating a payment.""" - - amount: int = Field(..., description="Amount in smallest currency unit") - currency: Currency = Field(Currency.USD, description="Currency") - description: Optional[str] = Field(None, description="Payment description") - notes: Optional[Dict[str, str]] = Field(default_factory=dict, description="Notes") - - -class PaymentCallbackRequest(BaseModel): - """Request model for payment callback/webhook.""" - - razorpay_payment_id: str = Field(..., description="Razorpay payment ID") - razorpay_order_id: Optional[str] = Field(None, description="Razorpay order ID") - razorpay_subscription_id: Optional[str] = Field( - None, description="Razorpay subscription ID" - ) - razorpay_signature: str = Field( - ..., description="Razorpay signature for verification" - ) - - -class UpdateSubscriptionRequest(BaseModel): - """Request model for updating subscription.""" - - plan_id: Optional[str] = Field(None, description="New plan ID") - quantity: Optional[int] = Field(None, description="New quantity") - remaining_count: Optional[int] = Field(None, description="Remaining billing cycles") - replace_items: Optional[bool] = Field(False, description="Replace all items") - prorate: Optional[bool] = Field(True, description="Prorate the subscription") # Response Models @@ -143,25 +93,15 @@ class PlanResponse(BaseModel): class SubscriptionResponse(BaseModel): """Response model for subscription.""" - id: str = Field(..., description="Subscription ID") - razorpay_subscription_id: str = Field(..., description="Razorpay subscription ID") + id: str = Field(..., description="Internal subscription ID") + dodo_subscription_id: str = Field(..., description="Dodo subscription ID") user_id: str = Field(..., description="User ID") - plan_id: str = Field(..., description="Plan ID") + product_id: str = Field(..., description="Product ID") status: SubscriptionStatus = Field(..., description="Subscription status") quantity: int = Field(..., description="Quantity") - current_start: Optional[datetime] = Field(None, description="Current period start") - current_end: Optional[datetime] = Field(None, description="Current period end") - ended_at: Optional[datetime] = Field(None, description="Subscription end time") - charge_at: Optional[datetime] = Field(None, description="Next charge time") - start_at: Optional[datetime] = Field(None, description="Subscription start time") - end_at: Optional[datetime] = Field(None, description="Subscription end time") - auth_attempts: int = Field(0, description="Authentication attempts") - total_count: int = Field(..., description="Total billing cycles") - paid_count: int = Field(0, description="Paid billing cycles") - customer_notify: bool = Field(True, description="Customer notification enabled") + payment_link: Optional[str] = Field(None, description="Payment link URL") created_at: datetime = Field(..., description="Creation timestamp") updated_at: datetime = Field(..., description="Update timestamp") - notes: Optional[Dict[str, str]] = Field(default_factory=dict, description="Notes") class PaymentResponse(BaseModel): @@ -170,24 +110,11 @@ class PaymentResponse(BaseModel): id: str = Field(..., description="Payment ID") user_id: str = Field(..., description="User ID") subscription_id: Optional[str] = Field(None, description="Subscription ID") - order_id: Optional[str] = Field(None, description="Order ID") amount: int = Field(..., description="Payment amount") currency: str = Field(..., description="Currency") status: PaymentStatus = Field(..., description="Payment status") - method: Optional[PaymentMethod] = Field(None, description="Payment method") description: Optional[str] = Field(None, description="Payment description") - international: bool = Field(False, description="International payment") - refund_status: Optional[str] = Field(None, description="Refund status") - amount_refunded: int = Field(0, description="Refunded amount") - captured: bool = Field(False, description="Payment captured") - email: Optional[str] = Field(None, description="Customer email") - contact: Optional[str] = Field(None, description="Customer contact") - fee: Optional[int] = Field(None, description="Processing fee") - tax: Optional[int] = Field(None, description="Tax") - error_code: Optional[str] = Field(None, description="Error code") - error_description: Optional[str] = Field(None, description="Error description") created_at: datetime = Field(..., description="Creation timestamp") - notes: Optional[Dict[str, str]] = Field(default_factory=dict, description="Notes") class UserSubscriptionStatus(BaseModel): @@ -219,12 +146,6 @@ class UserSubscriptionStatus(BaseModel): status: Optional[SubscriptionStatus] = Field( None, description="Legacy field - check subscription" ) - current_period_start: Optional[datetime] = Field(None, description="Legacy field") - current_period_end: Optional[datetime] = Field(None, description="Legacy field") - cancel_at_period_end: Optional[bool] = Field(None, description="Legacy field") - trial_end: Optional[datetime] = Field(None, description="Legacy field") - subscription_id: Optional[str] = Field(None, description="Legacy field") - plan_id: Optional[str] = Field(None, description="Legacy field") class PaymentHistoryResponse(BaseModel): @@ -238,13 +159,35 @@ class PaymentHistoryResponse(BaseModel): class WebhookEvent(BaseModel): - """Webhook event model.""" + """Webhook event model for Dodo Payments.""" - entity: str = Field(..., description="Entity type") - account_id: str = Field(..., description="Account ID") - event: str = Field(..., description="Event type") - created_at: int = Field(..., description="Creation timestamp") - payload: Dict[str, Any] = Field(..., description="Event payload") + addons: List[Dict[str, Any]] = Field(default_factory=list, description="Addons") + billing: Dict[str, Any] = Field(..., description="Billing address") + cancel_at_next_billing_date: bool = Field(..., description="Cancel at next billing") + created_at: str = Field(..., description="Creation timestamp") + currency: str = Field(..., description="Currency") + customer: Dict[str, Any] = Field(..., description="Customer details") + metadata: Dict[str, Any] = Field(..., description="Metadata") + next_billing_date: str = Field(..., description="Next billing date") + on_demand: bool = Field(..., description="On demand subscription") + payment_frequency_count: int = Field(..., description="Payment frequency count") + payment_frequency_interval: str = Field( + ..., description="Payment frequency interval" + ) + previous_billing_date: str = Field(..., description="Previous billing date") + product_id: str = Field(..., description="Product ID") + quantity: int = Field(..., description="Quantity") + recurring_pre_tax_amount: int = Field(..., description="Recurring pre-tax amount") + status: str = Field(..., description="Subscription status") + subscription_id: str = Field(..., description="Subscription ID") + subscription_period_count: int = Field(..., description="Subscription period count") + subscription_period_interval: str = Field( + ..., description="Subscription period interval" + ) + tax_inclusive: bool = Field(..., description="Tax inclusive") + trial_period_days: int = Field(..., description="Trial period days") + cancelled_at: Optional[str] = Field(None, description="Cancelled at") + discount_id: Optional[str] = Field(None, description="Discount ID") # Database Models (Internal) @@ -252,7 +195,7 @@ class PlanDB(BaseModel): """Database model for subscription plan.""" id: Optional[str] = Field(None, alias="_id") - razorpay_plan_id: str = Field(..., description="Razorpay plan ID") + dodo_product_id: str = Field(..., description="Dodo product ID") name: str = Field(..., description="Plan name") description: Optional[str] = Field(None, description="Plan description") amount: int = Field(..., description="Plan amount") @@ -268,60 +211,58 @@ class PlanDB(BaseModel): default_factory=datetime.utcnow, description="Update timestamp" ) + class Config: + populate_by_name = True + allow_population_by_field_name = True + class SubscriptionDB(BaseModel): """Database model for subscription.""" id: Optional[str] = Field(None, alias="_id") - razorpay_subscription_id: str = Field(..., description="Razorpay subscription ID") + dodo_subscription_id: str = Field(..., description="Dodo subscription ID") user_id: str = Field(..., description="User ID") - plan_id: str = Field(..., description="Plan ID") + product_id: str = Field(..., description="Product ID") status: str = Field(..., description="Subscription status") quantity: int = Field(1, description="Quantity") - current_start: Optional[datetime] = Field(None, description="Current period start") - current_end: Optional[datetime] = Field(None, description="Current period end") - ended_at: Optional[datetime] = Field(None, description="Subscription end time") - charge_at: Optional[datetime] = Field(None, description="Next charge time") - start_at: Optional[datetime] = Field(None, description="Subscription start time") - end_at: Optional[datetime] = Field(None, description="Subscription end time") - auth_attempts: int = Field(0, description="Authentication attempts") - total_count: int = Field(..., description="Total billing cycles") - paid_count: int = Field(0, description="Paid billing cycles") - customer_notify: bool = Field(True, description="Customer notification") + payment_link: Optional[str] = Field(None, description="Payment link URL") + webhook_verified: bool = Field(False, description="Webhook verification status") created_at: datetime = Field( default_factory=datetime.utcnow, description="Creation timestamp" ) updated_at: datetime = Field( default_factory=datetime.utcnow, description="Update timestamp" ) - notes: Dict[str, str] = Field(default_factory=dict, description="Notes") + metadata: Dict[str, Any] = Field( + default_factory=dict, description="Additional data" + ) + + class Config: + populate_by_name = True + allow_population_by_field_name = True class PaymentDB(BaseModel): """Database model for payment.""" id: Optional[str] = Field(None, alias="_id") - razorpay_payment_id: str = Field(..., description="Razorpay payment ID") + dodo_subscription_id: str = Field(..., description="Dodo subscription ID") user_id: str = Field(..., description="User ID") - subscription_id: Optional[str] = Field(None, description="Subscription ID") - order_id: Optional[str] = Field(None, description="Order ID") + subscription_id: Optional[str] = Field(None, description="Internal subscription ID") amount: int = Field(..., description="Payment amount") currency: str = Field(..., description="Currency") status: str = Field(..., description="Payment status") - method: Optional[str] = Field(None, description="Payment method") description: Optional[str] = Field(None, description="Payment description") - international: bool = Field(False, description="International payment") - refund_status: Optional[str] = Field(None, description="Refund status") - amount_refunded: int = Field(0, description="Refunded amount") - captured: bool = Field(False, description="Payment captured") - email: Optional[str] = Field(None, description="Customer email") - contact: Optional[str] = Field(None, description="Customer contact") - fee: Optional[int] = Field(None, description="Processing fee") - tax: Optional[int] = Field(None, description="Tax") - error_code: Optional[str] = Field(None, description="Error code") - error_description: Optional[str] = Field(None, description="Error description") webhook_verified: bool = Field(False, description="Webhook verification status") created_at: datetime = Field( default_factory=datetime.utcnow, description="Creation timestamp" ) - notes: Dict[str, str] = Field(default_factory=dict, description="Notes") + metadata: Dict[str, Any] = Field( + default_factory=dict, description="Additional data" + ) + + +class PaymentVerificationResponse(BaseModel): + payment_completed: bool + subscription_id: str | None = None + message: str diff --git a/backend/app/models/webhook_models.py b/backend/app/models/webhook_models.py deleted file mode 100644 index 285889f7..00000000 --- a/backend/app/models/webhook_models.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -Webhook event tracking for idempotency. -""" - -from datetime import datetime -from typing import Optional - -from pydantic import BaseModel, Field - - -class WebhookEventDB(BaseModel): - """Database model for tracking processed webhook events.""" - - id: Optional[str] = Field(None, alias="_id") - event_id: str - event_type: str - razorpay_entity_id: str # subscription_id or payment_id - processed_at: datetime - payload_hash: str # SHA256 hash of payload for deduplication - status: str # "processed", "failed", "retrying" - retry_count: int = 0 - error_message: Optional[str] = None - - class Config: - allow_population_by_field_name = True - json_encoders = {datetime: lambda v: v.isoformat()} diff --git a/backend/app/services/payment_service.py b/backend/app/services/payment_service.py new file mode 100644 index 00000000..e778998a --- /dev/null +++ b/backend/app/services/payment_service.py @@ -0,0 +1,239 @@ +""" +Streamlined Dodo Payments integration service. +Clean, simple, and maintainable. +""" + +from datetime import datetime, timezone +from typing import Any, Dict, List + +from bson import ObjectId +from dodopayments import DodoPayments +from fastapi import HTTPException + +from app.config.settings import settings +from app.db.mongodb.collections import ( + plans_collection, + subscriptions_collection, + users_collection, +) +from app.db.redis import redis_cache +from app.db.utils import serialize_document +from app.models.payment_models import ( + PlanResponse, + PlanType, + SubscriptionStatus, + UserSubscriptionStatus, +) +from app.utils.email_utils import send_pro_subscription_email + + +class DodoPaymentService: + """Streamlined Dodo Payments service.""" + + def __init__(self): + try: + environment = "live_mode" if settings.ENV == "production" else "test_mode" + + self.client = DodoPayments( + bearer_token=settings.DODO_PAYMENTS_API_KEY, + environment=environment, + ) + except Exception as e: + print(f"Failed to instantiate dodo payments: {e}") + + async def get_plans(self, active_only: bool = True) -> List[PlanResponse]: + """Get subscription plans with caching.""" + cache_key = f"plans:{'active' if active_only else 'all'}" + + # Try cache first + cached = await redis_cache.get(cache_key) + if cached: + return [PlanResponse(**plan) for plan in cached] + + # Fetch from database + query = {"is_active": True} if active_only else {} + plans = await plans_collection.find(query).sort("amount", 1).to_list(None) + + plan_responses = [ + PlanResponse( + id=str(plan["_id"]), + name=plan["name"], + description=plan.get("description"), + amount=plan["amount"], + currency=plan["currency"], + duration=plan["duration"], + max_users=plan.get("max_users"), + features=plan.get("features", []), + is_active=plan["is_active"], + created_at=plan["created_at"], + updated_at=plan["updated_at"], + ) + for plan in plans + ] + + # Cache result + await redis_cache.set(cache_key, [plan.model_dump() for plan in plan_responses]) + return plan_responses + + async def create_subscription( + self, user_id: str, product_id: str, quantity: int = 1 + ) -> Dict[str, Any]: + """Create subscription - backend handles all security.""" + # Get user + user = await users_collection.find_one({"_id": ObjectId(user_id)}) + if not user: + raise HTTPException(404, "User not found") + + # Check for existing subscription + existing = await subscriptions_collection.find_one( + {"user_id": user_id, "status": {"$in": ["pending", "active"]}} + ) + if existing: + raise HTTPException(409, "Active subscription exists") + + # Create with Dodo + try: + subscription = self.client.subscriptions.create( + billing={ + "city": "N/A", + "country": "IN", + "state": "N/A", + "street": "N/A", + "zipcode": "000000", + }, + customer={"customer_id": user_id}, + product_id=product_id, + quantity=quantity, + payment_link=True, + return_url=f"{settings.FRONTEND_URL}/payment/success", + ) + except Exception as e: + raise HTTPException(502, f"Payment service error: {str(e)}") + + # Store in database - create dict directly for insertion + subscription_doc = { + "dodo_subscription_id": subscription.subscription_id, + "user_id": user_id, + "product_id": product_id, + "status": "pending", + "quantity": quantity, + "payment_link": getattr(subscription, "payment_link", None), + "webhook_verified": False, + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + "metadata": {"user_email": user.get("email")}, + } + + await subscriptions_collection.insert_one(subscription_doc) + + return { + "subscription_id": subscription.subscription_id, + "payment_link": getattr(subscription, "payment_link", None), + "status": "pending", + } + + async def verify_payment_completion(self, user_id: str) -> Dict[str, Any]: + """Check payment completion status.""" + subscription = await subscriptions_collection.find_one( + {"user_id": user_id}, sort=[("created_at", -1)] + ) + + if not subscription: + return {"payment_completed": False, "message": "No subscription found"} + + if subscription["status"] == "active" and subscription.get("webhook_verified"): + # Send welcome email (don't fail if email fails) + try: + user = await users_collection.find_one({"_id": ObjectId(user_id)}) + if user and user.get("email"): + await send_pro_subscription_email( + user_name=user.get("first_name", "User"), + user_email=user["email"], + ) + except Exception: + pass # Email failure shouldn't break payment verification + + return { + "payment_completed": True, + "subscription_id": subscription["dodo_subscription_id"], + "message": "Payment completed", + } + + return { + "payment_completed": False, + "subscription_id": subscription.get("dodo_subscription_id"), + "message": "Payment pending", + } + + async def get_user_subscription_status( + self, user_id: str + ) -> UserSubscriptionStatus: + """Get user subscription status.""" + subscription = await subscriptions_collection.find_one( + {"user_id": user_id, "status": "active"} + ) + + if not subscription: + return UserSubscriptionStatus( + user_id=user_id, + current_plan=None, + subscription=None, + is_subscribed=False, + days_remaining=None, + can_upgrade=True, + can_downgrade=False, + has_subscription=False, + plan_type=PlanType.FREE, + status=SubscriptionStatus.PENDING, + ) + + # Get plan details + try: + plans = await self.get_plans(active_only=False) + plan = next( + (p for p in plans if p.id == subscription.get("product_id")), None + ) + except Exception: + plan = None + + return UserSubscriptionStatus( + user_id=user_id, + current_plan=plan.model_dump() if plan else None, + subscription=serialize_document(subscription), + is_subscribed=True, + days_remaining=None, + can_upgrade=True, + can_downgrade=True, + has_subscription=True, + plan_type=PlanType.PRO, + status=SubscriptionStatus(subscription["status"]), + ) + + async def handle_webhook(self, webhook_data: Dict[str, Any]) -> Dict[str, str]: + """Handle Dodo webhook.""" + subscription_id = webhook_data.get("subscription_id") + status = webhook_data.get("status") + + if not subscription_id or not status: + raise HTTPException(400, "Invalid webhook data") + + result = await subscriptions_collection.update_one( + {"dodo_subscription_id": subscription_id}, + { + "$set": { + "status": status, + "webhook_verified": True, + "updated_at": datetime.now(timezone.utc), + } + }, + ) + + return { + "status": "processed" if result.modified_count > 0 else "not_found", + "subscription_id": subscription_id, + } + + +print(f"{settings.DODO_PAYMENTS_API_KEY=}") + +payment_service = DodoPaymentService() diff --git a/backend/app/services/payments/__init__.py b/backend/app/services/payments/__init__.py deleted file mode 100644 index 63c03b0f..00000000 --- a/backend/app/services/payments/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -Payment services module. -""" - -from .core import ( - get_plans, - create_subscription, - verify_payment, - get_user_subscription_status, - update_subscription, - cancel_subscription, - sync_subscription_from_razorpay, - process_webhook, - razorpay_service, -) - -__all__ = [ - "get_plans", - "create_subscription", - "verify_payment", - "get_user_subscription_status", - "update_subscription", - "cancel_subscription", - "sync_subscription_from_razorpay", - "process_webhook", - "razorpay_service", -] diff --git a/backend/app/services/payments/client.py b/backend/app/services/payments/client.py deleted file mode 100644 index 4975b246..00000000 --- a/backend/app/services/payments/client.py +++ /dev/null @@ -1,97 +0,0 @@ -""" -Razorpay client and signature verification utilities. -""" - -import hashlib -import hmac -from typing import Optional, Dict, Any - -import razorpay - -from app.config.loggers import general_logger as logger -from app.config.settings import settings - - -class PaymentServiceError(Exception): - """Base exception for payment service errors.""" - - def __init__( - self, - message: str, - status_code: int = 500, - details: Optional[Dict[str, Any]] = None, - ): - self.message = message - self.status_code = status_code - self.details = details or {} - super().__init__(self.message) - - -class RazorpayService: - """Service class for Razorpay payment integration.""" - - def __init__(self): - """Initialize Razorpay client.""" - try: - # Use Razorpay credentials from settings (via Infisical) - key_id = settings.RAZORPAY_KEY_ID - key_secret = settings.RAZORPAY_KEY_SECRET - - # Initialize client - self.client: razorpay.Client = razorpay.Client(auth=(key_id, key_secret)) - - # Auto-detect test mode based on key prefix - self.is_test_mode = key_id.startswith("rzp_test_") - mode = "test" if self.is_test_mode else "live" - logger.info(f"Razorpay client initialized in {mode} mode") - except Exception as e: - logger.error(f"Failed to initialize Razorpay client: {e}") - raise PaymentServiceError("Failed to initialize payment service", 502) - - def verify_webhook_signature(self, payload: bytes, signature: str) -> bool: - """Verify Razorpay webhook signature.""" - try: - # Use webhook secret from settings - key_secret = settings.RAZORPAY_KEY_SECRET - expected_signature = hmac.new( - key_secret.encode(), payload, hashlib.sha256 - ).hexdigest() - return hmac.compare_digest(expected_signature, signature) - except Exception as e: - logger.error(f"Error verifying webhook signature: {e}") - return False - - def verify_payment_signature( - self, razorpay_order_id: str, razorpay_payment_id: str, razorpay_signature: str - ) -> bool: - """Verify payment signature for security.""" - try: - body = f"{razorpay_order_id}|{razorpay_payment_id}" - expected_signature = hmac.new( - settings.RAZORPAY_KEY_SECRET.encode(), body.encode(), hashlib.sha256 - ).hexdigest() - return hmac.compare_digest(expected_signature, razorpay_signature) - except Exception as e: - logger.error(f"Error verifying payment signature: {e}") - return False - - def verify_subscription_signature( - self, - razorpay_payment_id: str, - razorpay_subscription_id: str, - razorpay_signature: str, - ) -> bool: - """Verify subscription payment signature for security.""" - try: - body = f"{razorpay_payment_id}|{razorpay_subscription_id}" - expected_signature = hmac.new( - settings.RAZORPAY_KEY_SECRET.encode(), body.encode(), hashlib.sha256 - ).hexdigest() - return hmac.compare_digest(expected_signature, razorpay_signature) - except Exception as e: - logger.error(f"Error verifying subscription signature: {e}") - return False - - -# Initialize Razorpay service -razorpay_service = RazorpayService() diff --git a/backend/app/services/payments/core.py b/backend/app/services/payments/core.py deleted file mode 100644 index 3c80ef70..00000000 --- a/backend/app/services/payments/core.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -Core payment service functions. -""" - -# Import all the functions to re-export them -from .client import razorpay_service -from .plans import get_plans -from .subscriptions import ( - create_subscription, - get_user_subscription_status, - update_subscription, - cancel_subscription, - sync_subscription_from_razorpay, -) -from .verification import verify_payment -from .webhooks import process_webhook - -# Re-export everything -__all__ = [ - "razorpay_service", - "get_plans", - "create_subscription", - "verify_payment", - "get_user_subscription_status", - "update_subscription", - "cancel_subscription", - "sync_subscription_from_razorpay", - "process_webhook", -] diff --git a/backend/app/services/payments/plans.py b/backend/app/services/payments/plans.py deleted file mode 100644 index 031a17d4..00000000 --- a/backend/app/services/payments/plans.py +++ /dev/null @@ -1,87 +0,0 @@ -""" -Plans management service. -""" - -from typing import List, Optional - -from bson import ObjectId -from fastapi import HTTPException - -from app.config.loggers import general_logger as logger -from app.db.mongodb.collections import plans_collection -from app.models.payment_models import PlanResponse - - -async def get_plans(active_only: bool = True) -> List[PlanResponse]: - """Get all subscription plans.""" - try: - query = {"is_active": True} if active_only else {} - plans_cursor = plans_collection.find(query).sort("amount", 1) - plans = await plans_cursor.to_list(length=None) - - return [ - PlanResponse( - id=str(plan["_id"]), - name=plan["name"], - description=plan.get("description"), - amount=plan["amount"], - currency=plan["currency"], - duration=plan["duration"], - max_users=plan.get("max_users"), - features=plan.get("features", []), - is_active=plan["is_active"], - created_at=plan["created_at"], - updated_at=plan["updated_at"], - ) - for plan in plans - ] - except Exception as e: - logger.error(f"Failed to fetch plans: {e}") - raise HTTPException(status_code=500, detail="Failed to retrieve plans") - - -async def get_plan_by_id(plan_id: str) -> Optional[PlanResponse]: - """Get a specific plan by ID.""" - try: - if not ObjectId.is_valid(plan_id): - raise HTTPException(status_code=400, detail="Invalid plan ID format") - - plan = await plans_collection.find_one({"_id": ObjectId(plan_id)}) - - if not plan: - return None - - return PlanResponse( - id=str(plan["_id"]), - name=plan["name"], - description=plan.get("description"), - amount=plan["amount"], - currency=plan["currency"], - duration=plan["duration"], - max_users=plan.get("max_users"), - features=plan.get("features", []), - is_active=plan["is_active"], - created_at=plan["created_at"], - updated_at=plan["updated_at"], - ) - except HTTPException: - raise - except Exception as e: - logger.error(f"Failed to fetch plan {plan_id}: {e}") - raise HTTPException(status_code=500, detail="Failed to retrieve plan") - - -async def get_razorpay_plan_id(plan_id: str) -> str: - """Get Razorpay plan ID from our plan ID.""" - try: - plan = await plans_collection.find_one({"_id": ObjectId(plan_id)}) - if not plan: - raise HTTPException(status_code=404, detail="Plan not found") - - logger.info(f'plan["razorpay_plan_id"]={plan["razorpay_plan_id"]}') - return plan["razorpay_plan_id"] - except HTTPException: - raise - except Exception as e: - logger.error(f"Failed to get Razorpay plan ID for {plan_id}: {e}") - raise HTTPException(status_code=500, detail="Failed to retrieve plan details") diff --git a/backend/app/services/payments/subscriptions.py b/backend/app/services/payments/subscriptions.py deleted file mode 100644 index 2d218f5d..00000000 --- a/backend/app/services/payments/subscriptions.py +++ /dev/null @@ -1,613 +0,0 @@ -""" -Subscription management service. -""" - -from datetime import datetime, timedelta, timezone -from typing import Any, Dict - -from bson import ObjectId -from fastapi import HTTPException - -from app.config.loggers import general_logger as logger -from app.db.mongodb.collections import plans_collection, subscriptions_collection -from app.models.payment_models import ( - CreateSubscriptionRequest, - PlanType, - SubscriptionDB, - SubscriptionResponse, - SubscriptionStatus, - UpdateSubscriptionRequest, - UserSubscriptionStatus, -) -from app.services.user_service import get_user_by_id -from app.utils.payments_utils import calculate_subscription_dates, timestamp_to_datetime -from app.utils.timezone import add_timezone_info - -from .client import razorpay_service -from .plans import get_plan_by_id, get_razorpay_plan_id - - -async def create_subscription( - user_id: str, subscription_data: CreateSubscriptionRequest -) -> SubscriptionResponse: - """Create a new subscription for a user.""" - try: - # Validate user exists - user = await get_user_by_id(user_id) - if not user: - raise HTTPException(status_code=404, detail="User not found") - - # Validate plan exists - plan = await get_plan_by_id(subscription_data.plan_id) - if not plan: - raise HTTPException(status_code=404, detail="Plan not found") - - # Check for existing active subscriptions - existing_sub = await subscriptions_collection.find_one( - { - "user_id": user_id, - "status": {"$in": ["active", "authenticated"]}, - } - ) - - if existing_sub: - raise HTTPException( - status_code=409, detail="User already has an active subscription" - ) - - # Clean up old failed/abandoned subscriptions - await _cleanup_old_subscriptions(user_id) - - # Get Razorpay plan ID - razorpay_plan_id = await get_razorpay_plan_id(subscription_data.plan_id) - - # Create subscription in Razorpay - razorpay_subscription = await _create_razorpay_subscription( - user_id, subscription_data, razorpay_plan_id, user - ) - - # Store subscription in database - try: - current_time = datetime.now(timezone.utc) - - # Calculate all date fields with sensible defaults - dates = calculate_subscription_dates( - plan, current_time, razorpay_subscription - ) - - # Create subscription document - subscription_doc = _create_subscription_doc( - razorpay_subscription, user_id, subscription_data, dates, current_time - ) - - result = await subscriptions_collection.insert_one( - subscription_doc.dict(by_alias=True, exclude={"id"}) - ) - subscription_doc.id = str(result.inserted_id) - - if not result.inserted_id: - raise HTTPException( - status_code=500, detail="Failed to store subscription in database" - ) - except HTTPException: - raise - except Exception as e: - logger.error(f"Failed to store subscription in database: {e}") - raise HTTPException(status_code=500, detail="Failed to save subscription") - - logger.info( - f"Successfully created subscription for user {user_id}: {subscription_doc.id}" - ) - - return SubscriptionResponse( - id=subscription_doc.id, - razorpay_subscription_id=subscription_doc.razorpay_subscription_id, - user_id=subscription_doc.user_id, - plan_id=subscription_doc.plan_id, - status=SubscriptionStatus(subscription_doc.status), - quantity=subscription_doc.quantity, - current_start=subscription_doc.current_start, - current_end=subscription_doc.current_end, - ended_at=subscription_doc.ended_at, - charge_at=subscription_doc.charge_at, - start_at=subscription_doc.start_at, - end_at=subscription_doc.end_at, - auth_attempts=subscription_doc.auth_attempts, - total_count=subscription_doc.total_count, - paid_count=subscription_doc.paid_count, - customer_notify=subscription_doc.customer_notify, - created_at=subscription_doc.created_at, - updated_at=subscription_doc.updated_at, - notes=subscription_doc.notes, - ) - - except HTTPException: - raise - except Exception as e: - logger.error(f"Unexpected error creating subscription: {e}") - raise HTTPException(status_code=500, detail="Failed to create subscription") - - -async def get_user_subscription_status(user_id: str) -> UserSubscriptionStatus: - """Get user's current subscription status.""" - try: - # Get user's active subscription (only truly active ones with payment) - subscription = await subscriptions_collection.find_one( - {"user_id": user_id, "status": "active", "paid_count": {"$gt": 0}} - ) - - if not subscription: - return UserSubscriptionStatus( - user_id=user_id, - current_plan=None, - subscription=None, - is_subscribed=False, - days_remaining=None, - can_upgrade=True, - can_downgrade=False, - # Legacy fields - has_subscription=False, - plan_type=PlanType.FREE, - status=SubscriptionStatus.CANCELLED, - cancel_at_period_end=False, - current_period_end=None, - current_period_start=None, - plan_id=None, - subscription_id=None, - trial_end=None, - ) - - # Get plan details - plan = await plans_collection.find_one( - {"_id": ObjectId(subscription["plan_id"])} - ) - - plan_type = PlanType.FREE # Default to free plan - if plan: - plan_name = plan.get("name", "").lower() - if "pro" in plan_name: - plan_type = PlanType.PRO - - logger.info(f"current_end: {subscription.get('current_end')}") - logger.info(f"current_start: {datetime.now(timezone.utc)}") - - # Calculate days remaining - days_remaining = None - if subscription.get("current_end"): - remaining_delta = add_timezone_info( - target_datetime=subscription["current_end"], timezone_name="UTC" - ) - datetime.now(timezone.utc) - days_remaining = max(0, remaining_delta.days) - - # Format plan and subscription data - current_plan = None - if plan: - current_plan = { - "id": str(plan["_id"]), - "name": plan["name"], - "description": plan.get("description"), - "amount": plan["amount"], - "currency": plan["currency"], - "duration": plan["duration"], - "features": plan.get("features", []), - "is_active": plan.get("is_active", True), - } - - subscription_data = { - "id": str(subscription["_id"]), - "razorpay_subscription_id": subscription.get("razorpay_subscription_id"), - "user_id": subscription["user_id"], - "plan_id": subscription["plan_id"], - "status": subscription["status"], - "quantity": subscription.get("quantity", 1), - "current_start": subscription.get("current_start"), - "current_end": subscription.get("current_end"), - "auth_attempts": subscription.get("auth_attempts", 0), - "total_count": subscription.get("total_count", 0), - "paid_count": subscription.get("paid_count", 0), - "customer_notify": subscription.get("customer_notify", True), - "created_at": subscription.get("created_at"), - "updated_at": subscription.get("updated_at"), - } - - return UserSubscriptionStatus( - user_id=user_id, - current_plan=current_plan, - subscription=subscription_data, - is_subscribed=True, - days_remaining=days_remaining, - can_upgrade=plan_type != PlanType.PRO, - can_downgrade=plan_type not in [PlanType.FREE], - # Legacy fields for backward compatibility - has_subscription=True, - plan_type=plan_type, - status=SubscriptionStatus(subscription["status"]), - current_period_start=subscription.get("current_start"), - current_period_end=subscription.get("current_end"), - cancel_at_period_end=subscription.get("cancel_at_cycle_end", False), - trial_end=subscription.get("trial_end"), - subscription_id=subscription.get("razorpay_subscription_id"), - plan_id=subscription["plan_id"], - ) - - except Exception as e: - logger.error(f"Error getting subscription status for user {user_id}: {e}") - raise HTTPException(status_code=500, detail="Failed to get subscription status") - - -async def update_subscription( - user_id: str, subscription_data: UpdateSubscriptionRequest -) -> SubscriptionResponse | None: - """Update user's subscription with proper error handling and rollback.""" - try: - # Get current subscription - current_subscription = await subscriptions_collection.find_one( - {"user_id": user_id, "status": {"$in": ["active", "created"]}} - ) - - if not current_subscription: - raise HTTPException(status_code=404, detail="No active subscription found") - - razorpay_subscription_id = current_subscription["razorpay_subscription_id"] - - # Prepare update data for Razorpay - update_data: Dict[str, Any] = {} - if subscription_data.plan_id: - razorpay_plan_id = await get_razorpay_plan_id(subscription_data.plan_id) - update_data["plan_id"] = razorpay_plan_id - - if subscription_data.quantity is not None: - update_data["quantity"] = subscription_data.quantity - - if subscription_data.remaining_count is not None: - update_data["remaining_count"] = subscription_data.remaining_count - - if subscription_data.replace_items is not None: - update_data["replace_items"] = subscription_data.replace_items - - if subscription_data.prorate is not None: - update_data["prorate"] = subscription_data.prorate - - # Update subscription in Razorpay - try: - razorpay_service.client.subscription.update( - razorpay_subscription_id, update_data - ) - logger.info(f"Updated Razorpay subscription: {razorpay_subscription_id}") - except Exception as e: - logger.error(f"Failed to update Razorpay subscription: {e}") - raise HTTPException( - status_code=502, - detail="Failed to update subscription in payment gateway", - ) - - # Update subscription in database - db_update_data: Dict[str, Any] = { - "updated_at": datetime.now(timezone.utc), - } - - if subscription_data.plan_id: - db_update_data["plan_id"] = subscription_data.plan_id - - if subscription_data.quantity is not None: - db_update_data["quantity"] = subscription_data.quantity - - result = await subscriptions_collection.update_one( - {"_id": ObjectId(current_subscription["_id"])}, {"$set": db_update_data} - ) - - if result.modified_count == 0: - logger.warning(f"No subscription updated in database for user {user_id}") - - # Fetch updated subscription - updated_subscription_doc = await subscriptions_collection.find_one( - {"_id": ObjectId(current_subscription["_id"])} - ) - - if updated_subscription_doc: - return SubscriptionResponse( - id=str(updated_subscription_doc["_id"]), - razorpay_subscription_id=updated_subscription_doc[ - "razorpay_subscription_id" - ], - user_id=updated_subscription_doc["user_id"], - plan_id=updated_subscription_doc["plan_id"], - status=SubscriptionStatus(updated_subscription_doc["status"]), - quantity=updated_subscription_doc["quantity"], - current_start=updated_subscription_doc.get("current_start"), - current_end=updated_subscription_doc.get("current_end"), - ended_at=updated_subscription_doc.get("ended_at"), - charge_at=updated_subscription_doc.get("charge_at"), - start_at=updated_subscription_doc.get("start_at"), - end_at=updated_subscription_doc.get("end_at"), - auth_attempts=updated_subscription_doc.get("auth_attempts", 0), - total_count=updated_subscription_doc["total_count"], - paid_count=updated_subscription_doc["paid_count"], - customer_notify=updated_subscription_doc["customer_notify"], - created_at=updated_subscription_doc["created_at"], - updated_at=updated_subscription_doc["updated_at"], - notes=updated_subscription_doc.get("notes", {}), - ) - - else: - return None - - except HTTPException: - raise - except Exception as e: - logger.error(f"Unexpected error updating subscription for user {user_id}: {e}") - raise HTTPException(status_code=500, detail="Failed to update subscription") - - -async def cancel_subscription( - user_id: str, cancel_at_cycle_end: bool = True -) -> Dict[str, str]: - """Cancel user's subscription with proper cleanup.""" - try: - # Get current active subscription (only paid ones) - subscription = await subscriptions_collection.find_one( - {"user_id": user_id, "status": "active", "paid_count": {"$gt": 0}} - ) - - if not subscription: - raise HTTPException(status_code=404, detail="No active subscription found") - - razorpay_subscription_id = subscription["razorpay_subscription_id"] - - # Cancel subscription in Razorpay - try: - if cancel_at_cycle_end: - # Cancel at cycle end - razorpay_service.client.subscription.update( - razorpay_subscription_id, {"cancel_at_cycle_end": True} - ) - new_status = "active" # Remains active until cycle end - else: - # Cancel immediately - razorpay_service.client.subscription.cancel(razorpay_subscription_id) - new_status = "cancelled" - - logger.info(f"Cancelled Razorpay subscription: {razorpay_subscription_id}") - except Exception as e: - logger.error(f"Failed to cancel Razorpay subscription: {e}") - raise HTTPException( - status_code=502, - detail="Failed to cancel subscription in payment gateway", - ) - - # Update subscription in database - update_data = { - "status": new_status, - "cancel_at_cycle_end": cancel_at_cycle_end, - "updated_at": datetime.now(timezone.utc), - } - - if not cancel_at_cycle_end: - update_data["ended_at"] = datetime.now(timezone.utc) - - result = await subscriptions_collection.update_one( - {"_id": ObjectId(subscription["_id"])}, {"$set": update_data} - ) - - if result.modified_count == 0: - logger.warning(f"No subscription updated in database for user {user_id}") - - return { - "message": "Subscription cancelled successfully", - "cancel_at_cycle_end": str(cancel_at_cycle_end), - "status": new_status, - } - - except HTTPException: - raise - except Exception as e: - logger.error( - f"Unexpected error cancelling subscription for user {user_id}: {e}" - ) - raise HTTPException(status_code=500, detail="Failed to cancel subscription") - - -async def sync_subscription_from_razorpay(razorpay_subscription_id: str) -> bool: - """Sync subscription data from Razorpay to fix missing fields.""" - try: - # Fetch subscription details from Razorpay - razorpay_subscription = razorpay_service.client.subscription.fetch( - razorpay_subscription_id - ) - - # Get our plan details for calculating defaults - subscription_doc = await subscriptions_collection.find_one( - {"razorpay_subscription_id": razorpay_subscription_id} - ) - - if not subscription_doc: - logger.error( - f"Subscription not found in database: {razorpay_subscription_id}" - ) - return False - - plan = await plans_collection.find_one( - {"_id": ObjectId(subscription_doc["plan_id"])} - ) - - # Calculate updated fields - current_start = timestamp_to_datetime( - razorpay_subscription.get("current_start") - ) - current_end = timestamp_to_datetime(razorpay_subscription.get("current_end")) - charge_at = timestamp_to_datetime(razorpay_subscription.get("charge_at")) - start_at = timestamp_to_datetime(razorpay_subscription.get("start_at")) - end_at = timestamp_to_datetime(razorpay_subscription.get("end_at")) - - # Set defaults if fields are still null - created_at = subscription_doc.get("created_at", datetime.now(timezone.utc)) - - if not start_at: - start_at = created_at - - if not current_start: - current_start = start_at - - if not current_end and plan and current_start is not None: - # Calculate end based on plan duration - if plan.get("duration") == "monthly": - current_end = current_start + timedelta(days=30) - elif plan.get("duration") == "yearly": - current_end = current_start + timedelta(days=365) - - if not charge_at: - charge_at = current_start - - if not end_at and plan and start_at is not None: - # Set to total billing cycles from start - total_count = razorpay_subscription.get("total_count", 10) - if plan.get("duration") == "monthly": - end_at = start_at + timedelta(days=30 * total_count) - elif plan.get("duration") == "yearly": - end_at = start_at + timedelta(days=365 * total_count) - - # Update subscription in database - update_data = { - "status": razorpay_subscription["status"], - "current_start": current_start, - "current_end": current_end, - "charge_at": charge_at, - "start_at": start_at, - "end_at": end_at, - "auth_attempts": razorpay_subscription.get("auth_attempts", 0), - "total_count": razorpay_subscription.get("total_count", 10), - "paid_count": razorpay_subscription.get("paid_count", 0), - "updated_at": datetime.now(timezone.utc), - } - - # Remove None values - update_data = {k: v for k, v in update_data.items() if v is not None} - - result = await subscriptions_collection.update_one( - {"razorpay_subscription_id": razorpay_subscription_id}, - {"$set": update_data}, - ) - - if result.modified_count > 0: - logger.info( - f"Successfully synced subscription from Razorpay: {razorpay_subscription_id}" - ) - return True - else: - logger.warning( - f"No changes made to subscription: {razorpay_subscription_id}" - ) - return False - - except Exception as e: - logger.error( - f"Failed to sync subscription from Razorpay {razorpay_subscription_id}: {e}" - ) - return False - - -# Helper functions -async def _cleanup_old_subscriptions(user_id: str) -> None: - """Clean up old failed/abandoned subscriptions for a user.""" - try: - cleanup_result = await subscriptions_collection.delete_many( - { - "user_id": user_id, - "$or": [ - { - "status": "created", - "created_at": { - "$lt": datetime.now(timezone.utc) - timedelta(hours=1) - }, - }, - {"status": "failed"}, - ], - } - ) - - if cleanup_result.deleted_count > 0: - logger.info( - f"Cleaned up {cleanup_result.deleted_count} failed subscriptions for user {user_id}" - ) - except Exception as e: - logger.warning(f"Failed to cleanup subscriptions for user {user_id}: {e}") - - -async def _create_razorpay_subscription( - user_id: str, - subscription_data: CreateSubscriptionRequest, - razorpay_plan_id: str, - user: Dict[str, Any], -) -> Dict[str, Any]: - """Create subscription in Razorpay and return the subscription data.""" - razorpay_sub_data = { - "plan_id": razorpay_plan_id, - "quantity": subscription_data.quantity, - "customer_notify": subscription_data.customer_notify, - "total_count": 10, # Razorpay's maximum allowed - "addons": subscription_data.addons, - "notes": { - "user_id": user_id, - "user_email": user["email"], - **(subscription_data.notes or {}), - }, - } - - try: - razorpay_subscription = razorpay_service.client.subscription.create( - razorpay_sub_data - ) - mode = "test" if razorpay_service.is_test_mode else "live" - logger.info( - f"Created Razorpay subscription in {mode} mode: {razorpay_subscription['id']}" - ) - except Exception as e: - logger.error(f"Failed to create Razorpay subscription: {e}") - raise HTTPException( - status_code=502, detail="Failed to create subscription in payment gateway" - ) - - # Fetch latest subscription details to get any updated timestamps - try: - razorpay_subscription = razorpay_service.client.subscription.fetch( - razorpay_subscription["id"] - ) - logger.info( - f"Fetched updated subscription details for: {razorpay_subscription['id']}" - ) - except Exception as e: - logger.warning(f"Failed to fetch updated subscription details: {e}") - # Continue with original response - - return razorpay_subscription - - -def _create_subscription_doc( - razorpay_subscription: Dict[str, Any], - user_id: str, - subscription_data: CreateSubscriptionRequest, - dates: Dict[str, datetime], - current_time: datetime, -) -> SubscriptionDB: - """Create a SubscriptionDB document with all the necessary fields.""" - return SubscriptionDB( - _id=None, # Will be set by MongoDB - razorpay_subscription_id=razorpay_subscription["id"], - user_id=user_id, - plan_id=subscription_data.plan_id, - status=razorpay_subscription["status"], - quantity=subscription_data.quantity, - current_start=dates["current_start"], - current_end=dates["current_end"], - ended_at=None, - charge_at=dates["charge_at"], - start_at=dates["start_at"], - end_at=dates["end_at"], - auth_attempts=razorpay_subscription.get("auth_attempts", 0), - total_count=razorpay_subscription.get("total_count", 10), - paid_count=razorpay_subscription.get("paid_count", 0), - customer_notify=subscription_data.customer_notify, - notes=subscription_data.notes or {}, - created_at=current_time, - updated_at=current_time, - ) diff --git a/backend/app/services/payments/verification.py b/backend/app/services/payments/verification.py deleted file mode 100644 index 4e0710d2..00000000 --- a/backend/app/services/payments/verification.py +++ /dev/null @@ -1,223 +0,0 @@ -""" -Payment verification and processing. -""" - -from datetime import datetime, timezone - -from bson import ObjectId -from fastapi import HTTPException - -from app.config.loggers import general_logger as logger -from app.db.mongodb.collections import ( - payments_collection, - subscriptions_collection, - users_collection, -) -from app.models.payment_models import ( - PaymentCallbackRequest, - PaymentDB, - PaymentMethod, - PaymentResponse, - PaymentStatus, -) -from app.utils.email_utils import send_pro_subscription_email -from app.utils.payments_utils import safe_get_notes - -from .client import razorpay_service - - -async def verify_payment( - user_id: str, callback_data: PaymentCallbackRequest -) -> PaymentResponse: - """Verify and process payment callback.""" - try: - # Verify payment signature - if callback_data.razorpay_order_id: - # Regular payment verification - is_valid = razorpay_service.verify_payment_signature( - callback_data.razorpay_order_id, - callback_data.razorpay_payment_id, - callback_data.razorpay_signature, - ) - logger.info(f"Regular payment signature verification: {is_valid}") - elif callback_data.razorpay_subscription_id: - # Subscription payment verification - is_valid = razorpay_service.verify_subscription_signature( - callback_data.razorpay_payment_id, - callback_data.razorpay_subscription_id, - callback_data.razorpay_signature, - ) - logger.info(f"Subscription payment signature verification: {is_valid}") - else: - logger.error( - "No order_id or subscription_id provided for signature verification" - ) - raise HTTPException( - status_code=400, - detail="Missing order_id or subscription_id for verification", - ) - - if not is_valid: - logger.error( - f"Invalid payment signature for payment_id: {callback_data.razorpay_payment_id}" - ) - raise HTTPException(status_code=400, detail="Invalid payment signature") - - mode = "test" if razorpay_service.is_test_mode else "live" - logger.info(f"Payment signature verified successfully in {mode} mode") - - # Fetch payment details from Razorpay - try: - razorpay_payment = razorpay_service.client.payment.fetch( - callback_data.razorpay_payment_id - ) - logger.info( - f"Fetched payment data in {mode} mode: {callback_data.razorpay_payment_id}" - ) - except Exception as e: - logger.error(f"Failed to fetch payment details: {e}") - raise HTTPException( - status_code=502, detail="Failed to fetch payment details" - ) - - # Store payment in database - try: - # Determine payment method - payment_method = None - if razorpay_payment.get("method"): - try: - payment_method = PaymentMethod(razorpay_payment["method"]) - except ValueError: - payment_method = None - - current_time = datetime.now(timezone.utc) - payment_doc = PaymentDB( - _id=None, # Will be set by MongoDB - razorpay_payment_id=razorpay_payment["id"], - user_id=user_id, - subscription_id=callback_data.razorpay_subscription_id, - order_id=callback_data.razorpay_order_id, - amount=razorpay_payment["amount"], - currency=razorpay_payment["currency"], - status=razorpay_payment["status"], - method=payment_method, - description=razorpay_payment.get("description"), - international=razorpay_payment.get("international", False), - refund_status=razorpay_payment.get("refund_status"), - amount_refunded=razorpay_payment.get("amount_refunded", 0), - captured=razorpay_payment.get("captured", False), - email=razorpay_payment.get("email"), - contact=razorpay_payment.get("contact"), - fee=razorpay_payment.get("fee"), - tax=razorpay_payment.get("tax"), - error_code=razorpay_payment.get("error_code"), - error_description=razorpay_payment.get("error_description"), - webhook_verified=True, - notes=safe_get_notes(razorpay_payment.get("notes")), - created_at=current_time, - ) - - result = await payments_collection.insert_one( - payment_doc.dict(by_alias=True, exclude={"id"}) - ) - payment_doc.id = str(result.inserted_id) - - if not result.inserted_id: - raise HTTPException( - status_code=500, detail="Failed to store payment in database" - ) - except HTTPException: - raise - except Exception as e: - logger.error(f"Failed to store payment in database: {e}") - raise HTTPException(status_code=500, detail="Failed to save payment") - - # If this payment is for a subscription, activate it atomically - if callback_data.razorpay_subscription_id: - try: - # Ensure only successful, captured payments activate subscriptions - if razorpay_payment.get( - "status" - ) == "captured" and razorpay_payment.get("captured"): - result = await subscriptions_collection.update_one( - { - "razorpay_subscription_id": callback_data.razorpay_subscription_id, - "status": "created", # Only activate if still in created status - }, - { - "$set": { - "status": "active", - "paid_count": 1, - "updated_at": datetime.now(timezone.utc), - } - }, - ) - subscription_activated = result.modified_count > 0 - if subscription_activated: - logger.info( - f"Activated subscription: {callback_data.razorpay_subscription_id}" - ) - else: - logger.warning( - f"Subscription not found or already activated: {callback_data.razorpay_subscription_id}" - ) - else: - logger.warning( - f"Payment not captured, not activating subscription: {callback_data.razorpay_payment_id}" - ) - subscription_activated = False - except Exception as e: - logger.error( - f"Failed to activate subscription {callback_data.razorpay_subscription_id}: {e}" - ) - subscription_activated = False - - # Send pro activation email to the user if subscription was activated - if subscription_activated: - try: - user = await users_collection.find_one({"_id": ObjectId(user_id)}) - logger.info(user) - if user: - await send_pro_subscription_email( - user_name=user.get("first_name", user.get("name", "there")), - user_email=user["email"], - ) - logger.info( - f"Pro subscription welcome email sent to {user['email']}" - ) - except Exception as e: - logger.error( - f"Failed to send pro subscription email to user {user_id}: {e}" - ) - - logger.info(f"Successfully verified and stored payment: {payment_doc.id}") - - return PaymentResponse( - id=payment_doc.id, - user_id=payment_doc.user_id, - subscription_id=payment_doc.subscription_id, - order_id=payment_doc.order_id, - amount=payment_doc.amount, - currency=payment_doc.currency, - status=PaymentStatus(payment_doc.status), - method=payment_method, - description=payment_doc.description, - international=payment_doc.international, - refund_status=payment_doc.refund_status, - amount_refunded=payment_doc.amount_refunded, - captured=payment_doc.captured, - email=payment_doc.email, - contact=payment_doc.contact, - fee=payment_doc.fee, - tax=payment_doc.tax, - error_code=payment_doc.error_code, - error_description=payment_doc.error_description, - created_at=payment_doc.created_at, - notes=payment_doc.notes, - ) - - except HTTPException: - raise - except Exception as e: - logger.error(f"Unexpected error verifying payment: {e}") - raise HTTPException(status_code=500, detail="Failed to verify payment") diff --git a/backend/app/services/payments/webhooks.py b/backend/app/services/payments/webhooks.py deleted file mode 100644 index 6eb3417b..00000000 --- a/backend/app/services/payments/webhooks.py +++ /dev/null @@ -1,287 +0,0 @@ -""" -Webhook event processing for Razorpay. -""" - -import hashlib -from datetime import datetime, timezone -from typing import Any, Dict - -from fastapi import HTTPException - -from app.config.loggers import general_logger as logger -from app.db.mongodb.collections import ( - payments_collection, - subscriptions_collection, - webhook_events_collection, -) -from app.models.payment_models import WebhookEvent -from app.models.webhook_models import WebhookEventDB -from app.utils.payments_utils import timestamp_to_datetime - -from .client import razorpay_service - - -async def process_webhook(event: WebhookEvent) -> Dict[str, str]: - """Process Razorpay webhook events with idempotency.""" - event_type = event.event - entity = event.payload.get("payment", event.payload.get("subscription", {})) - entity_id = entity.get("id") - - try: - # Generate payload hash for idempotency - payload_str = f"{event_type}:{entity_id}:{entity.get('status', '')}" - payload_hash = hashlib.sha256(payload_str.encode()).hexdigest() - - logger.info(f"Processing webhook event: {event_type} for entity: {entity_id}") - - # Check if we've already processed this exact event - existing_event = await webhook_events_collection.find_one( - { - "$or": [ - {"event_id": f"{event_type}_{entity_id}"}, - {"payload_hash": payload_hash}, - ] - } - ) - - if existing_event and existing_event.get("status") == "processed": - logger.info(f"Event already processed: {event_type}_{entity_id}") - return {"status": "already_processed", "event": event_type} - - # Record the webhook event - webhook_event_doc = WebhookEventDB( - _id=None, - event_id=f"{event_type}_{entity_id}", - event_type=event_type, - razorpay_entity_id=entity_id, - processed_at=datetime.now(timezone.utc), - payload_hash=payload_hash, - status="processing", - ) - - await webhook_events_collection.insert_one( - webhook_event_doc.dict(by_alias=True, exclude={"id"}) - ) - - # Process the webhook - if event_type.startswith("payment."): - result = await _process_payment_webhook(event_type, entity) - elif event_type.startswith("subscription."): - result = await _process_subscription_webhook(event_type, entity) - else: - logger.warning(f"Unhandled webhook event type: {event_type}") - result = {"status": "ignored", "message": "Event type not handled"} - - # Mark as processed - await webhook_events_collection.update_one( - {"event_id": f"{event_type}_{entity_id}"}, {"$set": {"status": "processed"}} - ) - - logger.info(f"Webhook processed successfully: {event_type}") - return result - - except Exception as e: - # Mark as failed - event_id = f"{event_type}_{entity_id}" if entity_id else f"{event_type}_unknown" - await webhook_events_collection.update_one( - {"event_id": event_id}, - { - "$set": {"status": "failed", "error_message": str(e)}, - "$inc": {"retry_count": 1}, - }, - ) - - logger.error(f"Error processing webhook event {event.event}: {e}") - raise HTTPException(status_code=500, detail="Webhook processing failed") - - -async def _process_payment_webhook( - event_type: str, payment_entity: Dict[str, Any] -) -> Dict[str, str]: - """Process payment-related webhook events.""" - payment_id = payment_entity.get("id") - - if event_type == "payment.captured": - # Update payment status to captured - await payments_collection.update_one( - {"razorpay_payment_id": payment_id}, - { - "$set": { - "status": "captured", - "captured": True, - "updated_at": datetime.now(timezone.utc), - } - }, - ) - logger.info(f"Payment captured: {payment_id}") - - elif event_type == "payment.failed": - # Update payment status to failed - await payments_collection.update_one( - {"razorpay_payment_id": payment_id}, - { - "$set": { - "status": "failed", - "error_code": payment_entity.get("error_code"), - "error_description": payment_entity.get("error_description"), - "updated_at": datetime.now(timezone.utc), - } - }, - ) - logger.info(f"Payment failed: {payment_id}") - - return { - "status": "processed", - "event": event_type, - "payment_id": str(payment_id) if payment_id else "", - } - - -async def _process_subscription_webhook( - event_type: str, subscription_entity: Dict[str, Any] -) -> Dict[str, str]: - """Process subscription-related webhook events.""" - subscription_id = subscription_entity.get("id") - - if event_type == "subscription.activated": - # Activate subscription and fetch latest details from Razorpay - try: - # Fetch complete subscription details from Razorpay - complete_subscription = razorpay_service.client.subscription.fetch( - subscription_id - ) - - update_data = { - "status": "active", - "current_start": timestamp_to_datetime( - complete_subscription.get("current_start") - ), - "current_end": timestamp_to_datetime( - complete_subscription.get("current_end") - ), - "charge_at": timestamp_to_datetime( - complete_subscription.get("charge_at") - ), - "start_at": timestamp_to_datetime( - complete_subscription.get("start_at") - ), - "end_at": timestamp_to_datetime(complete_subscription.get("end_at")), - "auth_attempts": complete_subscription.get("auth_attempts", 0), - "total_count": complete_subscription.get("total_count", 10), - "paid_count": complete_subscription.get("paid_count", 0), - "updated_at": datetime.now(timezone.utc), - } - - # Remove None values to avoid overwriting existing data - update_data = {k: v for k, v in update_data.items() if v is not None} - - await subscriptions_collection.update_one( - {"razorpay_subscription_id": subscription_id}, {"$set": update_data} - ) - except Exception as e: - logger.error(f"Failed to fetch complete subscription details: {e}") - # Fallback to webhook data - await subscriptions_collection.update_one( - {"razorpay_subscription_id": subscription_id}, - { - "$set": { - "status": "active", - "current_start": timestamp_to_datetime( - subscription_entity.get("current_start") - ), - "current_end": timestamp_to_datetime( - subscription_entity.get("current_end") - ), - "updated_at": datetime.now(timezone.utc), - } - }, - ) - logger.info(f"Subscription activated: {subscription_id}") - - elif event_type == "subscription.charged": - # Update subscription payment count and billing period - try: - # Fetch complete subscription details from Razorpay - complete_subscription = razorpay_service.client.subscription.fetch( - subscription_id - ) - - update_data = { - "current_start": timestamp_to_datetime( - complete_subscription.get("current_start") - ), - "current_end": timestamp_to_datetime( - complete_subscription.get("current_end") - ), - "charge_at": timestamp_to_datetime( - complete_subscription.get("charge_at") - ), - "paid_count": complete_subscription.get("paid_count", 0), - "updated_at": datetime.now(timezone.utc), - } - - # Remove None values - update_data = {k: v for k, v in update_data.items() if v is not None} - - await subscriptions_collection.update_one( - {"razorpay_subscription_id": subscription_id}, - { - "$set": update_data, - "$inc": ( - {"paid_count": 1} if "paid_count" not in update_data else {} - ), - }, - ) - except Exception as e: - logger.error(f"Failed to fetch complete subscription details: {e}") - # Fallback to webhook data - await subscriptions_collection.update_one( - {"razorpay_subscription_id": subscription_id}, - { - "$inc": {"paid_count": 1}, - "$set": { - "current_start": timestamp_to_datetime( - subscription_entity.get("current_start") - ), - "current_end": timestamp_to_datetime( - subscription_entity.get("current_end") - ), - "updated_at": datetime.now(timezone.utc), - }, - }, - ) - logger.info(f"Subscription charged: {subscription_id}") - - elif event_type == "subscription.cancelled": - # Cancel subscription - await subscriptions_collection.update_one( - {"razorpay_subscription_id": subscription_id}, - { - "$set": { - "status": "cancelled", - "ended_at": datetime.now(timezone.utc), - "updated_at": datetime.now(timezone.utc), - } - }, - ) - logger.info(f"Subscription cancelled: {subscription_id}") - - elif event_type == "subscription.completed": - # Complete subscription - await subscriptions_collection.update_one( - {"razorpay_subscription_id": subscription_id}, - { - "$set": { - "status": "completed", - "ended_at": datetime.now(timezone.utc), - "updated_at": datetime.now(timezone.utc), - } - }, - ) - logger.info(f"Subscription completed: {subscription_id}") - - return { - "status": "processed", - "event": event_type, - "subscription_id": str(subscription_id) if subscription_id else "", - } diff --git a/backend/app/tasks/subscription_cleanup.py b/backend/app/tasks/subscription_cleanup.py deleted file mode 100644 index bce8c085..00000000 --- a/backend/app/tasks/subscription_cleanup.py +++ /dev/null @@ -1,165 +0,0 @@ -""" -Subscription cleanup and reconciliation tasks. -""" - -from datetime import datetime, timezone, timedelta -from typing import Dict, Any - -from app.config.loggers import general_logger as logger -from app.db.mongodb.collections import subscriptions_collection -from app.services.payments.client import razorpay_service - - -async def cleanup_abandoned_subscriptions() -> Dict[str, Any]: - """ - Clean up abandoned subscriptions that were created but never paid. - Removes subscriptions in 'created' status older than 30 minutes. - """ - try: - cutoff_time = datetime.now(timezone.utc) - timedelta(minutes=30) - - # Find abandoned subscriptions - abandoned_subscriptions = subscriptions_collection.find( - {"status": "created", "paid_count": 0, "created_at": {"$lt": cutoff_time}} - ) - - cleanup_count = 0 - async for subscription in abandoned_subscriptions: - try: - # Cancel in Razorpay if it exists - razorpay_subscription_id = subscription.get("razorpay_subscription_id") - if razorpay_subscription_id: - try: - razorpay_service.client.subscription.cancel( - razorpay_subscription_id - ) - logger.info( - f"Cancelled Razorpay subscription: {razorpay_subscription_id}" - ) - except Exception as e: - logger.warning( - f"Failed to cancel Razorpay subscription {razorpay_subscription_id}: {e}" - ) - - # Remove from our database - await subscriptions_collection.delete_one({"_id": subscription["_id"]}) - cleanup_count += 1 - - logger.info(f"Cleaned up abandoned subscription: {subscription['_id']}") - - except Exception as e: - logger.error( - f"Failed to cleanup subscription {subscription.get('_id')}: {e}" - ) - continue - - result = { - "status": "completed", - "cleaned_up_count": cleanup_count, - "timestamp": datetime.now(timezone.utc).isoformat(), - } - - if cleanup_count > 0: - logger.info(f"Cleaned up {cleanup_count} abandoned subscriptions") - - return result - - except Exception as e: - logger.error(f"Error during subscription cleanup: {e}") - return { - "status": "failed", - "error": str(e), - "timestamp": datetime.now(timezone.utc).isoformat(), - } - - -async def reconcile_subscription_payments() -> Dict[str, Any]: - """ - Reconcile subscription payments with Razorpay. - Syncs payment status for subscriptions that might be out of sync. - """ - try: - # Find active subscriptions that might need reconciliation - suspect_subscriptions = subscriptions_collection.find( - { - "status": "active", - "paid_count": 0, # Active but no payments recorded - "created_at": {"$gte": datetime.now(timezone.utc) - timedelta(days=7)}, - } - ) - - reconciled_count = 0 - deactivated_count = 0 - - async for subscription in suspect_subscriptions: - try: - razorpay_subscription_id = subscription.get("razorpay_subscription_id") - if not razorpay_subscription_id: - continue - - # Fetch latest status from Razorpay - razorpay_subscription = razorpay_service.client.subscription.fetch( - razorpay_subscription_id - ) - - razorpay_status = razorpay_subscription.get("status") - razorpay_paid_count = razorpay_subscription.get("paid_count", 0) - - # If Razorpay shows no payments, deactivate subscription - if razorpay_paid_count == 0 and razorpay_status != "active": - await subscriptions_collection.update_one( - {"_id": subscription["_id"]}, - { - "$set": { - "status": "cancelled", - "updated_at": datetime.now(timezone.utc), - } - }, - ) - deactivated_count += 1 - logger.info( - f"Deactivated unpaid subscription: {subscription['_id']}" - ) - - # If Razorpay shows payments, update our records - elif razorpay_paid_count > 0: - await subscriptions_collection.update_one( - {"_id": subscription["_id"]}, - { - "$set": { - "paid_count": razorpay_paid_count, - "status": razorpay_status, - "updated_at": datetime.now(timezone.utc), - } - }, - ) - reconciled_count += 1 - logger.info(f"Reconciled subscription: {subscription['_id']}") - - except Exception as e: - logger.error( - f"Failed to reconcile subscription {subscription.get('_id')}: {e}" - ) - continue - - result = { - "status": "completed", - "reconciled_count": reconciled_count, - "deactivated_count": deactivated_count, - "timestamp": datetime.now(timezone.utc).isoformat(), - } - - if reconciled_count > 0 or deactivated_count > 0: - logger.info( - f"Reconciliation completed: {reconciled_count} reconciled, {deactivated_count} deactivated" - ) - - return result - - except Exception as e: - logger.error(f"Error during subscription reconciliation: {e}") - return { - "status": "failed", - "error": str(e), - "timestamp": datetime.now(timezone.utc).isoformat(), - } diff --git a/backend/app/utils/payments_utils.py b/backend/app/utils/payments_utils.py deleted file mode 100644 index ac274e44..00000000 --- a/backend/app/utils/payments_utils.py +++ /dev/null @@ -1,88 +0,0 @@ -""" -Utility functions for payment processing. -""" - -from datetime import datetime, timedelta -from typing import Any, Dict, Optional - -from app.config.loggers import general_logger as logger -from app.models.payment_models import PlanResponse - - -def safe_get_notes(notes_value: Any) -> Dict[str, str]: - """ - Safely extract notes from Razorpay response. - Razorpay sometimes returns notes as a list instead of dict. - """ - if isinstance(notes_value, dict): - return {str(k): str(v) for k, v in notes_value.items()} - elif isinstance(notes_value, list) and len(notes_value) == 0: - return {} - elif notes_value is None: - return {} - else: - logger.warning( - f"Unexpected notes format from Razorpay: {type(notes_value)} - {notes_value}" - ) - return {} - - -def timestamp_to_datetime(timestamp: Optional[int]) -> Optional[datetime]: - """Convert Unix timestamp to datetime.""" - return datetime.fromtimestamp(timestamp) if timestamp else None - - -def calculate_subscription_dates( - plan: PlanResponse, current_time: datetime, razorpay_subscription: Dict[str, Any] -) -> Dict[str, datetime]: - """Calculate subscription date fields with sensible defaults.""" - # Get timestamps from Razorpay (may be null for new subscriptions) - razorpay_current_start = timestamp_to_datetime( - razorpay_subscription.get("current_start") - ) - razorpay_current_end = timestamp_to_datetime( - razorpay_subscription.get("current_end") - ) - razorpay_charge_at = timestamp_to_datetime(razorpay_subscription.get("charge_at")) - razorpay_start_at = timestamp_to_datetime(razorpay_subscription.get("start_at")) - razorpay_end_at = timestamp_to_datetime(razorpay_subscription.get("end_at")) - - # Always set meaningful defaults for a new subscription - start_at = razorpay_start_at or current_time - current_start = razorpay_current_start or start_at - charge_at = razorpay_charge_at or current_start - - # Calculate current_end based on plan duration - if razorpay_current_end: - current_end = razorpay_current_end - elif plan: - if plan.duration == "monthly": - current_end = current_start + timedelta(days=30) - elif plan.duration == "yearly": - current_end = current_start + timedelta(days=365) - else: - current_end = current_start + timedelta(days=30) # Default to monthly - else: - current_end = current_start + timedelta(days=30) # Fallback default - - # Calculate subscription end date based on total cycles - if razorpay_end_at: - end_at = razorpay_end_at - elif plan: - total_count = razorpay_subscription.get("total_count", 10) - if plan.duration == "monthly": - end_at = start_at + timedelta(days=30 * total_count) - elif plan.duration == "yearly": - end_at = start_at + timedelta(days=365 * total_count) - else: - end_at = start_at + timedelta(days=30 * total_count) # Default to monthly - else: - end_at = start_at + timedelta(days=300) # Fallback: 10 months - - return { - "start_at": start_at, - "current_start": current_start, - "current_end": current_end, - "charge_at": charge_at, - "end_at": end_at, - } diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 35e00b32..6494c4bc 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -86,7 +86,6 @@ dependencies = [ "markdown2>=2.5.3", "pandas>=2.3.0", "resend>=2.10.0", - "razorpay>=1.4.2", "tomli>=2.2.1", "cfgv>=3.4.0", "pre-commit>=4.2.0", @@ -98,6 +97,7 @@ dependencies = [ "workos>=5.24.0", "asyncpg>=0.30.0", "authlib>=1.6.1", + "dodopayments>=1.44.0", ] [dependency-groups] diff --git a/backend/scripts/dodo_setup.py b/backend/scripts/dodo_setup.py new file mode 100644 index 00000000..c3bb3221 --- /dev/null +++ b/backend/scripts/dodo_setup.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +""" +Complete Dodo Payments setup script for GAIA. +This script sets up subscription plans in the database using Dodo product IDs. + +IMPORTANT: Run this script from the correct directory! + +1. If running locally: + cd /path/to/your/gaia/backend + python scripts/dodo_setup.py --monthly-product-id --yearly-product-id + +2. If running inside Docker container: + cd /app + python scripts/dodo_setup.py --monthly-product-id --yearly-product-id + +3. Alternative Docker approach (set PYTHONPATH): + PYTHONPATH=/app python scripts/dodo_setup.py --monthly-product-id --yearly-product-id + +4. Run as module (from app directory): + python -m scripts.dodo_setup --monthly-product-id --yearly-product-id + +Prerequisites: +- DODO_PAYMENTS_API_KEY environment variable must be set +- MongoDB connection string (MONGO_DB) must be configured +- Have your Dodo product IDs ready from your Dodo Payments dashboard + +Usage: + python dodo_setup.py --monthly-product-id --yearly-product-id + +Example: + python dodo_setup.py --monthly-product-id "xyz" --yearly-product-id "xyz" +""" + +import argparse +import asyncio +from datetime import datetime, timezone + +from motor.motor_asyncio import AsyncIOMotorClient + +from app.config.settings import settings +from app.models.payment_models import PlanDB + + +async def setup_dodo_plans(monthly_product_id: str, yearly_product_id: str): + """Set up GAIA subscription plans in the database using Dodo product IDs.""" + print("🚀 GAIA Dodo Payments Setup") + print("=" * 50) + + if not settings.DODO_PAYMENTS_API_KEY: + print("❌ DODO_PAYMENTS_API_KEY not found in environment variables") + return False + + print(f"🔗 Using Dodo Payments API Key: {settings.DODO_PAYMENTS_API_KEY[:10]}...") + print(f"📦 Monthly Product ID: {monthly_product_id}") + print(f"📦 Yearly Product ID: {yearly_product_id}") + print() + + # Define plans with their corresponding Dodo product IDs + plans_data = [ + { + "dodo_product_id": "", # Free plan doesn't need Dodo product ID + "name": "Free", + "description": "Free tier with basic features", + "amount": 0, + "currency": "USD", + "duration": "monthly", + "max_users": 1, + "features": [ + "Basic AI assistant", + "Limited conversations per day", + "Standard response time", + "Email support", + "Mobile app access", + ], + "is_active": True, + }, + { + "dodo_product_id": "", # Free yearly plan + "name": "Free", + "description": "Free tier with basic features (yearly billing)", + "amount": 0, + "currency": "USD", + "duration": "yearly", + "max_users": 1, + "features": [ + "Basic AI assistant", + "Limited conversations per day", + "Standard response time", + "Email support", + "Mobile app access", + ], + "is_active": True, + }, + { + "dodo_product_id": monthly_product_id, # Monthly plan + "name": "Pro", + "description": "Professional tier with advanced features", + "amount": 2000, # $20.00 in cents + "currency": "USD", + "duration": "monthly", + "max_users": 5, + "features": [ + "Advanced AI assistant with GPT-4", + "Unlimited conversations", + "Priority response time", + "24/7 priority support", + "Mobile & desktop apps", + "Advanced integrations", + "Team collaboration features", + "API access", + "Custom workflows", + ], + "is_active": True, + }, + { + "dodo_product_id": yearly_product_id, # Yearly plan + "name": "Pro", + "description": "Professional tier with advanced features (yearly - 2 months free!)", + "amount": 20000, # $200.00 in cents (2 months free) + "currency": "USD", + "duration": "yearly", + "max_users": 5, + "features": [ + "Advanced AI assistant with GPT-4", + "Unlimited conversations", + "Priority response time", + "24/7 priority support", + "Mobile & desktop apps", + "Advanced integrations", + "Team collaboration features", + "API access", + "Custom workflows", + "🎉 2 months FREE (yearly discount)", + ], + "is_active": True, + }, + ] + + # Connect to database + client = None + try: + client = AsyncIOMotorClient(settings.MONGO_DB) + db = client["GAIA"] + collection = db["subscription_plans"] + + print("📊 Setting up subscription plans...") + print() + + created_count = 0 + updated_count = 0 + + for plan_item in plans_data: + try: + plan_name = plan_item["name"] + plan_duration = plan_item["duration"] + dodo_product_id = plan_item["dodo_product_id"] + + print(f"⚙️ Processing: {plan_name} ({plan_duration.capitalize()})") + + # Check if plan already exists + existing_plan = await collection.find_one( + { + "name": plan_name, + "duration": plan_duration, + } + ) + + plan_doc = PlanDB.parse_obj( + { + "dodo_product_id": dodo_product_id, + "name": plan_item["name"], + "description": plan_item["description"], + "amount": plan_item["amount"], + "currency": plan_item["currency"], + "duration": plan_item["duration"], + "max_users": plan_item["max_users"], + "features": plan_item["features"], + "is_active": plan_item["is_active"], + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + } + ) + + if existing_plan: + # Update existing plan + await collection.update_one( + {"_id": existing_plan["_id"]}, + { + "$set": plan_doc.dict( + by_alias=True, exclude={"id", "created_at"} + ) + }, + ) + updated_count += 1 + print(" ✅ Updated existing plan") + else: + # Insert new plan + await collection.insert_one( + plan_doc.dict(by_alias=True, exclude={"id"}) + ) + created_count += 1 + print(" ✅ Created new plan") + + print( + f" 💰 Amount: ${plan_item['amount'] / 100:.2f} {plan_item['currency']}" + ) + print(f" 📅 Duration: {plan_duration.capitalize()}") + print(f" 👥 Max Users: {plan_item['max_users']}") + print( + f" 🏷️ Dodo Product ID: {dodo_product_id or 'Free Plan (No Product ID)'}" + ) + print(f" 🎯 Features: {len(plan_item['features'])} features") + print() + + except Exception as e: + print(f" ❌ Error processing {plan_item['name']}: {e}") + + print("=" * 50) + print("📈 Setup Summary:") + print(f" • Created: {created_count} plans") + print(f" • Updated: {updated_count} plans") + print(f" • Total: {created_count + updated_count} plans processed") + print() + + # Display final plan list + plans_cursor = collection.find({"is_active": True}).sort("amount", 1) + plans = await plans_cursor.to_list(length=None) + + print("📋 Active Plans:") + for plan in plans: + print( + f" • {plan['name']} ({plan['duration']}) - ${plan['amount'] / 100:.2f}" + ) + print(f" Dodo Product ID: {plan.get('dodo_product_id') or 'N/A'}") + + print() + print("✅ Payment system setup complete!") + print("🔗 Frontend can now fetch plans via GET /api/v1/payments/plans") + print( + "🎯 Users can create subscriptions via POST /api/v1/payments/subscriptions" + ) + + return True + + except Exception as e: + print(f"❌ Setup failed: {e}") + return False + finally: + if client: + client.close() + print("🔌 Database connection closed") + + +async def main(): + """Main entry point.""" + parser = argparse.ArgumentParser(description="Setup Dodo Payments plans for GAIA") + parser.add_argument( + "--monthly-product-id", + required=True, + help="Dodo product ID for monthly Pro plan", + ) + parser.add_argument( + "--yearly-product-id", + required=True, + help="Dodo product ID for yearly Pro plan", + ) + + args = parser.parse_args() + + try: + await setup_dodo_plans(args.monthly_product_id, args.yearly_product_id) + print("\n🎉 Dodo Payments setup completed successfully!") + except Exception as e: + print(f"\n💥 Setup failed with error: {e}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/scripts/razorpay_setup.py b/backend/scripts/razorpay_setup.py deleted file mode 100644 index 9044a717..00000000 --- a/backend/scripts/razorpay_setup.py +++ /dev/null @@ -1,297 +0,0 @@ -#!/usr/bin/env python3 -""" -Complete Razorpay setup script for GAIA. -This script sets up subscription plans in the database using existing Razorpay plan IDs. - -Usage: - # Development environment (default) - python razorpay_setup.py - - # Production environment - python razorpay_setup.py --prod -""" - -import sys -import os -import asyncio -from datetime import datetime - -# Add the parent directory to the path so we can import from app -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from app.db.mongodb.collections import plans_collection -from app.models.payment_models import PlanDB, CreatePlanRequest, Currency, PlanDuration -from app.config.settings import settings - - -async def setup_razorpay_plans(is_prod: bool = False): - """Set up GAIA subscription plans in the database using existing Razorpay plan IDs.""" - - env_type = "PRODUCTION" if is_prod else "DEVELOPMENT" - print(f"🚀 GAIA Razorpay Setup ({env_type})") - print("=" * 40) - - if not settings.RAZORPAY_KEY_ID or not settings.RAZORPAY_KEY_SECRET: - print("❌ Razorpay credentials not found in settings") - print("Please check your Infisical configuration") - return - - print(f"🔗 Using Razorpay Key: {settings.RAZORPAY_KEY_ID}") - print(f"🌍 Environment: {env_type}") - - # Define plan IDs based on environment - if is_prod: - # Production Razorpay plan IDs - monthly_plan_id = "plan_QzntOT0NuTyA4t" - yearly_plan_id = "plan_Qznu3PaP1ZmY1X" - else: - # Development/Testing Razorpay plan IDs - monthly_plan_id = "plan_QmJ1F2fJOIzSea" - yearly_plan_id = "plan_QmJ1bew3wsABYv" - - # Define plans with their corresponding Razorpay plan IDs - plans_to_create = [ - { - "razorpay_plan_id": None, # Free plan doesn't need Razorpay plan ID - "plan": CreatePlanRequest( - name="Free", - description="Get started with GAIA for free", - amount=0, # Free plan - currency=Currency.USD, - duration=PlanDuration.MONTHLY, - max_users=1, - features=[ - "Limited file uploads", - "Limited calendar management", - "Limited email actions", - "Limited AI image generation", - "Limited goal tracking", - "Limited web search", - "Limited deep research", - "Limited todo operations", - "Limited reminders", - "Limited weather checks", - "Limited webpage fetch", - "Limited document generation", - "Limited flowchart creation", - "Limited code execution", - "Limited Google Docs operations", - "Basic memory features", - "Standard support", - ], - is_active=True, - ), - }, - { - "razorpay_plan_id": None, # Free yearly plan - "plan": CreatePlanRequest( - name="Free", - description="Get started with GAIA for free - annual commitment", - amount=0, # Free plan - currency=Currency.USD, - duration=PlanDuration.YEARLY, - max_users=1, - features=[ - "Limited file uploads", - "Limited calendar management", - "Limited email actions", - "Limited AI image generation", - "Limited goal tracking", - "Limited web search", - "Limited deep research", - "Limited todo operations", - "Limited reminders", - "Limited weather checks", - "Limited webpage fetch", - "Limited document generation", - "Limited flowchart creation", - "Limited code execution", - "Limited Google Docs operations", - "Basic memory features", - "Standard support", - ], - is_active=True, - ), - }, - { - "razorpay_plan_id": monthly_plan_id, # Monthly plan - "plan": CreatePlanRequest( - name="GAIA Pro", - description="For productivity nerds - billed monthly", - amount=1000, # $10.00 in cents - currency=Currency.USD, - duration=PlanDuration.MONTHLY, - max_users=1, - features=[ - "Extended file uploads", - "Extended calendar management", - "Extended email actions", - "Extended AI image generation", - "Extended goal tracking", - "Extended web search", - "Extended deep research", - "Extended todo operations", - "Extended reminders", - "Extended weather checks", - "Extended webpage fetch", - "Extended document generation", - "Extended flowchart creation", - "Extended code execution", - "Extended Google Docs operations", - "Advanced memory features", - "Private Discord channels", - "Priority support", - ], - is_active=True, - ), - }, - { - "razorpay_plan_id": yearly_plan_id, # Yearly plan - "plan": CreatePlanRequest( - name="GAIA Pro", - description="For productivity nerds - billed annually (save $60/year)", - amount=10000, # $100.00 in cents (save $20) - currency=Currency.USD, - duration=PlanDuration.YEARLY, - max_users=1, - features=[ - "Extended file uploads", - "Extended calendar management", - "Extended email actions", - "Extended AI image generation", - "Extended goal tracking", - "Extended web search", - "Extended deep research", - "Extended todo operations", - "Extended reminders", - "Extended weather checks", - "Extended webpage fetch", - "Extended document generation", - "Extended flowchart creation", - "Extended code execution", - "Extended Google Docs operations", - "Advanced memory features", - "Private Discord channels", - "Priority support", - "Annual discount - Save $60", - ], - is_active=True, - ), - }, - ] - - created_plans = [] - - print("\n🔄 Setting up plans in database...") - - # Check if plans already exist - try: - existing_count = await plans_collection.count_documents({}) - if existing_count > 0: - print(f"⚠️ Found {existing_count} existing plans in database") - choice = ( - input("Do you want to clear existing plans and recreate? (y/N): ") - .strip() - .lower() - ) - if choice == "y": - result = await plans_collection.delete_many({}) - print(f"🗑️ Deleted {result.deleted_count} existing plans") - else: - print("✅ Keeping existing plans. Exiting...") - return - except Exception as e: - print(f"⚠️ Error checking existing plans: {e}") - - # Create plans in database - for plan_item in plans_to_create: - try: - plan_data = plan_item["plan"] - razorpay_plan_id = plan_item["razorpay_plan_id"] - - print(f"\n🔄 Creating: {plan_data.name}...") - - current_time = datetime.utcnow() - plan_doc = PlanDB( - razorpay_plan_id=razorpay_plan_id - or f"free_plan_{int(current_time.timestamp())}", # Use a dummy ID for free plans - name=plan_data.name, - description=plan_data.description, - amount=plan_data.amount, - currency=plan_data.currency.value, # Get string value from enum - duration=plan_data.duration.value, # Get string value from enum - max_users=plan_data.max_users, - features=plan_data.features, - is_active=plan_data.is_active, - created_at=current_time, - updated_at=current_time, - ) - - result = await plans_collection.insert_one( - plan_doc.model_dump(exclude={"id"}) - ) - plan_doc.id = str(result.inserted_id) - created_plans.append(plan_doc) - - print(f"✅ Created plan: {plan_data.name}") - print(f" 📋 Database ID: {plan_doc.id}") - print( - f" 🏷️ Razorpay Plan ID: {razorpay_plan_id or 'Free Plan (No Razorpay ID)'}" - ) - amount_display = ( - f"${plan_data.amount / 100:.2f}" if plan_data.amount > 0 else "Free" - ) - print(f" 💰 Amount: {amount_display}") - print(f" 📅 Duration: {plan_data.duration.value}") - - except Exception as e: - print(f"❌ Failed to create {plan_data.name}: {e}") - continue - - print(f"\n🎉 Successfully created {len(created_plans)} plans in database!") - - if created_plans: - print("\n📋 Summary:") - print("-" * 60) - for plan in created_plans: - amount_display = f"${plan.amount / 100:.2f}" if plan.amount > 0 else "Free" - print(f"Plan: {plan.name}") - print(f"Database ID: {plan.id}") - print(f"Razorpay Plan ID: {plan.razorpay_plan_id}") - print(f"Amount: {amount_display}") - print(f"Duration: {plan.duration}") - print("-" * 30) - - print("\n✅ Your payment system is now ready!") - print("🔗 Frontend can now fetch plans via GET /api/v1/payments/plans") - print("💳 Users can subscribe using the plan IDs from the database") - - return created_plans - - -async def main(): - """Main function.""" - import argparse - - parser = argparse.ArgumentParser(description="Setup Razorpay plans for GAIA") - parser.add_argument( - "--prod", - action="store_true", - help="Use production Razorpay plan IDs (default: development IDs)", - ) - args = parser.parse_args() - - try: - await setup_razorpay_plans(is_prod=args.prod) - env_type = "PRODUCTION" if args.prod else "DEVELOPMENT" - print(f"\n🎉 Razorpay setup completed successfully for {env_type}!") - - except Exception as e: - print(f"❌ Error during setup: {e}") - import traceback - - traceback.print_exc() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/docs/configuration/environment-variables.mdx b/docs/configuration/environment-variables.mdx index 7df13d06..5c400d90 100644 --- a/docs/configuration/environment-variables.mdx +++ b/docs/configuration/environment-variables.mdx @@ -154,10 +154,6 @@ OPENWEATHERMAP_API_KEY=your-openweathermap-api-key # Email (Resend) RESEND_API_KEY=your-resend-api-key -# Razorpay (payments) -RAZORPAY_KEY_ID=your-razorpay-key-id -RAZORPAY_KEY_SECRET=your-razorpay-key-secret - # Blog/Content BLOG_BEARER_TOKEN=your-blog-bearer-token ``` diff --git a/frontend/package.json b/frontend/package.json index b57cf585..c945e929 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -127,6 +127,7 @@ "dagre": "^0.8.5", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", + "dodopayments": "^1.44.0", "dompurify": "^3.2.5", "embla-carousel-react": "^8.6.0", "emblor": "^1.4.8", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 64d9bcb7..812a801a 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -335,6 +335,9 @@ importers: date-fns-tz: specifier: ^3.2.0 version: 3.2.0(date-fns@4.1.0) + dodopayments: + specifier: ^1.44.0 + version: 1.44.0 dompurify: specifier: ^3.2.5 version: 3.2.5 @@ -4692,6 +4695,12 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node-fetch@2.6.13': + resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} + + '@types/node@18.19.121': + resolution: {integrity: sha512-bHOrbyztmyYIi4f1R0s17QsPs1uyyYnGcXeZoGEd227oZjry0q6XQBQxd82X1I57zEfwO8h9Xo+Kl5gX1d9MwQ==} + '@types/node@22.15.21': resolution: {integrity: sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==} @@ -4906,6 +4915,10 @@ packages: '@xyflow/system@0.0.57': resolution: {integrity: sha512-1YpBo0WgmZLR5wQw9Jvk3Tu0gISi/oYc4uSimrDuAsA/G2rGleulLrKkM59uuT/QU5m6DYC2VdBDAzjSNMGuBA==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -4920,6 +4933,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -5552,6 +5569,9 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dodopayments@1.44.0: + resolution: {integrity: sha512-qZvMamcFclI8xOf5cGTV3PYVBBMTkN0CFD/cUbdXy3lHwmM6eB7ZWRctZX68HzwMCQUV5NOVviILvuqYrPaENw==} + dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} @@ -5834,6 +5854,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} @@ -5941,14 +5965,25 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + form-data-encoder@1.7.2: + resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} + form-data@4.0.2: resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} engines: {node: '>= 6'} + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + engines: {node: '>= 6'} + format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} + formdata-node@4.4.1: + resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} + engines: {node: '>= 12.20'} + framer-motion@12.9.2: resolution: {integrity: sha512-R0O3Jdqbfwywpm45obP+8sTgafmdEcUoShQTAV+rB5pi+Y1Px/FYL5qLLRe5tPtBdN1J4jos7M+xN2VV2oEAbQ==} peerDependencies: @@ -6159,6 +6194,9 @@ packages: htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -6933,6 +6971,20 @@ packages: node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + npm@11.3.0: resolution: {integrity: sha512-luthFIP0nFX3+nTfYbWI3p4hP4CiVnKOZ5jdxnF2x7B+Shz8feiSJCLLzgJUNxQ2cDdTaVUiH6RRsMT++vIMZg==} engines: {node: ^20.17.0 || >=22.9.0} @@ -7965,6 +8017,9 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -8028,6 +8083,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -8165,11 +8223,21 @@ packages: warning@4.0.3: resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} + web-streams-polyfill@4.0.0-beta.3: + resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} + engines: {node: '>= 14'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webpack-bundle-analyzer@4.10.1: resolution: {integrity: sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==} engines: {node: '>= 10.13.0'} hasBin: true + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -14259,6 +14327,15 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node-fetch@2.6.13': + dependencies: + '@types/node': 22.15.21 + form-data: 4.0.4 + + '@types/node@18.19.121': + dependencies: + undici-types: 5.26.5 + '@types/node@22.15.21': dependencies: undici-types: 6.21.0 @@ -14499,6 +14576,10 @@ snapshots: d3-selection: 3.0.0 d3-zoom: 3.0.0 + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + acorn-jsx@5.3.2(acorn@8.14.1): dependencies: acorn: 8.14.1 @@ -14509,6 +14590,10 @@ snapshots: acorn@8.14.1: {} + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -15205,6 +15290,18 @@ snapshots: dependencies: esutils: 2.0.3 + dodopayments@1.44.0: + dependencies: + '@types/node': 18.19.121 + '@types/node-fetch': 2.6.13 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.27.1 @@ -15654,6 +15751,8 @@ snapshots: esutils@2.0.3: {} + event-target-shim@5.0.1: {} + eventemitter3@4.0.7: {} expand-template@2.0.3: @@ -15752,6 +15851,8 @@ snapshots: dependencies: is-callable: 1.2.7 + form-data-encoder@1.7.2: {} + form-data@4.0.2: dependencies: asynckit: 0.4.0 @@ -15759,8 +15860,21 @@ snapshots: es-set-tostringtag: 2.1.0 mime-types: 2.1.35 + form-data@4.0.4: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + format@0.2.2: {} + formdata-node@4.4.1: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 4.0.0-beta.3 + framer-motion@12.9.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: motion-dom: 12.9.1 @@ -16021,6 +16135,10 @@ snapshots: domutils: 3.2.2 entities: 4.5.0 + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + husky@9.1.7: {} iconv-lite@0.6.3: @@ -17032,6 +17150,12 @@ snapshots: node-addon-api@7.1.1: optional: true + node-domexception@1.0.0: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + npm@11.3.0: {} object-assign@4.1.1: {} @@ -18195,6 +18319,8 @@ snapshots: totalist@3.0.1: {} + tr46@0.0.3: {} + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -18271,6 +18397,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undici-types@5.26.5: {} + undici-types@6.21.0: {} unified@11.0.5: @@ -18444,6 +18572,10 @@ snapshots: dependencies: loose-envify: 1.4.0 + web-streams-polyfill@4.0.0-beta.3: {} + + webidl-conversions@3.0.1: {} + webpack-bundle-analyzer@4.10.1: dependencies: '@discoveryjs/json-ext': 0.5.7 @@ -18463,6 +18595,11 @@ snapshots: - bufferutil - utf-8-validate + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 diff --git a/frontend/src/app/(landing)/blog/page.tsx b/frontend/src/app/(landing)/blog/page.tsx index 2aa7c976..ecb41e09 100644 --- a/frontend/src/app/(landing)/blog/page.tsx +++ b/frontend/src/app/(landing)/blog/page.tsx @@ -6,6 +6,7 @@ import { blogApi, type BlogPost } from "@/features/blog/api/blogApi"; import { BlogCard } from "@/features/blog/components/BlogCard"; import { BlogHeader } from "@/features/blog/components/BlogHeader"; import { BlogListItem } from "@/features/blog/components/BlogListItem"; +import { Button } from "@heroui/button"; interface Blog { slug: string; @@ -96,12 +97,7 @@ export default function BlogList() {
    Error loading blog posts {error} - +
    diff --git a/frontend/src/app/(landing)/payment/success/page.tsx b/frontend/src/app/(landing)/payment/success/page.tsx new file mode 100644 index 00000000..cc2fd878 --- /dev/null +++ b/frontend/src/app/(landing)/payment/success/page.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { Button } from "@heroui/button"; +import { Card, CardBody } from "@heroui/card"; +import { Spinner } from "@heroui/spinner"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +import { SubscriptionSuccessModal } from "@/features/pricing/components/SubscriptionSuccessModal"; +import { usePricing } from "@/features/pricing/hooks/usePricing"; + +export default function PaymentSuccessPage() { + const router = useRouter(); + const { verifyPayment } = usePricing(); + + const [isVerifying, setIsVerifying] = useState(true); + const [paymentCompleted, setPaymentCompleted] = useState(false); + const [error, setError] = useState(null); + const [showSuccessModal, setShowSuccessModal] = useState(false); + + useEffect(() => { + const verifyPaymentStatus = async () => { + try { + const result = await verifyPayment(); + + if (result.payment_completed) { + setPaymentCompleted(true); + setShowSuccessModal(true); + toast.success("Payment completed successfully!"); + } else { + setError("Payment not completed yet. Please try again in a moment."); + } + } catch (error) { + console.error("Payment verification failed:", error); + setError("Failed to verify payment. Please contact support."); + } finally { + setIsVerifying(false); + } + }; + + verifyPaymentStatus(); + }, [verifyPayment]); + + const handleSuccessClose = () => { + setShowSuccessModal(false); + router.push("/c"); + }; + + const handleRetry = () => { + setIsVerifying(true); + setError(null); + window.location.reload(); + }; + + if (isVerifying) { + return ( +
    + + + +

    Verifying Payment...

    +

    + Please wait while we confirm your payment with Dodo Payments. +

    +
    +
    +
    + ); + } + + if (error) { + return ( +
    + + +
    +

    + Payment Verification Failed +

    +

    {error}

    +
    + + +
    +
    +
    +
    + ); + } + + return ( + <> +
    + + +
    +

    + Payment Successful! +

    +

    + Your subscription has been activated successfully. +

    + +
    +
    +
    + + + + ); +} diff --git a/frontend/src/features/pricing/api/pricingApi.ts b/frontend/src/features/pricing/api/pricingApi.ts index f79c9547..6437875f 100644 --- a/frontend/src/features/pricing/api/pricingApi.ts +++ b/frontend/src/features/pricing/api/pricingApi.ts @@ -16,48 +16,34 @@ export interface Plan { updated_at: string; } -export interface PaymentConfig { - razorpay_key_id: string; - currency: string; - company_name: string; - theme_color: string; +export interface CreateSubscriptionRequest { + product_id: string; } -export interface CreateSubscriptionRequest { - plan_id: string; - quantity?: number; - customer_notify?: boolean; - addons?: Array>; - notes?: Record; +export interface CreateSubscriptionResponse { + subscription_id: string; + payment_link: string; + status: string; } -export interface PaymentCallbackData { - razorpay_payment_id: string; - razorpay_order_id?: string; - razorpay_subscription_id?: string; - razorpay_signature: string; +export interface PaymentVerificationResponse { + payment_completed: boolean; + subscription_id?: string; + message: string; } export interface Subscription { id: string; - razorpay_subscription_id: string; + dodo_subscription_id: string; user_id: string; - plan_id: string; + product_id: string; status: string; quantity: number; - current_start?: string; - current_end?: string; - ended_at?: string; - charge_at?: string; - start_at?: string; - end_at?: string; - auth_attempts: number; - total_count: number; - paid_count: number; - customer_notify: boolean; + payment_link?: string; + webhook_verified: boolean; created_at: string; updated_at: string; - notes?: Record; + metadata?: Record; } export interface UserSubscriptionStatus { @@ -71,18 +57,15 @@ export interface UserSubscriptionStatus { } // Helper function for consistent error handling -// Error response interface from backend interface ApiErrorResponse { detail?: string; message?: string; } -// Helper function for consistent error handling const handleApiError = (error: unknown, context: string): never => { let errorMessage = "An unexpected error occurred"; let status: number | undefined; - // Check if it's an AxiosError if (error && typeof error === "object" && "isAxiosError" in error) { const axiosError = error as AxiosError; errorMessage = @@ -115,30 +98,12 @@ class PricingApi { } } - // Get specific plan by ID - async getPlan(planId: string): Promise { - try { - return await apiService.get(`/payments/plans/${planId}`); - } catch (error) { - return handleApiError(error, "Get plan"); - } - } - - // Get payment configuration - async getPaymentConfig(): Promise { - try { - return await apiService.get("/payments/config"); - } catch (error) { - return handleApiError(error, "Get payment config"); - } - } - - // Create subscription + // Create subscription and get payment link async createSubscription( data: CreateSubscriptionRequest, - ): Promise { + ): Promise { try { - return await apiService.post( + return await apiService.post( "/payments/subscriptions", data, ); @@ -147,63 +112,26 @@ class PricingApi { } } - // Get user subscription status - async getUserSubscriptionStatus(): Promise { + // Verify payment completion after redirect + async verifyPayment(): Promise { try { - return await apiService.get( - "/payments/subscriptions/status", + return await apiService.post( + "/payments/verify-payment", + {}, ); - } catch (error) { - return handleApiError(error, "Get subscription status"); - } - } - - // Verify payment - async verifyPayment(callbackData: PaymentCallbackData): Promise<{ - id: string; - user_id: string; - subscription_id?: string; - order_id?: string; - amount: number; - currency: string; - status: string; - }> { - try { - return await apiService.post("/payments/verify", callbackData); } catch (error) { return handleApiError(error, "Verify payment"); } } - // Update subscription - async updateSubscription(data: { - plan_id?: string; - quantity?: number; - remaining_count?: number; - replace_items?: boolean; - prorate?: boolean; - }): Promise { - try { - return await apiService.put( - "/payments/subscriptions", - data, - ); - } catch (error) { - return handleApiError(error, "Update subscription"); - } - } - - // Cancel subscription - async cancelSubscription(cancelAtCycleEnd = true): Promise<{ - message: string; - cancel_at_cycle_end: boolean; - }> { + // Get user subscription status + async getSubscriptionStatus(): Promise { try { - return await apiService.delete( - `/payments/subscriptions?cancel_at_cycle_end=${cancelAtCycleEnd}`, + return await apiService.get( + "/payments/subscription-status", ); } catch (error) { - return handleApiError(error, "Cancel subscription"); + return handleApiError(error, "Get subscription status"); } } } diff --git a/frontend/src/features/pricing/components/PaymentSummary.tsx b/frontend/src/features/pricing/components/PaymentSummary.tsx index d3f42971..d4cdb8ed 100644 --- a/frontend/src/features/pricing/components/PaymentSummary.tsx +++ b/frontend/src/features/pricing/components/PaymentSummary.tsx @@ -108,16 +108,6 @@ export function PaymentSummary() { )} - - {subscription?.current_end && ( - <> - -
    - Next billing:{" "} - {new Date(subscription.current_end).toLocaleDateString()} -
    - - )} ); diff --git a/frontend/src/features/pricing/components/PricingCard.tsx b/frontend/src/features/pricing/components/PricingCard.tsx index 50252a71..cd83fd97 100644 --- a/frontend/src/features/pricing/components/PricingCard.tsx +++ b/frontend/src/features/pricing/components/PricingCard.tsx @@ -10,9 +10,7 @@ import { Tick02Icon } from "@/components/shared/icons"; import { useUser } from "@/features/auth/hooks/useUser"; // Removed currency import - using USD only -import { useRazorpay } from "../hooks/useRazorpay"; -import { PaymentStatusIndicator } from "./PaymentStatusIndicator"; -import { SubscriptionSuccessModal } from "./SubscriptionSuccessModal"; +import { useDodoPayments } from "../hooks/useDodoPayments"; interface PricingCardProps { title: string; @@ -72,11 +70,10 @@ export function PricingCard({ : null; const { - createSubscriptionPayment, - isLoading, - paymentStates, - paymentActions, - } = useRazorpay(); + createSubscriptionAndRedirect, + isLoading: isCreatingSubscription, + error: paymentError, + } = useDodoPayments(); const user = useUser(); const router = useRouter(); @@ -111,10 +108,8 @@ export function PricingCard({ return; } - await createSubscriptionPayment(planId, { - name: user.name, - email: user.email, - }); + // Create subscription and redirect to Dodo payment page + await createSubscriptionAndRedirect(planId); }; return ( @@ -198,10 +193,11 @@ export function PricingCard({
    - {(paymentStates.isInitiating || - paymentStates.isProcessing || - paymentStates.isVerifying) && ( - + {/* Show payment error if any */} + {paymentError && ( +
    +

    {paymentError}

    +
    )}
    - - ); } diff --git a/frontend/src/features/pricing/components/PricingCards.tsx b/frontend/src/features/pricing/components/PricingCards.tsx index 66fc9f2f..31fc5bcb 100644 --- a/frontend/src/features/pricing/components/PricingCards.tsx +++ b/frontend/src/features/pricing/components/PricingCards.tsx @@ -3,7 +3,7 @@ import { Skeleton } from "@heroui/react"; import type { Plan } from "../api/pricingApi"; -import { usePlans, useUserSubscriptionStatus } from "../hooks/usePricing"; +import { usePricing } from "../hooks/usePricing"; import { convertToUSDCents } from "../utils/currencyConverter"; import { PricingCard } from "./PricingCard"; @@ -16,8 +16,7 @@ export function PricingCards({ durationIsMonth = false, initialPlans, }: PricingCardsProps) { - const { data: plans, isLoading, error } = usePlans(true, initialPlans); - const { data: subscriptionStatus } = useUserSubscriptionStatus(); + const { plans, isLoading, error, subscriptionStatus } = usePricing(); if (isLoading) { return ( @@ -48,7 +47,7 @@ export function PricingCards({ } // Filter plans by duration (always show free plan) - const filteredPlans = plans.filter((plan) => { + const filteredPlans = plans.filter((plan: Plan) => { // Always show free plan regardless of selected duration if (plan.amount === 0) { return true; @@ -61,7 +60,7 @@ export function PricingCards({ }); // Sort plans: Free first, then by amount - const sortedPlans = filteredPlans.sort((a, b) => { + const sortedPlans = filteredPlans.sort((a: Plan, b: Plan) => { if (a.amount === 0) return -1; if (b.amount === 0) return 1; return a.amount - b.amount; @@ -69,7 +68,7 @@ export function PricingCards({ return (
    - {sortedPlans.map((plan) => { + {sortedPlans.map((plan: Plan) => { const isPro = plan.name.toLowerCase().includes("pro"); // Convert any currency to USD cents for display const priceInUSDCents = convertToUSDCents(plan.amount, plan.currency); diff --git a/frontend/src/features/pricing/components/PricingPage.tsx b/frontend/src/features/pricing/components/PricingPage.tsx index 39403eb7..f9a1e3a9 100644 --- a/frontend/src/features/pricing/components/PricingPage.tsx +++ b/frontend/src/features/pricing/components/PricingPage.tsx @@ -1,12 +1,13 @@ "use client"; -import { useEffect, useState } from "react"; import { Chip } from "@heroui/chip"; import { Tab, Tabs } from "@heroui/tabs"; +import { useEffect, useState } from "react"; import type { Plan } from "@/features/pricing/api/pricingApi"; import { pricingApi } from "@/features/pricing/api/pricingApi"; import { PricingCards } from "@/features/pricing/components/PricingCards"; +import { Button } from "@heroui/button"; export default function PricingPage() { const [plans, setPlans] = useState([]); @@ -48,12 +49,7 @@ export default function PricingPage() {
    Error loading pricing plans {error} - +
    ); diff --git a/frontend/src/features/pricing/hooks/useDodoPayments.ts b/frontend/src/features/pricing/hooks/useDodoPayments.ts new file mode 100644 index 00000000..c8830f0d --- /dev/null +++ b/frontend/src/features/pricing/hooks/useDodoPayments.ts @@ -0,0 +1,51 @@ +"use client"; + +import { useCallback,useState } from "react"; +import { toast } from "sonner"; + +import { pricingApi } from "../api/pricingApi"; + +export const useDodoPayments = () => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const createSubscriptionAndRedirect = useCallback( + async (productId: string) => { + setIsLoading(true); + setError(null); + + try { + // Create subscription via API - backend handles user authentication via JWT + const result = await pricingApi.createSubscription({ + product_id: productId, + }); + + // Redirect user to Dodo payment link + if (result.payment_link) { + window.location.href = result.payment_link; + } else { + throw new Error("Payment link not received"); + } + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Failed to create subscription"; + setError(errorMessage); + toast.error(errorMessage); + } finally { + setIsLoading(false); + } + }, + [], + ); + + const clearError = useCallback(() => { + setError(null); + }, []); + + return { + createSubscriptionAndRedirect, + isLoading, + error, + clearError, + }; +}; diff --git a/frontend/src/features/pricing/hooks/usePaymentErrorHandling.ts b/frontend/src/features/pricing/hooks/usePaymentErrorHandling.ts deleted file mode 100644 index c2e719c9..00000000 --- a/frontend/src/features/pricing/hooks/usePaymentErrorHandling.ts +++ /dev/null @@ -1,177 +0,0 @@ -"use client"; - -import { useCallback, useState } from "react"; -import { toast } from "sonner"; - -interface PaymentError { - type: - | "NETWORK_ERROR" - | "VERIFICATION_ERROR" - | "TIMEOUT_ERROR" - | "SUBSCRIPTION_ERROR" - | "UNKNOWN_ERROR"; - message: string; - retryable: boolean; - details?: string; -} - -export const usePaymentErrorHandling = () => { - const [retryCount, setRetryCount] = useState(0); - const maxRetries = 3; - - const categorizeError = useCallback((error: unknown): PaymentError => { - if (error && typeof error === "object" && "message" in error) { - const errorMessage = (error as Error).message.toLowerCase(); - - if (errorMessage.includes("network") || errorMessage.includes("fetch")) { - return { - type: "NETWORK_ERROR", - message: - "Network connection issue. Please check your internet and try again.", - retryable: true, - details: (error as Error).message, - }; - } - - if ( - errorMessage.includes("verification") || - errorMessage.includes("signature") - ) { - return { - type: "VERIFICATION_ERROR", - message: - "Payment verification failed. Your payment may still be processing.", - retryable: false, - details: (error as Error).message, - }; - } - - if ( - errorMessage.includes("timeout") || - errorMessage.includes("timed out") - ) { - return { - type: "TIMEOUT_ERROR", - message: "Request timed out. Please try again.", - retryable: true, - details: (error as Error).message, - }; - } - - if (errorMessage.includes("subscription")) { - return { - type: "SUBSCRIPTION_ERROR", - message: - "Subscription setup failed. Please contact support if this persists.", - retryable: true, - details: (error as Error).message, - }; - } - } - - return { - type: "UNKNOWN_ERROR", - message: "An unexpected error occurred. Please try again.", - retryable: true, - details: error instanceof Error ? error.message : "Unknown error", - }; - }, []); - - const handleError = useCallback( - (error: unknown, context: string) => { - const categorizedError = categorizeError(error); - console.error(`Payment error in ${context}:`, { - type: categorizedError.type, - message: categorizedError.message, - details: categorizedError.details, - retryCount, - }); - - // Show appropriate toast based on error type - switch (categorizedError.type) { - case "NETWORK_ERROR": - toast.error(categorizedError.message, { - duration: 5000, - action: - categorizedError.retryable && retryCount < maxRetries - ? { - label: "Retry", - onClick: () => { - setRetryCount((prev) => prev + 1); - // The retry logic should be handled by the calling component - }, - } - : undefined, - }); - break; - - case "VERIFICATION_ERROR": - toast.warning(categorizedError.message, { - duration: 7000, - description: - "Check your email for payment confirmation. Contact support if you don't receive it.", - }); - break; - - case "TIMEOUT_ERROR": - toast.error(categorizedError.message, { - duration: 5000, - action: - categorizedError.retryable && retryCount < maxRetries - ? { - label: "Retry", - onClick: () => setRetryCount((prev) => prev + 1), - } - : undefined, - }); - break; - - case "SUBSCRIPTION_ERROR": - toast.error(categorizedError.message, { - duration: 6000, - description: - "Our team has been notified. Try again or contact support.", - }); - break; - - default: - toast.error(categorizedError.message, { - duration: 5000, - }); - } - - return categorizedError; - }, - [categorizeError, retryCount, maxRetries], - ); - - const showNetworkRetryToast = useCallback(() => { - toast.error("Connection lost. Retrying...", { - duration: 3000, - }); - }, []); - - const showSuccessRecoveryToast = useCallback(() => { - toast.success("Connection restored! Continuing...", { - duration: 3000, - }); - }, []); - - const resetRetryCount = useCallback(() => { - setRetryCount(0); - }, []); - - const canRetry = useCallback(() => { - return retryCount < maxRetries; - }, [retryCount, maxRetries]); - - return { - handleError, - showNetworkRetryToast, - showSuccessRecoveryToast, - resetRetryCount, - canRetry, - retryCount, - maxRetries, - }; -}; diff --git a/frontend/src/features/pricing/hooks/usePaymentFlow.ts b/frontend/src/features/pricing/hooks/usePaymentFlow.ts index 0550394e..4fe91e13 100644 --- a/frontend/src/features/pricing/hooks/usePaymentFlow.ts +++ b/frontend/src/features/pricing/hooks/usePaymentFlow.ts @@ -1,12 +1,6 @@ "use client"; -import { useQueryClient } from "@tanstack/react-query"; -import { useRouter } from "next/navigation"; import { useCallback, useState } from "react"; -import { toast } from "sonner"; - -import { type PaymentCallbackData, pricingApi } from "../api/pricingApi"; -import { usePaymentErrorHandling } from "./usePaymentErrorHandling"; export interface PaymentFlowStates { isInitiating: boolean; @@ -14,7 +8,6 @@ export interface PaymentFlowStates { isVerifying: boolean; isComplete: boolean; error: string | null; - showSuccessModal: boolean; } export const usePaymentFlow = () => { @@ -24,234 +17,52 @@ export const usePaymentFlow = () => { isVerifying: false, isComplete: false, error: null, - showSuccessModal: false, }); - const queryClient = useQueryClient(); - const router = useRouter(); - const { - handleError, - showNetworkRetryToast, - showSuccessRecoveryToast, - canRetry, - resetRetryCount, - } = usePaymentErrorHandling(); + const setInitiating = useCallback((value: boolean) => { + setStates((prev) => ({ ...prev, isInitiating: value, error: null })); + }, []); - const updateState = (newState: Partial) => { - setStates((prev) => ({ ...prev, ...newState })); - }; + const setProcessing = useCallback((value: boolean) => { + setStates((prev) => ({ ...prev, isProcessing: value, error: null })); + }, []); - const resetFlow = useCallback(() => { - setStates({ + const setVerifying = useCallback((value: boolean) => { + setStates((prev) => ({ ...prev, isVerifying: value, error: null })); + }, []); + + const setComplete = useCallback((value: boolean) => { + setStates((prev) => ({ ...prev, isComplete: value, error: null })); + }, []); + + const setError = useCallback((error: string | null) => { + setStates((prev) => ({ + ...prev, + error, isInitiating: false, isProcessing: false, isVerifying: false, isComplete: false, - error: null, - showSuccessModal: false, - }); - resetRetryCount(); - }, [resetRetryCount]); - - const handlePaymentSuccess = useCallback( - async (response: PaymentCallbackData) => { - updateState({ isProcessing: false, isVerifying: true }); - - // Show immediate success feedback - toast.success("🎉 Payment successful! Activating your subscription...", { - duration: 3000, - }); - - try { - // Optimistically update subscription status - queryClient.setQueryData(["userSubscriptionStatus"], (old: unknown) => { - if (old && typeof old === "object") { - return { - ...old, - is_subscribed: true, - subscription: { - ...(old as { subscription?: object }).subscription, - status: "active", - }, - }; - } - return old; - }); - - // Start verification process in background - let verificationSuccessful = false; - - try { - await pricingApi.verifyPayment({ - razorpay_payment_id: response.razorpay_payment_id, - razorpay_subscription_id: response.razorpay_subscription_id, - razorpay_signature: response.razorpay_signature, - }); - verificationSuccessful = true; - showSuccessRecoveryToast(); - } catch (verificationError) { - handleError(verificationError, "Payment verification"); - // Don't throw here - webhook will handle verification - } - - // Start polling for real subscription status - let attempts = 0; - const maxAttempts = 8; // 40 seconds max - - const pollStatus = async (): Promise => { - try { - const realStatus = await pricingApi.getUserSubscriptionStatus(); - - // Update with real data - queryClient.setQueryData(["userSubscriptionStatus"], realStatus); - - if ( - realStatus.is_subscribed && - realStatus.subscription?.status === "active" - ) { - updateState({ - isVerifying: false, - isComplete: true, - showSuccessModal: true, - }); - - // Show completion message - toast.success( - "🎉 Welcome to GAIA Pro! Your subscription is now active.", - { - duration: 4000, - }, - ); - - return; - } - - attempts++; - if (attempts < maxAttempts) { - setTimeout(pollStatus, 5000); - } else { - // Polling timed out but don't show error - user might still be active - updateState({ - isVerifying: false, - isComplete: true, - showSuccessModal: verificationSuccessful, - }); - - if (verificationSuccessful) { - toast.success( - "Subscription activated! If you experience any issues, please contact support.", - { - duration: 5000, - }, - ); - } else { - toast.warning( - "Subscription is being processed. You'll receive a confirmation email shortly.", - { - duration: 5000, - }, - ); - // Only auto-navigate if modal won't show - setTimeout(() => { - router.push("/c"); - }, 2000); - } - } - } catch (error) { - handleError(error, "Subscription status polling"); - attempts++; - - if (attempts < maxAttempts && canRetry()) { - showNetworkRetryToast(); - setTimeout(pollStatus, 5000); - } else { - // Final fallback - updateState({ isVerifying: false, isComplete: true }); - toast.info( - "Subscription is being processed. Please check your email for confirmation.", - { - duration: 5000, - }, - ); - setTimeout(() => { - router.push("/c"); - }, 2000); - } - } - }; - - // Start polling after brief delay - setTimeout(pollStatus, 2000); - } catch (error) { - const categorizedError = handleError(error, "Post-payment processing"); - updateState({ - isVerifying: false, - error: categorizedError.message, - }); - } - }, - [ - queryClient, - router, - handleError, - showSuccessRecoveryToast, - showNetworkRetryToast, - canRetry, - ], - ); - - const handlePaymentError = useCallback( - (error: Error) => { - const categorizedError = handleError(error, "Payment processing"); - updateState({ - isInitiating: false, - isProcessing: false, - isVerifying: false, - error: categorizedError.message, - }); - }, - [handleError], - ); + })); + }, []); - const handlePaymentDismiss = useCallback(() => { - updateState({ + const reset = useCallback(() => { + setStates({ isInitiating: false, isProcessing: false, + isVerifying: false, + isComplete: false, + error: null, }); - - toast.info("Payment was cancelled. Your subscription was not activated.", { - duration: 4000, - }); - }, []); - - const startProcessing = useCallback(() => { - updateState({ isInitiating: false, isProcessing: true }); }, []); - const startInitiating = useCallback(() => { - updateState({ isInitiating: true, error: null }); - }, []); - - const handleSuccessModalClose = useCallback(() => { - updateState({ showSuccessModal: false }); - }, []); - - const handleSuccessModalNavigate = useCallback(() => { - updateState({ showSuccessModal: false }); - router.push("/c"); - }, [router]); - return { states, - actions: { - resetFlow, - startInitiating, - startProcessing, - handlePaymentSuccess, - handlePaymentError, - handlePaymentDismiss, - handleSuccessModalClose, - handleSuccessModalNavigate, - }, + setInitiating, + setProcessing, + setVerifying, + setComplete, + setError, + reset, }; }; diff --git a/frontend/src/features/pricing/hooks/usePricing.ts b/frontend/src/features/pricing/hooks/usePricing.ts index 6f55549e..e2e2c727 100644 --- a/frontend/src/features/pricing/hooks/usePricing.ts +++ b/frontend/src/features/pricing/hooks/usePricing.ts @@ -1,36 +1,95 @@ +"use client"; + import { useQuery } from "@tanstack/react-query"; +import { useCallback, useState } from "react"; -import { type Plan,pricingApi } from "../api/pricingApi"; +import { Plan, pricingApi } from "../api/pricingApi"; -export const usePlans = (activeOnly = true, initialData?: Plan[]) => { - return useQuery({ - queryKey: ["plans", activeOnly], - queryFn: () => pricingApi.getPlans(activeOnly), +export const usePricing = () => { + const [error, setError] = useState(null); + + // Get all plans + const { + data: plans = [], + isLoading: plansLoading, + error: plansError, + } = useQuery({ + queryKey: ["plans"], + queryFn: () => pricingApi.getPlans(), staleTime: 5 * 60 * 1000, // 5 minutes - ...(initialData && { initialData }), }); -}; -export const usePlan = (planId: string) => { - return useQuery({ - queryKey: ["plan", planId], - queryFn: () => pricingApi.getPlan(planId), - enabled: !!planId, + // Get user subscription status + const { + data: subscriptionStatus, + isLoading: subscriptionLoading, + error: subscriptionError, + refetch: refetchSubscription, + } = useQuery({ + queryKey: ["subscription-status"], + queryFn: () => pricingApi.getSubscriptionStatus(), + staleTime: 1 * 60 * 1000, // 1 minute }); -}; -export const usePaymentConfig = () => { - return useQuery({ - queryKey: ["paymentConfig"], - queryFn: () => pricingApi.getPaymentConfig(), - staleTime: 10 * 60 * 1000, // 10 minutes - }); + // Verify payment status + const verifyPayment = useCallback(async () => { + try { + setError(null); + const result = await pricingApi.verifyPayment(); + + // Refetch subscription status after verification + await refetchSubscription(); + + return result; + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Payment verification failed"; + setError(errorMessage); + throw err; + } + }, [refetchSubscription]); + + // Get plan by ID + const getPlanById = useCallback( + (planId: string): Plan | undefined => { + return plans.find((plan) => plan.id === planId); + }, + [plans], + ); + + // Clear error + const clearError = useCallback(() => { + setError(null); + }, []); + + return { + // Data + plans, + subscriptionStatus, + + // Loading states + isLoading: plansLoading || subscriptionLoading, + plansLoading, + subscriptionLoading, + + // Errors + error: error || plansError || subscriptionError, + plansError, + subscriptionError, + + // Methods + verifyPayment, + getPlanById, + clearError, + refetchSubscription, + }; }; +// Separate hook for just subscription status (for backward compatibility) export const useUserSubscriptionStatus = () => { return useQuery({ - queryKey: ["userSubscriptionStatus"], - queryFn: () => pricingApi.getUserSubscriptionStatus(), + queryKey: ["subscription-status"], + queryFn: () => pricingApi.getSubscriptionStatus(), staleTime: 1 * 60 * 1000, // 1 minute }); }; diff --git a/frontend/src/features/pricing/hooks/useRazorpay.ts b/frontend/src/features/pricing/hooks/useRazorpay.ts deleted file mode 100644 index 32ce2c36..00000000 --- a/frontend/src/features/pricing/hooks/useRazorpay.ts +++ /dev/null @@ -1,133 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useState } from "react"; -import { toast } from "sonner"; - -import { type PaymentCallbackData, pricingApi } from "../api/pricingApi"; -import { usePaymentFlow } from "./usePaymentFlow"; -import { usePaymentConfig } from "./usePricing"; - -declare global { - interface Window { - Razorpay: new (options: RazorpayOptions) => { - open: () => void; - on: (event: string, handler: () => void) => void; - }; - } -} - -interface RazorpayOptions { - key: string; - amount: number; - currency: string; - name: string; - description?: string; - image?: string; - order_id?: string; - subscription_id?: string; - prefill?: { - name?: string; - email?: string; - contact?: string; - }; - theme?: { - color?: string; - }; - handler: (response: PaymentCallbackData) => void; - modal?: { - ondismiss?: () => void; - }; -} - -export const useRazorpay = () => { - const [isScriptLoaded, setIsScriptLoaded] = useState(false); - const { data: config } = usePaymentConfig(); - const { states, actions } = usePaymentFlow(); - - // Load Razorpay script - useEffect(() => { - const loadScript = () => { - return new Promise((resolve) => { - const script = document.createElement("script"); - script.src = "https://checkout.razorpay.com/v1/checkout.js"; - script.onload = () => { - setIsScriptLoaded(true); - resolve(true); - }; - script.onerror = () => resolve(false); - document.body.appendChild(script); - }); - }; - - if (window.Razorpay) { - setIsScriptLoaded(true); - } else { - loadScript(); - } - }, []); - - // Create subscription payment - const createSubscriptionPayment = useCallback( - async ( - planId: string, - userInfo?: { name?: string; email?: string; contact?: string }, - ) => { - if (!isScriptLoaded || !config) { - toast.error("Payment system not ready. Please try again."); - return; - } - - actions.resetFlow(); - actions.startInitiating(); - - try { - // Create subscription - const subscription = await pricingApi.createSubscription({ - plan_id: planId, - customer_notify: true, - }); - - const options: RazorpayOptions = { - key: config.razorpay_key_id, - subscription_id: subscription.razorpay_subscription_id, - amount: 0, - currency: config.currency, - name: config.company_name, - description: "GAIA Pro Subscription", - prefill: userInfo, - theme: { - color: config.theme_color, - }, - handler: async (response: PaymentCallbackData) => { - actions.startProcessing(); - await actions.handlePaymentSuccess(response); - }, - modal: { - ondismiss: () => { - actions.handlePaymentDismiss(); - }, - }, - }; - - const rzp = new window.Razorpay(options); - rzp.open(); - } catch (error) { - console.error("Error creating subscription:", error); - actions.handlePaymentError( - error instanceof Error - ? error - : new Error("Failed to initiate payment"), - ); - } - }, - [isScriptLoaded, config, actions], - ); - - return { - isLoading: states.isInitiating || states.isProcessing, - isScriptLoaded, - createSubscriptionPayment, - paymentStates: states, - paymentActions: actions, - }; -}; diff --git a/frontend/src/features/settings/components/SubscriptionSettings.tsx b/frontend/src/features/settings/components/SubscriptionSettings.tsx index 3154a093..1befcf70 100644 --- a/frontend/src/features/settings/components/SubscriptionSettings.tsx +++ b/frontend/src/features/settings/components/SubscriptionSettings.tsx @@ -3,14 +3,12 @@ import { Button } from "@heroui/button"; import { Chip } from "@heroui/chip"; import { Skeleton } from "@heroui/skeleton"; +import { Tooltip } from "@heroui/tooltip"; import { CreditCard } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { toast } from "sonner"; import { CreditCardIcon } from "@/components"; import { SettingsCard } from "@/components/shared/SettingsCard"; -import { pricingApi } from "@/features/pricing/api/pricingApi"; import { useUserSubscriptionStatus } from "@/features/pricing/hooks/usePricing"; import { convertToUSDCents, @@ -23,31 +21,8 @@ export function SubscriptionSettings() { isLoading, refetch, } = useUserSubscriptionStatus(); - const [isCancelling, setIsCancelling] = useState(false); const router = useRouter(); - const handleCancelSubscription = async () => { - if ( - !confirm( - "Are you sure you want to cancel your subscription? You'll continue to have access until the end of your current billing period.", - ) - ) { - return; - } - - setIsCancelling(true); - try { - await pricingApi.cancelSubscription(true); - toast.success("Subscription cancelled successfully."); - refetch(); - } catch (error) { - console.error("Failed to cancel subscription:", error); - toast.error("Failed to cancel subscription. Please try again."); - } finally { - setIsCancelling(false); - } - }; - const handleUpgrade = () => { router.push("/pricing"); }; @@ -174,14 +149,6 @@ export function SubscriptionSettings() { {priceFormatted} / {plan.duration} - {subscription?.current_end && ( -
    - Next billing - - {formatDate(subscription.current_end)} - -
    - )} {subscriptionStatus.days_remaining !== undefined && (
    Days remaining @@ -205,16 +172,16 @@ export function SubscriptionSettings() { {subscription?.status === "active" && ( - + + + )}
    From 9720dd0dd0f74e8cc3b4324b9f220637b305c19c Mon Sep 17 00:00:00 2001 From: Aryan Date: Thu, 7 Aug 2025 05:03:41 +0530 Subject: [PATCH 10/72] feat: add dodo_product_id to Plan model and update related components --- backend/app/models/payment_models.py | 1 + backend/app/services/payment_service.py | 22 +++++++++++++++---- backend/scripts/dodo_setup.py | 22 +++++++++++++++++++ .../src/features/pricing/api/pricingApi.ts | 1 + .../pricing/components/PricingCards.tsx | 2 +- 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/backend/app/models/payment_models.py b/backend/app/models/payment_models.py index 13ad14e7..bed02ef4 100644 --- a/backend/app/models/payment_models.py +++ b/backend/app/models/payment_models.py @@ -78,6 +78,7 @@ class PlanResponse(BaseModel): """Response model for subscription plan.""" id: str = Field(..., description="Plan ID") + dodo_product_id: str = Field(..., description="Dodo product ID") name: str = Field(..., description="Plan name") description: Optional[str] = Field(None, description="Plan description") amount: int = Field(..., description="Plan amount") diff --git a/backend/app/services/payment_service.py b/backend/app/services/payment_service.py index e778998a..fcfc321f 100644 --- a/backend/app/services/payment_service.py +++ b/backend/app/services/payment_service.py @@ -48,7 +48,18 @@ async def get_plans(self, active_only: bool = True) -> List[PlanResponse]: # Try cache first cached = await redis_cache.get(cache_key) if cached: - return [PlanResponse(**plan) for plan in cached] + try: + # Try to create PlanResponse objects from cached data + plan_responses = [] + for plan_data in cached: + # Ensure dodo_product_id exists in cached data + if "dodo_product_id" not in plan_data: + plan_data["dodo_product_id"] = "" + plan_responses.append(PlanResponse(**plan_data)) + return plan_responses + except Exception: + # If cached data is incompatible, clear cache and fetch fresh + await redis_cache.delete(cache_key) # Fetch from database query = {"is_active": True} if active_only else {} @@ -57,6 +68,7 @@ async def get_plans(self, active_only: bool = True) -> List[PlanResponse]: plan_responses = [ PlanResponse( id=str(plan["_id"]), + dodo_product_id=plan.get("dodo_product_id", ""), name=plan["name"], description=plan.get("description"), amount=plan["amount"], @@ -101,7 +113,11 @@ async def create_subscription( "street": "N/A", "zipcode": "000000", }, - customer={"customer_id": user_id}, + customer={ + "email": user.get("email"), + "name": user.get("first_name") or user.get("name", "User"), + "create_new_customer": True, + }, product_id=product_id, quantity=quantity, payment_link=True, @@ -234,6 +250,4 @@ async def handle_webhook(self, webhook_data: Dict[str, Any]) -> Dict[str, str]: } -print(f"{settings.DODO_PAYMENTS_API_KEY=}") - payment_service = DodoPaymentService() diff --git a/backend/scripts/dodo_setup.py b/backend/scripts/dodo_setup.py index c3bb3221..8c994e9b 100644 --- a/backend/scripts/dodo_setup.py +++ b/backend/scripts/dodo_setup.py @@ -41,6 +41,25 @@ from app.models.payment_models import PlanDB +async def cleanup_old_indexes(collection): + """Remove old payment gateway indexes that might conflict.""" + try: + # List all indexes + indexes = await collection.list_indexes().to_list(length=None) + + # Find and drop old payment gateway indexes + old_indexes = ["razorpay_plan_id_1", "stripe_plan_id_1", "paypal_plan_id_1"] + + for index in indexes: + index_name = index.get("name") + if index_name in old_indexes: + print(f"🗑️ Dropping old index: {index_name}") + await collection.drop_index(index_name) + + except Exception as e: + print(f"⚠️ Warning: Could not clean up old indexes: {e}") + + async def setup_dodo_plans(monthly_product_id: str, yearly_product_id: str): """Set up GAIA subscription plans in the database using Dodo product IDs.""" print("🚀 GAIA Dodo Payments Setup") @@ -143,6 +162,9 @@ async def setup_dodo_plans(monthly_product_id: str, yearly_product_id: str): db = client["GAIA"] collection = db["subscription_plans"] + # Clean up old payment gateway indexes first + await cleanup_old_indexes(collection) + print("📊 Setting up subscription plans...") print() diff --git a/frontend/src/features/pricing/api/pricingApi.ts b/frontend/src/features/pricing/api/pricingApi.ts index 6437875f..d29ad0f0 100644 --- a/frontend/src/features/pricing/api/pricingApi.ts +++ b/frontend/src/features/pricing/api/pricingApi.ts @@ -4,6 +4,7 @@ import { apiService } from "@/lib/api"; export interface Plan { id: string; + dodo_product_id: string; // Add Dodo product ID field name: string; description?: string; amount: number; diff --git a/frontend/src/features/pricing/components/PricingCards.tsx b/frontend/src/features/pricing/components/PricingCards.tsx index 31fc5bcb..f16baf6e 100644 --- a/frontend/src/features/pricing/components/PricingCards.tsx +++ b/frontend/src/features/pricing/components/PricingCards.tsx @@ -89,7 +89,7 @@ export function PricingCards({ return ( Date: Thu, 7 Aug 2025 18:39:13 +0530 Subject: [PATCH 11/72] feat: implement JWT authentication for Pub/Sub webhook requests --- PUBSUB_DETAILED.md | 28 ++- backend/app/api/v1/router/mail_webhook.py | 22 ++- backend/app/config/settings.py | 1 + backend/app/utils/pubsub_auth.py | 198 ++++++++++++++++++++++ backend/pyproject.toml | 1 + 5 files changed, 238 insertions(+), 12 deletions(-) create mode 100644 backend/app/utils/pubsub_auth.py diff --git a/PUBSUB_DETAILED.md b/PUBSUB_DETAILED.md index 5e8f48af..73fcb721 100644 --- a/PUBSUB_DETAILED.md +++ b/PUBSUB_DETAILED.md @@ -117,19 +117,36 @@ To test push subscriptions locally: ## 4. Configuration in Gaia -Gaia uses [Infisical](https://infisical.io/) to manage secret environment variables. You only need to set the following secret: +Gaia uses [Infisical](https://infisical.io/) to manage secret environment variables. You need to set the following secrets: - `GCP_TOPIC_NAME`: The full Pub/Sub topic resource name, e.g., `projects/your-gcp-project-id/topics/gmail-notifications` +- `GCP_SUBSCRIPTION_NAME`: The Pub/Sub subscription name, e.g., `gmail-notifications-sub` +- `ENABLE_PUBSUB_JWT_VERIFICATION`: Boolean flag to enable/disable JWT authentication for Pub/Sub webhooks (default: `true`) All other application settings, such as OAuth credentials and webhook URLs, are managed securely in Infisical and do not require manual `.env` configuration. +### Authentication Security + +Gaia now implements JWT token verification for Pub/Sub push subscriptions to ensure that webhook requests come from Google Cloud and not unauthorized entities. This follows Google's recommended security practices: + +- **JWT Verification**: Each push request must include a valid JWT token in the Authorization header +- **Signature Validation**: Tokens are verified using Google's public keys +- **Claims Validation**: Audience, issuer, and expiration claims are validated +- **Configurable**: Can be disabled in development environments by setting `ENABLE_PUBSUB_JWT_VERIFICATION=false` + +To enable JWT authentication for your Pub/Sub subscription: +1. In GCP Console → Pub/Sub → Subscriptions → select your subscription +2. Edit subscription settings and enable "Authentication" with proper service account +3. Ensure your subscription is configured to send JWT tokens + --- ## 5. Code References - **watch_mail.py**: Calls `service.users().watch(userId="me", body=...)` -- **router/mail.py**: Defines `/api/v1/notifications/gmail` endpoint to parse Pub/Sub push messages -- **tasks/mail_tasks.py**: Enqueues jobs to RabbitMQ for further processing +- **router/mail_webhook.py**: Defines `/api/v1/mail-webhook/receive` endpoint to parse Pub/Sub push messages with JWT authentication +- **utils/pubsub_auth.py**: Implements JWT token verification for Pub/Sub push subscriptions +- **services/mail_webhook_service.py**: Enqueues jobs to RabbitMQ for further processing - **worker.py**: Consumes RabbitMQ tasks and fetches email data from Gmail --- @@ -137,10 +154,13 @@ All other application settings, such as OAuth credentials and webhook URLs, are ## 6. Best Practices and Troubleshooting - **Message Retry**: Use idempotent processing; Pub/Sub may redeliver on failure. -- **Authentication**: Validate Pub/Sub push messages using tokens or JWT if configured. +- **Authentication**: JWT token validation is now implemented by default to verify Pub/Sub push messages come from Google Cloud. +- **Security**: Never disable JWT verification in production environments unless absolutely necessary. +- **Development**: For local development, you may need to disable JWT verification temporarily with `ENABLE_PUBSUB_JWT_VERIFICATION=false`. - **Scaling**: Adjust `max_concurrent` for `renew_gmail_watch_subscriptions` based on resource limits. - **Monitoring**: Use Stackdriver (Cloud Monitoring) for Pub/Sub metrics and logs. - **Error Handling**: Log and surface errors in `renew_gmail_watch_for_user` and webhook failures. +- **Token Issues**: If JWT verification fails, check subscription configuration and ensure tokens are being sent. ## 7. Additional Resources diff --git a/backend/app/api/v1/router/mail_webhook.py b/backend/app/api/v1/router/mail_webhook.py index 07d5ac99..c3d37b18 100644 --- a/backend/app/api/v1/router/mail_webhook.py +++ b/backend/app/api/v1/router/mail_webhook.py @@ -1,13 +1,13 @@ import json -from fastapi import APIRouter, HTTPException, Request -from fastapi.encoders import jsonable_encoder -from fastapi.exceptions import RequestValidationError -from pydantic import ValidationError - from app.config.loggers import mail_webhook_logger as logger from app.models.mail_models import EmailWebhookRequest from app.services.mail_webhook_service import queue_email_processing +from app.utils.pubsub_auth import get_verified_pubsub_request +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError +from pydantic import ValidationError router = APIRouter() @@ -16,13 +16,21 @@ "/mail-webhook/receive", summary="Process Email Webhook", ) -async def receive_email(request: Request): +async def receive_email( + request: Request, + jwt_payload: dict = Depends( + get_verified_pubsub_request + ), # Ensure the request is verified +): """ Process incoming email webhook notifications from Gmail. The webhook payload contains information about new emails. This endpoint receives the webhook, validates the payload, and queues a background task for processing. + + The request is authenticated using JWT tokens to ensure it + comes from Google Cloud Pub/Sub and not unauthorized entities. """ try: # Log raw request body @@ -44,8 +52,6 @@ async def receive_email(request: Request): detail="Email address and history ID must be provided.", ) - logger.info(f"Parsed: email={email_address}, historyId={history_id}") - # Use service to queue email processing return await queue_email_processing(email_address, history_id) diff --git a/backend/app/config/settings.py b/backend/app/config/settings.py index b7ffa2e9..df35cf27 100644 --- a/backend/app/config/settings.py +++ b/backend/app/config/settings.py @@ -78,6 +78,7 @@ class Settings(BaseSettings): # OAuth & Authentication GOOGLE_CLIENT_ID: str GOOGLE_CLIENT_SECRET: str + ENABLE_PUBSUB_JWT_VERIFICATION: bool = True GOOGLE_USERINFO_URL: str = "https://www.googleapis.com/oauth2/v2/userinfo" GOOGLE_TOKEN_URL: str = "https://oauth2.googleapis.com/token" diff --git a/backend/app/utils/pubsub_auth.py b/backend/app/utils/pubsub_auth.py new file mode 100644 index 00000000..c7bb6aa3 --- /dev/null +++ b/backend/app/utils/pubsub_auth.py @@ -0,0 +1,198 @@ +import json +from typing import Dict, Optional + +import httpx +import jwt +from fastapi import HTTPException, Request +from jwt import PyJWK + +from app.config.loggers import mail_webhook_logger as logger +from app.config.settings import settings +from app.db.redis import get_cache, set_cache + + +class PubSubAuthenticationError(Exception): + """Exception raised when Pub/Sub authentication fails.""" + + pass + + +class GoogleJWTVerifier: + """Verifies JWT tokens from Google Cloud Pub/Sub push subscriptions.""" + + def __init__(self): + self._cache_ttl = 3600 # Cache keys for 1 hour + self._token_cache_ttl = 300 # Cache tokens for 5 minutes + self._keys_cache_key = "google_jwt_keys" + self._token_cache_prefix = "jwt_token:" + + async def _get_google_public_keys(self) -> Dict[str, Dict]: + """Fetch Google's public keys and return as dictionary indexed by kid.""" + # Try to get from Redis cache first + cached_keys = await get_cache(self._keys_cache_key) + if cached_keys: + logger.debug("Using cached Google public keys from Redis") + return json.loads(cached_keys) + + try: + async with httpx.AsyncClient() as client: + response = await client.get( + "https://www.googleapis.com/oauth2/v3/certs", timeout=10.0 + ) + response.raise_for_status() + keys_response = response.json() + + # Convert keys list to dictionary indexed by kid + keys_dict = {} + for key in keys_response.get("keys", []): + kid = key.get("kid") + if kid: + keys_dict[kid] = key + + # Cache in Redis + await set_cache( + self._keys_cache_key, json.dumps(keys_dict), ttl=self._cache_ttl + ) + + logger.info(f"Cached {len(keys_dict)} Google public keys in Redis") + return keys_dict + + except Exception as e: + logger.error(f"Failed to fetch Google public keys: {e}") + raise PubSubAuthenticationError("Cannot fetch verification keys") + + def _get_project_number_from_topic(self, topic_name: str) -> str: + """Extract project number from topic name.""" + # Topic format: projects/{project-number}/topics/{topic-name} + try: + parts = topic_name.split("/") + if len(parts) >= 2 and parts[0] == "projects": + return parts[1] + raise ValueError("Invalid topic format") + except Exception: + raise PubSubAuthenticationError("Cannot extract project number from topic") + + async def _get_cached_token(self, token: str) -> Optional[Dict]: + """Get cached token payload if valid.""" + cache_key = f"{self._token_cache_prefix}{hash(token)}" + cached_payload = await get_cache(cache_key) + if cached_payload: + logger.debug("Using cached JWT token verification from Redis") + return json.loads(cached_payload) + return None + + async def _cache_token(self, token: str, payload: Dict) -> None: + """Cache token payload in Redis.""" + cache_key = f"{self._token_cache_prefix}{hash(token)}" + await set_cache(cache_key, json.dumps(payload), ttl=self._token_cache_ttl) + + async def verify_jwt_token(self, token: str) -> Dict: + """ + Verify a JWT token from Pub/Sub push subscription. + + Args: + token: The JWT token from Authorization header + + Returns: + Dict: Decoded JWT payload if valid + + Raises: + PubSubAuthenticationError: If token is invalid + """ + # Check cache first + cached_payload = await self._get_cached_token(token) + if cached_payload: + return cached_payload + + try: + # Get Google's public keys as dictionary + keys_dict = await self._get_google_public_keys() + + # Decode header to get key ID + unverified_header = jwt.get_unverified_header(token) + key_id = unverified_header.get("kid") + + if not key_id: + raise PubSubAuthenticationError("No key ID in JWT header") + + # Get key data directly from dictionary + key_data = keys_dict.get(key_id) + if not key_data: + raise PubSubAuthenticationError("Public key not found") + + # Convert JWK to public key + jwk = PyJWK(key_data) + public_key = jwk.key + + # Verify and decode the token + payload = jwt.decode( + token, + public_key, + algorithms=["RS256"], + audience=settings.HOST + "/api/v1/mail-webhook/receive", + issuer="https://accounts.google.com", + options={ + "verify_signature": True, + "verify_aud": True, + "verify_iss": True, + "verify_exp": True, + "verify_iat": True, + }, + ) + + # Cache the verified token + await self._cache_token(token, payload) + + return payload + + except jwt.ExpiredSignatureError: + raise PubSubAuthenticationError("JWT token has expired") + except jwt.InvalidAudienceError: + raise PubSubAuthenticationError("Invalid audience in JWT token") + except jwt.InvalidIssuerError: + raise PubSubAuthenticationError("Invalid issuer in JWT token") + except jwt.InvalidSignatureError: + raise PubSubAuthenticationError("Invalid JWT signature") + except jwt.InvalidTokenError as e: + raise PubSubAuthenticationError(f"Invalid JWT token: {str(e)}") + except Exception as e: + logger.error(f"JWT verification error: {e}") + raise PubSubAuthenticationError("JWT verification failed") + + +# Global verifier instance for caching +_jwt_verifier = GoogleJWTVerifier() + + +# Dependency function for FastAPI +async def get_verified_pubsub_request(request: Request) -> Dict: + """ + FastAPI dependency to verify Pub/Sub requests. + + Args: + request: FastAPI request object (injected by Depends) + + Returns: + Dict: Decoded JWT payload if verification succeeds, empty dict if verification is disabled + + Raises: + HTTPException: If verification fails when enabled + """ + if not settings.ENABLE_PUBSUB_JWT_VERIFICATION: + return {} + + auth_header = request.headers.get("authorization", "") + jwt_token = auth_header.split("Bearer ")[-1].strip() if auth_header else "" + + if not jwt_token: + logger.warning("No JWT token found in Authorization header") + raise HTTPException( + status_code=401, detail="Missing or invalid Authorization header" + ) + + # Verify the JWT token using global verifier instance + try: + return await _jwt_verifier.verify_jwt_token(jwt_token) + except PubSubAuthenticationError as e: + logger.error(f"Pub/Sub authentication failed: {e}") + raise HTTPException(status_code=401, detail=f"Authentication failed: {str(e)}") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 35e00b32..3da0a896 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -50,6 +50,7 @@ dependencies = [ "protobuf==5.29.5", "pydantic==2.10.4", "pydantic-settings>=2.8.1", + "PyJWT>=2.10.0", "pymongo==4.9.2", "pymupdf>=1.25.5", "pypdf2==3.0.1", From 1cd16f08534a025e85d902b9ed3bafe36f36e61a Mon Sep 17 00:00:00 2001 From: Dhruv Maradiya Date: Thu, 7 Aug 2025 19:04:38 +0530 Subject: [PATCH 12/72] fix: filtered empty user messages in system generated chat when response is empty --- .../components/interface/ChatRenderer.tsx | 74 ++++++++----- .../chat/utils/messageContentUtils.ts | 5 +- .../features/chat/utils/messagePropsUtils.ts | 102 +++++++++--------- .../src/types/features/baseMessageRegistry.ts | 39 ++++--- 4 files changed, 127 insertions(+), 93 deletions(-) diff --git a/frontend/src/features/chat/components/interface/ChatRenderer.tsx b/frontend/src/features/chat/components/interface/ChatRenderer.tsx index cf348004..2acac111 100644 --- a/frontend/src/features/chat/components/interface/ChatRenderer.tsx +++ b/frontend/src/features/chat/components/interface/ChatRenderer.tsx @@ -12,10 +12,16 @@ import { useConversation } from "@/features/chat/hooks/useConversation"; import { useConversationList } from "@/features/chat/hooks/useConversationList"; import { useLoading } from "@/features/chat/hooks/useLoading"; import { useLoadingText } from "@/features/chat/hooks/useLoadingText"; -import { filterEmptyMessagePairs } from "@/features/chat/utils/messageContentUtils"; +import { + filterEmptyMessagePairs, + isBotMessageEmpty, +} from "@/features/chat/utils/messageContentUtils"; import { getMessageProps } from "@/features/chat/utils/messagePropsUtils"; import { getToolCategoryIcon } from "@/features/chat/utils/toolIcons"; -import { SetImageDataType } from "@/types/features/chatBubbleTypes"; +import { + ChatBubbleBotProps, + SetImageDataType, +} from "@/types/features/chatBubbleTypes"; import { MessageType } from "@/types/features/convoTypes"; export default function ChatRenderer() { @@ -116,33 +122,49 @@ export default function ChatRenderer() { - {filteredMessages?.map((message: MessageType, index: number) => - message.type === "bot" && message.response.trim().length > 0 ? ( -
    -
    - GAIA Logo { + let messageProps = null; + + if (message.type === "bot") { + messageProps = getMessageProps(message, "bot", messagePropsOptions); + } else if (message.type === "user") { + messageProps = getMessageProps(message, "user", messagePropsOptions); + } + + if (!messageProps) { + return null; // Skip rendering if messageProps is null + } + + if ( + message.type === "bot" && + !isBotMessageEmpty(messageProps as ChatBubbleBotProps) + ) { + return ( +
    +
    + GAIA Logo +
    + +
    + ); + } - -
    - ) : ( - - ), - )} + return ( + + ); + })} {isLoading && (
    {toolInfo?.toolCategory && ( diff --git a/frontend/src/features/chat/utils/messageContentUtils.ts b/frontend/src/features/chat/utils/messageContentUtils.ts index 5bd1f6ef..91537939 100644 --- a/frontend/src/features/chat/utils/messageContentUtils.ts +++ b/frontend/src/features/chat/utils/messageContentUtils.ts @@ -3,6 +3,7 @@ import { BASE_MESSAGE_KEYS, BASE_MESSAGE_SCHEMA, BaseMessageData, + TOOLS_MESSAGE_KEYS, } from "@/types/features/baseMessageRegistry"; import { ChatBubbleBotProps } from "@/types/features/chatBubbleTypes"; import { ConversationMessage, MessageType } from "@/types/features/convoTypes"; @@ -34,8 +35,8 @@ export const isBotMessageEmpty = (props: ChatBubbleBotProps): boolean => { if (loading) return false; - // Only check keys that are in BASE_MESSAGE_KEYS - const hasAnyContent = BASE_MESSAGE_KEYS.some((key) => !!props[key]); + // Only check keys that are in TOOLS_MESSAGE_KEYS + const hasAnyContent = TOOLS_MESSAGE_KEYS.some((key) => !!props[key]); return !( hasAnyContent || diff --git a/frontend/src/features/chat/utils/messagePropsUtils.ts b/frontend/src/features/chat/utils/messagePropsUtils.ts index f769bb74..f239e00c 100644 --- a/frontend/src/features/chat/utils/messagePropsUtils.ts +++ b/frontend/src/features/chat/utils/messagePropsUtils.ts @@ -1,8 +1,8 @@ import { SystemPurpose } from "@/features/chat/api/chatApi"; import { - ChatBubbleBotProps, - ChatBubbleUserProps, - SetImageDataType, + ChatBubbleBotProps, + ChatBubbleUserProps, + SetImageDataType, } from "@/types/features/chatBubbleTypes"; import { MessageType } from "@/types/features/convoTypes"; @@ -19,66 +19,66 @@ import { MessageType } from "@/types/features/convoTypes"; // Options interface for the function parameters interface MessagePropsOptions { - conversation?: { - is_system_generated?: boolean; - system_purpose?: SystemPurpose; - }; - setImageData: React.Dispatch>; - setOpenGeneratedImage: React.Dispatch>; - setOpenMemoryModal: React.Dispatch>; + conversation?: { + is_system_generated?: boolean; + system_purpose?: SystemPurpose; + }; + setImageData: React.Dispatch>; + setOpenGeneratedImage: React.Dispatch>; + setOpenMemoryModal: React.Dispatch>; } // Function overloads for better type safety export function getMessageProps( - message: MessageType, - messageType: "bot", - options: MessagePropsOptions, + message: MessageType, + messageType: "bot", + options: MessagePropsOptions, ): ChatBubbleBotProps; export function getMessageProps( - message: MessageType, - messageType: "user", - options: MessagePropsOptions, + message: MessageType, + messageType: "user", + options: MessagePropsOptions, ): ChatBubbleUserProps; export function getMessageProps( - message: MessageType, - messageType: "bot" | "user", - options: MessagePropsOptions, + message: MessageType, + messageType: "bot" | "user", + options: MessagePropsOptions, ): ChatBubbleBotProps | ChatBubbleUserProps { - const { - conversation, - setImageData, - setOpenGeneratedImage, - setOpenMemoryModal, - } = options; + const { + conversation, + setImageData, + setOpenGeneratedImage, + setOpenMemoryModal, + } = options; - // Extract all props from message, filtering out undefined values - const { response, ...messageProps } = message; + // Extract all props from message, filtering out undefined values + const { response, ...messageProps } = message; - // Filter out undefined values dynamically - const filteredProps = Object.fromEntries( - Object.entries(messageProps).filter(([_, value]) => value !== undefined), - ); + // Filter out undefined values dynamically + const filteredProps = Object.fromEntries( + Object.entries(messageProps).filter(([_, value]) => value !== undefined), + ); - // Base props common to both bot and user messages - const baseProps = { - ...filteredProps, - text: response || "", // Map response to text, ensure always a string - message_id: - messageType === "user" ? message.message_id || "" : message.message_id, // User fallback to empty string - isConvoSystemGenerated: conversation?.is_system_generated || false, - }; + // Base props common to both bot and user messages + const baseProps = { + ...filteredProps, + text: response || "", // Map response to text, ensure always a string + message_id: + messageType === "user" ? message.message_id || "" : message.message_id, // User fallback to empty string + isConvoSystemGenerated: conversation?.is_system_generated || false, + }; - // Add bot-specific props if message type is 'bot' - if (messageType === "bot") { - return { - ...baseProps, - setImageData, - setOpenImage: setOpenGeneratedImage, - onOpenMemoryModal: () => setOpenMemoryModal(true), - systemPurpose: conversation?.system_purpose, - } as ChatBubbleBotProps; - } + // Add bot-specific props if message type is 'bot' + if (messageType === "bot") { + return { + ...baseProps, + setImageData, + setOpenImage: setOpenGeneratedImage, + onOpenMemoryModal: () => setOpenMemoryModal(true), + systemPurpose: conversation?.system_purpose, + } as ChatBubbleBotProps; + } - // Return base props for user messages - return baseProps as ChatBubbleUserProps; + // Return base props for user messages + return baseProps as ChatBubbleUserProps; } diff --git a/frontend/src/types/features/baseMessageRegistry.ts b/frontend/src/types/features/baseMessageRegistry.ts index aba426bd..68ef7158 100644 --- a/frontend/src/types/features/baseMessageRegistry.ts +++ b/frontend/src/types/features/baseMessageRegistry.ts @@ -25,19 +25,7 @@ import { } from "./toolDataTypes"; import { WeatherData } from "./weatherTypes"; -// BASE_MESSAGE_SCHEMA defines all the fields for message data. -// Each value is set as `undefined as type` (or similar) to: -// 1. Allow TypeScript to infer the correct type for BaseMessageData. -// 2. Represent optional/nullable fields for runtime and type generation. -// 3. Enable DRY code for both runtime key extraction and type safety. -export const BASE_MESSAGE_SCHEMA = { - message_id: "" as string, // required string field - date: undefined as string | undefined, - pinned: undefined as boolean | undefined, - fileIds: undefined as string[] | undefined, - fileData: undefined as FileData[] | undefined, - selectedTool: undefined as string | null | undefined, - toolCategory: undefined as string | null | undefined, +export const TOOLS_MESSAGE_SCHEMA = { calendar_options: undefined as CalendarOptions[] | null | undefined, calendar_delete_options: undefined as | CalendarDeleteOptions[] @@ -57,17 +45,33 @@ export const BASE_MESSAGE_SCHEMA = { memory_data: undefined as MemoryData | null | undefined, goal_data: undefined as GoalDataMessageType | null | undefined, google_docs_data: undefined as GoogleDocsData | null | undefined, - isConvoSystemGenerated: undefined as boolean | undefined, calendar_fetch_data: undefined as CalendarFetchData[] | null | undefined, calendar_list_fetch_data: undefined as | CalendarListFetchData[] | null | undefined, +}; + +// BASE_MESSAGE_SCHEMA defines all the fields for message data. +// Each value is set as `undefined as type` (or similar) to: +// 1. Allow TypeScript to infer the correct type for BaseMessageData. +// 2. Represent optional/nullable fields for runtime and type generation. +// 3. Enable DRY code for both runtime key extraction and type safety. +export const BASE_MESSAGE_SCHEMA = { + message_id: "" as string, // required string field + date: undefined as string | undefined, + pinned: undefined as boolean | undefined, + fileIds: undefined as string[] | undefined, + fileData: undefined as FileData[] | undefined, + selectedTool: undefined as string | null | undefined, + toolCategory: undefined as string | null | undefined, + isConvoSystemGenerated: undefined as boolean | undefined, follow_up_actions: undefined as string[] | undefined, integration_connection_required: undefined as | IntegrationConnectionData | null | undefined, + ...TOOLS_MESSAGE_SCHEMA, }; export type BaseMessageData = typeof BASE_MESSAGE_SCHEMA; @@ -75,3 +79,10 @@ export type BaseMessageKey = keyof typeof BASE_MESSAGE_SCHEMA; export const BASE_MESSAGE_KEYS = Object.keys( BASE_MESSAGE_SCHEMA, ) as BaseMessageKey[]; + +export type ToolsMessageData = typeof TOOLS_MESSAGE_SCHEMA; +export type ToolsMessageKey = keyof typeof TOOLS_MESSAGE_SCHEMA; +export const TOOLS_MESSAGE_KEYS = Object.keys( + TOOLS_MESSAGE_SCHEMA, +) as ToolsMessageKey[]; + From 45627a9e9bb5af51cd41d27932d8a9b4953a9cb2 Mon Sep 17 00:00:00 2001 From: Dhruv Maradiya Date: Thu, 7 Aug 2025 20:17:58 +0530 Subject: [PATCH 13/72] feat: enhance logout functionality to redirect to logout URL from backend --- backend/app/api/v1/router/oauth.py | 56 +++++++++++++++-------- frontend/src/features/auth/api/authApi.ts | 7 ++- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/backend/app/api/v1/router/oauth.py b/backend/app/api/v1/router/oauth.py index 8147af01..9313b100 100644 --- a/backend/app/api/v1/router/oauth.py +++ b/backend/app/api/v1/router/oauth.py @@ -37,6 +37,7 @@ File, Form, HTTPException, + Request, UploadFile, ) from fastapi.responses import JSONResponse, RedirectResponse @@ -80,7 +81,8 @@ async def login_workos(): """ # Add any needed parameters for your SSO implementation authorization_url = workos.user_management.get_authorization_url( - provider="authkit", redirect_uri=settings.WORKOS_REDIRECT_URI + provider="authkit", + redirect_uri=settings.WORKOS_REDIRECT_URI, ) return RedirectResponse(url=authorization_url) @@ -533,24 +535,42 @@ async def update_user_name( @router.post("/logout") -async def logout(): +async def logout( + request: Request, +): + """ + Logout user and return logout URL for frontend redirection. """ - Log out the user by revoking tokens for both Google and WorkOS. + wos_session = request.cookies.get("wos_session") - Args: - access_token: JWT access token or Google token + if not wos_session: + raise HTTPException(status_code=401, detail="No active session") - Returns: - JSONResponse with logout status - """ - response = JSONResponse(content={"detail": "Logged out successfully"}) - - response.delete_cookie( - "wos_session", - httponly=True, - path="/", - secure=settings.ENV == "production", - samesite="lax", - ) + try: + session = workos.user_management.load_sealed_session( + sealed_session=wos_session, + cookie_password=settings.WORKOS_COOKIE_PASSWORD, + ) + + if not session: + raise HTTPException(status_code=401, detail="Invalid session") + + logout_url = session.get_logout_url() - return response + # Create response with logout URL + response = JSONResponse(content={"logout_url": logout_url}) + + # Clear the session cookie + response.delete_cookie( + "wos_session", + httponly=True, + path="/", + secure=settings.ENV == "production", + samesite="lax", + ) + + return response + + except Exception as e: + logger.error(f"Logout error: {e}") + raise HTTPException(status_code=500, detail="Logout failed") diff --git a/frontend/src/features/auth/api/authApi.ts b/frontend/src/features/auth/api/authApi.ts index 7d51ac22..d9405c59 100644 --- a/frontend/src/features/auth/api/authApi.ts +++ b/frontend/src/features/auth/api/authApi.ts @@ -55,10 +55,15 @@ export const authApi = { // Logout user logout: async (): Promise => { - return apiService.post("/oauth/logout", undefined, { + const response = await apiService.post<{ logout_url: string }>("/oauth/logout", {}, { successMessage: "Logged out successfully", errorMessage: "Failed to logout", }); + + // Redirect to the logout URL returned by the backend + if (response.logout_url) { + window.location.href = response.logout_url; + } }, // Complete onboarding From 6898cb6ae53077df903b15f94e211193c70fd521 Mon Sep 17 00:00:00 2001 From: Aryan Date: Thu, 7 Aug 2025 20:41:08 +0530 Subject: [PATCH 14/72] feat: implement Dodo Payments webhook handling and processing logic --- backend/app/api/v1/router/payments.py | 55 ++- backend/app/config/settings.py | 1 + backend/app/models/payment_models.py | 8 +- backend/app/models/webhook_models.py | 141 ++++++ backend/app/services/payment_service.py | 27 +- .../app/services/payment_webhook_service.py | 461 ++++++++++++++++++ backend/app/services/webhook_service.py | 436 +++++++++++++++++ 7 files changed, 1085 insertions(+), 44 deletions(-) create mode 100644 backend/app/models/webhook_models.py create mode 100644 backend/app/services/payment_webhook_service.py create mode 100644 backend/app/services/webhook_service.py diff --git a/backend/app/api/v1/router/payments.py b/backend/app/api/v1/router/payments.py index a99a3d33..e4c4f4ce 100644 --- a/backend/app/api/v1/router/payments.py +++ b/backend/app/api/v1/router/payments.py @@ -3,11 +3,9 @@ Single service approach - simple and maintainable. """ -import json from typing import List -from fastapi import APIRouter, Depends, HTTPException, Request -from pydantic import BaseModel +from fastapi import APIRouter, Depends, HTTPException, Request, Header from app.api.v1.dependencies.oauth_dependencies import get_current_user from app.config.loggers import general_logger as logger @@ -19,6 +17,7 @@ UserSubscriptionStatus, ) from app.services.payment_service import payment_service +from app.services.payment_webhook_service import payment_webhook_service router = APIRouter() @@ -77,21 +76,45 @@ async def get_subscription_status_endpoint( return await payment_service.get_user_subscription_status(user_id) -@router.post("/webhooks/dodo") -async def handle_dodo_webhook(request: Request): - """Handle incoming webhooks from Dodo Payments.""" +@router.post("/webhooks/dodo", response_model=dict) +async def handle_dodo_webhook( + request: Request, + webhook_data: dict, + x_signature: str = Header(None, alias="X-Signature"), +): + """ + Handle incoming webhooks from Dodo Payments. + + Security: Verifies webhook signature to ensure authenticity. + Events: Processes payment.succeeded, subscription.active, etc. + """ try: + # Get raw body for signature verification body = await request.body() - webhook_data = json.loads(body.decode("utf-8")) - - result = await payment_service.handle_webhook(webhook_data) - - logger.info(f"Webhook processed: {result}") - return {"status": "success", "result": result} - except json.JSONDecodeError: - logger.error("Invalid JSON in webhook payload") - raise HTTPException(status_code=400, detail="Invalid JSON payload") + # Verify webhook signature for security + if x_signature and not payment_webhook_service.verify_webhook_signature( + body, x_signature + ): + logger.warning("Invalid webhook signature") + raise HTTPException(status_code=401, detail="Invalid webhook signature") + + # Process the webhook + result = await payment_webhook_service.process_webhook(webhook_data) + + logger.info( + f"Webhook processed successfully: {result.event_type} - {result.status}" + ) + + return { + "status": "success", + "event_type": result.event_type, + "processing_status": result.status, + "message": result.message, + } + + except HTTPException: + raise except Exception as e: - logger.error(f"Error processing webhook: {e}") + logger.error(f"Unexpected error processing webhook: {e}") raise HTTPException(status_code=500, detail="Webhook processing failed") diff --git a/backend/app/config/settings.py b/backend/app/config/settings.py index af5212d0..2f628807 100644 --- a/backend/app/config/settings.py +++ b/backend/app/config/settings.py @@ -136,6 +136,7 @@ class Settings(BaseSettings): # Dodo Payments Configuration DODO_PAYMENTS_API_KEY: str + DODO_WEBHOOK_SECRET: str = "" @computed_field # type: ignore @property diff --git a/backend/app/models/payment_models.py b/backend/app/models/payment_models.py index bed02ef4..3e087074 100644 --- a/backend/app/models/payment_models.py +++ b/backend/app/models/payment_models.py @@ -227,7 +227,9 @@ class SubscriptionDB(BaseModel): status: str = Field(..., description="Subscription status") quantity: int = Field(1, description="Quantity") payment_link: Optional[str] = Field(None, description="Payment link URL") - webhook_verified: bool = Field(False, description="Webhook verification status") + webhook_processed_at: Optional[datetime] = Field( + None, description="Webhook processing timestamp" + ) created_at: datetime = Field( default_factory=datetime.utcnow, description="Creation timestamp" ) @@ -254,7 +256,9 @@ class PaymentDB(BaseModel): currency: str = Field(..., description="Currency") status: str = Field(..., description="Payment status") description: Optional[str] = Field(None, description="Payment description") - webhook_verified: bool = Field(False, description="Webhook verification status") + webhook_processed_at: Optional[datetime] = Field( + None, description="Webhook processing timestamp" + ) created_at: datetime = Field( default_factory=datetime.utcnow, description="Creation timestamp" ) diff --git a/backend/app/models/webhook_models.py b/backend/app/models/webhook_models.py new file mode 100644 index 00000000..bf43b7bf --- /dev/null +++ b/backend/app/models/webhook_models.py @@ -0,0 +1,141 @@ +""" +Payment webhook models for Dodo Payments integration. +Clean models based on actual Dodo webhook format. +""" + +from datetime import datetime +from enum import Enum +from typing import Any, Dict, Optional + +from pydantic import BaseModel, Field + + +class PaymentWebhookType(str, Enum): + """Payment webhook event types from Dodo Payments.""" + + # Payment events + PAYMENT_SUCCEEDED = "payment.succeeded" + PAYMENT_FAILED = "payment.failed" + PAYMENT_PROCESSING = "payment.processing" + PAYMENT_CANCELLED = "payment.cancelled" + + # Subscription events + SUBSCRIPTION_ACTIVE = "subscription.active" + SUBSCRIPTION_RENEWED = "subscription.renewed" + SUBSCRIPTION_CANCELLED = "subscription.cancelled" + SUBSCRIPTION_EXPIRED = "subscription.expired" + SUBSCRIPTION_FAILED = "subscription.failed" + SUBSCRIPTION_ON_HOLD = "subscription.on_hold" + SUBSCRIPTION_PLAN_CHANGED = "subscription.plan_changed" + + +class DodoCustomer(BaseModel): + """Customer data from Dodo webhook.""" + + customer_id: str + email: str + name: str + + +class DodoBilling(BaseModel): + """Billing address from Dodo webhook.""" + + city: str + country: str + state: str + street: str + zipcode: str + + +class DodoPaymentData(BaseModel): + """Payment data from Dodo payment webhook.""" + + payment_id: str + subscription_id: Optional[str] = None + business_id: str + brand_id: str + customer: DodoCustomer + billing: DodoBilling + currency: str + total_amount: int + settlement_amount: int + settlement_currency: str + tax: int + settlement_tax: int + status: str + payment_method: str + card_network: Optional[str] = None + card_type: Optional[str] = None + card_last_four: Optional[str] = None + card_issuing_country: Optional[str] = None + created_at: str + updated_at: Optional[str] = None + metadata: Dict[str, Any] = Field(default_factory=dict) + error_code: Optional[str] = None + error_message: Optional[str] = None + + +class DodoSubscriptionData(BaseModel): + """Subscription data from Dodo subscription webhook.""" + + subscription_id: str + product_id: str + customer: DodoCustomer + billing: DodoBilling + status: str + currency: str + quantity: int + recurring_pre_tax_amount: int + payment_frequency_count: int + payment_frequency_interval: str + subscription_period_count: int + subscription_period_interval: str + next_billing_date: Optional[str] = None + previous_billing_date: Optional[str] = None + created_at: str + cancelled_at: Optional[str] = None + cancel_at_next_billing_date: bool = False + tax_inclusive: bool = False + trial_period_days: int = 0 + on_demand: bool = False + metadata: Dict[str, Any] = Field(default_factory=dict) + addons: list = Field(default_factory=list) + discount_id: Optional[str] = None + + +class PaymentWebhookEvent(BaseModel): + """Complete payment webhook event from Dodo Payments.""" + + business_id: str + type: PaymentWebhookType + timestamp: str + data: Dict[str, Any] + + def get_payment_data(self) -> Optional[DodoPaymentData]: + """Extract payment data if this is a payment event.""" + if self.type.value.startswith("payment."): + try: + return DodoPaymentData(**self.data) + except Exception: + return None + return None + + def get_subscription_data(self) -> Optional[DodoSubscriptionData]: + """Extract subscription data if this is a subscription event.""" + if self.type.value.startswith("subscription."): + try: + return DodoSubscriptionData(**self.data) + except Exception: + return None + return None + + +class PaymentWebhookResult(BaseModel): + """Result of payment webhook processing.""" + + event_type: str + status: str # "processed", "ignored", "failed" + message: str + payment_id: Optional[str] = None + subscription_id: Optional[str] = None + processed_at: datetime = Field(default_factory=lambda: datetime.now()) diff --git a/backend/app/services/payment_service.py b/backend/app/services/payment_service.py index fcfc321f..b8c73b56 100644 --- a/backend/app/services/payment_service.py +++ b/backend/app/services/payment_service.py @@ -134,7 +134,6 @@ async def create_subscription( "status": "pending", "quantity": quantity, "payment_link": getattr(subscription, "payment_link", None), - "webhook_verified": False, "created_at": datetime.now(timezone.utc), "updated_at": datetime.now(timezone.utc), "metadata": {"user_email": user.get("email")}, @@ -157,7 +156,7 @@ async def verify_payment_completion(self, user_id: str) -> Dict[str, Any]: if not subscription: return {"payment_completed": False, "message": "No subscription found"} - if subscription["status"] == "active" and subscription.get("webhook_verified"): + if subscription["status"] == "active": # Send welcome email (don't fail if email fails) try: user = await users_collection.find_one({"_id": ObjectId(user_id)}) @@ -225,29 +224,5 @@ async def get_user_subscription_status( status=SubscriptionStatus(subscription["status"]), ) - async def handle_webhook(self, webhook_data: Dict[str, Any]) -> Dict[str, str]: - """Handle Dodo webhook.""" - subscription_id = webhook_data.get("subscription_id") - status = webhook_data.get("status") - - if not subscription_id or not status: - raise HTTPException(400, "Invalid webhook data") - - result = await subscriptions_collection.update_one( - {"dodo_subscription_id": subscription_id}, - { - "$set": { - "status": status, - "webhook_verified": True, - "updated_at": datetime.now(timezone.utc), - } - }, - ) - - return { - "status": "processed" if result.modified_count > 0 else "not_found", - "subscription_id": subscription_id, - } - payment_service = DodoPaymentService() diff --git a/backend/app/services/payment_webhook_service.py b/backend/app/services/payment_webhook_service.py new file mode 100644 index 00000000..3a9d4c6e --- /dev/null +++ b/backend/app/services/payment_webhook_service.py @@ -0,0 +1,461 @@ +""" +Payment webhook service for Dodo Payments integration. +Clean implementation following Dodo webhook format and best practices. +""" + +import hashlib +import hmac +from datetime import datetime, timezone +from typing import Any, Dict + +from bson import ObjectId + +from app.config.loggers import general_logger as logger +from app.config.settings import settings +from app.db.mongodb.collections import ( + payments_collection, + subscriptions_collection, + users_collection, + webhook_events_collection, +) +from app.models.webhook_models import ( + PaymentWebhookEvent, + PaymentWebhookType, + PaymentWebhookResult, +) +from app.utils.email_utils import send_pro_subscription_email + + +class PaymentWebhookService: + """Clean service for handling Dodo payment webhooks.""" + + def __init__(self): + self.webhook_secret = settings.DODO_WEBHOOK_SECRET + self.handlers = { + PaymentWebhookType.PAYMENT_SUCCEEDED: self._handle_payment_succeeded, + PaymentWebhookType.PAYMENT_FAILED: self._handle_payment_failed, + PaymentWebhookType.PAYMENT_PROCESSING: self._handle_payment_processing, + PaymentWebhookType.PAYMENT_CANCELLED: self._handle_payment_cancelled, + PaymentWebhookType.SUBSCRIPTION_ACTIVE: self._handle_subscription_active, + PaymentWebhookType.SUBSCRIPTION_RENEWED: self._handle_subscription_renewed, + PaymentWebhookType.SUBSCRIPTION_CANCELLED: self._handle_subscription_cancelled, + PaymentWebhookType.SUBSCRIPTION_EXPIRED: self._handle_subscription_expired, + PaymentWebhookType.SUBSCRIPTION_FAILED: self._handle_subscription_failed, + PaymentWebhookType.SUBSCRIPTION_ON_HOLD: self._handle_subscription_on_hold, + PaymentWebhookType.SUBSCRIPTION_PLAN_CHANGED: self._handle_subscription_plan_changed, + } + + def verify_webhook_signature(self, payload: bytes, signature: str) -> bool: + """Verify Dodo webhook signature.""" + if not self.webhook_secret: + logger.warning("No webhook secret configured") + return True + + try: + if signature.startswith("sha256="): + signature = signature[7:] + + expected = hmac.new( + self.webhook_secret.encode("utf-8"), payload, hashlib.sha256 + ).hexdigest() + + return hmac.compare_digest(signature, expected) + except Exception as e: + logger.error(f"Signature verification failed: {e}") + return False + + async def process_webhook( + self, webhook_data: Dict[str, Any] + ) -> PaymentWebhookResult: + """Process Dodo payment webhook.""" + try: + event = PaymentWebhookEvent(**webhook_data) + await self._store_event(event) + + handler = self.handlers.get(event.type) + if not handler: + return PaymentWebhookResult( + event_type=event.type.value, + status="ignored", + message=f"Handler not found for {event.type}", + ) + + result = await handler(event) + logger.info(f"Webhook processed: {event.type} - {result.status}") + return result + + except Exception as e: + logger.error(f"Webhook processing failed: {e}") + return PaymentWebhookResult( + event_type=webhook_data.get("type", "unknown"), + status="failed", + message=f"Processing error: {str(e)}", + ) + + # Payment event handlers + async def _handle_payment_succeeded( + self, event: PaymentWebhookEvent + ) -> PaymentWebhookResult: + """Handle successful payment.""" + payment_data = event.get_payment_data() + if not payment_data: + raise ValueError("Invalid payment data") + + payment_doc = { + "dodo_payment_id": payment_data.payment_id, + "dodo_subscription_id": payment_data.subscription_id, + "business_id": payment_data.business_id, + "customer_email": payment_data.customer.email, + "customer_id": payment_data.customer.customer_id, + "total_amount": payment_data.total_amount, + "settlement_amount": payment_data.settlement_amount, + "currency": payment_data.currency, + "settlement_currency": payment_data.settlement_currency, + "status": payment_data.status, + "payment_method": payment_data.payment_method, + "created_at": datetime.now(timezone.utc), + "webhook_processed_at": datetime.now(timezone.utc), + "metadata": payment_data.metadata, + } + + await payments_collection.insert_one(payment_doc) + + return PaymentWebhookResult( + event_type=event.type.value, + status="processed", + message="Payment recorded successfully", + payment_id=payment_data.payment_id, + subscription_id=payment_data.subscription_id, + ) + + async def _handle_payment_failed( + self, event: PaymentWebhookEvent + ) -> PaymentWebhookResult: + """Handle failed payment.""" + payment_data = event.get_payment_data() + if not payment_data: + raise ValueError("Invalid payment data") + + payment_doc = { + "dodo_payment_id": payment_data.payment_id, + "dodo_subscription_id": payment_data.subscription_id, + "business_id": payment_data.business_id, + "customer_email": payment_data.customer.email, + "customer_id": payment_data.customer.customer_id, + "total_amount": payment_data.total_amount, + "currency": payment_data.currency, + "status": "failed", + "payment_method": payment_data.payment_method, + "error_code": payment_data.error_code, + "error_message": payment_data.error_message, + "created_at": datetime.now(timezone.utc), + "webhook_processed_at": datetime.now(timezone.utc), + "metadata": payment_data.metadata, + } + + await payments_collection.insert_one(payment_doc) + + if payment_data.subscription_id: + await subscriptions_collection.update_one( + {"dodo_subscription_id": payment_data.subscription_id}, + { + "$set": { + "status": "payment_failed", + "updated_at": datetime.now(timezone.utc), + "webhook_processed_at": datetime.now(timezone.utc), + } + }, + ) + + return PaymentWebhookResult( + event_type=event.type.value, + status="processed", + message="Payment failure recorded", + payment_id=payment_data.payment_id, + subscription_id=payment_data.subscription_id, + ) + + async def _handle_payment_processing( + self, event: PaymentWebhookEvent + ) -> PaymentWebhookResult: + """Handle payment processing status.""" + payment_data = event.get_payment_data() + if not payment_data: + raise ValueError("Invalid payment data") + + return PaymentWebhookResult( + event_type=event.type.value, + status="processed", + message="Payment processing status noted", + payment_id=payment_data.payment_id, + subscription_id=payment_data.subscription_id, + ) + + async def _handle_payment_cancelled( + self, event: PaymentWebhookEvent + ) -> PaymentWebhookResult: + """Handle cancelled payment.""" + payment_data = event.get_payment_data() + if not payment_data: + raise ValueError("Invalid payment data") + + return PaymentWebhookResult( + event_type=event.type.value, + status="processed", + message="Payment cancellation noted", + payment_id=payment_data.payment_id, + subscription_id=payment_data.subscription_id, + ) + + # Subscription event handlers + async def _handle_subscription_active( + self, event: PaymentWebhookEvent + ) -> PaymentWebhookResult: + """Handle subscription becoming active.""" + sub_data = event.get_subscription_data() + if not sub_data: + raise ValueError("Invalid subscription data") + + result = await subscriptions_collection.update_one( + {"dodo_subscription_id": sub_data.subscription_id}, + { + "$set": { + "status": "active", + "updated_at": datetime.now(timezone.utc), + "webhook_processed_at": datetime.now(timezone.utc), + "next_billing_date": sub_data.next_billing_date, + "previous_billing_date": sub_data.previous_billing_date, + } + }, + ) + + if result.modified_count == 0: + logger.warning(f"Subscription not found: {sub_data.subscription_id}") + return PaymentWebhookResult( + event_type=event.type.value, + status="ignored", + message="Subscription not found", + subscription_id=sub_data.subscription_id, + ) + + await self._send_welcome_email(sub_data) + + return PaymentWebhookResult( + event_type=event.type.value, + status="processed", + message="Subscription activated", + subscription_id=sub_data.subscription_id, + ) + + async def _handle_subscription_renewed( + self, event: PaymentWebhookEvent + ) -> PaymentWebhookResult: + """Handle subscription renewal.""" + sub_data = event.get_subscription_data() + if not sub_data: + raise ValueError("Invalid subscription data") + + await subscriptions_collection.update_one( + {"dodo_subscription_id": sub_data.subscription_id}, + { + "$set": { + "status": "active", + "updated_at": datetime.now(timezone.utc), + "webhook_processed_at": datetime.now(timezone.utc), + "next_billing_date": sub_data.next_billing_date, + "previous_billing_date": sub_data.previous_billing_date, + } + }, + ) + + return PaymentWebhookResult( + event_type=event.type.value, + status="processed", + message="Subscription renewed", + subscription_id=sub_data.subscription_id, + ) + + async def _handle_subscription_cancelled( + self, event: PaymentWebhookEvent + ) -> PaymentWebhookResult: + """Handle subscription cancellation.""" + sub_data = event.get_subscription_data() + if not sub_data: + raise ValueError("Invalid subscription data") + + update_data = { + "status": "cancelled", + "updated_at": datetime.now(timezone.utc), + "webhook_processed_at": datetime.now(timezone.utc), + } + + if sub_data.cancelled_at: + update_data["cancelled_at"] = sub_data.cancelled_at + + await subscriptions_collection.update_one( + {"dodo_subscription_id": sub_data.subscription_id}, + {"$set": update_data}, + ) + + return PaymentWebhookResult( + event_type=event.type.value, + status="processed", + message="Subscription cancelled", + subscription_id=sub_data.subscription_id, + ) + + async def _handle_subscription_expired( + self, event: PaymentWebhookEvent + ) -> PaymentWebhookResult: + """Handle subscription expiration.""" + sub_data = event.get_subscription_data() + if not sub_data: + raise ValueError("Invalid subscription data") + + await subscriptions_collection.update_one( + {"dodo_subscription_id": sub_data.subscription_id}, + { + "$set": { + "status": "expired", + "updated_at": datetime.now(timezone.utc), + "webhook_processed_at": datetime.now(timezone.utc), + } + }, + ) + + return PaymentWebhookResult( + event_type=event.type.value, + status="processed", + message="Subscription expired", + subscription_id=sub_data.subscription_id, + ) + + async def _handle_subscription_failed( + self, event: PaymentWebhookEvent + ) -> PaymentWebhookResult: + """Handle subscription failure.""" + sub_data = event.get_subscription_data() + if not sub_data: + raise ValueError("Invalid subscription data") + + await subscriptions_collection.update_one( + {"dodo_subscription_id": sub_data.subscription_id}, + { + "$set": { + "status": "failed", + "updated_at": datetime.now(timezone.utc), + "webhook_processed_at": datetime.now(timezone.utc), + } + }, + ) + + return PaymentWebhookResult( + event_type=event.type.value, + status="processed", + message="Subscription failed", + subscription_id=sub_data.subscription_id, + ) + + async def _handle_subscription_on_hold( + self, event: PaymentWebhookEvent + ) -> PaymentWebhookResult: + """Handle subscription on hold.""" + sub_data = event.get_subscription_data() + if not sub_data: + raise ValueError("Invalid subscription data") + + await subscriptions_collection.update_one( + {"dodo_subscription_id": sub_data.subscription_id}, + { + "$set": { + "status": "on_hold", + "updated_at": datetime.now(timezone.utc), + "webhook_processed_at": datetime.now(timezone.utc), + } + }, + ) + + return PaymentWebhookResult( + event_type=event.type.value, + status="processed", + message="Subscription on hold", + subscription_id=sub_data.subscription_id, + ) + + async def _handle_subscription_plan_changed( + self, event: PaymentWebhookEvent + ) -> PaymentWebhookResult: + """Handle subscription plan change.""" + sub_data = event.get_subscription_data() + if not sub_data: + raise ValueError("Invalid subscription data") + + await subscriptions_collection.update_one( + {"dodo_subscription_id": sub_data.subscription_id}, + { + "$set": { + "product_id": sub_data.product_id, + "quantity": sub_data.quantity, + "recurring_pre_tax_amount": sub_data.recurring_pre_tax_amount, + "updated_at": datetime.now(timezone.utc), + "webhook_processed_at": datetime.now(timezone.utc), + } + }, + ) + + return PaymentWebhookResult( + event_type=event.type.value, + status="processed", + message="Subscription plan changed", + subscription_id=sub_data.subscription_id, + ) + + # Helper methods + async def _send_welcome_email(self, sub_data) -> None: + """Send welcome email for new subscription.""" + try: + subscription = await subscriptions_collection.find_one( + {"dodo_subscription_id": sub_data.subscription_id} + ) + + if not subscription: + logger.warning( + f"Cannot send welcome email - subscription not found: {sub_data.subscription_id}" + ) + return + + user = await users_collection.find_one( + {"_id": ObjectId(subscription["user_id"])} + ) + + if not user or not user.get("email"): + logger.warning( + f"Cannot send welcome email - user not found: {subscription.get('user_id')}" + ) + return + + await send_pro_subscription_email( + user_name=user.get("first_name", "User"), user_email=user["email"] + ) + + except Exception as e: + logger.error(f"Failed to send welcome email: {e}") + + async def _store_event(self, event: PaymentWebhookEvent) -> None: + """Store webhook event for audit.""" + try: + event_doc = { + "business_id": event.business_id, + "event_type": event.type.value, + "timestamp": event.timestamp, + "data": event.data, + "processed_at": datetime.now(timezone.utc), + } + + await webhook_events_collection.insert_one(event_doc) + + except Exception as e: + logger.error(f"Failed to store webhook event: {e}") + + +payment_webhook_service = PaymentWebhookService() diff --git a/backend/app/services/webhook_service.py b/backend/app/services/webhook_service.py new file mode 100644 index 00000000..24613010 --- /dev/null +++ b/backend/app/services/webhook_service.py @@ -0,0 +1,436 @@ +""" +Payment webhook service for Dodo Payments integration. +Clean implementation following Dodo webhook format and best practices. +""" + +import hashlib +import hmac +from datetime import datetime, timezone +from typing import Any, Dict + +from bson import ObjectId + +from app.config.loggers import general_logger as logger +from app.config.settings import settings +from app.db.mongodb.collections import ( + payments_collection, + subscriptions_collection, + users_collection, + webhook_events_collection, +) +from app.models.webhook_models import ( + PaymentWebhookEvent, + PaymentWebhookType, + PaymentWebhookResult, +) +from app.utils.email_utils import send_pro_subscription_email + + +class PaymentWebhookService: + """Clean service for handling Dodo payment webhooks.""" + + def __init__(self): + self.webhook_secret = settings.DODO_WEBHOOK_SECRET + self.handlers = { + PaymentWebhookType.PAYMENT_SUCCEEDED: self._handle_payment_succeeded, + PaymentWebhookType.PAYMENT_FAILED: self._handle_payment_failed, + PaymentWebhookType.PAYMENT_PROCESSING: self._handle_payment_processing, + PaymentWebhookType.PAYMENT_CANCELLED: self._handle_payment_cancelled, + PaymentWebhookType.SUBSCRIPTION_ACTIVE: self._handle_subscription_active, + PaymentWebhookType.SUBSCRIPTION_RENEWED: self._handle_subscription_renewed, + PaymentWebhookType.SUBSCRIPTION_CANCELLED: self._handle_subscription_cancelled, + PaymentWebhookType.SUBSCRIPTION_EXPIRED: self._handle_subscription_expired, + PaymentWebhookType.SUBSCRIPTION_FAILED: self._handle_subscription_failed, + PaymentWebhookType.SUBSCRIPTION_ON_HOLD: self._handle_subscription_on_hold, + PaymentWebhookType.SUBSCRIPTION_PLAN_CHANGED: self._handle_subscription_plan_changed, + } + + def verify_signature(self, payload: bytes, signature: str) -> bool: + """Verify Dodo webhook signature.""" + if not self.webhook_secret: + logger.warning("No webhook secret configured") + return True + + try: + if signature.startswith("sha256="): + signature = signature[7:] + + expected = hmac.new( + self.webhook_secret.encode("utf-8"), payload, hashlib.sha256 + ).hexdigest() + + return hmac.compare_digest(signature, expected) + except Exception as e: + logger.error(f"Signature verification failed: {e}") + return False + + async def process_webhook( + self, webhook_data: Dict[str, Any] + ) -> PaymentWebhookResult: + """Process Dodo payment webhook.""" + try: + event = PaymentWebhookEvent(**webhook_data) + await self._store_event(event) + + handler = self.handlers.get(event.type) + if not handler: + return PaymentWebhookResult( + event_type=event.type.value, + status="ignored", + message=f"Handler not found for {event.type}", + ) + + result = await handler(event) + logger.info(f"Webhook processed: {event.type} - {result.status}") + return result + + except Exception as e: + logger.error(f"Webhook processing failed: {e}") + return PaymentWebhookResult( + event_type=webhook_data.get("type", "unknown"), + status="failed", + message=f"Processing error: {str(e)}", + ) + + # Payment event handlers + async def _handle_payment_succeeded( + self, event: PaymentWebhookEvent + ) -> PaymentWebhookResult: + """Handle successful payment.""" + payment_data = event.get_payment_data() + if not payment_data: + raise ValueError("Invalid payment data") + + payment_doc = { + "dodo_payment_id": payment_data.payment_id, + "dodo_subscription_id": payment_data.subscription_id, + "business_id": payment_data.business_id, + "customer_email": payment_data.customer.email, + "customer_id": payment_data.customer.customer_id, + "total_amount": payment_data.total_amount, + "settlement_amount": payment_data.settlement_amount, + "currency": payment_data.currency, + "settlement_currency": payment_data.settlement_currency, + "status": payment_data.status, + "payment_method": payment_data.payment_method, + "created_at": datetime.now(timezone.utc), + "metadata": payment_data.metadata, + } + + await payments_collection.insert_one(payment_doc) + + return PaymentWebhookResult( + event_type=event.type.value, + status="processed", + message="Payment recorded successfully", + payment_id=payment_data.payment_id, + subscription_id=payment_data.subscription_id, + ) + + async def _handle_payment_failed( + self, event: PaymentWebhookEvent + ) -> PaymentWebhookResult: + """Handle failed payment.""" + payment_data = event.get_payment_data() + if not payment_data: + raise ValueError("Invalid payment data") + + payment_doc = { + "dodo_payment_id": payment_data.payment_id, + "dodo_subscription_id": payment_data.subscription_id, + "business_id": payment_data.business_id, + "customer_email": payment_data.customer.email, + "customer_id": payment_data.customer.customer_id, + "total_amount": payment_data.total_amount, + "currency": payment_data.currency, + "status": "failed", + "payment_method": payment_data.payment_method, + "error_code": payment_data.error_code, + "error_message": payment_data.error_message, + "created_at": datetime.now(timezone.utc), + "metadata": payment_data.metadata, + } + + await payments_collection.insert_one(payment_doc) + + if payment_data.subscription_id: + await subscriptions_collection.update_one( + {"dodo_subscription_id": payment_data.subscription_id}, + { + "$set": { + "status": "payment_failed", + "updated_at": datetime.now(timezone.utc), + } + }, + ) + + return PaymentWebhookResult( + event_type=event.type.value, + status="processed", + message="Payment failure recorded", + payment_id=payment_data.payment_id, + subscription_id=payment_data.subscription_id, + ) + + async def _handle_payment_processing( + self, event: PaymentWebhookEvent + ) -> PaymentWebhookResult: + """Handle payment processing status.""" + payment_data = event.get_payment_data() + if not payment_data: + raise ValueError("Invalid payment data") + + return PaymentWebhookResult( + event_type=event.type.value, + status="processed", + message="Payment processing status noted", + payment_id=payment_data.payment_id, + subscription_id=payment_data.subscription_id, + ) + + async def _handle_payment_cancelled( + self, event: PaymentWebhookEvent + ) -> PaymentWebhookResult: + """Handle cancelled payment.""" + payment_data = event.get_payment_data() + if not payment_data: + raise ValueError("Invalid payment data") + + return PaymentWebhookResult( + event_type=event.type.value, + status="processed", + message="Payment cancellation noted", + payment_id=payment_data.payment_id, + subscription_id=payment_data.subscription_id, + ) + + # Subscription event handlers + async def _handle_subscription_active( + self, event: PaymentWebhookEvent + ) -> PaymentWebhookResult: + """Handle subscription becoming active.""" + sub_data = event.get_subscription_data() + if not sub_data: + raise ValueError("Invalid subscription data") + + result = await subscriptions_collection.update_one( + {"dodo_subscription_id": sub_data.subscription_id}, + { + "$set": { + "status": "active", + "updated_at": datetime.now(timezone.utc), + "next_billing_date": sub_data.next_billing_date, + "previous_billing_date": sub_data.previous_billing_date, + } + }, + ) + + if result.modified_count == 0: + logger.warning(f"Subscription not found: {sub_data.subscription_id}") + return PaymentWebhookResult( + event_type=event.type.value, + status="ignored", + message="Subscription not found", + subscription_id=sub_data.subscription_id, + ) + + await self._send_welcome_email(sub_data) + + return PaymentWebhookResult( + event_type=event.type.value, + status="processed", + message="Subscription activated", + subscription_id=sub_data.subscription_id, + ) + + async def _handle_subscription_renewed( + self, event: PaymentWebhookEvent + ) -> PaymentWebhookResult: + """Handle subscription renewal.""" + sub_data = event.get_subscription_data() + if not sub_data: + raise ValueError("Invalid subscription data") + + await subscriptions_collection.update_one( + {"dodo_subscription_id": sub_data.subscription_id}, + { + "$set": { + "status": "active", + "updated_at": datetime.now(timezone.utc), + "next_billing_date": sub_data.next_billing_date, + "previous_billing_date": sub_data.previous_billing_date, + } + }, + ) + + return PaymentWebhookResult( + event_type=event.type.value, + status="processed", + message="Subscription renewed", + subscription_id=sub_data.subscription_id, + ) + + async def _handle_subscription_cancelled( + self, event: PaymentWebhookEvent + ) -> PaymentWebhookResult: + """Handle subscription cancellation.""" + sub_data = event.get_subscription_data() + if not sub_data: + raise ValueError("Invalid subscription data") + + update_data = { + "status": "cancelled", + "updated_at": datetime.now(timezone.utc), + } + + if sub_data.cancelled_at: + update_data["cancelled_at"] = sub_data.cancelled_at + + await subscriptions_collection.update_one( + {"dodo_subscription_id": sub_data.subscription_id}, + {"$set": update_data}, + ) + + return PaymentWebhookResult( + event_type=event.type.value, + status="processed", + message="Subscription cancelled", + subscription_id=sub_data.subscription_id, + ) + + async def _handle_subscription_expired( + self, event: PaymentWebhookEvent + ) -> PaymentWebhookResult: + """Handle subscription expiration.""" + sub_data = event.get_subscription_data() + if not sub_data: + raise ValueError("Invalid subscription data") + + await subscriptions_collection.update_one( + {"dodo_subscription_id": sub_data.subscription_id}, + {"$set": {"status": "expired", "updated_at": datetime.now(timezone.utc)}}, + ) + + return PaymentWebhookResult( + event_type=event.type.value, + status="processed", + message="Subscription expired", + subscription_id=sub_data.subscription_id, + ) + + async def _handle_subscription_failed( + self, event: PaymentWebhookEvent + ) -> PaymentWebhookResult: + """Handle subscription failure.""" + sub_data = event.get_subscription_data() + if not sub_data: + raise ValueError("Invalid subscription data") + + await subscriptions_collection.update_one( + {"dodo_subscription_id": sub_data.subscription_id}, + {"$set": {"status": "failed", "updated_at": datetime.now(timezone.utc)}}, + ) + + return PaymentWebhookResult( + event_type=event.type.value, + status="processed", + message="Subscription failed", + subscription_id=sub_data.subscription_id, + ) + + async def _handle_subscription_on_hold( + self, event: PaymentWebhookEvent + ) -> PaymentWebhookResult: + """Handle subscription on hold.""" + sub_data = event.get_subscription_data() + if not sub_data: + raise ValueError("Invalid subscription data") + + await subscriptions_collection.update_one( + {"dodo_subscription_id": sub_data.subscription_id}, + {"$set": {"status": "on_hold", "updated_at": datetime.now(timezone.utc)}}, + ) + + return PaymentWebhookResult( + event_type=event.type.value, + status="processed", + message="Subscription on hold", + subscription_id=sub_data.subscription_id, + ) + + async def _handle_subscription_plan_changed( + self, event: PaymentWebhookEvent + ) -> PaymentWebhookResult: + """Handle subscription plan change.""" + sub_data = event.get_subscription_data() + if not sub_data: + raise ValueError("Invalid subscription data") + + await subscriptions_collection.update_one( + {"dodo_subscription_id": sub_data.subscription_id}, + { + "$set": { + "product_id": sub_data.product_id, + "quantity": sub_data.quantity, + "recurring_pre_tax_amount": sub_data.recurring_pre_tax_amount, + "updated_at": datetime.now(timezone.utc), + } + }, + ) + + return PaymentWebhookResult( + event_type=event.type.value, + status="processed", + message="Subscription plan changed", + subscription_id=sub_data.subscription_id, + ) + + # Helper methods + async def _send_welcome_email(self, sub_data) -> None: + """Send welcome email for new subscription.""" + try: + subscription = await subscriptions_collection.find_one( + {"dodo_subscription_id": sub_data.subscription_id} + ) + + if not subscription: + logger.warning( + f"Cannot send welcome email - subscription not found: {sub_data.subscription_id}" + ) + return + + user = await users_collection.find_one( + {"_id": ObjectId(subscription["user_id"])} + ) + + if not user or not user.get("email"): + logger.warning( + f"Cannot send welcome email - user not found: {subscription.get('user_id')}" + ) + return + + await send_pro_subscription_email( + user_name=user.get("first_name", "User"), user_email=user["email"] + ) + + except Exception as e: + logger.error(f"Failed to send welcome email: {e}") + + async def _store_event(self, event: PaymentWebhookEvent) -> None: + """Store webhook event for audit.""" + try: + event_doc = { + "business_id": event.business_id, + "event_type": event.type.value, + "timestamp": event.timestamp, + "data": event.data, + "processed_at": datetime.now(timezone.utc), + } + + await webhook_events_collection.insert_one(event_doc) + + except Exception as e: + logger.error(f"Failed to store webhook event: {e}") + + +payment_webhook_service = PaymentWebhookService() From 8bc35492372a41ab0fa69825b4d3abe253e6bf86 Mon Sep 17 00:00:00 2001 From: Dhruv Maradiya Date: Thu, 7 Aug 2025 23:50:12 +0530 Subject: [PATCH 15/72] feat: enhance error handling for integration toasts to prevent duplicates --- .../dependencies/google_scope_dependencies.py | 11 +++++---- frontend/src/utils/interceptorUtils.ts | 23 ++++++++++++++++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/backend/app/api/v1/dependencies/google_scope_dependencies.py b/backend/app/api/v1/dependencies/google_scope_dependencies.py index 9c3165d2..bf041596 100644 --- a/backend/app/api/v1/dependencies/google_scope_dependencies.py +++ b/backend/app/api/v1/dependencies/google_scope_dependencies.py @@ -40,10 +40,13 @@ async def wrapper(user: dict = Depends(get_current_user)): ) try: - token = await token_repository.get_token( - user_id, "google", renew_if_expired=True - ) - authorized_scopes = str(token.get("scope", "")).split() + try: + token = await token_repository.get_token( + user_id, "google", renew_if_expired=True + ) + authorized_scopes = str(token.get("scope", "")).split() + except HTTPException: + authorized_scopes = [] # Handle both single scope and list of scopes required_scopes = [scope] if isinstance(scope, str) else scope diff --git a/frontend/src/utils/interceptorUtils.ts b/frontend/src/utils/interceptorUtils.ts index edcf02d4..d65c687b 100644 --- a/frontend/src/utils/interceptorUtils.ts +++ b/frontend/src/utils/interceptorUtils.ts @@ -14,6 +14,9 @@ interface ErrorHandlerDependencies { router: AppRouterInstance; } +// Track active integration toasts to prevent duplicates +const activeIntegrationToasts = new Set(); + // Constants const LANDING_ROUTES = [ "/", @@ -97,6 +100,16 @@ const handleForbiddenError = ( detail.type === "integration" ) { const integrationDetail = detail as { type: string; message?: string }; + const toastKey = `integration-${integrationDetail.type || "default"}`; + + // Check if toast for this integration is already active + if (activeIntegrationToasts.has(toastKey)) { + return; + } + + // Add to active toasts set + activeIntegrationToasts.add(toastKey); + toast.error(integrationDetail.message || "Integration required.", { duration: Infinity, classNames: { @@ -104,7 +117,15 @@ const handleForbiddenError = ( }, action: { label: "Connect", - onClick: () => router.push("/settings?section=integrations"), + onClick: () => { + // Clear from active toasts when action is clicked + activeIntegrationToasts.delete(toastKey); + router.push("/settings?section=integrations"); + }, + }, + onDismiss: () => { + // Clear from active toasts when dismissed + activeIntegrationToasts.delete(toastKey); }, }); } else { From 5101fda154a0f91a801ac9a8c633853a589487e7 Mon Sep 17 00:00:00 2001 From: Dhruv Maradiya Date: Fri, 8 Aug 2025 00:24:26 +0530 Subject: [PATCH 16/72] feat: update Navbar to display chat icon and link based on user authentication --- frontend/src/components/navigation/Navbar.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/navigation/Navbar.tsx b/frontend/src/components/navigation/Navbar.tsx index b9bc43e3..eb26567a 100644 --- a/frontend/src/components/navigation/Navbar.tsx +++ b/frontend/src/components/navigation/Navbar.tsx @@ -15,6 +15,8 @@ import useMediaQuery from "@/hooks/ui/useMediaQuery"; import { NavbarMenu } from "./NavbarMenu"; import { RainbowGithubButton } from "./RainbowGithubButton"; +import { useUser } from "@/features/auth/hooks/useUser"; +import { BubbleConversationChatIcon } from "../shared"; export default function Navbar() { const pathname = usePathname(); @@ -22,6 +24,8 @@ export default function Navbar() { const [activeDropdown, setActiveDropdown] = useState(null); const [hoveredItem, setHoveredItem] = useState(null); + const user = useUser(); + // Function to control backdrop blur const toggleBackdrop = (show: boolean) => { const backdrop = document.getElementById("navbar-backdrop"); @@ -158,9 +162,16 @@ export default function Navbar() { size="sm" className="h-9 max-h-9 min-h-9 rounded-xl bg-primary px-4! text-sm font-medium text-black transition-all! hover:scale-105 hover:bg-primary!" as={Link} - href="/signup" + href={user.email ? "/c" : "/signup"} > - Get Started + {user.email && ( + + )} + {user.email ? "Chat" : "Get Started"}
    )} From 1f84ef839b9ee7c532cf12adc848b17d7cb133fd Mon Sep 17 00:00:00 2001 From: Aryan Date: Fri, 8 Aug 2025 00:34:46 +0530 Subject: [PATCH 17/72] Refactor Dodo Payments integration: - Simplified webhook handling in payments.py, removing signature verification and improving error handling. - Reorganized MongoDB collections in collections.py for clarity. - Updated indexes in indexes.py to optimize payment and subscription queries. - Enhanced webhook models in webhook_models.py for better structure and naming consistency. - Streamlined payment_service.py to focus on payment link generation without immediate database storage. - Consolidated payment webhook logic into payment_webhook_service.py, removing redundant code and improving event handling. - Deleted outdated webhook_service.py to eliminate duplication and maintain a single source of truth for webhook processing. --- backend/app/api/v1/router/payments.py | 40 +- backend/app/db/mongodb/collections.py | 7 +- backend/app/db/mongodb/indexes.py | 18 +- backend/app/models/webhook_models.py | 59 ++- backend/app/services/payment_service.py | 68 +-- .../app/services/payment_webhook_service.py | 327 +++++-------- backend/app/services/webhook_service.py | 436 ------------------ 7 files changed, 205 insertions(+), 750 deletions(-) delete mode 100644 backend/app/services/webhook_service.py diff --git a/backend/app/api/v1/router/payments.py b/backend/app/api/v1/router/payments.py index e4c4f4ce..543e71b1 100644 --- a/backend/app/api/v1/router/payments.py +++ b/backend/app/api/v1/router/payments.py @@ -3,9 +3,10 @@ Single service approach - simple and maintainable. """ +import json from typing import List -from fastapi import APIRouter, Depends, HTTPException, Request, Header +from fastapi import APIRouter, Depends, HTTPException, Request from app.api.v1.dependencies.oauth_dependencies import get_current_user from app.config.loggers import general_logger as logger @@ -76,36 +77,16 @@ async def get_subscription_status_endpoint( return await payment_service.get_user_subscription_status(user_id) -@router.post("/webhooks/dodo", response_model=dict) -async def handle_dodo_webhook( - request: Request, - webhook_data: dict, - x_signature: str = Header(None, alias="X-Signature"), -): - """ - Handle incoming webhooks from Dodo Payments. - - Security: Verifies webhook signature to ensure authenticity. - Events: Processes payment.succeeded, subscription.active, etc. - """ +@router.post("/webhooks/dodo") +async def handle_dodo_webhook(request: Request): + """Handle incoming webhooks from Dodo Payments.""" try: - # Get raw body for signature verification body = await request.body() + webhook_data = json.loads(body.decode("utf-8")) - # Verify webhook signature for security - if x_signature and not payment_webhook_service.verify_webhook_signature( - body, x_signature - ): - logger.warning("Invalid webhook signature") - raise HTTPException(status_code=401, detail="Invalid webhook signature") - - # Process the webhook result = await payment_webhook_service.process_webhook(webhook_data) - logger.info( - f"Webhook processed successfully: {result.event_type} - {result.status}" - ) - + logger.info(f"Webhook processed: {result.event_type} - {result.status}") return { "status": "success", "event_type": result.event_type, @@ -113,8 +94,9 @@ async def handle_dodo_webhook( "message": result.message, } - except HTTPException: - raise + except json.JSONDecodeError: + logger.error("Invalid JSON in webhook payload") + raise HTTPException(status_code=400, detail="Invalid JSON payload") except Exception as e: - logger.error(f"Unexpected error processing webhook: {e}") + logger.error(f"Error processing webhook: {e}") raise HTTPException(status_code=500, detail="Webhook processing failed") diff --git a/backend/app/db/mongodb/collections.py b/backend/app/db/mongodb/collections.py index 4c8eab47..9b3aea77 100644 --- a/backend/app/db/mongodb/collections.py +++ b/backend/app/db/mongodb/collections.py @@ -24,11 +24,10 @@ reminders_collection = mongodb_instance.get_collection("reminders") support_collection = mongodb_instance.get_collection("support_requests") -# Payment collections -plans_collection = mongodb_instance.get_collection("subscription_plans") -subscriptions_collection = mongodb_instance.get_collection("subscriptions") +# Payment-related collections payments_collection = mongodb_instance.get_collection("payments") -webhook_events_collection = mongodb_instance.get_collection("webhook_events") +subscriptions_collection = mongodb_instance.get_collection("subscriptions") +plans_collection = mongodb_instance.get_collection("subscription_plans") # Usage usage_snapshots_collection = mongodb_instance.get_collection("usage_snapshots") diff --git a/backend/app/db/mongodb/indexes.py b/backend/app/db/mongodb/indexes.py index 3cd12b55..dd4a9733 100644 --- a/backend/app/db/mongodb/indexes.py +++ b/backend/app/db/mongodb/indexes.py @@ -31,7 +31,6 @@ todos_collection, usage_snapshots_collection, users_collection, - webhook_events_collection, ) @@ -424,21 +423,24 @@ async def create_payment_indexes(): try: # Create payment collection indexes await asyncio.gather( - # Payment indexes - payments_collection.create_index("user_id"), + # Payment indexes - for successful payments only + payments_collection.create_index("dodo_payment_id", unique=True), payments_collection.create_index("dodo_subscription_id", sparse=True), + payments_collection.create_index("customer_email"), payments_collection.create_index("status"), - payments_collection.create_index([("user_id", 1), ("created_at", -1)]), - payments_collection.create_index("webhook_verified"), - # Subscription indexes - updated for Dodo schema + payments_collection.create_index( + [("customer_email", 1), ("created_at", -1)] + ), + payments_collection.create_index("webhook_processed_at", sparse=True), + # Subscription indexes - for active subscriptions only subscriptions_collection.create_index("user_id"), subscriptions_collection.create_index("dodo_subscription_id", unique=True), subscriptions_collection.create_index("product_id"), subscriptions_collection.create_index("status"), subscriptions_collection.create_index([("user_id", 1), ("status", 1)]), subscriptions_collection.create_index([("user_id", 1), ("created_at", -1)]), - subscriptions_collection.create_index("webhook_verified"), - # Plans indexes - updated for subscription_plans collection + subscriptions_collection.create_index("webhook_processed_at", sparse=True), + # Plans indexes plans_collection.create_index("is_active"), plans_collection.create_index("dodo_product_id", sparse=True), plans_collection.create_index([("is_active", 1), ("amount", 1)]), diff --git a/backend/app/models/webhook_models.py b/backend/app/models/webhook_models.py index bf43b7bf..87651f63 100644 --- a/backend/app/models/webhook_models.py +++ b/backend/app/models/webhook_models.py @@ -1,17 +1,15 @@ """ -Payment webhook models for Dodo Payments integration. -Clean models based on actual Dodo webhook format. +Clean webhook models for Dodo Payments based on actual webhook format. """ -from datetime import datetime from enum import Enum -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from pydantic import BaseModel, Field -class PaymentWebhookType(str, Enum): - """Payment webhook event types from Dodo Payments.""" +class WebhookEventType(str, Enum): + """Dodo Payments webhook event types.""" # Payment events PAYMENT_SUCCEEDED = "payment.succeeded" @@ -29,16 +27,16 @@ class PaymentWebhookType(str, Enum): SUBSCRIPTION_PLAN_CHANGED = "subscription.plan_changed" -class DodoCustomer(BaseModel): - """Customer data from Dodo webhook.""" +class CustomerData(BaseModel): + """Customer info from webhook.""" customer_id: str email: str name: str -class DodoBilling(BaseModel): - """Billing address from Dodo webhook.""" +class BillingData(BaseModel): + """Billing address from webhook.""" city: str country: str @@ -47,15 +45,15 @@ class DodoBilling(BaseModel): zipcode: str -class DodoPaymentData(BaseModel): - """Payment data from Dodo payment webhook.""" +class PaymentData(BaseModel): + """Payment data from payment webhook.""" payment_id: str subscription_id: Optional[str] = None business_id: str brand_id: str - customer: DodoCustomer - billing: DodoBilling + customer: CustomerData + billing: BillingData currency: str total_amount: int settlement_amount: int @@ -75,13 +73,13 @@ class DodoPaymentData(BaseModel): error_message: Optional[str] = None -class DodoSubscriptionData(BaseModel): - """Subscription data from Dodo subscription webhook.""" +class SubscriptionData(BaseModel): + """Subscription data from subscription webhook.""" subscription_id: str product_id: str - customer: DodoCustomer - billing: DodoBilling + customer: CustomerData + billing: BillingData status: str currency: str quantity: int @@ -99,43 +97,42 @@ class DodoSubscriptionData(BaseModel): trial_period_days: int = 0 on_demand: bool = False metadata: Dict[str, Any] = Field(default_factory=dict) - addons: list = Field(default_factory=list) + addons: List[Any] = Field(default_factory=list) discount_id: Optional[str] = None -class PaymentWebhookEvent(BaseModel): - """Complete payment webhook event from Dodo Payments.""" +class DodoWebhookEvent(BaseModel): + """Dodo webhook event structure.""" business_id: str - type: PaymentWebhookType + type: WebhookEventType timestamp: str data: Dict[str, Any] - def get_payment_data(self) -> Optional[DodoPaymentData]: - """Extract payment data if this is a payment event.""" + def get_payment_data(self) -> Optional[PaymentData]: + """Extract payment data if payment event.""" if self.type.value.startswith("payment."): try: - return DodoPaymentData(**self.data) + return PaymentData(**self.data) except Exception: return None return None - def get_subscription_data(self) -> Optional[DodoSubscriptionData]: - """Extract subscription data if this is a subscription event.""" + def get_subscription_data(self) -> Optional[SubscriptionData]: + """Extract subscription data if subscription event.""" if self.type.value.startswith("subscription."): try: - return DodoSubscriptionData(**self.data) + return SubscriptionData(**self.data) except Exception: return None return None -class PaymentWebhookResult(BaseModel): - """Result of payment webhook processing.""" +class WebhookProcessingResult(BaseModel): + """Result of webhook processing.""" event_type: str status: str # "processed", "ignored", "failed" message: str payment_id: Optional[str] = None subscription_id: Optional[str] = None - processed_at: datetime = Field(default_factory=lambda: datetime.now()) diff --git a/backend/app/services/payment_service.py b/backend/app/services/payment_service.py index b8c73b56..9f287913 100644 --- a/backend/app/services/payment_service.py +++ b/backend/app/services/payment_service.py @@ -3,7 +3,6 @@ Clean, simple, and maintainable. """ -from datetime import datetime, timezone from typing import Any, Dict, List from bson import ObjectId @@ -90,20 +89,20 @@ async def get_plans(self, active_only: bool = True) -> List[PlanResponse]: async def create_subscription( self, user_id: str, product_id: str, quantity: int = 1 ) -> Dict[str, Any]: - """Create subscription - backend handles all security.""" + """Create subscription - only get payment link, store data after webhook.""" # Get user user = await users_collection.find_one({"_id": ObjectId(user_id)}) if not user: raise HTTPException(404, "User not found") - # Check for existing subscription + # Check for existing active subscription existing = await subscriptions_collection.find_one( - {"user_id": user_id, "status": {"$in": ["pending", "active"]}} + {"user_id": user_id, "status": "active"} ) if existing: raise HTTPException(409, "Active subscription exists") - # Create with Dodo + # Create with Dodo - get payment link only try: subscription = self.client.subscriptions.create( billing={ @@ -122,62 +121,45 @@ async def create_subscription( quantity=quantity, payment_link=True, return_url=f"{settings.FRONTEND_URL}/payment/success", + metadata={"user_id": user_id, "product_id": product_id}, ) except Exception as e: raise HTTPException(502, f"Payment service error: {str(e)}") - # Store in database - create dict directly for insertion - subscription_doc = { - "dodo_subscription_id": subscription.subscription_id, - "user_id": user_id, - "product_id": product_id, - "status": "pending", - "quantity": quantity, - "payment_link": getattr(subscription, "payment_link", None), - "created_at": datetime.now(timezone.utc), - "updated_at": datetime.now(timezone.utc), - "metadata": {"user_email": user.get("email")}, - } - - await subscriptions_collection.insert_one(subscription_doc) - + # Return payment link without storing in database return { "subscription_id": subscription.subscription_id, "payment_link": getattr(subscription, "payment_link", None), - "status": "pending", + "status": "payment_link_created", } async def verify_payment_completion(self, user_id: str) -> Dict[str, Any]: - """Check payment completion status.""" + """Check payment completion status from webhook data.""" subscription = await subscriptions_collection.find_one( - {"user_id": user_id}, sort=[("created_at", -1)] + {"user_id": user_id, "status": "active"}, sort=[("created_at", -1)] ) if not subscription: - return {"payment_completed": False, "message": "No subscription found"} - - if subscription["status"] == "active": - # Send welcome email (don't fail if email fails) - try: - user = await users_collection.find_one({"_id": ObjectId(user_id)}) - if user and user.get("email"): - await send_pro_subscription_email( - user_name=user.get("first_name", "User"), - user_email=user["email"], - ) - except Exception: - pass # Email failure shouldn't break payment verification - return { - "payment_completed": True, - "subscription_id": subscription["dodo_subscription_id"], - "message": "Payment completed", + "payment_completed": False, + "message": "No active subscription found", } + # Send welcome email (don't fail if email fails) + try: + user = await users_collection.find_one({"_id": ObjectId(user_id)}) + if user and user.get("email"): + await send_pro_subscription_email( + user_name=user.get("first_name", "User"), + user_email=user["email"], + ) + except Exception: + pass # Email failure shouldn't break payment verification + return { - "payment_completed": False, - "subscription_id": subscription.get("dodo_subscription_id"), - "message": "Payment pending", + "payment_completed": True, + "subscription_id": subscription["dodo_subscription_id"], + "message": "Payment completed", } async def get_user_subscription_status( diff --git a/backend/app/services/payment_webhook_service.py b/backend/app/services/payment_webhook_service.py index 3a9d4c6e..786201b3 100644 --- a/backend/app/services/payment_webhook_service.py +++ b/backend/app/services/payment_webhook_service.py @@ -1,27 +1,22 @@ """ -Payment webhook service for Dodo Payments integration. -Clean implementation following Dodo webhook format and best practices. +Clean payment webhook service for Dodo Payments integration. +Handles webhook events and updates database state accordingly. """ -import hashlib -import hmac from datetime import datetime, timezone from typing import Any, Dict from bson import ObjectId from app.config.loggers import general_logger as logger -from app.config.settings import settings from app.db.mongodb.collections import ( - payments_collection, subscriptions_collection, users_collection, - webhook_events_collection, ) from app.models.webhook_models import ( - PaymentWebhookEvent, - PaymentWebhookType, - PaymentWebhookResult, + DodoWebhookEvent, + WebhookEventType, + WebhookProcessingResult, ) from app.utils.email_utils import send_pro_subscription_email @@ -30,54 +25,33 @@ class PaymentWebhookService: """Clean service for handling Dodo payment webhooks.""" def __init__(self): - self.webhook_secret = settings.DODO_WEBHOOK_SECRET self.handlers = { - PaymentWebhookType.PAYMENT_SUCCEEDED: self._handle_payment_succeeded, - PaymentWebhookType.PAYMENT_FAILED: self._handle_payment_failed, - PaymentWebhookType.PAYMENT_PROCESSING: self._handle_payment_processing, - PaymentWebhookType.PAYMENT_CANCELLED: self._handle_payment_cancelled, - PaymentWebhookType.SUBSCRIPTION_ACTIVE: self._handle_subscription_active, - PaymentWebhookType.SUBSCRIPTION_RENEWED: self._handle_subscription_renewed, - PaymentWebhookType.SUBSCRIPTION_CANCELLED: self._handle_subscription_cancelled, - PaymentWebhookType.SUBSCRIPTION_EXPIRED: self._handle_subscription_expired, - PaymentWebhookType.SUBSCRIPTION_FAILED: self._handle_subscription_failed, - PaymentWebhookType.SUBSCRIPTION_ON_HOLD: self._handle_subscription_on_hold, - PaymentWebhookType.SUBSCRIPTION_PLAN_CHANGED: self._handle_subscription_plan_changed, + WebhookEventType.PAYMENT_SUCCEEDED: self._handle_payment_succeeded, + WebhookEventType.PAYMENT_FAILED: self._handle_payment_failed, + WebhookEventType.PAYMENT_PROCESSING: self._handle_payment_processing, + WebhookEventType.PAYMENT_CANCELLED: self._handle_payment_cancelled, + WebhookEventType.SUBSCRIPTION_ACTIVE: self._handle_subscription_active, + WebhookEventType.SUBSCRIPTION_RENEWED: self._handle_subscription_renewed, + WebhookEventType.SUBSCRIPTION_CANCELLED: self._handle_subscription_cancelled, + WebhookEventType.SUBSCRIPTION_EXPIRED: self._handle_subscription_expired, + WebhookEventType.SUBSCRIPTION_FAILED: self._handle_subscription_failed, + WebhookEventType.SUBSCRIPTION_ON_HOLD: self._handle_subscription_on_hold, + WebhookEventType.SUBSCRIPTION_PLAN_CHANGED: self._handle_subscription_plan_changed, } - def verify_webhook_signature(self, payload: bytes, signature: str) -> bool: - """Verify Dodo webhook signature.""" - if not self.webhook_secret: - logger.warning("No webhook secret configured") - return True - - try: - if signature.startswith("sha256="): - signature = signature[7:] - - expected = hmac.new( - self.webhook_secret.encode("utf-8"), payload, hashlib.sha256 - ).hexdigest() - - return hmac.compare_digest(signature, expected) - except Exception as e: - logger.error(f"Signature verification failed: {e}") - return False - async def process_webhook( self, webhook_data: Dict[str, Any] - ) -> PaymentWebhookResult: + ) -> WebhookProcessingResult: """Process Dodo payment webhook.""" try: - event = PaymentWebhookEvent(**webhook_data) - await self._store_event(event) + event = DodoWebhookEvent(**webhook_data) handler = self.handlers.get(event.type) if not handler: - return PaymentWebhookResult( + return WebhookProcessingResult( event_type=event.type.value, status="ignored", - message=f"Handler not found for {event.type}", + message=f"No handler for {event.type}", ) result = await handler(event) @@ -86,7 +60,7 @@ async def process_webhook( except Exception as e: logger.error(f"Webhook processing failed: {e}") - return PaymentWebhookResult( + return WebhookProcessingResult( event_type=webhook_data.get("type", "unknown"), status="failed", message=f"Processing error: {str(e)}", @@ -94,112 +68,66 @@ async def process_webhook( # Payment event handlers async def _handle_payment_succeeded( - self, event: PaymentWebhookEvent - ) -> PaymentWebhookResult: - """Handle successful payment.""" + self, event: DodoWebhookEvent + ) -> WebhookProcessingResult: + """Handle successful payment - just log, subscription activation handles the rest.""" payment_data = event.get_payment_data() if not payment_data: raise ValueError("Invalid payment data") - payment_doc = { - "dodo_payment_id": payment_data.payment_id, - "dodo_subscription_id": payment_data.subscription_id, - "business_id": payment_data.business_id, - "customer_email": payment_data.customer.email, - "customer_id": payment_data.customer.customer_id, - "total_amount": payment_data.total_amount, - "settlement_amount": payment_data.settlement_amount, - "currency": payment_data.currency, - "settlement_currency": payment_data.settlement_currency, - "status": payment_data.status, - "payment_method": payment_data.payment_method, - "created_at": datetime.now(timezone.utc), - "webhook_processed_at": datetime.now(timezone.utc), - "metadata": payment_data.metadata, - } - - await payments_collection.insert_one(payment_doc) + logger.info(f"Payment succeeded: {payment_data.payment_id}") - return PaymentWebhookResult( + return WebhookProcessingResult( event_type=event.type.value, status="processed", - message="Payment recorded successfully", + message="Payment success logged", payment_id=payment_data.payment_id, subscription_id=payment_data.subscription_id, ) async def _handle_payment_failed( - self, event: PaymentWebhookEvent - ) -> PaymentWebhookResult: + self, event: DodoWebhookEvent + ) -> WebhookProcessingResult: """Handle failed payment.""" payment_data = event.get_payment_data() if not payment_data: raise ValueError("Invalid payment data") - payment_doc = { - "dodo_payment_id": payment_data.payment_id, - "dodo_subscription_id": payment_data.subscription_id, - "business_id": payment_data.business_id, - "customer_email": payment_data.customer.email, - "customer_id": payment_data.customer.customer_id, - "total_amount": payment_data.total_amount, - "currency": payment_data.currency, - "status": "failed", - "payment_method": payment_data.payment_method, - "error_code": payment_data.error_code, - "error_message": payment_data.error_message, - "created_at": datetime.now(timezone.utc), - "webhook_processed_at": datetime.now(timezone.utc), - "metadata": payment_data.metadata, - } - - await payments_collection.insert_one(payment_doc) - - if payment_data.subscription_id: - await subscriptions_collection.update_one( - {"dodo_subscription_id": payment_data.subscription_id}, - { - "$set": { - "status": "payment_failed", - "updated_at": datetime.now(timezone.utc), - "webhook_processed_at": datetime.now(timezone.utc), - } - }, - ) + logger.warning(f"Payment failed: {payment_data.payment_id}") - return PaymentWebhookResult( + return WebhookProcessingResult( event_type=event.type.value, status="processed", - message="Payment failure recorded", + message="Payment failure logged", payment_id=payment_data.payment_id, subscription_id=payment_data.subscription_id, ) async def _handle_payment_processing( - self, event: PaymentWebhookEvent - ) -> PaymentWebhookResult: + self, event: DodoWebhookEvent + ) -> WebhookProcessingResult: """Handle payment processing status.""" payment_data = event.get_payment_data() if not payment_data: raise ValueError("Invalid payment data") - return PaymentWebhookResult( + return WebhookProcessingResult( event_type=event.type.value, status="processed", - message="Payment processing status noted", + message="Payment processing noted", payment_id=payment_data.payment_id, subscription_id=payment_data.subscription_id, ) async def _handle_payment_cancelled( - self, event: PaymentWebhookEvent - ) -> PaymentWebhookResult: + self, event: DodoWebhookEvent + ) -> WebhookProcessingResult: """Handle cancelled payment.""" payment_data = event.get_payment_data() if not payment_data: raise ValueError("Invalid payment data") - return PaymentWebhookResult( + return WebhookProcessingResult( event_type=event.type.value, status="processed", message="Payment cancellation noted", @@ -209,38 +137,72 @@ async def _handle_payment_cancelled( # Subscription event handlers async def _handle_subscription_active( - self, event: PaymentWebhookEvent - ) -> PaymentWebhookResult: - """Handle subscription becoming active.""" + self, event: DodoWebhookEvent + ) -> WebhookProcessingResult: + """Handle subscription activation - CREATE subscription record here.""" sub_data = event.get_subscription_data() if not sub_data: raise ValueError("Invalid subscription data") - result = await subscriptions_collection.update_one( - {"dodo_subscription_id": sub_data.subscription_id}, - { - "$set": { - "status": "active", - "updated_at": datetime.now(timezone.utc), - "webhook_processed_at": datetime.now(timezone.utc), - "next_billing_date": sub_data.next_billing_date, - "previous_billing_date": sub_data.previous_billing_date, - } - }, + # Check if subscription already exists + existing = await subscriptions_collection.find_one( + {"dodo_subscription_id": sub_data.subscription_id} ) - if result.modified_count == 0: - logger.warning(f"Subscription not found: {sub_data.subscription_id}") - return PaymentWebhookResult( + if existing: + logger.info(f"Subscription already exists: {sub_data.subscription_id}") + return WebhookProcessingResult( event_type=event.type.value, - status="ignored", - message="Subscription not found", + status="processed", + message="Subscription already active", subscription_id=sub_data.subscription_id, ) - await self._send_welcome_email(sub_data) + # Find user by email or metadata + user_id = sub_data.metadata.get("user_id") + if not user_id: + user = await users_collection.find_one({"email": sub_data.customer.email}) + if not user: + logger.error( + f"User not found for subscription: {sub_data.subscription_id}" + ) + return WebhookProcessingResult( + event_type=event.type.value, + status="failed", + message="User not found", + subscription_id=sub_data.subscription_id, + ) + user_id = str(user["_id"]) + + # Create subscription record + subscription_doc = { + "dodo_subscription_id": sub_data.subscription_id, + "user_id": user_id, + "product_id": sub_data.product_id, + "status": "active", + "quantity": sub_data.quantity, + "currency": sub_data.currency, + "recurring_pre_tax_amount": sub_data.recurring_pre_tax_amount, + "payment_frequency_count": sub_data.payment_frequency_count, + "payment_frequency_interval": sub_data.payment_frequency_interval, + "subscription_period_count": sub_data.subscription_period_count, + "subscription_period_interval": sub_data.subscription_period_interval, + "next_billing_date": sub_data.next_billing_date, + "previous_billing_date": sub_data.previous_billing_date, + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + "metadata": sub_data.metadata, + } + + result = await subscriptions_collection.insert_one(subscription_doc) + if not result.inserted_id: + raise Exception("Failed to create subscription record") - return PaymentWebhookResult( + # Send welcome email + await self._send_welcome_email(user_id) + + logger.info(f"Subscription activated: {sub_data.subscription_id}") + return WebhookProcessingResult( event_type=event.type.value, status="processed", message="Subscription activated", @@ -248,27 +210,32 @@ async def _handle_subscription_active( ) async def _handle_subscription_renewed( - self, event: PaymentWebhookEvent - ) -> PaymentWebhookResult: + self, event: DodoWebhookEvent + ) -> WebhookProcessingResult: """Handle subscription renewal.""" sub_data = event.get_subscription_data() if not sub_data: raise ValueError("Invalid subscription data") - await subscriptions_collection.update_one( + # Update subscription billing dates + result = await subscriptions_collection.update_one( {"dodo_subscription_id": sub_data.subscription_id}, { "$set": { "status": "active", - "updated_at": datetime.now(timezone.utc), - "webhook_processed_at": datetime.now(timezone.utc), "next_billing_date": sub_data.next_billing_date, "previous_billing_date": sub_data.previous_billing_date, + "updated_at": datetime.now(timezone.utc), } }, ) - return PaymentWebhookResult( + if result.matched_count == 0: + logger.warning( + f"Subscription not found for renewal: {sub_data.subscription_id}" + ) + + return WebhookProcessingResult( event_type=event.type.value, status="processed", message="Subscription renewed", @@ -276,8 +243,8 @@ async def _handle_subscription_renewed( ) async def _handle_subscription_cancelled( - self, event: PaymentWebhookEvent - ) -> PaymentWebhookResult: + self, event: DodoWebhookEvent + ) -> WebhookProcessingResult: """Handle subscription cancellation.""" sub_data = event.get_subscription_data() if not sub_data: @@ -286,7 +253,6 @@ async def _handle_subscription_cancelled( update_data = { "status": "cancelled", "updated_at": datetime.now(timezone.utc), - "webhook_processed_at": datetime.now(timezone.utc), } if sub_data.cancelled_at: @@ -297,7 +263,7 @@ async def _handle_subscription_cancelled( {"$set": update_data}, ) - return PaymentWebhookResult( + return WebhookProcessingResult( event_type=event.type.value, status="processed", message="Subscription cancelled", @@ -305,8 +271,8 @@ async def _handle_subscription_cancelled( ) async def _handle_subscription_expired( - self, event: PaymentWebhookEvent - ) -> PaymentWebhookResult: + self, event: DodoWebhookEvent + ) -> WebhookProcessingResult: """Handle subscription expiration.""" sub_data = event.get_subscription_data() if not sub_data: @@ -318,12 +284,11 @@ async def _handle_subscription_expired( "$set": { "status": "expired", "updated_at": datetime.now(timezone.utc), - "webhook_processed_at": datetime.now(timezone.utc), } }, ) - return PaymentWebhookResult( + return WebhookProcessingResult( event_type=event.type.value, status="processed", message="Subscription expired", @@ -331,8 +296,8 @@ async def _handle_subscription_expired( ) async def _handle_subscription_failed( - self, event: PaymentWebhookEvent - ) -> PaymentWebhookResult: + self, event: DodoWebhookEvent + ) -> WebhookProcessingResult: """Handle subscription failure.""" sub_data = event.get_subscription_data() if not sub_data: @@ -344,12 +309,11 @@ async def _handle_subscription_failed( "$set": { "status": "failed", "updated_at": datetime.now(timezone.utc), - "webhook_processed_at": datetime.now(timezone.utc), } }, ) - return PaymentWebhookResult( + return WebhookProcessingResult( event_type=event.type.value, status="processed", message="Subscription failed", @@ -357,8 +321,8 @@ async def _handle_subscription_failed( ) async def _handle_subscription_on_hold( - self, event: PaymentWebhookEvent - ) -> PaymentWebhookResult: + self, event: DodoWebhookEvent + ) -> WebhookProcessingResult: """Handle subscription on hold.""" sub_data = event.get_subscription_data() if not sub_data: @@ -370,12 +334,11 @@ async def _handle_subscription_on_hold( "$set": { "status": "on_hold", "updated_at": datetime.now(timezone.utc), - "webhook_processed_at": datetime.now(timezone.utc), } }, ) - return PaymentWebhookResult( + return WebhookProcessingResult( event_type=event.type.value, status="processed", message="Subscription on hold", @@ -383,8 +346,8 @@ async def _handle_subscription_on_hold( ) async def _handle_subscription_plan_changed( - self, event: PaymentWebhookEvent - ) -> PaymentWebhookResult: + self, event: DodoWebhookEvent + ) -> WebhookProcessingResult: """Handle subscription plan change.""" sub_data = event.get_subscription_data() if not sub_data: @@ -398,64 +361,30 @@ async def _handle_subscription_plan_changed( "quantity": sub_data.quantity, "recurring_pre_tax_amount": sub_data.recurring_pre_tax_amount, "updated_at": datetime.now(timezone.utc), - "webhook_processed_at": datetime.now(timezone.utc), } }, ) - return PaymentWebhookResult( + return WebhookProcessingResult( event_type=event.type.value, status="processed", message="Subscription plan changed", subscription_id=sub_data.subscription_id, ) - # Helper methods - async def _send_welcome_email(self, sub_data) -> None: + async def _send_welcome_email(self, user_id: str) -> None: """Send welcome email for new subscription.""" try: - subscription = await subscriptions_collection.find_one( - {"dodo_subscription_id": sub_data.subscription_id} - ) - - if not subscription: - logger.warning( - f"Cannot send welcome email - subscription not found: {sub_data.subscription_id}" + user = await users_collection.find_one({"_id": ObjectId(user_id)}) + if user and user.get("email"): + await send_pro_subscription_email( + user_name=user.get("first_name", "User"), + user_email=user["email"], ) - return - - user = await users_collection.find_one( - {"_id": ObjectId(subscription["user_id"])} - ) - - if not user or not user.get("email"): - logger.warning( - f"Cannot send welcome email - user not found: {subscription.get('user_id')}" - ) - return - - await send_pro_subscription_email( - user_name=user.get("first_name", "User"), user_email=user["email"] - ) - + logger.info(f"Welcome email sent to {user['email']}") except Exception as e: logger.error(f"Failed to send welcome email: {e}") - async def _store_event(self, event: PaymentWebhookEvent) -> None: - """Store webhook event for audit.""" - try: - event_doc = { - "business_id": event.business_id, - "event_type": event.type.value, - "timestamp": event.timestamp, - "data": event.data, - "processed_at": datetime.now(timezone.utc), - } - - await webhook_events_collection.insert_one(event_doc) - - except Exception as e: - logger.error(f"Failed to store webhook event: {e}") - +# Single instance payment_webhook_service = PaymentWebhookService() diff --git a/backend/app/services/webhook_service.py b/backend/app/services/webhook_service.py deleted file mode 100644 index 24613010..00000000 --- a/backend/app/services/webhook_service.py +++ /dev/null @@ -1,436 +0,0 @@ -""" -Payment webhook service for Dodo Payments integration. -Clean implementation following Dodo webhook format and best practices. -""" - -import hashlib -import hmac -from datetime import datetime, timezone -from typing import Any, Dict - -from bson import ObjectId - -from app.config.loggers import general_logger as logger -from app.config.settings import settings -from app.db.mongodb.collections import ( - payments_collection, - subscriptions_collection, - users_collection, - webhook_events_collection, -) -from app.models.webhook_models import ( - PaymentWebhookEvent, - PaymentWebhookType, - PaymentWebhookResult, -) -from app.utils.email_utils import send_pro_subscription_email - - -class PaymentWebhookService: - """Clean service for handling Dodo payment webhooks.""" - - def __init__(self): - self.webhook_secret = settings.DODO_WEBHOOK_SECRET - self.handlers = { - PaymentWebhookType.PAYMENT_SUCCEEDED: self._handle_payment_succeeded, - PaymentWebhookType.PAYMENT_FAILED: self._handle_payment_failed, - PaymentWebhookType.PAYMENT_PROCESSING: self._handle_payment_processing, - PaymentWebhookType.PAYMENT_CANCELLED: self._handle_payment_cancelled, - PaymentWebhookType.SUBSCRIPTION_ACTIVE: self._handle_subscription_active, - PaymentWebhookType.SUBSCRIPTION_RENEWED: self._handle_subscription_renewed, - PaymentWebhookType.SUBSCRIPTION_CANCELLED: self._handle_subscription_cancelled, - PaymentWebhookType.SUBSCRIPTION_EXPIRED: self._handle_subscription_expired, - PaymentWebhookType.SUBSCRIPTION_FAILED: self._handle_subscription_failed, - PaymentWebhookType.SUBSCRIPTION_ON_HOLD: self._handle_subscription_on_hold, - PaymentWebhookType.SUBSCRIPTION_PLAN_CHANGED: self._handle_subscription_plan_changed, - } - - def verify_signature(self, payload: bytes, signature: str) -> bool: - """Verify Dodo webhook signature.""" - if not self.webhook_secret: - logger.warning("No webhook secret configured") - return True - - try: - if signature.startswith("sha256="): - signature = signature[7:] - - expected = hmac.new( - self.webhook_secret.encode("utf-8"), payload, hashlib.sha256 - ).hexdigest() - - return hmac.compare_digest(signature, expected) - except Exception as e: - logger.error(f"Signature verification failed: {e}") - return False - - async def process_webhook( - self, webhook_data: Dict[str, Any] - ) -> PaymentWebhookResult: - """Process Dodo payment webhook.""" - try: - event = PaymentWebhookEvent(**webhook_data) - await self._store_event(event) - - handler = self.handlers.get(event.type) - if not handler: - return PaymentWebhookResult( - event_type=event.type.value, - status="ignored", - message=f"Handler not found for {event.type}", - ) - - result = await handler(event) - logger.info(f"Webhook processed: {event.type} - {result.status}") - return result - - except Exception as e: - logger.error(f"Webhook processing failed: {e}") - return PaymentWebhookResult( - event_type=webhook_data.get("type", "unknown"), - status="failed", - message=f"Processing error: {str(e)}", - ) - - # Payment event handlers - async def _handle_payment_succeeded( - self, event: PaymentWebhookEvent - ) -> PaymentWebhookResult: - """Handle successful payment.""" - payment_data = event.get_payment_data() - if not payment_data: - raise ValueError("Invalid payment data") - - payment_doc = { - "dodo_payment_id": payment_data.payment_id, - "dodo_subscription_id": payment_data.subscription_id, - "business_id": payment_data.business_id, - "customer_email": payment_data.customer.email, - "customer_id": payment_data.customer.customer_id, - "total_amount": payment_data.total_amount, - "settlement_amount": payment_data.settlement_amount, - "currency": payment_data.currency, - "settlement_currency": payment_data.settlement_currency, - "status": payment_data.status, - "payment_method": payment_data.payment_method, - "created_at": datetime.now(timezone.utc), - "metadata": payment_data.metadata, - } - - await payments_collection.insert_one(payment_doc) - - return PaymentWebhookResult( - event_type=event.type.value, - status="processed", - message="Payment recorded successfully", - payment_id=payment_data.payment_id, - subscription_id=payment_data.subscription_id, - ) - - async def _handle_payment_failed( - self, event: PaymentWebhookEvent - ) -> PaymentWebhookResult: - """Handle failed payment.""" - payment_data = event.get_payment_data() - if not payment_data: - raise ValueError("Invalid payment data") - - payment_doc = { - "dodo_payment_id": payment_data.payment_id, - "dodo_subscription_id": payment_data.subscription_id, - "business_id": payment_data.business_id, - "customer_email": payment_data.customer.email, - "customer_id": payment_data.customer.customer_id, - "total_amount": payment_data.total_amount, - "currency": payment_data.currency, - "status": "failed", - "payment_method": payment_data.payment_method, - "error_code": payment_data.error_code, - "error_message": payment_data.error_message, - "created_at": datetime.now(timezone.utc), - "metadata": payment_data.metadata, - } - - await payments_collection.insert_one(payment_doc) - - if payment_data.subscription_id: - await subscriptions_collection.update_one( - {"dodo_subscription_id": payment_data.subscription_id}, - { - "$set": { - "status": "payment_failed", - "updated_at": datetime.now(timezone.utc), - } - }, - ) - - return PaymentWebhookResult( - event_type=event.type.value, - status="processed", - message="Payment failure recorded", - payment_id=payment_data.payment_id, - subscription_id=payment_data.subscription_id, - ) - - async def _handle_payment_processing( - self, event: PaymentWebhookEvent - ) -> PaymentWebhookResult: - """Handle payment processing status.""" - payment_data = event.get_payment_data() - if not payment_data: - raise ValueError("Invalid payment data") - - return PaymentWebhookResult( - event_type=event.type.value, - status="processed", - message="Payment processing status noted", - payment_id=payment_data.payment_id, - subscription_id=payment_data.subscription_id, - ) - - async def _handle_payment_cancelled( - self, event: PaymentWebhookEvent - ) -> PaymentWebhookResult: - """Handle cancelled payment.""" - payment_data = event.get_payment_data() - if not payment_data: - raise ValueError("Invalid payment data") - - return PaymentWebhookResult( - event_type=event.type.value, - status="processed", - message="Payment cancellation noted", - payment_id=payment_data.payment_id, - subscription_id=payment_data.subscription_id, - ) - - # Subscription event handlers - async def _handle_subscription_active( - self, event: PaymentWebhookEvent - ) -> PaymentWebhookResult: - """Handle subscription becoming active.""" - sub_data = event.get_subscription_data() - if not sub_data: - raise ValueError("Invalid subscription data") - - result = await subscriptions_collection.update_one( - {"dodo_subscription_id": sub_data.subscription_id}, - { - "$set": { - "status": "active", - "updated_at": datetime.now(timezone.utc), - "next_billing_date": sub_data.next_billing_date, - "previous_billing_date": sub_data.previous_billing_date, - } - }, - ) - - if result.modified_count == 0: - logger.warning(f"Subscription not found: {sub_data.subscription_id}") - return PaymentWebhookResult( - event_type=event.type.value, - status="ignored", - message="Subscription not found", - subscription_id=sub_data.subscription_id, - ) - - await self._send_welcome_email(sub_data) - - return PaymentWebhookResult( - event_type=event.type.value, - status="processed", - message="Subscription activated", - subscription_id=sub_data.subscription_id, - ) - - async def _handle_subscription_renewed( - self, event: PaymentWebhookEvent - ) -> PaymentWebhookResult: - """Handle subscription renewal.""" - sub_data = event.get_subscription_data() - if not sub_data: - raise ValueError("Invalid subscription data") - - await subscriptions_collection.update_one( - {"dodo_subscription_id": sub_data.subscription_id}, - { - "$set": { - "status": "active", - "updated_at": datetime.now(timezone.utc), - "next_billing_date": sub_data.next_billing_date, - "previous_billing_date": sub_data.previous_billing_date, - } - }, - ) - - return PaymentWebhookResult( - event_type=event.type.value, - status="processed", - message="Subscription renewed", - subscription_id=sub_data.subscription_id, - ) - - async def _handle_subscription_cancelled( - self, event: PaymentWebhookEvent - ) -> PaymentWebhookResult: - """Handle subscription cancellation.""" - sub_data = event.get_subscription_data() - if not sub_data: - raise ValueError("Invalid subscription data") - - update_data = { - "status": "cancelled", - "updated_at": datetime.now(timezone.utc), - } - - if sub_data.cancelled_at: - update_data["cancelled_at"] = sub_data.cancelled_at - - await subscriptions_collection.update_one( - {"dodo_subscription_id": sub_data.subscription_id}, - {"$set": update_data}, - ) - - return PaymentWebhookResult( - event_type=event.type.value, - status="processed", - message="Subscription cancelled", - subscription_id=sub_data.subscription_id, - ) - - async def _handle_subscription_expired( - self, event: PaymentWebhookEvent - ) -> PaymentWebhookResult: - """Handle subscription expiration.""" - sub_data = event.get_subscription_data() - if not sub_data: - raise ValueError("Invalid subscription data") - - await subscriptions_collection.update_one( - {"dodo_subscription_id": sub_data.subscription_id}, - {"$set": {"status": "expired", "updated_at": datetime.now(timezone.utc)}}, - ) - - return PaymentWebhookResult( - event_type=event.type.value, - status="processed", - message="Subscription expired", - subscription_id=sub_data.subscription_id, - ) - - async def _handle_subscription_failed( - self, event: PaymentWebhookEvent - ) -> PaymentWebhookResult: - """Handle subscription failure.""" - sub_data = event.get_subscription_data() - if not sub_data: - raise ValueError("Invalid subscription data") - - await subscriptions_collection.update_one( - {"dodo_subscription_id": sub_data.subscription_id}, - {"$set": {"status": "failed", "updated_at": datetime.now(timezone.utc)}}, - ) - - return PaymentWebhookResult( - event_type=event.type.value, - status="processed", - message="Subscription failed", - subscription_id=sub_data.subscription_id, - ) - - async def _handle_subscription_on_hold( - self, event: PaymentWebhookEvent - ) -> PaymentWebhookResult: - """Handle subscription on hold.""" - sub_data = event.get_subscription_data() - if not sub_data: - raise ValueError("Invalid subscription data") - - await subscriptions_collection.update_one( - {"dodo_subscription_id": sub_data.subscription_id}, - {"$set": {"status": "on_hold", "updated_at": datetime.now(timezone.utc)}}, - ) - - return PaymentWebhookResult( - event_type=event.type.value, - status="processed", - message="Subscription on hold", - subscription_id=sub_data.subscription_id, - ) - - async def _handle_subscription_plan_changed( - self, event: PaymentWebhookEvent - ) -> PaymentWebhookResult: - """Handle subscription plan change.""" - sub_data = event.get_subscription_data() - if not sub_data: - raise ValueError("Invalid subscription data") - - await subscriptions_collection.update_one( - {"dodo_subscription_id": sub_data.subscription_id}, - { - "$set": { - "product_id": sub_data.product_id, - "quantity": sub_data.quantity, - "recurring_pre_tax_amount": sub_data.recurring_pre_tax_amount, - "updated_at": datetime.now(timezone.utc), - } - }, - ) - - return PaymentWebhookResult( - event_type=event.type.value, - status="processed", - message="Subscription plan changed", - subscription_id=sub_data.subscription_id, - ) - - # Helper methods - async def _send_welcome_email(self, sub_data) -> None: - """Send welcome email for new subscription.""" - try: - subscription = await subscriptions_collection.find_one( - {"dodo_subscription_id": sub_data.subscription_id} - ) - - if not subscription: - logger.warning( - f"Cannot send welcome email - subscription not found: {sub_data.subscription_id}" - ) - return - - user = await users_collection.find_one( - {"_id": ObjectId(subscription["user_id"])} - ) - - if not user or not user.get("email"): - logger.warning( - f"Cannot send welcome email - user not found: {subscription.get('user_id')}" - ) - return - - await send_pro_subscription_email( - user_name=user.get("first_name", "User"), user_email=user["email"] - ) - - except Exception as e: - logger.error(f"Failed to send welcome email: {e}") - - async def _store_event(self, event: PaymentWebhookEvent) -> None: - """Store webhook event for audit.""" - try: - event_doc = { - "business_id": event.business_id, - "event_type": event.type.value, - "timestamp": event.timestamp, - "data": event.data, - "processed_at": datetime.now(timezone.utc), - } - - await webhook_events_collection.insert_one(event_doc) - - except Exception as e: - logger.error(f"Failed to store webhook event: {e}") - - -payment_webhook_service = PaymentWebhookService() From 6db268e085bfe757aaf8daa9732d7a065487413c Mon Sep 17 00:00:00 2001 From: Dhruv Maradiya Date: Fri, 8 Aug 2025 00:37:18 +0530 Subject: [PATCH 18/72] feat: enhance tool execution request message to include fallback for unavailable tools --- backend/app/langchain/core/messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/langchain/core/messages.py b/backend/app/langchain/core/messages.py index 8a7e1800..882b5bda 100644 --- a/backend/app/langchain/core/messages.py +++ b/backend/app/langchain/core/messages.py @@ -98,7 +98,7 @@ async def construct_langchain_messages( human_message_content += f"\n\n**TOOL SELECTION:** The user has specifically selected the '{tool_display_name}' tool and wants you to execute it to handle their request above. You must use the {selected_tool} tool to process their request. Do not suggest alternatives - the user has already chosen this specific tool for their task." else: # No user content, just tool selection - human_message_content = f"**TOOL EXECUTION REQUEST:** The user has selected the '{tool_display_name}' tool and wants you to execute it immediately. Use the {selected_tool} tool now. This is a direct tool execution request with no additional context needed. If you don't have tool context, use retrieve_tools to get tool information. Ignore older tools requests and focus on the current tool selection. You must use the {selected_tool} tool to process their request." + human_message_content = f"**TOOL EXECUTION REQUEST:** The user has selected the '{tool_display_name}' tool and wants you to execute it immediately. Use the {selected_tool} tool now. This is a direct tool execution request with no additional context needed. If you don't have tool context, use retrieve_tools to get tool information. Ignore older tools requests and focus on the current tool selection. You must use the {selected_tool} tool to process their request. If requested tool is not available then use `retrieve_tools` to get the relevant tool information." # If no human message then return error if not human_message_content: From 0ddae5946fa43813a0151520baa2548cdb5c48ef Mon Sep 17 00:00:00 2001 From: Aryan Date: Fri, 8 Aug 2025 00:54:54 +0530 Subject: [PATCH 19/72] feat: enhance Dodo Payments webhook handling with signature verification --- backend/app/api/v1/router/payments.py | 27 +++++++-- backend/app/config/settings.py | 2 +- .../app/services/payment_webhook_service.py | 57 +++++++++++++++++++ 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/backend/app/api/v1/router/payments.py b/backend/app/api/v1/router/payments.py index 543e71b1..45d0d3e2 100644 --- a/backend/app/api/v1/router/payments.py +++ b/backend/app/api/v1/router/payments.py @@ -6,7 +6,7 @@ import json from typing import List -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, Header from app.api.v1.dependencies.oauth_dependencies import get_current_user from app.config.loggers import general_logger as logger @@ -78,12 +78,29 @@ async def get_subscription_status_endpoint( @router.post("/webhooks/dodo") -async def handle_dodo_webhook(request: Request): - """Handle incoming webhooks from Dodo Payments.""" +async def handle_dodo_webhook( + request: Request, + webhook_id: str = Header(..., alias="webhook-id"), + webhook_timestamp: str = Header(..., alias="webhook-timestamp"), + webhook_signature: str = Header(..., alias="webhook-signature"), +): + """Handle incoming webhooks from Dodo Payments with signature verification.""" try: + # Get raw body for signature verification body = await request.body() - webhook_data = json.loads(body.decode("utf-8")) + payload = body.decode("utf-8") + + # Verify webhook signature + if not payment_webhook_service.verify_webhook_signature( + webhook_id, webhook_timestamp, payload, webhook_signature + ): + logger.warning("Invalid webhook signature") + raise HTTPException(status_code=401, detail="Invalid webhook signature") + + # Parse webhook data + webhook_data = json.loads(payload) + # Process the webhook result = await payment_webhook_service.process_webhook(webhook_data) logger.info(f"Webhook processed: {result.event_type} - {result.status}") @@ -94,6 +111,8 @@ async def handle_dodo_webhook(request: Request): "message": result.message, } + except HTTPException: + raise except json.JSONDecodeError: logger.error("Invalid JSON in webhook payload") raise HTTPException(status_code=400, detail="Invalid JSON payload") diff --git a/backend/app/config/settings.py b/backend/app/config/settings.py index 2f628807..7d8fa914 100644 --- a/backend/app/config/settings.py +++ b/backend/app/config/settings.py @@ -136,7 +136,7 @@ class Settings(BaseSettings): # Dodo Payments Configuration DODO_PAYMENTS_API_KEY: str - DODO_WEBHOOK_SECRET: str = "" + DODO_WEBHOOK_PAYMENTS_SECRET: str = "" @computed_field # type: ignore @property diff --git a/backend/app/services/payment_webhook_service.py b/backend/app/services/payment_webhook_service.py index 786201b3..1cc7c624 100644 --- a/backend/app/services/payment_webhook_service.py +++ b/backend/app/services/payment_webhook_service.py @@ -3,12 +3,16 @@ Handles webhook events and updates database state accordingly. """ +import base64 +import hashlib +import hmac from datetime import datetime, timezone from typing import Any, Dict from bson import ObjectId from app.config.loggers import general_logger as logger +from app.config.settings import settings from app.db.mongodb.collections import ( subscriptions_collection, users_collection, @@ -25,6 +29,7 @@ class PaymentWebhookService: """Clean service for handling Dodo payment webhooks.""" def __init__(self): + self.webhook_secret = settings.DODO_WEBHOOK_PAYMENTS_SECRET self.handlers = { WebhookEventType.PAYMENT_SUCCEEDED: self._handle_payment_succeeded, WebhookEventType.PAYMENT_FAILED: self._handle_payment_failed, @@ -39,6 +44,58 @@ def __init__(self): WebhookEventType.SUBSCRIPTION_PLAN_CHANGED: self._handle_subscription_plan_changed, } + def verify_webhook_signature( + self, webhook_id: str, webhook_timestamp: str, payload: str, signature: str + ) -> bool: + """ + Verify webhook signature following Standard Webhooks specification. + + Args: + webhook_id: The webhook ID from headers + webhook_timestamp: The timestamp from headers + payload: The raw JSON payload as string + signature: The signature from headers (format: v1,signature) + """ + if not self.webhook_secret: + logger.warning( + "No webhook secret configured - skipping signature verification" + ) + return True + + try: + # Extract the signature (remove v1, prefix) + if signature.startswith("v1,"): + signature = signature[3:] + + # Create the signed payload: webhook_id.webhook_timestamp.payload + signed_payload = f"{webhook_id}.{webhook_timestamp}.{payload}" + + # Compute HMAC SHA256 + expected_signature = hmac.new( + self.webhook_secret.encode("utf-8"), + signed_payload.encode("utf-8"), + hashlib.sha256, + ).digest() + + # Convert to base64 (like the received signature) + expected_signature_b64 = base64.b64encode(expected_signature).decode( + "utf-8" + ) + + # Compare signatures + is_valid = hmac.compare_digest(expected_signature_b64, signature) + + if not is_valid: + logger.warning( + f"Webhook signature verification failed. Expected: {expected_signature_b64}, Received: {signature}" + ) + + return is_valid + + except Exception as e: + logger.error(f"Error verifying webhook signature: {e}") + return False + async def process_webhook( self, webhook_data: Dict[str, Any] ) -> WebhookProcessingResult: From ec47b25e172982a6d2d61f25adb23ca9be595de9 Mon Sep 17 00:00:00 2001 From: Aryan Date: Fri, 8 Aug 2025 00:57:03 +0530 Subject: [PATCH 20/72] feat: add auto redirect countdown to SubscriptionSuccessModal --- .../components/SubscriptionSuccessModal.tsx | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/frontend/src/features/pricing/components/SubscriptionSuccessModal.tsx b/frontend/src/features/pricing/components/SubscriptionSuccessModal.tsx index cdcbf1eb..7d4be453 100644 --- a/frontend/src/features/pricing/components/SubscriptionSuccessModal.tsx +++ b/frontend/src/features/pricing/components/SubscriptionSuccessModal.tsx @@ -1,14 +1,9 @@ "use client"; import { Button } from "@heroui/button"; -import { - Modal, - ModalContent, - ModalFooter, - ModalHeader, -} from "@heroui/modal"; +import { Modal, ModalContent, ModalFooter, ModalHeader } from "@heroui/modal"; import { ArrowRight, Check } from "lucide-react"; -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import UseCreateConfetti from "../../../hooks/ui/useCreateConfetti"; @@ -17,6 +12,7 @@ interface SubscriptionSuccessModalProps { onClose: () => void; onNavigateToChat: () => void; planName?: string; + autoRedirectSeconds?: number; } export function SubscriptionSuccessModal({ @@ -24,13 +20,35 @@ export function SubscriptionSuccessModal({ onClose, onNavigateToChat, planName = "Pro", + autoRedirectSeconds = 5, }: SubscriptionSuccessModalProps) { + const [countdown, setCountdown] = useState(autoRedirectSeconds); + // Trigger confetti animation when modal opens useEffect(() => { if (isOpen) { UseCreateConfetti(3000); // 3 seconds of confetti + setCountdown(autoRedirectSeconds); // Reset countdown when modal opens } - }, [isOpen]); + }, [isOpen, autoRedirectSeconds]); + + // Auto redirect countdown timer + useEffect(() => { + if (!isOpen || countdown <= 0) return; + + const timer = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + // Redirect when countdown reaches 0 + setTimeout(() => onNavigateToChat(), 100); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(timer); + }, [isOpen, countdown, onNavigateToChat]); return ( @@ -54,7 +72,7 @@ export function SubscriptionSuccessModal({ onPress={onNavigateToChat} endContent={} > - Let's Chat! + {countdown > 0 ? `Let's Chat! (${countdown}s)` : "Let's Chat!"}
    - {appConfig.footerSections.map((section) => ( + {footerSections.map((section) => (
    {/* Main navigation links */} - {mainNavLinks.map((link) => ( + {main.map((link) => ( +
    + ); +} From 181a2e1ea65fbc50fcb9ec401f0de21870dba131 Mon Sep 17 00:00:00 2001 From: Aryan Date: Fri, 8 Aug 2025 04:10:45 +0530 Subject: [PATCH 27/72] feat: implement abort controller management for chat stream handling --- frontend/src/features/chat/api/chatApi.ts | 5 +-- .../components/composer/ComposerRight.tsx | 41 +++++++++++++----- .../features/chat/contexts/AbortContext.tsx | 42 +++++++++++++++++++ .../src/features/chat/hooks/useChatStream.ts | 29 ++++++++++--- .../src/features/chat/hooks/useLoading.ts | 14 +++++++ .../features/chat/utils/streamController.ts | 25 +++++++++++ 6 files changed, 138 insertions(+), 18 deletions(-) create mode 100644 frontend/src/features/chat/contexts/AbortContext.tsx create mode 100644 frontend/src/features/chat/utils/streamController.ts diff --git a/frontend/src/features/chat/api/chatApi.ts b/frontend/src/features/chat/api/chatApi.ts index 76b3f741..cb84603b 100644 --- a/frontend/src/features/chat/api/chatApi.ts +++ b/frontend/src/features/chat/api/chatApi.ts @@ -177,8 +177,9 @@ export const chatApi = { fileData: FileData[] = [], selectedTool: string | null = null, toolCategory: string | null = null, + externalController?: AbortController, ) => { - const controller = new AbortController(); + const controller = externalController || new AbortController(); // Extract fileIds from fileData for backward compatibility const fileIds = fileData.map((file) => file.fileId); @@ -214,13 +215,11 @@ export const chatApi = { if (event.data === "[DONE]") { onClose(); - controller.abort(); return; } }, onclose() { onClose(); - controller.abort(); }, onerror: onError, }, diff --git a/frontend/src/features/chat/components/composer/ComposerRight.tsx b/frontend/src/features/chat/components/composer/ComposerRight.tsx index 843d72a6..01ecc657 100644 --- a/frontend/src/features/chat/components/composer/ComposerRight.tsx +++ b/frontend/src/features/chat/components/composer/ComposerRight.tsx @@ -1,7 +1,8 @@ import { Button } from "@heroui/button"; import { Tooltip } from "@heroui/tooltip"; -import { ArrowUp } from "lucide-react"; +import { ArrowUp, Square } from "lucide-react"; +import { Cancel01Icon } from "@/components/shared/icons"; import { useLoading } from "@/features/chat/hooks/useLoading"; interface RightSideProps { @@ -15,12 +16,14 @@ export default function RightSide({ searchbarText, selectedTool, }: RightSideProps) { - const { isLoading } = useLoading(); + const { isLoading, stopStream } = useLoading(); const hasText = searchbarText.trim().length > 0; const hasSelectedTool = selectedTool !== null && selectedTool !== undefined; const isDisabled = isLoading || (!hasText && !hasSelectedTool); const getTooltipContent = () => { + if (isLoading) return "Stop generation"; + if (hasSelectedTool && !hasText) { // Format tool name to be more readable const formattedToolName = selectedTool @@ -32,26 +35,44 @@ export default function RightSide({ return "Send message"; }; + const handleButtonPress = () => { + if (isLoading) { + console.log("Stop button pressed, calling stopStream"); + stopStream(); + } else { + handleFormSubmit(); + } + }; + return (
    diff --git a/frontend/src/features/chat/contexts/AbortContext.tsx b/frontend/src/features/chat/contexts/AbortContext.tsx new file mode 100644 index 00000000..4eb0f0c8 --- /dev/null +++ b/frontend/src/features/chat/contexts/AbortContext.tsx @@ -0,0 +1,42 @@ +"use client"; + +import React, { createContext, useContext, useRef } from "react"; +import { toast } from "sonner"; + +interface AbortContextType { + abortControllerRef: React.MutableRefObject; + setAbortController: (controller: AbortController | null) => void; + stopStream: () => void; +} + +const AbortContext = createContext(null); + +export function AbortProvider({ children }: { children: React.ReactNode }) { + const abortControllerRef = useRef(null); + + const setAbortController = (controller: AbortController | null) => { + abortControllerRef.current = controller; + }; + + const stopStream = () => { + if (!abortControllerRef.current) return; + abortControllerRef.current.abort(); + abortControllerRef.current = null; + }; + + return ( + + {children} + + ); +} + +export function useAbortController() { + const context = useContext(AbortContext); + if (!context) + throw new Error("useAbortController must be used within AbortProvider"); + + return context; +} diff --git a/frontend/src/features/chat/hooks/useChatStream.ts b/frontend/src/features/chat/hooks/useChatStream.ts index 75cc96a7..8f2ce04e 100644 --- a/frontend/src/features/chat/hooks/useChatStream.ts +++ b/frontend/src/features/chat/hooks/useChatStream.ts @@ -8,6 +8,7 @@ import { chatApi } from "@/features/chat/api/chatApi"; import { useConversation } from "@/features/chat/hooks/useConversation"; import { useFetchConversations } from "@/features/chat/hooks/useConversationList"; import { useLoading } from "@/features/chat/hooks/useLoading"; +import { streamController } from "@/features/chat/utils/streamController"; import { MessageType } from "@/types/features/convoTypes"; import { FileData } from "@/types/shared"; import fetchDate from "@/utils/date/dateUtils"; @@ -16,7 +17,7 @@ import { useLoadingText } from "./useLoadingText"; import { parseStreamData } from "./useStreamDataParser"; export const useChatStream = () => { - const { setIsLoading } = useLoading(); + const { setIsLoading, setAbortController } = useLoading(); const { updateConvoMessages, convoMessages } = useConversation(); const router = useRouter(); const fetchConversations = useFetchConversations(); @@ -139,6 +140,7 @@ export const useChatStream = () => { setIsLoading(false); resetLoadingText(); + streamController.clear(); if (refs.current.newConversation.id) { // && !refs.current.convoMessages[0]?.conversation_id @@ -182,6 +184,10 @@ export const useChatStream = () => { fileData, }; + // Create abort controller for this stream + const controller = new AbortController(); + setAbortController(controller); + await chatApi.fetchChatStream( inputText, [...refs.current.convoMessages, ...currentMessages], @@ -191,15 +197,28 @@ export const useChatStream = () => { (err) => { setIsLoading(false); resetLoadingText(); - toast.error("Error in chat stream."); - console.error("Stream error:", err); - - // Save the user's input text for restoration on error + streamController.clear(); + + // Check if it was an abort signal (user cancelled) + if (err.name === "AbortError") { + // Still save incomplete response if any + if (refs.current.botMessage && refs.current.accumulatedResponse) + updateBotMessage({ + response: refs.current.accumulatedResponse, + loading: false, + }); + + // Toast is handled in streamController.abort() + } else { + toast.error(`Error while streaming: ${err}`); + console.error("Stream error:", err); + } // Save the user's input text for restoration on error localStorage.setItem("gaia-searchbar-text", inputText); }, fileData, selectedTool, toolCategory, + controller, ); }; }; diff --git a/frontend/src/features/chat/hooks/useLoading.ts b/frontend/src/features/chat/hooks/useLoading.ts index 5c07c2e0..bd720e6c 100644 --- a/frontend/src/features/chat/hooks/useLoading.ts +++ b/frontend/src/features/chat/hooks/useLoading.ts @@ -3,6 +3,7 @@ import { useDispatch, useSelector } from "react-redux"; import { setIsLoading } from "@/redux/slices/loadingSlice"; import { AppDispatch, RootState } from "@/redux/store"; +import { streamController } from "@/features/chat/utils/streamController"; export const useLoading = () => { const dispatch: AppDispatch = useDispatch(); @@ -12,8 +13,21 @@ export const useLoading = () => { dispatch(setIsLoading(loading)); }; + const setAbortController = (controller: AbortController | null) => { + streamController.set(controller); + }; + + const stopStream = () => { + const aborted = streamController.abort(); + if (aborted) { + setLoadingState(false); + } + }; + return { isLoading, setIsLoading: setLoadingState, + setAbortController, + stopStream, }; }; diff --git a/frontend/src/features/chat/utils/streamController.ts b/frontend/src/features/chat/utils/streamController.ts new file mode 100644 index 00000000..8d35baf3 --- /dev/null +++ b/frontend/src/features/chat/utils/streamController.ts @@ -0,0 +1,25 @@ +"use client"; + +// Simple module-level storage for the current abort controller +let currentAbortController: AbortController | null = null; + +export const streamController = { + set: (controller: AbortController | null) => { + currentAbortController = controller; + }, + + get: () => currentAbortController, + + abort: () => { + if (currentAbortController) { + currentAbortController.abort(); + currentAbortController = null; + return true; + } + return false; + }, + + clear: () => { + currentAbortController = null; + }, +}; From d512dfd3c93a24e4ca405c10be9d38387465f147 Mon Sep 17 00:00:00 2001 From: Aryan Date: Fri, 8 Aug 2025 19:08:17 +0530 Subject: [PATCH 28/72] feat: implement save functionality for incomplete conversations in chat stream --- backend/app/api/v1/router/chat.py | 73 ++++++++++++++++++- backend/app/api/v1/router/oauth.py | 4 +- backend/app/models/message_models.py | 10 +++ frontend/src/features/chat/api/chatApi.ts | 25 +++++++ .../src/features/chat/hooks/useChatStream.ts | 60 +++++++++++---- .../src/features/chat/hooks/useLoading.ts | 3 + .../features/chat/utils/streamController.ts | 18 +++++ 7 files changed, 175 insertions(+), 18 deletions(-) diff --git a/backend/app/api/v1/router/chat.py b/backend/app/api/v1/router/chat.py index 514893db..0ad24505 100644 --- a/backend/app/api/v1/router/chat.py +++ b/backend/app/api/v1/router/chat.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from fastapi import APIRouter, BackgroundTasks, Depends from fastapi.responses import StreamingResponse @@ -8,8 +8,17 @@ get_user_timezone, ) from app.decorators import tiered_rate_limit -from app.models.message_models import MessageRequestWithHistory -from app.services.chat_service import chat_stream +from app.models.message_models import ( + MessageRequestWithHistory, + SaveIncompleteConversationRequest, + MessageDict, +) +from app.models.chat_models import MessageModel, UpdateMessagesRequest +from app.services.chat_service import ( + chat_stream, +) +from app.services.conversation_service import update_messages +from app.utils.chat_utils import create_conversation router = APIRouter() @@ -38,3 +47,61 @@ async def chat_stream_endpoint( "Access-Control-Allow-Origin": "*", }, ) + + +@router.post("/save-incomplete-conversation") +@tiered_rate_limit("chat_messages") +async def save_incomplete_conversation( + body: SaveIncompleteConversationRequest, + background_tasks: BackgroundTasks, + user: dict = Depends(get_current_user), +) -> dict: + """ + Save incomplete conversation when stream is cancelled. + """ + conversation_id = body.conversation_id + + # Only create new conversation if conversation_id is None + if conversation_id is None: + last_message: MessageDict = {"role": "user", "content": body.message} + selectedTool = body.selectedTool + conversation = await create_conversation( + last_message, user=user, selectedTool=selectedTool + ) + conversation_id = conversation.get("conversation_id", "") + + # Save the incomplete conversation immediately (not as background task) + # Since user expects to see it right away when they navigate/refresh + + # Create user message + user_message = MessageModel( + type="user", + response=body.message, + date=datetime.now(timezone.utc).isoformat(), + fileIds=body.fileIds, + fileData=body.fileData, + selectedTool=body.selectedTool, + toolCategory=body.toolCategory, + ) + + # Create bot message with incomplete response + bot_message = MessageModel( + type="bot", + response=body.incomplete_response, + date=datetime.now(timezone.utc).isoformat(), + fileIds=body.fileIds, + ) + + # Save immediately instead of background task + await update_messages( + UpdateMessagesRequest( + conversation_id=conversation_id, + messages=[user_message, bot_message], + ), + user=user, + ) + + return { + "success": True, + "conversation_id": conversation_id, + } diff --git a/backend/app/api/v1/router/oauth.py b/backend/app/api/v1/router/oauth.py index 8147af01..e84ff068 100644 --- a/backend/app/api/v1/router/oauth.py +++ b/backend/app/api/v1/router/oauth.py @@ -119,7 +119,9 @@ async def workos_callback( # Extract user information email = auth_response.user.email - name = f"{auth_response.user.first_name} {auth_response.user.last_name}" + first = auth_response.user.first_name or "" + last = auth_response.user.last_name or "" + name = f"{first} {last}".strip() picture_url = auth_response.user.profile_picture_url # Store user info in our database diff --git a/backend/app/models/message_models.py b/backend/app/models/message_models.py index 422e1231..f39a4b72 100644 --- a/backend/app/models/message_models.py +++ b/backend/app/models/message_models.py @@ -27,6 +27,16 @@ class MessageRequestWithHistory(BaseModel): toolCategory: Optional[str] = None # Category of the selected tool +class SaveIncompleteConversationRequest(BaseModel): + message: str + conversation_id: Optional[str] = None + fileIds: Optional[List[str]] = [] + fileData: Optional[List[FileData]] = [] + selectedTool: Optional[str] = None + toolCategory: Optional[str] = None + incomplete_response: str = "" # The partial response from the bot + + class MessageRequest(BaseModel): message: str diff --git a/frontend/src/features/chat/api/chatApi.ts b/frontend/src/features/chat/api/chatApi.ts index cb84603b..0ee0db28 100644 --- a/frontend/src/features/chat/api/chatApi.ts +++ b/frontend/src/features/chat/api/chatApi.ts @@ -166,6 +166,31 @@ export const chatApi = { ); }, + // Save incomplete conversation when stream is cancelled + saveIncompleteConversation: async ( + inputText: string, + conversationId: string | null, + incompleteResponse: string, + fileData: FileData[] = [], + selectedTool: string | null = null, + toolCategory: string | null = null, + ): Promise<{ success: boolean; conversation_id: string }> => { + const fileIds = fileData.map((file) => file.fileId); + + return apiService.post<{ success: boolean; conversation_id: string }>( + "/save-incomplete-conversation", + { + conversation_id: conversationId, + message: inputText, + fileIds, + fileData, + selectedTool, + toolCategory, + incomplete_response: incompleteResponse, + }, + ); + }, + // Fetch chat stream fetchChatStream: async ( inputText: string, diff --git a/frontend/src/features/chat/hooks/useChatStream.ts b/frontend/src/features/chat/hooks/useChatStream.ts index 8f2ce04e..9b571ef4 100644 --- a/frontend/src/features/chat/hooks/useChatStream.ts +++ b/frontend/src/features/chat/hooks/useChatStream.ts @@ -40,6 +40,31 @@ export const useChatStream = () => { refs.current.convoMessages = convoMessages; }, [convoMessages]); + const saveIncompleteConversation = async () => { + if (!refs.current.botMessage || !refs.current.accumulatedResponse) { + return; + } + + try { + const response = await chatApi.saveIncompleteConversation( + refs.current.userPrompt, + refs.current.newConversation.id || null, + refs.current.accumulatedResponse, + refs.current.botMessage.fileData || [], + refs.current.botMessage.selectedTool || null, + refs.current.botMessage.toolCategory || null, + ); + + // Handle navigation for incomplete conversations + if (response.conversation_id && !refs.current.newConversation.id) { + router.push(`/c/${response.conversation_id}`); + fetchConversations(); + } + } catch (saveError) { + console.error("Failed to save incomplete conversation:", saveError); + } + }; + const updateBotMessage = (overrides: Partial) => { const baseMessage: MessageType = { type: "bot", @@ -142,8 +167,8 @@ export const useChatStream = () => { resetLoadingText(); streamController.clear(); + // Only navigate for successful completions (manual aborts are handled in the save callback) if (refs.current.newConversation.id) { - // && !refs.current.convoMessages[0]?.conversation_id router.push(`/c/${refs.current.newConversation.id}`); fetchConversations(); } @@ -188,6 +213,20 @@ export const useChatStream = () => { const controller = new AbortController(); setAbortController(controller); + // Register the save callback for when user clicks stop + streamController.setSaveCallback(() => { + // Update the UI immediately when stop is clicked + if (refs.current.botMessage) { + updateBotMessage({ + response: refs.current.accumulatedResponse, + loading: false, + }); + } + + // Save the incomplete conversation + saveIncompleteConversation(); + }); + await chatApi.fetchChatStream( inputText, [...refs.current.convoMessages, ...currentMessages], @@ -199,21 +238,14 @@ export const useChatStream = () => { resetLoadingText(); streamController.clear(); - // Check if it was an abort signal (user cancelled) - if (err.name === "AbortError") { - // Still save incomplete response if any - if (refs.current.botMessage && refs.current.accumulatedResponse) - updateBotMessage({ - response: refs.current.accumulatedResponse, - loading: false, - }); - - // Toast is handled in streamController.abort() - } else { + // Handle non-abort errors + if (err.name !== "AbortError") { toast.error(`Error while streaming: ${err}`); console.error("Stream error:", err); - } // Save the user's input text for restoration on error - localStorage.setItem("gaia-searchbar-text", inputText); + // Save the user's input text for restoration on error + localStorage.setItem("gaia-searchbar-text", inputText); + } + // Abort errors are now handled in handleStreamClose }, fileData, selectedTool, diff --git a/frontend/src/features/chat/hooks/useLoading.ts b/frontend/src/features/chat/hooks/useLoading.ts index bd720e6c..f0ce2285 100644 --- a/frontend/src/features/chat/hooks/useLoading.ts +++ b/frontend/src/features/chat/hooks/useLoading.ts @@ -18,6 +18,9 @@ export const useLoading = () => { }; const stopStream = () => { + // Trigger the save before aborting the stream + streamController.triggerSave(); + const aborted = streamController.abort(); if (aborted) { setLoadingState(false); diff --git a/frontend/src/features/chat/utils/streamController.ts b/frontend/src/features/chat/utils/streamController.ts index 8d35baf3..cae39ea5 100644 --- a/frontend/src/features/chat/utils/streamController.ts +++ b/frontend/src/features/chat/utils/streamController.ts @@ -2,16 +2,20 @@ // Simple module-level storage for the current abort controller let currentAbortController: AbortController | null = null; +let wasManuallyAborted = false; +let saveCallback: (() => void) | null = null; export const streamController = { set: (controller: AbortController | null) => { currentAbortController = controller; + wasManuallyAborted = false; // Reset flag when new controller is set }, get: () => currentAbortController, abort: () => { if (currentAbortController) { + wasManuallyAborted = true; // Set flag when manually aborted currentAbortController.abort(); currentAbortController = null; return true; @@ -19,7 +23,21 @@ export const streamController = { return false; }, + wasAborted: () => wasManuallyAborted, + + setSaveCallback: (callback: (() => void) | null) => { + saveCallback = callback; + }, + + triggerSave: () => { + if (saveCallback) { + saveCallback(); + } + }, + clear: () => { currentAbortController = null; + wasManuallyAborted = false; // Reset flag when cleared + saveCallback = null; // Clear save callback }, }; From a9f7ff0eb5e34aa73920b8624fb7c9b25ded3552 Mon Sep 17 00:00:00 2001 From: Aryan Date: Fri, 8 Aug 2025 19:12:16 +0530 Subject: [PATCH 29/72] feat: refactor modal handling in ChatOptionsDropdown for improved state management --- .../layout/sidebar/ChatOptionsDropdown.tsx | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/layout/sidebar/ChatOptionsDropdown.tsx b/frontend/src/components/layout/sidebar/ChatOptionsDropdown.tsx index 33fbafa0..c7f5fead 100644 --- a/frontend/src/components/layout/sidebar/ChatOptionsDropdown.tsx +++ b/frontend/src/components/layout/sidebar/ChatOptionsDropdown.tsx @@ -55,10 +55,8 @@ export default function ChatOptionsDropdown({ starred === undefined ? true : !starred, ); setIsOpen(false); - await fetchConversations(); } catch (error) { - console.error("Failed to update star", error); } }; @@ -67,7 +65,7 @@ export default function ChatOptionsDropdown({ if (!newName) return; try { await chatApi.renameConversation(chatId, newName); - setIsOpen(false); + closeModal(); await fetchConversations(1, 20, false); } catch (error) { console.error("Failed to update chat name", error); @@ -78,20 +76,26 @@ export default function ChatOptionsDropdown({ try { router.push("/c"); await chatApi.deleteConversation(chatId); - setIsOpen(false); - // Toast is already shown by the API service + closeModal(); await fetchConversations(1, 20, false); } catch (error) { - // Error toast is already shown by the API service console.error("Failed to delete chat", error); } }; const openModal = (action: "edit" | "delete") => { setModalAction(action); + if (action === "edit") setNewName(chatName); // Reset to current chat name when opening edit modal + setIsOpen(true); }; + const closeModal = () => { + setIsOpen(false); + setModalAction(null); + setNewName(""); // Clear the input field + }; + return ( <> @@ -157,7 +161,7 @@ export default function ChatOptionsDropdown({ {modalAction === "edit" ? ( @@ -186,7 +190,7 @@ export default function ChatOptionsDropdown({ /> -