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:
228
backend/igny8_core/business/billing/services/email_service.py
Normal file
228
backend/igny8_core/business/billing/services/email_service.py
Normal 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)}')
|
||||
|
||||
@@ -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()
|
||||
|
||||
246
backend/igny8_core/business/billing/services/pdf_service.py
Normal file
246
backend/igny8_core/business/billing/services/pdf_service.py
Normal 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
|
||||
Reference in New Issue
Block a user