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,228 @@
"""
Email service for billing notifications
"""
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.conf import settings
import logging
logger = logging.getLogger(__name__)
class BillingEmailService:
"""Service for sending billing-related emails"""
@staticmethod
def send_payment_confirmation_email(payment, account):
"""
Send email when user submits manual payment for approval
"""
subject = f'Payment Confirmation Received - Invoice #{payment.invoice.invoice_number}'
context = {
'account_name': account.name,
'invoice_number': payment.invoice.invoice_number,
'amount': payment.amount,
'currency': payment.currency,
'payment_method': payment.get_payment_method_display(),
'manual_reference': payment.manual_reference,
'created_at': payment.created_at,
}
# Plain text message
message = f"""
Hi {account.name},
We have received your payment confirmation for Invoice #{payment.invoice.invoice_number}.
Payment Details:
- Amount: {payment.currency} {payment.amount}
- Payment Method: {payment.get_payment_method_display()}
- Reference: {payment.manual_reference}
- Submitted: {payment.created_at.strftime('%Y-%m-%d %H:%M')}
Your payment is currently under review. You will receive another email once it has been approved.
Thank you,
The Igny8 Team
"""
try:
send_mail(
subject=subject,
message=message.strip(),
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[account.billing_email or account.owner.email],
fail_silently=False,
)
logger.info(f'Payment confirmation email sent for Payment {payment.id}')
except Exception as e:
logger.error(f'Failed to send payment confirmation email: {str(e)}')
@staticmethod
def send_payment_approved_email(payment, account, subscription):
"""
Send email when payment is approved and account activated
"""
subject = f'Payment Approved - Account Activated'
context = {
'account_name': account.name,
'invoice_number': payment.invoice.invoice_number,
'amount': payment.amount,
'currency': payment.currency,
'plan_name': subscription.plan.name if subscription else 'N/A',
'approved_at': payment.approved_at,
}
message = f"""
Hi {account.name},
Great news! Your payment has been approved and your account is now active.
Payment Details:
- Invoice: #{payment.invoice.invoice_number}
- Amount: {payment.currency} {payment.amount}
- Plan: {subscription.plan.name if subscription else 'N/A'}
- Approved: {payment.approved_at.strftime('%Y-%m-%d %H:%M')}
You can now access all features of your plan. Log in to get started!
Dashboard: {settings.FRONTEND_URL}/dashboard
Thank you,
The Igny8 Team
"""
try:
send_mail(
subject=subject,
message=message.strip(),
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[account.billing_email or account.owner.email],
fail_silently=False,
)
logger.info(f'Payment approved email sent for Payment {payment.id}')
except Exception as e:
logger.error(f'Failed to send payment approved email: {str(e)}')
@staticmethod
def send_payment_rejected_email(payment, account, reason):
"""
Send email when payment is rejected
"""
subject = f'Payment Declined - Action Required'
message = f"""
Hi {account.name},
Unfortunately, we were unable to approve your payment for Invoice #{payment.invoice.invoice_number}.
Reason: {reason}
Payment Details:
- Invoice: #{payment.invoice.invoice_number}
- Amount: {payment.currency} {payment.amount}
- Reference: {payment.manual_reference}
You can retry your payment by logging into your account:
{settings.FRONTEND_URL}/billing
If you have questions, please contact our support team.
Thank you,
The Igny8 Team
"""
try:
send_mail(
subject=subject,
message=message.strip(),
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[account.billing_email or account.owner.email],
fail_silently=False,
)
logger.info(f'Payment rejected email sent for Payment {payment.id}')
except Exception as e:
logger.error(f'Failed to send payment rejected email: {str(e)}')
@staticmethod
def send_refund_notification(user, payment, refund_amount, reason):
"""
Send email when refund is processed
"""
subject = f'Refund Processed - Invoice #{payment.invoice.invoice_number}'
message = f"""
Hi {user.first_name or user.email},
Your refund has been processed successfully.
Refund Details:
- Invoice: #{payment.invoice.invoice_number}
- Original Amount: {payment.currency} {payment.amount}
- Refund Amount: {payment.currency} {refund_amount}
- Reason: {reason}
- Processed: {payment.refunded_at.strftime('%Y-%m-%d %H:%M')}
The refund will appear in your original payment method within 5-10 business days.
If you have any questions, please contact our support team.
Thank you,
The Igny8 Team
"""
try:
send_mail(
subject=subject,
message=message.strip(),
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[user.email],
fail_silently=False,
)
logger.info(f'Refund notification email sent for Payment {payment.id}')
except Exception as e:
logger.error(f'Failed to send refund notification email: {str(e)}')
@staticmethod
def send_subscription_renewal_notice(subscription, days_until_renewal):
"""
Send email reminder before subscription renewal
"""
subject = f'Subscription Renewal Reminder - {days_until_renewal} Days'
account = subscription.account
user = account.owner
message = f"""
Hi {account.name},
Your subscription will be renewed in {days_until_renewal} days.
Subscription Details:
- Plan: {subscription.plan.name}
- Renewal Date: {subscription.current_period_end.strftime('%Y-%m-%d')}
- Amount: {subscription.plan.currency} {subscription.plan.price}
Your payment method will be charged automatically on the renewal date.
To manage your subscription or update payment details:
{settings.FRONTEND_URL}/billing/subscription
Thank you,
The Igny8 Team
"""
try:
send_mail(
subject=subject,
message=message.strip(),
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[account.billing_email or user.email],
fail_silently=False,
)
logger.info(f'Renewal notice sent for Subscription {subscription.id}')
except Exception as e:
logger.error(f'Failed to send renewal notice: {str(e)}')

View File

@@ -17,20 +17,31 @@ class InvoiceService:
@staticmethod
def generate_invoice_number(account: Account) -> str:
"""
Generate unique invoice number
Generate unique invoice number with atomic locking to prevent duplicates
Format: INV-{ACCOUNT_ID}-{YEAR}{MONTH}-{COUNTER}
"""
from django.db import transaction
now = timezone.now()
prefix = f"INV-{account.id}-{now.year}{now.month:02d}"
# Get count of invoices for this account this month
count = Invoice.objects.filter(
account=account,
created_at__year=now.year,
created_at__month=now.month
).count()
return f"{prefix}-{count + 1:04d}"
# Use atomic transaction with SELECT FOR UPDATE to prevent race conditions
with transaction.atomic():
# Lock the invoice table for this account/month to get accurate count
count = Invoice.objects.select_for_update().filter(
account=account,
created_at__year=now.year,
created_at__month=now.month
).count()
invoice_number = f"{prefix}-{count + 1:04d}"
# Double-check uniqueness (should not happen with lock, but safety check)
while Invoice.objects.filter(invoice_number=invoice_number).exists():
count += 1
invoice_number = f"{prefix}-{count + 1:04d}"
return invoice_number
@staticmethod
@transaction.atomic
@@ -58,27 +69,42 @@ class InvoiceService:
'snapshot_date': timezone.now().isoformat()
}
# For manual payments, use configurable grace period instead of billing_period_end
from igny8_core.business.billing.config import INVOICE_DUE_DATE_OFFSET
invoice_date = timezone.now().date()
due_date = invoice_date + timedelta(days=INVOICE_DUE_DATE_OFFSET)
# Get currency based on billing country
from igny8_core.business.billing.utils.currency import get_currency_for_country, convert_usd_to_local
currency = get_currency_for_country(account.billing_country)
# Convert plan price to local currency
local_price = convert_usd_to_local(float(plan.price), account.billing_country)
invoice = Invoice.objects.create(
account=account,
subscription=subscription, # Set FK directly
invoice_number=InvoiceService.generate_invoice_number(account),
status='pending',
currency='USD',
invoice_date=timezone.now().date(),
due_date=billing_period_end.date(),
currency=currency,
invoice_date=invoice_date,
due_date=due_date,
metadata={
'billing_snapshot': billing_snapshot,
'billing_period_start': billing_period_start.isoformat(),
'billing_period_end': billing_period_end.isoformat(),
'subscription_id': subscription.id
'subscription_id': subscription.id, # Keep in metadata for backward compatibility
'usd_price': str(plan.price), # Store original USD price
'exchange_rate': str(local_price / float(plan.price) if plan.price > 0 else 1.0)
}
)
# Add line item for subscription
# Add line item for subscription with converted price
invoice.add_line_item(
description=f"{plan.name} Plan - {billing_period_start.strftime('%b %Y')}",
quantity=1,
unit_price=plan.price,
amount=plan.price
unit_price=Decimal(str(local_price)),
amount=Decimal(str(local_price))
)
invoice.calculate_totals()
@@ -95,26 +121,38 @@ class InvoiceService:
"""
Create invoice for credit package purchase
"""
from igny8_core.business.billing.config import INVOICE_DUE_DATE_OFFSET
invoice_date = timezone.now().date()
# Get currency based on billing country
from igny8_core.business.billing.utils.currency import get_currency_for_country, convert_usd_to_local
currency = get_currency_for_country(account.billing_country)
# Convert credit package price to local currency
local_price = convert_usd_to_local(float(credit_package.price), account.billing_country)
invoice = Invoice.objects.create(
account=account,
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=timezone.now().date(),
currency=currency,
invoice_date=invoice_date,
due_date=invoice_date + timedelta(days=INVOICE_DUE_DATE_OFFSET),
metadata={
'credit_package_id': credit_package.id,
'credit_amount': credit_package.credits,
'usd_price': str(credit_package.price), # Store original USD price
'exchange_rate': str(local_price / float(credit_package.price) if credit_package.price > 0 else 1.0)
},
)
# Add line item for credit package
# Add line item for credit package with converted price
invoice.add_line_item(
description=f"{credit_package.name} - {credit_package.credits:,} Credits",
quantity=1,
unit_price=credit_package.price,
amount=credit_package.price
unit_price=Decimal(str(local_price)),
amount=Decimal(str(local_price))
)
invoice.calculate_totals()

View File

@@ -0,0 +1,246 @@
"""
Invoice PDF generation service
Generates PDF invoices for billing records
"""
from decimal import Decimal
from datetime import datetime
from io import BytesIO
from reportlab.lib import colors
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image
from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER
from django.conf import settings
import logging
logger = logging.getLogger(__name__)
class InvoicePDFGenerator:
"""Generate PDF invoices"""
@staticmethod
def generate_invoice_pdf(invoice):
"""
Generate PDF for an invoice
Args:
invoice: Invoice model instance
Returns:
BytesIO: PDF file buffer
"""
buffer = BytesIO()
# Create PDF document
doc = SimpleDocTemplate(
buffer,
pagesize=letter,
rightMargin=0.75*inch,
leftMargin=0.75*inch,
topMargin=0.75*inch,
bottomMargin=0.75*inch
)
# Container for PDF elements
elements = []
styles = getSampleStyleSheet()
# Custom styles
title_style = ParagraphStyle(
'CustomTitle',
parent=styles['Heading1'],
fontSize=24,
textColor=colors.HexColor('#1f2937'),
spaceAfter=30,
)
heading_style = ParagraphStyle(
'CustomHeading',
parent=styles['Heading2'],
fontSize=14,
textColor=colors.HexColor('#374151'),
spaceAfter=12,
)
normal_style = ParagraphStyle(
'CustomNormal',
parent=styles['Normal'],
fontSize=10,
textColor=colors.HexColor('#4b5563'),
)
# Header
elements.append(Paragraph('INVOICE', title_style))
elements.append(Spacer(1, 0.2*inch))
# Company info and invoice details side by side
company_data = [
['<b>From:</b>', f'<b>Invoice #:</b> {invoice.invoice_number}'],
[getattr(settings, 'COMPANY_NAME', 'Igny8'), f'<b>Date:</b> {invoice.created_at.strftime("%B %d, %Y")}'],
[getattr(settings, 'COMPANY_ADDRESS', ''), f'<b>Due Date:</b> {invoice.due_date.strftime("%B %d, %Y")}'],
[getattr(settings, 'COMPANY_EMAIL', settings.DEFAULT_FROM_EMAIL), f'<b>Status:</b> {invoice.status.upper()}'],
]
company_table = Table(company_data, colWidths=[3.5*inch, 3*inch])
company_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (-1, -1), 'Helvetica'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('TEXTCOLOR', (0, 0), (-1, -1), colors.HexColor('#4b5563')),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('ALIGN', (1, 0), (1, -1), 'RIGHT'),
]))
elements.append(company_table)
elements.append(Spacer(1, 0.3*inch))
# Bill to section
elements.append(Paragraph('<b>Bill To:</b>', heading_style))
bill_to_data = [
[invoice.account.name],
[invoice.account.owner.email],
]
if hasattr(invoice.account, 'billing_email') and invoice.account.billing_email:
bill_to_data.append([f'Billing: {invoice.account.billing_email}'])
for line in bill_to_data:
elements.append(Paragraph(line[0], normal_style))
elements.append(Spacer(1, 0.3*inch))
# Line items table
elements.append(Paragraph('<b>Items:</b>', heading_style))
# Table header
line_items_data = [
['Description', 'Quantity', 'Unit Price', 'Amount']
]
# Get line items
for item in invoice.line_items.all():
line_items_data.append([
item.description,
str(item.quantity),
f'{invoice.currency} {item.unit_price:.2f}',
f'{invoice.currency} {item.total_price:.2f}'
])
# Add subtotal, tax, total rows
line_items_data.append(['', '', '<b>Subtotal:</b>', f'<b>{invoice.currency} {invoice.subtotal:.2f}</b>'])
if invoice.tax_amount and invoice.tax_amount > 0:
line_items_data.append(['', '', f'Tax ({invoice.tax_rate}%):', f'{invoice.currency} {invoice.tax_amount:.2f}'])
if invoice.discount_amount and invoice.discount_amount > 0:
line_items_data.append(['', '', 'Discount:', f'-{invoice.currency} {invoice.discount_amount:.2f}'])
line_items_data.append(['', '', '<b>Total:</b>', f'<b>{invoice.currency} {invoice.total_amount:.2f}</b>'])
# Create table
line_items_table = Table(
line_items_data,
colWidths=[3*inch, 1*inch, 1.25*inch, 1.25*inch]
)
line_items_table.setStyle(TableStyle([
# Header row
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#f3f4f6')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.HexColor('#1f2937')),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 10),
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
# Body rows
('FONTNAME', (0, 1), (-1, -4), 'Helvetica'),
('FONTSIZE', (0, 1), (-1, -4), 9),
('TEXTCOLOR', (0, 1), (-1, -4), colors.HexColor('#4b5563')),
('ROWBACKGROUNDS', (0, 1), (-1, -4), [colors.white, colors.HexColor('#f9fafb')]),
# Summary rows (last 3-4 rows)
('FONTNAME', (0, -4), (-1, -1), 'Helvetica'),
('FONTSIZE', (0, -4), (-1, -1), 9),
('ALIGN', (2, 0), (2, -1), 'RIGHT'),
('ALIGN', (3, 0), (3, -1), 'RIGHT'),
# Grid
('GRID', (0, 0), (-1, -4), 0.5, colors.HexColor('#e5e7eb')),
('LINEABOVE', (2, -4), (-1, -4), 1, colors.HexColor('#d1d5db')),
('LINEABOVE', (2, -1), (-1, -1), 2, colors.HexColor('#1f2937')),
# Padding
('TOPPADDING', (0, 0), (-1, -1), 8),
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
('LEFTPADDING', (0, 0), (-1, -1), 10),
('RIGHTPADDING', (0, 0), (-1, -1), 10),
]))
elements.append(line_items_table)
elements.append(Spacer(1, 0.4*inch))
# Payment information
if invoice.status == 'paid':
elements.append(Paragraph('<b>Payment Information:</b>', heading_style))
payment = invoice.payments.filter(status='succeeded').first()
if payment:
payment_info = [
f'Payment Method: {payment.get_payment_method_display()}',
f'Paid On: {payment.processed_at.strftime("%B %d, %Y")}',
]
if payment.manual_reference:
payment_info.append(f'Reference: {payment.manual_reference}')
for line in payment_info:
elements.append(Paragraph(line, normal_style))
elements.append(Spacer(1, 0.2*inch))
# Footer / Notes
if invoice.notes:
elements.append(Spacer(1, 0.2*inch))
elements.append(Paragraph('<b>Notes:</b>', heading_style))
elements.append(Paragraph(invoice.notes, normal_style))
# Terms
elements.append(Spacer(1, 0.3*inch))
elements.append(Paragraph('<b>Terms & Conditions:</b>', heading_style))
terms = getattr(settings, 'INVOICE_TERMS', 'Payment is due within 7 days of invoice date.')
elements.append(Paragraph(terms, normal_style))
# Build PDF
doc.build(elements)
# Get PDF content
buffer.seek(0)
return buffer
@staticmethod
def save_invoice_pdf(invoice, file_path=None):
"""
Generate and save invoice PDF to file
Args:
invoice: Invoice model instance
file_path: Optional file path, defaults to media/invoices/
Returns:
str: File path where PDF was saved
"""
import os
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
# Generate PDF
pdf_buffer = InvoicePDFGenerator.generate_invoice_pdf(invoice)
# Determine file path
if not file_path:
file_path = f'invoices/{invoice.invoice_number}.pdf'
# Save to storage
saved_path = default_storage.save(file_path, ContentFile(pdf_buffer.read()))
logger.info(f'Invoice PDF saved: {saved_path}')
return saved_path