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:
@@ -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
|
||||
208
backend/igny8_core/business/billing/views/refund_views.py
Normal file
208
backend/igny8_core/business/billing/views/refund_views.py
Normal 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
|
||||
Reference in New Issue
Block a user