Files
orion/app/modules/orders/services/invoice_service.py
Samir Boulahtit f20266167d
Some checks failed
CI / ruff (push) Failing after 7s
CI / pytest (push) Failing after 1s
CI / architecture (push) Failing after 9s
CI / dependency-scanning (push) Successful in 27s
CI / audit (push) Successful in 8s
CI / docs (push) Has been skipped
fix(lint): auto-fix ruff violations and tune lint rules
- Auto-fixed 4,496 lint issues (import sorting, modern syntax, etc.)
- Added ignore rules for patterns intentional in this codebase:
  E402 (late imports), E712 (SQLAlchemy filters), B904 (raise from),
  SIM108/SIM105/SIM117 (readability preferences)
- Added per-file ignores for tests and scripts
- Excluded broken scripts/rename_terminology.py (has curly quotes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 23:10:42 +01:00

587 lines
19 KiB
Python

# app/modules/orders/services/invoice_service.py
"""
Invoice service for generating and managing invoices.
Handles:
- Store invoice settings management
- Invoice generation from orders
- VAT calculation (Luxembourg, EU, B2B reverse charge)
- Invoice number sequencing
- PDF generation (via separate module)
"""
import logging
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import and_, func
from sqlalchemy.orm import Session
from app.exceptions import ValidationException
from app.modules.orders.exceptions import (
InvoiceNotFoundException,
InvoiceSettingsNotFoundException,
OrderNotFoundException,
)
from app.modules.orders.models.invoice import (
Invoice,
InvoiceStatus,
StoreInvoiceSettings,
VATRegime,
)
from app.modules.orders.models.order import Order
from app.modules.orders.schemas.invoice import (
StoreInvoiceSettingsCreate,
StoreInvoiceSettingsUpdate,
)
from app.modules.tenancy.models import Store
logger = logging.getLogger(__name__)
# EU VAT rates by country code (2024 standard rates)
EU_VAT_RATES: dict[str, Decimal] = {
"AT": Decimal("20.00"),
"BE": Decimal("21.00"),
"BG": Decimal("20.00"),
"HR": Decimal("25.00"),
"CY": Decimal("19.00"),
"CZ": Decimal("21.00"),
"DK": Decimal("25.00"),
"EE": Decimal("22.00"),
"FI": Decimal("24.00"),
"FR": Decimal("20.00"),
"DE": Decimal("19.00"),
"GR": Decimal("24.00"),
"HU": Decimal("27.00"),
"IE": Decimal("23.00"),
"IT": Decimal("22.00"),
"LV": Decimal("21.00"),
"LT": Decimal("21.00"),
"LU": Decimal("17.00"),
"MT": Decimal("18.00"),
"NL": Decimal("21.00"),
"PL": Decimal("23.00"),
"PT": Decimal("23.00"),
"RO": Decimal("19.00"),
"SK": Decimal("20.00"),
"SI": Decimal("22.00"),
"ES": Decimal("21.00"),
"SE": Decimal("25.00"),
}
LU_VAT_RATES = {
"standard": Decimal("17.00"),
"intermediate": Decimal("14.00"),
"reduced": Decimal("8.00"),
"super_reduced": Decimal("3.00"),
}
class InvoiceService:
"""Service for invoice operations."""
# =========================================================================
# VAT Calculation
# =========================================================================
def get_vat_rate_for_country(self, country_iso: str) -> Decimal:
"""Get standard VAT rate for EU country."""
return EU_VAT_RATES.get(country_iso.upper(), Decimal("0.00"))
def get_vat_rate_label(self, country_iso: str, vat_rate: Decimal) -> str:
"""Get human-readable VAT rate label."""
country_names = {
"AT": "Austria", "BE": "Belgium", "BG": "Bulgaria", "HR": "Croatia",
"CY": "Cyprus", "CZ": "Czech Republic", "DK": "Denmark", "EE": "Estonia",
"FI": "Finland", "FR": "France", "DE": "Germany", "GR": "Greece",
"HU": "Hungary", "IE": "Ireland", "IT": "Italy", "LV": "Latvia",
"LT": "Lithuania", "LU": "Luxembourg", "MT": "Malta", "NL": "Netherlands",
"PL": "Poland", "PT": "Portugal", "RO": "Romania", "SK": "Slovakia",
"SI": "Slovenia", "ES": "Spain", "SE": "Sweden",
}
country_name = country_names.get(country_iso.upper(), country_iso)
return f"{country_name} VAT {vat_rate}%"
def determine_vat_regime(
self,
seller_country: str,
buyer_country: str,
buyer_vat_number: str | None,
seller_oss_registered: bool,
) -> tuple[VATRegime, Decimal, str | None]:
"""Determine VAT regime and rate for invoice."""
seller_country = seller_country.upper()
buyer_country = buyer_country.upper()
if seller_country == buyer_country:
vat_rate = self.get_vat_rate_for_country(seller_country)
return VATRegime.DOMESTIC, vat_rate, None
if buyer_country in EU_VAT_RATES:
if buyer_vat_number:
return VATRegime.REVERSE_CHARGE, Decimal("0.00"), buyer_country
if seller_oss_registered:
vat_rate = self.get_vat_rate_for_country(buyer_country)
return VATRegime.OSS, vat_rate, buyer_country
vat_rate = self.get_vat_rate_for_country(seller_country)
return VATRegime.ORIGIN, vat_rate, buyer_country
return VATRegime.EXEMPT, Decimal("0.00"), buyer_country
# =========================================================================
# Invoice Settings Management
# =========================================================================
def get_settings(
self, db: Session, store_id: int
) -> StoreInvoiceSettings | None:
"""Get store invoice settings."""
return (
db.query(StoreInvoiceSettings)
.filter(StoreInvoiceSettings.store_id == store_id)
.first()
)
def get_settings_or_raise(
self, db: Session, store_id: int
) -> StoreInvoiceSettings:
"""Get store invoice settings or raise exception."""
settings = self.get_settings(db, store_id)
if not settings:
raise InvoiceSettingsNotFoundException(store_id)
return settings
def create_settings(
self,
db: Session,
store_id: int,
data: StoreInvoiceSettingsCreate,
) -> StoreInvoiceSettings:
"""Create store invoice settings."""
existing = self.get_settings(db, store_id)
if existing:
raise ValidationException(
"Invoice settings already exist for this store"
)
settings = StoreInvoiceSettings(
store_id=store_id,
**data.model_dump(),
)
db.add(settings)
db.flush()
db.refresh(settings)
logger.info(f"Created invoice settings for store {store_id}")
return settings
def update_settings(
self,
db: Session,
store_id: int,
data: StoreInvoiceSettingsUpdate,
) -> StoreInvoiceSettings:
"""Update store invoice settings."""
settings = self.get_settings_or_raise(db, store_id)
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(settings, key, value)
settings.updated_at = datetime.now(UTC)
db.flush()
db.refresh(settings)
logger.info(f"Updated invoice settings for store {store_id}")
return settings
def create_settings_from_store(
self,
db: Session,
store: Store,
) -> StoreInvoiceSettings:
"""Create invoice settings from store/merchant info."""
merchant = store.merchant
settings = StoreInvoiceSettings(
store_id=store.id,
merchant_name=merchant.legal_name if merchant else store.name,
merchant_address=store.effective_business_address,
merchant_city=None,
merchant_postal_code=None,
merchant_country="LU",
vat_number=store.effective_tax_number,
is_vat_registered=bool(store.effective_tax_number),
)
db.add(settings)
db.flush()
db.refresh(settings)
logger.info(f"Created invoice settings from store data for store {store.id}")
return settings
# =========================================================================
# Invoice Number Generation
# =========================================================================
def _get_next_invoice_number(
self, db: Session, settings: StoreInvoiceSettings
) -> str:
"""Generate next invoice number and increment counter."""
number = str(settings.invoice_next_number).zfill(settings.invoice_number_padding)
invoice_number = f"{settings.invoice_prefix}{number}"
settings.invoice_next_number += 1
db.flush()
return invoice_number
# =========================================================================
# Invoice Creation
# =========================================================================
def create_invoice_from_order(
self,
db: Session,
store_id: int,
order_id: int,
notes: str | None = None,
) -> Invoice:
"""Create an invoice from an order."""
settings = self.get_settings_or_raise(db, store_id)
order = (
db.query(Order)
.filter(and_(Order.id == order_id, Order.store_id == store_id))
.first()
)
if not order:
raise OrderNotFoundException(f"Order {order_id} not found")
existing = (
db.query(Invoice)
.filter(and_(Invoice.order_id == order_id, Invoice.store_id == store_id))
.first()
)
if existing:
raise ValidationException(f"Invoice already exists for order {order_id}")
buyer_country = order.bill_country_iso
vat_regime, vat_rate, destination_country = self.determine_vat_regime(
seller_country=settings.merchant_country,
buyer_country=buyer_country,
buyer_vat_number=None,
seller_oss_registered=settings.is_oss_registered,
)
seller_details = {
"merchant_name": settings.merchant_name,
"address": settings.merchant_address,
"city": settings.merchant_city,
"postal_code": settings.merchant_postal_code,
"country": settings.merchant_country,
"vat_number": settings.vat_number,
}
buyer_details = {
"name": f"{order.bill_first_name} {order.bill_last_name}".strip(),
"email": order.customer_email,
"address": order.bill_address_line_1,
"city": order.bill_city,
"postal_code": order.bill_postal_code,
"country": order.bill_country_iso,
"vat_number": None,
}
if order.bill_company:
buyer_details["company"] = order.bill_company
line_items = []
for item in order.items:
line_items.append({
"description": item.product_name,
"quantity": item.quantity,
"unit_price_cents": item.unit_price_cents,
"total_cents": item.total_price_cents,
"sku": item.product_sku,
"ean": item.gtin,
})
subtotal_cents = sum(item["total_cents"] for item in line_items)
if vat_rate > 0:
vat_amount_cents = int(subtotal_cents * float(vat_rate) / 100)
else:
vat_amount_cents = 0
total_cents = subtotal_cents + vat_amount_cents
vat_rate_label = None
if vat_rate > 0:
if destination_country:
vat_rate_label = self.get_vat_rate_label(destination_country, vat_rate)
else:
vat_rate_label = self.get_vat_rate_label(settings.merchant_country, vat_rate)
invoice_number = self._get_next_invoice_number(db, settings)
invoice = Invoice(
store_id=store_id,
order_id=order_id,
invoice_number=invoice_number,
invoice_date=datetime.now(UTC),
status=InvoiceStatus.DRAFT.value,
seller_details=seller_details,
buyer_details=buyer_details,
line_items=line_items,
vat_regime=vat_regime.value,
destination_country=destination_country,
vat_rate=vat_rate,
vat_rate_label=vat_rate_label,
currency=order.currency,
subtotal_cents=subtotal_cents,
vat_amount_cents=vat_amount_cents,
total_cents=total_cents,
payment_terms=settings.payment_terms,
bank_details={
"bank_name": settings.bank_name,
"iban": settings.bank_iban,
"bic": settings.bank_bic,
} if settings.bank_iban else None,
footer_text=settings.footer_text,
notes=notes,
)
db.add(invoice)
db.flush()
db.refresh(invoice)
logger.info(
f"Created invoice {invoice_number} for order {order_id} "
f"(store={store_id}, total={total_cents/100:.2f} EUR, VAT={vat_regime.value})"
)
return invoice
# =========================================================================
# Invoice Retrieval
# =========================================================================
def get_invoice(
self, db: Session, store_id: int, invoice_id: int
) -> Invoice | None:
"""Get invoice by ID."""
return (
db.query(Invoice)
.filter(and_(Invoice.id == invoice_id, Invoice.store_id == store_id))
.first()
)
def get_invoice_or_raise(
self, db: Session, store_id: int, invoice_id: int
) -> Invoice:
"""Get invoice by ID or raise exception."""
invoice = self.get_invoice(db, store_id, invoice_id)
if not invoice:
raise InvoiceNotFoundException(invoice_id)
return invoice
def get_invoice_by_number(
self, db: Session, store_id: int, invoice_number: str
) -> Invoice | None:
"""Get invoice by invoice number."""
return (
db.query(Invoice)
.filter(
and_(
Invoice.invoice_number == invoice_number,
Invoice.store_id == store_id,
)
)
.first()
)
def get_invoice_by_order_id(
self, db: Session, store_id: int, order_id: int
) -> Invoice | None:
"""Get invoice by order ID."""
return (
db.query(Invoice)
.filter(
and_(
Invoice.order_id == order_id,
Invoice.store_id == store_id,
)
)
.first()
)
def list_invoices(
self,
db: Session,
store_id: int,
status: str | None = None,
page: int = 1,
per_page: int = 20,
) -> tuple[list[Invoice], int]:
"""List invoices for store with pagination."""
query = db.query(Invoice).filter(Invoice.store_id == store_id)
if status:
query = query.filter(Invoice.status == status)
total = query.count()
invoices = (
query.order_by(Invoice.invoice_date.desc())
.offset((page - 1) * per_page)
.limit(per_page)
.all()
)
return invoices, total
# =========================================================================
# Invoice Status Management
# =========================================================================
def update_status(
self,
db: Session,
store_id: int,
invoice_id: int,
new_status: str,
) -> Invoice:
"""Update invoice status."""
invoice = self.get_invoice_or_raise(db, store_id, invoice_id)
valid_statuses = [s.value for s in InvoiceStatus]
if new_status not in valid_statuses:
raise ValidationException(f"Invalid status: {new_status}")
if invoice.status == InvoiceStatus.CANCELLED.value:
raise ValidationException("Cannot change status of cancelled invoice")
invoice.status = new_status
invoice.updated_at = datetime.now(UTC)
db.flush()
db.refresh(invoice)
logger.info(f"Updated invoice {invoice.invoice_number} status to {new_status}")
return invoice
def mark_as_issued(
self, db: Session, store_id: int, invoice_id: int
) -> Invoice:
"""Mark invoice as issued."""
return self.update_status(db, store_id, invoice_id, InvoiceStatus.ISSUED.value)
def mark_as_paid(
self, db: Session, store_id: int, invoice_id: int
) -> Invoice:
"""Mark invoice as paid."""
return self.update_status(db, store_id, invoice_id, InvoiceStatus.PAID.value)
def cancel_invoice(
self, db: Session, store_id: int, invoice_id: int
) -> Invoice:
"""Cancel invoice."""
return self.update_status(db, store_id, invoice_id, InvoiceStatus.CANCELLED.value)
# =========================================================================
# Statistics
# =========================================================================
def get_invoice_stats(
self, db: Session, store_id: int
) -> dict[str, Any]:
"""Get invoice statistics for store."""
total_count = (
db.query(func.count(Invoice.id))
.filter(Invoice.store_id == store_id)
.scalar()
or 0
)
total_revenue = (
db.query(func.sum(Invoice.total_cents))
.filter(
and_(
Invoice.store_id == store_id,
Invoice.status.in_([
InvoiceStatus.ISSUED.value,
InvoiceStatus.PAID.value,
]),
)
)
.scalar()
or 0
)
draft_count = (
db.query(func.count(Invoice.id))
.filter(
and_(
Invoice.store_id == store_id,
Invoice.status == InvoiceStatus.DRAFT.value,
)
)
.scalar()
or 0
)
paid_count = (
db.query(func.count(Invoice.id))
.filter(
and_(
Invoice.store_id == store_id,
Invoice.status == InvoiceStatus.PAID.value,
)
)
.scalar()
or 0
)
return {
"total_invoices": total_count,
"total_revenue_cents": total_revenue,
"total_revenue": total_revenue / 100 if total_revenue else 0,
"draft_count": draft_count,
"paid_count": paid_count,
}
# =========================================================================
# PDF Generation
# =========================================================================
def generate_pdf(
self,
db: Session,
store_id: int,
invoice_id: int,
force_regenerate: bool = False,
) -> str:
"""Generate PDF for an invoice."""
from app.modules.orders.services.invoice_pdf_service import invoice_pdf_service
invoice = self.get_invoice_or_raise(db, store_id, invoice_id)
return invoice_pdf_service.generate_pdf(db, invoice, force_regenerate)
def get_pdf_path(
self,
db: Session,
store_id: int,
invoice_id: int,
) -> str | None:
"""Get PDF path for an invoice if it exists."""
from app.modules.orders.services.invoice_pdf_service import invoice_pdf_service
invoice = self.get_invoice_or_raise(db, store_id, invoice_id)
return invoice_pdf_service.get_pdf_path(invoice)
# Singleton instance
invoice_service = InvoiceService()