feat: add invoicing system and subscription tier enforcement

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>
This commit is contained in:
2025-12-24 18:15:27 +01:00
parent 4d9b816072
commit 6232bb47f6
23 changed files with 4342 additions and 241 deletions

View File

@@ -19,6 +19,12 @@ from .company import Company
from .content_page import ContentPage
from .customer import Customer, CustomerAddress
from .inventory import Inventory
from .invoice import (
Invoice,
InvoiceStatus,
VATRegime,
VendorInvoiceSettings,
)
from .letzshop import (
LetzshopFulfillmentQueue,
LetzshopHistoricalImportJob,
@@ -44,6 +50,12 @@ from .order import Order, OrderItem
from .order_item_exception import OrderItemException
from .product import Product
from .product_translation import ProductTranslation
from .subscription import (
SubscriptionStatus,
TierCode,
TIER_LIMITS,
VendorSubscription,
)
from .test_run import TestCollection, TestResult, TestRun
from .user import User
from .vendor import Role, Vendor, VendorUser
@@ -95,6 +107,11 @@ __all__ = [
"MarketplaceImportError",
# Inventory
"Inventory",
# Invoicing
"Invoice",
"InvoiceStatus",
"VATRegime",
"VendorInvoiceSettings",
# Orders
"Order",
"OrderItem",
@@ -104,6 +121,11 @@ __all__ = [
"LetzshopFulfillmentQueue",
"LetzshopSyncLog",
"LetzshopHistoricalImportJob",
# Subscription
"VendorSubscription",
"SubscriptionStatus",
"TierCode",
"TIER_LIMITS",
# Messaging
"Conversation",
"ConversationParticipant",

215
models/database/invoice.py Normal file
View File

@@ -0,0 +1,215 @@
# 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

View File

@@ -143,6 +143,9 @@ class Order(Base, TimestampMixin):
items = relationship(
"OrderItem", back_populates="order", cascade="all, delete-orphan"
)
invoices = relationship(
"Invoice", back_populates="order", cascade="all, delete-orphan"
)
# Composite indexes for common queries
__table_args__ = (

View File

@@ -0,0 +1,354 @@
# models/database/subscription.py
"""
Subscription database models for tier-based access control.
Provides models for:
- SubscriptionTier: Tier definitions with limits and features
- VendorSubscription: Per-vendor subscription tracking
Tier Structure:
- Essential (€49/mo): 100 orders/mo, 200 products, 1 user, LU invoicing
- Professional (€99/mo): 500 orders/mo, unlimited products, 3 users, EU VAT
- Business (€199/mo): 2000 orders/mo, unlimited products, 10 users, analytics, API
- Enterprise (€399+/mo): Unlimited, white-label, custom integrations
"""
import enum
from datetime import UTC, datetime
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 TierCode(str, enum.Enum):
"""Subscription tier codes."""
ESSENTIAL = "essential"
PROFESSIONAL = "professional"
BUSINESS = "business"
ENTERPRISE = "enterprise"
class SubscriptionStatus(str, enum.Enum):
"""Subscription status."""
TRIAL = "trial" # Free trial period
ACTIVE = "active" # Paid and active
PAST_DUE = "past_due" # Payment failed, grace period
CANCELLED = "cancelled" # Cancelled, access until period end
EXPIRED = "expired" # No longer active
# Tier limit definitions (hardcoded for now, could be moved to DB)
TIER_LIMITS = {
TierCode.ESSENTIAL: {
"name": "Essential",
"price_monthly_cents": 4900, # €49
"price_annual_cents": 49000, # €490 (2 months free)
"orders_per_month": 100,
"products_limit": 200,
"team_members": 1,
"order_history_months": 6,
"features": [
"letzshop_sync",
"inventory_basic",
"invoice_lu",
"customer_view",
],
},
TierCode.PROFESSIONAL: {
"name": "Professional",
"price_monthly_cents": 9900, # €99
"price_annual_cents": 99000, # €990
"orders_per_month": 500,
"products_limit": None, # Unlimited
"team_members": 3,
"order_history_months": 24,
"features": [
"letzshop_sync",
"inventory_locations",
"inventory_purchase_orders",
"invoice_lu",
"invoice_eu_vat",
"customer_view",
"customer_export",
],
},
TierCode.BUSINESS: {
"name": "Business",
"price_monthly_cents": 19900, # €199
"price_annual_cents": 199000, # €1990
"orders_per_month": 2000,
"products_limit": None, # Unlimited
"team_members": 10,
"order_history_months": None, # Unlimited
"features": [
"letzshop_sync",
"inventory_locations",
"inventory_purchase_orders",
"invoice_lu",
"invoice_eu_vat",
"invoice_bulk",
"customer_view",
"customer_export",
"analytics_dashboard",
"accounting_export",
"api_access",
"automation_rules",
"team_roles",
],
},
TierCode.ENTERPRISE: {
"name": "Enterprise",
"price_monthly_cents": 39900, # €399 starting
"price_annual_cents": None, # Custom
"orders_per_month": None, # Unlimited
"products_limit": None, # Unlimited
"team_members": None, # Unlimited
"order_history_months": None, # Unlimited
"features": [
"letzshop_sync",
"inventory_locations",
"inventory_purchase_orders",
"invoice_lu",
"invoice_eu_vat",
"invoice_bulk",
"customer_view",
"customer_export",
"analytics_dashboard",
"accounting_export",
"api_access",
"automation_rules",
"team_roles",
"white_label",
"multi_vendor",
"custom_integrations",
"sla_guarantee",
"dedicated_support",
],
},
}
class VendorSubscription(Base, TimestampMixin):
"""
Per-vendor subscription tracking.
Tracks the vendor's subscription tier, billing period,
and usage counters for limit enforcement.
"""
__tablename__ = "vendor_subscriptions"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(
Integer, ForeignKey("vendors.id"), unique=True, nullable=False, index=True
)
# Tier
tier = Column(
String(20), default=TierCode.ESSENTIAL.value, nullable=False, index=True
)
# Status
status = Column(
String(20), default=SubscriptionStatus.TRIAL.value, nullable=False, index=True
)
# Billing period
period_start = Column(DateTime(timezone=True), nullable=False)
period_end = Column(DateTime(timezone=True), nullable=False)
is_annual = Column(Boolean, default=False, nullable=False)
# Trial info
trial_ends_at = Column(DateTime(timezone=True), nullable=True)
# Usage counters (reset each billing period)
orders_this_period = Column(Integer, default=0, nullable=False)
orders_limit_reached_at = Column(DateTime(timezone=True), nullable=True)
# Overrides (for custom enterprise deals)
custom_orders_limit = Column(Integer, nullable=True) # Override tier limit
custom_products_limit = Column(Integer, nullable=True)
custom_team_limit = Column(Integer, nullable=True)
# Payment info (for future Stripe integration)
stripe_customer_id = Column(String(100), nullable=True, index=True)
stripe_subscription_id = Column(String(100), nullable=True, index=True)
# Cancellation
cancelled_at = Column(DateTime(timezone=True), nullable=True)
cancellation_reason = Column(Text, nullable=True)
# Relationships
vendor = relationship("Vendor", back_populates="subscription")
__table_args__ = (
Index("idx_subscription_vendor_status", "vendor_id", "status"),
Index("idx_subscription_period", "period_start", "period_end"),
)
def __repr__(self):
return f"<VendorSubscription(vendor_id={self.vendor_id}, tier='{self.tier}', status='{self.status}')>"
# =========================================================================
# Tier Limit Properties
# =========================================================================
@property
def tier_limits(self) -> dict:
"""Get the limit definitions for current tier."""
return TIER_LIMITS.get(TierCode(self.tier), TIER_LIMITS[TierCode.ESSENTIAL])
@property
def orders_limit(self) -> int | None:
"""Get effective orders limit (custom or tier default)."""
if self.custom_orders_limit is not None:
return self.custom_orders_limit
return self.tier_limits.get("orders_per_month")
@property
def products_limit(self) -> int | None:
"""Get effective products limit (custom or tier default)."""
if self.custom_products_limit is not None:
return self.custom_products_limit
return self.tier_limits.get("products_limit")
@property
def team_members_limit(self) -> int | None:
"""Get effective team members limit (custom or tier default)."""
if self.custom_team_limit is not None:
return self.custom_team_limit
return self.tier_limits.get("team_members")
@property
def features(self) -> list[str]:
"""Get list of enabled features for current tier."""
return self.tier_limits.get("features", [])
# =========================================================================
# Status Checks
# =========================================================================
@property
def is_active(self) -> bool:
"""Check if subscription allows access."""
return self.status in [
SubscriptionStatus.TRIAL.value,
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.PAST_DUE.value, # Grace period
SubscriptionStatus.CANCELLED.value, # Until period end
]
@property
def is_trial(self) -> bool:
"""Check if currently in trial."""
return self.status == SubscriptionStatus.TRIAL.value
@property
def trial_days_remaining(self) -> int | None:
"""Get remaining trial days."""
if not self.is_trial or not self.trial_ends_at:
return None
remaining = (self.trial_ends_at - datetime.now(UTC)).days
return max(0, remaining)
# =========================================================================
# Limit Checks
# =========================================================================
def can_create_order(self) -> tuple[bool, str | None]:
"""
Check if vendor can create/import another order.
Returns: (can_create, error_message)
"""
if not self.is_active:
return False, "Subscription is not active"
limit = self.orders_limit
if limit is None: # Unlimited
return True, None
if self.orders_this_period >= limit:
return False, f"Monthly order limit reached ({limit} orders). Upgrade to continue."
return True, None
def can_add_product(self, current_count: int) -> tuple[bool, str | None]:
"""
Check if vendor can add another product.
Args:
current_count: Current number of products
Returns: (can_add, error_message)
"""
if not self.is_active:
return False, "Subscription is not active"
limit = self.products_limit
if limit is None: # Unlimited
return True, None
if current_count >= limit:
return False, f"Product limit reached ({limit} products). Upgrade to add more."
return True, None
def can_add_team_member(self, current_count: int) -> tuple[bool, str | None]:
"""
Check if vendor can add another team member.
Args:
current_count: Current number of team members
Returns: (can_add, error_message)
"""
if not self.is_active:
return False, "Subscription is not active"
limit = self.team_members_limit
if limit is None: # Unlimited
return True, None
if current_count >= limit:
return False, f"Team member limit reached ({limit} members). Upgrade to add more."
return True, None
def has_feature(self, feature: str) -> bool:
"""Check if a feature is enabled for current tier."""
return feature in self.features
# =========================================================================
# Usage Tracking
# =========================================================================
def increment_order_count(self) -> None:
"""Increment the order counter for this period."""
self.orders_this_period += 1
# Track when limit was first reached
limit = self.orders_limit
if limit and self.orders_this_period >= limit and not self.orders_limit_reached_at:
self.orders_limit_reached_at = datetime.now(UTC)
def reset_period_counters(self) -> None:
"""Reset counters for new billing period."""
self.orders_this_period = 0
self.orders_limit_reached_at = None

View File

@@ -143,6 +143,29 @@ class Vendor(Base, TimestampMixin):
cascade="all, delete-orphan",
)
# Invoice settings (one-to-one)
invoice_settings = relationship(
"VendorInvoiceSettings",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan",
)
# Invoices (one-to-many)
invoices = relationship(
"Invoice",
back_populates="vendor",
cascade="all, delete-orphan",
)
# Subscription (one-to-one)
subscription = relationship(
"VendorSubscription",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan",
)
domains = relationship(
"VendorDomain",
back_populates="vendor",