diff --git a/backend/igny8_core/auth/admin.py b/backend/igny8_core/auth/admin.py index acf1bc49..7387a7af 100644 --- a/backend/igny8_core/auth/admin.py +++ b/backend/igny8_core/auth/admin.py @@ -161,6 +161,10 @@ class PlanAdmin(ImportExportMixin, Igny8ModelAdmin): ('Stripe Integration', { 'fields': ('stripe_product_id', 'stripe_price_id') }), + ('PayPal Integration', { + 'fields': ('paypal_plan_id',), + 'description': 'PayPal subscription plan ID (required for PayPal subscriptions)' + }), ) def bulk_set_active(self, request, queryset): diff --git a/backend/igny8_core/auth/migrations/0036_add_paypal_plan_id_to_plan.py b/backend/igny8_core/auth/migrations/0036_add_paypal_plan_id_to_plan.py new file mode 100644 index 00000000..c370e51e --- /dev/null +++ b/backend/igny8_core/auth/migrations/0036_add_paypal_plan_id_to_plan.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.10 on 2026-01-20 18:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('igny8_core_auth', '0035_account_bonus_credits'), + ] + + operations = [ + migrations.AddField( + model_name='plan', + name='paypal_plan_id', + field=models.CharField(blank=True, help_text='PayPal subscription plan ID', max_length=255, null=True), + ), + migrations.AlterField( + model_name='account', + name='status', + field=models.CharField(choices=[('active', 'Active'), ('suspended', 'Suspended'), ('trial', 'Trial'), ('cancelled', 'Cancelled'), ('pending_payment', 'Pending Payment')], default='active', max_length=20), + ), + migrations.AlterField( + model_name='historicalaccount', + name='status', + field=models.CharField(choices=[('active', 'Active'), ('suspended', 'Suspended'), ('trial', 'Trial'), ('cancelled', 'Cancelled'), ('pending_payment', 'Pending Payment')], default='active', max_length=20), + ), + ] diff --git a/backend/igny8_core/auth/models.py b/backend/igny8_core/auth/models.py index 7f2b3462..3feb5dc3 100644 --- a/backend/igny8_core/auth/models.py +++ b/backend/igny8_core/auth/models.py @@ -387,6 +387,9 @@ class Plan(models.Model): stripe_product_id = models.CharField(max_length=255, blank=True, null=True, help_text="For Stripe plan sync") stripe_price_id = models.CharField(max_length=255, blank=True, null=True, help_text="Monthly price ID for Stripe") + # PayPal Integration + paypal_plan_id = models.CharField(max_length=255, blank=True, null=True, help_text="PayPal subscription plan ID") + # Legacy field for backward compatibility credits_per_month = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="DEPRECATED: Use included_credits instead") diff --git a/backend/igny8_core/business/billing/urls.py b/backend/igny8_core/business/billing/urls.py index 3d66f110..4e0c1091 100644 --- a/backend/igny8_core/business/billing/urls.py +++ b/backend/igny8_core/business/billing/urls.py @@ -30,6 +30,7 @@ from .views.paypal_views import ( PayPalCreateSubscriptionOrderView, PayPalCaptureOrderView, PayPalCreateSubscriptionView, + PayPalVerifySubscriptionView, PayPalReturnVerificationView, paypal_webhook, ) @@ -68,6 +69,7 @@ urlpatterns = [ path('paypal/create-subscription-order/', PayPalCreateSubscriptionOrderView.as_view(), name='paypal-create-subscription-order'), path('paypal/capture-order/', PayPalCaptureOrderView.as_view(), name='paypal-capture-order'), path('paypal/create-subscription/', PayPalCreateSubscriptionView.as_view(), name='paypal-create-subscription'), + path('paypal/verify-subscription/', PayPalVerifySubscriptionView.as_view(), name='paypal-verify-subscription'), path('paypal/verify-return/', PayPalReturnVerificationView.as_view(), name='paypal-verify-return'), path('webhooks/paypal/', paypal_webhook, name='paypal-webhook'), ] diff --git a/backend/igny8_core/business/billing/views/paypal_views.py b/backend/igny8_core/business/billing/views/paypal_views.py index 4a5df942..1320b204 100644 --- a/backend/igny8_core/business/billing/views/paypal_views.py +++ b/backend/igny8_core/business/billing/views/paypal_views.py @@ -384,6 +384,240 @@ class PayPalCaptureOrderView(APIView): ) +class PayPalVerifySubscriptionView(APIView): + """Verify PayPal subscription and activate account""" + permission_classes = [IsAuthenticatedAndActive] + + def post(self, request): + """ + Verify PayPal subscription status and activate account if approved. + + This is a fallback for when webhooks don't fire or are delayed. + Called by frontend after user returns from PayPal approval. + + Request body: + { + "subscription_id": "I-xxxxxxxxxxxxx" + } + + Returns: + { + "status": "active", + "subscription_id": "I-...", + "plan_name": "Starter", + "message": "Subscription activated" + } + """ + account = request.user.account + subscription_id = request.data.get('subscription_id') + + if not subscription_id: + return error_response( + error='subscription_id is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + try: + service = PayPalService() + + # Get subscription details from PayPal + sub_details = service.get_subscription(subscription_id) + + logger.info( + f"PayPal subscription verification for account {account.id}: " + f"subscription={subscription_id}, status={sub_details.get('status')}" + ) + + paypal_status = sub_details.get('status', '').upper() + + # Check if subscription is active/approved + if paypal_status not in ['ACTIVE', 'APPROVED']: + return error_response( + error=f'Subscription not active. Status: {paypal_status}', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Get PayPal plan ID and find matching Django plan + paypal_plan_id = sub_details.get('plan_id') + + plan = Plan.objects.filter(paypal_plan_id=paypal_plan_id).first() + if not plan: + logger.error(f"No plan found with paypal_plan_id={paypal_plan_id}") + return error_response( + error='Plan configuration error', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Check if already activated (avoid duplicates) + existing_sub = Subscription.objects.filter( + account=account, + external_payment_id=subscription_id, + status='active' + ).first() + + if existing_sub: + logger.info(f"Subscription {subscription_id} already activated for account {account.id}") + return success_response( + data={ + 'status': 'active', + 'subscription_id': subscription_id, + 'plan_name': plan.name, + 'already_active': True + }, + message='Subscription already active', + request=request + ) + + # Activate the subscription + with transaction.atomic(): + now = timezone.now() + period_end = now + timedelta(days=30) + + # Create or update subscription + subscription, created = Subscription.objects.update_or_create( + account=account, + defaults={ + 'plan': plan, + 'status': 'active', + 'external_payment_id': subscription_id, + 'current_period_start': now, + 'current_period_end': period_end, + } + ) + + # Get or create invoice + invoice, _ = InvoiceService.get_or_create_subscription_invoice( + subscription=subscription, + billing_period_start=now, + billing_period_end=period_end, + ) + + # Mark invoice as paid + InvoiceService.mark_paid( + invoice=invoice, + payment_method='paypal', + transaction_id=subscription_id + ) + + # Create payment record + Payment.objects.create( + account=account, + invoice=invoice, + amount=float(plan.price), + currency='USD', + payment_method='paypal', + status='succeeded', + processed_at=now, + approved_at=now, + metadata={ + 'plan_id': str(plan.id), + 'subscription_type': 'paypal_subscription', + 'paypal_subscription_id': subscription_id, + 'auto_approved': True, + } + ) + + # Update/create AccountPaymentMethod + from ..models import AccountPaymentMethod + + # Clear default from existing methods + AccountPaymentMethod.objects.filter(account=account).update(is_default=False) + + # Delete any existing PayPal method + AccountPaymentMethod.objects.filter(account=account, type='paypal').delete() + + # Create fresh PayPal payment method + AccountPaymentMethod.objects.create( + account=account, + type='paypal', + display_name='PayPal', + is_default=True, + is_enabled=True, + is_verified=True, + country_code=account.billing_country or '', + metadata={ + 'last_payment_at': now.isoformat(), + 'paypal_subscription_id': subscription_id, + } + ) + + # Add subscription credits + credits_added = 0 + if plan.included_credits and plan.included_credits > 0: + CreditService.add_credits( + account=account, + amount=plan.included_credits, + transaction_type='subscription', + description=f'PayPal Subscription: {plan.name}', + metadata={ + 'plan_id': str(plan.id), + 'payment_method': 'paypal', + } + ) + credits_added = plan.included_credits + + # Activate account and set plan + update_fields = ['updated_at'] + if account.status != 'active': + account.status = 'active' + update_fields.append('status') + if account.plan_id != plan.id: + account.plan = plan + update_fields.append('plan') + if account.payment_method != 'paypal': + account.payment_method = 'paypal' + update_fields.append('payment_method') + account.save(update_fields=update_fields) + + logger.info( + f"PayPal subscription activated for account {account.id}: " + f"plan={plan.name}, credits_added={credits_added}" + ) + + # Send subscription activated email + try: + from igny8_core.business.billing.services.email_service import BillingEmailService + BillingEmailService.send_subscription_activated_email(account, subscription) + except Exception as e: + logger.error(f"Failed to send subscription activated email: {e}") + + return success_response( + data={ + 'status': 'active', + 'subscription_id': subscription_id, + 'plan_name': plan.name, + 'credits_added': credits_added, + }, + message='Subscription activated successfully', + request=request + ) + + except PayPalConfigurationError as e: + logger.error(f"PayPal configuration error: {e}") + return error_response( + error='Payment system not configured', + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + request=request + ) + except PayPalAPIError as e: + logger.error(f"PayPal API error verifying subscription: {e}") + return error_response( + error=f'PayPal error: {str(e)}', + status_code=status.HTTP_502_BAD_GATEWAY, + request=request + ) + except Exception as e: + logger.exception(f"PayPal subscription verification error: {e}") + return error_response( + error='Failed to verify subscription', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) + + class PayPalCreateSubscriptionView(APIView): """Create PayPal recurring subscription""" permission_classes = [IsAuthenticatedAndActive] diff --git a/backend/igny8_core/modules/billing/admin.py b/backend/igny8_core/modules/billing/admin.py index a3b8f91a..d958571d 100644 --- a/backend/igny8_core/modules/billing/admin.py +++ b/backend/igny8_core/modules/billing/admin.py @@ -640,10 +640,27 @@ class CreditPackageAdmin(ImportExportMixin, Igny8ModelAdmin): 'bulk_activate', 'bulk_deactivate', ] - actions = [ - 'bulk_activate', - 'bulk_deactivate', - ] + + fieldsets = ( + ('Package Info', { + 'fields': ('name', 'slug', 'credits', 'price', 'discount_percentage', 'is_active', 'is_featured', 'sort_order') + }), + ('Display', { + 'fields': ('description', 'features') + }), + ('Stripe Integration', { + 'fields': ('stripe_product_id', 'stripe_price_id'), + 'description': 'Stripe product/price IDs for credit package purchases' + }), + ('PayPal Integration', { + 'fields': ('paypal_plan_id',), + 'description': 'PayPal plan ID for credit package purchases' + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) def bulk_activate(self, request, queryset): updated = queryset.update(is_active=True) diff --git a/docs/90-REFERENCE/DJANGO-ADMIN-ACCESS-GUIDE.md b/docs/90-REFERENCE/DJANGO-ADMIN-ACCESS-GUIDE.md index e15bb20a..baf601c2 100644 --- a/docs/90-REFERENCE/DJANGO-ADMIN-ACCESS-GUIDE.md +++ b/docs/90-REFERENCE/DJANGO-ADMIN-ACCESS-GUIDE.md @@ -226,12 +226,53 @@ Go to [developer.paypal.com](https://developer.paypal.com) 5. Click **"Save"** 6. Copy the **Webhook ID** (starts with `WH-...`) -#### Step 4: Create Subscription Plans (Optional) +#### Step 4: Create Subscription Plans (Required for Subscriptions) -If you want PayPal subscriptions: -1. Go to **Products** in PayPal dashboard -2. Create subscription plans matching your Stripe plans -3. Copy the **Plan IDs** +PayPal subscriptions require creating **Products** and **Plans** in the PayPal dashboard. This is a manual process (not done via API in our implementation). + +##### 4.4.1 Create a PayPal Product + +1. Go to [sandbox.paypal.com/billing/plans](https://www.sandbox.paypal.com/billing/plans) (Sandbox) or [paypal.com/billing/plans](https://www.paypal.com/billing/plans) (Live) +2. Click **"Create product"** (or go to Products tab first) +3. Fill in: + - **Product name**: `IGNY8 Subscription` + - **Product type**: `Service` + - **Product ID**: `IGNY8-SUB` (or auto-generate) + - **Description**: `IGNY8 subscription plans` +4. Click **"Create product"** +5. Note the **Product ID** (e.g., `PROD-xxxxxxxxxxxxx`) + +##### 4.4.2 Create PayPal Plans (One Per IGNY8 Plan) + +For each plan in your system (Starter, Growth, Scale), create a PayPal plan: + +1. In PayPal dashboard, click **"Create plan"** +2. Select the product you just created +3. Fill in plan details: + +**Starter Plan:** +- **Plan name**: `Starter Plan - Monthly` +- **Description**: `Basic plan for small projects` +- **Pricing**: + - Billing cycle: `Monthly` + - Price: `$99.00 USD` + - Total cycles: `0` (infinite) +- **Setup fee**: `$0.00` (optional) +4. Click **"Create plan"** +5. **Copy the Plan ID** (starts with `P-...`, e.g., `P-5ML4271244454362WXXX`) + +Repeat for Growth ($199/month) and Scale ($299/month) plans. + +##### 4.4.3 Map PayPal Plan IDs to Django Plans + +1. Go to Django Admin → **Auth → Plans** +2. Edit **Starter Plan**: + - Scroll to **"PayPal Integration"** section + - **Paypal plan id**: Paste `P-xxxxxxxxxxxxx` +3. Click **"Save"** +4. Repeat for Growth and Scale plans + +**Note:** Without `paypal_plan_id` set, the subscription creation API will return an error. ### 4.2 Adding to Django Admin @@ -266,6 +307,65 @@ Config (JSON): 4. Click **"Save"** +### 4.3 Live PayPal Payment Enablement (Production) + +Use this section when switching from sandbox to live PayPal payments. + +#### Step 1: Create/Select a Live App +1. Go to [developer.paypal.com](https://developer.paypal.com) +2. Select **Live** (top toggle) +3. Create a new app or select your existing live app +4. Copy the **Live Client ID** and **Live Secret** + +#### Step 2: Configure Live Webhook +1. In your live app settings, add a webhook: + ``` + https://api.igny8.com/api/v1/billing/webhooks/paypal/ + ``` +2. Select events: + - `CHECKOUT.ORDER.APPROVED` + - `PAYMENT.CAPTURE.COMPLETED` + - `PAYMENT.CAPTURE.DENIED` + - `BILLING.SUBSCRIPTION.ACTIVATED` + - `BILLING.SUBSCRIPTION.CANCELLED` +3. Copy the **Live Webhook ID** + +#### Step 3: Update Django Admin Provider (Live) +1. Go to **System → Integration providers → paypal** +2. Update fields: + - **API key**: Live Client ID + - **API secret**: Live Secret + - **API endpoint**: `https://api-m.paypal.com` + - **Config (JSON)**: set `webhook_id` to the live webhook ID +3. Set: + - ✅ `is_active` = True + - ✅ `is_sandbox` = False +4. Click **"Save"** + +#### Step 3.1: Map PayPal Plan IDs in Django +PayPal subscription webhooks only work if your plans are mapped. + +1. Go to Django Admin → **Auth → Plans** +2. For each plan, set: + - **Paypal plan id**: Live PayPal Plan ID (starts with `P-...`) +3. Save each plan + +#### Step 4: Validate Live Payment Flow +1. Open frontend: `/account/usage` +2. Select **PayPal** and complete a real payment +3. Confirm: + - Order is captured + - Credits are added + - Payment email is sent + +#### Step 5: Validate PayPal Subscriptions (If Enabled) +1. Open frontend: `/account/plans` +2. Select **PayPal** and subscribe to a plan +3. Confirm: + - Subscription is activated + - Webhook events are processed + - Account plan is updated + --- ## 5. Resend Configuration @@ -561,21 +661,83 @@ Go to Admin → **System → Integration providers** ### 8.3 Test PayPal Integration -1. **Get Config Endpoint:** - ```bash - curl -X GET https://api.igny8.com/api/v1/billing/paypal/config/ \ - -H "Authorization: Bearer YOUR_JWT_TOKEN" - ``` - Should return client_id and sandbox status +#### 8.3.1 Verify PayPal Provider Config -2. **Test Credit Purchase:** - - Go to frontend: `/account/usage` - - Select "PayPal" as payment method - - Click "Buy Credits" on a package - - Should redirect to PayPal - - Login with sandbox account - - Complete payment - - Should capture order and add credits +```bash +curl -X GET https://api.igny8.com/api/v1/billing/paypal/config/ \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" +``` +Should return `client_id` and `sandbox: true`. + +#### 8.3.2 Test One-Time Credit Purchase (Orders API) + +1. Go to frontend: `/account/usage` +2. Select **PayPal** as payment method +3. Click **"Buy Credits"** on a package +4. Should redirect to PayPal sandbox +5. Login with sandbox buyer account: + - Email: `sb-buyer@personal.example.com` (from PayPal sandbox accounts) + - Password: (your sandbox password) +6. Complete payment +7. **Verify:** + - Order is captured (check webhook logs) + - Credits are added to account + - Payment email is sent + +#### 8.3.3 Test PayPal Subscriptions (Subscriptions API) + +**Prerequisites:** +- [ ] PayPal Plans created in dashboard (see Section 4, Step 4) +- [ ] `paypal_plan_id` set on each Django Plan (Auth → Plans) + +**Test Steps:** + +1. **Verify Plan Configuration:** + ```bash + # Check if plan has paypal_plan_id + docker exec -it igny8_backend python manage.py shell -c " + from igny8_core.auth.models import Plan + for p in Plan.objects.filter(is_active=True): + print(f'{p.name}: paypal_plan_id={p.paypal_plan_id}') + " + ``` + All paid plans should show a `P-xxxxx` ID. + +2. **Create PayPal Subscription:** + - Go to frontend: `/account/plans` + - Select **PayPal** as payment method + - Click **"Subscribe"** on Starter/Growth/Scale plan + - Redirects to PayPal for approval + - Login with sandbox buyer account + - Approve the subscription + +3. **Verify Subscription Activation:** + - Check webhook logs: `BILLING.SUBSCRIPTION.ACTIVATED` should fire + - Account plan should be updated + - `Subscription` record created with `external_payment_id` = PayPal subscription ID + - Credits added based on plan's `included_credits` + +4. **Verify in Django Admin:** + - Go to **Auth → Subscriptions** + - Find the new subscription + - Confirm: + - `status` = `active` + - `external_payment_id` = `I-xxxxx` (PayPal subscription ID) + - `plan` = correct plan + +5. **Test Subscription Cancellation:** + - In PayPal sandbox, go to **Pay & Get Paid → Subscriptions** + - Cancel the test subscription + - `BILLING.SUBSCRIPTION.CANCELLED` webhook should fire + - Subscription status should update to `canceled` + +**Sandbox Test Accounts:** + +Create sandbox accounts at [developer.paypal.com/dashboard/accounts](https://developer.paypal.com/dashboard/accounts): +- **Business account** - receives payments (seller) +- **Personal account** - makes payments (buyer) + +Use the personal account credentials when approving payments/subscriptions. ### 8.4 Test Email Service @@ -694,6 +856,28 @@ Go to Admin → **System → Integration providers** - Check order status (must be APPROVED) - Verify order hasn't already been captured +**"PayPal plan ID not configured for this plan"** +- The Plan model is missing `paypal_plan_id` +- Go to Django Admin → **Auth → Plans** +- Edit the plan and add the PayPal Plan ID (starts with `P-...`) +- Create the plan in PayPal dashboard first if not done + +**"No plan found with paypal_plan_id=..."** +- Webhook received but no matching plan in Django +- Verify the `paypal_plan_id` in Django matches exactly what's in PayPal +- Check for typos or extra whitespace + +**Subscription not activating after approval** +- Check webhook logs for `BILLING.SUBSCRIPTION.ACTIVATED` event +- Verify webhook URL is correctly configured in PayPal app +- Check that `webhook_id` in config JSON matches PayPal dashboard +- Ensure sandbox/live environment matches between app and PayPal + +**PayPal subscription appears but no credits added** +- Check `included_credits` field on the Plan model +- Verify subscription webhook handler completed successfully +- Look for errors in Django logs during webhook processing + ### Resend Issues **"Invalid API key"** diff --git a/frontend/src/pages/account/PlansAndBillingPage.tsx b/frontend/src/pages/account/PlansAndBillingPage.tsx index 287ae2ae..21f0adac 100644 --- a/frontend/src/pages/account/PlansAndBillingPage.tsx +++ b/frontend/src/pages/account/PlansAndBillingPage.tsx @@ -153,6 +153,7 @@ export default function PlansAndBillingPage() { const purchase = params.get('purchase'); const paypalStatus = params.get('paypal'); const paypalToken = params.get('token'); // PayPal token from URL + const paypalSubscriptionId = params.get('subscription_id'); // PayPal subscription ID (for recurring) const planIdParam = params.get('plan_id'); const packageIdParam = params.get('package_id'); @@ -169,7 +170,8 @@ export default function PlansAndBillingPage() { // Detect which payment flow we're in const paymentFlow = - (paypalStatus === 'success' && paypalToken) ? 'PAYPAL_SUCCESS' : + (paypalStatus === 'success' && paypalSubscriptionId) ? 'PAYPAL_SUBSCRIPTION_SUCCESS' : + (paypalStatus === 'success' && paypalToken) ? 'PAYPAL_ORDER_SUCCESS' : paypalStatus === 'cancel' ? 'PAYPAL_CANCEL' : (success === 'true' && sessionId) ? 'STRIPE_SUCCESS_WITH_SESSION' : success === 'true' ? 'STRIPE_SUCCESS_NO_SESSION' : @@ -180,11 +182,90 @@ export default function PlansAndBillingPage() { console.log(`${LOG_PREFIX} ===== DETECTED PAYMENT FLOW =====`); console.log(`${LOG_PREFIX} Flow type:`, paymentFlow); + console.log(`${LOG_PREFIX} PayPal subscription_id:`, paypalSubscriptionId); console.groupEnd(); - // Handle PayPal return - Get order_id from localStorage and capture + // Handle PayPal SUBSCRIPTION return (recurring billing) + // Call backend to verify subscription status and activate account + if (paypalStatus === 'success' && paypalSubscriptionId) { + console.group(`${LOG_PREFIX} PayPal Subscription Success Flow`); + console.log(`${LOG_PREFIX} Subscription ID:`, paypalSubscriptionId); + + // Show processing UI + setPaymentProcessing({ + active: true, + stage: 'verifying', + message: 'Verifying your subscription...' + }); + + // Clean URL immediately + window.history.replaceState({}, '', window.location.pathname); + + // Clean up any stored data + localStorage.removeItem('paypal_order_id'); + localStorage.removeItem('paypal_subscription_id'); + + // Call backend to verify and activate subscription + import('../../services/billing.api').then(async ({ verifyPayPalSubscription }) => { + try { + console.log(`${LOG_PREFIX} Calling verifyPayPalSubscription...`); + + setPaymentProcessing({ + active: true, + stage: 'processing', + message: 'Processing your subscription...' + }); + + const result = await verifyPayPalSubscription(paypalSubscriptionId); + + console.log(`${LOG_PREFIX} ✓ Subscription verification result:`, result); + + setPaymentProcessing({ + active: true, + stage: 'activating', + message: 'Activating your account...' + }); + + // Refresh user data to get updated account status + try { + await refreshUser(); + console.log(`${LOG_PREFIX} ✓ User refreshed after subscription activation`); + } catch (refreshErr) { + console.error(`${LOG_PREFIX} User refresh failed:`, refreshErr); + } + + // Show success + setTimeout(() => { + setPaymentProcessing(null); + if (result.already_active) { + toast?.info?.('Subscription is already active!'); + } else { + toast?.success?.(`Subscription activated! ${result.credits_added ? `${result.credits_added} credits added.` : ''}`); + } + loadData(); + }, 500); + + } catch (err: any) { + console.error(`${LOG_PREFIX} ❌ Subscription verification FAILED:`, err); + setPaymentProcessing(null); + + // Show specific error message + const errorMsg = err?.message || err?.error || 'Failed to activate subscription'; + toast?.error?.(errorMsg); + + // Still load data to show current state + loadData(); + } + + console.groupEnd(); + }); + + return; + } + + // Handle PayPal ORDER return (one-time credit purchase) - Get order_id from localStorage and capture if (paypalStatus === 'success' && paypalToken) { - console.group(`${LOG_PREFIX} PayPal Success Flow`); + console.group(`${LOG_PREFIX} PayPal Order Success Flow`); // FIX: Retrieve order_id from localStorage (stored before redirect) const storedOrderId = localStorage.getItem('paypal_order_id'); diff --git a/frontend/src/services/billing.api.ts b/frontend/src/services/billing.api.ts index 28e33229..14f40c88 100644 --- a/frontend/src/services/billing.api.ts +++ b/frontend/src/services/billing.api.ts @@ -1373,6 +1373,27 @@ export async function createPayPalSubscription(planId: string, options?: { }); } +/** + * Verify and activate PayPal subscription + * Called after user returns from PayPal with subscription_id + * This activates the account if subscription is approved on PayPal + */ +export async function verifyPayPalSubscription(subscriptionId: string): Promise<{ + status: string; + subscription_id: string; + plan_name: string; + credits_added?: number; + already_active?: boolean; + message: string; +}> { + return fetchAPI('/v1/billing/paypal/verify-subscription/', { + method: 'POST', + body: JSON.stringify({ + subscription_id: subscriptionId, + }), + }); +} + // ============================================================================ // PAYMENT GATEWAY HELPERS // ============================================================================ @@ -1445,10 +1466,11 @@ export async function subscribeToPlan( return { redirect_url: redirectUrl.toString() }; } case 'paypal': { - const order = await createPayPalSubscriptionOrder(planId, options); - // FIX: Store order_id in localStorage before redirect - localStorage.setItem('paypal_order_id', order.order_id); - return { redirect_url: order.approval_url }; + // Use PayPal Subscriptions API for recurring billing + const subscription = await createPayPalSubscription(planId, options); + // Store subscription_id for verification after return + localStorage.setItem('paypal_subscription_id', subscription.subscription_id); + return { redirect_url: subscription.approval_url }; } case 'manual': throw new Error('Manual payment requires different flow - use submitManualPayment()');