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

@@ -44,7 +44,7 @@ setup: install-all migrate-up init-prod
# ============================================================================= # =============================================================================
dev: dev:
$(PYTHON) -m uvicorn main:app --reload --host 0.0.0.0 --port 8000 $(PYTHON) -m uvicorn main:app --reload --host 0.0.0.0 --port 9999
# ============================================================================= # =============================================================================
# DATABASE MIGRATIONS # DATABASE MIGRATIONS
@@ -274,7 +274,7 @@ qa: format lint arch-check test-coverage docs-check
docs-serve: docs-serve:
@echo "Starting documentation server..." @echo "Starting documentation server..."
$(PYTHON) -m mkdocs serve --dev-addr=0.0.0.0:8001 $(PYTHON) -m mkdocs serve --dev-addr=0.0.0.0:9991
docs-build: docs-build:
@echo "Building documentation..." @echo "Building documentation..."

View File

@@ -0,0 +1,198 @@
"""Add invoice tables
Revision ID: h6c7d8e9f0a1
Revises: g5b6c7d8e9f0
Create Date: 2025-12-24
This migration adds:
- vendor_invoice_settings: Per-vendor invoice configuration
- invoices: Invoice records with seller/buyer snapshots
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "h6c7d8e9f0a1"
down_revision: Union[str, None] = "g5b6c7d8e9f0"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create vendor_invoice_settings table
op.create_table(
"vendor_invoice_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("vendor_id", sa.Integer(), nullable=False),
# Company details
sa.Column("company_name", sa.String(length=255), nullable=False),
sa.Column("company_address", sa.String(length=255), nullable=True),
sa.Column("company_city", sa.String(length=100), nullable=True),
sa.Column("company_postal_code", sa.String(length=20), nullable=True),
sa.Column(
"company_country", sa.String(length=2), server_default="LU", nullable=False
),
# VAT information
sa.Column("vat_number", sa.String(length=50), nullable=True),
sa.Column("is_vat_registered", sa.Boolean(), server_default="1", nullable=False),
# OSS
sa.Column("is_oss_registered", sa.Boolean(), server_default="0", nullable=False),
sa.Column("oss_registration_country", sa.String(length=2), nullable=True),
# Invoice numbering
sa.Column(
"invoice_prefix", sa.String(length=20), server_default="INV", nullable=False
),
sa.Column("invoice_next_number", sa.Integer(), server_default="1", nullable=False),
sa.Column(
"invoice_number_padding", sa.Integer(), server_default="5", nullable=False
),
# Payment information
sa.Column("payment_terms", sa.Text(), nullable=True),
sa.Column("bank_name", sa.String(length=255), nullable=True),
sa.Column("bank_iban", sa.String(length=50), nullable=True),
sa.Column("bank_bic", sa.String(length=20), nullable=True),
# Footer
sa.Column("footer_text", sa.Text(), nullable=True),
# Default VAT rate
sa.Column(
"default_vat_rate", sa.Numeric(precision=5, scale=2), server_default="17.00", nullable=False
),
# Timestamps
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.ForeignKeyConstraint(
["vendor_id"],
["vendors.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("vendor_id"),
)
op.create_index(
op.f("ix_vendor_invoice_settings_id"),
"vendor_invoice_settings",
["id"],
unique=False,
)
op.create_index(
op.f("ix_vendor_invoice_settings_vendor_id"),
"vendor_invoice_settings",
["vendor_id"],
unique=True,
)
# Create invoices table
op.create_table(
"invoices",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("vendor_id", sa.Integer(), nullable=False),
sa.Column("order_id", sa.Integer(), nullable=True),
# Invoice identification
sa.Column("invoice_number", sa.String(length=50), nullable=False),
sa.Column("invoice_date", sa.DateTime(timezone=True), nullable=False),
# Status
sa.Column(
"status", sa.String(length=20), server_default="draft", nullable=False
),
# Snapshots (JSON)
sa.Column("seller_details", sa.JSON(), nullable=False),
sa.Column("buyer_details", sa.JSON(), nullable=False),
sa.Column("line_items", sa.JSON(), nullable=False),
# VAT information
sa.Column(
"vat_regime", sa.String(length=20), server_default="domestic", nullable=False
),
sa.Column("destination_country", sa.String(length=2), nullable=True),
sa.Column("vat_rate", sa.Numeric(precision=5, scale=2), nullable=False),
sa.Column("vat_rate_label", sa.String(length=50), nullable=True),
# Amounts (in cents)
sa.Column("currency", sa.String(length=3), server_default="EUR", nullable=False),
sa.Column("subtotal_cents", sa.Integer(), nullable=False),
sa.Column("vat_amount_cents", sa.Integer(), nullable=False),
sa.Column("total_cents", sa.Integer(), nullable=False),
# Payment info
sa.Column("payment_terms", sa.Text(), nullable=True),
sa.Column("bank_details", sa.JSON(), nullable=True),
sa.Column("footer_text", sa.Text(), nullable=True),
# PDF
sa.Column("pdf_generated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("pdf_path", sa.String(length=500), nullable=True),
# Notes
sa.Column("notes", sa.Text(), nullable=True),
# Timestamps
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.ForeignKeyConstraint(
["vendor_id"],
["vendors.id"],
),
sa.ForeignKeyConstraint(
["order_id"],
["orders.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_invoices_id"), "invoices", ["id"], unique=False)
op.create_index(op.f("ix_invoices_vendor_id"), "invoices", ["vendor_id"], unique=False)
op.create_index(op.f("ix_invoices_order_id"), "invoices", ["order_id"], unique=False)
op.create_index(
"idx_invoice_vendor_number",
"invoices",
["vendor_id", "invoice_number"],
unique=True,
)
op.create_index(
"idx_invoice_vendor_date",
"invoices",
["vendor_id", "invoice_date"],
unique=False,
)
op.create_index(
"idx_invoice_status",
"invoices",
["vendor_id", "status"],
unique=False,
)
def downgrade() -> None:
# Drop invoices table
op.drop_index("idx_invoice_status", table_name="invoices")
op.drop_index("idx_invoice_vendor_date", table_name="invoices")
op.drop_index("idx_invoice_vendor_number", table_name="invoices")
op.drop_index(op.f("ix_invoices_order_id"), table_name="invoices")
op.drop_index(op.f("ix_invoices_vendor_id"), table_name="invoices")
op.drop_index(op.f("ix_invoices_id"), table_name="invoices")
op.drop_table("invoices")
# Drop vendor_invoice_settings table
op.drop_index(
op.f("ix_vendor_invoice_settings_vendor_id"),
table_name="vendor_invoice_settings",
)
op.drop_index(
op.f("ix_vendor_invoice_settings_id"), table_name="vendor_invoice_settings"
)
op.drop_table("vendor_invoice_settings")

View File

@@ -0,0 +1,148 @@
"""Add vendor subscriptions table
Revision ID: i7d8e9f0a1b2
Revises: h6c7d8e9f0a1
Create Date: 2025-12-24
This migration adds:
- vendor_subscriptions: Per-vendor subscription tracking with tier limits
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "i7d8e9f0a1b2"
down_revision: Union[str, None] = "h6c7d8e9f0a1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create vendor_subscriptions table
op.create_table(
"vendor_subscriptions",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("vendor_id", sa.Integer(), nullable=False),
# Tier and status
sa.Column(
"tier", sa.String(length=20), server_default="essential", nullable=False
),
sa.Column(
"status", sa.String(length=20), server_default="trial", nullable=False
),
# Billing period
sa.Column("period_start", sa.DateTime(timezone=True), nullable=False),
sa.Column("period_end", sa.DateTime(timezone=True), nullable=False),
sa.Column("is_annual", sa.Boolean(), server_default="0", nullable=False),
# Trial
sa.Column("trial_ends_at", sa.DateTime(timezone=True), nullable=True),
# Usage counters
sa.Column("orders_this_period", sa.Integer(), server_default="0", nullable=False),
sa.Column("orders_limit_reached_at", sa.DateTime(timezone=True), nullable=True),
# Custom overrides
sa.Column("custom_orders_limit", sa.Integer(), nullable=True),
sa.Column("custom_products_limit", sa.Integer(), nullable=True),
sa.Column("custom_team_limit", sa.Integer(), nullable=True),
# Payment (future Stripe integration)
sa.Column("stripe_customer_id", sa.String(length=100), nullable=True),
sa.Column("stripe_subscription_id", sa.String(length=100), nullable=True),
# Cancellation
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("cancellation_reason", sa.Text(), nullable=True),
# Timestamps
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.ForeignKeyConstraint(
["vendor_id"],
["vendors.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("vendor_id"),
)
op.create_index(
op.f("ix_vendor_subscriptions_id"),
"vendor_subscriptions",
["id"],
unique=False,
)
op.create_index(
op.f("ix_vendor_subscriptions_vendor_id"),
"vendor_subscriptions",
["vendor_id"],
unique=True,
)
op.create_index(
op.f("ix_vendor_subscriptions_tier"),
"vendor_subscriptions",
["tier"],
unique=False,
)
op.create_index(
op.f("ix_vendor_subscriptions_status"),
"vendor_subscriptions",
["status"],
unique=False,
)
op.create_index(
op.f("ix_vendor_subscriptions_stripe_customer_id"),
"vendor_subscriptions",
["stripe_customer_id"],
unique=False,
)
op.create_index(
op.f("ix_vendor_subscriptions_stripe_subscription_id"),
"vendor_subscriptions",
["stripe_subscription_id"],
unique=False,
)
op.create_index(
"idx_subscription_vendor_status",
"vendor_subscriptions",
["vendor_id", "status"],
unique=False,
)
op.create_index(
"idx_subscription_period",
"vendor_subscriptions",
["period_start", "period_end"],
unique=False,
)
def downgrade() -> None:
op.drop_index("idx_subscription_period", table_name="vendor_subscriptions")
op.drop_index("idx_subscription_vendor_status", table_name="vendor_subscriptions")
op.drop_index(
op.f("ix_vendor_subscriptions_stripe_subscription_id"),
table_name="vendor_subscriptions",
)
op.drop_index(
op.f("ix_vendor_subscriptions_stripe_customer_id"),
table_name="vendor_subscriptions",
)
op.drop_index(
op.f("ix_vendor_subscriptions_status"), table_name="vendor_subscriptions"
)
op.drop_index(
op.f("ix_vendor_subscriptions_tier"), table_name="vendor_subscriptions"
)
op.drop_index(
op.f("ix_vendor_subscriptions_vendor_id"), table_name="vendor_subscriptions"
)
op.drop_index(
op.f("ix_vendor_subscriptions_id"), table_name="vendor_subscriptions"
)
op.drop_table("vendor_subscriptions")

View File

@@ -21,6 +21,7 @@ from . import (
dashboard, dashboard,
info, info,
inventory, inventory,
invoices,
letzshop, letzshop,
marketplace, marketplace,
media, media,
@@ -59,6 +60,7 @@ router.include_router(settings.router, tags=["vendor-settings"])
router.include_router(products.router, tags=["vendor-products"]) router.include_router(products.router, tags=["vendor-products"])
router.include_router(orders.router, tags=["vendor-orders"]) router.include_router(orders.router, tags=["vendor-orders"])
router.include_router(order_item_exceptions.router, tags=["vendor-order-exceptions"]) router.include_router(order_item_exceptions.router, tags=["vendor-order-exceptions"])
router.include_router(invoices.router, tags=["vendor-invoices"])
router.include_router(customers.router, tags=["vendor-customers"]) router.include_router(customers.router, tags=["vendor-customers"])
router.include_router(team.router, tags=["vendor-team"]) router.include_router(team.router, tags=["vendor-team"])
router.include_router(inventory.router, tags=["vendor-inventory"]) router.include_router(inventory.router, tags=["vendor-inventory"])

369
app/api/v1/vendor/invoices.py vendored Normal file
View File

@@ -0,0 +1,369 @@
# app/api/v1/vendor/invoices.py
"""
Vendor invoice management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
Endpoints:
- GET /invoices - List vendor invoices
- GET /invoices/{invoice_id} - Get invoice details
- POST /invoices - Create invoice from order
- PUT /invoices/{invoice_id}/status - Update invoice status
- GET /invoices/{invoice_id}/pdf - Download invoice PDF
- POST /invoices/{invoice_id}/pdf - Generate/regenerate invoice PDF
- GET /invoices/settings - Get invoice settings
- POST /invoices/settings - Create invoice settings
- PUT /invoices/settings - Update invoice settings
- GET /invoices/stats - Get invoice statistics
"""
import logging
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.invoice_service import (
InvoiceNotFoundException,
InvoiceSettingsNotFoundException,
invoice_service,
)
from models.database.user import User
from models.schema.invoice import (
InvoiceCreate,
InvoiceListPaginatedResponse,
InvoiceListResponse,
InvoiceResponse,
InvoiceStatusUpdate,
VendorInvoiceSettingsCreate,
VendorInvoiceSettingsResponse,
VendorInvoiceSettingsUpdate,
)
router = APIRouter(prefix="/invoices")
logger = logging.getLogger(__name__)
# ============================================================================
# Invoice Settings
# ============================================================================
@router.get("/settings", response_model=VendorInvoiceSettingsResponse | None)
def get_invoice_settings(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get vendor invoice settings.
Returns null if settings not yet configured.
"""
settings = invoice_service.get_settings(db, current_user.token_vendor_id)
if settings:
return VendorInvoiceSettingsResponse.model_validate(settings)
return None
@router.post("/settings", response_model=VendorInvoiceSettingsResponse, status_code=201)
def create_invoice_settings(
data: VendorInvoiceSettingsCreate,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Create vendor invoice settings.
Required before creating invoices. Sets company details,
VAT number, invoice numbering preferences, and payment info.
"""
try:
settings = invoice_service.create_settings(
db=db,
vendor_id=current_user.token_vendor_id,
data=data,
)
return VendorInvoiceSettingsResponse.model_validate(settings)
except Exception as e:
logger.error(f"Failed to create invoice settings: {e}")
raise HTTPException(status_code=400, detail=str(e))
@router.put("/settings", response_model=VendorInvoiceSettingsResponse)
def update_invoice_settings(
data: VendorInvoiceSettingsUpdate,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Update vendor invoice settings.
"""
try:
settings = invoice_service.update_settings(
db=db,
vendor_id=current_user.token_vendor_id,
data=data,
)
return VendorInvoiceSettingsResponse.model_validate(settings)
except InvoiceSettingsNotFoundException:
raise HTTPException(
status_code=404,
detail="Invoice settings not found. Create settings first.",
)
except Exception as e:
logger.error(f"Failed to update invoice settings: {e}")
raise HTTPException(status_code=400, detail=str(e))
# ============================================================================
# Invoice Statistics
# ============================================================================
@router.get("/stats")
def get_invoice_stats(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get invoice statistics for the vendor.
Returns:
- total_invoices: Total number of invoices
- total_revenue: Total revenue from issued/paid invoices (EUR)
- draft_count: Number of draft invoices
- paid_count: Number of paid invoices
"""
return invoice_service.get_invoice_stats(db, current_user.token_vendor_id)
# ============================================================================
# Invoice CRUD
# ============================================================================
@router.get("", response_model=InvoiceListPaginatedResponse)
def list_invoices(
page: int = Query(1, ge=1, description="Page number"),
per_page: int = Query(20, ge=1, le=100, description="Items per page"),
status: str | None = Query(None, description="Filter by status"),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
List vendor invoices with pagination.
Supports filtering by status: draft, issued, paid, cancelled
"""
invoices, total = invoice_service.list_invoices(
db=db,
vendor_id=current_user.token_vendor_id,
status=status,
page=page,
per_page=per_page,
)
# Build response with buyer name from snapshot
items = []
for inv in invoices:
buyer_name = inv.buyer_details.get("name") if inv.buyer_details else None
items.append(
InvoiceListResponse(
id=inv.id,
invoice_number=inv.invoice_number,
invoice_date=inv.invoice_date,
status=inv.status,
currency=inv.currency,
total_cents=inv.total_cents,
order_id=inv.order_id,
buyer_name=buyer_name,
)
)
pages = (total + per_page - 1) // per_page if total > 0 else 1
return InvoiceListPaginatedResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
@router.get("/{invoice_id}", response_model=InvoiceResponse)
def get_invoice(
invoice_id: int,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get invoice details by ID.
"""
try:
invoice = invoice_service.get_invoice_or_raise(
db=db,
vendor_id=current_user.token_vendor_id,
invoice_id=invoice_id,
)
return InvoiceResponse.model_validate(invoice)
except InvoiceNotFoundException:
raise HTTPException(status_code=404, detail="Invoice not found")
@router.post("", response_model=InvoiceResponse, status_code=201)
def create_invoice(
data: InvoiceCreate,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Create an invoice from an order.
Automatically:
- Captures seller/buyer details snapshots
- Calculates VAT based on buyer country and OSS settings
- Generates invoice number
"""
try:
invoice = invoice_service.create_invoice_from_order(
db=db,
vendor_id=current_user.token_vendor_id,
order_id=data.order_id,
notes=data.notes,
)
return InvoiceResponse.model_validate(invoice)
except InvoiceSettingsNotFoundException:
raise HTTPException(
status_code=400,
detail="Invoice settings not configured. Set up invoice settings first.",
)
except Exception as e:
logger.error(f"Failed to create invoice: {e}")
raise HTTPException(status_code=400, detail=str(e))
@router.put("/{invoice_id}/status", response_model=InvoiceResponse)
def update_invoice_status(
invoice_id: int,
data: InvoiceStatusUpdate,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Update invoice status.
Valid statuses:
- draft: Invoice created but not yet sent
- issued: Invoice sent to customer
- paid: Payment received
- cancelled: Invoice cancelled
Note: Cancelled invoices cannot be modified.
"""
try:
invoice = invoice_service.update_status(
db=db,
vendor_id=current_user.token_vendor_id,
invoice_id=invoice_id,
new_status=data.status,
)
return InvoiceResponse.model_validate(invoice)
except InvoiceNotFoundException:
raise HTTPException(status_code=404, detail="Invoice not found")
except Exception as e:
logger.error(f"Failed to update invoice status: {e}")
raise HTTPException(status_code=400, detail=str(e))
# ============================================================================
# PDF Generation
# ============================================================================
@router.post("/{invoice_id}/pdf")
def generate_invoice_pdf(
invoice_id: int,
regenerate: bool = Query(False, description="Force regenerate if exists"),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Generate PDF for an invoice.
Set regenerate=true to force regeneration if PDF already exists.
Returns the path to the generated PDF.
"""
try:
pdf_path = invoice_service.generate_pdf(
db=db,
vendor_id=current_user.token_vendor_id,
invoice_id=invoice_id,
force_regenerate=regenerate,
)
return {"pdf_path": pdf_path, "message": "PDF generated successfully"}
except InvoiceNotFoundException:
raise HTTPException(status_code=404, detail="Invoice not found")
except Exception as e:
logger.error(f"Failed to generate PDF: {e}")
raise HTTPException(status_code=500, detail=f"Failed to generate PDF: {str(e)}")
@router.get("/{invoice_id}/pdf")
def download_invoice_pdf(
invoice_id: int,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Download invoice PDF.
Returns the PDF file for download.
Generates PDF if not already generated.
"""
try:
# Check if PDF exists, generate if not
pdf_path = invoice_service.get_pdf_path(
db=db,
vendor_id=current_user.token_vendor_id,
invoice_id=invoice_id,
)
if not pdf_path:
# Generate PDF
pdf_path = invoice_service.generate_pdf(
db=db,
vendor_id=current_user.token_vendor_id,
invoice_id=invoice_id,
)
# Verify file exists
if not Path(pdf_path).exists():
raise HTTPException(status_code=404, detail="PDF file not found")
# Get invoice for filename
invoice = invoice_service.get_invoice_or_raise(
db=db,
vendor_id=current_user.token_vendor_id,
invoice_id=invoice_id,
)
filename = f"invoice-{invoice.invoice_number}.pdf"
return FileResponse(
path=pdf_path,
media_type="application/pdf",
filename=filename,
headers={
"Content-Disposition": f'attachment; filename="{filename}"'
},
)
except InvoiceNotFoundException:
raise HTTPException(status_code=404, detail="Invoice not found")
except Exception as e:
logger.error(f"Failed to download PDF: {e}")
raise HTTPException(status_code=500, detail=f"Failed to download PDF: {str(e)}")

View File

@@ -0,0 +1,164 @@
# app/services/invoice_pdf_service.py
"""
Invoice PDF generation service using WeasyPrint.
Renders HTML invoice templates to PDF using Jinja2 + WeasyPrint.
Stores generated PDFs in the configured storage location.
"""
import logging
import os
from datetime import UTC, datetime
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from sqlalchemy.orm import Session
from app.core.config import settings
from models.database.invoice import Invoice
logger = logging.getLogger(__name__)
# Template directory
TEMPLATE_DIR = Path(__file__).parent.parent / "templates" / "invoices"
# PDF storage directory (relative to project root)
PDF_STORAGE_DIR = Path("storage") / "invoices"
class InvoicePDFService:
"""Service for generating invoice PDFs."""
def __init__(self):
"""Initialize the PDF service with Jinja2 environment."""
self.env = Environment(
loader=FileSystemLoader(str(TEMPLATE_DIR)),
autoescape=True,
)
def _ensure_storage_dir(self, vendor_id: int) -> Path:
"""Ensure the storage directory exists for a vendor."""
storage_path = PDF_STORAGE_DIR / str(vendor_id)
storage_path.mkdir(parents=True, exist_ok=True)
return storage_path
def _get_pdf_filename(self, invoice: Invoice) -> str:
"""Generate PDF filename for an invoice."""
# Sanitize invoice number for filename
safe_number = invoice.invoice_number.replace("/", "-").replace("\\", "-")
return f"{safe_number}.pdf"
def generate_pdf(
self,
db: Session,
invoice: Invoice,
force_regenerate: bool = False,
) -> str:
"""
Generate PDF for an invoice.
Args:
db: Database session
invoice: Invoice to generate PDF for
force_regenerate: If True, regenerate even if PDF already exists
Returns:
Path to the generated PDF file
"""
# Check if PDF already exists
if invoice.pdf_path and not force_regenerate:
if Path(invoice.pdf_path).exists():
logger.debug(f"PDF already exists for invoice {invoice.invoice_number}")
return invoice.pdf_path
# Ensure storage directory exists
storage_dir = self._ensure_storage_dir(invoice.vendor_id)
pdf_filename = self._get_pdf_filename(invoice)
pdf_path = storage_dir / pdf_filename
# Render HTML template
html_content = self._render_html(invoice)
# Generate PDF using WeasyPrint
try:
from weasyprint import HTML
html_doc = HTML(string=html_content, base_url=str(TEMPLATE_DIR))
html_doc.write_pdf(str(pdf_path))
logger.info(f"Generated PDF for invoice {invoice.invoice_number} at {pdf_path}")
except ImportError:
logger.error("WeasyPrint not installed. Install with: pip install weasyprint")
raise RuntimeError("WeasyPrint not installed")
except Exception as e:
logger.error(f"Failed to generate PDF for invoice {invoice.invoice_number}: {e}")
raise
# Update invoice record with PDF path and timestamp
invoice.pdf_path = str(pdf_path)
invoice.pdf_generated_at = datetime.now(UTC)
db.commit()
return str(pdf_path)
def _render_html(self, invoice: Invoice) -> str:
"""Render the invoice HTML template."""
template = self.env.get_template("invoice.html")
# Prepare template context
context = {
"invoice": invoice,
"seller": invoice.seller_details,
"buyer": invoice.buyer_details,
"line_items": invoice.line_items,
"bank_details": invoice.bank_details,
"payment_terms": invoice.payment_terms,
"footer_text": invoice.footer_text,
"now": datetime.now(UTC),
}
return template.render(**context)
def get_pdf_path(self, invoice: Invoice) -> str | None:
"""Get the PDF path for an invoice if it exists."""
if invoice.pdf_path and Path(invoice.pdf_path).exists():
return invoice.pdf_path
return None
def delete_pdf(self, invoice: Invoice, db: Session) -> bool:
"""
Delete the PDF file for an invoice.
Args:
invoice: Invoice whose PDF to delete
db: Database session
Returns:
True if deleted, False if not found
"""
if not invoice.pdf_path:
return False
pdf_path = Path(invoice.pdf_path)
if pdf_path.exists():
try:
pdf_path.unlink()
logger.info(f"Deleted PDF for invoice {invoice.invoice_number}")
except Exception as e:
logger.error(f"Failed to delete PDF {pdf_path}: {e}")
return False
# Clear PDF fields
invoice.pdf_path = None
invoice.pdf_generated_at = None
db.commit()
return True
def regenerate_pdf(self, db: Session, invoice: Invoice) -> str:
"""Force regenerate PDF for an invoice."""
return self.generate_pdf(db, invoice, force_regenerate=True)
# Singleton instance
invoice_pdf_service = InvoicePDFService()

View File

@@ -0,0 +1,666 @@
# app/services/invoice_service.py
"""
Invoice service for generating and managing invoices.
Handles:
- Vendor invoice settings management
- Invoice generation from orders
- VAT calculation (Luxembourg, EU, B2B reverse charge)
- Invoice number sequencing
- PDF generation (via separate module)
VAT Logic:
- Luxembourg domestic: 17% (standard), 8% (reduced), 3% (super-reduced), 14% (intermediate)
- EU cross-border B2C with OSS: Use destination country VAT rate
- EU cross-border B2C without OSS: Use Luxembourg VAT rate (origin principle)
- EU B2B with valid VAT number: Reverse charge (0% VAT)
"""
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 (
OrderNotFoundException,
ValidationException,
)
from models.database.invoice import (
Invoice,
InvoiceStatus,
VATRegime,
VendorInvoiceSettings,
)
from models.database.order import Order
from models.database.vendor import Vendor
from models.schema.invoice import (
InvoiceBuyerDetails,
InvoiceCreate,
InvoiceLineItem,
InvoiceManualCreate,
InvoiceSellerDetails,
VendorInvoiceSettingsCreate,
VendorInvoiceSettingsUpdate,
)
logger = logging.getLogger(__name__)
# EU VAT rates by country code (2024 standard rates)
EU_VAT_RATES: dict[str, Decimal] = {
"AT": Decimal("20.00"), # Austria
"BE": Decimal("21.00"), # Belgium
"BG": Decimal("20.00"), # Bulgaria
"HR": Decimal("25.00"), # Croatia
"CY": Decimal("19.00"), # Cyprus
"CZ": Decimal("21.00"), # Czech Republic
"DK": Decimal("25.00"), # Denmark
"EE": Decimal("22.00"), # Estonia
"FI": Decimal("24.00"), # Finland
"FR": Decimal("20.00"), # France
"DE": Decimal("19.00"), # Germany
"GR": Decimal("24.00"), # Greece
"HU": Decimal("27.00"), # Hungary
"IE": Decimal("23.00"), # Ireland
"IT": Decimal("22.00"), # Italy
"LV": Decimal("21.00"), # Latvia
"LT": Decimal("21.00"), # Lithuania
"LU": Decimal("17.00"), # Luxembourg (standard)
"MT": Decimal("18.00"), # Malta
"NL": Decimal("21.00"), # Netherlands
"PL": Decimal("23.00"), # Poland
"PT": Decimal("23.00"), # Portugal
"RO": Decimal("19.00"), # Romania
"SK": Decimal("20.00"), # Slovakia
"SI": Decimal("22.00"), # Slovenia
"ES": Decimal("21.00"), # Spain
"SE": Decimal("25.00"), # Sweden
}
# Luxembourg specific VAT rates
LU_VAT_RATES = {
"standard": Decimal("17.00"),
"intermediate": Decimal("14.00"),
"reduced": Decimal("8.00"),
"super_reduced": Decimal("3.00"),
}
class InvoiceNotFoundException(Exception):
"""Raised when invoice not found."""
pass
class InvoiceSettingsNotFoundException(Exception):
"""Raised when vendor invoice settings not found."""
pass
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.
Returns: (regime, vat_rate, destination_country)
"""
seller_country = seller_country.upper()
buyer_country = buyer_country.upper()
# Same country = domestic VAT
if seller_country == buyer_country:
vat_rate = self.get_vat_rate_for_country(seller_country)
return VATRegime.DOMESTIC, vat_rate, None
# Different EU countries
if buyer_country in EU_VAT_RATES:
# B2B with valid VAT number = reverse charge
if buyer_vat_number:
return VATRegime.REVERSE_CHARGE, Decimal("0.00"), buyer_country
# B2C cross-border
if seller_oss_registered:
# OSS: use destination country VAT
vat_rate = self.get_vat_rate_for_country(buyer_country)
return VATRegime.OSS, vat_rate, buyer_country
else:
# No OSS: use origin country VAT
vat_rate = self.get_vat_rate_for_country(seller_country)
return VATRegime.ORIGIN, vat_rate, buyer_country
# Non-EU = VAT exempt (export)
return VATRegime.EXEMPT, Decimal("0.00"), buyer_country
# =========================================================================
# Invoice Settings Management
# =========================================================================
def get_settings(
self, db: Session, vendor_id: int
) -> VendorInvoiceSettings | None:
"""Get vendor invoice settings."""
return (
db.query(VendorInvoiceSettings)
.filter(VendorInvoiceSettings.vendor_id == vendor_id)
.first()
)
def get_settings_or_raise(
self, db: Session, vendor_id: int
) -> VendorInvoiceSettings:
"""Get vendor invoice settings or raise exception."""
settings = self.get_settings(db, vendor_id)
if not settings:
raise InvoiceSettingsNotFoundException(
f"Invoice settings not configured for vendor {vendor_id}"
)
return settings
def create_settings(
self,
db: Session,
vendor_id: int,
data: VendorInvoiceSettingsCreate,
) -> VendorInvoiceSettings:
"""Create vendor invoice settings."""
# Check if settings already exist
existing = self.get_settings(db, vendor_id)
if existing:
raise ValidationException(
"Invoice settings already exist for this vendor"
)
settings = VendorInvoiceSettings(
vendor_id=vendor_id,
**data.model_dump(),
)
db.add(settings)
db.commit()
db.refresh(settings)
logger.info(f"Created invoice settings for vendor {vendor_id}")
return settings
def update_settings(
self,
db: Session,
vendor_id: int,
data: VendorInvoiceSettingsUpdate,
) -> VendorInvoiceSettings:
"""Update vendor invoice settings."""
settings = self.get_settings_or_raise(db, vendor_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.commit()
db.refresh(settings)
logger.info(f"Updated invoice settings for vendor {vendor_id}")
return settings
def create_settings_from_vendor(
self,
db: Session,
vendor: Vendor,
) -> VendorInvoiceSettings:
"""
Create invoice settings from vendor/company info.
Used for initial setup based on existing vendor data.
"""
company = vendor.company
settings = VendorInvoiceSettings(
vendor_id=vendor.id,
company_name=company.legal_name if company else vendor.name,
company_address=vendor.effective_business_address,
company_city=None, # Would need to parse from address
company_postal_code=None,
company_country="LU",
vat_number=vendor.effective_tax_number,
is_vat_registered=bool(vendor.effective_tax_number),
)
db.add(settings)
db.commit()
db.refresh(settings)
logger.info(f"Created invoice settings from vendor data for vendor {vendor.id}")
return settings
# =========================================================================
# Invoice Number Generation
# =========================================================================
def _get_next_invoice_number(
self, db: Session, settings: VendorInvoiceSettings
) -> 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}"
# Increment counter
settings.invoice_next_number += 1
db.flush()
return invoice_number
# =========================================================================
# Invoice Creation
# =========================================================================
def create_invoice_from_order(
self,
db: Session,
vendor_id: int,
order_id: int,
notes: str | None = None,
) -> Invoice:
"""
Create an invoice from an order.
Captures snapshots of seller/buyer details and calculates VAT.
"""
# Get invoice settings
settings = self.get_settings_or_raise(db, vendor_id)
# Get order
order = (
db.query(Order)
.filter(and_(Order.id == order_id, Order.vendor_id == vendor_id))
.first()
)
if not order:
raise OrderNotFoundException(f"Order {order_id} not found")
# Check for existing invoice
existing = (
db.query(Invoice)
.filter(and_(Invoice.order_id == order_id, Invoice.vendor_id == vendor_id))
.first()
)
if existing:
raise ValidationException(f"Invoice already exists for order {order_id}")
# Determine VAT regime
buyer_country = order.bill_country_iso
vat_regime, vat_rate, destination_country = self.determine_vat_regime(
seller_country=settings.company_country,
buyer_country=buyer_country,
buyer_vat_number=None, # TODO: Add B2B VAT number support
seller_oss_registered=settings.is_oss_registered,
)
# Build seller details snapshot
seller_details = {
"company_name": settings.company_name,
"address": settings.company_address,
"city": settings.company_city,
"postal_code": settings.company_postal_code,
"country": settings.company_country,
"vat_number": settings.vat_number,
}
# Build buyer details snapshot
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, # TODO: B2B support
}
if order.bill_company:
buyer_details["company"] = order.bill_company
# Build line items from order items
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,
})
# Calculate amounts
subtotal_cents = sum(item["total_cents"] for item in line_items)
# Calculate VAT
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
# Get VAT label
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.company_country, vat_rate)
# Generate invoice number
invoice_number = self._get_next_invoice_number(db, settings)
# Create invoice
invoice = Invoice(
vendor_id=vendor_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.commit()
db.refresh(invoice)
logger.info(
f"Created invoice {invoice_number} for order {order_id} "
f"(vendor={vendor_id}, total={total_cents/100:.2f} EUR, VAT={vat_regime.value})"
)
return invoice
# =========================================================================
# Invoice Retrieval
# =========================================================================
def get_invoice(
self, db: Session, vendor_id: int, invoice_id: int
) -> Invoice | None:
"""Get invoice by ID."""
return (
db.query(Invoice)
.filter(and_(Invoice.id == invoice_id, Invoice.vendor_id == vendor_id))
.first()
)
def get_invoice_or_raise(
self, db: Session, vendor_id: int, invoice_id: int
) -> Invoice:
"""Get invoice by ID or raise exception."""
invoice = self.get_invoice(db, vendor_id, invoice_id)
if not invoice:
raise InvoiceNotFoundException(f"Invoice {invoice_id} not found")
return invoice
def get_invoice_by_number(
self, db: Session, vendor_id: int, invoice_number: str
) -> Invoice | None:
"""Get invoice by invoice number."""
return (
db.query(Invoice)
.filter(
and_(
Invoice.invoice_number == invoice_number,
Invoice.vendor_id == vendor_id,
)
)
.first()
)
def list_invoices(
self,
db: Session,
vendor_id: int,
status: str | None = None,
page: int = 1,
per_page: int = 20,
) -> tuple[list[Invoice], int]:
"""
List invoices for vendor with pagination.
Returns: (invoices, total_count)
"""
query = db.query(Invoice).filter(Invoice.vendor_id == vendor_id)
if status:
query = query.filter(Invoice.status == status)
# Get total count
total = query.count()
# Apply pagination and order
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,
vendor_id: int,
invoice_id: int,
new_status: str,
) -> Invoice:
"""Update invoice status."""
invoice = self.get_invoice_or_raise(db, vendor_id, invoice_id)
# Validate status transition
valid_statuses = [s.value for s in InvoiceStatus]
if new_status not in valid_statuses:
raise ValidationException(f"Invalid status: {new_status}")
# Cannot change cancelled invoices
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.commit()
db.refresh(invoice)
logger.info(f"Updated invoice {invoice.invoice_number} status to {new_status}")
return invoice
def mark_as_issued(
self, db: Session, vendor_id: int, invoice_id: int
) -> Invoice:
"""Mark invoice as issued."""
return self.update_status(db, vendor_id, invoice_id, InvoiceStatus.ISSUED.value)
def mark_as_paid(
self, db: Session, vendor_id: int, invoice_id: int
) -> Invoice:
"""Mark invoice as paid."""
return self.update_status(db, vendor_id, invoice_id, InvoiceStatus.PAID.value)
def cancel_invoice(
self, db: Session, vendor_id: int, invoice_id: int
) -> Invoice:
"""Cancel invoice."""
return self.update_status(
db, vendor_id, invoice_id, InvoiceStatus.CANCELLED.value
)
# =========================================================================
# Statistics
# =========================================================================
def get_invoice_stats(
self, db: Session, vendor_id: int
) -> dict[str, Any]:
"""Get invoice statistics for vendor."""
total_count = (
db.query(func.count(Invoice.id))
.filter(Invoice.vendor_id == vendor_id)
.scalar()
or 0
)
total_revenue = (
db.query(func.sum(Invoice.total_cents))
.filter(
and_(
Invoice.vendor_id == vendor_id,
Invoice.status.in_([
InvoiceStatus.ISSUED.value,
InvoiceStatus.PAID.value,
]),
)
)
.scalar()
or 0
)
draft_count = (
db.query(func.count(Invoice.id))
.filter(
and_(
Invoice.vendor_id == vendor_id,
Invoice.status == InvoiceStatus.DRAFT.value,
)
)
.scalar()
or 0
)
paid_count = (
db.query(func.count(Invoice.id))
.filter(
and_(
Invoice.vendor_id == vendor_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,
vendor_id: int,
invoice_id: int,
force_regenerate: bool = False,
) -> str:
"""
Generate PDF for an invoice.
Returns path to the generated PDF.
"""
from app.services.invoice_pdf_service import invoice_pdf_service
invoice = self.get_invoice_or_raise(db, vendor_id, invoice_id)
return invoice_pdf_service.generate_pdf(db, invoice, force_regenerate)
def get_pdf_path(
self,
db: Session,
vendor_id: int,
invoice_id: int,
) -> str | None:
"""Get PDF path for an invoice if it exists."""
from app.services.invoice_pdf_service import invoice_pdf_service
invoice = self.get_invoice_or_raise(db, vendor_id, invoice_id)
return invoice_pdf_service.get_pdf_path(invoice)
# Singleton instance
invoice_service = InvoiceService()

View File

@@ -15,6 +15,7 @@ from sqlalchemy import String, and_, func, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.services.order_service import order_service as unified_order_service from app.services.order_service import order_service as unified_order_service
from app.services.subscription_service import subscription_service
from models.database.letzshop import ( from models.database.letzshop import (
LetzshopFulfillmentQueue, LetzshopFulfillmentQueue,
LetzshopHistoricalImportJob, LetzshopHistoricalImportJob,
@@ -792,6 +793,7 @@ class LetzshopOrderService:
"updated": 0, "updated": 0,
"skipped": 0, "skipped": 0,
"errors": 0, "errors": 0,
"limit_exceeded": 0,
"products_matched": 0, "products_matched": 0,
"products_not_found": 0, "products_not_found": 0,
"eans_processed": set(), "eans_processed": set(),
@@ -800,6 +802,10 @@ class LetzshopOrderService:
"error_messages": [], "error_messages": [],
} }
# Get subscription usage upfront for batch efficiency
usage = subscription_service.get_usage(self.db, vendor_id)
orders_remaining = usage.orders_remaining # None = unlimited
for i, shipment in enumerate(shipments): for i, shipment in enumerate(shipments):
shipment_id = shipment.get("id") shipment_id = shipment.get("id")
if not shipment_id: if not shipment_id:
@@ -844,11 +850,24 @@ class LetzshopOrderService:
else: else:
stats["skipped"] += 1 stats["skipped"] += 1
else: else:
# Check tier limit before creating order
if orders_remaining is not None and orders_remaining <= 0:
stats["limit_exceeded"] += 1
stats["error_messages"].append(
f"Shipment {shipment_id}: Order limit reached"
)
continue
# Create new order using unified service # Create new order using unified service
try: try:
self.create_order(vendor_id, shipment) self.create_order(vendor_id, shipment)
self.db.commit() # noqa: SVC-006 - background task needs incremental commits self.db.commit() # noqa: SVC-006 - background task needs incremental commits
stats["imported"] += 1 stats["imported"] += 1
# Decrement remaining count for batch efficiency
if orders_remaining is not None:
orders_remaining -= 1
except Exception as e: except Exception as e:
self.db.rollback() # Rollback failed order self.db.rollback() # Rollback failed order
stats["errors"] += 1 stats["errors"] += 1

View File

@@ -31,6 +31,10 @@ from app.exceptions import (
ValidationException, ValidationException,
) )
from app.services.order_item_exception_service import order_item_exception_service from app.services.order_item_exception_service import order_item_exception_service
from app.services.subscription_service import (
subscription_service,
TierLimitExceededException,
)
from app.utils.money import Money, cents_to_euros, euros_to_cents from app.utils.money import Money, cents_to_euros, euros_to_cents
from models.database.customer import Customer from models.database.customer import Customer
from models.database.marketplace_product import MarketplaceProduct from models.database.marketplace_product import MarketplaceProduct
@@ -271,7 +275,11 @@ class OrderService:
Raises: Raises:
ValidationException: If order data is invalid ValidationException: If order data is invalid
InsufficientInventoryException: If not enough inventory InsufficientInventoryException: If not enough inventory
TierLimitExceededException: If vendor has reached order limit
""" """
# Check tier limit before creating order
subscription_service.check_order_limit(db, vendor_id)
try: try:
# Get or create customer # Get or create customer
if order_data.customer_id: if order_data.customer_id:
@@ -428,6 +436,9 @@ class OrderService:
db.flush() db.flush()
db.refresh(order) db.refresh(order)
# Increment order count for subscription tracking
subscription_service.increment_order_count(db, vendor_id)
logger.info( logger.info(
f"Order {order.order_number} created for vendor {vendor_id}, " f"Order {order.order_number} created for vendor {vendor_id}, "
f"total: EUR {cents_to_euros(total_amount_cents):.2f}" f"total: EUR {cents_to_euros(total_amount_cents):.2f}"
@@ -439,6 +450,7 @@ class OrderService:
ValidationException, ValidationException,
InsufficientInventoryException, InsufficientInventoryException,
CustomerNotFoundException, CustomerNotFoundException,
TierLimitExceededException,
): ):
raise raise
except Exception as e: except Exception as e:
@@ -450,6 +462,7 @@ class OrderService:
db: Session, db: Session,
vendor_id: int, vendor_id: int,
shipment_data: dict[str, Any], shipment_data: dict[str, Any],
skip_limit_check: bool = False,
) -> Order: ) -> Order:
""" """
Create an order from Letzshop shipment data. Create an order from Letzshop shipment data.
@@ -462,13 +475,27 @@ class OrderService:
db: Database session db: Database session
vendor_id: Vendor ID vendor_id: Vendor ID
shipment_data: Raw shipment data from Letzshop API shipment_data: Raw shipment data from Letzshop API
skip_limit_check: If True, skip tier limit check (for batch imports
that check limit upfront)
Returns: Returns:
Created Order object Created Order object
Raises: Raises:
ValidationException: If product not found by GTIN ValidationException: If product not found by GTIN
TierLimitExceededException: If vendor has reached order limit
""" """
# Check tier limit before creating order (unless skipped for batch ops)
if not skip_limit_check:
can_create, message = subscription_service.can_create_order(db, vendor_id)
if not can_create:
raise TierLimitExceededException(
message=message or "Order limit exceeded",
limit_type="orders",
current=0, # Will be filled by caller if needed
limit=0,
)
order_data = shipment_data.get("order", {}) order_data = shipment_data.get("order", {})
# Generate order number using Letzshop order number # Generate order number using Letzshop order number
@@ -777,6 +804,9 @@ class OrderService:
f"order {order.order_number}" f"order {order.order_number}"
) )
# Increment order count for subscription tracking
subscription_service.increment_order_count(db, vendor_id)
logger.info( logger.info(
f"Letzshop order {order.order_number} created for vendor {vendor_id}, " f"Letzshop order {order.order_number} created for vendor {vendor_id}, "
f"status: {status}, items: {len(inventory_units)}" f"status: {status}, items: {len(inventory_units)}"

View File

@@ -0,0 +1,512 @@
# app/services/subscription_service.py
"""
Subscription service for tier-based access control.
Handles:
- Subscription creation and management
- Tier limit enforcement
- Usage tracking
- Feature gating
Usage:
from app.services.subscription_service import subscription_service
# Check if vendor can create an order
can_create, message = subscription_service.can_create_order(db, vendor_id)
# Increment order counter after successful order
subscription_service.increment_order_count(db, vendor_id)
"""
import logging
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import func
from sqlalchemy.orm import Session
from models.database.product import Product
from models.database.subscription import (
SubscriptionStatus,
TIER_LIMITS,
TierCode,
VendorSubscription,
)
from models.database.vendor import Vendor, VendorUser
from models.schema.subscription import (
SubscriptionCreate,
SubscriptionUpdate,
SubscriptionUsage,
TierInfo,
TierLimits,
)
logger = logging.getLogger(__name__)
class SubscriptionNotFoundException(Exception):
"""Raised when subscription not found."""
pass
class TierLimitExceededException(Exception):
"""Raised when a tier limit is exceeded."""
def __init__(self, message: str, limit_type: str, current: int, limit: int):
super().__init__(message)
self.limit_type = limit_type
self.current = current
self.limit = limit
class FeatureNotAvailableException(Exception):
"""Raised when a feature is not available in current tier."""
def __init__(self, feature: str, current_tier: str, required_tier: str):
message = f"Feature '{feature}' requires {required_tier} tier (current: {current_tier})"
super().__init__(message)
self.feature = feature
self.current_tier = current_tier
self.required_tier = required_tier
class SubscriptionService:
"""Service for subscription and tier limit operations."""
# =========================================================================
# Tier Information
# =========================================================================
def get_tier_info(self, tier_code: str) -> TierInfo:
"""Get full tier information."""
try:
tier = TierCode(tier_code)
except ValueError:
tier = TierCode.ESSENTIAL
limits = TIER_LIMITS[tier]
return TierInfo(
code=tier.value,
name=limits["name"],
price_monthly_cents=limits["price_monthly_cents"],
price_annual_cents=limits.get("price_annual_cents"),
limits=TierLimits(
orders_per_month=limits.get("orders_per_month"),
products_limit=limits.get("products_limit"),
team_members=limits.get("team_members"),
order_history_months=limits.get("order_history_months"),
),
features=limits.get("features", []),
)
def get_all_tiers(self) -> list[TierInfo]:
"""Get information for all tiers."""
return [
self.get_tier_info(tier.value)
for tier in TierCode
]
# =========================================================================
# Subscription CRUD
# =========================================================================
def get_subscription(
self, db: Session, vendor_id: int
) -> VendorSubscription | None:
"""Get vendor subscription."""
return (
db.query(VendorSubscription)
.filter(VendorSubscription.vendor_id == vendor_id)
.first()
)
def get_subscription_or_raise(
self, db: Session, vendor_id: int
) -> VendorSubscription:
"""Get vendor subscription or raise exception."""
subscription = self.get_subscription(db, vendor_id)
if not subscription:
raise SubscriptionNotFoundException(
f"No subscription found for vendor {vendor_id}"
)
return subscription
def get_or_create_subscription(
self,
db: Session,
vendor_id: int,
tier: str = TierCode.ESSENTIAL.value,
trial_days: int = 14,
) -> VendorSubscription:
"""
Get existing subscription or create a new trial subscription.
Used when a vendor first accesses the system.
"""
subscription = self.get_subscription(db, vendor_id)
if subscription:
return subscription
# Create new trial subscription
now = datetime.now(UTC)
trial_end = now + timedelta(days=trial_days)
subscription = VendorSubscription(
vendor_id=vendor_id,
tier=tier,
status=SubscriptionStatus.TRIAL.value,
period_start=now,
period_end=trial_end,
trial_ends_at=trial_end,
is_annual=False,
)
db.add(subscription)
db.commit()
db.refresh(subscription)
logger.info(
f"Created trial subscription for vendor {vendor_id} "
f"(tier={tier}, trial_ends={trial_end})"
)
return subscription
def create_subscription(
self,
db: Session,
vendor_id: int,
data: SubscriptionCreate,
) -> VendorSubscription:
"""Create a subscription for a vendor."""
# Check if subscription exists
existing = self.get_subscription(db, vendor_id)
if existing:
raise ValueError("Vendor already has a subscription")
now = datetime.now(UTC)
# Calculate period end based on billing cycle
if data.is_annual:
period_end = now + timedelta(days=365)
else:
period_end = now + timedelta(days=30)
# Handle trial
trial_ends_at = None
status = SubscriptionStatus.ACTIVE.value
if data.trial_days > 0:
trial_ends_at = now + timedelta(days=data.trial_days)
status = SubscriptionStatus.TRIAL.value
period_end = trial_ends_at
subscription = VendorSubscription(
vendor_id=vendor_id,
tier=data.tier,
status=status,
period_start=now,
period_end=period_end,
trial_ends_at=trial_ends_at,
is_annual=data.is_annual,
)
db.add(subscription)
db.commit()
db.refresh(subscription)
logger.info(f"Created subscription for vendor {vendor_id}: {data.tier}")
return subscription
def update_subscription(
self,
db: Session,
vendor_id: int,
data: SubscriptionUpdate,
) -> VendorSubscription:
"""Update a vendor subscription."""
subscription = self.get_subscription_or_raise(db, vendor_id)
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(subscription, key, value)
subscription.updated_at = datetime.now(UTC)
db.commit()
db.refresh(subscription)
logger.info(f"Updated subscription for vendor {vendor_id}")
return subscription
def upgrade_tier(
self,
db: Session,
vendor_id: int,
new_tier: str,
) -> VendorSubscription:
"""Upgrade vendor to a new tier."""
subscription = self.get_subscription_or_raise(db, vendor_id)
old_tier = subscription.tier
subscription.tier = new_tier
subscription.updated_at = datetime.now(UTC)
# If upgrading from trial, mark as active
if subscription.status == SubscriptionStatus.TRIAL.value:
subscription.status = SubscriptionStatus.ACTIVE.value
db.commit()
db.refresh(subscription)
logger.info(f"Upgraded vendor {vendor_id} from {old_tier} to {new_tier}")
return subscription
def cancel_subscription(
self,
db: Session,
vendor_id: int,
reason: str | None = None,
) -> VendorSubscription:
"""Cancel a vendor subscription (access until period end)."""
subscription = self.get_subscription_or_raise(db, vendor_id)
subscription.status = SubscriptionStatus.CANCELLED.value
subscription.cancelled_at = datetime.now(UTC)
subscription.cancellation_reason = reason
subscription.updated_at = datetime.now(UTC)
db.commit()
db.refresh(subscription)
logger.info(f"Cancelled subscription for vendor {vendor_id}")
return subscription
# =========================================================================
# Usage Tracking
# =========================================================================
def get_usage(self, db: Session, vendor_id: int) -> SubscriptionUsage:
"""Get current subscription usage statistics."""
subscription = self.get_or_create_subscription(db, vendor_id)
# Get actual counts
products_count = (
db.query(func.count(Product.id))
.filter(Product.vendor_id == vendor_id)
.scalar()
or 0
)
team_count = (
db.query(func.count(VendorUser.id))
.filter(VendorUser.vendor_id == vendor_id, VendorUser.is_active == True)
.scalar()
or 0
)
# Calculate usage stats
orders_limit = subscription.orders_limit
products_limit = subscription.products_limit
team_limit = subscription.team_members_limit
def calc_remaining(current: int, limit: int | None) -> int | None:
if limit is None:
return None
return max(0, limit - current)
def calc_percent(current: int, limit: int | None) -> float | None:
if limit is None or limit == 0:
return None
return min(100.0, (current / limit) * 100)
return SubscriptionUsage(
orders_used=subscription.orders_this_period,
orders_limit=orders_limit,
orders_remaining=calc_remaining(subscription.orders_this_period, orders_limit),
orders_percent_used=calc_percent(subscription.orders_this_period, orders_limit),
products_used=products_count,
products_limit=products_limit,
products_remaining=calc_remaining(products_count, products_limit),
products_percent_used=calc_percent(products_count, products_limit),
team_members_used=team_count,
team_members_limit=team_limit,
team_members_remaining=calc_remaining(team_count, team_limit),
team_members_percent_used=calc_percent(team_count, team_limit),
)
def increment_order_count(self, db: Session, vendor_id: int) -> None:
"""
Increment the order counter for the current period.
Call this after successfully creating/importing an order.
"""
subscription = self.get_or_create_subscription(db, vendor_id)
subscription.increment_order_count()
db.commit()
def reset_period_counters(self, db: Session, vendor_id: int) -> None:
"""Reset counters for a new billing period."""
subscription = self.get_subscription_or_raise(db, vendor_id)
subscription.reset_period_counters()
db.commit()
logger.info(f"Reset period counters for vendor {vendor_id}")
# =========================================================================
# Limit Checks
# =========================================================================
def can_create_order(
self, db: Session, vendor_id: int
) -> tuple[bool, str | None]:
"""
Check if vendor can create/import another order.
Returns: (allowed, error_message)
"""
subscription = self.get_or_create_subscription(db, vendor_id)
return subscription.can_create_order()
def check_order_limit(self, db: Session, vendor_id: int) -> None:
"""
Check order limit and raise exception if exceeded.
Use this in order creation flows.
"""
can_create, message = self.can_create_order(db, vendor_id)
if not can_create:
subscription = self.get_subscription(db, vendor_id)
raise TierLimitExceededException(
message=message or "Order limit exceeded",
limit_type="orders",
current=subscription.orders_this_period if subscription else 0,
limit=subscription.orders_limit if subscription else 0,
)
def can_add_product(
self, db: Session, vendor_id: int
) -> tuple[bool, str | None]:
"""
Check if vendor can add another product.
Returns: (allowed, error_message)
"""
subscription = self.get_or_create_subscription(db, vendor_id)
products_count = (
db.query(func.count(Product.id))
.filter(Product.vendor_id == vendor_id)
.scalar()
or 0
)
return subscription.can_add_product(products_count)
def check_product_limit(self, db: Session, vendor_id: int) -> None:
"""
Check product limit and raise exception if exceeded.
Use this in product creation flows.
"""
can_add, message = self.can_add_product(db, vendor_id)
if not can_add:
subscription = self.get_subscription(db, vendor_id)
products_count = (
db.query(func.count(Product.id))
.filter(Product.vendor_id == vendor_id)
.scalar()
or 0
)
raise TierLimitExceededException(
message=message or "Product limit exceeded",
limit_type="products",
current=products_count,
limit=subscription.products_limit if subscription else 0,
)
def can_add_team_member(
self, db: Session, vendor_id: int
) -> tuple[bool, str | None]:
"""
Check if vendor can add another team member.
Returns: (allowed, error_message)
"""
subscription = self.get_or_create_subscription(db, vendor_id)
team_count = (
db.query(func.count(VendorUser.id))
.filter(VendorUser.vendor_id == vendor_id, VendorUser.is_active == True)
.scalar()
or 0
)
return subscription.can_add_team_member(team_count)
def check_team_limit(self, db: Session, vendor_id: int) -> None:
"""
Check team member limit and raise exception if exceeded.
Use this in team member invitation flows.
"""
can_add, message = self.can_add_team_member(db, vendor_id)
if not can_add:
subscription = self.get_subscription(db, vendor_id)
team_count = (
db.query(func.count(VendorUser.id))
.filter(VendorUser.vendor_id == vendor_id, VendorUser.is_active == True)
.scalar()
or 0
)
raise TierLimitExceededException(
message=message or "Team member limit exceeded",
limit_type="team_members",
current=team_count,
limit=subscription.team_members_limit if subscription else 0,
)
# =========================================================================
# Feature Gating
# =========================================================================
def has_feature(self, db: Session, vendor_id: int, feature: str) -> bool:
"""Check if vendor has access to a feature."""
subscription = self.get_or_create_subscription(db, vendor_id)
return subscription.has_feature(feature)
def check_feature(self, db: Session, vendor_id: int, feature: str) -> None:
"""
Check feature access and raise exception if not available.
Use this to gate premium features.
"""
if not self.has_feature(db, vendor_id, feature):
subscription = self.get_or_create_subscription(db, vendor_id)
# Find which tier has this feature
required_tier = None
for tier_code, limits in TIER_LIMITS.items():
if feature in limits.get("features", []):
required_tier = limits["name"]
break
raise FeatureNotAvailableException(
feature=feature,
current_tier=subscription.tier,
required_tier=required_tier or "higher",
)
def get_feature_tier(self, feature: str) -> str | None:
"""Get the minimum tier required for a feature."""
for tier_code in [
TierCode.ESSENTIAL,
TierCode.PROFESSIONAL,
TierCode.BUSINESS,
TierCode.ENTERPRISE,
]:
if feature in TIER_LIMITS[tier_code].get("features", []):
return tier_code.value
return None
# Singleton instance
subscription_service = SubscriptionService()

View File

@@ -0,0 +1,470 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Invoice {{ invoice.invoice_number }}</title>
<style>
/* Reset and base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 10pt;
line-height: 1.4;
color: #333;
background: #fff;
}
/* Page setup for A4 */
@page {
size: A4;
margin: 20mm 15mm 20mm 15mm;
}
.invoice-container {
max-width: 100%;
padding: 0;
}
/* Header */
.header {
display: flex;
justify-content: space-between;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #2563eb;
}
.company-info {
max-width: 50%;
}
.company-name {
font-size: 18pt;
font-weight: bold;
color: #1e40af;
margin-bottom: 8px;
}
.company-details {
font-size: 9pt;
color: #666;
line-height: 1.5;
}
.invoice-title {
text-align: right;
}
.invoice-title h1 {
font-size: 24pt;
color: #1e40af;
margin-bottom: 10px;
}
.invoice-meta {
font-size: 10pt;
color: #666;
}
.invoice-meta strong {
color: #333;
}
/* Addresses section */
.addresses {
display: flex;
justify-content: space-between;
margin-bottom: 30px;
gap: 40px;
}
.address-block {
flex: 1;
}
.address-label {
font-size: 8pt;
text-transform: uppercase;
letter-spacing: 1px;
color: #888;
margin-bottom: 8px;
font-weight: 600;
}
.address-content {
background: #f8fafc;
padding: 15px;
border-radius: 6px;
border-left: 3px solid #2563eb;
}
.address-name {
font-weight: bold;
font-size: 11pt;
margin-bottom: 5px;
}
.address-details {
font-size: 9pt;
color: #555;
line-height: 1.6;
}
/* VAT info badge */
.vat-badge {
display: inline-block;
background: #dbeafe;
color: #1e40af;
padding: 3px 8px;
border-radius: 4px;
font-size: 8pt;
margin-top: 8px;
}
/* Items table */
.items-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 30px;
}
.items-table thead {
background: #1e40af;
color: #fff;
}
.items-table th {
padding: 12px 10px;
text-align: left;
font-size: 9pt;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.items-table th.number {
text-align: right;
}
.items-table td {
padding: 12px 10px;
border-bottom: 1px solid #e5e7eb;
font-size: 9pt;
}
.items-table td.number {
text-align: right;
font-family: 'Courier New', monospace;
}
.items-table tbody tr:hover {
background: #f8fafc;
}
.item-sku {
color: #888;
font-size: 8pt;
display: block;
margin-top: 2px;
}
/* Totals */
.totals-section {
display: flex;
justify-content: flex-end;
margin-bottom: 30px;
}
.totals-table {
width: 280px;
}
.totals-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #e5e7eb;
}
.totals-row.total {
border-bottom: none;
border-top: 2px solid #1e40af;
margin-top: 5px;
padding-top: 12px;
}
.totals-label {
color: #666;
font-size: 9pt;
}
.totals-value {
font-family: 'Courier New', monospace;
font-size: 10pt;
}
.totals-row.total .totals-label,
.totals-row.total .totals-value {
font-weight: bold;
font-size: 12pt;
color: #1e40af;
}
/* VAT regime note */
.vat-note {
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 6px;
padding: 10px 15px;
font-size: 8pt;
color: #92400e;
margin-bottom: 20px;
}
/* Payment info */
.payment-section {
background: #f0fdf4;
border: 1px solid #22c55e;
border-radius: 6px;
padding: 15px;
margin-bottom: 20px;
}
.payment-title {
font-weight: bold;
color: #166534;
margin-bottom: 10px;
font-size: 10pt;
}
.payment-details {
font-size: 9pt;
color: #333;
}
.payment-details .label {
color: #666;
display: inline-block;
width: 60px;
}
/* Footer */
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
font-size: 8pt;
color: #888;
text-align: center;
}
.footer p {
margin-bottom: 5px;
}
/* Status badge */
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-size: 9pt;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-draft {
background: #fef3c7;
color: #92400e;
}
.status-issued {
background: #dbeafe;
color: #1e40af;
}
.status-paid {
background: #dcfce7;
color: #166534;
}
.status-cancelled {
background: #fee2e2;
color: #991b1b;
}
/* Print styles */
@media print {
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
</style>
</head>
<body>
<div class="invoice-container">
<!-- Header -->
<div class="header">
<div class="company-info">
<div class="company-name">{{ seller.company_name }}</div>
<div class="company-details">
{% if seller.address %}{{ seller.address }}<br>{% endif %}
{% if seller.postal_code or seller.city %}
{{ seller.postal_code }} {{ seller.city }}<br>
{% endif %}
{{ seller.country }}
{% if seller.vat_number %}
<br>VAT: {{ seller.vat_number }}
{% endif %}
</div>
</div>
<div class="invoice-title">
<h1>INVOICE</h1>
<div class="invoice-meta">
<strong>{{ invoice.invoice_number }}</strong><br>
Date: {{ invoice.invoice_date.strftime('%d/%m/%Y') }}<br>
<span class="status-badge status-{{ invoice.status }}">{{ invoice.status }}</span>
</div>
</div>
</div>
<!-- Addresses -->
<div class="addresses">
<div class="address-block">
<div class="address-label">Bill To</div>
<div class="address-content">
<div class="address-name">{{ buyer.name }}</div>
<div class="address-details">
{% if buyer.get('company') %}{{ buyer.company }}<br>{% endif %}
{% if buyer.address %}{{ buyer.address }}<br>{% endif %}
{% if buyer.postal_code or buyer.city %}
{{ buyer.postal_code }} {{ buyer.city }}<br>
{% endif %}
{{ buyer.country }}
{% if buyer.email %}<br>{{ buyer.email }}{% endif %}
</div>
{% if buyer.vat_number %}
<div class="vat-badge">VAT: {{ buyer.vat_number }}</div>
{% endif %}
</div>
</div>
{% if invoice.order_id %}
<div class="address-block">
<div class="address-label">Order Reference</div>
<div class="address-content">
<div class="address-details">
Order #{{ invoice.order_id }}<br>
Currency: {{ invoice.currency }}
</div>
</div>
</div>
{% endif %}
</div>
<!-- VAT Regime Note -->
{% if invoice.vat_regime == 'reverse_charge' %}
<div class="vat-note">
<strong>Reverse Charge:</strong> VAT to be accounted for by the recipient pursuant to Article 196 of Council Directive 2006/112/EC.
</div>
{% elif invoice.vat_regime == 'oss' %}
<div class="vat-note">
<strong>OSS Invoice:</strong> VAT charged at {{ invoice.vat_rate }}% ({{ invoice.destination_country }} rate) under One-Stop-Shop scheme.
</div>
{% elif invoice.vat_regime == 'exempt' %}
<div class="vat-note">
<strong>VAT Exempt:</strong> Export outside EU - VAT not applicable.
</div>
{% endif %}
<!-- Items Table -->
<table class="items-table">
<thead>
<tr>
<th style="width: 50%">Description</th>
<th class="number" style="width: 10%">Qty</th>
<th class="number" style="width: 20%">Unit Price</th>
<th class="number" style="width: 20%">Total</th>
</tr>
</thead>
<tbody>
{% for item in line_items %}
<tr>
<td>
{{ item.description }}
{% if item.sku or item.ean %}
<span class="item-sku">
{% if item.sku %}SKU: {{ item.sku }}{% endif %}
{% if item.sku and item.ean %} | {% endif %}
{% if item.ean %}EAN: {{ item.ean }}{% endif %}
</span>
{% endif %}
</td>
<td class="number">{{ item.quantity }}</td>
<td class="number">{{ "%.2f"|format(item.unit_price_cents / 100) }} {{ invoice.currency }}</td>
<td class="number">{{ "%.2f"|format(item.total_cents / 100) }} {{ invoice.currency }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Totals -->
<div class="totals-section">
<div class="totals-table">
<div class="totals-row">
<span class="totals-label">Subtotal</span>
<span class="totals-value">{{ "%.2f"|format(invoice.subtotal_cents / 100) }} {{ invoice.currency }}</span>
</div>
<div class="totals-row">
<span class="totals-label">
VAT ({{ invoice.vat_rate }}%)
{% if invoice.vat_rate_label %}<br><small>{{ invoice.vat_rate_label }}</small>{% endif %}
</span>
<span class="totals-value">{{ "%.2f"|format(invoice.vat_amount_cents / 100) }} {{ invoice.currency }}</span>
</div>
<div class="totals-row total">
<span class="totals-label">Total</span>
<span class="totals-value">{{ "%.2f"|format(invoice.total_cents / 100) }} {{ invoice.currency }}</span>
</div>
</div>
</div>
<!-- Payment Information -->
{% if bank_details or payment_terms %}
<div class="payment-section">
<div class="payment-title">Payment Information</div>
<div class="payment-details">
{% if payment_terms %}
<p style="margin-bottom: 10px;">{{ payment_terms }}</p>
{% endif %}
{% if bank_details %}
{% if bank_details.bank_name %}
<span class="label">Bank:</span> {{ bank_details.bank_name }}<br>
{% endif %}
{% if bank_details.iban %}
<span class="label">IBAN:</span> {{ bank_details.iban }}<br>
{% endif %}
{% if bank_details.bic %}
<span class="label">BIC:</span> {{ bank_details.bic }}
{% endif %}
{% endif %}
</div>
</div>
{% endif %}
<!-- Footer -->
<div class="footer">
{% if footer_text %}
<p>{{ footer_text }}</p>
{% endif %}
<p>Invoice {{ invoice.invoice_number }} | Generated on {{ now.strftime('%d/%m/%Y %H:%M') }}</p>
</div>
</div>
</body>
</html>

View File

@@ -1,112 +1,127 @@
{# app/templates/platform/homepage-modern.html #} {# app/templates/platform/homepage-modern.html #}
{# Modern/trendy platform homepage template with animations #} {# Wizamart OMS - Luxembourg-focused homepage inspired by Veeqo #}
{% extends "platform/base.html" %} {% extends "platform/base.html" %}
{% block title %} {% block title %}
{% if page %}{{ page.title }}{% else %}Home{% endif %} - Marketplace Wizamart - The Back-Office for Letzshop Sellers
{% endblock %} {% endblock %}
{% block extra_head %} {% block extra_head %}
<style> <style>
.gradient-lu {
background: linear-gradient(135deg, #00A1DE 0%, #EF3340 100%);
}
.gradient-lu-subtle {
background: linear-gradient(135deg, #f0f9ff 0%, #fef2f2 100%);
}
@keyframes float { @keyframes float {
0%, 100% { transform: translateY(0px); } 0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-20px); } 50% { transform: translateY(-10px); }
} }
.float-animation { .float-animation {
animation: float 6s ease-in-out infinite; animation: float 4s ease-in-out infinite;
} }
.feature-card:hover {
@keyframes gradient { transform: translateY(-4px);
0% { background-position: 0% 50%; } box-shadow: 0 20px 40px rgba(0,0,0,0.1);
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.animated-gradient {
background: linear-gradient(270deg, #6366f1, #8b5cf6, #ec4899, #f43f5e);
background-size: 400% 400%;
animation: gradient 15s ease infinite;
} }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<!-- ═══════════════════════════════════════════════════════════════ --> <!-- ═══════════════════════════════════════════════════════════════ -->
<!-- MODERN HERO WITH GRADIENT --> <!-- HERO - The Back-Office Letzshop Doesn't Give You -->
<!-- ═══════════════════════════════════════════════════════════════ --> <!-- ═══════════════════════════════════════════════════════════════ -->
<section class="relative overflow-hidden animated-gradient text-white py-24 md:py-32"> <section class="relative overflow-hidden bg-gray-900 text-white py-20 md:py-28">
{# Decorative elements #} {# Background pattern #}
<div class="absolute top-0 right-0 w-1/3 h-full opacity-20"> <div class="absolute inset-0 opacity-10">
<div class="absolute top-20 right-20 w-72 h-72 rounded-full bg-white blur-3xl"></div> <div class="absolute top-0 left-0 w-full h-full" style="background-image: url('data:image/svg+xml,%3Csvg width=\"60\" height=\"60\" viewBox=\"0 0 60 60\" xmlns=\"http://www.w3.org/2000/svg\"%3E%3Cg fill=\"none\" fill-rule=\"evenodd\"%3E%3Cg fill=\"%23ffffff\" fill-opacity=\"0.4\"%3E%3Cpath d=\"M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z\"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E');"></div>
<div class="absolute bottom-20 right-40 w-96 h-96 rounded-full bg-white blur-3xl"></div>
</div> </div>
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
{# Left column - Content #} {# Left column - Content #}
<div> <div>
{% if page %} <div class="inline-flex items-center px-4 py-2 bg-blue-500/20 backdrop-blur-sm rounded-full text-sm font-medium mb-6 border border-blue-400/30">
<h1 class="text-5xl md:text-7xl font-black mb-6 leading-tight"> <span class="mr-2">🇱🇺</span> Built for Luxembourg E-Commerce
{{ page.title }} </div>
</h1>
<div class="text-xl md:text-2xl mb-8 opacity-95">
{{ page.content | safe }}
</div>
{% else %}
<div class="inline-block px-4 py-2 bg-white/20 backdrop-blur-sm rounded-full text-sm font-semibold mb-6">
✨ The Future of E-Commerce
</div>
<h1 class="text-5xl md:text-7xl font-black mb-6 leading-tight">
Build Your<br>
<span class="text-transparent bg-clip-text bg-white">
Dream Store
</span>
</h1>
<p class="text-xl md:text-2xl mb-8 opacity-95">
Launch a stunning online marketplace in minutes. No coding required. Scale effortlessly.
</p>
{% endif %}
<div class="flex flex-col sm:flex-row gap-4"> <h1 class="text-4xl md:text-5xl lg:text-6xl font-bold mb-6 leading-tight">
The Back-Office<br>
<span class="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-cyan-400">
Letzshop Doesn't Give You
</span>
</h1>
<p class="text-xl md:text-2xl text-gray-300 mb-8 leading-relaxed">
Sync orders, manage inventory, generate invoices with correct VAT, and own your customer data. All in one place.
</p>
<div class="flex flex-col sm:flex-row gap-4 mb-8">
<a href="/contact" <a href="/contact"
class="inline-flex items-center justify-center bg-white text-gray-900 px-8 py-4 rounded-xl font-bold hover:shadow-2xl transform hover:scale-105 transition-all duration-200"> class="inline-flex items-center justify-center bg-blue-500 hover:bg-blue-600 text-white px-8 py-4 rounded-xl font-bold transition-all duration-200 shadow-lg hover:shadow-xl">
<span>Start Free Trial</span> <span>Start 14-Day Free Trial</span>
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
</svg> </svg>
</a> </a>
<a href="#features" <a href="#how-it-works"
class="inline-flex items-center justify-center border-2 border-white text-white px-8 py-4 rounded-xl font-bold hover:bg-white/10 backdrop-blur-sm transition-all duration-200"> class="inline-flex items-center justify-center border-2 border-gray-600 text-white px-8 py-4 rounded-xl font-bold hover:bg-white/10 transition-all duration-200">
Learn More See How It Works
</a> </a>
</div> </div>
{# Stats #} <p class="text-sm text-gray-400">
<div class="grid grid-cols-3 gap-6 mt-12 pt-12 border-t border-white/20"> No credit card required. Setup in 5 minutes. Cancel anytime.
<div> </p>
<div class="text-3xl font-bold mb-1">10K+</div>
<div class="text-sm opacity-80">Active Vendors</div>
</div>
<div>
<div class="text-3xl font-bold mb-1">50M+</div>
<div class="text-sm opacity-80">Products Sold</div>
</div>
<div>
<div class="text-3xl font-bold mb-1">99.9%</div>
<div class="text-sm opacity-80">Uptime</div>
</div>
</div>
</div> </div>
{# Right column - Visual element #} {# Right column - Dashboard Preview #}
<div class="hidden lg:block float-animation"> <div class="hidden lg:block">
<div class="relative"> <div class="relative float-animation">
<div class="w-full h-96 bg-white/10 backdrop-blur-xl rounded-3xl shadow-2xl p-8"> <div class="bg-gray-800 rounded-2xl shadow-2xl border border-gray-700 overflow-hidden">
<div class="w-full h-full bg-gradient-to-br from-white/20 to-transparent rounded-2xl flex items-center justify-center"> {# Mock dashboard header #}
<div class="text-center"> <div class="bg-gray-900 px-4 py-3 flex items-center gap-2 border-b border-gray-700">
<div class="text-8xl mb-4">🚀</div> <div class="w-3 h-3 rounded-full bg-red-500"></div>
<div class="text-2xl font-bold">Launch Today</div> <div class="w-3 h-3 rounded-full bg-yellow-500"></div>
<div class="w-3 h-3 rounded-full bg-green-500"></div>
<span class="ml-4 text-gray-400 text-sm">Wizamart Dashboard</span>
</div>
{# Mock dashboard content #}
<div class="p-6 space-y-4">
<div class="grid grid-cols-3 gap-4">
<div class="bg-gray-700/50 rounded-lg p-4">
<div class="text-gray-400 text-xs mb-1">Today's Orders</div>
<div class="text-2xl font-bold text-white">24</div>
<div class="text-green-400 text-xs">+12% vs yesterday</div>
</div>
<div class="bg-gray-700/50 rounded-lg p-4">
<div class="text-gray-400 text-xs mb-1">Revenue</div>
<div class="text-2xl font-bold text-white">EUR 1,847</div>
<div class="text-green-400 text-xs">+8% vs yesterday</div>
</div>
<div class="bg-gray-700/50 rounded-lg p-4">
<div class="text-gray-400 text-xs mb-1">Low Stock</div>
<div class="text-2xl font-bold text-yellow-400">3</div>
<div class="text-gray-400 text-xs">items need restock</div>
</div>
</div>
<div class="bg-gray-700/50 rounded-lg p-4">
<div class="text-gray-400 text-xs mb-3">Recent Orders from Letzshop</div>
<div class="space-y-2">
<div class="flex justify-between items-center text-sm">
<span class="text-white">#LS-4521</span>
<span class="text-gray-400">Marie D.</span>
<span class="text-green-400">EUR 89.00</span>
<span class="bg-blue-500/20 text-blue-400 px-2 py-0.5 rounded text-xs">Confirmed</span>
</div>
<div class="flex justify-between items-center text-sm">
<span class="text-white">#LS-4520</span>
<span class="text-gray-400">Jean M.</span>
<span class="text-green-400">EUR 156.50</span>
<span class="bg-purple-500/20 text-purple-400 px-2 py-0.5 rounded text-xs">Shipped</span>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -117,141 +132,466 @@
</section> </section>
<!-- ═══════════════════════════════════════════════════════════════ --> <!-- ═══════════════════════════════════════════════════════════════ -->
<!-- FEATURES WITH CARDS --> <!-- INTEGRATION BADGE - Works with Letzshop -->
<!-- ═══════════════════════════════════════════════════════════════ --> <!-- ═══════════════════════════════════════════════════════════════ -->
<section id="features" class="py-24 bg-gray-50 dark:bg-gray-900"> <section class="py-8 bg-gray-50 dark:bg-gray-800 border-y border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex flex-col md:flex-row items-center justify-center gap-6 md:gap-12">
<span class="text-gray-500 dark:text-gray-400 font-medium">Official Integration</span>
<div class="flex items-center gap-3 bg-white dark:bg-gray-700 px-6 py-3 rounded-xl shadow-sm">
<span class="text-2xl">🛒</span>
<span class="font-bold text-gray-900 dark:text-white">Letzshop.lu</span>
</div>
<span class="text-gray-500 dark:text-gray-400 text-sm">Connect in 2 minutes</span>
</div>
</div>
</section>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- THE PROBLEM - Pain Points -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<section class="py-20 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16"> <div class="text-center mb-16">
<div class="inline-block px-4 py-2 bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded-full text-sm font-semibold mb-4"> <h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
✨ Features Sound Familiar?
</div>
<h2 class="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white mb-4">
Everything You Need
</h2> </h2>
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto"> <p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
Powerful features to help you succeed in the digital marketplace These are the daily frustrations of Letzshop sellers
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6">
<div class="text-3xl mb-4">📋</div>
<h3 class="font-bold text-gray-900 dark:text-white mb-2">Manual Order Entry</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm">Copy-pasting orders from Letzshop to spreadsheets. Every. Single. Day.</p>
</div>
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6">
<div class="text-3xl mb-4">📦</div>
<h3 class="font-bold text-gray-900 dark:text-white mb-2">Inventory Chaos</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm">Stock in Letzshop doesn't match reality. Overselling happens.</p>
</div>
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6">
<div class="text-3xl mb-4">🧾</div>
<h3 class="font-bold text-gray-900 dark:text-white mb-2">Wrong VAT Invoices</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm">EU customers need correct VAT. Your accountant keeps complaining.</p>
</div>
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6">
<div class="text-3xl mb-4">👥</div>
<h3 class="font-bold text-gray-900 dark:text-white mb-2">Lost Customers</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm">Letzshop owns your customer data. You can't retarget or build loyalty.</p>
</div>
</div>
</div>
</section>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- HOW IT WORKS - 4-Step Workflow (Veeqo-style) -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<section id="how-it-works" class="py-20 gradient-lu-subtle dark:bg-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<div class="inline-block px-4 py-2 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded-full text-sm font-semibold mb-4">
How It Works
</div>
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
From Chaos to Control in 4 Steps
</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{# Step 1 #}
<div class="relative">
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg h-full">
<div class="w-12 h-12 rounded-full bg-blue-500 text-white flex items-center justify-center font-bold text-xl mb-6">1</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Connect Letzshop</h3>
<p class="text-gray-600 dark:text-gray-400">Enter your Letzshop API credentials. Done in 2 minutes, no technical skills needed.</p>
</div>
<div class="hidden lg:block absolute top-1/2 -right-4 w-8 h-0.5 bg-blue-300"></div>
</div>
{# Step 2 #}
<div class="relative">
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg h-full">
<div class="w-12 h-12 rounded-full bg-blue-500 text-white flex items-center justify-center font-bold text-xl mb-6">2</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Orders Flow In</h3>
<p class="text-gray-600 dark:text-gray-400">Orders sync automatically. Confirm and add tracking directly from Wizamart.</p>
</div>
<div class="hidden lg:block absolute top-1/2 -right-4 w-8 h-0.5 bg-blue-300"></div>
</div>
{# Step 3 #}
<div class="relative">
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg h-full">
<div class="w-12 h-12 rounded-full bg-blue-500 text-white flex items-center justify-center font-bold text-xl mb-6">3</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Generate Invoices</h3>
<p class="text-gray-600 dark:text-gray-400">One click to create compliant PDF invoices with correct VAT for any EU country.</p>
</div>
<div class="hidden lg:block absolute top-1/2 -right-4 w-8 h-0.5 bg-blue-300"></div>
</div>
{# Step 4 #}
<div class="relative">
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg h-full">
<div class="w-12 h-12 rounded-full bg-green-500 text-white flex items-center justify-center font-bold text-xl mb-6">4</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Grow Your Business</h3>
<p class="text-gray-600 dark:text-gray-400">Export customers for marketing. Track inventory. Focus on selling, not spreadsheets.</p>
</div>
</div>
</div>
</div>
</section>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- FEATURES - What You Get -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<section id="features" class="py-20 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<div class="inline-block px-4 py-2 bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 rounded-full text-sm font-semibold mb-4">
Features
</div>
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
Everything a Letzshop Seller Needs
</h2>
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
The operational tools Letzshop doesn't provide
</p> </p>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{# Feature cards with hover effects #} {# Feature 1: Order Sync #}
<div class="group bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-sm hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2"> <div class="feature-card bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 transition-all duration-300">
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform"> <div class="w-14 h-14 rounded-2xl bg-blue-500 flex items-center justify-center mb-6">
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg> </svg>
</div> </div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3"> <h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Automatic Order Sync</h3>
Blazing Fast <p class="text-gray-600 dark:text-gray-400 mb-4">Orders from Letzshop appear instantly. Confirm orders and sync tracking numbers back automatically.</p>
</h3> <ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
<p class="text-gray-600 dark:text-gray-400"> <li>Real-time sync</li>
Optimized for performance with sub-second page loads and instant search results. <li>One-click confirmation</li>
</p> <li>Tracking number sync</li>
</ul>
</div> </div>
<div class="group bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-sm hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2"> {# Feature 2: Inventory #}
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform"> <div class="feature-card bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 transition-all duration-300">
<div class="w-14 h-14 rounded-2xl bg-green-500 flex items-center justify-center mb-6">
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path>
</svg> </svg>
</div> </div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3"> <h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Real Inventory Management</h3>
Bank-Level Security <p class="text-gray-600 dark:text-gray-400 mb-4">One source of truth for all stock. Locations, reservations, and incoming stock tracking.</p>
</h3> <ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
<p class="text-gray-600 dark:text-gray-400"> <li>Product locations (bins)</li>
Enterprise-grade encryption and security measures to protect your business. <li>Stock reservations</li>
</p> <li>Low stock alerts</li>
</ul>
</div> </div>
<div class="group bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-sm hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2"> {# Feature 3: Invoicing #}
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-green-500 to-teal-500 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform"> <div class="feature-card bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 transition-all duration-300">
<div class="w-14 h-14 rounded-2xl bg-purple-500 flex items-center justify-center mb-6">
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg> </svg>
</div> </div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3"> <h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Smart VAT Invoicing</h3>
Fully Customizable <p class="text-gray-600 dark:text-gray-400 mb-4">Generate PDF invoices with correct VAT rates. Luxembourg, EU countries, B2B reverse charge.</p>
</h3> <ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
<p class="text-gray-600 dark:text-gray-400"> <li>Luxembourg 17% VAT</li>
Brand your store with custom themes, colors, fonts, and layouts. <li>EU destination VAT (OSS)</li>
</p> <li>B2B reverse charge</li>
</ul>
</div> </div>
<div class="group bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-sm hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2"> {# Feature 4: Customers #}
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-orange-500 to-red-500 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform"> <div class="feature-card bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 transition-all duration-300">
<div class="w-14 h-14 rounded-2xl bg-orange-500 flex items-center justify-center mb-6">
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
</svg> </svg>
</div> </div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3"> <h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Own Your Customers</h3>
Analytics & Insights <p class="text-gray-600 dark:text-gray-400 mb-4">All customer data in your database. Export to Mailchimp for marketing campaigns.</p>
</h3> <ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
<p class="text-gray-600 dark:text-gray-400"> <li>Order history per customer</li>
Powerful analytics to track sales, customer behavior, and growth metrics. <li>Lifetime value tracking</li>
</p> <li>CSV export for marketing</li>
</ul>
</div> </div>
<div class="group bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-sm hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2"> {# Feature 5: Team #}
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform"> <div class="feature-card bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 transition-all duration-300">
<div class="w-14 h-14 rounded-2xl bg-cyan-500 flex items-center justify-center mb-6">
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path>
</svg> </svg>
</div> </div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3"> <h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Team Management</h3>
Mobile-First Design <p class="text-gray-600 dark:text-gray-400 mb-4">Invite team members with role-based permissions. Everyone works from one dashboard.</p>
</h3> <ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
<p class="text-gray-600 dark:text-gray-400"> <li>Multiple users</li>
Beautiful, responsive design that works perfectly on all devices. <li>Role-based access</li>
</p> <li>Activity logging</li>
</ul>
</div> </div>
<div class="group bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-sm hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2"> {# Feature 6: Purchase Orders #}
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-pink-500 to-rose-500 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform"> <div class="feature-card bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 transition-all duration-300">
<div class="w-14 h-14 rounded-2xl bg-pink-500 flex items-center justify-center mb-6">
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192l-3.536 3.536M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"></path>
</svg> </svg>
</div> </div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3"> <h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Purchase Orders</h3>
24/7 Support <p class="text-gray-600 dark:text-gray-400 mb-4">Track incoming stock from suppliers. Know what's on order and when it arrives.</p>
</h3> <ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
<p class="text-gray-600 dark:text-gray-400"> <li>Track supplier orders</li>
Round-the-clock customer support to help you succeed at every step. <li>Expected arrival dates</li>
</p> <li>Receive and update stock</li>
</ul>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<!-- ═══════════════════════════════════════════════════════════════ --> <!-- ═══════════════════════════════════════════════════════════════ -->
<!-- MODERN CTA WITH GRADIENT --> <!-- PRICING - 4 Tiers -->
<!-- ═══════════════════════════════════════════════════════════════ --> <!-- ═══════════════════════════════════════════════════════════════ -->
<section class="py-24 relative overflow-hidden"> <section id="pricing" class="py-20 bg-gray-50 dark:bg-gray-800">
<div class="absolute inset-0 gradient-accent opacity-90"></div> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<div class="inline-block px-4 py-2 bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded-full text-sm font-semibold mb-4">
Pricing
</div>
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
Simple, Transparent Pricing
</h2>
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
No per-order fees. No hidden costs. Flat monthly rate.
</p>
</div>
<div class="relative max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-white"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<h2 class="text-4xl md:text-5xl font-bold mb-6"> {# Essential #}
Start Your Journey Today <div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-sm border border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2">Essential</h3>
<p class="text-gray-500 dark:text-gray-400 text-sm mb-4">For solo vendors getting started</p>
<div class="mb-6">
<span class="text-4xl font-bold text-gray-900 dark:text-white">EUR 49</span>
<span class="text-gray-500 dark:text-gray-400">/month</span>
</div>
<ul class="space-y-3 mb-8 text-sm">
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
100 orders/month
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
200 products
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Luxembourg VAT invoices
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
1 team member
</li>
</ul>
<a href="/contact" class="block w-full text-center bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white px-6 py-3 rounded-xl font-semibold hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
Start Free Trial
</a>
</div>
{# Professional - Highlighted #}
<div class="bg-blue-600 rounded-2xl p-8 shadow-xl relative transform lg:scale-105">
<div class="absolute -top-4 left-1/2 -translate-x-1/2 bg-orange-500 text-white text-xs font-bold px-3 py-1 rounded-full">
MOST POPULAR
</div>
<h3 class="text-lg font-bold text-white mb-2">Professional</h3>
<p class="text-blue-200 text-sm mb-4">For growing multi-channel sellers</p>
<div class="mb-6">
<span class="text-4xl font-bold text-white">EUR 99</span>
<span class="text-blue-200">/month</span>
</div>
<ul class="space-y-3 mb-8 text-sm">
<li class="flex items-center text-blue-100">
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
500 orders/month
</li>
<li class="flex items-center text-blue-100">
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Unlimited products
</li>
<li class="flex items-center text-blue-100">
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
<strong>EU VAT invoices</strong>
</li>
<li class="flex items-center text-blue-100">
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Product locations
</li>
<li class="flex items-center text-blue-100">
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Purchase orders
</li>
<li class="flex items-center text-blue-100">
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Customer export
</li>
<li class="flex items-center text-blue-100">
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
3 team members
</li>
</ul>
<a href="/contact" class="block w-full text-center bg-white text-blue-600 px-6 py-3 rounded-xl font-bold hover:bg-blue-50 transition-colors">
Start Free Trial
</a>
</div>
{# Business #}
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-sm border border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2">Business</h3>
<p class="text-gray-500 dark:text-gray-400 text-sm mb-4">For high-volume operations</p>
<div class="mb-6">
<span class="text-4xl font-bold text-gray-900 dark:text-white">EUR 199</span>
<span class="text-gray-500 dark:text-gray-400">/month</span>
</div>
<ul class="space-y-3 mb-8 text-sm">
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
2,000 orders/month
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Everything in Professional
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
<strong>Analytics dashboard</strong>
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
<strong>API access</strong>
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Accounting export
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
10 team members
</li>
</ul>
<a href="/contact" class="block w-full text-center bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white px-6 py-3 rounded-xl font-semibold hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
Start Free Trial
</a>
</div>
{# Enterprise #}
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-sm border border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2">Enterprise</h3>
<p class="text-gray-500 dark:text-gray-400 text-sm mb-4">For large operations & agencies</p>
<div class="mb-6">
<span class="text-4xl font-bold text-gray-900 dark:text-white">EUR 399+</span>
<span class="text-gray-500 dark:text-gray-400">/month</span>
</div>
<ul class="space-y-3 mb-8 text-sm">
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Unlimited orders
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Everything in Business
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
<strong>White-label option</strong>
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Custom integrations
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
99.9% SLA
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Dedicated support
</li>
</ul>
<a href="/contact" class="block w-full text-center bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white px-6 py-3 rounded-xl font-semibold hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
Contact Sales
</a>
</div>
</div>
<p class="text-center text-gray-500 dark:text-gray-400 mt-8">
All plans include a 14-day free trial. No credit card required.
</p>
</div>
</section>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- TESTIMONIAL / SOCIAL PROOF -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<section class="py-20 bg-white dark:bg-gray-900">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<div class="inline-block px-4 py-2 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-600 dark:text-yellow-400 rounded-full text-sm font-semibold mb-8">
Built for Luxembourg
</div>
<blockquote class="text-2xl md:text-3xl font-medium text-gray-900 dark:text-white mb-8 leading-relaxed">
"Finally, a tool that understands what Letzshop sellers actually need. No more spreadsheets, no more VAT headaches."
</blockquote>
<div class="flex items-center justify-center gap-4">
<div class="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center text-xl">
👩
</div>
<div class="text-left">
<div class="font-semibold text-gray-900 dark:text-white">Marie L.</div>
<div class="text-gray-500 dark:text-gray-400 text-sm">Letzshop Vendor, Luxembourg City</div>
</div>
</div>
</div>
</section>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- FINAL CTA -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<section class="py-20 bg-gray-900 text-white">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-3xl md:text-4xl font-bold mb-6">
Ready to Take Control of Your Letzshop Business?
</h2> </h2>
<p class="text-xl mb-10 opacity-90"> <p class="text-xl text-gray-300 mb-10">
Join thousands of successful vendors on our platform Join Luxembourg vendors who've stopped fighting spreadsheets and started growing their business.
</p> </p>
<div class="flex flex-col sm:flex-row gap-4 justify-center"> <div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/contact" <a href="/contact"
class="inline-flex items-center justify-center bg-white text-gray-900 px-8 py-4 rounded-xl font-bold hover:shadow-2xl transform hover:scale-105 transition-all duration-200"> class="inline-flex items-center justify-center bg-blue-500 hover:bg-blue-600 text-white px-8 py-4 rounded-xl font-bold transition-all duration-200 shadow-lg">
<span>Get Started Free</span> <span>Start Your 14-Day Free Trial</span>
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
</svg> </svg>
</a> </a>
<a href="/about"
class="inline-flex items-center justify-center border-2 border-white text-white px-8 py-4 rounded-xl font-bold hover:bg-white/10 backdrop-blur-sm transition-all duration-200">
Learn More About Us
</a>
</div> </div>
<p class="mt-8 text-sm opacity-75"> <p class="mt-8 text-sm text-gray-400">
No credit card required · Free 14-day trial · Cancel anytime No credit card required. Setup in 5 minutes. Full Professional features during trial.
</p> </p>
</div> </div>
</section> </section>

View File

@@ -4,7 +4,7 @@
**"Lightweight OMS for Letzshop Sellers"** **"Lightweight OMS for Letzshop Sellers"**
A focused Order Management System that works alongside Letzshop, not instead of it. Provides the operational tools Letzshop lacks: real inventory, correct invoicing, customer ownership. A focused Order Management System built specifically for Luxembourg e-commerce. Works alongside Letzshop, not instead of it. Provides the operational tools Letzshop lacks: real inventory, correct invoicing, customer ownership.
--- ---
@@ -79,80 +79,104 @@ A focused Order Management System that works alongside Letzshop, not instead of
**Upgrade Triggers:** **Upgrade Triggers:**
- Exceeds 500 orders/month - Exceeds 500 orders/month
- Multiple Letzshop vendor accounts - Needs analytics and reporting
- Needs API for integrations
- Team grows beyond 3 - Team grows beyond 3
- Accountant needs structured exports
--- ---
### Business - EUR 199/month ### Business - EUR 199/month
**Target:** High-volume vendors, teams, multiple operations **Target:** High-volume vendors, teams, data-driven operations
| Feature | Limit | | Feature | Limit |
|---------|-------| |---------|-------|
| Letzshop Order Sync | Unlimited orders | | Letzshop Order Sync | Up to 2,000 orders/month |
| Everything in Professional | Included | | Everything in Professional | Included |
| **Multi-Vendor Support** | Multiple Letzshop accounts | | **Analytics Dashboard** | Sales trends, top products, customer insights |
| **API Access** | For custom integrations |
| **Accounting Export** | CSV/XML for accounting software | | **Accounting Export** | CSV/XML for accounting software |
| **Bulk Operations** | Mass product updates, bulk invoicing |
| **Advanced Reporting** | Custom date ranges, exportable reports |
| **API Access** | For custom integrations |
| **Automation Rules** | Auto-confirm orders, low-stock alerts |
| Team Members | 10 users | | Team Members | 10 users |
| Team Roles & Permissions | Full access control | | Team Roles & Permissions | Full access control |
| Order History | Unlimited | | Order History | Unlimited |
| Phone Support | Available | | Priority Support | 4h response, phone available |
| Dedicated Onboarding | 2-hour session included |
**Value Proposition:** "Enterprise-grade OMS at SMB pricing." **Value Proposition:** "Scale with confidence. Analytics, automation, and full control."
**Upgrade Triggers:**
- Exceeds 2,000 orders/month
- Needs white-label or custom integrations
- Requires SLA guarantee
- Multiple brands/operations
--- ---
### Enterprise - Custom Pricing ### Enterprise - EUR 399+/month (Custom)
**Target:** Large operations, special requirements **Target:** Large operations, special requirements, agencies
| Feature | Notes | | Feature | Notes |
|---------|-------| |---------|-------|
| Everything in Business | Included | | Everything in Business | Included |
| Unlimited Orders | No volume limits |
| Unlimited Team Members | As needed | | Unlimited Team Members | As needed |
| Custom Integrations | Built to spec | | **White-Label Option** | Remove Wizamart branding |
| SLA Guarantee | 99.9% uptime | | **Custom Integrations** | Connect to ERP, accounting software |
| Dedicated Account Manager | Named contact | | **Multi-Vendor Support** | Multiple Letzshop accounts under one dashboard |
| On-Premise Option | If required | | **SLA Guarantee** | 99.9% uptime commitment |
| **Dedicated Account Manager** | Named contact |
| **On-Premise Option** | For security-sensitive clients |
| **Custom Development** | Bespoke features on request |
**Starting at EUR 499/month** - Contact for quote **Starting at EUR 399/month** - Contact for quote
--- ---
## Feature Comparison Matrix ## Feature Comparison Matrix
| Feature | Essential | Professional | Business | | Feature | Essential | Professional | Business | Enterprise |
|---------|:---------:|:------------:|:--------:| |---------|:---------:|:------------:|:--------:|:----------:|
| **Orders** | | **Orders** |
| Letzshop Sync | 100/mo | 500/mo | Unlimited | | Letzshop Sync | 100/mo | 500/mo | 2,000/mo | Unlimited |
| Order Confirmation | ✓ | ✓ | ✓ | | Order Confirmation | Yes | Yes | Yes | Yes |
| Tracking Sync | ✓ | ✓ | ✓ | | Tracking Sync | Yes | Yes | Yes | Yes |
| Order History | 6 months | 24 months | Unlimited | | Order History | 6 months | 24 months | Unlimited | Unlimited |
| **Inventory** | | **Inventory** |
| Product Limit | 200 | Unlimited | Unlimited | | Product Limit | 200 | Unlimited | Unlimited | Unlimited |
| Stock Levels | ✓ | ✓ | ✓ | | Stock Levels | Yes | Yes | Yes | Yes |
| Product Locations | - | ✓ | ✓ | | Product Locations | - | Yes | Yes | Yes |
| Incoming Stock / PO | - | ✓ | ✓ | | Incoming Stock / PO | - | Yes | Yes | Yes |
| **Invoicing** | | **Invoicing** |
| Luxembourg Invoice | ✓ | ✓ | ✓ | | Luxembourg Invoice | Yes | Yes | Yes | Yes |
| EU VAT Invoice | - | ✓ | ✓ | | EU VAT Invoice | - | Yes | Yes | Yes |
| Accounting Export | - | - | ✓ | | Bulk Invoicing | - | - | Yes | Yes |
| Accounting Export | - | - | Yes | Yes |
| **Customers** | | **Customers** |
| Customer List | View | View + Export | View + Export | | Customer List | View | View + Export | View + Export | View + Export |
| **Analytics & Reporting** |
| Basic Stats | Yes | Yes | Yes | Yes |
| Analytics Dashboard | - | - | Yes | Yes |
| Advanced Reporting | - | - | Yes | Yes |
| Custom Reports | - | - | - | Yes |
| **Automation** |
| Low Stock Alerts | - | - | Yes | Yes |
| Auto-Confirm Orders | - | - | Yes | Yes |
| Custom Automation | - | - | - | Yes |
| **Team** | | **Team** |
| Users | 1 | 3 | 10 | | Users | 1 | 3 | 10 | Unlimited |
| Roles & Permissions | - | - | | | Roles & Permissions | - | - | Yes | Yes |
| **Operations** | | **Integration** |
| Multi-Vendor | - | - | | | API Access | - | - | Yes | Yes |
| API Access | - | - | | | Custom Integrations | - | - | - | Yes |
| White-Label | - | - | - | Yes |
| **Support** | | **Support** |
| Email | 72h | 24h | 24h | | Email | 72h | 24h | 4h | 4h |
| Phone | - | - | | | Phone | - | - | Yes | Yes |
| Onboarding | - | - | Included | | Dedicated Manager | - | - | - | Yes |
| SLA | - | - | - | 99.9% |
--- ---
@@ -173,6 +197,7 @@ A focused Order Management System that works alongside Letzshop, not instead of
| Essential | EUR 49/mo | EUR 490/year (EUR 41/mo) | | Essential | EUR 49/mo | EUR 490/year (EUR 41/mo) |
| Professional | EUR 99/mo | EUR 990/year (EUR 82/mo) | | Professional | EUR 99/mo | EUR 990/year (EUR 82/mo) |
| Business | EUR 199/mo | EUR 1,990/year (EUR 166/mo) | | Business | EUR 199/mo | EUR 1,990/year (EUR 166/mo) |
| Enterprise | Custom | Contact for annual pricing |
--- ---
@@ -191,8 +216,8 @@ A focused Order Management System that works alongside Letzshop, not instead of
| Add-On | Price | Description | | Add-On | Price | Description |
|--------|-------|-------------| |--------|-------|-------------|
| Additional Team Member | +EUR 9/user/mo | Beyond plan limit | | Additional Team Member | +EUR 9/user/mo | Beyond plan limit |
| Additional Vendor Account | +EUR 29/mo | For Professional tier | | Extended History | +EUR 19/mo | Unlimited history for Essential/Professional |
| Extended History | +EUR 19/mo | Unlimited history for Essential | | Extra Order Capacity | +EUR 29/100 orders | Beyond plan limit |
--- ---
@@ -210,23 +235,43 @@ A focused Order Management System that works alongside Letzshop, not instead of
- Most active vendors will need this - Most active vendors will need this
- Price feels reasonable for operational software - Price feels reasonable for operational software
3. **Business (EUR 199) provides anchor** 3. **Business (EUR 199) is for growth**
- Makes Professional look affordable - Analytics and automation justify the jump
- Captures high-value customers - 2,000 orders/month covers most high-volume vendors
- Multi-vendor is a clear enterprise need - API access enables custom workflows
4. **No per-order fees** 4. **Enterprise (EUR 399+) captures high-value clients**
- White-label and SLA for agencies/resellers
- Custom integrations for complex operations
- Multi-vendor for the rare but valuable cases
5. **No per-order fees**
- Unlike Letzshop's commission model - Unlike Letzshop's commission model
- The more you sell, the better the value - The more you sell, the better the value
- Predictable costs for budgeting - Predictable costs for budgeting
5. **Annual = lower churn** 6. **Annual = lower churn**
- 17% discount is meaningful - 17% discount is meaningful
- 2 months free is easy to understand - 2 months free is easy to understand
- Reduces monthly cancellation opportunity - Reduces monthly cancellation opportunity
--- ---
## Upgrade Path Logic
```
Essential -> Professional
Triggers: Ships to EU (VAT) | 100+ orders | Team access needed
Professional -> Business
Triggers: 500+ orders | Needs analytics | Accountant needs exports
Business -> Enterprise
Triggers: 2,000+ orders | White-label | SLA required | Custom integrations
```
---
## Competitive Positioning ## Competitive Positioning
| Alternative | Their Model | Our Advantage | | Alternative | Their Model | Our Advantage |
@@ -244,21 +289,27 @@ A focused Order Management System that works alongside Letzshop, not instead of
### Qualifying Questions ### Qualifying Questions
1. "How many orders do you process per month?" 1. "How many orders do you process per month?"
- <100 Essential - <100 -> Essential
- 100-500 Professional - 100-500 -> Professional
- 500+ → Business - 500-2000 -> Business
- 2000+ -> Enterprise
2. "Do you ship to other EU countries?" 2. "Do you ship to other EU countries?"
- Yes Professional minimum (VAT invoicing) - Yes -> Professional minimum (VAT invoicing)
- No Essential may suffice - No -> Essential may suffice
3. "How many people handle orders?" 3. "How many people handle orders?"
- 1 Essential - 1 -> Essential
- 2-3 Professional - 2-3 -> Professional
- 4+ → Business - 4-10 -> Business
- 10+ -> Enterprise
4. "Do you have multiple Letzshop accounts?" 4. "Do you need analytics and reporting?"
- Yes Business - Yes -> Business minimum
5. "Do you need to connect to other systems (ERP, accounting)?"
- API needed -> Business
- Custom integration -> Enterprise
### Objection Handling ### Objection Handling
@@ -270,3 +321,6 @@ A focused Order Management System that works alongside Letzshop, not instead of
**"Why do I need Professional for EU invoices?"** **"Why do I need Professional for EU invoices?"**
> "EU VAT rules are complex - wrong rate, wrong country, wrong invoice = compliance issues. We handle this automatically so your accountant doesn't have to fix it." > "EU VAT rules are complex - wrong rate, wrong country, wrong invoice = compliance issues. We handle this automatically so your accountant doesn't have to fix it."
**"Business seems expensive"**
> "At 500+ orders/month, you're doing at least EUR 25,000/month in revenue. EUR 199 is less than 1% of that, and the analytics alone will help you find opportunities to grow."

View File

@@ -167,22 +167,22 @@ The goal is natural upgrade pressure based on **growth**, not artificial limits.
| Growth Signal | Triggers Upgrade To | | Growth Signal | Triggers Upgrade To |
|---------------|---------------------| |---------------|---------------------|
| More orders | Higher tier (volume) | | More orders | Higher tier (volume) |
| Shipping to EU | Pro (VAT invoicing) | | Shipping to EU | Professional (VAT invoicing) |
| Team grows | Pro/Business (users) | | Team grows | Professional/Business (users) |
| More products | Pro (locations, PO) | | Needs analytics | Business (reporting) |
| Multiple shops | Business (multi-vendor) | | Custom integrations | Enterprise (API/white-label) |
--- ---
### Essential - EUR 49/month ### Essential - EUR 49/month
**Target:** Solo vendors, getting started, Letzshop + 1 channel **Target:** Solo vendors, getting started, Letzshop-focused
| Feature | Limit | | Feature | Limit |
|---------|-------| |---------|-------|
| Letzshop Order Sync | Up to 100 orders/month | | Letzshop Order Sync | Up to 100 orders/month |
| Inventory Management | Up to 200 products | | Inventory Management | Up to 200 products |
| Basic Invoices (Luxembourg) | Included | | Basic Invoices (Luxembourg VAT) | Included |
| Team Members | 1 user | | Team Members | 1 user |
| Order History | 6 months | | Order History | 6 months |
| Email Support | 72h response | | Email Support | 72h response |
@@ -207,44 +207,69 @@ The goal is natural upgrade pressure based on **growth**, not artificial limits.
| Order History | 24 months | | Order History | 24 months |
| Priority Email Support | 24h response | | Priority Email Support | 24h response |
**Why they upgrade:** Multiple team members, multiple Letzshop accounts, need API. **Why they upgrade:** Hit 500 orders, need analytics/reporting, team grows beyond 3.
--- ---
### Business - EUR 199/month ### Business - EUR 199/month
**Target:** Teams, high volume, multiple vendor accounts **Target:** High-volume vendors, teams, data-driven operations
| Feature | Limit | | Feature | Limit |
|---------|-------| |---------|-------|
| Letzshop Order Sync | Unlimited orders | | Letzshop Order Sync | Up to 2,000 orders/month |
| Everything in Professional | Included | | Everything in Professional | Included |
| **Multi-Vendor Support** | Multiple Letzshop accounts | | **Analytics Dashboard** | Sales trends, top products, insights |
| **Accounting Export** | CSV/XML for accounting software |
| **Bulk Operations** | Mass updates, bulk invoicing |
| **Advanced Reporting** | Custom date ranges, exports |
| **API Access** | For custom integrations | | **API Access** | For custom integrations |
| **Accounting Export** | CSV/XML for accountants | | **Automation Rules** | Auto-confirm, low-stock alerts |
| Team Members | 10 users | | Team Members | 10 users |
| Team Roles & Permissions | Full RBAC | | Team Roles & Permissions | Full RBAC |
| Order History | Unlimited | | Order History | Unlimited |
| Phone Support | Available | | Priority Support | 4h response, phone available |
| Dedicated Onboarding | Included |
**Why they upgrade:** Hit 2,000 orders, need white-label, need SLA, custom integrations.
---
### Enterprise - EUR 399+/month (Custom)
**Target:** Large operations, agencies, special requirements
| Feature | Notes |
|---------|-------|
| Everything in Business | Included |
| Unlimited Orders | No volume limits |
| Unlimited Team Members | As needed |
| **White-Label Option** | Remove Wizamart branding |
| **Custom Integrations** | Connect to ERP, accounting software |
| **Multi-Vendor Support** | Multiple Letzshop accounts |
| **SLA Guarantee** | 99.9% uptime |
| **Dedicated Account Manager** | Named contact |
--- ---
### Feature Access by Tier ### Feature Access by Tier
| Feature | Essential | Professional | Business | | Feature | Essential | Professional | Business | Enterprise |
|---------|:---------:|:------------:|:--------:| |---------|:---------:|:------------:|:--------:|:----------:|
| Letzshop Order Sync | 100/mo | 500/mo | Unlimited | | Letzshop Order Sync | 100/mo | 500/mo | 2,000/mo | Unlimited |
| Inventory Management | 200 SKU | Unlimited | Unlimited | | Inventory Management | 200 SKU | Unlimited | Unlimited | Unlimited |
| Product Locations | - | ✓ | ✓ | | Product Locations | - | Yes | Yes | Yes |
| Incoming Stock / PO | - | ✓ | ✓ | | Incoming Stock / PO | - | Yes | Yes | Yes |
| Basic Invoice (LU) | ✓ | ✓ | ✓ | | Basic Invoice (LU) | Yes | Yes | Yes | Yes |
| EU VAT Invoice | - | ✓ | ✓ | | EU VAT Invoice | - | Yes | Yes | Yes |
| Customer List | View | + Export | + Export | | Customer List | View | + Export | + Export | + Export |
| Team Members | 1 | 3 | 10 | | Analytics Dashboard | - | - | Yes | Yes |
| Multi-Vendor | - | - | | | Accounting Export | - | - | Yes | Yes |
| API Access | - | - | | | API Access | - | - | Yes | Yes |
| Order History | 6 mo | 24 mo | Unlimited | | Automation Rules | - | - | Yes | Yes |
| White-Label | - | - | - | Yes |
| Multi-Vendor | - | - | - | Yes |
| Team Members | 1 | 3 | 10 | Unlimited |
| Order History | 6 mo | 24 mo | Unlimited | Unlimited |
--- ---
@@ -260,12 +285,18 @@ The goal is natural upgrade pressure based on **growth**, not artificial limits.
- Product locations + PO = operational maturity - Product locations + PO = operational maturity
- 3 users = small team - 3 users = small team
3. **Business (EUR 199)** is for scale 3. **Business (EUR 199)** is for growth
- Multi-vendor = manages multiple Letzshop shops - Analytics and automation justify the price jump
- API = integrates with other systems - 2,000 orders/month covers most high-volume vendors
- 10 users = real team - API enables custom workflows
- No multi-vendor requirement (most don't need it)
**Price anchoring:** EUR 99 is the target tier. EUR 49 exists to capture price-sensitive leads. EUR 199 exists to make EUR 99 look reasonable. 4. **Enterprise (EUR 399+)** captures high-value clients
- White-label for agencies/resellers
- Multi-vendor for the rare cases that need it
- SLA and dedicated support for critical operations
**Price anchoring:** EUR 99 is the target tier. EUR 49 captures price-sensitive leads. EUR 199 is for growth. EUR 399+ makes EUR 199 look reasonable.
--- ---

View File

@@ -19,6 +19,12 @@ from .company import Company
from .content_page import ContentPage from .content_page import ContentPage
from .customer import Customer, CustomerAddress from .customer import Customer, CustomerAddress
from .inventory import Inventory from .inventory import Inventory
from .invoice import (
Invoice,
InvoiceStatus,
VATRegime,
VendorInvoiceSettings,
)
from .letzshop import ( from .letzshop import (
LetzshopFulfillmentQueue, LetzshopFulfillmentQueue,
LetzshopHistoricalImportJob, LetzshopHistoricalImportJob,
@@ -44,6 +50,12 @@ from .order import Order, OrderItem
from .order_item_exception import OrderItemException from .order_item_exception import OrderItemException
from .product import Product from .product import Product
from .product_translation import ProductTranslation from .product_translation import ProductTranslation
from .subscription import (
SubscriptionStatus,
TierCode,
TIER_LIMITS,
VendorSubscription,
)
from .test_run import TestCollection, TestResult, TestRun from .test_run import TestCollection, TestResult, TestRun
from .user import User from .user import User
from .vendor import Role, Vendor, VendorUser from .vendor import Role, Vendor, VendorUser
@@ -95,6 +107,11 @@ __all__ = [
"MarketplaceImportError", "MarketplaceImportError",
# Inventory # Inventory
"Inventory", "Inventory",
# Invoicing
"Invoice",
"InvoiceStatus",
"VATRegime",
"VendorInvoiceSettings",
# Orders # Orders
"Order", "Order",
"OrderItem", "OrderItem",
@@ -104,6 +121,11 @@ __all__ = [
"LetzshopFulfillmentQueue", "LetzshopFulfillmentQueue",
"LetzshopSyncLog", "LetzshopSyncLog",
"LetzshopHistoricalImportJob", "LetzshopHistoricalImportJob",
# Subscription
"VendorSubscription",
"SubscriptionStatus",
"TierCode",
"TIER_LIMITS",
# Messaging # Messaging
"Conversation", "Conversation",
"ConversationParticipant", "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( items = relationship(
"OrderItem", back_populates="order", cascade="all, delete-orphan" "OrderItem", back_populates="order", cascade="all, delete-orphan"
) )
invoices = relationship(
"Invoice", back_populates="order", cascade="all, delete-orphan"
)
# Composite indexes for common queries # Composite indexes for common queries
__table_args__ = ( __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", 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( domains = relationship(
"VendorDomain", "VendorDomain",
back_populates="vendor", back_populates="vendor",

View File

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

View File

@@ -22,5 +22,8 @@ python-multipart==0.0.20
pandas==2.2.3 pandas==2.2.3
requests==2.32.3 requests==2.32.3
# PDF generation
weasyprint==62.3
# Environment and configuration # Environment and configuration
python-dotenv==1.0.1 python-dotenv==1.0.1