test: add tests for merchant dashboard metrics and fix invoice template location
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>
This commit is contained in:
470
app/modules/orders/templates/invoices/invoice.html
Normal file
470
app/modules/orders/templates/invoices/invoice.html
Normal file
@@ -0,0 +1,470 @@
|
||||
<!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>
|
||||
@@ -1,8 +1,13 @@
|
||||
"""Unit tests for InvoicePDFService."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.orders.services.invoice_pdf_service import InvoicePDFService
|
||||
from app.modules.orders.services.invoice_pdf_service import (
|
||||
TEMPLATE_DIR,
|
||||
InvoicePDFService,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -16,3 +21,25 @@ class TestInvoicePDFService:
|
||||
def test_service_instantiation(self):
|
||||
"""Service can be instantiated."""
|
||||
assert self.service is not None
|
||||
|
||||
def test_template_directory_exists(self):
|
||||
"""Template directory must exist at the expected path."""
|
||||
assert TEMPLATE_DIR.exists(), f"Template directory missing: {TEMPLATE_DIR}"
|
||||
assert TEMPLATE_DIR.is_dir()
|
||||
|
||||
def test_invoice_template_exists(self):
|
||||
"""invoice.html template must exist in the template directory."""
|
||||
template_path = TEMPLATE_DIR / "invoice.html"
|
||||
assert template_path.exists(), f"Invoice template missing: {template_path}"
|
||||
|
||||
def test_template_can_be_loaded(self):
|
||||
"""Jinja2 environment can load the invoice template."""
|
||||
template = self.service.env.get_template("invoice.html")
|
||||
assert template is not None
|
||||
|
||||
def test_template_dir_is_inside_orders_module(self):
|
||||
"""Template directory should be inside the orders module, not the global templates."""
|
||||
orders_module_dir = Path(__file__).parent.parent.parent
|
||||
assert str(TEMPLATE_DIR).startswith(str(orders_module_dir)), (
|
||||
f"TEMPLATE_DIR ({TEMPLATE_DIR}) should be inside orders module ({orders_module_dir})"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user