AL lPyment methods fully fucntion onstripe and paypal on sandbox
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"**
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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()');
|
||||
|
||||
Reference in New Issue
Block a user