feat: add invoicing system and subscription tier enforcement

Phase 1 OMS implementation:

Invoicing:
- Add Invoice and VendorInvoiceSettings database models
- Full EU VAT support (27 countries, OSS, B2B reverse charge)
- Invoice PDF generation with WeasyPrint + Jinja2 templates
- Vendor invoice API endpoints for settings, creation, PDF download

Subscription Tiers:
- Add VendorSubscription model with 4 tiers (Essential/Professional/Business/Enterprise)
- Tier limit enforcement for orders, products, team members
- Feature gating based on subscription tier
- Automatic trial subscription creation for new vendors
- Integrate limit checks into order creation (direct and Letzshop sync)

Marketing:
- Update pricing documentation with 4-tier structure
- Revise back-office positioning strategy
- Update homepage with Veeqo-inspired Letzshop-focused messaging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-24 18:15:27 +01:00
parent 4d9b816072
commit 6232bb47f6
23 changed files with 4342 additions and 241 deletions

View File

@@ -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
View File

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

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ from sqlalchemy import String, and_, func, or_
from sqlalchemy.orm import Session
from 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

View File

@@ -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)}"

View File

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

View File

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

View File

@@ -1,112 +1,127 @@
{# app/templates/platform/homepage-modern.html #}
{# 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>