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:
2
app/api/v1/vendor/__init__.py
vendored
2
app/api/v1/vendor/__init__.py
vendored
@@ -21,6 +21,7 @@ from . import (
|
||||
dashboard,
|
||||
info,
|
||||
inventory,
|
||||
invoices,
|
||||
letzshop,
|
||||
marketplace,
|
||||
media,
|
||||
@@ -59,6 +60,7 @@ router.include_router(settings.router, tags=["vendor-settings"])
|
||||
router.include_router(products.router, tags=["vendor-products"])
|
||||
router.include_router(orders.router, tags=["vendor-orders"])
|
||||
router.include_router(order_item_exceptions.router, tags=["vendor-order-exceptions"])
|
||||
router.include_router(invoices.router, tags=["vendor-invoices"])
|
||||
router.include_router(customers.router, tags=["vendor-customers"])
|
||||
router.include_router(team.router, tags=["vendor-team"])
|
||||
router.include_router(inventory.router, tags=["vendor-inventory"])
|
||||
|
||||
369
app/api/v1/vendor/invoices.py
vendored
Normal file
369
app/api/v1/vendor/invoices.py
vendored
Normal file
@@ -0,0 +1,369 @@
|
||||
# app/api/v1/vendor/invoices.py
|
||||
"""
|
||||
Vendor invoice management endpoints.
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
|
||||
The get_current_vendor_api dependency guarantees token_vendor_id is present.
|
||||
|
||||
Endpoints:
|
||||
- GET /invoices - List vendor invoices
|
||||
- GET /invoices/{invoice_id} - Get invoice details
|
||||
- POST /invoices - Create invoice from order
|
||||
- PUT /invoices/{invoice_id}/status - Update invoice status
|
||||
- GET /invoices/{invoice_id}/pdf - Download invoice PDF
|
||||
- POST /invoices/{invoice_id}/pdf - Generate/regenerate invoice PDF
|
||||
- GET /invoices/settings - Get invoice settings
|
||||
- POST /invoices/settings - Create invoice settings
|
||||
- PUT /invoices/settings - Update invoice settings
|
||||
- GET /invoices/stats - Get invoice statistics
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.services.invoice_service import (
|
||||
InvoiceNotFoundException,
|
||||
InvoiceSettingsNotFoundException,
|
||||
invoice_service,
|
||||
)
|
||||
from models.database.user import User
|
||||
from models.schema.invoice import (
|
||||
InvoiceCreate,
|
||||
InvoiceListPaginatedResponse,
|
||||
InvoiceListResponse,
|
||||
InvoiceResponse,
|
||||
InvoiceStatusUpdate,
|
||||
VendorInvoiceSettingsCreate,
|
||||
VendorInvoiceSettingsResponse,
|
||||
VendorInvoiceSettingsUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/invoices")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Invoice Settings
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/settings", response_model=VendorInvoiceSettingsResponse | None)
|
||||
def get_invoice_settings(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get vendor invoice settings.
|
||||
|
||||
Returns null if settings not yet configured.
|
||||
"""
|
||||
settings = invoice_service.get_settings(db, current_user.token_vendor_id)
|
||||
if settings:
|
||||
return VendorInvoiceSettingsResponse.model_validate(settings)
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/settings", response_model=VendorInvoiceSettingsResponse, status_code=201)
|
||||
def create_invoice_settings(
|
||||
data: VendorInvoiceSettingsCreate,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Create vendor invoice settings.
|
||||
|
||||
Required before creating invoices. Sets company details,
|
||||
VAT number, invoice numbering preferences, and payment info.
|
||||
"""
|
||||
try:
|
||||
settings = invoice_service.create_settings(
|
||||
db=db,
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
data=data,
|
||||
)
|
||||
return VendorInvoiceSettingsResponse.model_validate(settings)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create invoice settings: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/settings", response_model=VendorInvoiceSettingsResponse)
|
||||
def update_invoice_settings(
|
||||
data: VendorInvoiceSettingsUpdate,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Update vendor invoice settings.
|
||||
"""
|
||||
try:
|
||||
settings = invoice_service.update_settings(
|
||||
db=db,
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
data=data,
|
||||
)
|
||||
return VendorInvoiceSettingsResponse.model_validate(settings)
|
||||
except InvoiceSettingsNotFoundException:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Invoice settings not found. Create settings first.",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update invoice settings: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Invoice Statistics
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def get_invoice_stats(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get invoice statistics for the vendor.
|
||||
|
||||
Returns:
|
||||
- total_invoices: Total number of invoices
|
||||
- total_revenue: Total revenue from issued/paid invoices (EUR)
|
||||
- draft_count: Number of draft invoices
|
||||
- paid_count: Number of paid invoices
|
||||
"""
|
||||
return invoice_service.get_invoice_stats(db, current_user.token_vendor_id)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Invoice CRUD
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("", response_model=InvoiceListPaginatedResponse)
|
||||
def list_invoices(
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
per_page: int = Query(20, ge=1, le=100, description="Items per page"),
|
||||
status: str | None = Query(None, description="Filter by status"),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
List vendor invoices with pagination.
|
||||
|
||||
Supports filtering by status: draft, issued, paid, cancelled
|
||||
"""
|
||||
invoices, total = invoice_service.list_invoices(
|
||||
db=db,
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
status=status,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
)
|
||||
|
||||
# Build response with buyer name from snapshot
|
||||
items = []
|
||||
for inv in invoices:
|
||||
buyer_name = inv.buyer_details.get("name") if inv.buyer_details else None
|
||||
items.append(
|
||||
InvoiceListResponse(
|
||||
id=inv.id,
|
||||
invoice_number=inv.invoice_number,
|
||||
invoice_date=inv.invoice_date,
|
||||
status=inv.status,
|
||||
currency=inv.currency,
|
||||
total_cents=inv.total_cents,
|
||||
order_id=inv.order_id,
|
||||
buyer_name=buyer_name,
|
||||
)
|
||||
)
|
||||
|
||||
pages = (total + per_page - 1) // per_page if total > 0 else 1
|
||||
|
||||
return InvoiceListPaginatedResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{invoice_id}", response_model=InvoiceResponse)
|
||||
def get_invoice(
|
||||
invoice_id: int,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get invoice details by ID.
|
||||
"""
|
||||
try:
|
||||
invoice = invoice_service.get_invoice_or_raise(
|
||||
db=db,
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
invoice_id=invoice_id,
|
||||
)
|
||||
return InvoiceResponse.model_validate(invoice)
|
||||
except InvoiceNotFoundException:
|
||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
|
||||
|
||||
@router.post("", response_model=InvoiceResponse, status_code=201)
|
||||
def create_invoice(
|
||||
data: InvoiceCreate,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Create an invoice from an order.
|
||||
|
||||
Automatically:
|
||||
- Captures seller/buyer details snapshots
|
||||
- Calculates VAT based on buyer country and OSS settings
|
||||
- Generates invoice number
|
||||
"""
|
||||
try:
|
||||
invoice = invoice_service.create_invoice_from_order(
|
||||
db=db,
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
order_id=data.order_id,
|
||||
notes=data.notes,
|
||||
)
|
||||
return InvoiceResponse.model_validate(invoice)
|
||||
except InvoiceSettingsNotFoundException:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invoice settings not configured. Set up invoice settings first.",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create invoice: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{invoice_id}/status", response_model=InvoiceResponse)
|
||||
def update_invoice_status(
|
||||
invoice_id: int,
|
||||
data: InvoiceStatusUpdate,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Update invoice status.
|
||||
|
||||
Valid statuses:
|
||||
- draft: Invoice created but not yet sent
|
||||
- issued: Invoice sent to customer
|
||||
- paid: Payment received
|
||||
- cancelled: Invoice cancelled
|
||||
|
||||
Note: Cancelled invoices cannot be modified.
|
||||
"""
|
||||
try:
|
||||
invoice = invoice_service.update_status(
|
||||
db=db,
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
invoice_id=invoice_id,
|
||||
new_status=data.status,
|
||||
)
|
||||
return InvoiceResponse.model_validate(invoice)
|
||||
except InvoiceNotFoundException:
|
||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update invoice status: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PDF Generation
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.post("/{invoice_id}/pdf")
|
||||
def generate_invoice_pdf(
|
||||
invoice_id: int,
|
||||
regenerate: bool = Query(False, description="Force regenerate if exists"),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Generate PDF for an invoice.
|
||||
|
||||
Set regenerate=true to force regeneration if PDF already exists.
|
||||
Returns the path to the generated PDF.
|
||||
"""
|
||||
try:
|
||||
pdf_path = invoice_service.generate_pdf(
|
||||
db=db,
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
invoice_id=invoice_id,
|
||||
force_regenerate=regenerate,
|
||||
)
|
||||
return {"pdf_path": pdf_path, "message": "PDF generated successfully"}
|
||||
except InvoiceNotFoundException:
|
||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate PDF: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to generate PDF: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{invoice_id}/pdf")
|
||||
def download_invoice_pdf(
|
||||
invoice_id: int,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Download invoice PDF.
|
||||
|
||||
Returns the PDF file for download.
|
||||
Generates PDF if not already generated.
|
||||
"""
|
||||
try:
|
||||
# Check if PDF exists, generate if not
|
||||
pdf_path = invoice_service.get_pdf_path(
|
||||
db=db,
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
invoice_id=invoice_id,
|
||||
)
|
||||
|
||||
if not pdf_path:
|
||||
# Generate PDF
|
||||
pdf_path = invoice_service.generate_pdf(
|
||||
db=db,
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
invoice_id=invoice_id,
|
||||
)
|
||||
|
||||
# Verify file exists
|
||||
if not Path(pdf_path).exists():
|
||||
raise HTTPException(status_code=404, detail="PDF file not found")
|
||||
|
||||
# Get invoice for filename
|
||||
invoice = invoice_service.get_invoice_or_raise(
|
||||
db=db,
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
invoice_id=invoice_id,
|
||||
)
|
||||
|
||||
filename = f"invoice-{invoice.invoice_number}.pdf"
|
||||
|
||||
return FileResponse(
|
||||
path=pdf_path,
|
||||
media_type="application/pdf",
|
||||
filename=filename,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"'
|
||||
},
|
||||
)
|
||||
except InvoiceNotFoundException:
|
||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download PDF: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to download PDF: {str(e)}")
|
||||
164
app/services/invoice_pdf_service.py
Normal file
164
app/services/invoice_pdf_service.py
Normal file
@@ -0,0 +1,164 @@
|
||||
# app/services/invoice_pdf_service.py
|
||||
"""
|
||||
Invoice PDF generation service using WeasyPrint.
|
||||
|
||||
Renders HTML invoice templates to PDF using Jinja2 + WeasyPrint.
|
||||
Stores generated PDFs in the configured storage location.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from models.database.invoice import Invoice
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Template directory
|
||||
TEMPLATE_DIR = Path(__file__).parent.parent / "templates" / "invoices"
|
||||
|
||||
# PDF storage directory (relative to project root)
|
||||
PDF_STORAGE_DIR = Path("storage") / "invoices"
|
||||
|
||||
|
||||
class InvoicePDFService:
|
||||
"""Service for generating invoice PDFs."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the PDF service with Jinja2 environment."""
|
||||
self.env = Environment(
|
||||
loader=FileSystemLoader(str(TEMPLATE_DIR)),
|
||||
autoescape=True,
|
||||
)
|
||||
|
||||
def _ensure_storage_dir(self, vendor_id: int) -> Path:
|
||||
"""Ensure the storage directory exists for a vendor."""
|
||||
storage_path = PDF_STORAGE_DIR / str(vendor_id)
|
||||
storage_path.mkdir(parents=True, exist_ok=True)
|
||||
return storage_path
|
||||
|
||||
def _get_pdf_filename(self, invoice: Invoice) -> str:
|
||||
"""Generate PDF filename for an invoice."""
|
||||
# Sanitize invoice number for filename
|
||||
safe_number = invoice.invoice_number.replace("/", "-").replace("\\", "-")
|
||||
return f"{safe_number}.pdf"
|
||||
|
||||
def generate_pdf(
|
||||
self,
|
||||
db: Session,
|
||||
invoice: Invoice,
|
||||
force_regenerate: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Generate PDF for an invoice.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
invoice: Invoice to generate PDF for
|
||||
force_regenerate: If True, regenerate even if PDF already exists
|
||||
|
||||
Returns:
|
||||
Path to the generated PDF file
|
||||
"""
|
||||
# Check if PDF already exists
|
||||
if invoice.pdf_path and not force_regenerate:
|
||||
if Path(invoice.pdf_path).exists():
|
||||
logger.debug(f"PDF already exists for invoice {invoice.invoice_number}")
|
||||
return invoice.pdf_path
|
||||
|
||||
# Ensure storage directory exists
|
||||
storage_dir = self._ensure_storage_dir(invoice.vendor_id)
|
||||
pdf_filename = self._get_pdf_filename(invoice)
|
||||
pdf_path = storage_dir / pdf_filename
|
||||
|
||||
# Render HTML template
|
||||
html_content = self._render_html(invoice)
|
||||
|
||||
# Generate PDF using WeasyPrint
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
|
||||
html_doc = HTML(string=html_content, base_url=str(TEMPLATE_DIR))
|
||||
html_doc.write_pdf(str(pdf_path))
|
||||
|
||||
logger.info(f"Generated PDF for invoice {invoice.invoice_number} at {pdf_path}")
|
||||
except ImportError:
|
||||
logger.error("WeasyPrint not installed. Install with: pip install weasyprint")
|
||||
raise RuntimeError("WeasyPrint not installed")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate PDF for invoice {invoice.invoice_number}: {e}")
|
||||
raise
|
||||
|
||||
# Update invoice record with PDF path and timestamp
|
||||
invoice.pdf_path = str(pdf_path)
|
||||
invoice.pdf_generated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
|
||||
return str(pdf_path)
|
||||
|
||||
def _render_html(self, invoice: Invoice) -> str:
|
||||
"""Render the invoice HTML template."""
|
||||
template = self.env.get_template("invoice.html")
|
||||
|
||||
# Prepare template context
|
||||
context = {
|
||||
"invoice": invoice,
|
||||
"seller": invoice.seller_details,
|
||||
"buyer": invoice.buyer_details,
|
||||
"line_items": invoice.line_items,
|
||||
"bank_details": invoice.bank_details,
|
||||
"payment_terms": invoice.payment_terms,
|
||||
"footer_text": invoice.footer_text,
|
||||
"now": datetime.now(UTC),
|
||||
}
|
||||
|
||||
return template.render(**context)
|
||||
|
||||
def get_pdf_path(self, invoice: Invoice) -> str | None:
|
||||
"""Get the PDF path for an invoice if it exists."""
|
||||
if invoice.pdf_path and Path(invoice.pdf_path).exists():
|
||||
return invoice.pdf_path
|
||||
return None
|
||||
|
||||
def delete_pdf(self, invoice: Invoice, db: Session) -> bool:
|
||||
"""
|
||||
Delete the PDF file for an invoice.
|
||||
|
||||
Args:
|
||||
invoice: Invoice whose PDF to delete
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
if not invoice.pdf_path:
|
||||
return False
|
||||
|
||||
pdf_path = Path(invoice.pdf_path)
|
||||
if pdf_path.exists():
|
||||
try:
|
||||
pdf_path.unlink()
|
||||
logger.info(f"Deleted PDF for invoice {invoice.invoice_number}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete PDF {pdf_path}: {e}")
|
||||
return False
|
||||
|
||||
# Clear PDF fields
|
||||
invoice.pdf_path = None
|
||||
invoice.pdf_generated_at = None
|
||||
db.commit()
|
||||
|
||||
return True
|
||||
|
||||
def regenerate_pdf(self, db: Session, invoice: Invoice) -> str:
|
||||
"""Force regenerate PDF for an invoice."""
|
||||
return self.generate_pdf(db, invoice, force_regenerate=True)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
invoice_pdf_service = InvoicePDFService()
|
||||
666
app/services/invoice_service.py
Normal file
666
app/services/invoice_service.py
Normal file
@@ -0,0 +1,666 @@
|
||||
# app/services/invoice_service.py
|
||||
"""
|
||||
Invoice service for generating and managing invoices.
|
||||
|
||||
Handles:
|
||||
- Vendor invoice settings management
|
||||
- Invoice generation from orders
|
||||
- VAT calculation (Luxembourg, EU, B2B reverse charge)
|
||||
- Invoice number sequencing
|
||||
- PDF generation (via separate module)
|
||||
|
||||
VAT Logic:
|
||||
- Luxembourg domestic: 17% (standard), 8% (reduced), 3% (super-reduced), 14% (intermediate)
|
||||
- EU cross-border B2C with OSS: Use destination country VAT rate
|
||||
- EU cross-border B2C without OSS: Use Luxembourg VAT rate (origin principle)
|
||||
- EU B2B with valid VAT number: Reverse charge (0% VAT)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import (
|
||||
OrderNotFoundException,
|
||||
ValidationException,
|
||||
)
|
||||
from models.database.invoice import (
|
||||
Invoice,
|
||||
InvoiceStatus,
|
||||
VATRegime,
|
||||
VendorInvoiceSettings,
|
||||
)
|
||||
from models.database.order import Order
|
||||
from models.database.vendor import Vendor
|
||||
from models.schema.invoice import (
|
||||
InvoiceBuyerDetails,
|
||||
InvoiceCreate,
|
||||
InvoiceLineItem,
|
||||
InvoiceManualCreate,
|
||||
InvoiceSellerDetails,
|
||||
VendorInvoiceSettingsCreate,
|
||||
VendorInvoiceSettingsUpdate,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# EU VAT rates by country code (2024 standard rates)
|
||||
EU_VAT_RATES: dict[str, Decimal] = {
|
||||
"AT": Decimal("20.00"), # Austria
|
||||
"BE": Decimal("21.00"), # Belgium
|
||||
"BG": Decimal("20.00"), # Bulgaria
|
||||
"HR": Decimal("25.00"), # Croatia
|
||||
"CY": Decimal("19.00"), # Cyprus
|
||||
"CZ": Decimal("21.00"), # Czech Republic
|
||||
"DK": Decimal("25.00"), # Denmark
|
||||
"EE": Decimal("22.00"), # Estonia
|
||||
"FI": Decimal("24.00"), # Finland
|
||||
"FR": Decimal("20.00"), # France
|
||||
"DE": Decimal("19.00"), # Germany
|
||||
"GR": Decimal("24.00"), # Greece
|
||||
"HU": Decimal("27.00"), # Hungary
|
||||
"IE": Decimal("23.00"), # Ireland
|
||||
"IT": Decimal("22.00"), # Italy
|
||||
"LV": Decimal("21.00"), # Latvia
|
||||
"LT": Decimal("21.00"), # Lithuania
|
||||
"LU": Decimal("17.00"), # Luxembourg (standard)
|
||||
"MT": Decimal("18.00"), # Malta
|
||||
"NL": Decimal("21.00"), # Netherlands
|
||||
"PL": Decimal("23.00"), # Poland
|
||||
"PT": Decimal("23.00"), # Portugal
|
||||
"RO": Decimal("19.00"), # Romania
|
||||
"SK": Decimal("20.00"), # Slovakia
|
||||
"SI": Decimal("22.00"), # Slovenia
|
||||
"ES": Decimal("21.00"), # Spain
|
||||
"SE": Decimal("25.00"), # Sweden
|
||||
}
|
||||
|
||||
# Luxembourg specific VAT rates
|
||||
LU_VAT_RATES = {
|
||||
"standard": Decimal("17.00"),
|
||||
"intermediate": Decimal("14.00"),
|
||||
"reduced": Decimal("8.00"),
|
||||
"super_reduced": Decimal("3.00"),
|
||||
}
|
||||
|
||||
|
||||
class InvoiceNotFoundException(Exception):
|
||||
"""Raised when invoice not found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvoiceSettingsNotFoundException(Exception):
|
||||
"""Raised when vendor invoice settings not found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvoiceService:
|
||||
"""Service for invoice operations."""
|
||||
|
||||
# =========================================================================
|
||||
# VAT Calculation
|
||||
# =========================================================================
|
||||
|
||||
def get_vat_rate_for_country(self, country_iso: str) -> Decimal:
|
||||
"""Get standard VAT rate for EU country."""
|
||||
return EU_VAT_RATES.get(country_iso.upper(), Decimal("0.00"))
|
||||
|
||||
def get_vat_rate_label(self, country_iso: str, vat_rate: Decimal) -> str:
|
||||
"""Get human-readable VAT rate label."""
|
||||
country_names = {
|
||||
"AT": "Austria",
|
||||
"BE": "Belgium",
|
||||
"BG": "Bulgaria",
|
||||
"HR": "Croatia",
|
||||
"CY": "Cyprus",
|
||||
"CZ": "Czech Republic",
|
||||
"DK": "Denmark",
|
||||
"EE": "Estonia",
|
||||
"FI": "Finland",
|
||||
"FR": "France",
|
||||
"DE": "Germany",
|
||||
"GR": "Greece",
|
||||
"HU": "Hungary",
|
||||
"IE": "Ireland",
|
||||
"IT": "Italy",
|
||||
"LV": "Latvia",
|
||||
"LT": "Lithuania",
|
||||
"LU": "Luxembourg",
|
||||
"MT": "Malta",
|
||||
"NL": "Netherlands",
|
||||
"PL": "Poland",
|
||||
"PT": "Portugal",
|
||||
"RO": "Romania",
|
||||
"SK": "Slovakia",
|
||||
"SI": "Slovenia",
|
||||
"ES": "Spain",
|
||||
"SE": "Sweden",
|
||||
}
|
||||
country_name = country_names.get(country_iso.upper(), country_iso)
|
||||
return f"{country_name} VAT {vat_rate}%"
|
||||
|
||||
def determine_vat_regime(
|
||||
self,
|
||||
seller_country: str,
|
||||
buyer_country: str,
|
||||
buyer_vat_number: str | None,
|
||||
seller_oss_registered: bool,
|
||||
) -> tuple[VATRegime, Decimal, str | None]:
|
||||
"""
|
||||
Determine VAT regime and rate for invoice.
|
||||
|
||||
Returns: (regime, vat_rate, destination_country)
|
||||
"""
|
||||
seller_country = seller_country.upper()
|
||||
buyer_country = buyer_country.upper()
|
||||
|
||||
# Same country = domestic VAT
|
||||
if seller_country == buyer_country:
|
||||
vat_rate = self.get_vat_rate_for_country(seller_country)
|
||||
return VATRegime.DOMESTIC, vat_rate, None
|
||||
|
||||
# Different EU countries
|
||||
if buyer_country in EU_VAT_RATES:
|
||||
# B2B with valid VAT number = reverse charge
|
||||
if buyer_vat_number:
|
||||
return VATRegime.REVERSE_CHARGE, Decimal("0.00"), buyer_country
|
||||
|
||||
# B2C cross-border
|
||||
if seller_oss_registered:
|
||||
# OSS: use destination country VAT
|
||||
vat_rate = self.get_vat_rate_for_country(buyer_country)
|
||||
return VATRegime.OSS, vat_rate, buyer_country
|
||||
else:
|
||||
# No OSS: use origin country VAT
|
||||
vat_rate = self.get_vat_rate_for_country(seller_country)
|
||||
return VATRegime.ORIGIN, vat_rate, buyer_country
|
||||
|
||||
# Non-EU = VAT exempt (export)
|
||||
return VATRegime.EXEMPT, Decimal("0.00"), buyer_country
|
||||
|
||||
# =========================================================================
|
||||
# Invoice Settings Management
|
||||
# =========================================================================
|
||||
|
||||
def get_settings(
|
||||
self, db: Session, vendor_id: int
|
||||
) -> VendorInvoiceSettings | None:
|
||||
"""Get vendor invoice settings."""
|
||||
return (
|
||||
db.query(VendorInvoiceSettings)
|
||||
.filter(VendorInvoiceSettings.vendor_id == vendor_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_settings_or_raise(
|
||||
self, db: Session, vendor_id: int
|
||||
) -> VendorInvoiceSettings:
|
||||
"""Get vendor invoice settings or raise exception."""
|
||||
settings = self.get_settings(db, vendor_id)
|
||||
if not settings:
|
||||
raise InvoiceSettingsNotFoundException(
|
||||
f"Invoice settings not configured for vendor {vendor_id}"
|
||||
)
|
||||
return settings
|
||||
|
||||
def create_settings(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
data: VendorInvoiceSettingsCreate,
|
||||
) -> VendorInvoiceSettings:
|
||||
"""Create vendor invoice settings."""
|
||||
# Check if settings already exist
|
||||
existing = self.get_settings(db, vendor_id)
|
||||
if existing:
|
||||
raise ValidationException(
|
||||
"Invoice settings already exist for this vendor"
|
||||
)
|
||||
|
||||
settings = VendorInvoiceSettings(
|
||||
vendor_id=vendor_id,
|
||||
**data.model_dump(),
|
||||
)
|
||||
db.add(settings)
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
|
||||
logger.info(f"Created invoice settings for vendor {vendor_id}")
|
||||
return settings
|
||||
|
||||
def update_settings(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
data: VendorInvoiceSettingsUpdate,
|
||||
) -> VendorInvoiceSettings:
|
||||
"""Update vendor invoice settings."""
|
||||
settings = self.get_settings_or_raise(db, vendor_id)
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
settings.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
|
||||
logger.info(f"Updated invoice settings for vendor {vendor_id}")
|
||||
return settings
|
||||
|
||||
def create_settings_from_vendor(
|
||||
self,
|
||||
db: Session,
|
||||
vendor: Vendor,
|
||||
) -> VendorInvoiceSettings:
|
||||
"""
|
||||
Create invoice settings from vendor/company info.
|
||||
|
||||
Used for initial setup based on existing vendor data.
|
||||
"""
|
||||
company = vendor.company
|
||||
|
||||
settings = VendorInvoiceSettings(
|
||||
vendor_id=vendor.id,
|
||||
company_name=company.legal_name if company else vendor.name,
|
||||
company_address=vendor.effective_business_address,
|
||||
company_city=None, # Would need to parse from address
|
||||
company_postal_code=None,
|
||||
company_country="LU",
|
||||
vat_number=vendor.effective_tax_number,
|
||||
is_vat_registered=bool(vendor.effective_tax_number),
|
||||
)
|
||||
db.add(settings)
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
|
||||
logger.info(f"Created invoice settings from vendor data for vendor {vendor.id}")
|
||||
return settings
|
||||
|
||||
# =========================================================================
|
||||
# Invoice Number Generation
|
||||
# =========================================================================
|
||||
|
||||
def _get_next_invoice_number(
|
||||
self, db: Session, settings: VendorInvoiceSettings
|
||||
) -> str:
|
||||
"""Generate next invoice number and increment counter."""
|
||||
number = str(settings.invoice_next_number).zfill(settings.invoice_number_padding)
|
||||
invoice_number = f"{settings.invoice_prefix}{number}"
|
||||
|
||||
# Increment counter
|
||||
settings.invoice_next_number += 1
|
||||
db.flush()
|
||||
|
||||
return invoice_number
|
||||
|
||||
# =========================================================================
|
||||
# Invoice Creation
|
||||
# =========================================================================
|
||||
|
||||
def create_invoice_from_order(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
order_id: int,
|
||||
notes: str | None = None,
|
||||
) -> Invoice:
|
||||
"""
|
||||
Create an invoice from an order.
|
||||
|
||||
Captures snapshots of seller/buyer details and calculates VAT.
|
||||
"""
|
||||
# Get invoice settings
|
||||
settings = self.get_settings_or_raise(db, vendor_id)
|
||||
|
||||
# Get order
|
||||
order = (
|
||||
db.query(Order)
|
||||
.filter(and_(Order.id == order_id, Order.vendor_id == vendor_id))
|
||||
.first()
|
||||
)
|
||||
if not order:
|
||||
raise OrderNotFoundException(f"Order {order_id} not found")
|
||||
|
||||
# Check for existing invoice
|
||||
existing = (
|
||||
db.query(Invoice)
|
||||
.filter(and_(Invoice.order_id == order_id, Invoice.vendor_id == vendor_id))
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
raise ValidationException(f"Invoice already exists for order {order_id}")
|
||||
|
||||
# Determine VAT regime
|
||||
buyer_country = order.bill_country_iso
|
||||
vat_regime, vat_rate, destination_country = self.determine_vat_regime(
|
||||
seller_country=settings.company_country,
|
||||
buyer_country=buyer_country,
|
||||
buyer_vat_number=None, # TODO: Add B2B VAT number support
|
||||
seller_oss_registered=settings.is_oss_registered,
|
||||
)
|
||||
|
||||
# Build seller details snapshot
|
||||
seller_details = {
|
||||
"company_name": settings.company_name,
|
||||
"address": settings.company_address,
|
||||
"city": settings.company_city,
|
||||
"postal_code": settings.company_postal_code,
|
||||
"country": settings.company_country,
|
||||
"vat_number": settings.vat_number,
|
||||
}
|
||||
|
||||
# Build buyer details snapshot
|
||||
buyer_details = {
|
||||
"name": f"{order.bill_first_name} {order.bill_last_name}".strip(),
|
||||
"email": order.customer_email,
|
||||
"address": order.bill_address_line_1,
|
||||
"city": order.bill_city,
|
||||
"postal_code": order.bill_postal_code,
|
||||
"country": order.bill_country_iso,
|
||||
"vat_number": None, # TODO: B2B support
|
||||
}
|
||||
if order.bill_company:
|
||||
buyer_details["company"] = order.bill_company
|
||||
|
||||
# Build line items from order items
|
||||
line_items = []
|
||||
for item in order.items:
|
||||
line_items.append({
|
||||
"description": item.product_name,
|
||||
"quantity": item.quantity,
|
||||
"unit_price_cents": item.unit_price_cents,
|
||||
"total_cents": item.total_price_cents,
|
||||
"sku": item.product_sku,
|
||||
"ean": item.gtin,
|
||||
})
|
||||
|
||||
# Calculate amounts
|
||||
subtotal_cents = sum(item["total_cents"] for item in line_items)
|
||||
|
||||
# Calculate VAT
|
||||
if vat_rate > 0:
|
||||
vat_amount_cents = int(
|
||||
subtotal_cents * float(vat_rate) / 100
|
||||
)
|
||||
else:
|
||||
vat_amount_cents = 0
|
||||
|
||||
total_cents = subtotal_cents + vat_amount_cents
|
||||
|
||||
# Get VAT label
|
||||
vat_rate_label = None
|
||||
if vat_rate > 0:
|
||||
if destination_country:
|
||||
vat_rate_label = self.get_vat_rate_label(destination_country, vat_rate)
|
||||
else:
|
||||
vat_rate_label = self.get_vat_rate_label(settings.company_country, vat_rate)
|
||||
|
||||
# Generate invoice number
|
||||
invoice_number = self._get_next_invoice_number(db, settings)
|
||||
|
||||
# Create invoice
|
||||
invoice = Invoice(
|
||||
vendor_id=vendor_id,
|
||||
order_id=order_id,
|
||||
invoice_number=invoice_number,
|
||||
invoice_date=datetime.now(UTC),
|
||||
status=InvoiceStatus.DRAFT.value,
|
||||
seller_details=seller_details,
|
||||
buyer_details=buyer_details,
|
||||
line_items=line_items,
|
||||
vat_regime=vat_regime.value,
|
||||
destination_country=destination_country,
|
||||
vat_rate=vat_rate,
|
||||
vat_rate_label=vat_rate_label,
|
||||
currency=order.currency,
|
||||
subtotal_cents=subtotal_cents,
|
||||
vat_amount_cents=vat_amount_cents,
|
||||
total_cents=total_cents,
|
||||
payment_terms=settings.payment_terms,
|
||||
bank_details={
|
||||
"bank_name": settings.bank_name,
|
||||
"iban": settings.bank_iban,
|
||||
"bic": settings.bank_bic,
|
||||
} if settings.bank_iban else None,
|
||||
footer_text=settings.footer_text,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
db.add(invoice)
|
||||
db.commit()
|
||||
db.refresh(invoice)
|
||||
|
||||
logger.info(
|
||||
f"Created invoice {invoice_number} for order {order_id} "
|
||||
f"(vendor={vendor_id}, total={total_cents/100:.2f} EUR, VAT={vat_regime.value})"
|
||||
)
|
||||
|
||||
return invoice
|
||||
|
||||
# =========================================================================
|
||||
# Invoice Retrieval
|
||||
# =========================================================================
|
||||
|
||||
def get_invoice(
|
||||
self, db: Session, vendor_id: int, invoice_id: int
|
||||
) -> Invoice | None:
|
||||
"""Get invoice by ID."""
|
||||
return (
|
||||
db.query(Invoice)
|
||||
.filter(and_(Invoice.id == invoice_id, Invoice.vendor_id == vendor_id))
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_invoice_or_raise(
|
||||
self, db: Session, vendor_id: int, invoice_id: int
|
||||
) -> Invoice:
|
||||
"""Get invoice by ID or raise exception."""
|
||||
invoice = self.get_invoice(db, vendor_id, invoice_id)
|
||||
if not invoice:
|
||||
raise InvoiceNotFoundException(f"Invoice {invoice_id} not found")
|
||||
return invoice
|
||||
|
||||
def get_invoice_by_number(
|
||||
self, db: Session, vendor_id: int, invoice_number: str
|
||||
) -> Invoice | None:
|
||||
"""Get invoice by invoice number."""
|
||||
return (
|
||||
db.query(Invoice)
|
||||
.filter(
|
||||
and_(
|
||||
Invoice.invoice_number == invoice_number,
|
||||
Invoice.vendor_id == vendor_id,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def list_invoices(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
per_page: int = 20,
|
||||
) -> tuple[list[Invoice], int]:
|
||||
"""
|
||||
List invoices for vendor with pagination.
|
||||
|
||||
Returns: (invoices, total_count)
|
||||
"""
|
||||
query = db.query(Invoice).filter(Invoice.vendor_id == vendor_id)
|
||||
|
||||
if status:
|
||||
query = query.filter(Invoice.status == status)
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
|
||||
# Apply pagination and order
|
||||
invoices = (
|
||||
query.order_by(Invoice.invoice_date.desc())
|
||||
.offset((page - 1) * per_page)
|
||||
.limit(per_page)
|
||||
.all()
|
||||
)
|
||||
|
||||
return invoices, total
|
||||
|
||||
# =========================================================================
|
||||
# Invoice Status Management
|
||||
# =========================================================================
|
||||
|
||||
def update_status(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
invoice_id: int,
|
||||
new_status: str,
|
||||
) -> Invoice:
|
||||
"""Update invoice status."""
|
||||
invoice = self.get_invoice_or_raise(db, vendor_id, invoice_id)
|
||||
|
||||
# Validate status transition
|
||||
valid_statuses = [s.value for s in InvoiceStatus]
|
||||
if new_status not in valid_statuses:
|
||||
raise ValidationException(f"Invalid status: {new_status}")
|
||||
|
||||
# Cannot change cancelled invoices
|
||||
if invoice.status == InvoiceStatus.CANCELLED.value:
|
||||
raise ValidationException("Cannot change status of cancelled invoice")
|
||||
|
||||
invoice.status = new_status
|
||||
invoice.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
db.refresh(invoice)
|
||||
|
||||
logger.info(f"Updated invoice {invoice.invoice_number} status to {new_status}")
|
||||
return invoice
|
||||
|
||||
def mark_as_issued(
|
||||
self, db: Session, vendor_id: int, invoice_id: int
|
||||
) -> Invoice:
|
||||
"""Mark invoice as issued."""
|
||||
return self.update_status(db, vendor_id, invoice_id, InvoiceStatus.ISSUED.value)
|
||||
|
||||
def mark_as_paid(
|
||||
self, db: Session, vendor_id: int, invoice_id: int
|
||||
) -> Invoice:
|
||||
"""Mark invoice as paid."""
|
||||
return self.update_status(db, vendor_id, invoice_id, InvoiceStatus.PAID.value)
|
||||
|
||||
def cancel_invoice(
|
||||
self, db: Session, vendor_id: int, invoice_id: int
|
||||
) -> Invoice:
|
||||
"""Cancel invoice."""
|
||||
return self.update_status(
|
||||
db, vendor_id, invoice_id, InvoiceStatus.CANCELLED.value
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Statistics
|
||||
# =========================================================================
|
||||
|
||||
def get_invoice_stats(
|
||||
self, db: Session, vendor_id: int
|
||||
) -> dict[str, Any]:
|
||||
"""Get invoice statistics for vendor."""
|
||||
total_count = (
|
||||
db.query(func.count(Invoice.id))
|
||||
.filter(Invoice.vendor_id == vendor_id)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
total_revenue = (
|
||||
db.query(func.sum(Invoice.total_cents))
|
||||
.filter(
|
||||
and_(
|
||||
Invoice.vendor_id == vendor_id,
|
||||
Invoice.status.in_([
|
||||
InvoiceStatus.ISSUED.value,
|
||||
InvoiceStatus.PAID.value,
|
||||
]),
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
draft_count = (
|
||||
db.query(func.count(Invoice.id))
|
||||
.filter(
|
||||
and_(
|
||||
Invoice.vendor_id == vendor_id,
|
||||
Invoice.status == InvoiceStatus.DRAFT.value,
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
paid_count = (
|
||||
db.query(func.count(Invoice.id))
|
||||
.filter(
|
||||
and_(
|
||||
Invoice.vendor_id == vendor_id,
|
||||
Invoice.status == InvoiceStatus.PAID.value,
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
return {
|
||||
"total_invoices": total_count,
|
||||
"total_revenue_cents": total_revenue,
|
||||
"total_revenue": total_revenue / 100 if total_revenue else 0,
|
||||
"draft_count": draft_count,
|
||||
"paid_count": paid_count,
|
||||
}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# PDF Generation
|
||||
# =========================================================================
|
||||
|
||||
def generate_pdf(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
invoice_id: int,
|
||||
force_regenerate: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Generate PDF for an invoice.
|
||||
|
||||
Returns path to the generated PDF.
|
||||
"""
|
||||
from app.services.invoice_pdf_service import invoice_pdf_service
|
||||
|
||||
invoice = self.get_invoice_or_raise(db, vendor_id, invoice_id)
|
||||
return invoice_pdf_service.generate_pdf(db, invoice, force_regenerate)
|
||||
|
||||
def get_pdf_path(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
invoice_id: int,
|
||||
) -> str | None:
|
||||
"""Get PDF path for an invoice if it exists."""
|
||||
from app.services.invoice_pdf_service import invoice_pdf_service
|
||||
|
||||
invoice = self.get_invoice_or_raise(db, vendor_id, invoice_id)
|
||||
return invoice_pdf_service.get_pdf_path(invoice)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
invoice_service = InvoiceService()
|
||||
@@ -15,6 +15,7 @@ from sqlalchemy import String, and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.order_service import order_service as unified_order_service
|
||||
from app.services.subscription_service import subscription_service
|
||||
from models.database.letzshop import (
|
||||
LetzshopFulfillmentQueue,
|
||||
LetzshopHistoricalImportJob,
|
||||
@@ -792,6 +793,7 @@ class LetzshopOrderService:
|
||||
"updated": 0,
|
||||
"skipped": 0,
|
||||
"errors": 0,
|
||||
"limit_exceeded": 0,
|
||||
"products_matched": 0,
|
||||
"products_not_found": 0,
|
||||
"eans_processed": set(),
|
||||
@@ -800,6 +802,10 @@ class LetzshopOrderService:
|
||||
"error_messages": [],
|
||||
}
|
||||
|
||||
# Get subscription usage upfront for batch efficiency
|
||||
usage = subscription_service.get_usage(self.db, vendor_id)
|
||||
orders_remaining = usage.orders_remaining # None = unlimited
|
||||
|
||||
for i, shipment in enumerate(shipments):
|
||||
shipment_id = shipment.get("id")
|
||||
if not shipment_id:
|
||||
@@ -844,11 +850,24 @@ class LetzshopOrderService:
|
||||
else:
|
||||
stats["skipped"] += 1
|
||||
else:
|
||||
# Check tier limit before creating order
|
||||
if orders_remaining is not None and orders_remaining <= 0:
|
||||
stats["limit_exceeded"] += 1
|
||||
stats["error_messages"].append(
|
||||
f"Shipment {shipment_id}: Order limit reached"
|
||||
)
|
||||
continue
|
||||
|
||||
# Create new order using unified service
|
||||
try:
|
||||
self.create_order(vendor_id, shipment)
|
||||
self.db.commit() # noqa: SVC-006 - background task needs incremental commits
|
||||
stats["imported"] += 1
|
||||
|
||||
# Decrement remaining count for batch efficiency
|
||||
if orders_remaining is not None:
|
||||
orders_remaining -= 1
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback() # Rollback failed order
|
||||
stats["errors"] += 1
|
||||
|
||||
@@ -31,6 +31,10 @@ from app.exceptions import (
|
||||
ValidationException,
|
||||
)
|
||||
from app.services.order_item_exception_service import order_item_exception_service
|
||||
from app.services.subscription_service import (
|
||||
subscription_service,
|
||||
TierLimitExceededException,
|
||||
)
|
||||
from app.utils.money import Money, cents_to_euros, euros_to_cents
|
||||
from models.database.customer import Customer
|
||||
from models.database.marketplace_product import MarketplaceProduct
|
||||
@@ -271,7 +275,11 @@ class OrderService:
|
||||
Raises:
|
||||
ValidationException: If order data is invalid
|
||||
InsufficientInventoryException: If not enough inventory
|
||||
TierLimitExceededException: If vendor has reached order limit
|
||||
"""
|
||||
# Check tier limit before creating order
|
||||
subscription_service.check_order_limit(db, vendor_id)
|
||||
|
||||
try:
|
||||
# Get or create customer
|
||||
if order_data.customer_id:
|
||||
@@ -428,6 +436,9 @@ class OrderService:
|
||||
db.flush()
|
||||
db.refresh(order)
|
||||
|
||||
# Increment order count for subscription tracking
|
||||
subscription_service.increment_order_count(db, vendor_id)
|
||||
|
||||
logger.info(
|
||||
f"Order {order.order_number} created for vendor {vendor_id}, "
|
||||
f"total: EUR {cents_to_euros(total_amount_cents):.2f}"
|
||||
@@ -439,6 +450,7 @@ class OrderService:
|
||||
ValidationException,
|
||||
InsufficientInventoryException,
|
||||
CustomerNotFoundException,
|
||||
TierLimitExceededException,
|
||||
):
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -450,6 +462,7 @@ class OrderService:
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
shipment_data: dict[str, Any],
|
||||
skip_limit_check: bool = False,
|
||||
) -> Order:
|
||||
"""
|
||||
Create an order from Letzshop shipment data.
|
||||
@@ -462,13 +475,27 @@ class OrderService:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
shipment_data: Raw shipment data from Letzshop API
|
||||
skip_limit_check: If True, skip tier limit check (for batch imports
|
||||
that check limit upfront)
|
||||
|
||||
Returns:
|
||||
Created Order object
|
||||
|
||||
Raises:
|
||||
ValidationException: If product not found by GTIN
|
||||
TierLimitExceededException: If vendor has reached order limit
|
||||
"""
|
||||
# Check tier limit before creating order (unless skipped for batch ops)
|
||||
if not skip_limit_check:
|
||||
can_create, message = subscription_service.can_create_order(db, vendor_id)
|
||||
if not can_create:
|
||||
raise TierLimitExceededException(
|
||||
message=message or "Order limit exceeded",
|
||||
limit_type="orders",
|
||||
current=0, # Will be filled by caller if needed
|
||||
limit=0,
|
||||
)
|
||||
|
||||
order_data = shipment_data.get("order", {})
|
||||
|
||||
# Generate order number using Letzshop order number
|
||||
@@ -777,6 +804,9 @@ class OrderService:
|
||||
f"order {order.order_number}"
|
||||
)
|
||||
|
||||
# Increment order count for subscription tracking
|
||||
subscription_service.increment_order_count(db, vendor_id)
|
||||
|
||||
logger.info(
|
||||
f"Letzshop order {order.order_number} created for vendor {vendor_id}, "
|
||||
f"status: {status}, items: {len(inventory_units)}"
|
||||
|
||||
512
app/services/subscription_service.py
Normal file
512
app/services/subscription_service.py
Normal file
@@ -0,0 +1,512 @@
|
||||
# app/services/subscription_service.py
|
||||
"""
|
||||
Subscription service for tier-based access control.
|
||||
|
||||
Handles:
|
||||
- Subscription creation and management
|
||||
- Tier limit enforcement
|
||||
- Usage tracking
|
||||
- Feature gating
|
||||
|
||||
Usage:
|
||||
from app.services.subscription_service import subscription_service
|
||||
|
||||
# Check if vendor can create an order
|
||||
can_create, message = subscription_service.can_create_order(db, vendor_id)
|
||||
|
||||
# Increment order counter after successful order
|
||||
subscription_service.increment_order_count(db, vendor_id)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.database.product import Product
|
||||
from models.database.subscription import (
|
||||
SubscriptionStatus,
|
||||
TIER_LIMITS,
|
||||
TierCode,
|
||||
VendorSubscription,
|
||||
)
|
||||
from models.database.vendor import Vendor, VendorUser
|
||||
from models.schema.subscription import (
|
||||
SubscriptionCreate,
|
||||
SubscriptionUpdate,
|
||||
SubscriptionUsage,
|
||||
TierInfo,
|
||||
TierLimits,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SubscriptionNotFoundException(Exception):
|
||||
"""Raised when subscription not found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TierLimitExceededException(Exception):
|
||||
"""Raised when a tier limit is exceeded."""
|
||||
|
||||
def __init__(self, message: str, limit_type: str, current: int, limit: int):
|
||||
super().__init__(message)
|
||||
self.limit_type = limit_type
|
||||
self.current = current
|
||||
self.limit = limit
|
||||
|
||||
|
||||
class FeatureNotAvailableException(Exception):
|
||||
"""Raised when a feature is not available in current tier."""
|
||||
|
||||
def __init__(self, feature: str, current_tier: str, required_tier: str):
|
||||
message = f"Feature '{feature}' requires {required_tier} tier (current: {current_tier})"
|
||||
super().__init__(message)
|
||||
self.feature = feature
|
||||
self.current_tier = current_tier
|
||||
self.required_tier = required_tier
|
||||
|
||||
|
||||
class SubscriptionService:
|
||||
"""Service for subscription and tier limit operations."""
|
||||
|
||||
# =========================================================================
|
||||
# Tier Information
|
||||
# =========================================================================
|
||||
|
||||
def get_tier_info(self, tier_code: str) -> TierInfo:
|
||||
"""Get full tier information."""
|
||||
try:
|
||||
tier = TierCode(tier_code)
|
||||
except ValueError:
|
||||
tier = TierCode.ESSENTIAL
|
||||
|
||||
limits = TIER_LIMITS[tier]
|
||||
return TierInfo(
|
||||
code=tier.value,
|
||||
name=limits["name"],
|
||||
price_monthly_cents=limits["price_monthly_cents"],
|
||||
price_annual_cents=limits.get("price_annual_cents"),
|
||||
limits=TierLimits(
|
||||
orders_per_month=limits.get("orders_per_month"),
|
||||
products_limit=limits.get("products_limit"),
|
||||
team_members=limits.get("team_members"),
|
||||
order_history_months=limits.get("order_history_months"),
|
||||
),
|
||||
features=limits.get("features", []),
|
||||
)
|
||||
|
||||
def get_all_tiers(self) -> list[TierInfo]:
|
||||
"""Get information for all tiers."""
|
||||
return [
|
||||
self.get_tier_info(tier.value)
|
||||
for tier in TierCode
|
||||
]
|
||||
|
||||
# =========================================================================
|
||||
# Subscription CRUD
|
||||
# =========================================================================
|
||||
|
||||
def get_subscription(
|
||||
self, db: Session, vendor_id: int
|
||||
) -> VendorSubscription | None:
|
||||
"""Get vendor subscription."""
|
||||
return (
|
||||
db.query(VendorSubscription)
|
||||
.filter(VendorSubscription.vendor_id == vendor_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_subscription_or_raise(
|
||||
self, db: Session, vendor_id: int
|
||||
) -> VendorSubscription:
|
||||
"""Get vendor subscription or raise exception."""
|
||||
subscription = self.get_subscription(db, vendor_id)
|
||||
if not subscription:
|
||||
raise SubscriptionNotFoundException(
|
||||
f"No subscription found for vendor {vendor_id}"
|
||||
)
|
||||
return subscription
|
||||
|
||||
def get_or_create_subscription(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
tier: str = TierCode.ESSENTIAL.value,
|
||||
trial_days: int = 14,
|
||||
) -> VendorSubscription:
|
||||
"""
|
||||
Get existing subscription or create a new trial subscription.
|
||||
|
||||
Used when a vendor first accesses the system.
|
||||
"""
|
||||
subscription = self.get_subscription(db, vendor_id)
|
||||
if subscription:
|
||||
return subscription
|
||||
|
||||
# Create new trial subscription
|
||||
now = datetime.now(UTC)
|
||||
trial_end = now + timedelta(days=trial_days)
|
||||
|
||||
subscription = VendorSubscription(
|
||||
vendor_id=vendor_id,
|
||||
tier=tier,
|
||||
status=SubscriptionStatus.TRIAL.value,
|
||||
period_start=now,
|
||||
period_end=trial_end,
|
||||
trial_ends_at=trial_end,
|
||||
is_annual=False,
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
db.commit()
|
||||
db.refresh(subscription)
|
||||
|
||||
logger.info(
|
||||
f"Created trial subscription for vendor {vendor_id} "
|
||||
f"(tier={tier}, trial_ends={trial_end})"
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
def create_subscription(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
data: SubscriptionCreate,
|
||||
) -> VendorSubscription:
|
||||
"""Create a subscription for a vendor."""
|
||||
# Check if subscription exists
|
||||
existing = self.get_subscription(db, vendor_id)
|
||||
if existing:
|
||||
raise ValueError("Vendor already has a subscription")
|
||||
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Calculate period end based on billing cycle
|
||||
if data.is_annual:
|
||||
period_end = now + timedelta(days=365)
|
||||
else:
|
||||
period_end = now + timedelta(days=30)
|
||||
|
||||
# Handle trial
|
||||
trial_ends_at = None
|
||||
status = SubscriptionStatus.ACTIVE.value
|
||||
if data.trial_days > 0:
|
||||
trial_ends_at = now + timedelta(days=data.trial_days)
|
||||
status = SubscriptionStatus.TRIAL.value
|
||||
period_end = trial_ends_at
|
||||
|
||||
subscription = VendorSubscription(
|
||||
vendor_id=vendor_id,
|
||||
tier=data.tier,
|
||||
status=status,
|
||||
period_start=now,
|
||||
period_end=period_end,
|
||||
trial_ends_at=trial_ends_at,
|
||||
is_annual=data.is_annual,
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
db.commit()
|
||||
db.refresh(subscription)
|
||||
|
||||
logger.info(f"Created subscription for vendor {vendor_id}: {data.tier}")
|
||||
return subscription
|
||||
|
||||
def update_subscription(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
data: SubscriptionUpdate,
|
||||
) -> VendorSubscription:
|
||||
"""Update a vendor subscription."""
|
||||
subscription = self.get_subscription_or_raise(db, vendor_id)
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(subscription, key, value)
|
||||
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
db.refresh(subscription)
|
||||
|
||||
logger.info(f"Updated subscription for vendor {vendor_id}")
|
||||
return subscription
|
||||
|
||||
def upgrade_tier(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
new_tier: str,
|
||||
) -> VendorSubscription:
|
||||
"""Upgrade vendor to a new tier."""
|
||||
subscription = self.get_subscription_or_raise(db, vendor_id)
|
||||
|
||||
old_tier = subscription.tier
|
||||
subscription.tier = new_tier
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
# If upgrading from trial, mark as active
|
||||
if subscription.status == SubscriptionStatus.TRIAL.value:
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
|
||||
db.commit()
|
||||
db.refresh(subscription)
|
||||
|
||||
logger.info(f"Upgraded vendor {vendor_id} from {old_tier} to {new_tier}")
|
||||
return subscription
|
||||
|
||||
def cancel_subscription(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
reason: str | None = None,
|
||||
) -> VendorSubscription:
|
||||
"""Cancel a vendor subscription (access until period end)."""
|
||||
subscription = self.get_subscription_or_raise(db, vendor_id)
|
||||
|
||||
subscription.status = SubscriptionStatus.CANCELLED.value
|
||||
subscription.cancelled_at = datetime.now(UTC)
|
||||
subscription.cancellation_reason = reason
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
db.commit()
|
||||
db.refresh(subscription)
|
||||
|
||||
logger.info(f"Cancelled subscription for vendor {vendor_id}")
|
||||
return subscription
|
||||
|
||||
# =========================================================================
|
||||
# Usage Tracking
|
||||
# =========================================================================
|
||||
|
||||
def get_usage(self, db: Session, vendor_id: int) -> SubscriptionUsage:
|
||||
"""Get current subscription usage statistics."""
|
||||
subscription = self.get_or_create_subscription(db, vendor_id)
|
||||
|
||||
# Get actual counts
|
||||
products_count = (
|
||||
db.query(func.count(Product.id))
|
||||
.filter(Product.vendor_id == vendor_id)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
team_count = (
|
||||
db.query(func.count(VendorUser.id))
|
||||
.filter(VendorUser.vendor_id == vendor_id, VendorUser.is_active == True)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Calculate usage stats
|
||||
orders_limit = subscription.orders_limit
|
||||
products_limit = subscription.products_limit
|
||||
team_limit = subscription.team_members_limit
|
||||
|
||||
def calc_remaining(current: int, limit: int | None) -> int | None:
|
||||
if limit is None:
|
||||
return None
|
||||
return max(0, limit - current)
|
||||
|
||||
def calc_percent(current: int, limit: int | None) -> float | None:
|
||||
if limit is None or limit == 0:
|
||||
return None
|
||||
return min(100.0, (current / limit) * 100)
|
||||
|
||||
return SubscriptionUsage(
|
||||
orders_used=subscription.orders_this_period,
|
||||
orders_limit=orders_limit,
|
||||
orders_remaining=calc_remaining(subscription.orders_this_period, orders_limit),
|
||||
orders_percent_used=calc_percent(subscription.orders_this_period, orders_limit),
|
||||
products_used=products_count,
|
||||
products_limit=products_limit,
|
||||
products_remaining=calc_remaining(products_count, products_limit),
|
||||
products_percent_used=calc_percent(products_count, products_limit),
|
||||
team_members_used=team_count,
|
||||
team_members_limit=team_limit,
|
||||
team_members_remaining=calc_remaining(team_count, team_limit),
|
||||
team_members_percent_used=calc_percent(team_count, team_limit),
|
||||
)
|
||||
|
||||
def increment_order_count(self, db: Session, vendor_id: int) -> None:
|
||||
"""
|
||||
Increment the order counter for the current period.
|
||||
|
||||
Call this after successfully creating/importing an order.
|
||||
"""
|
||||
subscription = self.get_or_create_subscription(db, vendor_id)
|
||||
subscription.increment_order_count()
|
||||
db.commit()
|
||||
|
||||
def reset_period_counters(self, db: Session, vendor_id: int) -> None:
|
||||
"""Reset counters for a new billing period."""
|
||||
subscription = self.get_subscription_or_raise(db, vendor_id)
|
||||
subscription.reset_period_counters()
|
||||
db.commit()
|
||||
logger.info(f"Reset period counters for vendor {vendor_id}")
|
||||
|
||||
# =========================================================================
|
||||
# Limit Checks
|
||||
# =========================================================================
|
||||
|
||||
def can_create_order(
|
||||
self, db: Session, vendor_id: int
|
||||
) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Check if vendor can create/import another order.
|
||||
|
||||
Returns: (allowed, error_message)
|
||||
"""
|
||||
subscription = self.get_or_create_subscription(db, vendor_id)
|
||||
return subscription.can_create_order()
|
||||
|
||||
def check_order_limit(self, db: Session, vendor_id: int) -> None:
|
||||
"""
|
||||
Check order limit and raise exception if exceeded.
|
||||
|
||||
Use this in order creation flows.
|
||||
"""
|
||||
can_create, message = self.can_create_order(db, vendor_id)
|
||||
if not can_create:
|
||||
subscription = self.get_subscription(db, vendor_id)
|
||||
raise TierLimitExceededException(
|
||||
message=message or "Order limit exceeded",
|
||||
limit_type="orders",
|
||||
current=subscription.orders_this_period if subscription else 0,
|
||||
limit=subscription.orders_limit if subscription else 0,
|
||||
)
|
||||
|
||||
def can_add_product(
|
||||
self, db: Session, vendor_id: int
|
||||
) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Check if vendor can add another product.
|
||||
|
||||
Returns: (allowed, error_message)
|
||||
"""
|
||||
subscription = self.get_or_create_subscription(db, vendor_id)
|
||||
|
||||
products_count = (
|
||||
db.query(func.count(Product.id))
|
||||
.filter(Product.vendor_id == vendor_id)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
return subscription.can_add_product(products_count)
|
||||
|
||||
def check_product_limit(self, db: Session, vendor_id: int) -> None:
|
||||
"""
|
||||
Check product limit and raise exception if exceeded.
|
||||
|
||||
Use this in product creation flows.
|
||||
"""
|
||||
can_add, message = self.can_add_product(db, vendor_id)
|
||||
if not can_add:
|
||||
subscription = self.get_subscription(db, vendor_id)
|
||||
products_count = (
|
||||
db.query(func.count(Product.id))
|
||||
.filter(Product.vendor_id == vendor_id)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
raise TierLimitExceededException(
|
||||
message=message or "Product limit exceeded",
|
||||
limit_type="products",
|
||||
current=products_count,
|
||||
limit=subscription.products_limit if subscription else 0,
|
||||
)
|
||||
|
||||
def can_add_team_member(
|
||||
self, db: Session, vendor_id: int
|
||||
) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Check if vendor can add another team member.
|
||||
|
||||
Returns: (allowed, error_message)
|
||||
"""
|
||||
subscription = self.get_or_create_subscription(db, vendor_id)
|
||||
|
||||
team_count = (
|
||||
db.query(func.count(VendorUser.id))
|
||||
.filter(VendorUser.vendor_id == vendor_id, VendorUser.is_active == True)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
return subscription.can_add_team_member(team_count)
|
||||
|
||||
def check_team_limit(self, db: Session, vendor_id: int) -> None:
|
||||
"""
|
||||
Check team member limit and raise exception if exceeded.
|
||||
|
||||
Use this in team member invitation flows.
|
||||
"""
|
||||
can_add, message = self.can_add_team_member(db, vendor_id)
|
||||
if not can_add:
|
||||
subscription = self.get_subscription(db, vendor_id)
|
||||
team_count = (
|
||||
db.query(func.count(VendorUser.id))
|
||||
.filter(VendorUser.vendor_id == vendor_id, VendorUser.is_active == True)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
raise TierLimitExceededException(
|
||||
message=message or "Team member limit exceeded",
|
||||
limit_type="team_members",
|
||||
current=team_count,
|
||||
limit=subscription.team_members_limit if subscription else 0,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Feature Gating
|
||||
# =========================================================================
|
||||
|
||||
def has_feature(self, db: Session, vendor_id: int, feature: str) -> bool:
|
||||
"""Check if vendor has access to a feature."""
|
||||
subscription = self.get_or_create_subscription(db, vendor_id)
|
||||
return subscription.has_feature(feature)
|
||||
|
||||
def check_feature(self, db: Session, vendor_id: int, feature: str) -> None:
|
||||
"""
|
||||
Check feature access and raise exception if not available.
|
||||
|
||||
Use this to gate premium features.
|
||||
"""
|
||||
if not self.has_feature(db, vendor_id, feature):
|
||||
subscription = self.get_or_create_subscription(db, vendor_id)
|
||||
|
||||
# Find which tier has this feature
|
||||
required_tier = None
|
||||
for tier_code, limits in TIER_LIMITS.items():
|
||||
if feature in limits.get("features", []):
|
||||
required_tier = limits["name"]
|
||||
break
|
||||
|
||||
raise FeatureNotAvailableException(
|
||||
feature=feature,
|
||||
current_tier=subscription.tier,
|
||||
required_tier=required_tier or "higher",
|
||||
)
|
||||
|
||||
def get_feature_tier(self, feature: str) -> str | None:
|
||||
"""Get the minimum tier required for a feature."""
|
||||
for tier_code in [
|
||||
TierCode.ESSENTIAL,
|
||||
TierCode.PROFESSIONAL,
|
||||
TierCode.BUSINESS,
|
||||
TierCode.ENTERPRISE,
|
||||
]:
|
||||
if feature in TIER_LIMITS[tier_code].get("features", []):
|
||||
return tier_code.value
|
||||
return None
|
||||
|
||||
|
||||
# Singleton instance
|
||||
subscription_service = SubscriptionService()
|
||||
470
app/templates/invoices/invoice.html
Normal file
470
app/templates/invoices/invoice.html
Normal file
@@ -0,0 +1,470 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Invoice {{ invoice.invoice_number }}</title>
|
||||
<style>
|
||||
/* Reset and base styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
font-size: 10pt;
|
||||
line-height: 1.4;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* Page setup for A4 */
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 20mm 15mm 20mm 15mm;
|
||||
}
|
||||
|
||||
.invoice-container {
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #2563eb;
|
||||
}
|
||||
|
||||
.company-info {
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.company-name {
|
||||
font-size: 18pt;
|
||||
font-weight: bold;
|
||||
color: #1e40af;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.company-details {
|
||||
font-size: 9pt;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.invoice-title {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.invoice-title h1 {
|
||||
font-size: 24pt;
|
||||
color: #1e40af;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.invoice-meta {
|
||||
font-size: 10pt;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.invoice-meta strong {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Addresses section */
|
||||
.addresses {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 30px;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.address-block {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.address-label {
|
||||
font-size: 8pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #888;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.address-content {
|
||||
background: #f8fafc;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #2563eb;
|
||||
}
|
||||
|
||||
.address-name {
|
||||
font-weight: bold;
|
||||
font-size: 11pt;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.address-details {
|
||||
font-size: 9pt;
|
||||
color: #555;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* VAT info badge */
|
||||
.vat-badge {
|
||||
display: inline-block;
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 8pt;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Items table */
|
||||
.items-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.items-table thead {
|
||||
background: #1e40af;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.items-table th {
|
||||
padding: 12px 10px;
|
||||
text-align: left;
|
||||
font-size: 9pt;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.items-table th.number {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.items-table td {
|
||||
padding: 12px 10px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.items-table td.number {
|
||||
text-align: right;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.items-table tbody tr:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.item-sku {
|
||||
color: #888;
|
||||
font-size: 8pt;
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Totals */
|
||||
.totals-section {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.totals-table {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.totals-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.totals-row.total {
|
||||
border-bottom: none;
|
||||
border-top: 2px solid #1e40af;
|
||||
margin-top: 5px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.totals-label {
|
||||
color: #666;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.totals-value {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.totals-row.total .totals-label,
|
||||
.totals-row.total .totals-value {
|
||||
font-weight: bold;
|
||||
font-size: 12pt;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
/* VAT regime note */
|
||||
.vat-note {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 6px;
|
||||
padding: 10px 15px;
|
||||
font-size: 8pt;
|
||||
color: #92400e;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Payment info */
|
||||
.payment-section {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #22c55e;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.payment-title {
|
||||
font-weight: bold;
|
||||
color: #166534;
|
||||
margin-bottom: 10px;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.payment-details {
|
||||
font-size: 9pt;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.payment-details .label {
|
||||
color: #666;
|
||||
display: inline-block;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
font-size: 8pt;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* Status badge */
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 9pt;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status-draft {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.status-issued {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.status-paid {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.status-cancelled {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
body {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="invoice-container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="company-info">
|
||||
<div class="company-name">{{ seller.company_name }}</div>
|
||||
<div class="company-details">
|
||||
{% if seller.address %}{{ seller.address }}<br>{% endif %}
|
||||
{% if seller.postal_code or seller.city %}
|
||||
{{ seller.postal_code }} {{ seller.city }}<br>
|
||||
{% endif %}
|
||||
{{ seller.country }}
|
||||
{% if seller.vat_number %}
|
||||
<br>VAT: {{ seller.vat_number }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="invoice-title">
|
||||
<h1>INVOICE</h1>
|
||||
<div class="invoice-meta">
|
||||
<strong>{{ invoice.invoice_number }}</strong><br>
|
||||
Date: {{ invoice.invoice_date.strftime('%d/%m/%Y') }}<br>
|
||||
<span class="status-badge status-{{ invoice.status }}">{{ invoice.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Addresses -->
|
||||
<div class="addresses">
|
||||
<div class="address-block">
|
||||
<div class="address-label">Bill To</div>
|
||||
<div class="address-content">
|
||||
<div class="address-name">{{ buyer.name }}</div>
|
||||
<div class="address-details">
|
||||
{% if buyer.get('company') %}{{ buyer.company }}<br>{% endif %}
|
||||
{% if buyer.address %}{{ buyer.address }}<br>{% endif %}
|
||||
{% if buyer.postal_code or buyer.city %}
|
||||
{{ buyer.postal_code }} {{ buyer.city }}<br>
|
||||
{% endif %}
|
||||
{{ buyer.country }}
|
||||
{% if buyer.email %}<br>{{ buyer.email }}{% endif %}
|
||||
</div>
|
||||
{% if buyer.vat_number %}
|
||||
<div class="vat-badge">VAT: {{ buyer.vat_number }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if invoice.order_id %}
|
||||
<div class="address-block">
|
||||
<div class="address-label">Order Reference</div>
|
||||
<div class="address-content">
|
||||
<div class="address-details">
|
||||
Order #{{ invoice.order_id }}<br>
|
||||
Currency: {{ invoice.currency }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- VAT Regime Note -->
|
||||
{% if invoice.vat_regime == 'reverse_charge' %}
|
||||
<div class="vat-note">
|
||||
<strong>Reverse Charge:</strong> VAT to be accounted for by the recipient pursuant to Article 196 of Council Directive 2006/112/EC.
|
||||
</div>
|
||||
{% elif invoice.vat_regime == 'oss' %}
|
||||
<div class="vat-note">
|
||||
<strong>OSS Invoice:</strong> VAT charged at {{ invoice.vat_rate }}% ({{ invoice.destination_country }} rate) under One-Stop-Shop scheme.
|
||||
</div>
|
||||
{% elif invoice.vat_regime == 'exempt' %}
|
||||
<div class="vat-note">
|
||||
<strong>VAT Exempt:</strong> Export outside EU - VAT not applicable.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Items Table -->
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50%">Description</th>
|
||||
<th class="number" style="width: 10%">Qty</th>
|
||||
<th class="number" style="width: 20%">Unit Price</th>
|
||||
<th class="number" style="width: 20%">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in line_items %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ item.description }}
|
||||
{% if item.sku or item.ean %}
|
||||
<span class="item-sku">
|
||||
{% if item.sku %}SKU: {{ item.sku }}{% endif %}
|
||||
{% if item.sku and item.ean %} | {% endif %}
|
||||
{% if item.ean %}EAN: {{ item.ean }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="number">{{ item.quantity }}</td>
|
||||
<td class="number">{{ "%.2f"|format(item.unit_price_cents / 100) }} {{ invoice.currency }}</td>
|
||||
<td class="number">{{ "%.2f"|format(item.total_cents / 100) }} {{ invoice.currency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Totals -->
|
||||
<div class="totals-section">
|
||||
<div class="totals-table">
|
||||
<div class="totals-row">
|
||||
<span class="totals-label">Subtotal</span>
|
||||
<span class="totals-value">{{ "%.2f"|format(invoice.subtotal_cents / 100) }} {{ invoice.currency }}</span>
|
||||
</div>
|
||||
<div class="totals-row">
|
||||
<span class="totals-label">
|
||||
VAT ({{ invoice.vat_rate }}%)
|
||||
{% if invoice.vat_rate_label %}<br><small>{{ invoice.vat_rate_label }}</small>{% endif %}
|
||||
</span>
|
||||
<span class="totals-value">{{ "%.2f"|format(invoice.vat_amount_cents / 100) }} {{ invoice.currency }}</span>
|
||||
</div>
|
||||
<div class="totals-row total">
|
||||
<span class="totals-label">Total</span>
|
||||
<span class="totals-value">{{ "%.2f"|format(invoice.total_cents / 100) }} {{ invoice.currency }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Information -->
|
||||
{% if bank_details or payment_terms %}
|
||||
<div class="payment-section">
|
||||
<div class="payment-title">Payment Information</div>
|
||||
<div class="payment-details">
|
||||
{% if payment_terms %}
|
||||
<p style="margin-bottom: 10px;">{{ payment_terms }}</p>
|
||||
{% endif %}
|
||||
{% if bank_details %}
|
||||
{% if bank_details.bank_name %}
|
||||
<span class="label">Bank:</span> {{ bank_details.bank_name }}<br>
|
||||
{% endif %}
|
||||
{% if bank_details.iban %}
|
||||
<span class="label">IBAN:</span> {{ bank_details.iban }}<br>
|
||||
{% endif %}
|
||||
{% if bank_details.bic %}
|
||||
<span class="label">BIC:</span> {{ bank_details.bic }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
{% if footer_text %}
|
||||
<p>{{ footer_text }}</p>
|
||||
{% endif %}
|
||||
<p>Invoice {{ invoice.invoice_number }} | Generated on {{ now.strftime('%d/%m/%Y %H:%M') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,112 +1,127 @@
|
||||
{# app/templates/platform/homepage-modern.html #}
|
||||
{# Modern/trendy platform homepage template with animations #}
|
||||
{# Wizamart OMS - Luxembourg-focused homepage inspired by Veeqo #}
|
||||
{% extends "platform/base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{% if page %}{{ page.title }}{% else %}Home{% endif %} - Marketplace
|
||||
Wizamart - The Back-Office for Letzshop Sellers
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.gradient-lu {
|
||||
background: linear-gradient(135deg, #00A1DE 0%, #EF3340 100%);
|
||||
}
|
||||
.gradient-lu-subtle {
|
||||
background: linear-gradient(135deg, #f0f9ff 0%, #fef2f2 100%);
|
||||
}
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-20px); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
.float-animation {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
animation: float 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes gradient {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
.animated-gradient {
|
||||
background: linear-gradient(270deg, #6366f1, #8b5cf6, #ec4899, #f43f5e);
|
||||
background-size: 400% 400%;
|
||||
animation: gradient 15s ease infinite;
|
||||
.feature-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- MODERN HERO WITH GRADIENT -->
|
||||
<!-- HERO - The Back-Office Letzshop Doesn't Give You -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<section class="relative overflow-hidden animated-gradient text-white py-24 md:py-32">
|
||||
{# Decorative elements #}
|
||||
<div class="absolute top-0 right-0 w-1/3 h-full opacity-20">
|
||||
<div class="absolute top-20 right-20 w-72 h-72 rounded-full bg-white blur-3xl"></div>
|
||||
<div class="absolute bottom-20 right-40 w-96 h-96 rounded-full bg-white blur-3xl"></div>
|
||||
<section class="relative overflow-hidden bg-gray-900 text-white py-20 md:py-28">
|
||||
{# Background pattern #}
|
||||
<div class="absolute inset-0 opacity-10">
|
||||
<div class="absolute top-0 left-0 w-full h-full" style="background-image: url('data:image/svg+xml,%3Csvg width=\"60\" height=\"60\" viewBox=\"0 0 60 60\" xmlns=\"http://www.w3.org/2000/svg\"%3E%3Cg fill=\"none\" fill-rule=\"evenodd\"%3E%3Cg fill=\"%23ffffff\" fill-opacity=\"0.4\"%3E%3Cpath d=\"M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z\"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E');"></div>
|
||||
</div>
|
||||
|
||||
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||
{# Left column - Content #}
|
||||
<div>
|
||||
{% if page %}
|
||||
<h1 class="text-5xl md:text-7xl font-black mb-6 leading-tight">
|
||||
{{ page.title }}
|
||||
</h1>
|
||||
<div class="text-xl md:text-2xl mb-8 opacity-95">
|
||||
{{ page.content | safe }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="inline-block px-4 py-2 bg-white/20 backdrop-blur-sm rounded-full text-sm font-semibold mb-6">
|
||||
✨ The Future of E-Commerce
|
||||
</div>
|
||||
<h1 class="text-5xl md:text-7xl font-black mb-6 leading-tight">
|
||||
Build Your<br>
|
||||
<span class="text-transparent bg-clip-text bg-white">
|
||||
Dream Store
|
||||
</span>
|
||||
</h1>
|
||||
<p class="text-xl md:text-2xl mb-8 opacity-95">
|
||||
Launch a stunning online marketplace in minutes. No coding required. Scale effortlessly.
|
||||
</p>
|
||||
{% endif %}
|
||||
<div class="inline-flex items-center px-4 py-2 bg-blue-500/20 backdrop-blur-sm rounded-full text-sm font-medium mb-6 border border-blue-400/30">
|
||||
<span class="mr-2">🇱🇺</span> Built for Luxembourg E-Commerce
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<h1 class="text-4xl md:text-5xl lg:text-6xl font-bold mb-6 leading-tight">
|
||||
The Back-Office<br>
|
||||
<span class="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-cyan-400">
|
||||
Letzshop Doesn't Give You
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p class="text-xl md:text-2xl text-gray-300 mb-8 leading-relaxed">
|
||||
Sync orders, manage inventory, generate invoices with correct VAT, and own your customer data. All in one place.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 mb-8">
|
||||
<a href="/contact"
|
||||
class="inline-flex items-center justify-center bg-white text-gray-900 px-8 py-4 rounded-xl font-bold hover:shadow-2xl transform hover:scale-105 transition-all duration-200">
|
||||
<span>Start Free Trial</span>
|
||||
class="inline-flex items-center justify-center bg-blue-500 hover:bg-blue-600 text-white px-8 py-4 rounded-xl font-bold transition-all duration-200 shadow-lg hover:shadow-xl">
|
||||
<span>Start 14-Day Free Trial</span>
|
||||
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="#features"
|
||||
class="inline-flex items-center justify-center border-2 border-white text-white px-8 py-4 rounded-xl font-bold hover:bg-white/10 backdrop-blur-sm transition-all duration-200">
|
||||
Learn More
|
||||
<a href="#how-it-works"
|
||||
class="inline-flex items-center justify-center border-2 border-gray-600 text-white px-8 py-4 rounded-xl font-bold hover:bg-white/10 transition-all duration-200">
|
||||
See How It Works
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Stats #}
|
||||
<div class="grid grid-cols-3 gap-6 mt-12 pt-12 border-t border-white/20">
|
||||
<div>
|
||||
<div class="text-3xl font-bold mb-1">10K+</div>
|
||||
<div class="text-sm opacity-80">Active Vendors</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-3xl font-bold mb-1">50M+</div>
|
||||
<div class="text-sm opacity-80">Products Sold</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-3xl font-bold mb-1">99.9%</div>
|
||||
<div class="text-sm opacity-80">Uptime</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400">
|
||||
No credit card required. Setup in 5 minutes. Cancel anytime.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Right column - Visual element #}
|
||||
<div class="hidden lg:block float-animation">
|
||||
<div class="relative">
|
||||
<div class="w-full h-96 bg-white/10 backdrop-blur-xl rounded-3xl shadow-2xl p-8">
|
||||
<div class="w-full h-full bg-gradient-to-br from-white/20 to-transparent rounded-2xl flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="text-8xl mb-4">🚀</div>
|
||||
<div class="text-2xl font-bold">Launch Today</div>
|
||||
{# Right column - Dashboard Preview #}
|
||||
<div class="hidden lg:block">
|
||||
<div class="relative float-animation">
|
||||
<div class="bg-gray-800 rounded-2xl shadow-2xl border border-gray-700 overflow-hidden">
|
||||
{# Mock dashboard header #}
|
||||
<div class="bg-gray-900 px-4 py-3 flex items-center gap-2 border-b border-gray-700">
|
||||
<div class="w-3 h-3 rounded-full bg-red-500"></div>
|
||||
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
|
||||
<div class="w-3 h-3 rounded-full bg-green-500"></div>
|
||||
<span class="ml-4 text-gray-400 text-sm">Wizamart Dashboard</span>
|
||||
</div>
|
||||
{# Mock dashboard content #}
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="bg-gray-700/50 rounded-lg p-4">
|
||||
<div class="text-gray-400 text-xs mb-1">Today's Orders</div>
|
||||
<div class="text-2xl font-bold text-white">24</div>
|
||||
<div class="text-green-400 text-xs">+12% vs yesterday</div>
|
||||
</div>
|
||||
<div class="bg-gray-700/50 rounded-lg p-4">
|
||||
<div class="text-gray-400 text-xs mb-1">Revenue</div>
|
||||
<div class="text-2xl font-bold text-white">EUR 1,847</div>
|
||||
<div class="text-green-400 text-xs">+8% vs yesterday</div>
|
||||
</div>
|
||||
<div class="bg-gray-700/50 rounded-lg p-4">
|
||||
<div class="text-gray-400 text-xs mb-1">Low Stock</div>
|
||||
<div class="text-2xl font-bold text-yellow-400">3</div>
|
||||
<div class="text-gray-400 text-xs">items need restock</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-700/50 rounded-lg p-4">
|
||||
<div class="text-gray-400 text-xs mb-3">Recent Orders from Letzshop</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-white">#LS-4521</span>
|
||||
<span class="text-gray-400">Marie D.</span>
|
||||
<span class="text-green-400">EUR 89.00</span>
|
||||
<span class="bg-blue-500/20 text-blue-400 px-2 py-0.5 rounded text-xs">Confirmed</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-white">#LS-4520</span>
|
||||
<span class="text-gray-400">Jean M.</span>
|
||||
<span class="text-green-400">EUR 156.50</span>
|
||||
<span class="bg-purple-500/20 text-purple-400 px-2 py-0.5 rounded text-xs">Shipped</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,141 +132,466 @@
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- FEATURES WITH CARDS -->
|
||||
<!-- INTEGRATION BADGE - Works with Letzshop -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<section id="features" class="py-24 bg-gray-50 dark:bg-gray-900">
|
||||
<section class="py-8 bg-gray-50 dark:bg-gray-800 border-y border-gray-200 dark:border-gray-700">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col md:flex-row items-center justify-center gap-6 md:gap-12">
|
||||
<span class="text-gray-500 dark:text-gray-400 font-medium">Official Integration</span>
|
||||
<div class="flex items-center gap-3 bg-white dark:bg-gray-700 px-6 py-3 rounded-xl shadow-sm">
|
||||
<span class="text-2xl">🛒</span>
|
||||
<span class="font-bold text-gray-900 dark:text-white">Letzshop.lu</span>
|
||||
</div>
|
||||
<span class="text-gray-500 dark:text-gray-400 text-sm">Connect in 2 minutes</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- THE PROBLEM - Pain Points -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<section class="py-20 bg-white dark:bg-gray-900">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
<div class="inline-block px-4 py-2 bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded-full text-sm font-semibold mb-4">
|
||||
✨ Features
|
||||
</div>
|
||||
<h2 class="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Everything You Need
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Sound Familiar?
|
||||
</h2>
|
||||
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
Powerful features to help you succeed in the digital marketplace
|
||||
These are the daily frustrations of Letzshop sellers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6">
|
||||
<div class="text-3xl mb-4">📋</div>
|
||||
<h3 class="font-bold text-gray-900 dark:text-white mb-2">Manual Order Entry</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm">Copy-pasting orders from Letzshop to spreadsheets. Every. Single. Day.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6">
|
||||
<div class="text-3xl mb-4">📦</div>
|
||||
<h3 class="font-bold text-gray-900 dark:text-white mb-2">Inventory Chaos</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm">Stock in Letzshop doesn't match reality. Overselling happens.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6">
|
||||
<div class="text-3xl mb-4">🧾</div>
|
||||
<h3 class="font-bold text-gray-900 dark:text-white mb-2">Wrong VAT Invoices</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm">EU customers need correct VAT. Your accountant keeps complaining.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6">
|
||||
<div class="text-3xl mb-4">👥</div>
|
||||
<h3 class="font-bold text-gray-900 dark:text-white mb-2">Lost Customers</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm">Letzshop owns your customer data. You can't retarget or build loyalty.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- HOW IT WORKS - 4-Step Workflow (Veeqo-style) -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<section id="how-it-works" class="py-20 gradient-lu-subtle dark:bg-gray-800">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
<div class="inline-block px-4 py-2 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded-full text-sm font-semibold mb-4">
|
||||
How It Works
|
||||
</div>
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
From Chaos to Control in 4 Steps
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{# Step 1 #}
|
||||
<div class="relative">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg h-full">
|
||||
<div class="w-12 h-12 rounded-full bg-blue-500 text-white flex items-center justify-center font-bold text-xl mb-6">1</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Connect Letzshop</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">Enter your Letzshop API credentials. Done in 2 minutes, no technical skills needed.</p>
|
||||
</div>
|
||||
<div class="hidden lg:block absolute top-1/2 -right-4 w-8 h-0.5 bg-blue-300"></div>
|
||||
</div>
|
||||
|
||||
{# Step 2 #}
|
||||
<div class="relative">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg h-full">
|
||||
<div class="w-12 h-12 rounded-full bg-blue-500 text-white flex items-center justify-center font-bold text-xl mb-6">2</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Orders Flow In</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">Orders sync automatically. Confirm and add tracking directly from Wizamart.</p>
|
||||
</div>
|
||||
<div class="hidden lg:block absolute top-1/2 -right-4 w-8 h-0.5 bg-blue-300"></div>
|
||||
</div>
|
||||
|
||||
{# Step 3 #}
|
||||
<div class="relative">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg h-full">
|
||||
<div class="w-12 h-12 rounded-full bg-blue-500 text-white flex items-center justify-center font-bold text-xl mb-6">3</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Generate Invoices</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">One click to create compliant PDF invoices with correct VAT for any EU country.</p>
|
||||
</div>
|
||||
<div class="hidden lg:block absolute top-1/2 -right-4 w-8 h-0.5 bg-blue-300"></div>
|
||||
</div>
|
||||
|
||||
{# Step 4 #}
|
||||
<div class="relative">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg h-full">
|
||||
<div class="w-12 h-12 rounded-full bg-green-500 text-white flex items-center justify-center font-bold text-xl mb-6">4</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Grow Your Business</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">Export customers for marketing. Track inventory. Focus on selling, not spreadsheets.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- FEATURES - What You Get -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<section id="features" class="py-20 bg-white dark:bg-gray-900">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
<div class="inline-block px-4 py-2 bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 rounded-full text-sm font-semibold mb-4">
|
||||
Features
|
||||
</div>
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Everything a Letzshop Seller Needs
|
||||
</h2>
|
||||
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
The operational tools Letzshop doesn't provide
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{# Feature cards with hover effects #}
|
||||
<div class="group bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-sm hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2">
|
||||
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform">
|
||||
{# Feature 1: Order Sync #}
|
||||
<div class="feature-card bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 transition-all duration-300">
|
||||
<div class="w-14 h-14 rounded-2xl bg-blue-500 flex items-center justify-center mb-6">
|
||||
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
Blazing Fast
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Optimized for performance with sub-second page loads and instant search results.
|
||||
</p>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Automatic Order Sync</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">Orders from Letzshop appear instantly. Confirm orders and sync tracking numbers back automatically.</p>
|
||||
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<li>Real-time sync</li>
|
||||
<li>One-click confirmation</li>
|
||||
<li>Tracking number sync</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="group bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-sm hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2">
|
||||
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform">
|
||||
{# Feature 2: Inventory #}
|
||||
<div class="feature-card bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 transition-all duration-300">
|
||||
<div class="w-14 h-14 rounded-2xl bg-green-500 flex items-center justify-center mb-6">
|
||||
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
Bank-Level Security
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Enterprise-grade encryption and security measures to protect your business.
|
||||
</p>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Real Inventory Management</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">One source of truth for all stock. Locations, reservations, and incoming stock tracking.</p>
|
||||
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<li>Product locations (bins)</li>
|
||||
<li>Stock reservations</li>
|
||||
<li>Low stock alerts</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="group bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-sm hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2">
|
||||
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-green-500 to-teal-500 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform">
|
||||
{# Feature 3: Invoicing #}
|
||||
<div class="feature-card bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 transition-all duration-300">
|
||||
<div class="w-14 h-14 rounded-2xl bg-purple-500 flex items-center justify-center mb-6">
|
||||
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
Fully Customizable
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Brand your store with custom themes, colors, fonts, and layouts.
|
||||
</p>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Smart VAT Invoicing</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">Generate PDF invoices with correct VAT rates. Luxembourg, EU countries, B2B reverse charge.</p>
|
||||
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<li>Luxembourg 17% VAT</li>
|
||||
<li>EU destination VAT (OSS)</li>
|
||||
<li>B2B reverse charge</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="group bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-sm hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2">
|
||||
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-orange-500 to-red-500 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform">
|
||||
{# Feature 4: Customers #}
|
||||
<div class="feature-card bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 transition-all duration-300">
|
||||
<div class="w-14 h-14 rounded-2xl bg-orange-500 flex items-center justify-center mb-6">
|
||||
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
Analytics & Insights
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Powerful analytics to track sales, customer behavior, and growth metrics.
|
||||
</p>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Own Your Customers</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">All customer data in your database. Export to Mailchimp for marketing campaigns.</p>
|
||||
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<li>Order history per customer</li>
|
||||
<li>Lifetime value tracking</li>
|
||||
<li>CSV export for marketing</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="group bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-sm hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2">
|
||||
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform">
|
||||
{# Feature 5: Team #}
|
||||
<div class="feature-card bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 transition-all duration-300">
|
||||
<div class="w-14 h-14 rounded-2xl bg-cyan-500 flex items-center justify-center mb-6">
|
||||
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
Mobile-First Design
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Beautiful, responsive design that works perfectly on all devices.
|
||||
</p>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Team Management</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">Invite team members with role-based permissions. Everyone works from one dashboard.</p>
|
||||
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<li>Multiple users</li>
|
||||
<li>Role-based access</li>
|
||||
<li>Activity logging</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="group bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-sm hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2">
|
||||
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-pink-500 to-rose-500 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform">
|
||||
{# Feature 6: Purchase Orders #}
|
||||
<div class="feature-card bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 transition-all duration-300">
|
||||
<div class="w-14 h-14 rounded-2xl bg-pink-500 flex items-center justify-center mb-6">
|
||||
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192l-3.536 3.536M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
24/7 Support
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Round-the-clock customer support to help you succeed at every step.
|
||||
</p>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Purchase Orders</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">Track incoming stock from suppliers. Know what's on order and when it arrives.</p>
|
||||
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<li>Track supplier orders</li>
|
||||
<li>Expected arrival dates</li>
|
||||
<li>Receive and update stock</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- MODERN CTA WITH GRADIENT -->
|
||||
<!-- PRICING - 4 Tiers -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<section class="py-24 relative overflow-hidden">
|
||||
<div class="absolute inset-0 gradient-accent opacity-90"></div>
|
||||
<section id="pricing" class="py-20 bg-gray-50 dark:bg-gray-800">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
<div class="inline-block px-4 py-2 bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded-full text-sm font-semibold mb-4">
|
||||
Pricing
|
||||
</div>
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Simple, Transparent Pricing
|
||||
</h2>
|
||||
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
No per-order fees. No hidden costs. Flat monthly rate.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="relative max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-white">
|
||||
<h2 class="text-4xl md:text-5xl font-bold mb-6">
|
||||
Start Your Journey Today
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{# Essential #}
|
||||
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2">Essential</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm mb-4">For solo vendors getting started</p>
|
||||
<div class="mb-6">
|
||||
<span class="text-4xl font-bold text-gray-900 dark:text-white">EUR 49</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">/month</span>
|
||||
</div>
|
||||
<ul class="space-y-3 mb-8 text-sm">
|
||||
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
100 orders/month
|
||||
</li>
|
||||
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
200 products
|
||||
</li>
|
||||
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
Luxembourg VAT invoices
|
||||
</li>
|
||||
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
1 team member
|
||||
</li>
|
||||
</ul>
|
||||
<a href="/contact" class="block w-full text-center bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white px-6 py-3 rounded-xl font-semibold hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
|
||||
Start Free Trial
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Professional - Highlighted #}
|
||||
<div class="bg-blue-600 rounded-2xl p-8 shadow-xl relative transform lg:scale-105">
|
||||
<div class="absolute -top-4 left-1/2 -translate-x-1/2 bg-orange-500 text-white text-xs font-bold px-3 py-1 rounded-full">
|
||||
MOST POPULAR
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-white mb-2">Professional</h3>
|
||||
<p class="text-blue-200 text-sm mb-4">For growing multi-channel sellers</p>
|
||||
<div class="mb-6">
|
||||
<span class="text-4xl font-bold text-white">EUR 99</span>
|
||||
<span class="text-blue-200">/month</span>
|
||||
</div>
|
||||
<ul class="space-y-3 mb-8 text-sm">
|
||||
<li class="flex items-center text-blue-100">
|
||||
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
500 orders/month
|
||||
</li>
|
||||
<li class="flex items-center text-blue-100">
|
||||
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
Unlimited products
|
||||
</li>
|
||||
<li class="flex items-center text-blue-100">
|
||||
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
<strong>EU VAT invoices</strong>
|
||||
</li>
|
||||
<li class="flex items-center text-blue-100">
|
||||
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
Product locations
|
||||
</li>
|
||||
<li class="flex items-center text-blue-100">
|
||||
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
Purchase orders
|
||||
</li>
|
||||
<li class="flex items-center text-blue-100">
|
||||
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
Customer export
|
||||
</li>
|
||||
<li class="flex items-center text-blue-100">
|
||||
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
3 team members
|
||||
</li>
|
||||
</ul>
|
||||
<a href="/contact" class="block w-full text-center bg-white text-blue-600 px-6 py-3 rounded-xl font-bold hover:bg-blue-50 transition-colors">
|
||||
Start Free Trial
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Business #}
|
||||
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2">Business</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm mb-4">For high-volume operations</p>
|
||||
<div class="mb-6">
|
||||
<span class="text-4xl font-bold text-gray-900 dark:text-white">EUR 199</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">/month</span>
|
||||
</div>
|
||||
<ul class="space-y-3 mb-8 text-sm">
|
||||
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
2,000 orders/month
|
||||
</li>
|
||||
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
Everything in Professional
|
||||
</li>
|
||||
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
<strong>Analytics dashboard</strong>
|
||||
</li>
|
||||
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
<strong>API access</strong>
|
||||
</li>
|
||||
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
Accounting export
|
||||
</li>
|
||||
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
10 team members
|
||||
</li>
|
||||
</ul>
|
||||
<a href="/contact" class="block w-full text-center bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white px-6 py-3 rounded-xl font-semibold hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
|
||||
Start Free Trial
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Enterprise #}
|
||||
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2">Enterprise</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm mb-4">For large operations & agencies</p>
|
||||
<div class="mb-6">
|
||||
<span class="text-4xl font-bold text-gray-900 dark:text-white">EUR 399+</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">/month</span>
|
||||
</div>
|
||||
<ul class="space-y-3 mb-8 text-sm">
|
||||
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
Unlimited orders
|
||||
</li>
|
||||
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
Everything in Business
|
||||
</li>
|
||||
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
<strong>White-label option</strong>
|
||||
</li>
|
||||
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
Custom integrations
|
||||
</li>
|
||||
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
99.9% SLA
|
||||
</li>
|
||||
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
Dedicated support
|
||||
</li>
|
||||
</ul>
|
||||
<a href="/contact" class="block w-full text-center bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white px-6 py-3 rounded-xl font-semibold hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
|
||||
Contact Sales
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-gray-500 dark:text-gray-400 mt-8">
|
||||
All plans include a 14-day free trial. No credit card required.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- TESTIMONIAL / SOCIAL PROOF -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<section class="py-20 bg-white dark:bg-gray-900">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<div class="inline-block px-4 py-2 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-600 dark:text-yellow-400 rounded-full text-sm font-semibold mb-8">
|
||||
Built for Luxembourg
|
||||
</div>
|
||||
|
||||
<blockquote class="text-2xl md:text-3xl font-medium text-gray-900 dark:text-white mb-8 leading-relaxed">
|
||||
"Finally, a tool that understands what Letzshop sellers actually need. No more spreadsheets, no more VAT headaches."
|
||||
</blockquote>
|
||||
|
||||
<div class="flex items-center justify-center gap-4">
|
||||
<div class="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center text-xl">
|
||||
👩
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="font-semibold text-gray-900 dark:text-white">Marie L.</div>
|
||||
<div class="text-gray-500 dark:text-gray-400 text-sm">Letzshop Vendor, Luxembourg City</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- FINAL CTA -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<section class="py-20 bg-gray-900 text-white">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h2 class="text-3xl md:text-4xl font-bold mb-6">
|
||||
Ready to Take Control of Your Letzshop Business?
|
||||
</h2>
|
||||
<p class="text-xl mb-10 opacity-90">
|
||||
Join thousands of successful vendors on our platform
|
||||
<p class="text-xl text-gray-300 mb-10">
|
||||
Join Luxembourg vendors who've stopped fighting spreadsheets and started growing their business.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a href="/contact"
|
||||
class="inline-flex items-center justify-center bg-white text-gray-900 px-8 py-4 rounded-xl font-bold hover:shadow-2xl transform hover:scale-105 transition-all duration-200">
|
||||
<span>Get Started Free</span>
|
||||
class="inline-flex items-center justify-center bg-blue-500 hover:bg-blue-600 text-white px-8 py-4 rounded-xl font-bold transition-all duration-200 shadow-lg">
|
||||
<span>Start Your 14-Day Free Trial</span>
|
||||
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="/about"
|
||||
class="inline-flex items-center justify-center border-2 border-white text-white px-8 py-4 rounded-xl font-bold hover:bg-white/10 backdrop-blur-sm transition-all duration-200">
|
||||
Learn More About Us
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="mt-8 text-sm opacity-75">
|
||||
No credit card required · Free 14-day trial · Cancel anytime
|
||||
<p class="mt-8 text-sm text-gray-400">
|
||||
No credit card required. Setup in 5 minutes. Full Professional features during trial.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user