Move invoice PDF template from app/templates/invoices/ to app/modules/orders/templates/invoices/ where InvoicePDFService expects it. Expand invoice PDF tests to validate template path and existence. Add unit tests for get_merchant_metrics() in tenancy, billing, and customer metrics providers. Add unit tests for StatsAggregatorService merchant methods. Add integration tests for the merchant dashboard stats endpoint. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
471 lines
14 KiB
HTML
471 lines
14 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Invoice {{ invoice.invoice_number }}</title>
|
|
<style>
|
|
/* Reset and base styles */
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
font-size: 10pt;
|
|
line-height: 1.4;
|
|
color: #333;
|
|
background: #fff;
|
|
}
|
|
|
|
/* Page setup for A4 */
|
|
@page {
|
|
size: A4;
|
|
margin: 20mm 15mm 20mm 15mm;
|
|
}
|
|
|
|
.invoice-container {
|
|
max-width: 100%;
|
|
padding: 0;
|
|
}
|
|
|
|
/* Header */
|
|
.header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
margin-bottom: 30px;
|
|
padding-bottom: 20px;
|
|
border-bottom: 2px solid #2563eb;
|
|
}
|
|
|
|
.merchant-info {
|
|
max-width: 50%;
|
|
}
|
|
|
|
.merchant-name {
|
|
font-size: 18pt;
|
|
font-weight: bold;
|
|
color: #1e40af;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.merchant-details {
|
|
font-size: 9pt;
|
|
color: #666;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.invoice-title {
|
|
text-align: right;
|
|
}
|
|
|
|
.invoice-title h1 {
|
|
font-size: 24pt;
|
|
color: #1e40af;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.invoice-meta {
|
|
font-size: 10pt;
|
|
color: #666;
|
|
}
|
|
|
|
.invoice-meta strong {
|
|
color: #333;
|
|
}
|
|
|
|
/* Addresses section */
|
|
.addresses {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
margin-bottom: 30px;
|
|
gap: 40px;
|
|
}
|
|
|
|
.address-block {
|
|
flex: 1;
|
|
}
|
|
|
|
.address-label {
|
|
font-size: 8pt;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
color: #888;
|
|
margin-bottom: 8px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.address-content {
|
|
background: #f8fafc;
|
|
padding: 15px;
|
|
border-radius: 6px;
|
|
border-left: 3px solid #2563eb;
|
|
}
|
|
|
|
.address-name {
|
|
font-weight: bold;
|
|
font-size: 11pt;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.address-details {
|
|
font-size: 9pt;
|
|
color: #555;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
/* VAT info badge */
|
|
.vat-badge {
|
|
display: inline-block;
|
|
background: #dbeafe;
|
|
color: #1e40af;
|
|
padding: 3px 8px;
|
|
border-radius: 4px;
|
|
font-size: 8pt;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
/* Items table */
|
|
.items-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.items-table thead {
|
|
background: #1e40af;
|
|
color: #fff;
|
|
}
|
|
|
|
.items-table th {
|
|
padding: 12px 10px;
|
|
text-align: left;
|
|
font-size: 9pt;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.items-table th.number {
|
|
text-align: right;
|
|
}
|
|
|
|
.items-table td {
|
|
padding: 12px 10px;
|
|
border-bottom: 1px solid #e5e7eb;
|
|
font-size: 9pt;
|
|
}
|
|
|
|
.items-table td.number {
|
|
text-align: right;
|
|
font-family: 'Courier New', monospace;
|
|
}
|
|
|
|
.items-table tbody tr:hover {
|
|
background: #f8fafc;
|
|
}
|
|
|
|
.item-sku {
|
|
color: #888;
|
|
font-size: 8pt;
|
|
display: block;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
/* Totals */
|
|
.totals-section {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.totals-table {
|
|
width: 280px;
|
|
}
|
|
|
|
.totals-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid #e5e7eb;
|
|
}
|
|
|
|
.totals-row.total {
|
|
border-bottom: none;
|
|
border-top: 2px solid #1e40af;
|
|
margin-top: 5px;
|
|
padding-top: 12px;
|
|
}
|
|
|
|
.totals-label {
|
|
color: #666;
|
|
font-size: 9pt;
|
|
}
|
|
|
|
.totals-value {
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 10pt;
|
|
}
|
|
|
|
.totals-row.total .totals-label,
|
|
.totals-row.total .totals-value {
|
|
font-weight: bold;
|
|
font-size: 12pt;
|
|
color: #1e40af;
|
|
}
|
|
|
|
/* VAT regime note */
|
|
.vat-note {
|
|
background: #fef3c7;
|
|
border: 1px solid #f59e0b;
|
|
border-radius: 6px;
|
|
padding: 10px 15px;
|
|
font-size: 8pt;
|
|
color: #92400e;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
/* Payment info */
|
|
.payment-section {
|
|
background: #f0fdf4;
|
|
border: 1px solid #22c55e;
|
|
border-radius: 6px;
|
|
padding: 15px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.payment-title {
|
|
font-weight: bold;
|
|
color: #166534;
|
|
margin-bottom: 10px;
|
|
font-size: 10pt;
|
|
}
|
|
|
|
.payment-details {
|
|
font-size: 9pt;
|
|
color: #333;
|
|
}
|
|
|
|
.payment-details .label {
|
|
color: #666;
|
|
display: inline-block;
|
|
width: 60px;
|
|
}
|
|
|
|
/* Footer */
|
|
.footer {
|
|
margin-top: 40px;
|
|
padding-top: 20px;
|
|
border-top: 1px solid #e5e7eb;
|
|
font-size: 8pt;
|
|
color: #888;
|
|
text-align: center;
|
|
}
|
|
|
|
.footer p {
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
/* Status badge */
|
|
.status-badge {
|
|
display: inline-block;
|
|
padding: 4px 12px;
|
|
border-radius: 20px;
|
|
font-size: 9pt;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.status-draft {
|
|
background: #fef3c7;
|
|
color: #92400e;
|
|
}
|
|
|
|
.status-issued {
|
|
background: #dbeafe;
|
|
color: #1e40af;
|
|
}
|
|
|
|
.status-paid {
|
|
background: #dcfce7;
|
|
color: #166534;
|
|
}
|
|
|
|
.status-cancelled {
|
|
background: #fee2e2;
|
|
color: #991b1b;
|
|
}
|
|
|
|
/* Print styles */
|
|
@media print {
|
|
body {
|
|
-webkit-print-color-adjust: exact;
|
|
print-color-adjust: exact;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="invoice-container">
|
|
<!-- Header -->
|
|
<div class="header">
|
|
<div class="merchant-info">
|
|
<div class="merchant-name">{{ seller.merchant_name }}</div>
|
|
<div class="merchant-details">
|
|
{% if seller.address %}{{ seller.address }}<br>{% endif %}
|
|
{% if seller.postal_code or seller.city %}
|
|
{{ seller.postal_code }} {{ seller.city }}<br>
|
|
{% endif %}
|
|
{{ seller.country }}
|
|
{% if seller.vat_number %}
|
|
<br>VAT: {{ seller.vat_number }}
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="invoice-title">
|
|
<h1>INVOICE</h1>
|
|
<div class="invoice-meta">
|
|
<strong>{{ invoice.invoice_number }}</strong><br>
|
|
Date: {{ invoice.invoice_date.strftime('%d/%m/%Y') }}<br>
|
|
<span class="status-badge status-{{ invoice.status }}">{{ invoice.status }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Addresses -->
|
|
<div class="addresses">
|
|
<div class="address-block">
|
|
<div class="address-label">Bill To</div>
|
|
<div class="address-content">
|
|
<div class="address-name">{{ buyer.name }}</div>
|
|
<div class="address-details">
|
|
{% if buyer.get('merchant') %}{{ buyer.merchant }}<br>{% endif %}
|
|
{% if buyer.address %}{{ buyer.address }}<br>{% endif %}
|
|
{% if buyer.postal_code or buyer.city %}
|
|
{{ buyer.postal_code }} {{ buyer.city }}<br>
|
|
{% endif %}
|
|
{{ buyer.country }}
|
|
{% if buyer.email %}<br>{{ buyer.email }}{% endif %}
|
|
</div>
|
|
{% if buyer.vat_number %}
|
|
<div class="vat-badge">VAT: {{ buyer.vat_number }}</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% if invoice.order_id %}
|
|
<div class="address-block">
|
|
<div class="address-label">Order Reference</div>
|
|
<div class="address-content">
|
|
<div class="address-details">
|
|
Order #{{ invoice.order_id }}<br>
|
|
Currency: {{ invoice.currency }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- VAT Regime Note -->
|
|
{% if invoice.vat_regime == 'reverse_charge' %}
|
|
<div class="vat-note">
|
|
<strong>Reverse Charge:</strong> VAT to be accounted for by the recipient pursuant to Article 196 of Council Directive 2006/112/EC.
|
|
</div>
|
|
{% elif invoice.vat_regime == 'oss' %}
|
|
<div class="vat-note">
|
|
<strong>OSS Invoice:</strong> VAT charged at {{ invoice.vat_rate }}% ({{ invoice.destination_country }} rate) under One-Stop-Shop scheme.
|
|
</div>
|
|
{% elif invoice.vat_regime == 'exempt' %}
|
|
<div class="vat-note">
|
|
<strong>VAT Exempt:</strong> Export outside EU - VAT not applicable.
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Items Table -->
|
|
<table class="items-table">
|
|
<thead>
|
|
<tr>
|
|
<th style="width: 50%">Description</th>
|
|
<th class="number" style="width: 10%">Qty</th>
|
|
<th class="number" style="width: 20%">Unit Price</th>
|
|
<th class="number" style="width: 20%">Total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for item in line_items %}
|
|
<tr>
|
|
<td>
|
|
{{ item.description }}
|
|
{% if item.sku or item.ean %}
|
|
<span class="item-sku">
|
|
{% if item.sku %}SKU: {{ item.sku }}{% endif %}
|
|
{% if item.sku and item.ean %} | {% endif %}
|
|
{% if item.ean %}EAN: {{ item.ean }}{% endif %}
|
|
</span>
|
|
{% endif %}
|
|
</td>
|
|
<td class="number">{{ item.quantity }}</td>
|
|
<td class="number">{{ "%.2f"|format(item.unit_price_cents / 100) }} {{ invoice.currency }}</td>
|
|
<td class="number">{{ "%.2f"|format(item.total_cents / 100) }} {{ invoice.currency }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
|
|
<!-- Totals -->
|
|
<div class="totals-section">
|
|
<div class="totals-table">
|
|
<div class="totals-row">
|
|
<span class="totals-label">Subtotal</span>
|
|
<span class="totals-value">{{ "%.2f"|format(invoice.subtotal_cents / 100) }} {{ invoice.currency }}</span>
|
|
</div>
|
|
<div class="totals-row">
|
|
<span class="totals-label">
|
|
VAT ({{ invoice.vat_rate }}%)
|
|
{% if invoice.vat_rate_label %}<br><small>{{ invoice.vat_rate_label }}</small>{% endif %}
|
|
</span>
|
|
<span class="totals-value">{{ "%.2f"|format(invoice.vat_amount_cents / 100) }} {{ invoice.currency }}</span>
|
|
</div>
|
|
<div class="totals-row total">
|
|
<span class="totals-label">Total</span>
|
|
<span class="totals-value">{{ "%.2f"|format(invoice.total_cents / 100) }} {{ invoice.currency }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Payment Information -->
|
|
{% if bank_details or payment_terms %}
|
|
<div class="payment-section">
|
|
<div class="payment-title">Payment Information</div>
|
|
<div class="payment-details">
|
|
{% if payment_terms %}
|
|
<p style="margin-bottom: 10px;">{{ payment_terms }}</p>
|
|
{% endif %}
|
|
{% if bank_details %}
|
|
{% if bank_details.bank_name %}
|
|
<span class="label">Bank:</span> {{ bank_details.bank_name }}<br>
|
|
{% endif %}
|
|
{% if bank_details.iban %}
|
|
<span class="label">IBAN:</span> {{ bank_details.iban }}<br>
|
|
{% endif %}
|
|
{% if bank_details.bic %}
|
|
<span class="label">BIC:</span> {{ bank_details.bic }}
|
|
{% endif %}
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Footer -->
|
|
<div class="footer">
|
|
{% if footer_text %}
|
|
<p>{{ footer_text }}</p>
|
|
{% endif %}
|
|
<p>Invoice {{ invoice.invoice_number }} | Generated on {{ now.strftime('%d/%m/%Y %H:%M') }}</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|