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:
@@ -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
283
models/schema/invoice.py
Normal 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
|
||||
193
models/schema/subscription.py
Normal file
193
models/schema/subscription.py
Normal 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
|
||||
Reference in New Issue
Block a user