feat(billing): add missing payment methods and configurations

- Added migration to include global payment method configurations for Stripe and PayPal (both disabled).
- Ensured existing payment methods like bank transfer and manual payment are correctly configured.
- Added database constraints and indexes for improved data integrity in billing models.
- Introduced foreign key relationship between CreditTransaction and Payment models.
- Added webhook configuration fields to PaymentMethodConfig for future payment gateway integrations.
- Updated SignUpFormUnified component to handle payment method selection based on user country and plan.
- Implemented PaymentHistory component to display user's payment history with status indicators.
This commit is contained in:
IGNY8 VPS (Salman)
2025-12-09 06:14:44 +00:00
parent 72d0b6b0fd
commit 4d13a57068
36 changed files with 4159 additions and 253 deletions

View File

@@ -125,29 +125,136 @@ class PaymentAdmin(AccountAdminMixin, admin.ModelAdmin):
def save_model(self, request, obj, form, change):
"""
Override save_model to set approved_by when status changes to succeeded.
The Payment.save() method will handle all the cascade updates automatically.
Override save_model to trigger approval workflow when status changes to succeeded.
This ensures manual status changes in admin also activate accounts and add credits.
"""
from django.db import transaction
from django.utils import timezone
from igny8_core.business.billing.services.credit_service import CreditService
from igny8_core.auth.models import Subscription
# Check if status changed to 'succeeded'
status_changed_to_succeeded = False
if change and 'status' in form.changed_data:
if obj.status == 'succeeded' and form.initial.get('status') != 'succeeded':
status_changed_to_succeeded = True
elif not change and obj.status == 'succeeded':
status_changed_to_succeeded = True
# Save the payment first
if obj.status == 'succeeded' and not obj.approved_by:
obj.approved_by = request.user
if not obj.approved_at:
obj.approved_at = timezone.now()
if not obj.processed_at:
obj.processed_at = timezone.now()
super().save_model(request, obj, form, change)
# If status changed to succeeded, trigger the full approval workflow
if status_changed_to_succeeded:
try:
with transaction.atomic():
invoice = obj.invoice
account = obj.account
# Get subscription from invoice or account
subscription = None
if invoice and hasattr(invoice, 'subscription') and invoice.subscription:
subscription = invoice.subscription
elif account and hasattr(account, 'subscription'):
try:
subscription = account.subscription
except Subscription.DoesNotExist:
pass
# Update Invoice
if invoice and invoice.status != 'paid':
invoice.status = 'paid'
invoice.paid_at = timezone.now()
invoice.save()
# Update Subscription
if subscription and subscription.status != 'active':
subscription.status = 'active'
subscription.external_payment_id = obj.manual_reference
subscription.save()
# Update Account
if account.status != 'active':
account.status = 'active'
account.save()
# Add Credits (check if not already added)
from igny8_core.business.billing.models import CreditTransaction
existing_credit = CreditTransaction.objects.filter(
account=account,
metadata__payment_id=obj.id
).exists()
if not existing_credit:
credits_to_add = 0
plan_name = ''
if subscription and subscription.plan:
credits_to_add = subscription.plan.included_credits
plan_name = subscription.plan.name
elif account and account.plan:
credits_to_add = account.plan.included_credits
plan_name = account.plan.name
if credits_to_add > 0:
CreditService.add_credits(
account=account,
amount=credits_to_add,
transaction_type='subscription',
description=f'{plan_name} - Invoice {invoice.invoice_number}',
metadata={
'subscription_id': subscription.id if subscription else None,
'invoice_id': invoice.id,
'payment_id': obj.id,
'approved_by': request.user.email
}
)
self.message_user(
request,
f'✓ Payment approved: Account activated, {credits_to_add} credits added',
level='SUCCESS'
)
except Exception as e:
self.message_user(
request,
f'✗ Payment saved but workflow failed: {str(e)}',
level='ERROR'
)
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
from igny8_core.auth.models import Subscription
count = 0
successful = []
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
# Get subscription from invoice or account
subscription = None
if invoice and hasattr(invoice, 'subscription') and invoice.subscription:
subscription = invoice.subscription
elif account and hasattr(account, 'subscription'):
try:
subscription = account.subscription
except Subscription.DoesNotExist:
pass
# Update Payment
payment.status = 'succeeded'
payment.approved_by = request.user
@@ -172,10 +279,12 @@ class PaymentAdmin(AccountAdminMixin, admin.ModelAdmin):
account.save()
# Add Credits
if subscription and subscription.plan:
credits_added = 0
if subscription and subscription.plan and subscription.plan.included_credits > 0:
credits_added = subscription.plan.included_credits
CreditService.add_credits(
account=account,
amount=subscription.plan.included_credits,
amount=credits_added,
transaction_type='subscription',
description=f'{subscription.plan.name} - Invoice {invoice.invoice_number}',
metadata={
@@ -185,17 +294,38 @@ class PaymentAdmin(AccountAdminMixin, admin.ModelAdmin):
'approved_by': request.user.email
}
)
elif account and account.plan and account.plan.included_credits > 0:
credits_added = account.plan.included_credits
CreditService.add_credits(
account=account,
amount=credits_added,
transaction_type='subscription',
description=f'{account.plan.name} - Invoice {invoice.invoice_number}',
metadata={
'invoice_id': invoice.id,
'payment_id': payment.id,
'approved_by': request.user.email
}
)
count += 1
successful.append(f'Payment #{payment.id} - {account.name} - Invoice {invoice.invoice_number} - {credits_added} credits')
except Exception as e:
errors.append(f'Payment {payment.id}: {str(e)}')
errors.append(f'Payment #{payment.id}: {str(e)}')
if count:
self.message_user(request, f'Successfully approved {count} payment(s)')
# Detailed success message
if successful:
self.message_user(request, f'✓ Successfully approved {len(successful)} payment(s):', level='SUCCESS')
for msg in successful[:10]: # Show first 10
self.message_user(request, f'{msg}', level='SUCCESS')
if len(successful) > 10:
self.message_user(request, f' ... and {len(successful) - 10} more', level='SUCCESS')
# Detailed error messages
if errors:
self.message_user(request, f'✗ Failed to approve {len(errors)} payment(s):', level='ERROR')
for error in errors:
self.message_user(request, error, level='ERROR')
self.message_user(request, f'{error}', level='ERROR')
approve_payments.short_description = 'Approve selected manual payments'

View File

@@ -0,0 +1,84 @@
# Generated migration to add subscription FK to Invoice and fix Payment status default
from django.db import migrations, models
import django.db.models.deletion
def populate_subscription_from_metadata(apps, schema_editor):
"""Populate subscription FK from metadata for existing invoices"""
# Use raw SQL to avoid model field issues during migration
from django.db import connection
with connection.cursor() as cursor:
# Get invoices with subscription_id in metadata
cursor.execute("""
SELECT id, metadata
FROM igny8_invoices
WHERE metadata::text LIKE '%subscription_id%'
""")
updated_count = 0
for invoice_id, metadata in cursor.fetchall():
if metadata and isinstance(metadata, dict) and 'subscription_id' in metadata:
try:
sub_id = int(metadata['subscription_id'])
# Check if subscription exists
cursor.execute(
"SELECT id FROM igny8_subscriptions WHERE id = %s",
[sub_id]
)
if cursor.fetchone():
# Update invoice with subscription FK
cursor.execute(
"UPDATE igny8_invoices SET subscription_id = %s WHERE id = %s",
[sub_id, invoice_id]
)
updated_count += 1
except (ValueError, KeyError) as e:
print(f"Could not populate subscription for invoice {invoice_id}: {e}")
print(f"Populated subscription FK for {updated_count} invoices")
class Migration(migrations.Migration):
dependencies = [
('billing', '0007_simplify_payment_statuses'),
('igny8_core_auth', '0011_remove_subscription_payment_method'),
]
operations = [
# Add subscription FK to Invoice
migrations.AddField(
model_name='invoice',
name='subscription',
field=models.ForeignKey(
blank=True,
help_text='Subscription this invoice is for (if subscription-based)',
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='invoices',
to='igny8_core_auth.subscription'
),
),
# Populate data
migrations.RunPython(
populate_subscription_from_metadata,
reverse_code=migrations.RunPython.noop
),
# Fix Payment status default
migrations.AlterField(
model_name='payment',
name='status',
field=models.CharField(
choices=[
('pending_approval', 'Pending Approval'),
('succeeded', 'Succeeded'),
('failed', 'Failed'),
('refunded', 'Refunded')
],
db_index=True,
default='pending_approval',
max_length=20
),
),
]

View File

@@ -0,0 +1,80 @@
# Migration to add missing payment method configurations
from django.db import migrations
def add_missing_payment_methods(apps, schema_editor):
"""Add stripe and paypal global configs (disabled) and ensure all configs exist"""
PaymentMethodConfig = apps.get_model('billing', 'PaymentMethodConfig')
# Add global Stripe (disabled - waiting for integration)
PaymentMethodConfig.objects.get_or_create(
country_code='*',
payment_method='stripe',
defaults={
'is_enabled': False,
'display_name': 'Credit/Debit Card (Stripe)',
'instructions': 'Stripe payment integration coming soon.',
'sort_order': 1
}
)
# Add global PayPal (disabled - waiting for integration)
PaymentMethodConfig.objects.get_or_create(
country_code='*',
payment_method='paypal',
defaults={
'is_enabled': False,
'display_name': 'PayPal',
'instructions': 'PayPal payment integration coming soon.',
'sort_order': 2
}
)
# Ensure global bank_transfer exists with good instructions
PaymentMethodConfig.objects.get_or_create(
country_code='*',
payment_method='bank_transfer',
defaults={
'is_enabled': True,
'display_name': 'Bank Transfer',
'instructions': 'Bank transfer details will be provided after registration.',
'sort_order': 3
}
)
# Add manual payment as global option
PaymentMethodConfig.objects.get_or_create(
country_code='*',
payment_method='manual',
defaults={
'is_enabled': True,
'display_name': 'Manual Payment (Contact Support)',
'instructions': 'Contact support@igny8.com for manual payment arrangements.',
'sort_order': 10
}
)
print("Added/updated payment method configurations")
def remove_added_payment_methods(apps, schema_editor):
"""Reverse migration"""
PaymentMethodConfig = apps.get_model('billing', 'PaymentMethodConfig')
PaymentMethodConfig.objects.filter(
country_code='*',
payment_method__in=['stripe', 'paypal', 'manual']
).delete()
class Migration(migrations.Migration):
dependencies = [
('billing', '0008_add_invoice_subscription_fk'),
]
operations = [
migrations.RunPython(
add_missing_payment_methods,
reverse_code=remove_added_payment_methods
),
]

View File

@@ -0,0 +1,58 @@
# Migration to add database constraints and indexes for data integrity
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0009_add_missing_payment_methods'),
('igny8_core_auth', '0011_remove_subscription_payment_method'),
]
operations = [
# Add DB index on invoice_number for fast lookups
migrations.AlterField(
model_name='invoice',
name='invoice_number',
field=models.CharField(db_index=True, max_length=50, unique=True),
),
# Add index on Payment.status for filtering
migrations.AlterField(
model_name='payment',
name='status',
field=models.CharField(
choices=[
('pending_approval', 'Pending Approval'),
('succeeded', 'Succeeded'),
('failed', 'Failed'),
('refunded', 'Refunded')
],
db_index=True,
default='pending_approval',
max_length=20
),
),
# Add partial unique index on AccountPaymentMethod for single default per account
# This prevents multiple is_default=True per account
migrations.RunSQL(
sql="""
CREATE UNIQUE INDEX billing_account_payment_method_single_default
ON igny8_account_payment_methods (tenant_id)
WHERE is_default = true AND is_enabled = true;
""",
reverse_sql="""
DROP INDEX IF EXISTS billing_account_payment_method_single_default;
"""
),
# Add composite index on Payment for common queries
migrations.AddIndex(
model_name='payment',
index=models.Index(
fields=['account', 'status', '-created_at'],
name='payment_account_status_created_idx'
),
),
]

View File

@@ -0,0 +1,25 @@
# Generated migration to add payment constraints
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('billing', '0010_add_database_constraints'),
]
operations = [
# Add composite unique constraint on manual_reference + tenant_id
# This prevents duplicate payment submissions with same reference for an account
migrations.RunSQL(
sql="""
CREATE UNIQUE INDEX billing_payment_manual_ref_account_unique
ON igny8_payments(tenant_id, manual_reference)
WHERE manual_reference != '' AND manual_reference IS NOT NULL;
""",
reverse_sql="""
DROP INDEX IF EXISTS billing_payment_manual_ref_account_unique;
"""
),
]

View File

@@ -0,0 +1,44 @@
# Generated migration to add Payment FK to CreditTransaction
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('billing', '0011_add_manual_reference_constraint'),
]
operations = [
# Add payment FK field
migrations.AddField(
model_name='credittransaction',
name='payment',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='credit_transactions',
to='billing.payment',
help_text='Payment that triggered this credit transaction'
),
),
# Migrate existing reference_id data to payment FK
migrations.RunSQL(
sql="""
UPDATE igny8_credit_transactions ct
SET payment_id = (
SELECT id FROM igny8_payments p
WHERE p.id::text = ct.reference_id
AND ct.reference_id ~ '^[0-9]+$'
LIMIT 1
)
WHERE ct.reference_id IS NOT NULL
AND ct.reference_id != ''
AND ct.reference_id ~ '^[0-9]+$';
""",
reverse_sql=migrations.RunSQL.noop
),
]

View File

@@ -0,0 +1,49 @@
# Generated migration to add webhook fields to PaymentMethodConfig
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0012_add_payment_fk_to_credit_transaction'),
]
operations = [
# Add webhook configuration fields
migrations.AddField(
model_name='paymentmethodconfig',
name='webhook_url',
field=models.URLField(
blank=True,
help_text='Webhook URL for payment gateway callbacks (Stripe/PayPal)'
),
),
migrations.AddField(
model_name='paymentmethodconfig',
name='webhook_secret',
field=models.CharField(
max_length=255,
blank=True,
help_text='Webhook secret for signature verification'
),
),
migrations.AddField(
model_name='paymentmethodconfig',
name='api_key',
field=models.CharField(
max_length=255,
blank=True,
help_text='API key for payment gateway integration'
),
),
migrations.AddField(
model_name='paymentmethodconfig',
name='api_secret',
field=models.CharField(
max_length=255,
blank=True,
help_text='API secret for payment gateway integration'
),
),
]

View File

@@ -1,6 +1,8 @@
"""
Serializers for Billing Models
"""
from typing import Any, Dict, Optional
from decimal import Decimal
from rest_framework import serializers
from .models import CreditTransaction, CreditUsageLog
from igny8_core.auth.models import Account
@@ -8,7 +10,11 @@ from igny8_core.business.billing.models import PaymentMethodConfig, Payment
class CreditTransactionSerializer(serializers.ModelSerializer):
transaction_type_display = serializers.CharField(source='get_transaction_type_display', read_only=True)
"""Serializer for credit transactions"""
transaction_type_display: serializers.CharField = serializers.CharField(
source='get_transaction_type_display',
read_only=True
)
class Meta:
model = CreditTransaction
@@ -20,7 +26,11 @@ class CreditTransactionSerializer(serializers.ModelSerializer):
class CreditUsageLogSerializer(serializers.ModelSerializer):
operation_type_display = serializers.CharField(source='get_operation_type_display', read_only=True)
"""Serializer for credit usage logs"""
operation_type_display: serializers.CharField = serializers.CharField(
source='get_operation_type_display',
read_only=True
)
class Meta:
model = CreditUsageLog
@@ -34,24 +44,27 @@ class CreditUsageLogSerializer(serializers.ModelSerializer):
class CreditBalanceSerializer(serializers.Serializer):
"""Serializer for credit balance response"""
credits = serializers.IntegerField()
plan_credits_per_month = serializers.IntegerField()
credits_used_this_month = serializers.IntegerField()
credits_remaining = serializers.IntegerField()
credits: serializers.IntegerField = serializers.IntegerField()
plan_credits_per_month: serializers.IntegerField = serializers.IntegerField()
credits_used_this_month: serializers.IntegerField = serializers.IntegerField()
credits_remaining: serializers.IntegerField = serializers.IntegerField()
class UsageSummarySerializer(serializers.Serializer):
"""Serializer for usage summary response"""
period = serializers.DictField()
total_credits_used = serializers.IntegerField()
total_cost_usd = serializers.DecimalField(max_digits=10, decimal_places=2)
by_operation = serializers.DictField()
by_model = serializers.DictField()
period: serializers.DictField = serializers.DictField()
total_credits_used: serializers.IntegerField = serializers.IntegerField()
total_cost_usd: serializers.DecimalField = serializers.DecimalField(max_digits=10, decimal_places=2)
by_operation: serializers.DictField = serializers.DictField()
by_model: serializers.DictField = serializers.DictField()
class PaymentMethodConfigSerializer(serializers.ModelSerializer):
"""Serializer for payment method configuration"""
payment_method_display = serializers.CharField(source='get_payment_method_display', read_only=True)
payment_method_display: serializers.CharField = serializers.CharField(
source='get_payment_method_display',
read_only=True
)
class Meta:
model = PaymentMethodConfig
@@ -66,43 +79,66 @@ class PaymentMethodConfigSerializer(serializers.ModelSerializer):
class PaymentConfirmationSerializer(serializers.Serializer):
"""Serializer for manual payment confirmation"""
invoice_id = serializers.IntegerField(required=True)
payment_method = serializers.ChoiceField(
invoice_id: serializers.IntegerField = serializers.IntegerField(required=True)
payment_method: serializers.ChoiceField = serializers.ChoiceField(
choices=['bank_transfer', 'local_wallet'],
required=True
)
manual_reference = serializers.CharField(
manual_reference: serializers.CharField = serializers.CharField(
required=True,
max_length=255,
help_text="Transaction reference number"
)
manual_notes = serializers.CharField(
manual_notes: serializers.CharField = serializers.CharField(
required=False,
allow_blank=True,
help_text="Additional notes about the payment"
)
amount = serializers.DecimalField(
amount: serializers.DecimalField = serializers.DecimalField(
max_digits=10,
decimal_places=2,
required=True
)
proof_url = serializers.URLField(
proof_url: serializers.URLField = serializers.URLField(
required=False,
allow_blank=True,
help_text="URL to receipt/proof of payment"
)
def validate_proof_url(self, value: Optional[str]) -> Optional[str]:
"""Validate proof_url is a valid URL format"""
if value and not value.strip():
raise serializers.ValidationError("Proof URL cannot be empty if provided")
if value:
# Additional validation: must be http or https
if not value.startswith(('http://', 'https://')):
raise serializers.ValidationError("Proof URL must start with http:// or https://")
return value
def validate_amount(self, value: Optional[Decimal]) -> Decimal:
"""Validate amount has max 2 decimal places"""
if value is None:
raise serializers.ValidationError("Amount is required")
if value <= 0:
raise serializers.ValidationError("Amount must be greater than 0")
# Check decimal precision (max 2 decimal places)
if value.as_tuple().exponent < -2:
raise serializers.ValidationError("Amount can have maximum 2 decimal places")
return value
class LimitCardSerializer(serializers.Serializer):
"""Serializer for individual limit card"""
title = serializers.CharField()
limit = serializers.IntegerField()
used = serializers.IntegerField()
available = serializers.IntegerField()
unit = serializers.CharField()
category = serializers.CharField()
percentage = serializers.FloatField()
title: serializers.CharField = serializers.CharField()
limit: serializers.IntegerField = serializers.IntegerField()
used: serializers.IntegerField = serializers.IntegerField()
available: serializers.IntegerField = serializers.IntegerField()
unit: serializers.CharField = serializers.CharField()
category: serializers.CharField = serializers.CharField()
percentage: serializers.FloatField = serializers.FloatField()
class UsageLimitsSerializer(serializers.Serializer):
"""Serializer for usage limits response"""
limits = LimitCardSerializer(many=True)
limits: LimitCardSerializer = LimitCardSerializer(many=True)