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:
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
@@ -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'
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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;
|
||||
"""
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
@@ -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'
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user