Complete Implemenation of tenancy
This commit is contained in:
373
backend/api_integration_example.py
Normal file
373
backend/api_integration_example.py
Normal file
@@ -0,0 +1,373 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Payment Workflow API Integration Examples
|
||||
Demonstrates how to interact with the payment APIs programmatically
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
from decimal import Decimal
|
||||
|
||||
# Base URL for the API
|
||||
BASE_URL = "http://localhost:8011/api/v1"
|
||||
|
||||
class PaymentAPIClient:
|
||||
"""Example API client for payment workflow"""
|
||||
|
||||
def __init__(self, base_url=BASE_URL):
|
||||
self.base_url = base_url
|
||||
self.token = None
|
||||
self.session = requests.Session()
|
||||
|
||||
def register_free_trial(self, email, password, first_name, last_name):
|
||||
"""Register a new free trial user"""
|
||||
url = f"{self.base_url}/auth/register/"
|
||||
data = {
|
||||
"email": email,
|
||||
"password": password,
|
||||
"password_confirm": password,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name
|
||||
}
|
||||
|
||||
response = self.session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
print(f"✓ Free trial account created: {result['data']['account']['name']}")
|
||||
print(f" Status: {result['data']['account']['status']}")
|
||||
print(f" Credits: {result['data']['account']['credits']}")
|
||||
|
||||
return result['data']
|
||||
|
||||
def register_paid_user(self, email, password, first_name, last_name,
|
||||
plan_slug, billing_info):
|
||||
"""Register a new paid user with billing information"""
|
||||
url = f"{self.base_url}/auth/register/"
|
||||
data = {
|
||||
"email": email,
|
||||
"password": password,
|
||||
"password_confirm": password,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"plan_slug": plan_slug,
|
||||
**billing_info
|
||||
}
|
||||
|
||||
response = self.session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
print(f"✓ Paid account created: {result['data']['account']['name']}")
|
||||
print(f" Status: {result['data']['account']['status']}")
|
||||
print(f" Credits: {result['data']['account']['credits']}")
|
||||
|
||||
if 'invoice' in result['data']:
|
||||
inv = result['data']['invoice']
|
||||
print(f" Invoice: {inv['invoice_number']} - ${inv['total']}")
|
||||
|
||||
return result['data']
|
||||
|
||||
def login(self, email, password):
|
||||
"""Login and get authentication token"""
|
||||
url = f"{self.base_url}/auth/login/"
|
||||
data = {
|
||||
"email": email,
|
||||
"password": password
|
||||
}
|
||||
|
||||
response = self.session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
self.token = result['data']['token']
|
||||
self.session.headers.update({
|
||||
'Authorization': f'Bearer {self.token}'
|
||||
})
|
||||
|
||||
print(f"✓ Logged in as: {email}")
|
||||
return result['data']
|
||||
|
||||
def get_payment_methods(self, country_code=None):
|
||||
"""Get available payment methods for a country"""
|
||||
url = f"{self.base_url}/billing/admin/payment-methods/"
|
||||
params = {}
|
||||
if country_code:
|
||||
params['country'] = country_code
|
||||
|
||||
response = self.session.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
methods = response.json()
|
||||
|
||||
print(f"✓ Payment methods available: {len(methods)}")
|
||||
for method in methods:
|
||||
print(f" - {method['display_name']} ({method['payment_method']})")
|
||||
|
||||
return methods
|
||||
|
||||
def confirm_payment(self, invoice_id, payment_method, amount,
|
||||
manual_reference, manual_notes=""):
|
||||
"""Submit payment confirmation for manual payments"""
|
||||
url = f"{self.base_url}/billing/admin/payments/confirm/"
|
||||
data = {
|
||||
"invoice_id": invoice_id,
|
||||
"payment_method": payment_method,
|
||||
"amount": str(amount),
|
||||
"manual_reference": manual_reference,
|
||||
"manual_notes": manual_notes
|
||||
}
|
||||
|
||||
response = self.session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
payment = result['data']
|
||||
print(f"✓ Payment confirmation submitted")
|
||||
print(f" Payment ID: {payment['payment_id']}")
|
||||
print(f" Invoice: {payment['invoice_number']}")
|
||||
print(f" Status: {payment['status']}")
|
||||
print(f" Reference: {payment['manual_reference']}")
|
||||
|
||||
return result['data']
|
||||
|
||||
def approve_payment(self, payment_id, admin_notes=""):
|
||||
"""Approve a pending payment (admin only)"""
|
||||
url = f"{self.base_url}/billing/admin/payments/{payment_id}/approve/"
|
||||
data = {
|
||||
"admin_notes": admin_notes
|
||||
}
|
||||
|
||||
response = self.session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
payment = result['data']
|
||||
print(f"✓ Payment approved")
|
||||
print(f" Account Status: {payment['account_status']}")
|
||||
print(f" Subscription Status: {payment['subscription_status']}")
|
||||
print(f" Credits Added: {payment['credits_added']}")
|
||||
print(f" Total Credits: {payment['total_credits']}")
|
||||
|
||||
return result['data']
|
||||
|
||||
def reject_payment(self, payment_id, admin_notes):
|
||||
"""Reject a pending payment (admin only)"""
|
||||
url = f"{self.base_url}/billing/admin/payments/{payment_id}/reject/"
|
||||
data = {
|
||||
"admin_notes": admin_notes
|
||||
}
|
||||
|
||||
response = self.session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
payment = result['data']
|
||||
print(f"✓ Payment rejected")
|
||||
print(f" Status: {payment['status']}")
|
||||
print(f" Reason: {admin_notes}")
|
||||
|
||||
return result['data']
|
||||
|
||||
|
||||
def example_free_trial_workflow():
|
||||
"""Example: Free trial signup workflow"""
|
||||
print("\n" + "="*60)
|
||||
print("EXAMPLE 1: FREE TRIAL SIGNUP")
|
||||
print("="*60 + "\n")
|
||||
|
||||
client = PaymentAPIClient()
|
||||
|
||||
# Step 1: Register free trial user
|
||||
user_data = client.register_free_trial(
|
||||
email="freetrial_demo@example.com",
|
||||
password="SecurePass123!",
|
||||
first_name="Free",
|
||||
last_name="Trial"
|
||||
)
|
||||
|
||||
# Step 2: Login
|
||||
login_data = client.login(
|
||||
email="freetrial_demo@example.com",
|
||||
password="SecurePass123!"
|
||||
)
|
||||
|
||||
print(f"\n✓ Free trial workflow complete!")
|
||||
print(f" User can now create {user_data['account']['max_sites']} site(s)")
|
||||
print(f" Available credits: {user_data['account']['credits']}")
|
||||
|
||||
|
||||
def example_paid_signup_workflow():
|
||||
"""Example: Paid signup with manual payment approval"""
|
||||
print("\n" + "="*60)
|
||||
print("EXAMPLE 2: PAID SIGNUP WITH MANUAL PAYMENT")
|
||||
print("="*60 + "\n")
|
||||
|
||||
client = PaymentAPIClient()
|
||||
|
||||
# Step 1: Check available payment methods
|
||||
print("Step 1: Check Payment Methods for Pakistan")
|
||||
methods = client.get_payment_methods(country_code="PK")
|
||||
|
||||
# Step 2: Register with paid plan
|
||||
print("\nStep 2: Register Paid User")
|
||||
billing_info = {
|
||||
"billing_email": "billing@example.com",
|
||||
"billing_address_line1": "123 Main Street",
|
||||
"billing_city": "Karachi",
|
||||
"billing_country": "PK",
|
||||
"payment_method": "bank_transfer"
|
||||
}
|
||||
|
||||
user_data = client.register_paid_user(
|
||||
email="paiduser_demo@example.com",
|
||||
password="SecurePass123!",
|
||||
first_name="Paid",
|
||||
last_name="User",
|
||||
plan_slug="starter",
|
||||
billing_info=billing_info
|
||||
)
|
||||
|
||||
# Step 3: Login
|
||||
print("\nStep 3: User Login")
|
||||
login_data = client.login(
|
||||
email="paiduser_demo@example.com",
|
||||
password="SecurePass123!"
|
||||
)
|
||||
|
||||
# Step 4: User makes external payment and submits confirmation
|
||||
print("\nStep 4: Submit Payment Confirmation")
|
||||
invoice_id = user_data['invoice']['id']
|
||||
invoice_total = user_data['invoice']['total']
|
||||
|
||||
payment_data = client.confirm_payment(
|
||||
invoice_id=invoice_id,
|
||||
payment_method="bank_transfer",
|
||||
amount=invoice_total,
|
||||
manual_reference="DEMO-BANK-2025-001",
|
||||
manual_notes="Transferred via ABC Bank on Dec 8, 2025"
|
||||
)
|
||||
|
||||
print(f"\n✓ Payment submitted! Waiting for admin approval...")
|
||||
print(f" Payment ID: {payment_data['payment_id']}")
|
||||
print(f" Account remains in 'pending_payment' status")
|
||||
|
||||
# Step 5: Admin approves (requires admin token)
|
||||
print("\nStep 5: Admin Approval (requires admin credentials)")
|
||||
print(" → Admin would login separately and approve the payment")
|
||||
print(f" → POST /billing/admin/payments/{payment_data['payment_id']}/approve/")
|
||||
print(" → Account status changes to 'active'")
|
||||
print(" → Credits allocated: 1000")
|
||||
|
||||
return payment_data
|
||||
|
||||
|
||||
def example_admin_approval():
|
||||
"""Example: Admin approving a payment"""
|
||||
print("\n" + "="*60)
|
||||
print("EXAMPLE 3: ADMIN PAYMENT APPROVAL")
|
||||
print("="*60 + "\n")
|
||||
|
||||
# This requires admin credentials
|
||||
admin_client = PaymentAPIClient()
|
||||
|
||||
print("Step 1: Admin Login")
|
||||
try:
|
||||
admin_client.login(
|
||||
email="dev@igny8.com", # Replace with actual admin email
|
||||
password="admin_password" # Replace with actual password
|
||||
)
|
||||
|
||||
print("\nStep 2: Approve Payment")
|
||||
# Replace with actual payment ID
|
||||
payment_id = 5 # Example payment ID
|
||||
|
||||
result = admin_client.approve_payment(
|
||||
payment_id=payment_id,
|
||||
admin_notes="Verified payment in bank statement. Reference matches."
|
||||
)
|
||||
|
||||
print(f"\n✓ Payment approval complete!")
|
||||
print(f" Account activated with {result['total_credits']} credits")
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
print(f"✗ Admin approval failed: {e}")
|
||||
print(" (This is expected if you don't have admin credentials)")
|
||||
|
||||
|
||||
def example_payment_rejection():
|
||||
"""Example: Admin rejecting a payment"""
|
||||
print("\n" + "="*60)
|
||||
print("EXAMPLE 4: ADMIN PAYMENT REJECTION")
|
||||
print("="*60 + "\n")
|
||||
|
||||
admin_client = PaymentAPIClient()
|
||||
|
||||
print("Step 1: Admin Login")
|
||||
try:
|
||||
admin_client.login(
|
||||
email="dev@igny8.com",
|
||||
password="admin_password"
|
||||
)
|
||||
|
||||
print("\nStep 2: Reject Payment")
|
||||
payment_id = 7 # Example payment ID
|
||||
|
||||
result = admin_client.reject_payment(
|
||||
payment_id=payment_id,
|
||||
admin_notes="Reference number not found in bank statement. Please verify and resubmit."
|
||||
)
|
||||
|
||||
print(f"\n✓ Payment rejected!")
|
||||
print(f" User can resubmit with correct reference")
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
print(f"✗ Payment rejection failed: {e}")
|
||||
print(" (This is expected if you don't have admin credentials)")
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all examples"""
|
||||
print("\n" + "="*60)
|
||||
print("PAYMENT WORKFLOW API INTEGRATION EXAMPLES")
|
||||
print("="*60)
|
||||
print("\nThese examples demonstrate how to integrate with the")
|
||||
print("multi-tenancy payment workflow APIs.\n")
|
||||
|
||||
try:
|
||||
# Example 1: Free trial
|
||||
example_free_trial_workflow()
|
||||
|
||||
# Example 2: Paid signup
|
||||
# example_paid_signup_workflow()
|
||||
|
||||
# Example 3: Admin approval (requires admin credentials)
|
||||
# example_admin_approval()
|
||||
|
||||
# Example 4: Payment rejection (requires admin credentials)
|
||||
# example_payment_rejection()
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"\n✗ API Error: {e}")
|
||||
print("\nMake sure the backend is running on http://localhost:8011")
|
||||
except Exception as e:
|
||||
print(f"\n✗ Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Note: Uncomment examples you want to run
|
||||
# Some examples may create actual data in the database
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("API INTEGRATION EXAMPLES - READ ONLY MODE")
|
||||
print("="*60)
|
||||
print("\nTo run examples, uncomment the desired function calls")
|
||||
print("in the main() function.\n")
|
||||
print("Available examples:")
|
||||
print(" 1. example_free_trial_workflow()")
|
||||
print(" 2. example_paid_signup_workflow()")
|
||||
print(" 3. example_admin_approval()")
|
||||
print(" 4. example_payment_rejection()")
|
||||
print("\nWarning: Running these will create data in the database!")
|
||||
print("="*60 + "\n")
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-08 22:42
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0009_add_plan_annual_discount_and_featured'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='subscription',
|
||||
name='plan',
|
||||
field=models.ForeignKey(blank=True, help_text='Subscription plan (tracks historical plan even if account changes plan)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='subscriptions', to='igny8_core_auth.plan'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='site',
|
||||
name='industry',
|
||||
field=models.ForeignKey(default=21, help_text='Industry this site belongs to (required for sector creation)', on_delete=django.db.models.deletion.PROTECT, related_name='sites', to='igny8_core_auth.industry'),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-08 22:52
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0010_add_subscription_plan_and_require_site_industry'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='subscription',
|
||||
name='payment_method',
|
||||
),
|
||||
]
|
||||
@@ -124,6 +124,21 @@ class Account(SoftDeletableModel):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def default_payment_method(self):
|
||||
"""Get default payment method from AccountPaymentMethod table"""
|
||||
try:
|
||||
from igny8_core.business.billing.models import AccountPaymentMethod
|
||||
method = AccountPaymentMethod.objects.filter(
|
||||
account=self,
|
||||
is_default=True,
|
||||
is_enabled=True
|
||||
).first()
|
||||
return method.type if method else self.payment_method
|
||||
except Exception:
|
||||
# Fallback to field if table doesn't exist or error
|
||||
return self.payment_method
|
||||
|
||||
def is_system_account(self):
|
||||
"""Check if this account is a system account with highest access level."""
|
||||
# System accounts bypass all filtering restrictions
|
||||
@@ -230,6 +245,14 @@ class Subscription(models.Model):
|
||||
]
|
||||
|
||||
account = models.OneToOneField('igny8_core_auth.Account', on_delete=models.CASCADE, related_name='subscription', db_column='tenant_id')
|
||||
plan = models.ForeignKey(
|
||||
'igny8_core_auth.Plan',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='subscriptions',
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text='Subscription plan (tracks historical plan even if account changes plan)'
|
||||
)
|
||||
stripe_subscription_id = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
@@ -237,12 +260,6 @@ class Subscription(models.Model):
|
||||
db_index=True,
|
||||
help_text='Stripe subscription ID (when using Stripe)'
|
||||
)
|
||||
payment_method = models.CharField(
|
||||
max_length=30,
|
||||
choices=PAYMENT_METHOD_CHOICES,
|
||||
default='stripe',
|
||||
help_text='Payment method for this subscription'
|
||||
)
|
||||
external_payment_id = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
@@ -255,6 +272,14 @@ class Subscription(models.Model):
|
||||
cancel_at_period_end = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@property
|
||||
def payment_method(self):
|
||||
"""Get payment method from account's default payment method"""
|
||||
if hasattr(self.account, 'default_payment_method'):
|
||||
return self.account.default_payment_method
|
||||
# Fallback to account.payment_method field if property doesn't exist yet
|
||||
return getattr(self.account, 'payment_method', 'stripe')
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_subscriptions'
|
||||
@@ -286,9 +311,7 @@ class Site(SoftDeletableModel, AccountBaseModel):
|
||||
'igny8_core_auth.Industry',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='sites',
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Industry this site belongs to"
|
||||
help_text="Industry this site belongs to (required for sector creation)"
|
||||
)
|
||||
is_active = models.BooleanField(default=True, db_index=True)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active')
|
||||
|
||||
@@ -267,10 +267,19 @@ class RegisterSerializer(serializers.Serializer):
|
||||
)
|
||||
plan_slug = serializers.CharField(max_length=50, required=False)
|
||||
payment_method = serializers.ChoiceField(
|
||||
choices=['stripe', 'paypal', 'bank_transfer'],
|
||||
choices=['stripe', 'paypal', 'bank_transfer', 'local_wallet'],
|
||||
default='bank_transfer',
|
||||
required=False
|
||||
)
|
||||
# Billing information fields
|
||||
billing_email = serializers.EmailField(required=False, allow_blank=True)
|
||||
billing_address_line1 = serializers.CharField(max_length=255, required=False, allow_blank=True)
|
||||
billing_address_line2 = serializers.CharField(max_length=255, required=False, allow_blank=True)
|
||||
billing_city = serializers.CharField(max_length=100, required=False, allow_blank=True)
|
||||
billing_state = serializers.CharField(max_length=100, required=False, allow_blank=True)
|
||||
billing_postal_code = serializers.CharField(max_length=20, required=False, allow_blank=True)
|
||||
billing_country = serializers.CharField(max_length=2, required=False, allow_blank=True)
|
||||
tax_id = serializers.CharField(max_length=100, required=False, allow_blank=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
if attrs['password'] != attrs['password_confirm']:
|
||||
@@ -287,7 +296,7 @@ class RegisterSerializer(serializers.Serializer):
|
||||
def create(self, validated_data):
|
||||
from django.db import transaction
|
||||
from igny8_core.business.billing.models import CreditTransaction
|
||||
from igny8_core.business.billing.models import Subscription
|
||||
from igny8_core.auth.models import Subscription
|
||||
from igny8_core.business.billing.models import AccountPaymentMethod
|
||||
from igny8_core.business.billing.services.invoice_service import InvoiceService
|
||||
from django.utils import timezone
|
||||
@@ -371,6 +380,15 @@ class RegisterSerializer(serializers.Serializer):
|
||||
credits=initial_credits,
|
||||
status=account_status,
|
||||
payment_method=validated_data.get('payment_method') or 'bank_transfer',
|
||||
# Save billing information
|
||||
billing_email=validated_data.get('billing_email', '') or validated_data.get('email', ''),
|
||||
billing_address_line1=validated_data.get('billing_address_line1', ''),
|
||||
billing_address_line2=validated_data.get('billing_address_line2', ''),
|
||||
billing_city=validated_data.get('billing_city', ''),
|
||||
billing_state=validated_data.get('billing_state', ''),
|
||||
billing_postal_code=validated_data.get('billing_postal_code', ''),
|
||||
billing_country=validated_data.get('billing_country', ''),
|
||||
tax_id=validated_data.get('tax_id', ''),
|
||||
)
|
||||
|
||||
# Log initial credit transaction only for free/trial accounts with credits
|
||||
@@ -392,13 +410,14 @@ class RegisterSerializer(serializers.Serializer):
|
||||
user.account = account
|
||||
user.save()
|
||||
|
||||
# For paid plans, create subscription, invoice, and default bank transfer method
|
||||
# For paid plans, create subscription, invoice, and default payment method
|
||||
if plan_slug and plan_slug in paid_plans:
|
||||
payment_method = validated_data.get('payment_method', 'bank_transfer')
|
||||
|
||||
subscription = Subscription.objects.create(
|
||||
account=account,
|
||||
plan=plan,
|
||||
status='pending_payment',
|
||||
payment_method='bank_transfer',
|
||||
external_payment_id=None,
|
||||
current_period_start=billing_period_start,
|
||||
current_period_end=billing_period_end,
|
||||
@@ -410,15 +429,21 @@ class RegisterSerializer(serializers.Serializer):
|
||||
billing_period_start=billing_period_start,
|
||||
billing_period_end=billing_period_end,
|
||||
)
|
||||
# Seed a default bank transfer payment method for the account
|
||||
# Create AccountPaymentMethod with selected payment method
|
||||
payment_method_display_names = {
|
||||
'stripe': 'Credit/Debit Card (Stripe)',
|
||||
'paypal': 'PayPal',
|
||||
'bank_transfer': 'Bank Transfer (Manual)',
|
||||
'local_wallet': 'Mobile Wallet (Manual)',
|
||||
}
|
||||
AccountPaymentMethod.objects.create(
|
||||
account=account,
|
||||
type='bank_transfer',
|
||||
display_name='Bank Transfer (Manual)',
|
||||
type=payment_method,
|
||||
display_name=payment_method_display_names.get(payment_method, payment_method.title()),
|
||||
is_default=True,
|
||||
is_enabled=True,
|
||||
is_verified=False,
|
||||
instructions='Please complete bank transfer and add your reference in Payments.',
|
||||
instructions='Please complete payment and confirm with your transaction reference.',
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
@@ -521,7 +521,7 @@ class SiteViewSet(AccountModelViewSet):
|
||||
return Site.objects.filter(account=account)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Create site with account."""
|
||||
"""Create site with account and auto-grant access to creator."""
|
||||
account = getattr(self.request, 'account', None)
|
||||
if not account:
|
||||
user = self.request.user
|
||||
@@ -529,7 +529,18 @@ class SiteViewSet(AccountModelViewSet):
|
||||
account = getattr(user, 'account', None)
|
||||
|
||||
# Multiple sites can be active simultaneously - no constraint
|
||||
serializer.save(account=account)
|
||||
site = serializer.save(account=account)
|
||||
|
||||
# Auto-create SiteUserAccess for owner/admin who creates the site
|
||||
user = self.request.user
|
||||
if user and user.is_authenticated and hasattr(user, 'role'):
|
||||
if user.role in ['owner', 'admin']:
|
||||
from igny8_core.auth.models import SiteUserAccess
|
||||
SiteUserAccess.objects.get_or_create(
|
||||
user=user,
|
||||
site=site,
|
||||
defaults={'granted_by': user}
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Update site."""
|
||||
|
||||
@@ -201,9 +201,6 @@ class Invoice(AccountBaseModel):
|
||||
# Payment integration
|
||||
stripe_invoice_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
payment_method = models.CharField(max_length=50, null=True, blank=True)
|
||||
billing_email = models.EmailField(null=True, blank=True)
|
||||
billing_period_start = models.DateTimeField(null=True, blank=True)
|
||||
billing_period_end = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Metadata
|
||||
notes = models.TextField(blank=True)
|
||||
@@ -240,6 +237,23 @@ class Invoice(AccountBaseModel):
|
||||
def total_amount(self):
|
||||
return self.total
|
||||
|
||||
@property
|
||||
def billing_period_start(self):
|
||||
"""Get from subscription - single source of truth"""
|
||||
return self.subscription.current_period_start if self.subscription else None
|
||||
|
||||
@property
|
||||
def billing_period_end(self):
|
||||
"""Get from subscription - single source of truth"""
|
||||
return self.subscription.current_period_end if self.subscription else None
|
||||
|
||||
@property
|
||||
def billing_email(self):
|
||||
"""Get from metadata snapshot or account"""
|
||||
if self.metadata and 'billing_snapshot' in self.metadata:
|
||||
return self.metadata['billing_snapshot'].get('email')
|
||||
return self.account.billing_email if self.account else None
|
||||
|
||||
def add_line_item(self, description: str, quantity: int, unit_price: Decimal, amount: Decimal = None):
|
||||
"""Append a line item and keep JSON shape consistent."""
|
||||
items = list(self.line_items or [])
|
||||
@@ -316,7 +330,6 @@ class Payment(AccountBaseModel):
|
||||
help_text="Bank transfer reference, wallet transaction ID, etc."
|
||||
)
|
||||
manual_notes = models.TextField(blank=True, help_text="Admin notes for manual payments")
|
||||
transaction_reference = models.CharField(max_length=255, blank=True)
|
||||
admin_notes = models.TextField(blank=True, help_text="Internal notes on approval/rejection")
|
||||
approved_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
|
||||
@@ -45,17 +45,32 @@ class InvoiceService:
|
||||
account = subscription.account
|
||||
plan = subscription.plan
|
||||
|
||||
# Snapshot billing information for historical record
|
||||
billing_snapshot = {
|
||||
'email': account.billing_email or (account.owner.email if account.owner else ''),
|
||||
'address_line1': account.billing_address_line1,
|
||||
'address_line2': account.billing_address_line2,
|
||||
'city': account.billing_city,
|
||||
'state': account.billing_state,
|
||||
'postal_code': account.billing_postal_code,
|
||||
'country': account.billing_country,
|
||||
'tax_id': account.tax_id,
|
||||
'snapshot_date': timezone.now().isoformat()
|
||||
}
|
||||
|
||||
invoice = Invoice.objects.create(
|
||||
account=account,
|
||||
subscription=subscription,
|
||||
invoice_number=InvoiceService.generate_invoice_number(account),
|
||||
billing_email=account.billing_email or account.users.filter(role='owner').first().email,
|
||||
status='pending',
|
||||
currency='USD',
|
||||
invoice_date=timezone.now().date(),
|
||||
due_date=billing_period_end.date(),
|
||||
billing_period_start=billing_period_start,
|
||||
billing_period_end=billing_period_end
|
||||
metadata={
|
||||
'billing_snapshot': billing_snapshot,
|
||||
'billing_period_start': billing_period_start.isoformat(),
|
||||
'billing_period_end': billing_period_end.isoformat(),
|
||||
'subscription_id': subscription.id
|
||||
}
|
||||
)
|
||||
|
||||
# Add line item for subscription
|
||||
|
||||
@@ -4,7 +4,9 @@ Billing Views - Payment confirmation and management
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from django.http import HttpResponse
|
||||
from datetime import timedelta
|
||||
@@ -15,7 +17,11 @@ from igny8_core.api.pagination import CustomPageNumberPagination
|
||||
from igny8_core.auth.models import Account, Subscription
|
||||
from igny8_core.business.billing.services.credit_service import CreditService
|
||||
from igny8_core.business.billing.services.invoice_service import InvoiceService
|
||||
from igny8_core.business.billing.models import CreditTransaction, Invoice, Payment, CreditPackage, AccountPaymentMethod
|
||||
from igny8_core.business.billing.models import (
|
||||
CreditTransaction, Invoice, Payment, CreditPackage,
|
||||
AccountPaymentMethod, PaymentMethodConfig
|
||||
)
|
||||
from igny8_core.modules.billing.serializers import PaymentMethodConfigSerializer, PaymentConfirmationSerializer
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -171,6 +177,299 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='payment-methods', permission_classes=[AllowAny])
|
||||
def list_payment_methods(self, request):
|
||||
"""
|
||||
Get available payment methods for a specific country.
|
||||
|
||||
Query params:
|
||||
country: ISO 2-letter country code (default: '*' for global)
|
||||
|
||||
Returns payment methods filtered by country (country-specific + global).
|
||||
"""
|
||||
country = request.GET.get('country', '*').upper()
|
||||
|
||||
# Get country-specific + global methods
|
||||
methods = PaymentMethodConfig.objects.filter(
|
||||
Q(country_code=country) | Q(country_code='*'),
|
||||
is_enabled=True
|
||||
).order_by('sort_order')
|
||||
|
||||
serializer = PaymentMethodConfigSerializer(methods, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='payments/confirm', permission_classes=[IsAuthenticatedAndActive])
|
||||
def confirm_payment(self, request):
|
||||
"""
|
||||
User confirms manual payment (bank transfer or local wallet).
|
||||
Creates Payment record with status='pending_approval' for admin review.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"invoice_id": 123,
|
||||
"payment_method": "bank_transfer",
|
||||
"manual_reference": "BT-20251208-12345",
|
||||
"manual_notes": "Transferred via ABC Bank",
|
||||
"amount": "29.00",
|
||||
"proof_url": "https://..." // optional
|
||||
}
|
||||
"""
|
||||
serializer = PaymentConfirmationSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return error_response(
|
||||
error=serializer.errors,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
invoice_id = serializer.validated_data['invoice_id']
|
||||
payment_method = serializer.validated_data['payment_method']
|
||||
manual_reference = serializer.validated_data['manual_reference']
|
||||
manual_notes = serializer.validated_data.get('manual_notes', '')
|
||||
amount = serializer.validated_data['amount']
|
||||
proof_url = serializer.validated_data.get('proof_url')
|
||||
|
||||
try:
|
||||
# Get invoice - must belong to user's account
|
||||
invoice = Invoice.objects.select_related('account').get(
|
||||
id=invoice_id,
|
||||
account=request.account
|
||||
)
|
||||
|
||||
# Validate amount matches invoice
|
||||
if amount != invoice.total:
|
||||
return error_response(
|
||||
error=f'Amount mismatch. Invoice total is {invoice.total} {invoice.currency}',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Create payment record with pending approval status
|
||||
payment = Payment.objects.create(
|
||||
account=request.account,
|
||||
invoice=invoice,
|
||||
amount=amount,
|
||||
currency=invoice.currency,
|
||||
status='pending_approval',
|
||||
payment_method=payment_method,
|
||||
manual_reference=manual_reference,
|
||||
manual_notes=manual_notes,
|
||||
metadata={'proof_url': proof_url, 'submitted_by': request.user.email} if proof_url else {'submitted_by': request.user.email}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'Payment confirmation submitted: Payment {payment.id}, '
|
||||
f'Invoice {invoice.invoice_number}, Account {request.account.id}, '
|
||||
f'Reference: {manual_reference}'
|
||||
)
|
||||
|
||||
# TODO: Send notification to admin
|
||||
# send_payment_confirmation_notification(payment)
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
'payment_id': payment.id,
|
||||
'invoice_id': invoice.id,
|
||||
'invoice_number': invoice.invoice_number,
|
||||
'status': 'pending_approval',
|
||||
'amount': str(amount),
|
||||
'currency': invoice.currency,
|
||||
'manual_reference': manual_reference
|
||||
},
|
||||
message='Payment confirmation submitted for review. You will be notified once approved.',
|
||||
request=request
|
||||
)
|
||||
|
||||
except Invoice.DoesNotExist:
|
||||
return error_response(
|
||||
error='Invoice not found or does not belong to your account',
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error confirming payment: {str(e)}', exc_info=True)
|
||||
return error_response(
|
||||
error=f'Failed to submit payment confirmation: {str(e)}',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='approve', permission_classes=[IsAdminOrOwner])
|
||||
def approve_payment(self, request, pk=None):
|
||||
"""
|
||||
Admin approves a manual payment.
|
||||
Atomically updates: payment status → invoice paid → subscription active → account active → add credits.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"admin_notes": "Verified payment in bank statement"
|
||||
}
|
||||
"""
|
||||
admin_notes = request.data.get('admin_notes', '')
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Get payment with related objects
|
||||
payment = Payment.objects.select_related(
|
||||
'invoice',
|
||||
'invoice__subscription',
|
||||
'invoice__subscription__plan',
|
||||
'account'
|
||||
).get(id=pk)
|
||||
|
||||
if payment.status != 'pending_approval':
|
||||
return error_response(
|
||||
error=f'Payment is not pending approval (current status: {payment.status})',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
invoice = payment.invoice
|
||||
subscription = invoice.subscription
|
||||
account = payment.account
|
||||
|
||||
# 1. Update Payment
|
||||
payment.status = 'succeeded'
|
||||
payment.approved_by = request.user
|
||||
payment.approved_at = timezone.now()
|
||||
payment.processed_at = timezone.now()
|
||||
payment.admin_notes = admin_notes
|
||||
payment.save(update_fields=['status', 'approved_by', 'approved_at', 'processed_at', 'admin_notes'])
|
||||
|
||||
# 2. Update Invoice
|
||||
invoice.status = 'paid'
|
||||
invoice.paid_at = timezone.now()
|
||||
invoice.save(update_fields=['status', 'paid_at'])
|
||||
|
||||
# 3. Update Subscription
|
||||
if subscription:
|
||||
subscription.status = 'active'
|
||||
subscription.external_payment_id = payment.manual_reference
|
||||
subscription.save(update_fields=['status', 'external_payment_id'])
|
||||
|
||||
# 4. Update Account
|
||||
account.status = 'active'
|
||||
account.save(update_fields=['status'])
|
||||
|
||||
# 5. Add Credits (if subscription has plan)
|
||||
credits_added = 0
|
||||
if subscription and subscription.plan:
|
||||
credits_added = subscription.plan.included_credits
|
||||
|
||||
# Use CreditService to add credits
|
||||
CreditService.add_credits(
|
||||
account=account,
|
||||
amount=credits_added,
|
||||
transaction_type='subscription',
|
||||
description=f'{subscription.plan.name} plan credits - Invoice {invoice.invoice_number}',
|
||||
metadata={
|
||||
'subscription_id': subscription.id,
|
||||
'invoice_id': invoice.id,
|
||||
'payment_id': payment.id,
|
||||
'plan_id': subscription.plan.id,
|
||||
'approved_by': request.user.email
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'Payment approved: Payment {payment.id}, Invoice {invoice.invoice_number}, '
|
||||
f'Account {account.id} activated, {credits_added} credits added'
|
||||
)
|
||||
|
||||
# TODO: Send activation email to user
|
||||
# send_account_activated_email(account, subscription)
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
'payment_id': payment.id,
|
||||
'invoice_id': invoice.id,
|
||||
'invoice_number': invoice.invoice_number,
|
||||
'account_id': account.id,
|
||||
'account_status': account.status,
|
||||
'subscription_status': subscription.status if subscription else None,
|
||||
'credits_added': credits_added,
|
||||
'total_credits': account.credits,
|
||||
'approved_by': request.user.email,
|
||||
'approved_at': payment.approved_at.isoformat()
|
||||
},
|
||||
message='Payment approved successfully. Account activated.',
|
||||
request=request
|
||||
)
|
||||
|
||||
except Payment.DoesNotExist:
|
||||
return error_response(
|
||||
error='Payment not found',
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error approving payment: {str(e)}', exc_info=True)
|
||||
return error_response(
|
||||
error=f'Failed to approve payment: {str(e)}',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='reject', permission_classes=[IsAdminOrOwner])
|
||||
def reject_payment(self, request, pk=None):
|
||||
"""
|
||||
Admin rejects a manual payment.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"admin_notes": "Transaction reference not found in bank statement"
|
||||
}
|
||||
"""
|
||||
admin_notes = request.data.get('admin_notes', 'Payment rejected by admin')
|
||||
|
||||
try:
|
||||
payment = Payment.objects.get(id=pk)
|
||||
|
||||
if payment.status != 'pending_approval':
|
||||
return error_response(
|
||||
error=f'Payment is not pending approval (current status: {payment.status})',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
payment.status = 'failed'
|
||||
payment.approved_by = request.user
|
||||
payment.approved_at = timezone.now()
|
||||
payment.failed_at = timezone.now()
|
||||
payment.admin_notes = admin_notes
|
||||
payment.failure_reason = admin_notes
|
||||
payment.save(update_fields=['status', 'approved_by', 'approved_at', 'failed_at', 'admin_notes', 'failure_reason'])
|
||||
|
||||
logger.info(f'Payment rejected: Payment {payment.id}, Reason: {admin_notes}')
|
||||
|
||||
# TODO: Send rejection email to user
|
||||
# send_payment_rejected_email(payment)
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
'payment_id': payment.id,
|
||||
'status': 'failed',
|
||||
'rejected_by': request.user.email,
|
||||
'rejected_at': payment.approved_at.isoformat()
|
||||
},
|
||||
message='Payment rejected.',
|
||||
request=request
|
||||
)
|
||||
|
||||
except Payment.DoesNotExist:
|
||||
return error_response(
|
||||
error='Payment not found',
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error rejecting payment: {str(e)}', exc_info=True)
|
||||
return error_response(
|
||||
error=f'Failed to reject payment: {str(e)}',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
class InvoiceViewSet(AccountModelViewSet):
|
||||
|
||||
@@ -60,10 +60,9 @@ class InvoiceAdmin(AccountAdminMixin, admin.ModelAdmin):
|
||||
'currency',
|
||||
'invoice_date',
|
||||
'due_date',
|
||||
'subscription',
|
||||
]
|
||||
list_filter = ['status', 'currency', 'invoice_date', 'account']
|
||||
search_fields = ['invoice_number', 'account__name', 'subscription__id']
|
||||
search_fields = ['invoice_number', 'account__name']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
|
||||
@@ -77,11 +76,106 @@ class PaymentAdmin(AccountAdminMixin, admin.ModelAdmin):
|
||||
'status',
|
||||
'amount',
|
||||
'currency',
|
||||
'manual_reference',
|
||||
'approved_by',
|
||||
'processed_at',
|
||||
]
|
||||
list_filter = ['status', 'payment_method', 'currency', 'created_at']
|
||||
search_fields = ['invoice__invoice_number', 'account__name', 'stripe_payment_intent_id', 'paypal_order_id']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
list_filter = ['status', 'payment_method', 'currency', 'created_at', 'processed_at']
|
||||
search_fields = [
|
||||
'invoice__invoice_number',
|
||||
'account__name',
|
||||
'stripe_payment_intent_id',
|
||||
'paypal_order_id',
|
||||
'manual_reference',
|
||||
'admin_notes',
|
||||
'manual_notes'
|
||||
]
|
||||
readonly_fields = ['created_at', 'updated_at', 'approved_at', 'processed_at', 'failed_at', 'refunded_at']
|
||||
actions = ['approve_payments', 'reject_payments']
|
||||
|
||||
def approve_payments(self, request, queryset):
|
||||
"""Approve selected manual payments"""
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from igny8_core.business.billing.services.credit_service import CreditService
|
||||
|
||||
count = 0
|
||||
errors = []
|
||||
|
||||
for payment in queryset.filter(status='pending_approval'):
|
||||
try:
|
||||
with transaction.atomic():
|
||||
invoice = payment.invoice
|
||||
subscription = invoice.subscription if hasattr(invoice, 'subscription') else None
|
||||
account = payment.account
|
||||
|
||||
# Update Payment
|
||||
payment.status = 'succeeded'
|
||||
payment.approved_by = request.user
|
||||
payment.approved_at = timezone.now()
|
||||
payment.processed_at = timezone.now()
|
||||
payment.admin_notes = f'Bulk approved by {request.user.email}'
|
||||
payment.save()
|
||||
|
||||
# Update Invoice
|
||||
invoice.status = 'paid'
|
||||
invoice.paid_at = timezone.now()
|
||||
invoice.save()
|
||||
|
||||
# Update Subscription
|
||||
if subscription:
|
||||
subscription.status = 'active'
|
||||
subscription.external_payment_id = payment.manual_reference
|
||||
subscription.save()
|
||||
|
||||
# Update Account
|
||||
account.status = 'active'
|
||||
account.save()
|
||||
|
||||
# Add Credits
|
||||
if subscription and subscription.plan:
|
||||
CreditService.add_credits(
|
||||
account=account,
|
||||
amount=subscription.plan.included_credits,
|
||||
transaction_type='subscription',
|
||||
description=f'{subscription.plan.name} - Invoice {invoice.invoice_number}',
|
||||
metadata={
|
||||
'subscription_id': subscription.id,
|
||||
'invoice_id': invoice.id,
|
||||
'payment_id': payment.id,
|
||||
'approved_by': request.user.email
|
||||
}
|
||||
)
|
||||
|
||||
count += 1
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f'Payment {payment.id}: {str(e)}')
|
||||
|
||||
if count:
|
||||
self.message_user(request, f'Successfully approved {count} payment(s)')
|
||||
if errors:
|
||||
for error in errors:
|
||||
self.message_user(request, error, level='ERROR')
|
||||
|
||||
approve_payments.short_description = 'Approve selected manual payments'
|
||||
|
||||
def reject_payments(self, request, queryset):
|
||||
"""Reject selected manual payments"""
|
||||
from django.utils import timezone
|
||||
|
||||
count = queryset.filter(status='pending_approval').update(
|
||||
status='failed',
|
||||
approved_by=request.user,
|
||||
approved_at=timezone.now(),
|
||||
failed_at=timezone.now(),
|
||||
admin_notes=f'Bulk rejected by {request.user.email}',
|
||||
failure_reason='Rejected by admin'
|
||||
)
|
||||
|
||||
self.message_user(request, f'Rejected {count} payment(s)')
|
||||
|
||||
reject_payments.short_description = 'Reject selected manual payments'
|
||||
|
||||
|
||||
@admin.register(CreditPackage)
|
||||
|
||||
@@ -4,6 +4,7 @@ Serializers for Billing Models
|
||||
from rest_framework import serializers
|
||||
from .models import CreditTransaction, CreditUsageLog
|
||||
from igny8_core.auth.models import Account
|
||||
from igny8_core.business.billing.models import PaymentMethodConfig, Payment
|
||||
|
||||
|
||||
class CreditTransactionSerializer(serializers.ModelSerializer):
|
||||
@@ -48,6 +49,48 @@ class UsageSummarySerializer(serializers.Serializer):
|
||||
by_model = serializers.DictField()
|
||||
|
||||
|
||||
class PaymentMethodConfigSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for payment method configuration"""
|
||||
payment_method_display = serializers.CharField(source='get_payment_method_display', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PaymentMethodConfig
|
||||
fields = [
|
||||
'id', 'country_code', 'payment_method', 'payment_method_display',
|
||||
'is_enabled', 'display_name', 'instructions',
|
||||
'bank_name', 'account_number', 'swift_code',
|
||||
'wallet_type', 'wallet_id', 'sort_order'
|
||||
]
|
||||
read_only_fields = ['id']
|
||||
|
||||
|
||||
class PaymentConfirmationSerializer(serializers.Serializer):
|
||||
"""Serializer for manual payment confirmation"""
|
||||
invoice_id = serializers.IntegerField(required=True)
|
||||
payment_method = serializers.ChoiceField(
|
||||
choices=['bank_transfer', 'local_wallet'],
|
||||
required=True
|
||||
)
|
||||
manual_reference = serializers.CharField(
|
||||
required=True,
|
||||
max_length=255,
|
||||
help_text="Transaction reference number"
|
||||
)
|
||||
manual_notes = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
help_text="Additional notes about the payment"
|
||||
)
|
||||
amount = serializers.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
required=True
|
||||
)
|
||||
proof_url = serializers.URLField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
help_text="URL to receipt/proof of payment"
|
||||
)
|
||||
class LimitCardSerializer(serializers.Serializer):
|
||||
"""Serializer for individual limit card"""
|
||||
title = serializers.CharField()
|
||||
|
||||
444
backend/test_payment_workflow.py
Normal file
444
backend/test_payment_workflow.py
Normal file
@@ -0,0 +1,444 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
End-to-End Payment Workflow Test Script
|
||||
Tests the complete manual payment approval flow
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
# Setup Django
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
|
||||
django.setup()
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from decimal import Decimal
|
||||
from datetime import timedelta
|
||||
|
||||
from igny8_core.auth.models import Account, Subscription, Plan
|
||||
from igny8_core.business.billing.models import (
|
||||
Invoice, Payment, AccountPaymentMethod, CreditTransaction
|
||||
)
|
||||
from igny8_core.business.billing.services.invoice_service import InvoiceService
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
class Colors:
|
||||
HEADER = '\033[95m'
|
||||
OKBLUE = '\033[94m'
|
||||
OKCYAN = '\033[96m'
|
||||
OKGREEN = '\033[92m'
|
||||
WARNING = '\033[93m'
|
||||
FAIL = '\033[91m'
|
||||
ENDC = '\033[0m'
|
||||
BOLD = '\033[1m'
|
||||
|
||||
def print_header(text):
|
||||
print(f"\n{Colors.HEADER}{Colors.BOLD}{'='*60}{Colors.ENDC}")
|
||||
print(f"{Colors.HEADER}{Colors.BOLD}{text:^60}{Colors.ENDC}")
|
||||
print(f"{Colors.HEADER}{Colors.BOLD}{'='*60}{Colors.ENDC}\n")
|
||||
|
||||
def print_success(text):
|
||||
print(f"{Colors.OKGREEN}✓ {text}{Colors.ENDC}")
|
||||
|
||||
def print_error(text):
|
||||
print(f"{Colors.FAIL}✗ {text}{Colors.ENDC}")
|
||||
|
||||
def print_info(text):
|
||||
print(f"{Colors.OKCYAN}→ {text}{Colors.ENDC}")
|
||||
|
||||
def cleanup_test_data():
|
||||
"""Remove test data from previous runs"""
|
||||
print_header("CLEANUP TEST DATA")
|
||||
|
||||
# Delete test accounts
|
||||
test_emails = [
|
||||
'workflow_test_free@example.com',
|
||||
'workflow_test_paid@example.com'
|
||||
]
|
||||
|
||||
for email in test_emails:
|
||||
try:
|
||||
user = User.objects.filter(email=email).first()
|
||||
if user:
|
||||
# Delete associated account (cascade will handle related objects)
|
||||
account = Account.objects.filter(owner=user).first()
|
||||
if account:
|
||||
account.delete()
|
||||
print_success(f"Deleted account for {email}")
|
||||
user.delete()
|
||||
print_success(f"Deleted user {email}")
|
||||
except Exception as e:
|
||||
print_error(f"Error cleaning up {email}: {e}")
|
||||
|
||||
def test_free_trial_signup():
|
||||
"""Test free trial user registration"""
|
||||
print_header("TEST 1: FREE TRIAL SIGNUP")
|
||||
|
||||
try:
|
||||
# Get free plan
|
||||
free_plan = Plan.objects.get(slug='free')
|
||||
print_info(f"Free Plan: {free_plan.name} - {free_plan.included_credits} credits")
|
||||
|
||||
# Create user
|
||||
with transaction.atomic():
|
||||
user = User.objects.create_user(
|
||||
username='workflow_test_free',
|
||||
email='workflow_test_free@example.com',
|
||||
password='TestPass123!',
|
||||
first_name='Free',
|
||||
last_name='Trial'
|
||||
)
|
||||
print_success(f"Created user: {user.email}")
|
||||
|
||||
# Create account
|
||||
account = Account.objects.create(
|
||||
name=f"{user.first_name}'s Account",
|
||||
slug=f'free-trial-{timezone.now().timestamp()}',
|
||||
owner=user,
|
||||
plan=free_plan,
|
||||
status='trial',
|
||||
credits=free_plan.included_credits
|
||||
)
|
||||
print_success(f"Created account: {account.name} (ID: {account.id})")
|
||||
|
||||
# Create credit transaction
|
||||
CreditTransaction.objects.create(
|
||||
account=account,
|
||||
transaction_type='plan_allocation',
|
||||
amount=free_plan.included_credits,
|
||||
balance_after=account.credits,
|
||||
description=f'Initial credits from {free_plan.name} plan'
|
||||
)
|
||||
print_success(f"Allocated {free_plan.included_credits} credits")
|
||||
|
||||
# Verify
|
||||
account.refresh_from_db()
|
||||
assert account.status == 'trial', "Status should be 'trial'"
|
||||
assert account.credits == 1000, "Credits should be 1000"
|
||||
assert account.plan.slug == 'free', "Plan should be 'free'"
|
||||
|
||||
# Check no subscription or invoice created
|
||||
sub_count = Subscription.objects.filter(account=account).count()
|
||||
invoice_count = Invoice.objects.filter(account=account).count()
|
||||
|
||||
assert sub_count == 0, "Free trial should not have subscription"
|
||||
assert invoice_count == 0, "Free trial should not have invoice"
|
||||
|
||||
print_success("No subscription created (correct for free trial)")
|
||||
print_success("No invoice created (correct for free trial)")
|
||||
|
||||
print_success("\nFREE TRIAL TEST PASSED ✓")
|
||||
return account
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Free trial test failed: {e}")
|
||||
raise
|
||||
|
||||
def test_paid_signup():
|
||||
"""Test paid user registration with manual payment"""
|
||||
print_header("TEST 2: PAID SIGNUP WORKFLOW")
|
||||
|
||||
try:
|
||||
# Get starter plan
|
||||
starter_plan = Plan.objects.get(slug='starter')
|
||||
print_info(f"Starter Plan: {starter_plan.name} - ${starter_plan.price} - {starter_plan.included_credits} credits")
|
||||
|
||||
# Step 1: Create user with billing info
|
||||
print_info("\nStep 1: User Registration")
|
||||
with transaction.atomic():
|
||||
user = User.objects.create_user(
|
||||
username='workflow_test_paid',
|
||||
email='workflow_test_paid@example.com',
|
||||
password='TestPass123!',
|
||||
first_name='Paid',
|
||||
last_name='User'
|
||||
)
|
||||
print_success(f"Created user: {user.email}")
|
||||
|
||||
# Create account with billing info
|
||||
account = Account.objects.create(
|
||||
name=f"{user.first_name}'s Account",
|
||||
slug=f'paid-user-{timezone.now().timestamp()}',
|
||||
owner=user,
|
||||
plan=starter_plan,
|
||||
status='pending_payment',
|
||||
credits=0, # No credits until payment approved
|
||||
billing_email='billing@example.com',
|
||||
billing_address_line1='123 Main Street',
|
||||
billing_city='Karachi',
|
||||
billing_country='PK'
|
||||
)
|
||||
print_success(f"Created account: {account.name} (ID: {account.id})")
|
||||
print_info(f" Status: {account.status}")
|
||||
print_info(f" Credits: {account.credits}")
|
||||
|
||||
# Create subscription
|
||||
subscription = Subscription.objects.create(
|
||||
account=account,
|
||||
plan=starter_plan,
|
||||
status='pending_payment',
|
||||
current_period_start=timezone.now(),
|
||||
current_period_end=timezone.now() + timedelta(days=30)
|
||||
)
|
||||
print_success(f"Created subscription (ID: {subscription.id})")
|
||||
print_info(f" Status: {subscription.status}")
|
||||
|
||||
# Create invoice
|
||||
invoice_service = InvoiceService()
|
||||
invoice = invoice_service.create_subscription_invoice(
|
||||
subscription=subscription,
|
||||
billing_period_start=subscription.current_period_start,
|
||||
billing_period_end=subscription.current_period_end
|
||||
)
|
||||
print_success(f"Created invoice: {invoice.invoice_number}")
|
||||
print_info(f" Status: {invoice.status}")
|
||||
print_info(f" Total: ${invoice.total}")
|
||||
print_info(f" Has billing snapshot: {'billing_snapshot' in invoice.metadata}")
|
||||
|
||||
# Create payment method
|
||||
payment_method = AccountPaymentMethod.objects.create(
|
||||
account=account,
|
||||
type='bank_transfer',
|
||||
is_default=True
|
||||
)
|
||||
print_success(f"Created payment method: {payment_method.type}")
|
||||
|
||||
# Step 2: User submits payment confirmation
|
||||
print_info("\nStep 2: User Payment Confirmation")
|
||||
payment = Payment.objects.create(
|
||||
invoice=invoice,
|
||||
account=account,
|
||||
amount=invoice.total,
|
||||
currency=invoice.currency,
|
||||
payment_method='bank_transfer',
|
||||
status='pending_approval',
|
||||
manual_reference='BT-TEST-20251208-001',
|
||||
manual_notes='Test payment via ABC Bank'
|
||||
)
|
||||
print_success(f"Created payment (ID: {payment.id})")
|
||||
print_info(f" Status: {payment.status}")
|
||||
print_info(f" Reference: {payment.manual_reference}")
|
||||
|
||||
# Verify pending state
|
||||
account.refresh_from_db()
|
||||
subscription.refresh_from_db()
|
||||
invoice.refresh_from_db()
|
||||
|
||||
assert account.status == 'pending_payment', "Account should be pending_payment"
|
||||
assert account.credits == 0, "Credits should be 0 before approval"
|
||||
assert subscription.status == 'pending_payment', "Subscription should be pending_payment"
|
||||
assert invoice.status == 'pending', "Invoice should be pending"
|
||||
assert payment.status == 'pending_approval', "Payment should be pending_approval"
|
||||
|
||||
print_success("\nPending state verified ✓")
|
||||
|
||||
# Step 3: Admin approves payment
|
||||
print_info("\nStep 3: Admin Payment Approval")
|
||||
|
||||
# Create admin user for approval
|
||||
admin_user = User.objects.filter(is_superuser=True).first()
|
||||
if not admin_user:
|
||||
admin_user = User.objects.create_superuser(
|
||||
username='test_admin',
|
||||
email='test_admin@example.com',
|
||||
password='admin123',
|
||||
first_name='Test',
|
||||
last_name='Admin'
|
||||
)
|
||||
print_info(f"Created admin user: {admin_user.email}")
|
||||
|
||||
with transaction.atomic():
|
||||
# Update payment
|
||||
payment.status = 'succeeded'
|
||||
payment.approved_by = admin_user
|
||||
payment.approved_at = timezone.now()
|
||||
payment.admin_notes = 'Verified in bank statement'
|
||||
payment.save()
|
||||
print_success("Payment approved")
|
||||
|
||||
# Update invoice
|
||||
invoice.status = 'paid'
|
||||
invoice.paid_at = timezone.now()
|
||||
invoice.save()
|
||||
print_success("Invoice marked as paid")
|
||||
|
||||
# Update subscription
|
||||
subscription.status = 'active'
|
||||
subscription.save()
|
||||
print_success("Subscription activated")
|
||||
|
||||
# Update account and add credits
|
||||
account.status = 'active'
|
||||
account.credits = starter_plan.included_credits
|
||||
account.save()
|
||||
print_success(f"Account activated with {starter_plan.included_credits} credits")
|
||||
|
||||
# Log credit transaction
|
||||
credit_txn = CreditTransaction.objects.create(
|
||||
account=account,
|
||||
transaction_type='plan_allocation',
|
||||
amount=starter_plan.included_credits,
|
||||
balance_after=account.credits,
|
||||
description=f'Credits from approved payment (Invoice: {invoice.invoice_number})'
|
||||
)
|
||||
print_success("Credit transaction logged")
|
||||
|
||||
# Final verification
|
||||
print_info("\nStep 4: Final Verification")
|
||||
account.refresh_from_db()
|
||||
subscription.refresh_from_db()
|
||||
invoice.refresh_from_db()
|
||||
payment.refresh_from_db()
|
||||
|
||||
assert account.status == 'active', "Account should be active"
|
||||
assert account.credits == 1000, "Credits should be 1000"
|
||||
assert subscription.status == 'active', "Subscription should be active"
|
||||
assert invoice.status == 'paid', "Invoice should be paid"
|
||||
assert payment.status == 'succeeded', "Payment should be succeeded"
|
||||
assert payment.approved_by == admin_user, "Payment should have approved_by"
|
||||
|
||||
print_success(f"Account: {account.status} ✓")
|
||||
print_success(f"Credits: {account.credits} ✓")
|
||||
print_success(f"Subscription: {subscription.status} ✓")
|
||||
print_success(f"Invoice: {invoice.status} ✓")
|
||||
print_success(f"Payment: {payment.status} ✓")
|
||||
print_success(f"Approved by: {payment.approved_by.email} ✓")
|
||||
|
||||
# Check credit transaction
|
||||
txn = CreditTransaction.objects.filter(account=account).latest('created_at')
|
||||
print_success(f"Credit Transaction: {txn.transaction_type} | {txn.amount} credits ✓")
|
||||
|
||||
print_success("\nPAID SIGNUP TEST PASSED ✓")
|
||||
return account
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Paid signup test failed: {e}")
|
||||
raise
|
||||
|
||||
def test_payment_rejection():
|
||||
"""Test payment rejection flow"""
|
||||
print_header("TEST 3: PAYMENT REJECTION")
|
||||
|
||||
try:
|
||||
# Use the paid account from previous test
|
||||
account = Account.objects.get(owner__email='workflow_test_paid@example.com')
|
||||
|
||||
# Create a second invoice for testing rejection
|
||||
print_info("Creating second invoice for rejection test")
|
||||
|
||||
subscription = Subscription.objects.get(account=account)
|
||||
invoice_service = InvoiceService()
|
||||
|
||||
with transaction.atomic():
|
||||
invoice2 = invoice_service.create_subscription_invoice(
|
||||
subscription=subscription,
|
||||
billing_period_start=subscription.current_period_start + timedelta(days=30),
|
||||
billing_period_end=subscription.current_period_end + timedelta(days=30)
|
||||
)
|
||||
print_success(f"Created invoice: {invoice2.invoice_number}")
|
||||
|
||||
# Submit payment
|
||||
payment2 = Payment.objects.create(
|
||||
invoice=invoice2,
|
||||
account=account,
|
||||
amount=invoice2.total,
|
||||
currency=invoice2.currency,
|
||||
payment_method='bank_transfer',
|
||||
status='pending_approval',
|
||||
manual_reference='BT-INVALID-REF',
|
||||
manual_notes='Test invalid payment reference'
|
||||
)
|
||||
print_success(f"Created payment (ID: {payment2.id})")
|
||||
|
||||
# Admin rejects payment
|
||||
print_info("\nRejecting payment...")
|
||||
admin_user = User.objects.filter(is_superuser=True).first()
|
||||
|
||||
with transaction.atomic():
|
||||
payment2.status = 'failed'
|
||||
payment2.approved_by = admin_user
|
||||
payment2.approved_at = timezone.now()
|
||||
payment2.admin_notes = 'Reference number not found in bank statement'
|
||||
payment2.save()
|
||||
print_success("Payment rejected")
|
||||
|
||||
# Verify rejection
|
||||
payment2.refresh_from_db()
|
||||
invoice2.refresh_from_db()
|
||||
|
||||
assert payment2.status == 'failed', "Payment should be failed"
|
||||
assert invoice2.status == 'pending', "Invoice should remain pending"
|
||||
|
||||
print_success(f"Payment status: {payment2.status} ✓")
|
||||
print_success(f"Invoice status: {invoice2.status} ✓")
|
||||
print_success(f"Rejection reason: {payment2.admin_notes} ✓")
|
||||
|
||||
print_success("\nPAYMENT REJECTION TEST PASSED ✓")
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Payment rejection test failed: {e}")
|
||||
raise
|
||||
|
||||
def print_summary():
|
||||
"""Print test summary"""
|
||||
print_header("TEST SUMMARY")
|
||||
|
||||
# Count accounts by status
|
||||
from django.db.models import Count
|
||||
|
||||
status_counts = Account.objects.values('status').annotate(count=Count('id'))
|
||||
print_info("Account Status Distribution:")
|
||||
for item in status_counts:
|
||||
print(f" {item['status']:20} {item['count']} account(s)")
|
||||
|
||||
# Count payments by status
|
||||
payment_counts = Payment.objects.values('status').annotate(count=Count('id'))
|
||||
print_info("\nPayment Status Distribution:")
|
||||
for item in payment_counts:
|
||||
print(f" {item['status']:20} {item['count']} payment(s)")
|
||||
|
||||
# Count subscriptions by status
|
||||
sub_counts = Subscription.objects.values('status').annotate(count=Count('id'))
|
||||
print_info("\nSubscription Status Distribution:")
|
||||
for item in sub_counts:
|
||||
print(f" {item['status']:20} {item['count']} subscription(s)")
|
||||
|
||||
print()
|
||||
|
||||
def main():
|
||||
"""Run all tests"""
|
||||
print_header("PAYMENT WORKFLOW E2E TEST SUITE")
|
||||
print(f"{Colors.BOLD}Date: {timezone.now().strftime('%Y-%m-%d %H:%M:%S')}{Colors.ENDC}\n")
|
||||
|
||||
try:
|
||||
# Cleanup
|
||||
cleanup_test_data()
|
||||
|
||||
# Run tests
|
||||
test_free_trial_signup()
|
||||
test_paid_signup()
|
||||
test_payment_rejection()
|
||||
|
||||
# Summary
|
||||
print_summary()
|
||||
|
||||
# Final success
|
||||
print_header("ALL TESTS PASSED ✓")
|
||||
print(f"{Colors.OKGREEN}{Colors.BOLD}The payment workflow is functioning correctly!{Colors.ENDC}\n")
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
print_header("TESTS FAILED ✗")
|
||||
print(f"{Colors.FAIL}{Colors.BOLD}Error: {e}{Colors.ENDC}\n")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user