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:
@@ -33,6 +33,17 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
permission_classes = [IsAdminOrOwner]
|
||||
|
||||
def get_permissions(self):
|
||||
"""
|
||||
Allow action-level permissions to override class-level permissions.
|
||||
"""
|
||||
# Try to get permission_classes from the action
|
||||
try:
|
||||
# DRF stores action permission_classes in the view method
|
||||
return [permission() for permission in self.permission_classes]
|
||||
except Exception:
|
||||
return super().get_permissions()
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='confirm-bank-transfer')
|
||||
def confirm_bank_transfer(self, request):
|
||||
"""
|
||||
@@ -182,22 +193,30 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
def list_payment_methods(self, request):
|
||||
"""
|
||||
Get available payment methods for a specific country.
|
||||
Public endpoint - only returns enabled payment methods.
|
||||
Does not expose sensitive configuration details.
|
||||
|
||||
Query params:
|
||||
country: ISO 2-letter country code (default: '*' for global)
|
||||
country: ISO 2-letter country code (default: 'US')
|
||||
|
||||
Returns payment methods filtered by country (country-specific + global).
|
||||
Returns payment methods filtered by country.
|
||||
"""
|
||||
country = request.GET.get('country', '*').upper()
|
||||
country = request.GET.get('country', 'US').upper()
|
||||
|
||||
# Get country-specific + global methods
|
||||
# Get country-specific methods
|
||||
methods = PaymentMethodConfig.objects.filter(
|
||||
Q(country_code=country) | Q(country_code='*'),
|
||||
country_code=country,
|
||||
is_enabled=True
|
||||
).order_by('sort_order')
|
||||
|
||||
# Serialize using the proper serializer
|
||||
serializer = PaymentMethodConfigSerializer(methods, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
# Return in consistent format
|
||||
return Response({
|
||||
'success': True,
|
||||
'results': serializer.data
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='payments/confirm', permission_classes=[IsAuthenticatedAndActive])
|
||||
def confirm_payment(self, request):
|
||||
@@ -237,6 +256,26 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
account=request.account
|
||||
)
|
||||
|
||||
# Check if payment already exists for this invoice
|
||||
existing_payment = Payment.objects.filter(
|
||||
invoice=invoice,
|
||||
status__in=['pending_approval', 'succeeded']
|
||||
).first()
|
||||
|
||||
if existing_payment:
|
||||
if existing_payment.status == 'succeeded':
|
||||
return error_response(
|
||||
error='This invoice has already been paid and approved.',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
else:
|
||||
return error_response(
|
||||
error=f'A payment confirmation is already pending approval for this invoice (Payment ID: {existing_payment.id}).',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Validate amount matches invoice
|
||||
if amount != invoice.total:
|
||||
return error_response(
|
||||
@@ -264,8 +303,12 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
f'Reference: {manual_reference}'
|
||||
)
|
||||
|
||||
# TODO: Send notification to admin
|
||||
# send_payment_confirmation_notification(payment)
|
||||
# Send email notification to user
|
||||
try:
|
||||
from igny8_core.business.billing.services.email_service import BillingEmailService
|
||||
BillingEmailService.send_payment_confirmation_email(payment, request.account)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send payment confirmation email: {str(e)}')
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
@@ -283,14 +326,20 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
|
||||
except Invoice.DoesNotExist:
|
||||
return error_response(
|
||||
error='Invoice not found or does not belong to your account',
|
||||
error='Invoice not found. Please check the invoice ID or contact support.',
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
except ValueError as ve:
|
||||
return error_response(
|
||||
error=f'Invalid amount format: {str(ve)}',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
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)}',
|
||||
error='An unexpected error occurred while processing your payment confirmation. Please try again or contact support.',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
@@ -310,25 +359,66 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Get payment with related objects
|
||||
# Get payment with all related objects to prevent N+1 queries
|
||||
payment = Payment.objects.select_related(
|
||||
'invoice',
|
||||
'invoice__subscription',
|
||||
'invoice__subscription__plan',
|
||||
'account'
|
||||
'account',
|
||||
'account__subscription',
|
||||
'account__subscription__plan',
|
||||
'account__plan'
|
||||
).get(id=pk)
|
||||
|
||||
if payment.status != 'pending_approval':
|
||||
status_msg = {
|
||||
'succeeded': 'This payment has already been approved and processed',
|
||||
'failed': 'This payment was previously rejected and cannot be approved',
|
||||
'refunded': 'This payment was refunded and cannot be re-approved'
|
||||
}.get(payment.status, f'Payment has invalid status: {payment.status}')
|
||||
return error_response(
|
||||
error=f'Payment is not pending approval (current status: {payment.status})',
|
||||
error=status_msg,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
invoice = payment.invoice
|
||||
subscription = invoice.subscription
|
||||
account = payment.account
|
||||
|
||||
# Validate invoice is still pending
|
||||
if invoice.status == 'paid':
|
||||
return error_response(
|
||||
error='Invoice is already marked as paid. Payment cannot be approved again.',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Validate invoice is not void
|
||||
if invoice.status == 'void':
|
||||
return error_response(
|
||||
error='Invoice has been voided. Payment cannot be approved for a void invoice.',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Validate amount matches
|
||||
if payment.amount != invoice.total:
|
||||
return error_response(
|
||||
error=f'Payment amount ({payment.currency} {payment.amount}) does not match invoice total ({invoice.currency} {invoice.total}). Please verify the payment.',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get subscription from invoice first, fallback to account.subscription
|
||||
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 Exception:
|
||||
pass
|
||||
|
||||
# 1. Update Payment
|
||||
payment.status = 'succeeded'
|
||||
payment.approved_by = request.user
|
||||
@@ -354,31 +444,56 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
|
||||
# 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
|
||||
}
|
||||
)
|
||||
try:
|
||||
if subscription and subscription.plan and subscription.plan.included_credits > 0:
|
||||
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
|
||||
}
|
||||
)
|
||||
elif account and account.plan and account.plan.included_credits > 0:
|
||||
# Fallback: use account plan if subscription not found
|
||||
credits_added = account.plan.included_credits
|
||||
CreditService.add_credits(
|
||||
account=account,
|
||||
amount=credits_added,
|
||||
transaction_type='subscription',
|
||||
description=f'{account.plan.name} plan credits - Invoice {invoice.invoice_number}',
|
||||
metadata={
|
||||
'invoice_id': invoice.id,
|
||||
'payment_id': payment.id,
|
||||
'plan_id': account.plan.id,
|
||||
'approved_by': request.user.email,
|
||||
'fallback': 'account_plan'
|
||||
}
|
||||
)
|
||||
except Exception as credit_error:
|
||||
# Rollback payment approval if credit addition fails
|
||||
logger.error(f'Credit addition failed for payment {payment.id}: {credit_error}', exc_info=True)
|
||||
raise Exception(f'Failed to add credits: {str(credit_error)}') from credit_error
|
||||
|
||||
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)
|
||||
# Send activation email to user
|
||||
try:
|
||||
from igny8_core.business.billing.services.email_service import BillingEmailService
|
||||
BillingEmailService.send_payment_approved_email(payment, account, subscription)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send payment approved email: {str(e)}')
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
@@ -399,14 +514,24 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
|
||||
except Payment.DoesNotExist:
|
||||
return error_response(
|
||||
error='Payment not found',
|
||||
error='Payment not found. The payment may have been deleted or the ID is incorrect.',
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error approving payment: {str(e)}', exc_info=True)
|
||||
logger.error(f'Error approving payment {pk}: {str(e)}', exc_info=True)
|
||||
|
||||
# Provide specific error messages
|
||||
error_msg = str(e)
|
||||
if 'credit' in error_msg.lower():
|
||||
error_msg = 'Failed to add credits to account. Payment not approved. Please check the plan configuration.'
|
||||
elif 'subscription' in error_msg.lower():
|
||||
error_msg = 'Failed to activate subscription. Payment not approved. Please verify subscription exists.'
|
||||
else:
|
||||
error_msg = f'Payment approval failed: {error_msg}'
|
||||
|
||||
return error_response(
|
||||
error=f'Failed to approve payment: {str(e)}',
|
||||
error=error_msg,
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
@@ -441,10 +566,20 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
payment.failure_reason = admin_notes
|
||||
payment.save(update_fields=['status', 'approved_by', 'approved_at', 'failed_at', 'admin_notes', 'failure_reason'])
|
||||
|
||||
# Update account status to allow retry
|
||||
account = payment.account
|
||||
if account.status != 'active':
|
||||
account.status = 'pending_payment'
|
||||
account.save(update_fields=['status'])
|
||||
|
||||
logger.info(f'Payment rejected: Payment {payment.id}, Reason: {admin_notes}')
|
||||
|
||||
# TODO: Send rejection email to user
|
||||
# send_payment_rejected_email(payment)
|
||||
# Send rejection email to user
|
||||
try:
|
||||
from igny8_core.business.billing.services.email_service import BillingEmailService
|
||||
BillingEmailService.send_payment_rejected_email(payment, account, admin_notes)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send payment rejected email: {str(e)}')
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
@@ -459,14 +594,14 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
|
||||
except Payment.DoesNotExist:
|
||||
return error_response(
|
||||
error='Payment not found',
|
||||
error='Payment not found. The payment may have been deleted or the ID is incorrect.',
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error rejecting payment: {str(e)}', exc_info=True)
|
||||
logger.error(f'Error rejecting payment {pk}: {str(e)}', exc_info=True)
|
||||
return error_response(
|
||||
error=f'Failed to reject payment: {str(e)}',
|
||||
error=f'Failed to reject payment. Please try again or contact technical support.',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
@@ -504,6 +639,7 @@ class InvoiceViewSet(AccountModelViewSet):
|
||||
'id': invoice.id,
|
||||
'invoice_number': invoice.invoice_number,
|
||||
'status': invoice.status,
|
||||
'total': str(invoice.total), # Alias for compatibility
|
||||
'total_amount': str(invoice.total),
|
||||
'subtotal': str(invoice.subtotal),
|
||||
'tax_amount': str(invoice.tax),
|
||||
@@ -530,6 +666,7 @@ class InvoiceViewSet(AccountModelViewSet):
|
||||
'id': invoice.id,
|
||||
'invoice_number': invoice.invoice_number,
|
||||
'status': invoice.status,
|
||||
'total': str(invoice.total), # Alias for compatibility
|
||||
'total_amount': str(invoice.total),
|
||||
'subtotal': str(invoice.subtotal),
|
||||
'tax_amount': str(invoice.tax),
|
||||
@@ -565,6 +702,17 @@ class PaymentViewSet(AccountModelViewSet):
|
||||
queryset = Payment.objects.all().select_related('account', 'invoice')
|
||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
|
||||
pagination_class = CustomPageNumberPagination
|
||||
throttle_scope = 'payment_confirmation'
|
||||
|
||||
def get_throttles(self):
|
||||
"""Apply stricter throttling to manual payment submission"""
|
||||
from rest_framework.throttling import UserRateThrottle
|
||||
if self.action == 'manual':
|
||||
# 5 payment submissions per hour per user
|
||||
class PaymentSubmissionThrottle(UserRateThrottle):
|
||||
rate = '5/hour'
|
||||
return [PaymentSubmissionThrottle()]
|
||||
return super().get_throttles()
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter payments by account"""
|
||||
@@ -605,6 +753,7 @@ class PaymentViewSet(AccountModelViewSet):
|
||||
'processed_at': payment.processed_at.isoformat() if payment.processed_at else None,
|
||||
'manual_reference': payment.manual_reference,
|
||||
'manual_notes': payment.manual_notes,
|
||||
# admin_notes intentionally excluded - internal only
|
||||
})
|
||||
|
||||
return paginated_response(
|
||||
|
||||
Reference in New Issue
Block a user