Every payment platform needs invoices. They are the paper trail that merchants, accountants, and tax authorities rely on. In 0fee.dev, the invoice system goes beyond basic receipts -- it produces professional, branded PDF documents that merchants can send directly to their customers.
Invoice Data Model
An invoice in 0fee.dev is linked to a transaction but lives as its own entity with its own lifecycle:
pythonclass Invoice(Base):
__tablename__ = "invoices"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
app_id: Mapped[UUID] = mapped_column(ForeignKey("apps.id"))
transaction_id: Mapped[UUID] = mapped_column(
ForeignKey("transactions.id"), nullable=True
)
# Reference format: {REF}-{YYMMDD}-{APP_SLUG}-{XXXX}
reference: Mapped[str] = mapped_column(String(50), unique=True, index=True)
# Merchant info (from app settings at creation time)
merchant_name: Mapped[str]
merchant_address: Mapped[Optional[str]]
merchant_email: Mapped[Optional[str]]
merchant_phone: Mapped[Optional[str]]
merchant_logo_url: Mapped[Optional[str]]
merchant_tax_id: Mapped[Optional[str]]
# Customer info
customer_name: Mapped[Optional[str]]
customer_email: Mapped[Optional[str]]
customer_phone: Mapped[Optional[str]]
customer_address: Mapped[Optional[str]]
# Amounts
subtotal: Mapped[Decimal]
tax_amount: Mapped[Decimal] = mapped_column(default=Decimal("0"))
tax_rate: Mapped[Decimal] = mapped_column(default=Decimal("0"))
total: Mapped[Decimal]
currency: Mapped[str] = mapped_column(String(3))
# Line items stored as JSON
items: Mapped[dict] = mapped_column(JSON)
# Status
status: Mapped[str] = mapped_column(default="draft")
# draft, sent, paid, cancelled
# Dates
issued_at: Mapped[Optional[datetime]]
due_date: Mapped[Optional[date]]
paid_at: Mapped[Optional[datetime]]
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)Invoice Reference Format
The reference format is designed for multi-app auditing: {REF}-{YYMMDD}-{APP_SLUG}-{XXXX}
pythonimport re
from datetime import datetime
async def generate_invoice_reference(
payment_reference: str,
app_slug: str,
db: AsyncSession
) -> str:
"""Generate a unique invoice reference.
Format: {REF}-{YYMMDD}-{APP_SLUG}-{XXXX}
Example: ORD42-260315-myboutique-0001
"""
# Sanitize the payment reference
sanitized_ref = re.sub(r'[^a-zA-Z0-9]', '', payment_reference)[:10].upper()
if not sanitized_ref:
sanitized_ref = "INV"
# Date component
date_str = datetime.utcnow().strftime("%y%m%d")
# App slug (sanitized, max 15 chars)
clean_slug = re.sub(r'[^a-z0-9]', '', app_slug.lower())[:15]
# Sequence number: daily auto-incrementing per app
today = datetime.utcnow().date()
count = await db.execute(
select(func.count(Invoice.id)).where(
Invoice.app_id == app_slug,
func.date(Invoice.created_at) == today
)
)
sequence = count.scalar() + 1
return f"{sanitized_ref}-{date_str}-{clean_slug}-{sequence:04d}"The app slug in the reference allows auditors to immediately identify which application generated an invoice, even when viewing invoices across multiple merchant accounts.
Professional HTML Invoice Template
The PDF is generated from an HTML template styled with CSS. We chose HTML-to-PDF over direct PDF libraries because HTML is easier to design, maintain, and customize:
pythonINVOICE_HTML_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<style>
@page {
size: A4;
margin: 2cm;
}
body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 11pt;
color: #1a1a1a;
line-height: 1.5;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 40px;
border-bottom: 3px solid {{ brand_color }};
padding-bottom: 20px;
}
.logo img {
max-height: 60px;
max-width: 200px;
}
.invoice-title {
font-size: 28pt;
font-weight: 700;
color: {{ brand_color }};
text-align: right;
}
.invoice-meta {
text-align: right;
font-size: 10pt;
color: #666;
}
.parties {
display: flex;
justify-content: space-between;
margin-bottom: 30px;
}
.party {
width: 45%;
}
.party-label {
font-size: 9pt;
text-transform: uppercase;
letter-spacing: 1px;
color: #999;
margin-bottom: 5px;
}
.party-name {
font-size: 14pt;
font-weight: 600;
margin-bottom: 5px;
}
table.items {
width: 100%;
border-collapse: collapse;
margin-bottom: 30px;
}
table.items th {
background: {{ brand_color }};
color: white;
padding: 10px 12px;
text-align: left;
font-size: 9pt;
text-transform: uppercase;
letter-spacing: 0.5px;
}
table.items td {
padding: 10px 12px;
border-bottom: 1px solid #eee;
}
table.items tr:nth-child(even) {
background: #f9f9f9;
}
.totals {
width: 300px;
margin-left: auto;
}
.totals tr td {
padding: 6px 12px;
}
.totals .total-row {
font-size: 14pt;
font-weight: 700;
border-top: 2px solid #1a1a1a;
}
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
text-align: center;
font-size: 8pt;
color: #999;
padding: 10px 2cm;
border-top: 1px solid #eee;
}
</style>
</head>
<body>
<div class="header">
<div class="logo">
{% if merchant_logo_url %}
<img src="{{ merchant_logo_url }}" alt="{{ merchant_name }}" />
{% else %}
<div class="party-name">{{ merchant_name }}</div>
{% endif %}
</div>
<div>
<div class="invoice-title">INVOICE</div>
<div class="invoice-meta">
<div><strong>{{ reference }}</strong></div>
<div>Issued: {{ issued_at }}</div>
{% if due_date %}
<div>Due: {{ due_date }}</div>
{% endif %}
</div>
</div>
</div>
<div class="parties">
<div class="party">
<div class="party-label">From</div>
<div class="party-name">{{ merchant_name }}</div>
{% if merchant_address %}<div>{{ merchant_address }}</div>{% endif %}
{% if merchant_email %}<div>{{ merchant_email }}</div>{% endif %}
{% if merchant_tax_id %}<div>Tax ID: {{ merchant_tax_id }}</div>{% endif %}
</div>
<div class="party">
<div class="party-label">Bill To</div>
<div class="party-name">{{ customer_name or 'Customer' }}</div>
{% if customer_email %}<div>{{ customer_email }}</div>{% endif %}
{% if customer_address %}<div>{{ customer_address }}</div>{% endif %}
</div>
</div>
<table class="items">
<thead>
<tr>
<th>Description</th>
<th>Qty</th>
<th>Unit Price</th>
<th style="text-align: right;">Amount</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.description }}</td>
<td>{{ item.quantity }}</td>
<td>{{ format_currency(item.unit_price, currency) }}</td>
<td style="text-align: right;">
{{ format_currency(item.amount, currency) }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<table class="totals">
<tr>
<td>Subtotal</td>
<td style="text-align: right;">{{ format_currency(subtotal, currency) }}</td>
</tr>
{% if tax_rate > 0 %}
<tr>
<td>Tax ({{ tax_rate }}%)</td>
<td style="text-align: right;">{{ format_currency(tax_amount, currency) }}</td>
</tr>
{% endif %}
<tr class="total-row">
<td>Total</td>
<td style="text-align: right;">{{ format_currency(total, currency) }}</td>
</tr>
</table>
<div class="footer">
Invoice generated by {{ merchant_name }} via 0fee.dev
</div>
</body>
</html>
"""WeasyPrint PDF Generation
We chose WeasyPrint for HTML-to-PDF conversion. It is a Python library that renders HTML/CSS to PDF with excellent CSS support, including flexbox, custom fonts, and page-level styling.
pythonfrom weasyprint import HTML
from jinja2 import Template
import io
async def generate_invoice_pdf(invoice: Invoice) -> bytes:
"""Generate a PDF from the invoice data."""
# Load merchant branding
app_settings = await get_app_settings(invoice.app_id)
brand_color = app_settings.get("brand_color", "#2563eb")
# Render HTML template
template = Template(INVOICE_HTML_TEMPLATE)
html_content = template.render(
reference=invoice.reference,
merchant_name=invoice.merchant_name,
merchant_address=invoice.merchant_address,
merchant_email=invoice.merchant_email,
merchant_tax_id=invoice.merchant_tax_id,
merchant_logo_url=invoice.merchant_logo_url,
customer_name=invoice.customer_name,
customer_email=invoice.customer_email,
customer_address=invoice.customer_address,
items=invoice.items,
subtotal=invoice.subtotal,
tax_rate=invoice.tax_rate,
tax_amount=invoice.tax_amount,
total=invoice.total,
currency=invoice.currency,
issued_at=invoice.issued_at.strftime("%B %d, %Y") if invoice.issued_at else "",
due_date=invoice.due_date.strftime("%B %d, %Y") if invoice.due_date else "",
brand_color=brand_color,
format_currency=format_currency,
)
# Generate PDF
pdf_buffer = io.BytesIO()
HTML(string=html_content).write_pdf(pdf_buffer)
pdf_buffer.seek(0)
return pdf_buffer.read()Why WeasyPrint Over Alternatives?
| Library | Pros | Cons | Verdict |
|---|---|---|---|
| WeasyPrint | Pure Python, CSS3 support, no browser dependency | Slower than wkhtmltopdf, system deps (cairo, pango) | Chosen -- best CSS support |
| wkhtmltopdf | Fast, mature | Headless WebKit, large binary, security concerns | Rejected |
| Puppeteer/Playwright | Full Chrome rendering | Node.js dependency, heavy, slow startup | Rejected |
| ReportLab | Native PDF, fast | Manual positioning, no HTML/CSS | Rejected -- too low-level |
| pdfkit | Simple API | Wrapper around wkhtmltopdf | Same issues as wkhtmltopdf |
WeasyPrint's system dependencies (cairo, pango, GDK-PixBuf) add complexity to deployment, but they are standard on most Linux distributions and available via apt-get install.
Search and Filter
The invoice list endpoint supports full-text search and multi-parameter filtering:
python@router.get("/api/invoices")
async def list_invoices(
app_id: UUID = Depends(get_current_app),
q: Optional[str] = Query(None, description="Search text"),
status: Optional[str] = Query(None),
date_from: Optional[date] = Query(None),
date_to: Optional[date] = Query(None),
amount_min: Optional[Decimal] = Query(None),
amount_max: Optional[Decimal] = Query(None),
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
"""List invoices with search and filtering."""
query = select(Invoice).where(Invoice.app_id == app_id)
# Text search across reference, customer name, customer email
if q:
search_term = f"%{q}%"
query = query.where(
or_(
Invoice.reference.ilike(search_term),
Invoice.customer_name.ilike(search_term),
Invoice.customer_email.ilike(search_term),
)
)
# Status filter
if status:
query = query.where(Invoice.status == status)
# Date range
if date_from:
query = query.where(Invoice.created_at >= date_from)
if date_to:
query = query.where(Invoice.created_at <= date_to)
# Amount range
if amount_min is not None:
query = query.where(Invoice.total >= amount_min)
if amount_max is not None:
query = query.where(Invoice.total <= amount_max)
# Count total
count_query = select(func.count()).select_from(query.subquery())
total = (await db.execute(count_query)).scalar()
# Paginate
query = query.order_by(Invoice.created_at.desc())
query = query.offset((page - 1) * per_page).limit(per_page)
result = await db.execute(query)
invoices = result.scalars().all()
return {
"invoices": [invoice_to_dict(inv) for inv in invoices],
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page,
}Duplicate Endpoint
Merchants frequently need to create similar invoices. The duplicate endpoint copies an existing invoice with a new reference and date:
python@router.post("/api/invoices/{invoice_id}/duplicate")
async def duplicate_invoice(
invoice_id: UUID,
app_id: UUID = Depends(get_current_app),
db: AsyncSession = Depends(get_db),
):
"""Create a copy of an existing invoice with a new reference."""
original = await db.get(Invoice, invoice_id)
if not original or original.app_id != app_id:
raise HTTPException(status_code=404, detail="Invoice not found")
# Generate new reference
app = await db.get(App, app_id)
new_reference = await generate_invoice_reference(
payment_reference=original.reference.split("-")[0],
app_slug=app.slug,
db=db
)
new_invoice = Invoice(
app_id=app_id,
reference=new_reference,
merchant_name=original.merchant_name,
merchant_address=original.merchant_address,
merchant_email=original.merchant_email,
merchant_phone=original.merchant_phone,
merchant_logo_url=original.merchant_logo_url,
merchant_tax_id=original.merchant_tax_id,
customer_name=original.customer_name,
customer_email=original.customer_email,
customer_phone=original.customer_phone,
customer_address=original.customer_address,
subtotal=original.subtotal,
tax_amount=original.tax_amount,
tax_rate=original.tax_rate,
total=original.total,
currency=original.currency,
items=original.items,
status="draft",
)
db.add(new_invoice)
await db.commit()
await db.refresh(new_invoice)
return {"invoice": invoice_to_dict(new_invoice)}Email Delivery via SMSING API
Invoices are emailed to customers using the SMSING API (an African email/SMS delivery service):
pythonimport httpx
SMSING_API_KEY = os.getenv("SMSING_API_KEY")
SMSING_API_URL = "https://api.smsing.app/v1/email/send"
async def send_invoice_email(
invoice: Invoice,
pdf_bytes: bytes,
recipient_email: str,
):
"""Send invoice PDF via email using SMSING API."""
import base64
pdf_base64 = base64.b64encode(pdf_bytes).decode("utf-8")
payload = {
"to": recipient_email,
"subject": f"Invoice {invoice.reference} from {invoice.merchant_name}",
"html": f"""
<p>Dear {invoice.customer_name or 'Customer'},</p>
<p>Please find attached your invoice <strong>{invoice.reference}</strong>
from {invoice.merchant_name}.</p>
<p><strong>Amount due: {format_currency(invoice.total, invoice.currency)}</strong></p>
{f'<p>Due date: {invoice.due_date.strftime("%B %d, %Y")}</p>' if invoice.due_date else ''}
<p>Thank you for your business.</p>
<p>Best regards,<br>{invoice.merchant_name}</p>
""",
"attachments": [
{
"filename": f"invoice-{invoice.reference}.pdf",
"content": pdf_base64,
"content_type": "application/pdf",
}
],
}
async with httpx.AsyncClient() as client:
response = await client.post(
SMSING_API_URL,
json=payload,
headers={"Authorization": f"Bearer {SMSING_API_KEY}"},
)
response.raise_for_status()
# Update invoice status
invoice.status = "sent"
invoice.issued_at = datetime.utcnow()Merchant Branding from App Settings
Each app can customize its invoice appearance through settings:
pythonclass AppSettings(Base):
__tablename__ = "app_settings"
app_id: Mapped[UUID] = mapped_column(ForeignKey("apps.id"), primary_key=True)
# Branding
company_name: Mapped[Optional[str]]
company_address: Mapped[Optional[str]]
company_email: Mapped[Optional[str]]
company_phone: Mapped[Optional[str]]
company_logo_url: Mapped[Optional[str]]
tax_id: Mapped[Optional[str]]
brand_color: Mapped[str] = mapped_column(default="#2563eb")
# Invoice defaults
default_tax_rate: Mapped[Decimal] = mapped_column(default=Decimal("0"))
invoice_currency: Mapped[str] = mapped_column(default="USD")
invoice_footer_text: Mapped[Optional[str]]
invoice_payment_terms: Mapped[Optional[str]]When a merchant configures their branding in the dashboard, every invoice generated from that point forward carries their logo, colors, and company details. The branding is snapshot at invoice creation time so that subsequent branding changes don't alter existing invoices.
The PDF Download Endpoint
python@router.get("/api/invoices/{invoice_id}/pdf")
async def download_invoice_pdf(
invoice_id: UUID,
app_id: UUID = Depends(get_current_app),
db: AsyncSession = Depends(get_db),
):
"""Generate and return invoice PDF."""
invoice = await db.get(Invoice, invoice_id)
if not invoice or invoice.app_id != app_id:
raise HTTPException(status_code=404, detail="Invoice not found")
pdf_bytes = await generate_invoice_pdf(invoice)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="invoice-{invoice.reference}.pdf"'
},
)What We Learned
Building the invoice system revealed several truths about payment platforms:
- Invoices are a trust signal. Merchants who can send professional, branded invoices are perceived as more legitimate by their customers. The invoice system is as much a branding tool as a financial one.
- Reference format matters for auditing. Including the app slug and date in the reference makes multi-app auditing possible without opening each invoice. Accountants love predictable reference formats.
- WeasyPrint is the right trade-off. It is slower than binary alternatives but the CSS flexibility is worth it. Invoice generation is not a hot path -- a 200ms PDF render is perfectly acceptable.
- Snapshot branding at creation. If you dynamically render branding at PDF generation time, changing a logo retroactively changes all past invoices. Snapshotting at creation preserves the historical record.
- Search is non-negotiable. Once a merchant has 100+ invoices, being able to search by reference, customer name, or date range goes from nice-to-have to essential.
This article is part of the "How We Built 0fee.dev" series. 0fee.dev is a payment orchestrator covering 53+ providers across 200+ countries, built by Juste A. GNIMAVO and Claude from Abidjan with zero human engineers. Follow the series for the complete build story.