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

@@ -0,0 +1,41 @@
"""
Invoice PDF views
API endpoints for generating and downloading invoice PDFs
"""
from django.http import HttpResponse
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from igny8_core.business.billing.models import Invoice
from igny8_core.business.billing.services.pdf_service import InvoicePDFGenerator
from igny8_core.business.billing.utils.errors import not_found_response
import logging
logger = logging.getLogger(__name__)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def download_invoice_pdf(request, invoice_id):
"""
Download invoice as PDF
GET /api/v1/billing/invoices/<id>/pdf/
"""
try:
invoice = Invoice.objects.prefetch_related('line_items').get(
id=invoice_id,
account=request.user.account
)
except Invoice.DoesNotExist:
return not_found_response('Invoice', invoice_id)
# Generate PDF
pdf_buffer = InvoicePDFGenerator.generate_invoice_pdf(invoice)
# Return PDF response
response = HttpResponse(pdf_buffer.read(), content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="invoice_{invoice.invoice_number}.pdf"'
logger.info(f'Invoice PDF downloaded: {invoice.invoice_number} by user {request.user.id}')
return response

View File

@@ -0,0 +1,208 @@
"""
Refund workflow for payments
Handles full and partial refunds with proper accounting
"""
from decimal import Decimal
from django.db import transaction
from django.utils import timezone
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from igny8_core.business.billing.models import Payment, CreditTransaction, Invoice
from igny8_core.business.billing.utils.errors import (
error_response, success_response, not_found_response, ErrorCode
)
from igny8_core.business.billing.services.email_service import BillingEmailService
import logging
logger = logging.getLogger(__name__)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def initiate_refund(request, payment_id):
"""
Initiate a refund for a payment
Request body:
{
"amount": "50.00", # Optional, defaults to full refund
"reason": "Customer requested refund",
"refund_credits": true # Whether to deduct credits
}
"""
try:
payment = Payment.objects.select_related('invoice', 'account').get(
id=payment_id,
account=request.user.account
)
except Payment.DoesNotExist:
return not_found_response('Payment', payment_id)
# Validate payment can be refunded
if payment.status != 'succeeded':
return error_response(
message='Only successful payments can be refunded',
code=ErrorCode.INVALID_STATUS_TRANSITION,
status_code=status.HTTP_400_BAD_REQUEST
)
if payment.refunded_at:
return error_response(
message='This payment has already been refunded',
code=ErrorCode.PAYMENT_ALREADY_PROCESSED,
status_code=status.HTTP_400_BAD_REQUEST
)
# Parse refund amount
refund_amount = request.data.get('amount')
if refund_amount:
refund_amount = Decimal(str(refund_amount))
if refund_amount > Decimal(payment.amount):
return error_response(
message=f'Refund amount cannot exceed payment amount ({payment.amount})',
code=ErrorCode.VALIDATION_ERROR,
status_code=status.HTTP_400_BAD_REQUEST
)
else:
refund_amount = Decimal(payment.amount)
reason = request.data.get('reason', 'Refund requested')
refund_credits = request.data.get('refund_credits', True)
try:
with transaction.atomic():
# Process refund based on payment method
refund_successful = False
if payment.payment_method == 'stripe':
refund_successful = _process_stripe_refund(payment, refund_amount, reason)
elif payment.payment_method == 'paypal':
refund_successful = _process_paypal_refund(payment, refund_amount, reason)
else:
# Manual payment refund - mark as refunded
refund_successful = True
if not refund_successful:
return error_response(
message='Refund processing failed. Please contact support.',
code=ErrorCode.INTERNAL_ERROR,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
# Update payment record
payment.status = 'refunded'
payment.refunded_at = timezone.now()
payment.metadata['refund_amount'] = str(refund_amount)
payment.metadata['refund_reason'] = reason
payment.save(update_fields=['status', 'refunded_at', 'metadata'])
# Update invoice if full refund
if refund_amount == Decimal(payment.amount):
invoice = payment.invoice
if invoice.status == 'paid':
invoice.status = 'pending'
invoice.paid_at = None
invoice.save(update_fields=['status', 'paid_at'])
# Deduct credits if applicable
if refund_credits and payment.credit_transactions.exists():
for credit_tx in payment.credit_transactions.all():
if credit_tx.amount > 0: # Only deduct positive credits
# Get current balance
account = payment.account
current_balance = account.credit_balance
# Create deduction transaction
CreditTransaction.objects.create(
account=account,
transaction_type='refund',
amount=-credit_tx.amount,
balance_after=current_balance - credit_tx.amount,
description=f'Refund: {reason}',
payment=payment,
metadata={'original_transaction': credit_tx.id}
)
# Update account balance
account.credit_balance -= credit_tx.amount
account.save(update_fields=['credit_balance'])
# Send refund notification email
try:
BillingEmailService.send_refund_notification(
user=payment.account.owner,
payment=payment,
refund_amount=str(refund_amount),
reason=reason
)
except Exception as e:
logger.error(f"Failed to send refund email for payment {payment_id}: {str(e)}")
return success_response(
data={
'payment_id': payment.id,
'refund_amount': str(refund_amount),
'status': 'refunded'
},
message='Refund processed successfully'
)
except Exception as e:
logger.exception(f"Refund error for payment {payment_id}: {str(e)}")
return error_response(
message='An error occurred while processing the refund',
code=ErrorCode.INTERNAL_ERROR,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
def _process_stripe_refund(payment: Payment, amount: Decimal, reason: str) -> bool:
"""Process Stripe refund"""
try:
import stripe
from igny8_core.business.billing.utils.payment_gateways import get_stripe_client
stripe_client = get_stripe_client()
refund = stripe_client.Refund.create(
payment_intent=payment.stripe_payment_intent_id,
amount=int(amount * 100), # Convert to cents
reason='requested_by_customer',
metadata={'reason': reason}
)
payment.metadata['stripe_refund_id'] = refund.id
return refund.status == 'succeeded'
except Exception as e:
logger.exception(f"Stripe refund failed for payment {payment.id}: {str(e)}")
return False
def _process_paypal_refund(payment: Payment, amount: Decimal, reason: str) -> bool:
"""Process PayPal refund"""
try:
from igny8_core.business.billing.utils.payment_gateways import get_paypal_client
paypal_client = get_paypal_client()
refund_request = {
'amount': {
'value': str(amount),
'currency_code': payment.currency
},
'note_to_payer': reason
}
refund = paypal_client.payments.captures.refund(
payment.paypal_capture_id,
refund_request
)
payment.metadata['paypal_refund_id'] = refund.id
return refund.status == 'COMPLETED'
except Exception as e:
logger.exception(f"PayPal refund failed for payment {payment.id}: {str(e)}")
return False