Complete Implemenation of tenancy

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-09 00:11:35 +00:00
parent c54db6c2d9
commit bfbade7624
25 changed files with 4959 additions and 35 deletions

View File

@@ -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,
),
]

View File

@@ -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',
),
]

View File

@@ -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')

View File

@@ -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

View File

@@ -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."""

View File

@@ -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,

View File

@@ -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

View File

@@ -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):

View File

@@ -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)

View File

@@ -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()