FInal bank, stripe and paypal sandbox completed

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-08 00:12:41 +00:00
parent ad75fa031e
commit 7ad1f6bdff
19 changed files with 2622 additions and 375 deletions

View File

@@ -52,28 +52,27 @@ class InvoiceService:
def generate_invoice_number(account: Account) -> str:
"""
Generate unique invoice number with atomic locking to prevent duplicates
Format: INV-{ACCOUNT_ID}-{YEAR}{MONTH}-{COUNTER}
Format: INV-{YY}{MM}{COUNTER} (e.g., INV-26010001)
"""
from django.db import transaction
now = timezone.now()
prefix = f"INV-{account.id}-{now.year}{now.month:02d}"
prefix = f"INV-{now.year % 100:02d}{now.month:02d}"
# 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
# Lock the invoice table for this 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}"
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}"
invoice_number = f"{prefix}{count + 1:04d}"
return invoice_number
@@ -87,9 +86,10 @@ class InvoiceService:
"""
Create invoice for subscription billing period
Currency logic:
- USD for online payments (stripe, paypal)
- Local currency (PKR) only for bank_transfer in applicable countries
SIMPLIFIED CURRENCY LOGIC:
- ALL invoices are in USD (consistent for accounting)
- PKR equivalent is calculated and stored in metadata for display purposes
- Bank transfer users see PKR equivalent but invoice is technically USD
"""
account = subscription.account
plan = subscription.plan
@@ -112,22 +112,15 @@ class InvoiceService:
invoice_date = timezone.now().date()
due_date = invoice_date + timedelta(days=INVOICE_DUE_DATE_OFFSET)
# Determine currency based on payment method:
# - Online payments (stripe, paypal): Always USD
# - Manual payments (bank_transfer, local_wallet): Local currency for applicable countries
# ALWAYS use USD for invoices (simplified accounting)
from igny8_core.business.billing.utils.currency import get_currency_for_country, convert_usd_to_local
payment_method = account.payment_method
online_payment_methods = ['stripe', 'paypal']
currency = 'USD'
usd_price = float(plan.price)
if payment_method in online_payment_methods:
# Online payments are always in USD
currency = 'USD'
local_price = float(plan.price)
else:
# Manual payments use local currency for applicable countries
currency = get_currency_for_country(account.billing_country)
local_price = convert_usd_to_local(float(plan.price), account.billing_country)
# Calculate local equivalent for display purposes (if applicable)
local_currency = get_currency_for_country(account.billing_country) if account.billing_country else 'USD'
local_equivalent = convert_usd_to_local(usd_price, account.billing_country) if local_currency != 'USD' else usd_price
invoice = Invoice.objects.create(
account=account,
@@ -143,17 +136,19 @@ class InvoiceService:
'billing_period_end': billing_period_end.isoformat(),
'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),
'payment_method': payment_method
'local_currency': local_currency, # Store local currency code for display
'local_equivalent': str(round(local_equivalent, 2)), # Store local equivalent for display
'exchange_rate': str(local_equivalent / usd_price if usd_price > 0 else 1.0),
'payment_method': account.payment_method
}
)
# Add line item for subscription with converted price
# Add line item for subscription in USD
invoice.add_line_item(
description=f"{plan.name} Plan - {billing_period_start.strftime('%b %Y')}",
quantity=1,
unit_price=Decimal(str(local_price)),
amount=Decimal(str(local_price))
unit_price=Decimal(str(usd_price)),
amount=Decimal(str(usd_price))
)
invoice.calculate_totals()
@@ -170,27 +165,22 @@ class InvoiceService:
"""
Create invoice for credit package purchase
Currency logic:
- USD for online payments (stripe, paypal)
- Local currency (PKR) only for bank_transfer in applicable countries
SIMPLIFIED CURRENCY LOGIC:
- ALL invoices are in USD (consistent for accounting)
- PKR equivalent is calculated and stored in metadata for display purposes
"""
from igny8_core.business.billing.config import INVOICE_DUE_DATE_OFFSET
invoice_date = timezone.now().date()
# Determine currency based on payment method
# ALWAYS use USD for invoices (simplified accounting)
from igny8_core.business.billing.utils.currency import get_currency_for_country, convert_usd_to_local
payment_method = account.payment_method
online_payment_methods = ['stripe', 'paypal']
currency = 'USD'
usd_price = float(credit_package.price)
if payment_method in online_payment_methods:
# Online payments are always in USD
currency = 'USD'
local_price = float(credit_package.price)
else:
# Manual payments use local currency for applicable countries
currency = get_currency_for_country(account.billing_country)
local_price = convert_usd_to_local(float(credit_package.price), account.billing_country)
# Calculate local equivalent for display purposes (if applicable)
local_currency = get_currency_for_country(account.billing_country) if account.billing_country else 'USD'
local_equivalent = convert_usd_to_local(usd_price, account.billing_country) if local_currency != 'USD' else usd_price
invoice = Invoice.objects.create(
account=account,
@@ -204,17 +194,19 @@ class InvoiceService:
'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),
'payment_method': payment_method
'local_currency': local_currency, # Store local currency code for display
'local_equivalent': str(round(local_equivalent, 2)), # Store local equivalent for display
'exchange_rate': str(local_equivalent / usd_price if usd_price > 0 else 1.0),
'payment_method': account.payment_method
},
)
# Add line item for credit package with converted price
# Add line item for credit package in USD
invoice.add_line_item(
description=f"{credit_package.name} - {credit_package.credits:,} Credits",
quantity=1,
unit_price=Decimal(str(local_price)),
amount=Decimal(str(local_price))
unit_price=Decimal(str(usd_price)),
amount=Decimal(str(usd_price))
)
invoice.calculate_totals()
@@ -312,43 +304,13 @@ class InvoiceService:
@staticmethod
def generate_pdf(invoice: Invoice) -> bytes:
"""
Generate PDF for invoice
TODO: Implement PDF generation using reportlab or weasyprint
For now, return placeholder
Generate professional PDF invoice using ReportLab
"""
from io import BytesIO
from igny8_core.business.billing.services.pdf_service import InvoicePDFGenerator
# Placeholder - implement PDF generation
buffer = BytesIO()
# Simple text representation for now
content = f"""
INVOICE #{invoice.invoice_number}
Bill To: {invoice.account.name}
Email: {invoice.billing_email}
Date: {invoice.created_at.strftime('%Y-%m-%d')}
Due Date: {invoice.due_date.strftime('%Y-%m-%d') if invoice.due_date else 'N/A'}
Line Items:
"""
for item in invoice.line_items:
content += f" {item['description']} - ${item['amount']}\n"
content += f"""
Subtotal: ${invoice.subtotal}
Tax: ${invoice.tax_amount}
Total: ${invoice.total_amount}
Status: {invoice.status.upper()}
"""
buffer.write(content.encode('utf-8'))
buffer.seek(0)
return buffer.getvalue()
# Use the professional PDF generator
pdf_buffer = InvoicePDFGenerator.generate_invoice_pdf(invoice)
return pdf_buffer.getvalue()
@staticmethod
def get_account_invoices(

View File

@@ -9,17 +9,32 @@ 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.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image, HRFlowable
from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER
from django.conf import settings
import os
import logging
logger = logging.getLogger(__name__)
# Logo path - check multiple possible locations
LOGO_PATHS = [
'/data/app/igny8/frontend/public/images/logo/IGNY8_LIGHT_LOGO.png',
'/app/static/images/logo/IGNY8_LIGHT_LOGO.png',
]
class InvoicePDFGenerator:
"""Generate PDF invoices"""
@staticmethod
def get_logo_path():
"""Find the logo file from possible locations"""
for path in LOGO_PATHS:
if os.path.exists(path):
return path
return None
@staticmethod
def generate_invoice_pdf(invoice):
"""
@@ -39,8 +54,8 @@ class InvoicePDFGenerator:
pagesize=letter,
rightMargin=0.75*inch,
leftMargin=0.75*inch,
topMargin=0.75*inch,
bottomMargin=0.75*inch
topMargin=0.5*inch,
bottomMargin=0.5*inch
)
# Container for PDF elements
@@ -51,17 +66,19 @@ class InvoicePDFGenerator:
title_style = ParagraphStyle(
'CustomTitle',
parent=styles['Heading1'],
fontSize=24,
fontSize=28,
textColor=colors.HexColor('#1f2937'),
spaceAfter=30,
spaceAfter=0,
fontName='Helvetica-Bold',
)
heading_style = ParagraphStyle(
'CustomHeading',
parent=styles['Heading2'],
fontSize=14,
textColor=colors.HexColor('#374151'),
spaceAfter=12,
fontSize=12,
textColor=colors.HexColor('#1f2937'),
spaceAfter=8,
fontName='Helvetica-Bold',
)
normal_style = ParagraphStyle(
@@ -69,145 +86,292 @@ class InvoicePDFGenerator:
parent=styles['Normal'],
fontSize=10,
textColor=colors.HexColor('#4b5563'),
fontName='Helvetica',
)
# Header
elements.append(Paragraph('INVOICE', title_style))
elements.append(Spacer(1, 0.2*inch))
label_style = ParagraphStyle(
'LabelStyle',
parent=styles['Normal'],
fontSize=9,
textColor=colors.HexColor('#6b7280'),
fontName='Helvetica',
)
# 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()}'],
]
value_style = ParagraphStyle(
'ValueStyle',
parent=styles['Normal'],
fontSize=10,
textColor=colors.HexColor('#1f2937'),
fontName='Helvetica-Bold',
)
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'),
right_align_style = ParagraphStyle(
'RightAlign',
parent=styles['Normal'],
fontSize=10,
textColor=colors.HexColor('#4b5563'),
alignment=TA_RIGHT,
fontName='Helvetica',
)
right_bold_style = ParagraphStyle(
'RightBold',
parent=styles['Normal'],
fontSize=10,
textColor=colors.HexColor('#1f2937'),
alignment=TA_RIGHT,
fontName='Helvetica-Bold',
)
# Header with Logo and Invoice title
logo_path = InvoicePDFGenerator.get_logo_path()
header_data = []
if logo_path:
try:
logo = Image(logo_path, width=1.5*inch, height=0.5*inch)
logo.hAlign = 'LEFT'
header_data = [[logo, Paragraph('INVOICE', title_style)]]
except Exception as e:
logger.warning(f"Could not load logo: {e}")
header_data = [[Paragraph('IGNY8', title_style), Paragraph('INVOICE', title_style)]]
else:
header_data = [[Paragraph('IGNY8', title_style), Paragraph('INVOICE', title_style)]]
header_table = Table(header_data, colWidths=[3.5*inch, 3*inch])
header_table.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('ALIGN', (0, 0), (0, 0), 'LEFT'),
('ALIGN', (1, 0), (1, 0), 'RIGHT'),
]))
elements.append(company_table)
elements.append(header_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],
# Divider line
elements.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e5e7eb'), spaceAfter=20))
# Invoice details section (right side info)
invoice_info = [
[Paragraph('Invoice Number:', label_style), Paragraph(invoice.invoice_number, value_style)],
[Paragraph('Date:', label_style), Paragraph(invoice.created_at.strftime("%B %d, %Y"), value_style)],
[Paragraph('Due Date:', label_style), Paragraph(invoice.due_date.strftime("%B %d, %Y"), value_style)],
[Paragraph('Status:', label_style), Paragraph(invoice.status.upper(), value_style)],
]
if hasattr(invoice.account, 'billing_email') and invoice.account.billing_email:
bill_to_data.append([f'Billing: {invoice.account.billing_email}'])
invoice_info_table = Table(invoice_info, colWidths=[1.2*inch, 2*inch])
invoice_info_table.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 4),
]))
for line in bill_to_data:
elements.append(Paragraph(line[0], normal_style))
# From and To section
company_name = getattr(settings, 'COMPANY_NAME', 'Igny8')
company_email = getattr(settings, 'COMPANY_EMAIL', settings.DEFAULT_FROM_EMAIL)
elements.append(Spacer(1, 0.3*inch))
from_section = [
Paragraph('FROM', heading_style),
Paragraph(company_name, value_style),
Paragraph(company_email, normal_style),
]
customer_name = invoice.account.name if invoice.account else 'N/A'
customer_email = invoice.account.owner.email if invoice.account and invoice.account.owner else invoice.account.billing_email if invoice.account else 'N/A'
billing_email = invoice.account.billing_email if invoice.account and hasattr(invoice.account, 'billing_email') and invoice.account.billing_email else None
to_section = [
Paragraph('BILL TO', heading_style),
Paragraph(customer_name, value_style),
Paragraph(customer_email, normal_style),
]
if billing_email and billing_email != customer_email:
to_section.append(Paragraph(f'Billing: {billing_email}', normal_style))
# Create from/to layout
from_content = []
for item in from_section:
from_content.append([item])
from_table = Table(from_content, colWidths=[3*inch])
to_content = []
for item in to_section:
to_content.append([item])
to_table = Table(to_content, colWidths=[3*inch])
# Main info layout with From, To, and Invoice details
main_info = [[from_table, to_table, invoice_info_table]]
main_info_table = Table(main_info, colWidths=[2.3*inch, 2.3*inch, 2.4*inch])
main_info_table.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'TOP'),
]))
elements.append(main_info_table)
elements.append(Spacer(1, 0.4*inch))
# Line items table
elements.append(Paragraph('<b>Items:</b>', heading_style))
elements.append(Paragraph('ITEMS', heading_style))
elements.append(Spacer(1, 0.1*inch))
# Table header
# Table header - use Paragraph for proper rendering
line_items_data = [
['Description', 'Quantity', 'Unit Price', 'Amount']
[
Paragraph('Description', ParagraphStyle('Header', fontName='Helvetica-Bold', fontSize=10, textColor=colors.HexColor('#374151'))),
Paragraph('Qty', ParagraphStyle('Header', fontName='Helvetica-Bold', fontSize=10, textColor=colors.HexColor('#374151'), alignment=TA_CENTER)),
Paragraph('Unit Price', ParagraphStyle('Header', fontName='Helvetica-Bold', fontSize=10, textColor=colors.HexColor('#374151'), alignment=TA_RIGHT)),
Paragraph('Amount', ParagraphStyle('Header', fontName='Helvetica-Bold', fontSize=10, textColor=colors.HexColor('#374151'), alignment=TA_RIGHT)),
]
]
# Get line items
for item in invoice.line_items.all():
# Get line items - line_items is a JSON field (list of dicts)
items = invoice.line_items or []
for item in items:
unit_price = float(item.get('unit_price', 0))
amount = float(item.get('amount', 0))
line_items_data.append([
item.description,
str(item.quantity),
f'{invoice.currency} {item.unit_price:.2f}',
f'{invoice.currency} {item.total_price:.2f}'
Paragraph(item.get('description', ''), normal_style),
Paragraph(str(item.get('quantity', 1)), ParagraphStyle('Center', parent=normal_style, alignment=TA_CENTER)),
Paragraph(f'{invoice.currency} {unit_price:.2f}', right_align_style),
Paragraph(f'{invoice.currency} {amount:.2f}', right_align_style),
])
# 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>'])
# Add empty row for spacing before totals
line_items_data.append(['', '', '', ''])
# Create table
line_items_table = Table(
line_items_data,
colWidths=[3*inch, 1*inch, 1.25*inch, 1.25*inch]
colWidths=[3.2*inch, 0.8*inch, 1.25*inch, 1.25*inch]
)
num_items = len(items)
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),
('TOPPADDING', (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')]),
('ROWBACKGROUNDS', (0, 1), (-1, num_items), [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'),
# Alignment
('ALIGN', (1, 0), (1, -1), 'CENTER'),
('ALIGN', (2, 0), (-1, -1), 'RIGHT'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
# 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')),
# Grid for items only
('LINEBELOW', (0, 0), (-1, 0), 1, colors.HexColor('#d1d5db')),
('LINEBELOW', (0, num_items), (-1, num_items), 1, colors.HexColor('#e5e7eb')),
# 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),
('TOPPADDING', (0, 1), (-1, -1), 10),
('BOTTOMPADDING', (0, 1), (-1, -1), 10),
('LEFTPADDING', (0, 0), (-1, -1), 8),
('RIGHTPADDING', (0, 0), (-1, -1), 8),
]))
elements.append(line_items_table)
elements.append(Spacer(1, 0.2*inch))
# Totals section - right aligned
totals_data = [
[Paragraph('Subtotal:', right_align_style), Paragraph(f'{invoice.currency} {float(invoice.subtotal):.2f}', right_bold_style)],
]
tax_amount = float(invoice.tax or 0)
if tax_amount > 0:
tax_rate = invoice.metadata.get('tax_rate', 0) if invoice.metadata else 0
totals_data.append([
Paragraph(f'Tax ({tax_rate}%):', right_align_style),
Paragraph(f'{invoice.currency} {tax_amount:.2f}', right_align_style)
])
discount_amount = float(invoice.metadata.get('discount_amount', 0)) if invoice.metadata else 0
if discount_amount > 0:
totals_data.append([
Paragraph('Discount:', right_align_style),
Paragraph(f'-{invoice.currency} {discount_amount:.2f}', right_align_style)
])
totals_data.append([
Paragraph('Total:', ParagraphStyle('TotalLabel', fontName='Helvetica-Bold', fontSize=12, textColor=colors.HexColor('#1f2937'), alignment=TA_RIGHT)),
Paragraph(f'{invoice.currency} {float(invoice.total):.2f}', ParagraphStyle('TotalValue', fontName='Helvetica-Bold', fontSize=12, textColor=colors.HexColor('#1f2937'), alignment=TA_RIGHT))
])
totals_table = Table(totals_data, colWidths=[1.5*inch, 1.5*inch])
totals_table.setStyle(TableStyle([
('ALIGN', (0, 0), (-1, -1), 'RIGHT'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('TOPPADDING', (0, 0), (-1, -1), 6),
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
('LINEABOVE', (0, -1), (-1, -1), 2, colors.HexColor('#1f2937')),
]))
# Right-align the totals table
totals_wrapper = Table([[totals_table]], colWidths=[6.5*inch])
totals_wrapper.setStyle(TableStyle([
('ALIGN', (0, 0), (0, 0), 'RIGHT'),
]))
elements.append(totals_wrapper)
elements.append(Spacer(1, 0.4*inch))
# Payment information
if invoice.status == 'paid':
elements.append(Paragraph('<b>Payment Information:</b>', heading_style))
elements.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e5e7eb'), spaceBefore=10, spaceAfter=15))
elements.append(Paragraph('PAYMENT INFORMATION', heading_style))
payment = invoice.payments.filter(status='succeeded').first()
if payment:
payment_method = payment.get_payment_method_display() if hasattr(payment, 'get_payment_method_display') else str(payment.payment_method)
payment_date = payment.processed_at.strftime("%B %d, %Y") if payment.processed_at else 'N/A'
payment_info = [
f'Payment Method: {payment.get_payment_method_display()}',
f'Paid On: {payment.processed_at.strftime("%B %d, %Y")}',
[Paragraph('Payment Method:', label_style), Paragraph(payment_method, value_style)],
[Paragraph('Paid On:', label_style), Paragraph(payment_date, value_style)],
]
if payment.manual_reference:
payment_info.append(f'Reference: {payment.manual_reference}')
for line in payment_info:
elements.append(Paragraph(line, normal_style))
payment_info.append([Paragraph('Reference:', label_style), Paragraph(payment.manual_reference, value_style)])
payment_table = Table(payment_info, colWidths=[1.5*inch, 3*inch])
payment_table.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 4),
]))
elements.append(payment_table)
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('NOTES', 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))
elements.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e5e7eb'), spaceAfter=15))
terms_style = ParagraphStyle(
'Terms',
parent=styles['Normal'],
fontSize=8,
textColor=colors.HexColor('#9ca3af'),
fontName='Helvetica',
)
terms = getattr(settings, 'INVOICE_TERMS', 'Payment is due within 7 days of invoice date. Thank you for your business!')
elements.append(Paragraph(f'Terms & Conditions: {terms}', terms_style))
# Footer with company info
elements.append(Spacer(1, 0.2*inch))
footer_style = ParagraphStyle(
'Footer',
parent=styles['Normal'],
fontSize=8,
textColor=colors.HexColor('#9ca3af'),
fontName='Helvetica',
alignment=TA_CENTER,
)
elements.append(Paragraph(f'Generated by IGNY8 • {company_email}', footer_style))
# Build PDF
doc.build(elements)