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

@@ -6,6 +6,7 @@ from . import (
auth,
base,
inventory,
invoice,
marketplace_import_job,
marketplace_product,
message,
@@ -19,6 +20,7 @@ from .base import * # Base Pydantic models
__all__ = [
"base",
"auth",
"invoice",
"marketplace_product",
"message",
"inventory",

283
models/schema/invoice.py Normal file
View File

@@ -0,0 +1,283 @@
# models/schema/invoice.py
"""
Pydantic schemas for invoice operations.
Supports invoice settings management and invoice generation.
"""
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
# ============================================================================
# Invoice Settings Schemas
# ============================================================================
class VendorInvoiceSettingsCreate(BaseModel):
"""Schema for creating vendor invoice settings."""
company_name: str = Field(..., min_length=1, max_length=255)
company_address: str | None = Field(None, max_length=255)
company_city: str | None = Field(None, max_length=100)
company_postal_code: str | None = Field(None, max_length=20)
company_country: str = Field(default="LU", min_length=2, max_length=2)
vat_number: str | None = Field(None, max_length=50)
is_vat_registered: bool = True
is_oss_registered: bool = False
oss_registration_country: str | None = Field(None, min_length=2, max_length=2)
invoice_prefix: str = Field(default="INV", max_length=20)
invoice_number_padding: int = Field(default=5, ge=1, le=10)
payment_terms: str | None = None
bank_name: str | None = Field(None, max_length=255)
bank_iban: str | None = Field(None, max_length=50)
bank_bic: str | None = Field(None, max_length=20)
footer_text: str | None = None
default_vat_rate: Decimal = Field(default=Decimal("17.00"), ge=0, le=100)
class VendorInvoiceSettingsUpdate(BaseModel):
"""Schema for updating vendor invoice settings."""
company_name: str | None = Field(None, min_length=1, max_length=255)
company_address: str | None = Field(None, max_length=255)
company_city: str | None = Field(None, max_length=100)
company_postal_code: str | None = Field(None, max_length=20)
company_country: str | None = Field(None, min_length=2, max_length=2)
vat_number: str | None = None
is_vat_registered: bool | None = None
is_oss_registered: bool | None = None
oss_registration_country: str | None = None
invoice_prefix: str | None = Field(None, max_length=20)
invoice_number_padding: int | None = Field(None, ge=1, le=10)
payment_terms: str | None = None
bank_name: str | None = Field(None, max_length=255)
bank_iban: str | None = Field(None, max_length=50)
bank_bic: str | None = Field(None, max_length=20)
footer_text: str | None = None
default_vat_rate: Decimal | None = Field(None, ge=0, le=100)
class VendorInvoiceSettingsResponse(BaseModel):
"""Schema for vendor invoice settings response."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
company_name: str
company_address: str | None
company_city: str | None
company_postal_code: str | None
company_country: str
vat_number: str | None
is_vat_registered: bool
is_oss_registered: bool
oss_registration_country: str | None
invoice_prefix: str
invoice_next_number: int
invoice_number_padding: int
payment_terms: str | None
bank_name: str | None
bank_iban: str | None
bank_bic: str | None
footer_text: str | None
default_vat_rate: Decimal
created_at: datetime
updated_at: datetime
# ============================================================================
# Invoice Line Item Schemas
# ============================================================================
class InvoiceLineItem(BaseModel):
"""Schema for invoice line item."""
description: str
quantity: int = Field(..., ge=1)
unit_price_cents: int
total_cents: int
sku: str | None = None
ean: str | None = None
class InvoiceLineItemResponse(BaseModel):
"""Schema for invoice line item in response."""
description: str
quantity: int
unit_price_cents: int
total_cents: int
sku: str | None = None
ean: str | None = None
@property
def unit_price(self) -> float:
return self.unit_price_cents / 100
@property
def total(self) -> float:
return self.total_cents / 100
# ============================================================================
# Invoice Address Schemas
# ============================================================================
class InvoiceSellerDetails(BaseModel):
"""Seller details for invoice."""
company_name: str
address: str | None = None
city: str | None = None
postal_code: str | None = None
country: str
vat_number: str | None = None
class InvoiceBuyerDetails(BaseModel):
"""Buyer details for invoice."""
name: str
email: str | None = None
address: str | None = None
city: str | None = None
postal_code: str | None = None
country: str
vat_number: str | None = None # For B2B
# ============================================================================
# Invoice Schemas
# ============================================================================
class InvoiceCreate(BaseModel):
"""Schema for creating an invoice from an order."""
order_id: int
notes: str | None = None
class InvoiceManualCreate(BaseModel):
"""Schema for creating a manual invoice (without order)."""
buyer_details: InvoiceBuyerDetails
line_items: list[InvoiceLineItem]
notes: str | None = None
payment_terms: str | None = None
class InvoiceResponse(BaseModel):
"""Schema for invoice response."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
order_id: int | None
invoice_number: str
invoice_date: datetime
status: str
seller_details: dict
buyer_details: dict
line_items: list[dict]
vat_regime: str
destination_country: str | None
vat_rate: Decimal
vat_rate_label: str | None
currency: str
subtotal_cents: int
vat_amount_cents: int
total_cents: int
payment_terms: str | None
bank_details: dict | None
footer_text: str | None
pdf_generated_at: datetime | None
pdf_path: str | None
notes: str | None
created_at: datetime
updated_at: datetime
@property
def subtotal(self) -> float:
return self.subtotal_cents / 100
@property
def vat_amount(self) -> float:
return self.vat_amount_cents / 100
@property
def total(self) -> float:
return self.total_cents / 100
class InvoiceListResponse(BaseModel):
"""Schema for invoice list response (summary)."""
model_config = ConfigDict(from_attributes=True)
id: int
invoice_number: str
invoice_date: datetime
status: str
currency: str
total_cents: int
order_id: int | None
# Buyer name for display
buyer_name: str | None = None
@property
def total(self) -> float:
return self.total_cents / 100
class InvoiceStatusUpdate(BaseModel):
"""Schema for updating invoice status."""
status: str = Field(..., pattern="^(draft|issued|paid|cancelled)$")
# ============================================================================
# Paginated Response
# ============================================================================
class InvoiceListPaginatedResponse(BaseModel):
"""Paginated invoice list response."""
items: list[InvoiceListResponse]
total: int
page: int
per_page: int
pages: int

View File

@@ -0,0 +1,193 @@
# models/schema/subscription.py
"""
Pydantic schemas for subscription operations.
Supports subscription management and tier limit checks.
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
# ============================================================================
# Tier Information Schemas
# ============================================================================
class TierFeatures(BaseModel):
"""Features included in a tier."""
letzshop_sync: bool = True
inventory_basic: bool = True
inventory_locations: bool = False
inventory_purchase_orders: bool = False
invoice_lu: bool = True
invoice_eu_vat: bool = False
invoice_bulk: bool = False
customer_view: bool = True
customer_export: bool = False
analytics_dashboard: bool = False
accounting_export: bool = False
api_access: bool = False
automation_rules: bool = False
team_roles: bool = False
white_label: bool = False
multi_vendor: bool = False
custom_integrations: bool = False
sla_guarantee: bool = False
dedicated_support: bool = False
class TierLimits(BaseModel):
"""Limits for a subscription tier."""
orders_per_month: int | None = Field(None, description="None = unlimited")
products_limit: int | None = Field(None, description="None = unlimited")
team_members: int | None = Field(None, description="None = unlimited")
order_history_months: int | None = Field(None, description="None = unlimited")
class TierInfo(BaseModel):
"""Full tier information."""
code: str
name: str
price_monthly_cents: int
price_annual_cents: int | None
limits: TierLimits
features: list[str]
# ============================================================================
# Subscription Schemas
# ============================================================================
class SubscriptionCreate(BaseModel):
"""Schema for creating a subscription (admin/internal use)."""
tier: str = Field(default="essential", pattern="^(essential|professional|business|enterprise)$")
is_annual: bool = False
trial_days: int = Field(default=14, ge=0, le=30)
class SubscriptionUpdate(BaseModel):
"""Schema for updating a subscription."""
tier: str | None = Field(None, pattern="^(essential|professional|business|enterprise)$")
status: str | None = Field(None, pattern="^(trial|active|past_due|cancelled|expired)$")
is_annual: bool | None = None
custom_orders_limit: int | None = None
custom_products_limit: int | None = None
custom_team_limit: int | None = None
class SubscriptionResponse(BaseModel):
"""Schema for subscription response."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
tier: str
status: str
period_start: datetime
period_end: datetime
is_annual: bool
trial_ends_at: datetime | None
orders_this_period: int
orders_limit_reached_at: datetime | None
# Effective limits (with custom overrides applied)
orders_limit: int | None
products_limit: int | None
team_members_limit: int | None
# Computed properties
is_active: bool
is_trial: bool
trial_days_remaining: int | None
created_at: datetime
updated_at: datetime
class SubscriptionUsage(BaseModel):
"""Current subscription usage statistics."""
orders_used: int
orders_limit: int | None
orders_remaining: int | None
orders_percent_used: float | None
products_used: int
products_limit: int | None
products_remaining: int | None
products_percent_used: float | None
team_members_used: int
team_members_limit: int | None
team_members_remaining: int | None
team_members_percent_used: float | None
class SubscriptionStatusResponse(BaseModel):
"""Subscription status with usage and limits."""
subscription: SubscriptionResponse
usage: SubscriptionUsage
tier_info: TierInfo
# ============================================================================
# Limit Check Schemas
# ============================================================================
class LimitCheckResult(BaseModel):
"""Result of a limit check."""
allowed: bool
limit: int | None
current: int
remaining: int | None
message: str | None = None
class CanCreateOrderResponse(BaseModel):
"""Response for order creation check."""
allowed: bool
orders_this_period: int
orders_limit: int | None
message: str | None = None
class CanAddProductResponse(BaseModel):
"""Response for product addition check."""
allowed: bool
products_count: int
products_limit: int | None
message: str | None = None
class CanAddTeamMemberResponse(BaseModel):
"""Response for team member addition check."""
allowed: bool
team_count: int
team_limit: int | None
message: str | None = None
class FeatureCheckResponse(BaseModel):
"""Response for feature check."""
feature: str
enabled: bool
tier_required: str | None = None
message: str | None = None