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:
4
Makefile
4
Makefile
@@ -44,7 +44,7 @@ setup: install-all migrate-up init-prod
|
||||
# =============================================================================
|
||||
|
||||
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
|
||||
@@ -274,7 +274,7 @@ qa: format lint arch-check test-coverage docs-check
|
||||
|
||||
docs-serve:
|
||||
@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:
|
||||
@echo "Building documentation..."
|
||||
|
||||
198
alembic/versions/h6c7d8e9f0a1_add_invoice_tables.py
Normal file
198
alembic/versions/h6c7d8e9f0a1_add_invoice_tables.py
Normal 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")
|
||||
148
alembic/versions/i7d8e9f0a1b2_add_vendor_subscriptions.py
Normal file
148
alembic/versions/i7d8e9f0a1b2_add_vendor_subscriptions.py
Normal 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")
|
||||
2
app/api/v1/vendor/__init__.py
vendored
2
app/api/v1/vendor/__init__.py
vendored
@@ -21,6 +21,7 @@ from . import (
|
||||
dashboard,
|
||||
info,
|
||||
inventory,
|
||||
invoices,
|
||||
letzshop,
|
||||
marketplace,
|
||||
media,
|
||||
@@ -59,6 +60,7 @@ router.include_router(settings.router, tags=["vendor-settings"])
|
||||
router.include_router(products.router, tags=["vendor-products"])
|
||||
router.include_router(orders.router, tags=["vendor-orders"])
|
||||
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(team.router, tags=["vendor-team"])
|
||||
router.include_router(inventory.router, tags=["vendor-inventory"])
|
||||
|
||||
369
app/api/v1/vendor/invoices.py
vendored
Normal file
369
app/api/v1/vendor/invoices.py
vendored
Normal 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)}")
|
||||
164
app/services/invoice_pdf_service.py
Normal file
164
app/services/invoice_pdf_service.py
Normal 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()
|
||||
666
app/services/invoice_service.py
Normal file
666
app/services/invoice_service.py
Normal 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()
|
||||
@@ -15,6 +15,7 @@ from sqlalchemy import String, and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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 (
|
||||
LetzshopFulfillmentQueue,
|
||||
LetzshopHistoricalImportJob,
|
||||
@@ -792,6 +793,7 @@ class LetzshopOrderService:
|
||||
"updated": 0,
|
||||
"skipped": 0,
|
||||
"errors": 0,
|
||||
"limit_exceeded": 0,
|
||||
"products_matched": 0,
|
||||
"products_not_found": 0,
|
||||
"eans_processed": set(),
|
||||
@@ -800,6 +802,10 @@ class LetzshopOrderService:
|
||||
"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):
|
||||
shipment_id = shipment.get("id")
|
||||
if not shipment_id:
|
||||
@@ -844,11 +850,24 @@ class LetzshopOrderService:
|
||||
else:
|
||||
stats["skipped"] += 1
|
||||
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
|
||||
try:
|
||||
self.create_order(vendor_id, shipment)
|
||||
self.db.commit() # noqa: SVC-006 - background task needs incremental commits
|
||||
stats["imported"] += 1
|
||||
|
||||
# Decrement remaining count for batch efficiency
|
||||
if orders_remaining is not None:
|
||||
orders_remaining -= 1
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback() # Rollback failed order
|
||||
stats["errors"] += 1
|
||||
|
||||
@@ -31,6 +31,10 @@ from app.exceptions import (
|
||||
ValidationException,
|
||||
)
|
||||
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 models.database.customer import Customer
|
||||
from models.database.marketplace_product import MarketplaceProduct
|
||||
@@ -271,7 +275,11 @@ class OrderService:
|
||||
Raises:
|
||||
ValidationException: If order data is invalid
|
||||
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:
|
||||
# Get or create customer
|
||||
if order_data.customer_id:
|
||||
@@ -428,6 +436,9 @@ class OrderService:
|
||||
db.flush()
|
||||
db.refresh(order)
|
||||
|
||||
# Increment order count for subscription tracking
|
||||
subscription_service.increment_order_count(db, vendor_id)
|
||||
|
||||
logger.info(
|
||||
f"Order {order.order_number} created for vendor {vendor_id}, "
|
||||
f"total: EUR {cents_to_euros(total_amount_cents):.2f}"
|
||||
@@ -439,6 +450,7 @@ class OrderService:
|
||||
ValidationException,
|
||||
InsufficientInventoryException,
|
||||
CustomerNotFoundException,
|
||||
TierLimitExceededException,
|
||||
):
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -450,6 +462,7 @@ class OrderService:
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
shipment_data: dict[str, Any],
|
||||
skip_limit_check: bool = False,
|
||||
) -> Order:
|
||||
"""
|
||||
Create an order from Letzshop shipment data.
|
||||
@@ -462,13 +475,27 @@ class OrderService:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
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:
|
||||
Created Order object
|
||||
|
||||
Raises:
|
||||
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", {})
|
||||
|
||||
# Generate order number using Letzshop order number
|
||||
@@ -777,6 +804,9 @@ class OrderService:
|
||||
f"order {order.order_number}"
|
||||
)
|
||||
|
||||
# Increment order count for subscription tracking
|
||||
subscription_service.increment_order_count(db, vendor_id)
|
||||
|
||||
logger.info(
|
||||
f"Letzshop order {order.order_number} created for vendor {vendor_id}, "
|
||||
f"status: {status}, items: {len(inventory_units)}"
|
||||
|
||||
512
app/services/subscription_service.py
Normal file
512
app/services/subscription_service.py
Normal 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()
|
||||
470
app/templates/invoices/invoice.html
Normal file
470
app/templates/invoices/invoice.html
Normal 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>
|
||||
@@ -1,112 +1,127 @@
|
||||
{# 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" %}
|
||||
|
||||
{% block title %}
|
||||
{% if page %}{{ page.title }}{% else %}Home{% endif %} - Marketplace
|
||||
Wizamart - The Back-Office for Letzshop Sellers
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.gradient-lu {
|
||||
background: linear-gradient(135deg, #00A1DE 0%, #EF3340 100%);
|
||||
}
|
||||
.gradient-lu-subtle {
|
||||
background: linear-gradient(135deg, #f0f9ff 0%, #fef2f2 100%);
|
||||
}
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-20px); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
.float-animation {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
animation: float 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes gradient {
|
||||
0% { background-position: 0% 50%; }
|
||||
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;
|
||||
.feature-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% 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">
|
||||
{# Decorative elements #}
|
||||
<div class="absolute top-0 right-0 w-1/3 h-full opacity-20">
|
||||
<div class="absolute top-20 right-20 w-72 h-72 rounded-full bg-white blur-3xl"></div>
|
||||
<div class="absolute bottom-20 right-40 w-96 h-96 rounded-full bg-white blur-3xl"></div>
|
||||
<section class="relative overflow-hidden bg-gray-900 text-white py-20 md:py-28">
|
||||
{# Background pattern #}
|
||||
<div class="absolute inset-0 opacity-10">
|
||||
<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>
|
||||
|
||||
<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">
|
||||
{# Left column - Content #}
|
||||
<div>
|
||||
{% if page %}
|
||||
<h1 class="text-5xl md:text-7xl font-black mb-6 leading-tight">
|
||||
{{ page.title }}
|
||||
</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="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">
|
||||
<span class="mr-2">🇱🇺</span> Built for Luxembourg E-Commerce
|
||||
</div>
|
||||
|
||||
<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"
|
||||
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">
|
||||
<span>Start Free Trial</span>
|
||||
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 14-Day Free Trial</span>
|
||||
<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>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="#features"
|
||||
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
|
||||
<a href="#how-it-works"
|
||||
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">
|
||||
See How It Works
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Stats #}
|
||||
<div class="grid grid-cols-3 gap-6 mt-12 pt-12 border-t border-white/20">
|
||||
<div>
|
||||
<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>
|
||||
<p class="text-sm text-gray-400">
|
||||
No credit card required. Setup in 5 minutes. Cancel anytime.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Right column - Visual element #}
|
||||
<div class="hidden lg:block float-animation">
|
||||
<div class="relative">
|
||||
<div class="w-full h-96 bg-white/10 backdrop-blur-xl rounded-3xl shadow-2xl p-8">
|
||||
<div class="w-full h-full bg-gradient-to-br from-white/20 to-transparent rounded-2xl flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="text-8xl mb-4">🚀</div>
|
||||
<div class="text-2xl font-bold">Launch Today</div>
|
||||
{# Right column - Dashboard Preview #}
|
||||
<div class="hidden lg:block">
|
||||
<div class="relative float-animation">
|
||||
<div class="bg-gray-800 rounded-2xl shadow-2xl border border-gray-700 overflow-hidden">
|
||||
{# Mock dashboard header #}
|
||||
<div class="bg-gray-900 px-4 py-3 flex items-center gap-2 border-b border-gray-700">
|
||||
<div class="w-3 h-3 rounded-full bg-red-500"></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>
|
||||
@@ -117,141 +132,466 @@
|
||||
</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="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">
|
||||
✨ Features
|
||||
</div>
|
||||
<h2 class="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Everything You Need
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Sound Familiar?
|
||||
</h2>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{# Feature cards with hover effects #}
|
||||
<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="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">
|
||||
{# Feature 1: Order Sync #}
|
||||
<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-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">
|
||||
<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>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
Blazing Fast
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Optimized for performance with sub-second page loads and instant search results.
|
||||
</p>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Automatic Order Sync</h3>
|
||||
<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>
|
||||
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<li>Real-time sync</li>
|
||||
<li>One-click confirmation</li>
|
||||
<li>Tracking number sync</li>
|
||||
</ul>
|
||||
</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">
|
||||
<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">
|
||||
{# Feature 2: Inventory #}
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
Bank-Level Security
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Enterprise-grade encryption and security measures to protect your business.
|
||||
</p>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Real Inventory Management</h3>
|
||||
<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>
|
||||
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<li>Product locations (bins)</li>
|
||||
<li>Stock reservations</li>
|
||||
<li>Low stock alerts</li>
|
||||
</ul>
|
||||
</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">
|
||||
<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">
|
||||
{# Feature 3: Invoicing #}
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
Fully Customizable
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Brand your store with custom themes, colors, fonts, and layouts.
|
||||
</p>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Smart VAT Invoicing</h3>
|
||||
<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>
|
||||
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<li>Luxembourg 17% VAT</li>
|
||||
<li>EU destination VAT (OSS)</li>
|
||||
<li>B2B reverse charge</li>
|
||||
</ul>
|
||||
</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">
|
||||
<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">
|
||||
{# Feature 4: Customers #}
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
Analytics & Insights
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Powerful analytics to track sales, customer behavior, and growth metrics.
|
||||
</p>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Own Your Customers</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">All customer data in your database. Export to Mailchimp for marketing campaigns.</p>
|
||||
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<li>Order history per customer</li>
|
||||
<li>Lifetime value tracking</li>
|
||||
<li>CSV export for marketing</li>
|
||||
</ul>
|
||||
</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">
|
||||
<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">
|
||||
{# Feature 5: Team #}
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
Mobile-First Design
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Beautiful, responsive design that works perfectly on all devices.
|
||||
</p>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Team Management</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">Invite team members with role-based permissions. Everyone works from one dashboard.</p>
|
||||
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<li>Multiple users</li>
|
||||
<li>Role-based access</li>
|
||||
<li>Activity logging</li>
|
||||
</ul>
|
||||
</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">
|
||||
<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">
|
||||
{# Feature 6: Purchase Orders #}
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
24/7 Support
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Round-the-clock customer support to help you succeed at every step.
|
||||
</p>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Purchase Orders</h3>
|
||||
<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>
|
||||
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<li>Track supplier orders</li>
|
||||
<li>Expected arrival dates</li>
|
||||
<li>Receive and update stock</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- MODERN CTA WITH GRADIENT -->
|
||||
<!-- PRICING - 4 Tiers -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<section class="py-24 relative overflow-hidden">
|
||||
<div class="absolute inset-0 gradient-accent opacity-90"></div>
|
||||
<section id="pricing" class="py-20 bg-gray-50 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-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">
|
||||
<h2 class="text-4xl md:text-5xl font-bold mb-6">
|
||||
Start Your Journey Today
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{# Essential #}
|
||||
<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>
|
||||
<p class="text-xl mb-10 opacity-90">
|
||||
Join thousands of successful vendors on our platform
|
||||
<p class="text-xl text-gray-300 mb-10">
|
||||
Join Luxembourg vendors who've stopped fighting spreadsheets and started growing their business.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<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">
|
||||
<span>Get Started Free</span>
|
||||
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>Start Your 14-Day Free Trial</span>
|
||||
<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>
|
||||
</svg>
|
||||
</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>
|
||||
|
||||
<p class="mt-8 text-sm opacity-75">
|
||||
No credit card required · Free 14-day trial · Cancel anytime
|
||||
<p class="mt-8 text-sm text-gray-400">
|
||||
No credit card required. Setup in 5 minutes. Full Professional features during trial.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**"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:**
|
||||
- Exceeds 500 orders/month
|
||||
- Multiple Letzshop vendor accounts
|
||||
- Needs API for integrations
|
||||
- Needs analytics and reporting
|
||||
- Team grows beyond 3
|
||||
- Accountant needs structured exports
|
||||
|
||||
---
|
||||
|
||||
### Business - EUR 199/month
|
||||
|
||||
**Target:** High-volume vendors, teams, multiple operations
|
||||
**Target:** High-volume vendors, teams, data-driven operations
|
||||
|
||||
| Feature | Limit |
|
||||
|---------|-------|
|
||||
| Letzshop Order Sync | Unlimited orders |
|
||||
| Letzshop Order Sync | Up to 2,000 orders/month |
|
||||
| Everything in Professional | Included |
|
||||
| **Multi-Vendor Support** | Multiple Letzshop accounts |
|
||||
| **API Access** | For custom integrations |
|
||||
| **Analytics Dashboard** | Sales trends, top products, customer insights |
|
||||
| **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 Roles & Permissions | Full access control |
|
||||
| Order History | Unlimited |
|
||||
| Phone Support | Available |
|
||||
| Dedicated Onboarding | 2-hour session included |
|
||||
| Priority Support | 4h response, phone available |
|
||||
|
||||
**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 |
|
||||
|---------|-------|
|
||||
| Everything in Business | Included |
|
||||
| Unlimited Orders | No volume limits |
|
||||
| Unlimited Team Members | As needed |
|
||||
| Custom Integrations | Built to spec |
|
||||
| SLA Guarantee | 99.9% uptime |
|
||||
| Dedicated Account Manager | Named contact |
|
||||
| On-Premise Option | If required |
|
||||
| **White-Label Option** | Remove Wizamart branding |
|
||||
| **Custom Integrations** | Connect to ERP, accounting software |
|
||||
| **Multi-Vendor Support** | Multiple Letzshop accounts under one dashboard |
|
||||
| **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 | Essential | Professional | Business |
|
||||
|---------|:---------:|:------------:|:--------:|
|
||||
| Feature | Essential | Professional | Business | Enterprise |
|
||||
|---------|:---------:|:------------:|:--------:|:----------:|
|
||||
| **Orders** |
|
||||
| Letzshop Sync | 100/mo | 500/mo | Unlimited |
|
||||
| Order Confirmation | ✓ | ✓ | ✓ |
|
||||
| Tracking Sync | ✓ | ✓ | ✓ |
|
||||
| Order History | 6 months | 24 months | Unlimited |
|
||||
| Letzshop Sync | 100/mo | 500/mo | 2,000/mo | Unlimited |
|
||||
| Order Confirmation | Yes | Yes | Yes | Yes |
|
||||
| Tracking Sync | Yes | Yes | Yes | Yes |
|
||||
| Order History | 6 months | 24 months | Unlimited | Unlimited |
|
||||
| **Inventory** |
|
||||
| Product Limit | 200 | Unlimited | Unlimited |
|
||||
| Stock Levels | ✓ | ✓ | ✓ |
|
||||
| Product Locations | - | ✓ | ✓ |
|
||||
| Incoming Stock / PO | - | ✓ | ✓ |
|
||||
| Product Limit | 200 | Unlimited | Unlimited | Unlimited |
|
||||
| Stock Levels | Yes | Yes | Yes | Yes |
|
||||
| Product Locations | - | Yes | Yes | Yes |
|
||||
| Incoming Stock / PO | - | Yes | Yes | Yes |
|
||||
| **Invoicing** |
|
||||
| Luxembourg Invoice | ✓ | ✓ | ✓ |
|
||||
| EU VAT Invoice | - | ✓ | ✓ |
|
||||
| Accounting Export | - | - | ✓ |
|
||||
| Luxembourg Invoice | Yes | Yes | Yes | Yes |
|
||||
| EU VAT Invoice | - | Yes | Yes | Yes |
|
||||
| Bulk Invoicing | - | - | Yes | Yes |
|
||||
| Accounting Export | - | - | Yes | Yes |
|
||||
| **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** |
|
||||
| Users | 1 | 3 | 10 |
|
||||
| Roles & Permissions | - | - | ✓ |
|
||||
| **Operations** |
|
||||
| Multi-Vendor | - | - | ✓ |
|
||||
| API Access | - | - | ✓ |
|
||||
| Users | 1 | 3 | 10 | Unlimited |
|
||||
| Roles & Permissions | - | - | Yes | Yes |
|
||||
| **Integration** |
|
||||
| API Access | - | - | Yes | Yes |
|
||||
| Custom Integrations | - | - | - | Yes |
|
||||
| White-Label | - | - | - | Yes |
|
||||
| **Support** |
|
||||
| Email | 72h | 24h | 24h |
|
||||
| Phone | - | - | ✓ |
|
||||
| Onboarding | - | - | Included |
|
||||
| Email | 72h | 24h | 4h | 4h |
|
||||
| Phone | - | - | Yes | Yes |
|
||||
| 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) |
|
||||
| Professional | EUR 99/mo | EUR 990/year (EUR 82/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 |
|
||||
|--------|-------|-------------|
|
||||
| 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 |
|
||||
| Extended History | +EUR 19/mo | Unlimited history for Essential/Professional |
|
||||
| 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
|
||||
- Price feels reasonable for operational software
|
||||
|
||||
3. **Business (EUR 199) provides anchor**
|
||||
- Makes Professional look affordable
|
||||
- Captures high-value customers
|
||||
- Multi-vendor is a clear enterprise need
|
||||
3. **Business (EUR 199) is for growth**
|
||||
- Analytics and automation justify the jump
|
||||
- 2,000 orders/month covers most high-volume vendors
|
||||
- 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
|
||||
- The more you sell, the better the value
|
||||
- Predictable costs for budgeting
|
||||
|
||||
5. **Annual = lower churn**
|
||||
6. **Annual = lower churn**
|
||||
- 17% discount is meaningful
|
||||
- 2 months free is easy to understand
|
||||
- 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
|
||||
|
||||
| Alternative | Their Model | Our Advantage |
|
||||
@@ -244,21 +289,27 @@ A focused Order Management System that works alongside Letzshop, not instead of
|
||||
### Qualifying Questions
|
||||
|
||||
1. "How many orders do you process per month?"
|
||||
- <100 → Essential
|
||||
- 100-500 → Professional
|
||||
- 500+ → Business
|
||||
- <100 -> Essential
|
||||
- 100-500 -> Professional
|
||||
- 500-2000 -> Business
|
||||
- 2000+ -> Enterprise
|
||||
|
||||
2. "Do you ship to other EU countries?"
|
||||
- Yes → Professional minimum (VAT invoicing)
|
||||
- No → Essential may suffice
|
||||
- Yes -> Professional minimum (VAT invoicing)
|
||||
- No -> Essential may suffice
|
||||
|
||||
3. "How many people handle orders?"
|
||||
- 1 → Essential
|
||||
- 2-3 → Professional
|
||||
- 4+ → Business
|
||||
- 1 -> Essential
|
||||
- 2-3 -> Professional
|
||||
- 4-10 -> Business
|
||||
- 10+ -> Enterprise
|
||||
|
||||
4. "Do you have multiple Letzshop accounts?"
|
||||
- Yes → Business
|
||||
4. "Do you need analytics and reporting?"
|
||||
- Yes -> Business minimum
|
||||
|
||||
5. "Do you need to connect to other systems (ERP, accounting)?"
|
||||
- API needed -> Business
|
||||
- Custom integration -> Enterprise
|
||||
|
||||
### 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?"**
|
||||
> "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."
|
||||
|
||||
@@ -167,22 +167,22 @@ The goal is natural upgrade pressure based on **growth**, not artificial limits.
|
||||
| Growth Signal | Triggers Upgrade To |
|
||||
|---------------|---------------------|
|
||||
| More orders | Higher tier (volume) |
|
||||
| Shipping to EU | Pro (VAT invoicing) |
|
||||
| Team grows | Pro/Business (users) |
|
||||
| More products | Pro (locations, PO) |
|
||||
| Multiple shops | Business (multi-vendor) |
|
||||
| Shipping to EU | Professional (VAT invoicing) |
|
||||
| Team grows | Professional/Business (users) |
|
||||
| Needs analytics | Business (reporting) |
|
||||
| Custom integrations | Enterprise (API/white-label) |
|
||||
|
||||
---
|
||||
|
||||
### Essential - EUR 49/month
|
||||
|
||||
**Target:** Solo vendors, getting started, Letzshop + 1 channel
|
||||
**Target:** Solo vendors, getting started, Letzshop-focused
|
||||
|
||||
| Feature | Limit |
|
||||
|---------|-------|
|
||||
| Letzshop Order Sync | Up to 100 orders/month |
|
||||
| Inventory Management | Up to 200 products |
|
||||
| Basic Invoices (Luxembourg) | Included |
|
||||
| Basic Invoices (Luxembourg VAT) | Included |
|
||||
| Team Members | 1 user |
|
||||
| Order History | 6 months |
|
||||
| Email Support | 72h response |
|
||||
@@ -207,44 +207,69 @@ The goal is natural upgrade pressure based on **growth**, not artificial limits.
|
||||
| Order History | 24 months |
|
||||
| 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
|
||||
|
||||
**Target:** Teams, high volume, multiple vendor accounts
|
||||
**Target:** High-volume vendors, teams, data-driven operations
|
||||
|
||||
| Feature | Limit |
|
||||
|---------|-------|
|
||||
| Letzshop Order Sync | Unlimited orders |
|
||||
| Letzshop Order Sync | Up to 2,000 orders/month |
|
||||
| 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 |
|
||||
| **Accounting Export** | CSV/XML for accountants |
|
||||
| **Automation Rules** | Auto-confirm, low-stock alerts |
|
||||
| Team Members | 10 users |
|
||||
| Team Roles & Permissions | Full RBAC |
|
||||
| Order History | Unlimited |
|
||||
| Phone Support | Available |
|
||||
| Dedicated Onboarding | Included |
|
||||
| Priority Support | 4h response, phone available |
|
||||
|
||||
**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 | Essential | Professional | Business |
|
||||
|---------|:---------:|:------------:|:--------:|
|
||||
| Letzshop Order Sync | 100/mo | 500/mo | Unlimited |
|
||||
| Inventory Management | 200 SKU | Unlimited | Unlimited |
|
||||
| Product Locations | - | ✓ | ✓ |
|
||||
| Incoming Stock / PO | - | ✓ | ✓ |
|
||||
| Basic Invoice (LU) | ✓ | ✓ | ✓ |
|
||||
| EU VAT Invoice | - | ✓ | ✓ |
|
||||
| Customer List | View | + Export | + Export |
|
||||
| Team Members | 1 | 3 | 10 |
|
||||
| Multi-Vendor | - | - | ✓ |
|
||||
| API Access | - | - | ✓ |
|
||||
| Order History | 6 mo | 24 mo | Unlimited |
|
||||
| Feature | Essential | Professional | Business | Enterprise |
|
||||
|---------|:---------:|:------------:|:--------:|:----------:|
|
||||
| Letzshop Order Sync | 100/mo | 500/mo | 2,000/mo | Unlimited |
|
||||
| Inventory Management | 200 SKU | Unlimited | Unlimited | Unlimited |
|
||||
| Product Locations | - | Yes | Yes | Yes |
|
||||
| Incoming Stock / PO | - | Yes | Yes | Yes |
|
||||
| Basic Invoice (LU) | Yes | Yes | Yes | Yes |
|
||||
| EU VAT Invoice | - | Yes | Yes | Yes |
|
||||
| Customer List | View | + Export | + Export | + Export |
|
||||
| Analytics Dashboard | - | - | Yes | Yes |
|
||||
| Accounting Export | - | - | Yes | Yes |
|
||||
| API Access | - | - | Yes | Yes |
|
||||
| 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
|
||||
- 3 users = small team
|
||||
|
||||
3. **Business (EUR 199)** is for scale
|
||||
- Multi-vendor = manages multiple Letzshop shops
|
||||
- API = integrates with other systems
|
||||
- 10 users = real team
|
||||
3. **Business (EUR 199)** is for growth
|
||||
- Analytics and automation justify the price jump
|
||||
- 2,000 orders/month covers most high-volume vendors
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@ from .company import Company
|
||||
from .content_page import ContentPage
|
||||
from .customer import Customer, CustomerAddress
|
||||
from .inventory import Inventory
|
||||
from .invoice import (
|
||||
Invoice,
|
||||
InvoiceStatus,
|
||||
VATRegime,
|
||||
VendorInvoiceSettings,
|
||||
)
|
||||
from .letzshop import (
|
||||
LetzshopFulfillmentQueue,
|
||||
LetzshopHistoricalImportJob,
|
||||
@@ -44,6 +50,12 @@ from .order import Order, OrderItem
|
||||
from .order_item_exception import OrderItemException
|
||||
from .product import Product
|
||||
from .product_translation import ProductTranslation
|
||||
from .subscription import (
|
||||
SubscriptionStatus,
|
||||
TierCode,
|
||||
TIER_LIMITS,
|
||||
VendorSubscription,
|
||||
)
|
||||
from .test_run import TestCollection, TestResult, TestRun
|
||||
from .user import User
|
||||
from .vendor import Role, Vendor, VendorUser
|
||||
@@ -95,6 +107,11 @@ __all__ = [
|
||||
"MarketplaceImportError",
|
||||
# Inventory
|
||||
"Inventory",
|
||||
# Invoicing
|
||||
"Invoice",
|
||||
"InvoiceStatus",
|
||||
"VATRegime",
|
||||
"VendorInvoiceSettings",
|
||||
# Orders
|
||||
"Order",
|
||||
"OrderItem",
|
||||
@@ -104,6 +121,11 @@ __all__ = [
|
||||
"LetzshopFulfillmentQueue",
|
||||
"LetzshopSyncLog",
|
||||
"LetzshopHistoricalImportJob",
|
||||
# Subscription
|
||||
"VendorSubscription",
|
||||
"SubscriptionStatus",
|
||||
"TierCode",
|
||||
"TIER_LIMITS",
|
||||
# Messaging
|
||||
"Conversation",
|
||||
"ConversationParticipant",
|
||||
|
||||
215
models/database/invoice.py
Normal file
215
models/database/invoice.py
Normal 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
|
||||
@@ -143,6 +143,9 @@ class Order(Base, TimestampMixin):
|
||||
items = relationship(
|
||||
"OrderItem", back_populates="order", cascade="all, delete-orphan"
|
||||
)
|
||||
invoices = relationship(
|
||||
"Invoice", back_populates="order", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
# Composite indexes for common queries
|
||||
__table_args__ = (
|
||||
|
||||
354
models/database/subscription.py
Normal file
354
models/database/subscription.py
Normal 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
|
||||
@@ -143,6 +143,29 @@ class Vendor(Base, TimestampMixin):
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Invoice settings (one-to-one)
|
||||
invoice_settings = relationship(
|
||||
"VendorInvoiceSettings",
|
||||
back_populates="vendor",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Invoices (one-to-many)
|
||||
invoices = relationship(
|
||||
"Invoice",
|
||||
back_populates="vendor",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Subscription (one-to-one)
|
||||
subscription = relationship(
|
||||
"VendorSubscription",
|
||||
back_populates="vendor",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
domains = relationship(
|
||||
"VendorDomain",
|
||||
back_populates="vendor",
|
||||
|
||||
@@ -6,6 +6,7 @@ from . import (
|
||||
auth,
|
||||
base,
|
||||
inventory,
|
||||
invoice,
|
||||
marketplace_import_job,
|
||||
marketplace_product,
|
||||
message,
|
||||
@@ -19,6 +20,7 @@ from .base import * # Base Pydantic models
|
||||
__all__ = [
|
||||
"base",
|
||||
"auth",
|
||||
"invoice",
|
||||
"marketplace_product",
|
||||
"message",
|
||||
"inventory",
|
||||
|
||||
283
models/schema/invoice.py
Normal file
283
models/schema/invoice.py
Normal file
@@ -0,0 +1,283 @@
|
||||
# models/schema/invoice.py
|
||||
"""
|
||||
Pydantic schemas for invoice operations.
|
||||
|
||||
Supports invoice settings management and invoice generation.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
# ============================================================================
|
||||
# Invoice Settings Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class VendorInvoiceSettingsCreate(BaseModel):
|
||||
"""Schema for creating vendor invoice settings."""
|
||||
|
||||
company_name: str = Field(..., min_length=1, max_length=255)
|
||||
company_address: str | None = Field(None, max_length=255)
|
||||
company_city: str | None = Field(None, max_length=100)
|
||||
company_postal_code: str | None = Field(None, max_length=20)
|
||||
company_country: str = Field(default="LU", min_length=2, max_length=2)
|
||||
|
||||
vat_number: str | None = Field(None, max_length=50)
|
||||
is_vat_registered: bool = True
|
||||
|
||||
is_oss_registered: bool = False
|
||||
oss_registration_country: str | None = Field(None, min_length=2, max_length=2)
|
||||
|
||||
invoice_prefix: str = Field(default="INV", max_length=20)
|
||||
invoice_number_padding: int = Field(default=5, ge=1, le=10)
|
||||
|
||||
payment_terms: str | None = None
|
||||
bank_name: str | None = Field(None, max_length=255)
|
||||
bank_iban: str | None = Field(None, max_length=50)
|
||||
bank_bic: str | None = Field(None, max_length=20)
|
||||
|
||||
footer_text: str | None = None
|
||||
default_vat_rate: Decimal = Field(default=Decimal("17.00"), ge=0, le=100)
|
||||
|
||||
|
||||
class VendorInvoiceSettingsUpdate(BaseModel):
|
||||
"""Schema for updating vendor invoice settings."""
|
||||
|
||||
company_name: str | None = Field(None, min_length=1, max_length=255)
|
||||
company_address: str | None = Field(None, max_length=255)
|
||||
company_city: str | None = Field(None, max_length=100)
|
||||
company_postal_code: str | None = Field(None, max_length=20)
|
||||
company_country: str | None = Field(None, min_length=2, max_length=2)
|
||||
|
||||
vat_number: str | None = None
|
||||
is_vat_registered: bool | None = None
|
||||
|
||||
is_oss_registered: bool | None = None
|
||||
oss_registration_country: str | None = None
|
||||
|
||||
invoice_prefix: str | None = Field(None, max_length=20)
|
||||
invoice_number_padding: int | None = Field(None, ge=1, le=10)
|
||||
|
||||
payment_terms: str | None = None
|
||||
bank_name: str | None = Field(None, max_length=255)
|
||||
bank_iban: str | None = Field(None, max_length=50)
|
||||
bank_bic: str | None = Field(None, max_length=20)
|
||||
|
||||
footer_text: str | None = None
|
||||
default_vat_rate: Decimal | None = Field(None, ge=0, le=100)
|
||||
|
||||
|
||||
class VendorInvoiceSettingsResponse(BaseModel):
|
||||
"""Schema for vendor invoice settings response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
vendor_id: int
|
||||
|
||||
company_name: str
|
||||
company_address: str | None
|
||||
company_city: str | None
|
||||
company_postal_code: str | None
|
||||
company_country: str
|
||||
|
||||
vat_number: str | None
|
||||
is_vat_registered: bool
|
||||
|
||||
is_oss_registered: bool
|
||||
oss_registration_country: str | None
|
||||
|
||||
invoice_prefix: str
|
||||
invoice_next_number: int
|
||||
invoice_number_padding: int
|
||||
|
||||
payment_terms: str | None
|
||||
bank_name: str | None
|
||||
bank_iban: str | None
|
||||
bank_bic: str | None
|
||||
|
||||
footer_text: str | None
|
||||
default_vat_rate: Decimal
|
||||
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Invoice Line Item Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class InvoiceLineItem(BaseModel):
|
||||
"""Schema for invoice line item."""
|
||||
|
||||
description: str
|
||||
quantity: int = Field(..., ge=1)
|
||||
unit_price_cents: int
|
||||
total_cents: int
|
||||
sku: str | None = None
|
||||
ean: str | None = None
|
||||
|
||||
|
||||
class InvoiceLineItemResponse(BaseModel):
|
||||
"""Schema for invoice line item in response."""
|
||||
|
||||
description: str
|
||||
quantity: int
|
||||
unit_price_cents: int
|
||||
total_cents: int
|
||||
sku: str | None = None
|
||||
ean: str | None = None
|
||||
|
||||
@property
|
||||
def unit_price(self) -> float:
|
||||
return self.unit_price_cents / 100
|
||||
|
||||
@property
|
||||
def total(self) -> float:
|
||||
return self.total_cents / 100
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Invoice Address Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class InvoiceSellerDetails(BaseModel):
|
||||
"""Seller details for invoice."""
|
||||
|
||||
company_name: str
|
||||
address: str | None = None
|
||||
city: str | None = None
|
||||
postal_code: str | None = None
|
||||
country: str
|
||||
vat_number: str | None = None
|
||||
|
||||
|
||||
class InvoiceBuyerDetails(BaseModel):
|
||||
"""Buyer details for invoice."""
|
||||
|
||||
name: str
|
||||
email: str | None = None
|
||||
address: str | None = None
|
||||
city: str | None = None
|
||||
postal_code: str | None = None
|
||||
country: str
|
||||
vat_number: str | None = None # For B2B
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Invoice Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class InvoiceCreate(BaseModel):
|
||||
"""Schema for creating an invoice from an order."""
|
||||
|
||||
order_id: int
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class InvoiceManualCreate(BaseModel):
|
||||
"""Schema for creating a manual invoice (without order)."""
|
||||
|
||||
buyer_details: InvoiceBuyerDetails
|
||||
line_items: list[InvoiceLineItem]
|
||||
notes: str | None = None
|
||||
payment_terms: str | None = None
|
||||
|
||||
|
||||
class InvoiceResponse(BaseModel):
|
||||
"""Schema for invoice response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
vendor_id: int
|
||||
order_id: int | None
|
||||
|
||||
invoice_number: str
|
||||
invoice_date: datetime
|
||||
status: str
|
||||
|
||||
seller_details: dict
|
||||
buyer_details: dict
|
||||
line_items: list[dict]
|
||||
|
||||
vat_regime: str
|
||||
destination_country: str | None
|
||||
vat_rate: Decimal
|
||||
vat_rate_label: str | None
|
||||
|
||||
currency: str
|
||||
subtotal_cents: int
|
||||
vat_amount_cents: int
|
||||
total_cents: int
|
||||
|
||||
payment_terms: str | None
|
||||
bank_details: dict | None
|
||||
footer_text: str | None
|
||||
|
||||
pdf_generated_at: datetime | None
|
||||
pdf_path: str | None
|
||||
|
||||
notes: str | None
|
||||
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@property
|
||||
def subtotal(self) -> float:
|
||||
return self.subtotal_cents / 100
|
||||
|
||||
@property
|
||||
def vat_amount(self) -> float:
|
||||
return self.vat_amount_cents / 100
|
||||
|
||||
@property
|
||||
def total(self) -> float:
|
||||
return self.total_cents / 100
|
||||
|
||||
|
||||
class InvoiceListResponse(BaseModel):
|
||||
"""Schema for invoice list response (summary)."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
invoice_number: str
|
||||
invoice_date: datetime
|
||||
status: str
|
||||
currency: str
|
||||
total_cents: int
|
||||
order_id: int | None
|
||||
|
||||
# Buyer name for display
|
||||
buyer_name: str | None = None
|
||||
|
||||
@property
|
||||
def total(self) -> float:
|
||||
return self.total_cents / 100
|
||||
|
||||
|
||||
class InvoiceStatusUpdate(BaseModel):
|
||||
"""Schema for updating invoice status."""
|
||||
|
||||
status: str = Field(..., pattern="^(draft|issued|paid|cancelled)$")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Paginated Response
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class InvoiceListPaginatedResponse(BaseModel):
|
||||
"""Paginated invoice list response."""
|
||||
|
||||
items: list[InvoiceListResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
193
models/schema/subscription.py
Normal file
193
models/schema/subscription.py
Normal file
@@ -0,0 +1,193 @@
|
||||
# models/schema/subscription.py
|
||||
"""
|
||||
Pydantic schemas for subscription operations.
|
||||
|
||||
Supports subscription management and tier limit checks.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Tier Information Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TierFeatures(BaseModel):
|
||||
"""Features included in a tier."""
|
||||
|
||||
letzshop_sync: bool = True
|
||||
inventory_basic: bool = True
|
||||
inventory_locations: bool = False
|
||||
inventory_purchase_orders: bool = False
|
||||
invoice_lu: bool = True
|
||||
invoice_eu_vat: bool = False
|
||||
invoice_bulk: bool = False
|
||||
customer_view: bool = True
|
||||
customer_export: bool = False
|
||||
analytics_dashboard: bool = False
|
||||
accounting_export: bool = False
|
||||
api_access: bool = False
|
||||
automation_rules: bool = False
|
||||
team_roles: bool = False
|
||||
white_label: bool = False
|
||||
multi_vendor: bool = False
|
||||
custom_integrations: bool = False
|
||||
sla_guarantee: bool = False
|
||||
dedicated_support: bool = False
|
||||
|
||||
|
||||
class TierLimits(BaseModel):
|
||||
"""Limits for a subscription tier."""
|
||||
|
||||
orders_per_month: int | None = Field(None, description="None = unlimited")
|
||||
products_limit: int | None = Field(None, description="None = unlimited")
|
||||
team_members: int | None = Field(None, description="None = unlimited")
|
||||
order_history_months: int | None = Field(None, description="None = unlimited")
|
||||
|
||||
|
||||
class TierInfo(BaseModel):
|
||||
"""Full tier information."""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
price_monthly_cents: int
|
||||
price_annual_cents: int | None
|
||||
limits: TierLimits
|
||||
features: list[str]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Subscription Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class SubscriptionCreate(BaseModel):
|
||||
"""Schema for creating a subscription (admin/internal use)."""
|
||||
|
||||
tier: str = Field(default="essential", pattern="^(essential|professional|business|enterprise)$")
|
||||
is_annual: bool = False
|
||||
trial_days: int = Field(default=14, ge=0, le=30)
|
||||
|
||||
|
||||
class SubscriptionUpdate(BaseModel):
|
||||
"""Schema for updating a subscription."""
|
||||
|
||||
tier: str | None = Field(None, pattern="^(essential|professional|business|enterprise)$")
|
||||
status: str | None = Field(None, pattern="^(trial|active|past_due|cancelled|expired)$")
|
||||
is_annual: bool | None = None
|
||||
custom_orders_limit: int | None = None
|
||||
custom_products_limit: int | None = None
|
||||
custom_team_limit: int | None = None
|
||||
|
||||
|
||||
class SubscriptionResponse(BaseModel):
|
||||
"""Schema for subscription response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
vendor_id: int
|
||||
tier: str
|
||||
status: str
|
||||
|
||||
period_start: datetime
|
||||
period_end: datetime
|
||||
is_annual: bool
|
||||
|
||||
trial_ends_at: datetime | None
|
||||
orders_this_period: int
|
||||
orders_limit_reached_at: datetime | None
|
||||
|
||||
# Effective limits (with custom overrides applied)
|
||||
orders_limit: int | None
|
||||
products_limit: int | None
|
||||
team_members_limit: int | None
|
||||
|
||||
# Computed properties
|
||||
is_active: bool
|
||||
is_trial: bool
|
||||
trial_days_remaining: int | None
|
||||
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class SubscriptionUsage(BaseModel):
|
||||
"""Current subscription usage statistics."""
|
||||
|
||||
orders_used: int
|
||||
orders_limit: int | None
|
||||
orders_remaining: int | None
|
||||
orders_percent_used: float | None
|
||||
|
||||
products_used: int
|
||||
products_limit: int | None
|
||||
products_remaining: int | None
|
||||
products_percent_used: float | None
|
||||
|
||||
team_members_used: int
|
||||
team_members_limit: int | None
|
||||
team_members_remaining: int | None
|
||||
team_members_percent_used: float | None
|
||||
|
||||
|
||||
class SubscriptionStatusResponse(BaseModel):
|
||||
"""Subscription status with usage and limits."""
|
||||
|
||||
subscription: SubscriptionResponse
|
||||
usage: SubscriptionUsage
|
||||
tier_info: TierInfo
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Limit Check Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class LimitCheckResult(BaseModel):
|
||||
"""Result of a limit check."""
|
||||
|
||||
allowed: bool
|
||||
limit: int | None
|
||||
current: int
|
||||
remaining: int | None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class CanCreateOrderResponse(BaseModel):
|
||||
"""Response for order creation check."""
|
||||
|
||||
allowed: bool
|
||||
orders_this_period: int
|
||||
orders_limit: int | None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class CanAddProductResponse(BaseModel):
|
||||
"""Response for product addition check."""
|
||||
|
||||
allowed: bool
|
||||
products_count: int
|
||||
products_limit: int | None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class CanAddTeamMemberResponse(BaseModel):
|
||||
"""Response for team member addition check."""
|
||||
|
||||
allowed: bool
|
||||
team_count: int
|
||||
team_limit: int | None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class FeatureCheckResponse(BaseModel):
|
||||
"""Response for feature check."""
|
||||
|
||||
feature: str
|
||||
enabled: bool
|
||||
tier_required: str | None = None
|
||||
message: str | None = None
|
||||
@@ -22,5 +22,8 @@ python-multipart==0.0.20
|
||||
pandas==2.2.3
|
||||
requests==2.32.3
|
||||
|
||||
# PDF generation
|
||||
weasyprint==62.3
|
||||
|
||||
# Environment and configuration
|
||||
python-dotenv==1.0.1
|
||||
Reference in New Issue
Block a user