Phase 1 OMS implementation: Invoicing: - Add Invoice and VendorInvoiceSettings database models - Full EU VAT support (27 countries, OSS, B2B reverse charge) - Invoice PDF generation with WeasyPrint + Jinja2 templates - Vendor invoice API endpoints for settings, creation, PDF download Subscription Tiers: - Add VendorSubscription model with 4 tiers (Essential/Professional/Business/Enterprise) - Tier limit enforcement for orders, products, team members - Feature gating based on subscription tier - Automatic trial subscription creation for new vendors - Integrate limit checks into order creation (direct and Letzshop sync) Marketing: - Update pricing documentation with 4-tier structure - Revise back-office positioning strategy - Update homepage with Veeqo-inspired Letzshop-focused messaging 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
216 lines
7.0 KiB
Python
216 lines
7.0 KiB
Python
# models/database/invoice.py
|
|
"""
|
|
Invoice database models for the OMS.
|
|
|
|
Provides models for:
|
|
- VendorInvoiceSettings: Per-vendor invoice configuration (company details, VAT, numbering)
|
|
- Invoice: Invoice records with snapshots of seller/buyer details
|
|
"""
|
|
|
|
import enum
|
|
|
|
from sqlalchemy import (
|
|
Boolean,
|
|
Column,
|
|
DateTime,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
Numeric,
|
|
String,
|
|
Text,
|
|
)
|
|
from sqlalchemy.dialects.sqlite import JSON
|
|
from sqlalchemy.orm import relationship
|
|
|
|
from app.core.database import Base
|
|
from models.database.base import TimestampMixin
|
|
|
|
|
|
class VendorInvoiceSettings(Base, TimestampMixin):
|
|
"""
|
|
Per-vendor invoice configuration.
|
|
|
|
Stores company details, VAT number, invoice numbering preferences,
|
|
and payment information for invoice generation.
|
|
|
|
One-to-one relationship with Vendor.
|
|
"""
|
|
|
|
__tablename__ = "vendor_invoice_settings"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
vendor_id = Column(
|
|
Integer, ForeignKey("vendors.id"), unique=True, nullable=False, index=True
|
|
)
|
|
|
|
# Legal company details for invoice header
|
|
company_name = Column(String(255), nullable=False) # Legal name for invoices
|
|
company_address = Column(String(255), nullable=True) # Street address
|
|
company_city = Column(String(100), nullable=True)
|
|
company_postal_code = Column(String(20), nullable=True)
|
|
company_country = Column(String(2), nullable=False, default="LU") # ISO country code
|
|
|
|
# VAT information
|
|
vat_number = Column(String(50), nullable=True) # e.g., "LU12345678"
|
|
is_vat_registered = Column(Boolean, default=True, nullable=False)
|
|
|
|
# OSS (One-Stop-Shop) for EU VAT
|
|
is_oss_registered = Column(Boolean, default=False, nullable=False)
|
|
oss_registration_country = Column(String(2), nullable=True) # ISO country code
|
|
|
|
# Invoice numbering
|
|
invoice_prefix = Column(String(20), default="INV", nullable=False)
|
|
invoice_next_number = Column(Integer, default=1, nullable=False)
|
|
invoice_number_padding = Column(Integer, default=5, nullable=False) # e.g., INV00001
|
|
|
|
# Payment information
|
|
payment_terms = Column(Text, nullable=True) # e.g., "Payment due within 30 days"
|
|
bank_name = Column(String(255), nullable=True)
|
|
bank_iban = Column(String(50), nullable=True)
|
|
bank_bic = Column(String(20), nullable=True)
|
|
|
|
# Invoice footer
|
|
footer_text = Column(Text, nullable=True) # Custom footer text
|
|
|
|
# Default VAT rate for Luxembourg invoices (17% standard)
|
|
default_vat_rate = Column(Numeric(5, 2), default=17.00, nullable=False)
|
|
|
|
# Relationships
|
|
vendor = relationship("Vendor", back_populates="invoice_settings")
|
|
|
|
def __repr__(self):
|
|
return f"<VendorInvoiceSettings(vendor_id={self.vendor_id}, company='{self.company_name}')>"
|
|
|
|
def get_next_invoice_number(self) -> str:
|
|
"""Generate the next invoice number and increment counter."""
|
|
number = str(self.invoice_next_number).zfill(self.invoice_number_padding)
|
|
return f"{self.invoice_prefix}{number}"
|
|
|
|
|
|
class InvoiceStatus(str, enum.Enum):
|
|
"""Invoice status enumeration."""
|
|
|
|
DRAFT = "draft"
|
|
ISSUED = "issued"
|
|
PAID = "paid"
|
|
CANCELLED = "cancelled"
|
|
|
|
|
|
class VATRegime(str, enum.Enum):
|
|
"""VAT regime for invoice calculation."""
|
|
|
|
DOMESTIC = "domestic" # Same country as seller
|
|
OSS = "oss" # EU cross-border with OSS registration
|
|
REVERSE_CHARGE = "reverse_charge" # B2B with valid VAT number
|
|
ORIGIN = "origin" # Cross-border without OSS (use origin VAT)
|
|
EXEMPT = "exempt" # VAT exempt
|
|
|
|
|
|
class Invoice(Base, TimestampMixin):
|
|
"""
|
|
Invoice record with snapshots of seller/buyer details.
|
|
|
|
Stores complete invoice data including snapshots of seller and buyer
|
|
details at time of creation for audit purposes.
|
|
"""
|
|
|
|
__tablename__ = "invoices"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
|
|
order_id = Column(Integer, ForeignKey("orders.id"), nullable=True, index=True)
|
|
|
|
# Invoice identification
|
|
invoice_number = Column(String(50), nullable=False)
|
|
invoice_date = Column(DateTime(timezone=True), nullable=False)
|
|
|
|
# Status
|
|
status = Column(String(20), default=InvoiceStatus.DRAFT.value, nullable=False)
|
|
|
|
# Seller details snapshot (captured at invoice creation)
|
|
seller_details = Column(JSON, nullable=False)
|
|
# Structure: {
|
|
# "company_name": str,
|
|
# "address": str,
|
|
# "city": str,
|
|
# "postal_code": str,
|
|
# "country": str,
|
|
# "vat_number": str | None
|
|
# }
|
|
|
|
# Buyer details snapshot (captured at invoice creation)
|
|
buyer_details = Column(JSON, nullable=False)
|
|
# Structure: {
|
|
# "name": str,
|
|
# "email": str,
|
|
# "address": str,
|
|
# "city": str,
|
|
# "postal_code": str,
|
|
# "country": str,
|
|
# "vat_number": str | None (for B2B)
|
|
# }
|
|
|
|
# Line items snapshot
|
|
line_items = Column(JSON, nullable=False)
|
|
# Structure: [{
|
|
# "description": str,
|
|
# "quantity": int,
|
|
# "unit_price_cents": int,
|
|
# "total_cents": int,
|
|
# "sku": str | None,
|
|
# "ean": str | None
|
|
# }]
|
|
|
|
# VAT information
|
|
vat_regime = Column(String(20), default=VATRegime.DOMESTIC.value, nullable=False)
|
|
destination_country = Column(String(2), nullable=True) # For OSS invoices
|
|
vat_rate = Column(Numeric(5, 2), nullable=False) # e.g., 17.00 for 17%
|
|
vat_rate_label = Column(String(50), nullable=True) # e.g., "Luxembourg Standard VAT"
|
|
|
|
# Amounts (stored in cents for precision)
|
|
currency = Column(String(3), default="EUR", nullable=False)
|
|
subtotal_cents = Column(Integer, nullable=False) # Before VAT
|
|
vat_amount_cents = Column(Integer, nullable=False) # VAT amount
|
|
total_cents = Column(Integer, nullable=False) # After VAT
|
|
|
|
# Payment information
|
|
payment_terms = Column(Text, nullable=True)
|
|
bank_details = Column(JSON, nullable=True) # IBAN, BIC snapshot
|
|
footer_text = Column(Text, nullable=True)
|
|
|
|
# PDF storage
|
|
pdf_generated_at = Column(DateTime(timezone=True), nullable=True)
|
|
pdf_path = Column(String(500), nullable=True) # Path to stored PDF
|
|
|
|
# Notes
|
|
notes = Column(Text, nullable=True) # Internal notes
|
|
|
|
# Relationships
|
|
vendor = relationship("Vendor", back_populates="invoices")
|
|
order = relationship("Order", back_populates="invoices")
|
|
|
|
__table_args__ = (
|
|
Index("idx_invoice_vendor_number", "vendor_id", "invoice_number", unique=True),
|
|
Index("idx_invoice_vendor_date", "vendor_id", "invoice_date"),
|
|
Index("idx_invoice_status", "vendor_id", "status"),
|
|
)
|
|
|
|
def __repr__(self):
|
|
return f"<Invoice(id={self.id}, number='{self.invoice_number}', status='{self.status}')>"
|
|
|
|
@property
|
|
def subtotal(self) -> float:
|
|
"""Get subtotal in EUR."""
|
|
return self.subtotal_cents / 100
|
|
|
|
@property
|
|
def vat_amount(self) -> float:
|
|
"""Get VAT amount in EUR."""
|
|
return self.vat_amount_cents / 100
|
|
|
|
@property
|
|
def total(self) -> float:
|
|
"""Get total in EUR."""
|
|
return self.total_cents / 100
|