feat: add invoicing system and subscription tier enforcement
Phase 1 OMS implementation: Invoicing: - Add Invoice and VendorInvoiceSettings database models - Full EU VAT support (27 countries, OSS, B2B reverse charge) - Invoice PDF generation with WeasyPrint + Jinja2 templates - Vendor invoice API endpoints for settings, creation, PDF download Subscription Tiers: - Add VendorSubscription model with 4 tiers (Essential/Professional/Business/Enterprise) - Tier limit enforcement for orders, products, team members - Feature gating based on subscription tier - Automatic trial subscription creation for new vendors - Integrate limit checks into order creation (direct and Letzshop sync) Marketing: - Update pricing documentation with 4-tier structure - Revise back-office positioning strategy - Update homepage with Veeqo-inspired Letzshop-focused messaging 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
4
Makefile
4
Makefile
@@ -44,7 +44,7 @@ setup: install-all migrate-up init-prod
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
$(PYTHON) -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
$(PYTHON) -m uvicorn main:app --reload --host 0.0.0.0 --port 9999
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# DATABASE MIGRATIONS
|
# DATABASE MIGRATIONS
|
||||||
@@ -274,7 +274,7 @@ qa: format lint arch-check test-coverage docs-check
|
|||||||
|
|
||||||
docs-serve:
|
docs-serve:
|
||||||
@echo "Starting documentation server..."
|
@echo "Starting documentation server..."
|
||||||
$(PYTHON) -m mkdocs serve --dev-addr=0.0.0.0:8001
|
$(PYTHON) -m mkdocs serve --dev-addr=0.0.0.0:9991
|
||||||
|
|
||||||
docs-build:
|
docs-build:
|
||||||
@echo "Building documentation..."
|
@echo "Building documentation..."
|
||||||
|
|||||||
198
alembic/versions/h6c7d8e9f0a1_add_invoice_tables.py
Normal file
198
alembic/versions/h6c7d8e9f0a1_add_invoice_tables.py
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
"""Add invoice tables
|
||||||
|
|
||||||
|
Revision ID: h6c7d8e9f0a1
|
||||||
|
Revises: g5b6c7d8e9f0
|
||||||
|
Create Date: 2025-12-24
|
||||||
|
|
||||||
|
This migration adds:
|
||||||
|
- vendor_invoice_settings: Per-vendor invoice configuration
|
||||||
|
- invoices: Invoice records with seller/buyer snapshots
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "h6c7d8e9f0a1"
|
||||||
|
down_revision: Union[str, None] = "g5b6c7d8e9f0"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create vendor_invoice_settings table
|
||||||
|
op.create_table(
|
||||||
|
"vendor_invoice_settings",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("vendor_id", sa.Integer(), nullable=False),
|
||||||
|
# Company details
|
||||||
|
sa.Column("company_name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("company_address", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("company_city", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("company_postal_code", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"company_country", sa.String(length=2), server_default="LU", nullable=False
|
||||||
|
),
|
||||||
|
# VAT information
|
||||||
|
sa.Column("vat_number", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("is_vat_registered", sa.Boolean(), server_default="1", nullable=False),
|
||||||
|
# OSS
|
||||||
|
sa.Column("is_oss_registered", sa.Boolean(), server_default="0", nullable=False),
|
||||||
|
sa.Column("oss_registration_country", sa.String(length=2), nullable=True),
|
||||||
|
# Invoice numbering
|
||||||
|
sa.Column(
|
||||||
|
"invoice_prefix", sa.String(length=20), server_default="INV", nullable=False
|
||||||
|
),
|
||||||
|
sa.Column("invoice_next_number", sa.Integer(), server_default="1", nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"invoice_number_padding", sa.Integer(), server_default="5", nullable=False
|
||||||
|
),
|
||||||
|
# Payment information
|
||||||
|
sa.Column("payment_terms", sa.Text(), nullable=True),
|
||||||
|
sa.Column("bank_name", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("bank_iban", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("bank_bic", sa.String(length=20), nullable=True),
|
||||||
|
# Footer
|
||||||
|
sa.Column("footer_text", sa.Text(), nullable=True),
|
||||||
|
# Default VAT rate
|
||||||
|
sa.Column(
|
||||||
|
"default_vat_rate", sa.Numeric(precision=5, scale=2), server_default="17.00", nullable=False
|
||||||
|
),
|
||||||
|
# Timestamps
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("(CURRENT_TIMESTAMP)"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("(CURRENT_TIMESTAMP)"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["vendor_id"],
|
||||||
|
["vendors.id"],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("vendor_id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_vendor_invoice_settings_id"),
|
||||||
|
"vendor_invoice_settings",
|
||||||
|
["id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_vendor_invoice_settings_vendor_id"),
|
||||||
|
"vendor_invoice_settings",
|
||||||
|
["vendor_id"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create invoices table
|
||||||
|
op.create_table(
|
||||||
|
"invoices",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("vendor_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("order_id", sa.Integer(), nullable=True),
|
||||||
|
# Invoice identification
|
||||||
|
sa.Column("invoice_number", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("invoice_date", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
# Status
|
||||||
|
sa.Column(
|
||||||
|
"status", sa.String(length=20), server_default="draft", nullable=False
|
||||||
|
),
|
||||||
|
# Snapshots (JSON)
|
||||||
|
sa.Column("seller_details", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("buyer_details", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("line_items", sa.JSON(), nullable=False),
|
||||||
|
# VAT information
|
||||||
|
sa.Column(
|
||||||
|
"vat_regime", sa.String(length=20), server_default="domestic", nullable=False
|
||||||
|
),
|
||||||
|
sa.Column("destination_country", sa.String(length=2), nullable=True),
|
||||||
|
sa.Column("vat_rate", sa.Numeric(precision=5, scale=2), nullable=False),
|
||||||
|
sa.Column("vat_rate_label", sa.String(length=50), nullable=True),
|
||||||
|
# Amounts (in cents)
|
||||||
|
sa.Column("currency", sa.String(length=3), server_default="EUR", nullable=False),
|
||||||
|
sa.Column("subtotal_cents", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("vat_amount_cents", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("total_cents", sa.Integer(), nullable=False),
|
||||||
|
# Payment info
|
||||||
|
sa.Column("payment_terms", sa.Text(), nullable=True),
|
||||||
|
sa.Column("bank_details", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("footer_text", sa.Text(), nullable=True),
|
||||||
|
# PDF
|
||||||
|
sa.Column("pdf_generated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("pdf_path", sa.String(length=500), nullable=True),
|
||||||
|
# Notes
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
# Timestamps
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("(CURRENT_TIMESTAMP)"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("(CURRENT_TIMESTAMP)"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["vendor_id"],
|
||||||
|
["vendors.id"],
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["order_id"],
|
||||||
|
["orders.id"],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_invoices_id"), "invoices", ["id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_invoices_vendor_id"), "invoices", ["vendor_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_invoices_order_id"), "invoices", ["order_id"], unique=False)
|
||||||
|
op.create_index(
|
||||||
|
"idx_invoice_vendor_number",
|
||||||
|
"invoices",
|
||||||
|
["vendor_id", "invoice_number"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_invoice_vendor_date",
|
||||||
|
"invoices",
|
||||||
|
["vendor_id", "invoice_date"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_invoice_status",
|
||||||
|
"invoices",
|
||||||
|
["vendor_id", "status"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop invoices table
|
||||||
|
op.drop_index("idx_invoice_status", table_name="invoices")
|
||||||
|
op.drop_index("idx_invoice_vendor_date", table_name="invoices")
|
||||||
|
op.drop_index("idx_invoice_vendor_number", table_name="invoices")
|
||||||
|
op.drop_index(op.f("ix_invoices_order_id"), table_name="invoices")
|
||||||
|
op.drop_index(op.f("ix_invoices_vendor_id"), table_name="invoices")
|
||||||
|
op.drop_index(op.f("ix_invoices_id"), table_name="invoices")
|
||||||
|
op.drop_table("invoices")
|
||||||
|
|
||||||
|
# Drop vendor_invoice_settings table
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_vendor_invoice_settings_vendor_id"),
|
||||||
|
table_name="vendor_invoice_settings",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_vendor_invoice_settings_id"), table_name="vendor_invoice_settings"
|
||||||
|
)
|
||||||
|
op.drop_table("vendor_invoice_settings")
|
||||||
148
alembic/versions/i7d8e9f0a1b2_add_vendor_subscriptions.py
Normal file
148
alembic/versions/i7d8e9f0a1b2_add_vendor_subscriptions.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"""Add vendor subscriptions table
|
||||||
|
|
||||||
|
Revision ID: i7d8e9f0a1b2
|
||||||
|
Revises: h6c7d8e9f0a1
|
||||||
|
Create Date: 2025-12-24
|
||||||
|
|
||||||
|
This migration adds:
|
||||||
|
- vendor_subscriptions: Per-vendor subscription tracking with tier limits
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "i7d8e9f0a1b2"
|
||||||
|
down_revision: Union[str, None] = "h6c7d8e9f0a1"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create vendor_subscriptions table
|
||||||
|
op.create_table(
|
||||||
|
"vendor_subscriptions",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("vendor_id", sa.Integer(), nullable=False),
|
||||||
|
# Tier and status
|
||||||
|
sa.Column(
|
||||||
|
"tier", sa.String(length=20), server_default="essential", nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"status", sa.String(length=20), server_default="trial", nullable=False
|
||||||
|
),
|
||||||
|
# Billing period
|
||||||
|
sa.Column("period_start", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("period_end", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("is_annual", sa.Boolean(), server_default="0", nullable=False),
|
||||||
|
# Trial
|
||||||
|
sa.Column("trial_ends_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
# Usage counters
|
||||||
|
sa.Column("orders_this_period", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
sa.Column("orders_limit_reached_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
# Custom overrides
|
||||||
|
sa.Column("custom_orders_limit", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("custom_products_limit", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("custom_team_limit", sa.Integer(), nullable=True),
|
||||||
|
# Payment (future Stripe integration)
|
||||||
|
sa.Column("stripe_customer_id", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("stripe_subscription_id", sa.String(length=100), nullable=True),
|
||||||
|
# Cancellation
|
||||||
|
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("cancellation_reason", sa.Text(), nullable=True),
|
||||||
|
# Timestamps
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("(CURRENT_TIMESTAMP)"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("(CURRENT_TIMESTAMP)"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["vendor_id"],
|
||||||
|
["vendors.id"],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("vendor_id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_vendor_subscriptions_id"),
|
||||||
|
"vendor_subscriptions",
|
||||||
|
["id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_vendor_subscriptions_vendor_id"),
|
||||||
|
"vendor_subscriptions",
|
||||||
|
["vendor_id"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_vendor_subscriptions_tier"),
|
||||||
|
"vendor_subscriptions",
|
||||||
|
["tier"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_vendor_subscriptions_status"),
|
||||||
|
"vendor_subscriptions",
|
||||||
|
["status"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_vendor_subscriptions_stripe_customer_id"),
|
||||||
|
"vendor_subscriptions",
|
||||||
|
["stripe_customer_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_vendor_subscriptions_stripe_subscription_id"),
|
||||||
|
"vendor_subscriptions",
|
||||||
|
["stripe_subscription_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_subscription_vendor_status",
|
||||||
|
"vendor_subscriptions",
|
||||||
|
["vendor_id", "status"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_subscription_period",
|
||||||
|
"vendor_subscriptions",
|
||||||
|
["period_start", "period_end"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("idx_subscription_period", table_name="vendor_subscriptions")
|
||||||
|
op.drop_index("idx_subscription_vendor_status", table_name="vendor_subscriptions")
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_vendor_subscriptions_stripe_subscription_id"),
|
||||||
|
table_name="vendor_subscriptions",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_vendor_subscriptions_stripe_customer_id"),
|
||||||
|
table_name="vendor_subscriptions",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_vendor_subscriptions_status"), table_name="vendor_subscriptions"
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_vendor_subscriptions_tier"), table_name="vendor_subscriptions"
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_vendor_subscriptions_vendor_id"), table_name="vendor_subscriptions"
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_vendor_subscriptions_id"), table_name="vendor_subscriptions"
|
||||||
|
)
|
||||||
|
op.drop_table("vendor_subscriptions")
|
||||||
2
app/api/v1/vendor/__init__.py
vendored
2
app/api/v1/vendor/__init__.py
vendored
@@ -21,6 +21,7 @@ from . import (
|
|||||||
dashboard,
|
dashboard,
|
||||||
info,
|
info,
|
||||||
inventory,
|
inventory,
|
||||||
|
invoices,
|
||||||
letzshop,
|
letzshop,
|
||||||
marketplace,
|
marketplace,
|
||||||
media,
|
media,
|
||||||
@@ -59,6 +60,7 @@ router.include_router(settings.router, tags=["vendor-settings"])
|
|||||||
router.include_router(products.router, tags=["vendor-products"])
|
router.include_router(products.router, tags=["vendor-products"])
|
||||||
router.include_router(orders.router, tags=["vendor-orders"])
|
router.include_router(orders.router, tags=["vendor-orders"])
|
||||||
router.include_router(order_item_exceptions.router, tags=["vendor-order-exceptions"])
|
router.include_router(order_item_exceptions.router, tags=["vendor-order-exceptions"])
|
||||||
|
router.include_router(invoices.router, tags=["vendor-invoices"])
|
||||||
router.include_router(customers.router, tags=["vendor-customers"])
|
router.include_router(customers.router, tags=["vendor-customers"])
|
||||||
router.include_router(team.router, tags=["vendor-team"])
|
router.include_router(team.router, tags=["vendor-team"])
|
||||||
router.include_router(inventory.router, tags=["vendor-inventory"])
|
router.include_router(inventory.router, tags=["vendor-inventory"])
|
||||||
|
|||||||
369
app/api/v1/vendor/invoices.py
vendored
Normal file
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.services.order_service import order_service as unified_order_service
|
from app.services.order_service import order_service as unified_order_service
|
||||||
|
from app.services.subscription_service import subscription_service
|
||||||
from models.database.letzshop import (
|
from models.database.letzshop import (
|
||||||
LetzshopFulfillmentQueue,
|
LetzshopFulfillmentQueue,
|
||||||
LetzshopHistoricalImportJob,
|
LetzshopHistoricalImportJob,
|
||||||
@@ -792,6 +793,7 @@ class LetzshopOrderService:
|
|||||||
"updated": 0,
|
"updated": 0,
|
||||||
"skipped": 0,
|
"skipped": 0,
|
||||||
"errors": 0,
|
"errors": 0,
|
||||||
|
"limit_exceeded": 0,
|
||||||
"products_matched": 0,
|
"products_matched": 0,
|
||||||
"products_not_found": 0,
|
"products_not_found": 0,
|
||||||
"eans_processed": set(),
|
"eans_processed": set(),
|
||||||
@@ -800,6 +802,10 @@ class LetzshopOrderService:
|
|||||||
"error_messages": [],
|
"error_messages": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Get subscription usage upfront for batch efficiency
|
||||||
|
usage = subscription_service.get_usage(self.db, vendor_id)
|
||||||
|
orders_remaining = usage.orders_remaining # None = unlimited
|
||||||
|
|
||||||
for i, shipment in enumerate(shipments):
|
for i, shipment in enumerate(shipments):
|
||||||
shipment_id = shipment.get("id")
|
shipment_id = shipment.get("id")
|
||||||
if not shipment_id:
|
if not shipment_id:
|
||||||
@@ -844,11 +850,24 @@ class LetzshopOrderService:
|
|||||||
else:
|
else:
|
||||||
stats["skipped"] += 1
|
stats["skipped"] += 1
|
||||||
else:
|
else:
|
||||||
|
# Check tier limit before creating order
|
||||||
|
if orders_remaining is not None and orders_remaining <= 0:
|
||||||
|
stats["limit_exceeded"] += 1
|
||||||
|
stats["error_messages"].append(
|
||||||
|
f"Shipment {shipment_id}: Order limit reached"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
# Create new order using unified service
|
# Create new order using unified service
|
||||||
try:
|
try:
|
||||||
self.create_order(vendor_id, shipment)
|
self.create_order(vendor_id, shipment)
|
||||||
self.db.commit() # noqa: SVC-006 - background task needs incremental commits
|
self.db.commit() # noqa: SVC-006 - background task needs incremental commits
|
||||||
stats["imported"] += 1
|
stats["imported"] += 1
|
||||||
|
|
||||||
|
# Decrement remaining count for batch efficiency
|
||||||
|
if orders_remaining is not None:
|
||||||
|
orders_remaining -= 1
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.db.rollback() # Rollback failed order
|
self.db.rollback() # Rollback failed order
|
||||||
stats["errors"] += 1
|
stats["errors"] += 1
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ from app.exceptions import (
|
|||||||
ValidationException,
|
ValidationException,
|
||||||
)
|
)
|
||||||
from app.services.order_item_exception_service import order_item_exception_service
|
from app.services.order_item_exception_service import order_item_exception_service
|
||||||
|
from app.services.subscription_service import (
|
||||||
|
subscription_service,
|
||||||
|
TierLimitExceededException,
|
||||||
|
)
|
||||||
from app.utils.money import Money, cents_to_euros, euros_to_cents
|
from app.utils.money import Money, cents_to_euros, euros_to_cents
|
||||||
from models.database.customer import Customer
|
from models.database.customer import Customer
|
||||||
from models.database.marketplace_product import MarketplaceProduct
|
from models.database.marketplace_product import MarketplaceProduct
|
||||||
@@ -271,7 +275,11 @@ class OrderService:
|
|||||||
Raises:
|
Raises:
|
||||||
ValidationException: If order data is invalid
|
ValidationException: If order data is invalid
|
||||||
InsufficientInventoryException: If not enough inventory
|
InsufficientInventoryException: If not enough inventory
|
||||||
|
TierLimitExceededException: If vendor has reached order limit
|
||||||
"""
|
"""
|
||||||
|
# Check tier limit before creating order
|
||||||
|
subscription_service.check_order_limit(db, vendor_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get or create customer
|
# Get or create customer
|
||||||
if order_data.customer_id:
|
if order_data.customer_id:
|
||||||
@@ -428,6 +436,9 @@ class OrderService:
|
|||||||
db.flush()
|
db.flush()
|
||||||
db.refresh(order)
|
db.refresh(order)
|
||||||
|
|
||||||
|
# Increment order count for subscription tracking
|
||||||
|
subscription_service.increment_order_count(db, vendor_id)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Order {order.order_number} created for vendor {vendor_id}, "
|
f"Order {order.order_number} created for vendor {vendor_id}, "
|
||||||
f"total: EUR {cents_to_euros(total_amount_cents):.2f}"
|
f"total: EUR {cents_to_euros(total_amount_cents):.2f}"
|
||||||
@@ -439,6 +450,7 @@ class OrderService:
|
|||||||
ValidationException,
|
ValidationException,
|
||||||
InsufficientInventoryException,
|
InsufficientInventoryException,
|
||||||
CustomerNotFoundException,
|
CustomerNotFoundException,
|
||||||
|
TierLimitExceededException,
|
||||||
):
|
):
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -450,6 +462,7 @@ class OrderService:
|
|||||||
db: Session,
|
db: Session,
|
||||||
vendor_id: int,
|
vendor_id: int,
|
||||||
shipment_data: dict[str, Any],
|
shipment_data: dict[str, Any],
|
||||||
|
skip_limit_check: bool = False,
|
||||||
) -> Order:
|
) -> Order:
|
||||||
"""
|
"""
|
||||||
Create an order from Letzshop shipment data.
|
Create an order from Letzshop shipment data.
|
||||||
@@ -462,13 +475,27 @@ class OrderService:
|
|||||||
db: Database session
|
db: Database session
|
||||||
vendor_id: Vendor ID
|
vendor_id: Vendor ID
|
||||||
shipment_data: Raw shipment data from Letzshop API
|
shipment_data: Raw shipment data from Letzshop API
|
||||||
|
skip_limit_check: If True, skip tier limit check (for batch imports
|
||||||
|
that check limit upfront)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Created Order object
|
Created Order object
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValidationException: If product not found by GTIN
|
ValidationException: If product not found by GTIN
|
||||||
|
TierLimitExceededException: If vendor has reached order limit
|
||||||
"""
|
"""
|
||||||
|
# Check tier limit before creating order (unless skipped for batch ops)
|
||||||
|
if not skip_limit_check:
|
||||||
|
can_create, message = subscription_service.can_create_order(db, vendor_id)
|
||||||
|
if not can_create:
|
||||||
|
raise TierLimitExceededException(
|
||||||
|
message=message or "Order limit exceeded",
|
||||||
|
limit_type="orders",
|
||||||
|
current=0, # Will be filled by caller if needed
|
||||||
|
limit=0,
|
||||||
|
)
|
||||||
|
|
||||||
order_data = shipment_data.get("order", {})
|
order_data = shipment_data.get("order", {})
|
||||||
|
|
||||||
# Generate order number using Letzshop order number
|
# Generate order number using Letzshop order number
|
||||||
@@ -777,6 +804,9 @@ class OrderService:
|
|||||||
f"order {order.order_number}"
|
f"order {order.order_number}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Increment order count for subscription tracking
|
||||||
|
subscription_service.increment_order_count(db, vendor_id)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Letzshop order {order.order_number} created for vendor {vendor_id}, "
|
f"Letzshop order {order.order_number} created for vendor {vendor_id}, "
|
||||||
f"status: {status}, items: {len(inventory_units)}"
|
f"status: {status}, items: {len(inventory_units)}"
|
||||||
|
|||||||
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 #}
|
{# app/templates/platform/homepage-modern.html #}
|
||||||
{# Modern/trendy platform homepage template with animations #}
|
{# Wizamart OMS - Luxembourg-focused homepage inspired by Veeqo #}
|
||||||
{% extends "platform/base.html" %}
|
{% extends "platform/base.html" %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% if page %}{{ page.title }}{% else %}Home{% endif %} - Marketplace
|
Wizamart - The Back-Office for Letzshop Sellers
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<style>
|
<style>
|
||||||
|
.gradient-lu {
|
||||||
|
background: linear-gradient(135deg, #00A1DE 0%, #EF3340 100%);
|
||||||
|
}
|
||||||
|
.gradient-lu-subtle {
|
||||||
|
background: linear-gradient(135deg, #f0f9ff 0%, #fef2f2 100%);
|
||||||
|
}
|
||||||
@keyframes float {
|
@keyframes float {
|
||||||
0%, 100% { transform: translateY(0px); }
|
0%, 100% { transform: translateY(0px); }
|
||||||
50% { transform: translateY(-20px); }
|
50% { transform: translateY(-10px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.float-animation {
|
.float-animation {
|
||||||
animation: float 6s ease-in-out infinite;
|
animation: float 4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
.feature-card:hover {
|
||||||
@keyframes gradient {
|
transform: translateY(-4px);
|
||||||
0% { background-position: 0% 50%; }
|
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||||
50% { background-position: 100% 50%; }
|
|
||||||
100% { background-position: 0% 50%; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.animated-gradient {
|
|
||||||
background: linear-gradient(270deg, #6366f1, #8b5cf6, #ec4899, #f43f5e);
|
|
||||||
background-size: 400% 400%;
|
|
||||||
animation: gradient 15s ease infinite;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
<!-- MODERN HERO WITH GRADIENT -->
|
<!-- HERO - The Back-Office Letzshop Doesn't Give You -->
|
||||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
<section class="relative overflow-hidden animated-gradient text-white py-24 md:py-32">
|
<section class="relative overflow-hidden bg-gray-900 text-white py-20 md:py-28">
|
||||||
{# Decorative elements #}
|
{# Background pattern #}
|
||||||
<div class="absolute top-0 right-0 w-1/3 h-full opacity-20">
|
<div class="absolute inset-0 opacity-10">
|
||||||
<div class="absolute top-20 right-20 w-72 h-72 rounded-full bg-white blur-3xl"></div>
|
<div class="absolute top-0 left-0 w-full h-full" style="background-image: url('data:image/svg+xml,%3Csvg width=\"60\" height=\"60\" viewBox=\"0 0 60 60\" xmlns=\"http://www.w3.org/2000/svg\"%3E%3Cg fill=\"none\" fill-rule=\"evenodd\"%3E%3Cg fill=\"%23ffffff\" fill-opacity=\"0.4\"%3E%3Cpath d=\"M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z\"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E');"></div>
|
||||||
<div class="absolute bottom-20 right-40 w-96 h-96 rounded-full bg-white blur-3xl"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||||
{# Left column - Content #}
|
{# Left column - Content #}
|
||||||
<div>
|
<div>
|
||||||
{% if page %}
|
<div class="inline-flex items-center px-4 py-2 bg-blue-500/20 backdrop-blur-sm rounded-full text-sm font-medium mb-6 border border-blue-400/30">
|
||||||
<h1 class="text-5xl md:text-7xl font-black mb-6 leading-tight">
|
<span class="mr-2">🇱🇺</span> Built for Luxembourg E-Commerce
|
||||||
{{ page.title }}
|
</div>
|
||||||
</h1>
|
|
||||||
<div class="text-xl md:text-2xl mb-8 opacity-95">
|
|
||||||
{{ page.content | safe }}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="inline-block px-4 py-2 bg-white/20 backdrop-blur-sm rounded-full text-sm font-semibold mb-6">
|
|
||||||
✨ The Future of E-Commerce
|
|
||||||
</div>
|
|
||||||
<h1 class="text-5xl md:text-7xl font-black mb-6 leading-tight">
|
|
||||||
Build Your<br>
|
|
||||||
<span class="text-transparent bg-clip-text bg-white">
|
|
||||||
Dream Store
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
<p class="text-xl md:text-2xl mb-8 opacity-95">
|
|
||||||
Launch a stunning online marketplace in minutes. No coding required. Scale effortlessly.
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row gap-4">
|
<h1 class="text-4xl md:text-5xl lg:text-6xl font-bold mb-6 leading-tight">
|
||||||
|
The Back-Office<br>
|
||||||
|
<span class="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-cyan-400">
|
||||||
|
Letzshop Doesn't Give You
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="text-xl md:text-2xl text-gray-300 mb-8 leading-relaxed">
|
||||||
|
Sync orders, manage inventory, generate invoices with correct VAT, and own your customer data. All in one place.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 mb-8">
|
||||||
<a href="/contact"
|
<a href="/contact"
|
||||||
class="inline-flex items-center justify-center bg-white text-gray-900 px-8 py-4 rounded-xl font-bold hover:shadow-2xl transform hover:scale-105 transition-all duration-200">
|
class="inline-flex items-center justify-center bg-blue-500 hover:bg-blue-600 text-white px-8 py-4 rounded-xl font-bold transition-all duration-200 shadow-lg hover:shadow-xl">
|
||||||
<span>Start Free Trial</span>
|
<span>Start 14-Day Free Trial</span>
|
||||||
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<a href="#features"
|
<a href="#how-it-works"
|
||||||
class="inline-flex items-center justify-center border-2 border-white text-white px-8 py-4 rounded-xl font-bold hover:bg-white/10 backdrop-blur-sm transition-all duration-200">
|
class="inline-flex items-center justify-center border-2 border-gray-600 text-white px-8 py-4 rounded-xl font-bold hover:bg-white/10 transition-all duration-200">
|
||||||
Learn More
|
See How It Works
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Stats #}
|
<p class="text-sm text-gray-400">
|
||||||
<div class="grid grid-cols-3 gap-6 mt-12 pt-12 border-t border-white/20">
|
No credit card required. Setup in 5 minutes. Cancel anytime.
|
||||||
<div>
|
</p>
|
||||||
<div class="text-3xl font-bold mb-1">10K+</div>
|
|
||||||
<div class="text-sm opacity-80">Active Vendors</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-3xl font-bold mb-1">50M+</div>
|
|
||||||
<div class="text-sm opacity-80">Products Sold</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-3xl font-bold mb-1">99.9%</div>
|
|
||||||
<div class="text-sm opacity-80">Uptime</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Right column - Visual element #}
|
{# Right column - Dashboard Preview #}
|
||||||
<div class="hidden lg:block float-animation">
|
<div class="hidden lg:block">
|
||||||
<div class="relative">
|
<div class="relative float-animation">
|
||||||
<div class="w-full h-96 bg-white/10 backdrop-blur-xl rounded-3xl shadow-2xl p-8">
|
<div class="bg-gray-800 rounded-2xl shadow-2xl border border-gray-700 overflow-hidden">
|
||||||
<div class="w-full h-full bg-gradient-to-br from-white/20 to-transparent rounded-2xl flex items-center justify-center">
|
{# Mock dashboard header #}
|
||||||
<div class="text-center">
|
<div class="bg-gray-900 px-4 py-3 flex items-center gap-2 border-b border-gray-700">
|
||||||
<div class="text-8xl mb-4">🚀</div>
|
<div class="w-3 h-3 rounded-full bg-red-500"></div>
|
||||||
<div class="text-2xl font-bold">Launch Today</div>
|
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
|
||||||
|
<div class="w-3 h-3 rounded-full bg-green-500"></div>
|
||||||
|
<span class="ml-4 text-gray-400 text-sm">Wizamart Dashboard</span>
|
||||||
|
</div>
|
||||||
|
{# Mock dashboard content #}
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<div class="bg-gray-700/50 rounded-lg p-4">
|
||||||
|
<div class="text-gray-400 text-xs mb-1">Today's Orders</div>
|
||||||
|
<div class="text-2xl font-bold text-white">24</div>
|
||||||
|
<div class="text-green-400 text-xs">+12% vs yesterday</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-700/50 rounded-lg p-4">
|
||||||
|
<div class="text-gray-400 text-xs mb-1">Revenue</div>
|
||||||
|
<div class="text-2xl font-bold text-white">EUR 1,847</div>
|
||||||
|
<div class="text-green-400 text-xs">+8% vs yesterday</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-700/50 rounded-lg p-4">
|
||||||
|
<div class="text-gray-400 text-xs mb-1">Low Stock</div>
|
||||||
|
<div class="text-2xl font-bold text-yellow-400">3</div>
|
||||||
|
<div class="text-gray-400 text-xs">items need restock</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-700/50 rounded-lg p-4">
|
||||||
|
<div class="text-gray-400 text-xs mb-3">Recent Orders from Letzshop</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between items-center text-sm">
|
||||||
|
<span class="text-white">#LS-4521</span>
|
||||||
|
<span class="text-gray-400">Marie D.</span>
|
||||||
|
<span class="text-green-400">EUR 89.00</span>
|
||||||
|
<span class="bg-blue-500/20 text-blue-400 px-2 py-0.5 rounded text-xs">Confirmed</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center text-sm">
|
||||||
|
<span class="text-white">#LS-4520</span>
|
||||||
|
<span class="text-gray-400">Jean M.</span>
|
||||||
|
<span class="text-green-400">EUR 156.50</span>
|
||||||
|
<span class="bg-purple-500/20 text-purple-400 px-2 py-0.5 rounded text-xs">Shipped</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,141 +132,466 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
<!-- FEATURES WITH CARDS -->
|
<!-- INTEGRATION BADGE - Works with Letzshop -->
|
||||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
<section id="features" class="py-24 bg-gray-50 dark:bg-gray-900">
|
<section class="py-8 bg-gray-50 dark:bg-gray-800 border-y border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex flex-col md:flex-row items-center justify-center gap-6 md:gap-12">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400 font-medium">Official Integration</span>
|
||||||
|
<div class="flex items-center gap-3 bg-white dark:bg-gray-700 px-6 py-3 rounded-xl shadow-sm">
|
||||||
|
<span class="text-2xl">🛒</span>
|
||||||
|
<span class="font-bold text-gray-900 dark:text-white">Letzshop.lu</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400 text-sm">Connect in 2 minutes</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- THE PROBLEM - Pain Points -->
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<section class="py-20 bg-white dark:bg-gray-900">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="text-center mb-16">
|
<div class="text-center mb-16">
|
||||||
<div class="inline-block px-4 py-2 bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded-full text-sm font-semibold mb-4">
|
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
✨ Features
|
Sound Familiar?
|
||||||
</div>
|
|
||||||
<h2 class="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white mb-4">
|
|
||||||
Everything You Need
|
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||||
Powerful features to help you succeed in the digital marketplace
|
These are the daily frustrations of Letzshop sellers
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6">
|
||||||
|
<div class="text-3xl mb-4">📋</div>
|
||||||
|
<h3 class="font-bold text-gray-900 dark:text-white mb-2">Manual Order Entry</h3>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 text-sm">Copy-pasting orders from Letzshop to spreadsheets. Every. Single. Day.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6">
|
||||||
|
<div class="text-3xl mb-4">📦</div>
|
||||||
|
<h3 class="font-bold text-gray-900 dark:text-white mb-2">Inventory Chaos</h3>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 text-sm">Stock in Letzshop doesn't match reality. Overselling happens.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6">
|
||||||
|
<div class="text-3xl mb-4">🧾</div>
|
||||||
|
<h3 class="font-bold text-gray-900 dark:text-white mb-2">Wrong VAT Invoices</h3>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 text-sm">EU customers need correct VAT. Your accountant keeps complaining.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6">
|
||||||
|
<div class="text-3xl mb-4">👥</div>
|
||||||
|
<h3 class="font-bold text-gray-900 dark:text-white mb-2">Lost Customers</h3>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 text-sm">Letzshop owns your customer data. You can't retarget or build loyalty.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- HOW IT WORKS - 4-Step Workflow (Veeqo-style) -->
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<section id="how-it-works" class="py-20 gradient-lu-subtle dark:bg-gray-800">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="text-center mb-16">
|
||||||
|
<div class="inline-block px-4 py-2 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded-full text-sm font-semibold mb-4">
|
||||||
|
How It Works
|
||||||
|
</div>
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
From Chaos to Control in 4 Steps
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
|
{# Step 1 #}
|
||||||
|
<div class="relative">
|
||||||
|
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg h-full">
|
||||||
|
<div class="w-12 h-12 rounded-full bg-blue-500 text-white flex items-center justify-center font-bold text-xl mb-6">1</div>
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Connect Letzshop</h3>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">Enter your Letzshop API credentials. Done in 2 minutes, no technical skills needed.</p>
|
||||||
|
</div>
|
||||||
|
<div class="hidden lg:block absolute top-1/2 -right-4 w-8 h-0.5 bg-blue-300"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Step 2 #}
|
||||||
|
<div class="relative">
|
||||||
|
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg h-full">
|
||||||
|
<div class="w-12 h-12 rounded-full bg-blue-500 text-white flex items-center justify-center font-bold text-xl mb-6">2</div>
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Orders Flow In</h3>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">Orders sync automatically. Confirm and add tracking directly from Wizamart.</p>
|
||||||
|
</div>
|
||||||
|
<div class="hidden lg:block absolute top-1/2 -right-4 w-8 h-0.5 bg-blue-300"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Step 3 #}
|
||||||
|
<div class="relative">
|
||||||
|
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg h-full">
|
||||||
|
<div class="w-12 h-12 rounded-full bg-blue-500 text-white flex items-center justify-center font-bold text-xl mb-6">3</div>
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Generate Invoices</h3>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">One click to create compliant PDF invoices with correct VAT for any EU country.</p>
|
||||||
|
</div>
|
||||||
|
<div class="hidden lg:block absolute top-1/2 -right-4 w-8 h-0.5 bg-blue-300"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Step 4 #}
|
||||||
|
<div class="relative">
|
||||||
|
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg h-full">
|
||||||
|
<div class="w-12 h-12 rounded-full bg-green-500 text-white flex items-center justify-center font-bold text-xl mb-6">4</div>
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Grow Your Business</h3>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">Export customers for marketing. Track inventory. Focus on selling, not spreadsheets.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- FEATURES - What You Get -->
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<section id="features" class="py-20 bg-white dark:bg-gray-900">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="text-center mb-16">
|
||||||
|
<div class="inline-block px-4 py-2 bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 rounded-full text-sm font-semibold mb-4">
|
||||||
|
Features
|
||||||
|
</div>
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Everything a Letzshop Seller Needs
|
||||||
|
</h2>
|
||||||
|
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||||
|
The operational tools Letzshop doesn't provide
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{# Feature cards with hover effects #}
|
{# Feature 1: Order Sync #}
|
||||||
<div class="group bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-sm hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2">
|
<div class="feature-card bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 transition-all duration-300">
|
||||||
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform">
|
<div class="w-14 h-14 rounded-2xl bg-blue-500 flex items-center justify-center mb-6">
|
||||||
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Automatic Order Sync</h3>
|
||||||
Blazing Fast
|
<p class="text-gray-600 dark:text-gray-400 mb-4">Orders from Letzshop appear instantly. Confirm orders and sync tracking numbers back automatically.</p>
|
||||||
</h3>
|
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||||
<p class="text-gray-600 dark:text-gray-400">
|
<li>Real-time sync</li>
|
||||||
Optimized for performance with sub-second page loads and instant search results.
|
<li>One-click confirmation</li>
|
||||||
</p>
|
<li>Tracking number sync</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="group bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-sm hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2">
|
{# Feature 2: Inventory #}
|
||||||
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform">
|
<div class="feature-card bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 transition-all duration-300">
|
||||||
|
<div class="w-14 h-14 rounded-2xl bg-green-500 flex items-center justify-center mb-6">
|
||||||
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Real Inventory Management</h3>
|
||||||
Bank-Level Security
|
<p class="text-gray-600 dark:text-gray-400 mb-4">One source of truth for all stock. Locations, reservations, and incoming stock tracking.</p>
|
||||||
</h3>
|
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||||
<p class="text-gray-600 dark:text-gray-400">
|
<li>Product locations (bins)</li>
|
||||||
Enterprise-grade encryption and security measures to protect your business.
|
<li>Stock reservations</li>
|
||||||
</p>
|
<li>Low stock alerts</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="group bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-sm hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2">
|
{# Feature 3: Invoicing #}
|
||||||
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-green-500 to-teal-500 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform">
|
<div class="feature-card bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 transition-all duration-300">
|
||||||
|
<div class="w-14 h-14 rounded-2xl bg-purple-500 flex items-center justify-center mb-6">
|
||||||
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Smart VAT Invoicing</h3>
|
||||||
Fully Customizable
|
<p class="text-gray-600 dark:text-gray-400 mb-4">Generate PDF invoices with correct VAT rates. Luxembourg, EU countries, B2B reverse charge.</p>
|
||||||
</h3>
|
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||||
<p class="text-gray-600 dark:text-gray-400">
|
<li>Luxembourg 17% VAT</li>
|
||||||
Brand your store with custom themes, colors, fonts, and layouts.
|
<li>EU destination VAT (OSS)</li>
|
||||||
</p>
|
<li>B2B reverse charge</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="group bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-sm hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2">
|
{# Feature 4: Customers #}
|
||||||
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-orange-500 to-red-500 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform">
|
<div class="feature-card bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 transition-all duration-300">
|
||||||
|
<div class="w-14 h-14 rounded-2xl bg-orange-500 flex items-center justify-center mb-6">
|
||||||
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Own Your Customers</h3>
|
||||||
Analytics & Insights
|
<p class="text-gray-600 dark:text-gray-400 mb-4">All customer data in your database. Export to Mailchimp for marketing campaigns.</p>
|
||||||
</h3>
|
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||||
<p class="text-gray-600 dark:text-gray-400">
|
<li>Order history per customer</li>
|
||||||
Powerful analytics to track sales, customer behavior, and growth metrics.
|
<li>Lifetime value tracking</li>
|
||||||
</p>
|
<li>CSV export for marketing</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="group bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-sm hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2">
|
{# Feature 5: Team #}
|
||||||
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform">
|
<div class="feature-card bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 transition-all duration-300">
|
||||||
|
<div class="w-14 h-14 rounded-2xl bg-cyan-500 flex items-center justify-center mb-6">
|
||||||
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Team Management</h3>
|
||||||
Mobile-First Design
|
<p class="text-gray-600 dark:text-gray-400 mb-4">Invite team members with role-based permissions. Everyone works from one dashboard.</p>
|
||||||
</h3>
|
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||||
<p class="text-gray-600 dark:text-gray-400">
|
<li>Multiple users</li>
|
||||||
Beautiful, responsive design that works perfectly on all devices.
|
<li>Role-based access</li>
|
||||||
</p>
|
<li>Activity logging</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="group bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-sm hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2">
|
{# Feature 6: Purchase Orders #}
|
||||||
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-pink-500 to-rose-500 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform">
|
<div class="feature-card bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 transition-all duration-300">
|
||||||
|
<div class="w-14 h-14 rounded-2xl bg-pink-500 flex items-center justify-center mb-6">
|
||||||
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192l-3.536 3.536M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Purchase Orders</h3>
|
||||||
24/7 Support
|
<p class="text-gray-600 dark:text-gray-400 mb-4">Track incoming stock from suppliers. Know what's on order and when it arrives.</p>
|
||||||
</h3>
|
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||||
<p class="text-gray-600 dark:text-gray-400">
|
<li>Track supplier orders</li>
|
||||||
Round-the-clock customer support to help you succeed at every step.
|
<li>Expected arrival dates</li>
|
||||||
</p>
|
<li>Receive and update stock</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
<!-- MODERN CTA WITH GRADIENT -->
|
<!-- PRICING - 4 Tiers -->
|
||||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
<section class="py-24 relative overflow-hidden">
|
<section id="pricing" class="py-20 bg-gray-50 dark:bg-gray-800">
|
||||||
<div class="absolute inset-0 gradient-accent opacity-90"></div>
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="text-center mb-16">
|
||||||
|
<div class="inline-block px-4 py-2 bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded-full text-sm font-semibold mb-4">
|
||||||
|
Pricing
|
||||||
|
</div>
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Simple, Transparent Pricing
|
||||||
|
</h2>
|
||||||
|
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||||
|
No per-order fees. No hidden costs. Flat monthly rate.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="relative max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-white">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<h2 class="text-4xl md:text-5xl font-bold mb-6">
|
{# Essential #}
|
||||||
Start Your Journey Today
|
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2">Essential</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm mb-4">For solo vendors getting started</p>
|
||||||
|
<div class="mb-6">
|
||||||
|
<span class="text-4xl font-bold text-gray-900 dark:text-white">EUR 49</span>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">/month</span>
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-3 mb-8 text-sm">
|
||||||
|
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||||
|
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
100 orders/month
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||||
|
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
200 products
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||||
|
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
Luxembourg VAT invoices
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||||
|
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
1 team member
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<a href="/contact" class="block w-full text-center bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white px-6 py-3 rounded-xl font-semibold hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
Start Free Trial
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Professional - Highlighted #}
|
||||||
|
<div class="bg-blue-600 rounded-2xl p-8 shadow-xl relative transform lg:scale-105">
|
||||||
|
<div class="absolute -top-4 left-1/2 -translate-x-1/2 bg-orange-500 text-white text-xs font-bold px-3 py-1 rounded-full">
|
||||||
|
MOST POPULAR
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold text-white mb-2">Professional</h3>
|
||||||
|
<p class="text-blue-200 text-sm mb-4">For growing multi-channel sellers</p>
|
||||||
|
<div class="mb-6">
|
||||||
|
<span class="text-4xl font-bold text-white">EUR 99</span>
|
||||||
|
<span class="text-blue-200">/month</span>
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-3 mb-8 text-sm">
|
||||||
|
<li class="flex items-center text-blue-100">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
500 orders/month
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center text-blue-100">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
Unlimited products
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center text-blue-100">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
<strong>EU VAT invoices</strong>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center text-blue-100">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
Product locations
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center text-blue-100">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
Purchase orders
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center text-blue-100">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
Customer export
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center text-blue-100">
|
||||||
|
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
3 team members
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<a href="/contact" class="block w-full text-center bg-white text-blue-600 px-6 py-3 rounded-xl font-bold hover:bg-blue-50 transition-colors">
|
||||||
|
Start Free Trial
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Business #}
|
||||||
|
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2">Business</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm mb-4">For high-volume operations</p>
|
||||||
|
<div class="mb-6">
|
||||||
|
<span class="text-4xl font-bold text-gray-900 dark:text-white">EUR 199</span>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">/month</span>
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-3 mb-8 text-sm">
|
||||||
|
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||||
|
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
2,000 orders/month
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||||
|
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
Everything in Professional
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||||
|
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
<strong>Analytics dashboard</strong>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||||
|
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
<strong>API access</strong>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||||
|
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
Accounting export
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||||
|
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
10 team members
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<a href="/contact" class="block w-full text-center bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white px-6 py-3 rounded-xl font-semibold hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
Start Free Trial
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Enterprise #}
|
||||||
|
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2">Enterprise</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm mb-4">For large operations & agencies</p>
|
||||||
|
<div class="mb-6">
|
||||||
|
<span class="text-4xl font-bold text-gray-900 dark:text-white">EUR 399+</span>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">/month</span>
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-3 mb-8 text-sm">
|
||||||
|
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||||
|
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
Unlimited orders
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||||
|
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
Everything in Business
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||||
|
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
<strong>White-label option</strong>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||||
|
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
Custom integrations
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||||
|
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
99.9% SLA
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||||
|
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
Dedicated support
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<a href="/contact" class="block w-full text-center bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white px-6 py-3 rounded-xl font-semibold hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
Contact Sales
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-center text-gray-500 dark:text-gray-400 mt-8">
|
||||||
|
All plans include a 14-day free trial. No credit card required.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- TESTIMONIAL / SOCIAL PROOF -->
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<section class="py-20 bg-white dark:bg-gray-900">
|
||||||
|
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||||
|
<div class="inline-block px-4 py-2 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-600 dark:text-yellow-400 rounded-full text-sm font-semibold mb-8">
|
||||||
|
Built for Luxembourg
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<blockquote class="text-2xl md:text-3xl font-medium text-gray-900 dark:text-white mb-8 leading-relaxed">
|
||||||
|
"Finally, a tool that understands what Letzshop sellers actually need. No more spreadsheets, no more VAT headaches."
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center gap-4">
|
||||||
|
<div class="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center text-xl">
|
||||||
|
👩
|
||||||
|
</div>
|
||||||
|
<div class="text-left">
|
||||||
|
<div class="font-semibold text-gray-900 dark:text-white">Marie L.</div>
|
||||||
|
<div class="text-gray-500 dark:text-gray-400 text-sm">Letzshop Vendor, Luxembourg City</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- FINAL CTA -->
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<section class="py-20 bg-gray-900 text-white">
|
||||||
|
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold mb-6">
|
||||||
|
Ready to Take Control of Your Letzshop Business?
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-xl mb-10 opacity-90">
|
<p class="text-xl text-gray-300 mb-10">
|
||||||
Join thousands of successful vendors on our platform
|
Join Luxembourg vendors who've stopped fighting spreadsheets and started growing their business.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
<a href="/contact"
|
<a href="/contact"
|
||||||
class="inline-flex items-center justify-center bg-white text-gray-900 px-8 py-4 rounded-xl font-bold hover:shadow-2xl transform hover:scale-105 transition-all duration-200">
|
class="inline-flex items-center justify-center bg-blue-500 hover:bg-blue-600 text-white px-8 py-4 rounded-xl font-bold transition-all duration-200 shadow-lg">
|
||||||
<span>Get Started Free</span>
|
<span>Start Your 14-Day Free Trial</span>
|
||||||
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<a href="/about"
|
|
||||||
class="inline-flex items-center justify-center border-2 border-white text-white px-8 py-4 rounded-xl font-bold hover:bg-white/10 backdrop-blur-sm transition-all duration-200">
|
|
||||||
Learn More About Us
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="mt-8 text-sm opacity-75">
|
<p class="mt-8 text-sm text-gray-400">
|
||||||
No credit card required · Free 14-day trial · Cancel anytime
|
No credit card required. Setup in 5 minutes. Full Professional features during trial.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**"Lightweight OMS for Letzshop Sellers"**
|
**"Lightweight OMS for Letzshop Sellers"**
|
||||||
|
|
||||||
A focused Order Management System that works alongside Letzshop, not instead of it. Provides the operational tools Letzshop lacks: real inventory, correct invoicing, customer ownership.
|
A focused Order Management System built specifically for Luxembourg e-commerce. Works alongside Letzshop, not instead of it. Provides the operational tools Letzshop lacks: real inventory, correct invoicing, customer ownership.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -79,80 +79,104 @@ A focused Order Management System that works alongside Letzshop, not instead of
|
|||||||
|
|
||||||
**Upgrade Triggers:**
|
**Upgrade Triggers:**
|
||||||
- Exceeds 500 orders/month
|
- Exceeds 500 orders/month
|
||||||
- Multiple Letzshop vendor accounts
|
- Needs analytics and reporting
|
||||||
- Needs API for integrations
|
|
||||||
- Team grows beyond 3
|
- Team grows beyond 3
|
||||||
|
- Accountant needs structured exports
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Business - EUR 199/month
|
### Business - EUR 199/month
|
||||||
|
|
||||||
**Target:** High-volume vendors, teams, multiple operations
|
**Target:** High-volume vendors, teams, data-driven operations
|
||||||
|
|
||||||
| Feature | Limit |
|
| Feature | Limit |
|
||||||
|---------|-------|
|
|---------|-------|
|
||||||
| Letzshop Order Sync | Unlimited orders |
|
| Letzshop Order Sync | Up to 2,000 orders/month |
|
||||||
| Everything in Professional | Included |
|
| Everything in Professional | Included |
|
||||||
| **Multi-Vendor Support** | Multiple Letzshop accounts |
|
| **Analytics Dashboard** | Sales trends, top products, customer insights |
|
||||||
| **API Access** | For custom integrations |
|
|
||||||
| **Accounting Export** | CSV/XML for accounting software |
|
| **Accounting Export** | CSV/XML for accounting software |
|
||||||
|
| **Bulk Operations** | Mass product updates, bulk invoicing |
|
||||||
|
| **Advanced Reporting** | Custom date ranges, exportable reports |
|
||||||
|
| **API Access** | For custom integrations |
|
||||||
|
| **Automation Rules** | Auto-confirm orders, low-stock alerts |
|
||||||
| Team Members | 10 users |
|
| Team Members | 10 users |
|
||||||
| Team Roles & Permissions | Full access control |
|
| Team Roles & Permissions | Full access control |
|
||||||
| Order History | Unlimited |
|
| Order History | Unlimited |
|
||||||
| Phone Support | Available |
|
| Priority Support | 4h response, phone available |
|
||||||
| Dedicated Onboarding | 2-hour session included |
|
|
||||||
|
|
||||||
**Value Proposition:** "Enterprise-grade OMS at SMB pricing."
|
**Value Proposition:** "Scale with confidence. Analytics, automation, and full control."
|
||||||
|
|
||||||
|
**Upgrade Triggers:**
|
||||||
|
- Exceeds 2,000 orders/month
|
||||||
|
- Needs white-label or custom integrations
|
||||||
|
- Requires SLA guarantee
|
||||||
|
- Multiple brands/operations
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Enterprise - Custom Pricing
|
### Enterprise - EUR 399+/month (Custom)
|
||||||
|
|
||||||
**Target:** Large operations, special requirements
|
**Target:** Large operations, special requirements, agencies
|
||||||
|
|
||||||
| Feature | Notes |
|
| Feature | Notes |
|
||||||
|---------|-------|
|
|---------|-------|
|
||||||
| Everything in Business | Included |
|
| Everything in Business | Included |
|
||||||
|
| Unlimited Orders | No volume limits |
|
||||||
| Unlimited Team Members | As needed |
|
| Unlimited Team Members | As needed |
|
||||||
| Custom Integrations | Built to spec |
|
| **White-Label Option** | Remove Wizamart branding |
|
||||||
| SLA Guarantee | 99.9% uptime |
|
| **Custom Integrations** | Connect to ERP, accounting software |
|
||||||
| Dedicated Account Manager | Named contact |
|
| **Multi-Vendor Support** | Multiple Letzshop accounts under one dashboard |
|
||||||
| On-Premise Option | If required |
|
| **SLA Guarantee** | 99.9% uptime commitment |
|
||||||
|
| **Dedicated Account Manager** | Named contact |
|
||||||
|
| **On-Premise Option** | For security-sensitive clients |
|
||||||
|
| **Custom Development** | Bespoke features on request |
|
||||||
|
|
||||||
**Starting at EUR 499/month** - Contact for quote
|
**Starting at EUR 399/month** - Contact for quote
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Feature Comparison Matrix
|
## Feature Comparison Matrix
|
||||||
|
|
||||||
| Feature | Essential | Professional | Business |
|
| Feature | Essential | Professional | Business | Enterprise |
|
||||||
|---------|:---------:|:------------:|:--------:|
|
|---------|:---------:|:------------:|:--------:|:----------:|
|
||||||
| **Orders** |
|
| **Orders** |
|
||||||
| Letzshop Sync | 100/mo | 500/mo | Unlimited |
|
| Letzshop Sync | 100/mo | 500/mo | 2,000/mo | Unlimited |
|
||||||
| Order Confirmation | ✓ | ✓ | ✓ |
|
| Order Confirmation | Yes | Yes | Yes | Yes |
|
||||||
| Tracking Sync | ✓ | ✓ | ✓ |
|
| Tracking Sync | Yes | Yes | Yes | Yes |
|
||||||
| Order History | 6 months | 24 months | Unlimited |
|
| Order History | 6 months | 24 months | Unlimited | Unlimited |
|
||||||
| **Inventory** |
|
| **Inventory** |
|
||||||
| Product Limit | 200 | Unlimited | Unlimited |
|
| Product Limit | 200 | Unlimited | Unlimited | Unlimited |
|
||||||
| Stock Levels | ✓ | ✓ | ✓ |
|
| Stock Levels | Yes | Yes | Yes | Yes |
|
||||||
| Product Locations | - | ✓ | ✓ |
|
| Product Locations | - | Yes | Yes | Yes |
|
||||||
| Incoming Stock / PO | - | ✓ | ✓ |
|
| Incoming Stock / PO | - | Yes | Yes | Yes |
|
||||||
| **Invoicing** |
|
| **Invoicing** |
|
||||||
| Luxembourg Invoice | ✓ | ✓ | ✓ |
|
| Luxembourg Invoice | Yes | Yes | Yes | Yes |
|
||||||
| EU VAT Invoice | - | ✓ | ✓ |
|
| EU VAT Invoice | - | Yes | Yes | Yes |
|
||||||
| Accounting Export | - | - | ✓ |
|
| Bulk Invoicing | - | - | Yes | Yes |
|
||||||
|
| Accounting Export | - | - | Yes | Yes |
|
||||||
| **Customers** |
|
| **Customers** |
|
||||||
| Customer List | View | View + Export | View + Export |
|
| Customer List | View | View + Export | View + Export | View + Export |
|
||||||
|
| **Analytics & Reporting** |
|
||||||
|
| Basic Stats | Yes | Yes | Yes | Yes |
|
||||||
|
| Analytics Dashboard | - | - | Yes | Yes |
|
||||||
|
| Advanced Reporting | - | - | Yes | Yes |
|
||||||
|
| Custom Reports | - | - | - | Yes |
|
||||||
|
| **Automation** |
|
||||||
|
| Low Stock Alerts | - | - | Yes | Yes |
|
||||||
|
| Auto-Confirm Orders | - | - | Yes | Yes |
|
||||||
|
| Custom Automation | - | - | - | Yes |
|
||||||
| **Team** |
|
| **Team** |
|
||||||
| Users | 1 | 3 | 10 |
|
| Users | 1 | 3 | 10 | Unlimited |
|
||||||
| Roles & Permissions | - | - | ✓ |
|
| Roles & Permissions | - | - | Yes | Yes |
|
||||||
| **Operations** |
|
| **Integration** |
|
||||||
| Multi-Vendor | - | - | ✓ |
|
| API Access | - | - | Yes | Yes |
|
||||||
| API Access | - | - | ✓ |
|
| Custom Integrations | - | - | - | Yes |
|
||||||
|
| White-Label | - | - | - | Yes |
|
||||||
| **Support** |
|
| **Support** |
|
||||||
| Email | 72h | 24h | 24h |
|
| Email | 72h | 24h | 4h | 4h |
|
||||||
| Phone | - | - | ✓ |
|
| Phone | - | - | Yes | Yes |
|
||||||
| Onboarding | - | - | Included |
|
| Dedicated Manager | - | - | - | Yes |
|
||||||
|
| SLA | - | - | - | 99.9% |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -173,6 +197,7 @@ A focused Order Management System that works alongside Letzshop, not instead of
|
|||||||
| Essential | EUR 49/mo | EUR 490/year (EUR 41/mo) |
|
| Essential | EUR 49/mo | EUR 490/year (EUR 41/mo) |
|
||||||
| Professional | EUR 99/mo | EUR 990/year (EUR 82/mo) |
|
| Professional | EUR 99/mo | EUR 990/year (EUR 82/mo) |
|
||||||
| Business | EUR 199/mo | EUR 1,990/year (EUR 166/mo) |
|
| Business | EUR 199/mo | EUR 1,990/year (EUR 166/mo) |
|
||||||
|
| Enterprise | Custom | Contact for annual pricing |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -191,8 +216,8 @@ A focused Order Management System that works alongside Letzshop, not instead of
|
|||||||
| Add-On | Price | Description |
|
| Add-On | Price | Description |
|
||||||
|--------|-------|-------------|
|
|--------|-------|-------------|
|
||||||
| Additional Team Member | +EUR 9/user/mo | Beyond plan limit |
|
| Additional Team Member | +EUR 9/user/mo | Beyond plan limit |
|
||||||
| Additional Vendor Account | +EUR 29/mo | For Professional tier |
|
| Extended History | +EUR 19/mo | Unlimited history for Essential/Professional |
|
||||||
| Extended History | +EUR 19/mo | Unlimited history for Essential |
|
| Extra Order Capacity | +EUR 29/100 orders | Beyond plan limit |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -210,23 +235,43 @@ A focused Order Management System that works alongside Letzshop, not instead of
|
|||||||
- Most active vendors will need this
|
- Most active vendors will need this
|
||||||
- Price feels reasonable for operational software
|
- Price feels reasonable for operational software
|
||||||
|
|
||||||
3. **Business (EUR 199) provides anchor**
|
3. **Business (EUR 199) is for growth**
|
||||||
- Makes Professional look affordable
|
- Analytics and automation justify the jump
|
||||||
- Captures high-value customers
|
- 2,000 orders/month covers most high-volume vendors
|
||||||
- Multi-vendor is a clear enterprise need
|
- API access enables custom workflows
|
||||||
|
|
||||||
4. **No per-order fees**
|
4. **Enterprise (EUR 399+) captures high-value clients**
|
||||||
|
- White-label and SLA for agencies/resellers
|
||||||
|
- Custom integrations for complex operations
|
||||||
|
- Multi-vendor for the rare but valuable cases
|
||||||
|
|
||||||
|
5. **No per-order fees**
|
||||||
- Unlike Letzshop's commission model
|
- Unlike Letzshop's commission model
|
||||||
- The more you sell, the better the value
|
- The more you sell, the better the value
|
||||||
- Predictable costs for budgeting
|
- Predictable costs for budgeting
|
||||||
|
|
||||||
5. **Annual = lower churn**
|
6. **Annual = lower churn**
|
||||||
- 17% discount is meaningful
|
- 17% discount is meaningful
|
||||||
- 2 months free is easy to understand
|
- 2 months free is easy to understand
|
||||||
- Reduces monthly cancellation opportunity
|
- Reduces monthly cancellation opportunity
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Upgrade Path Logic
|
||||||
|
|
||||||
|
```
|
||||||
|
Essential -> Professional
|
||||||
|
Triggers: Ships to EU (VAT) | 100+ orders | Team access needed
|
||||||
|
|
||||||
|
Professional -> Business
|
||||||
|
Triggers: 500+ orders | Needs analytics | Accountant needs exports
|
||||||
|
|
||||||
|
Business -> Enterprise
|
||||||
|
Triggers: 2,000+ orders | White-label | SLA required | Custom integrations
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Competitive Positioning
|
## Competitive Positioning
|
||||||
|
|
||||||
| Alternative | Their Model | Our Advantage |
|
| Alternative | Their Model | Our Advantage |
|
||||||
@@ -244,21 +289,27 @@ A focused Order Management System that works alongside Letzshop, not instead of
|
|||||||
### Qualifying Questions
|
### Qualifying Questions
|
||||||
|
|
||||||
1. "How many orders do you process per month?"
|
1. "How many orders do you process per month?"
|
||||||
- <100 → Essential
|
- <100 -> Essential
|
||||||
- 100-500 → Professional
|
- 100-500 -> Professional
|
||||||
- 500+ → Business
|
- 500-2000 -> Business
|
||||||
|
- 2000+ -> Enterprise
|
||||||
|
|
||||||
2. "Do you ship to other EU countries?"
|
2. "Do you ship to other EU countries?"
|
||||||
- Yes → Professional minimum (VAT invoicing)
|
- Yes -> Professional minimum (VAT invoicing)
|
||||||
- No → Essential may suffice
|
- No -> Essential may suffice
|
||||||
|
|
||||||
3. "How many people handle orders?"
|
3. "How many people handle orders?"
|
||||||
- 1 → Essential
|
- 1 -> Essential
|
||||||
- 2-3 → Professional
|
- 2-3 -> Professional
|
||||||
- 4+ → Business
|
- 4-10 -> Business
|
||||||
|
- 10+ -> Enterprise
|
||||||
|
|
||||||
4. "Do you have multiple Letzshop accounts?"
|
4. "Do you need analytics and reporting?"
|
||||||
- Yes → Business
|
- Yes -> Business minimum
|
||||||
|
|
||||||
|
5. "Do you need to connect to other systems (ERP, accounting)?"
|
||||||
|
- API needed -> Business
|
||||||
|
- Custom integration -> Enterprise
|
||||||
|
|
||||||
### Objection Handling
|
### Objection Handling
|
||||||
|
|
||||||
@@ -270,3 +321,6 @@ A focused Order Management System that works alongside Letzshop, not instead of
|
|||||||
|
|
||||||
**"Why do I need Professional for EU invoices?"**
|
**"Why do I need Professional for EU invoices?"**
|
||||||
> "EU VAT rules are complex - wrong rate, wrong country, wrong invoice = compliance issues. We handle this automatically so your accountant doesn't have to fix it."
|
> "EU VAT rules are complex - wrong rate, wrong country, wrong invoice = compliance issues. We handle this automatically so your accountant doesn't have to fix it."
|
||||||
|
|
||||||
|
**"Business seems expensive"**
|
||||||
|
> "At 500+ orders/month, you're doing at least EUR 25,000/month in revenue. EUR 199 is less than 1% of that, and the analytics alone will help you find opportunities to grow."
|
||||||
|
|||||||
@@ -167,22 +167,22 @@ The goal is natural upgrade pressure based on **growth**, not artificial limits.
|
|||||||
| Growth Signal | Triggers Upgrade To |
|
| Growth Signal | Triggers Upgrade To |
|
||||||
|---------------|---------------------|
|
|---------------|---------------------|
|
||||||
| More orders | Higher tier (volume) |
|
| More orders | Higher tier (volume) |
|
||||||
| Shipping to EU | Pro (VAT invoicing) |
|
| Shipping to EU | Professional (VAT invoicing) |
|
||||||
| Team grows | Pro/Business (users) |
|
| Team grows | Professional/Business (users) |
|
||||||
| More products | Pro (locations, PO) |
|
| Needs analytics | Business (reporting) |
|
||||||
| Multiple shops | Business (multi-vendor) |
|
| Custom integrations | Enterprise (API/white-label) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Essential - EUR 49/month
|
### Essential - EUR 49/month
|
||||||
|
|
||||||
**Target:** Solo vendors, getting started, Letzshop + 1 channel
|
**Target:** Solo vendors, getting started, Letzshop-focused
|
||||||
|
|
||||||
| Feature | Limit |
|
| Feature | Limit |
|
||||||
|---------|-------|
|
|---------|-------|
|
||||||
| Letzshop Order Sync | Up to 100 orders/month |
|
| Letzshop Order Sync | Up to 100 orders/month |
|
||||||
| Inventory Management | Up to 200 products |
|
| Inventory Management | Up to 200 products |
|
||||||
| Basic Invoices (Luxembourg) | Included |
|
| Basic Invoices (Luxembourg VAT) | Included |
|
||||||
| Team Members | 1 user |
|
| Team Members | 1 user |
|
||||||
| Order History | 6 months |
|
| Order History | 6 months |
|
||||||
| Email Support | 72h response |
|
| Email Support | 72h response |
|
||||||
@@ -207,44 +207,69 @@ The goal is natural upgrade pressure based on **growth**, not artificial limits.
|
|||||||
| Order History | 24 months |
|
| Order History | 24 months |
|
||||||
| Priority Email Support | 24h response |
|
| Priority Email Support | 24h response |
|
||||||
|
|
||||||
**Why they upgrade:** Multiple team members, multiple Letzshop accounts, need API.
|
**Why they upgrade:** Hit 500 orders, need analytics/reporting, team grows beyond 3.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Business - EUR 199/month
|
### Business - EUR 199/month
|
||||||
|
|
||||||
**Target:** Teams, high volume, multiple vendor accounts
|
**Target:** High-volume vendors, teams, data-driven operations
|
||||||
|
|
||||||
| Feature | Limit |
|
| Feature | Limit |
|
||||||
|---------|-------|
|
|---------|-------|
|
||||||
| Letzshop Order Sync | Unlimited orders |
|
| Letzshop Order Sync | Up to 2,000 orders/month |
|
||||||
| Everything in Professional | Included |
|
| Everything in Professional | Included |
|
||||||
| **Multi-Vendor Support** | Multiple Letzshop accounts |
|
| **Analytics Dashboard** | Sales trends, top products, insights |
|
||||||
|
| **Accounting Export** | CSV/XML for accounting software |
|
||||||
|
| **Bulk Operations** | Mass updates, bulk invoicing |
|
||||||
|
| **Advanced Reporting** | Custom date ranges, exports |
|
||||||
| **API Access** | For custom integrations |
|
| **API Access** | For custom integrations |
|
||||||
| **Accounting Export** | CSV/XML for accountants |
|
| **Automation Rules** | Auto-confirm, low-stock alerts |
|
||||||
| Team Members | 10 users |
|
| Team Members | 10 users |
|
||||||
| Team Roles & Permissions | Full RBAC |
|
| Team Roles & Permissions | Full RBAC |
|
||||||
| Order History | Unlimited |
|
| Order History | Unlimited |
|
||||||
| Phone Support | Available |
|
| Priority Support | 4h response, phone available |
|
||||||
| Dedicated Onboarding | Included |
|
|
||||||
|
**Why they upgrade:** Hit 2,000 orders, need white-label, need SLA, custom integrations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Enterprise - EUR 399+/month (Custom)
|
||||||
|
|
||||||
|
**Target:** Large operations, agencies, special requirements
|
||||||
|
|
||||||
|
| Feature | Notes |
|
||||||
|
|---------|-------|
|
||||||
|
| Everything in Business | Included |
|
||||||
|
| Unlimited Orders | No volume limits |
|
||||||
|
| Unlimited Team Members | As needed |
|
||||||
|
| **White-Label Option** | Remove Wizamart branding |
|
||||||
|
| **Custom Integrations** | Connect to ERP, accounting software |
|
||||||
|
| **Multi-Vendor Support** | Multiple Letzshop accounts |
|
||||||
|
| **SLA Guarantee** | 99.9% uptime |
|
||||||
|
| **Dedicated Account Manager** | Named contact |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Feature Access by Tier
|
### Feature Access by Tier
|
||||||
|
|
||||||
| Feature | Essential | Professional | Business |
|
| Feature | Essential | Professional | Business | Enterprise |
|
||||||
|---------|:---------:|:------------:|:--------:|
|
|---------|:---------:|:------------:|:--------:|:----------:|
|
||||||
| Letzshop Order Sync | 100/mo | 500/mo | Unlimited |
|
| Letzshop Order Sync | 100/mo | 500/mo | 2,000/mo | Unlimited |
|
||||||
| Inventory Management | 200 SKU | Unlimited | Unlimited |
|
| Inventory Management | 200 SKU | Unlimited | Unlimited | Unlimited |
|
||||||
| Product Locations | - | ✓ | ✓ |
|
| Product Locations | - | Yes | Yes | Yes |
|
||||||
| Incoming Stock / PO | - | ✓ | ✓ |
|
| Incoming Stock / PO | - | Yes | Yes | Yes |
|
||||||
| Basic Invoice (LU) | ✓ | ✓ | ✓ |
|
| Basic Invoice (LU) | Yes | Yes | Yes | Yes |
|
||||||
| EU VAT Invoice | - | ✓ | ✓ |
|
| EU VAT Invoice | - | Yes | Yes | Yes |
|
||||||
| Customer List | View | + Export | + Export |
|
| Customer List | View | + Export | + Export | + Export |
|
||||||
| Team Members | 1 | 3 | 10 |
|
| Analytics Dashboard | - | - | Yes | Yes |
|
||||||
| Multi-Vendor | - | - | ✓ |
|
| Accounting Export | - | - | Yes | Yes |
|
||||||
| API Access | - | - | ✓ |
|
| API Access | - | - | Yes | Yes |
|
||||||
| Order History | 6 mo | 24 mo | Unlimited |
|
| Automation Rules | - | - | Yes | Yes |
|
||||||
|
| White-Label | - | - | - | Yes |
|
||||||
|
| Multi-Vendor | - | - | - | Yes |
|
||||||
|
| Team Members | 1 | 3 | 10 | Unlimited |
|
||||||
|
| Order History | 6 mo | 24 mo | Unlimited | Unlimited |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -260,12 +285,18 @@ The goal is natural upgrade pressure based on **growth**, not artificial limits.
|
|||||||
- Product locations + PO = operational maturity
|
- Product locations + PO = operational maturity
|
||||||
- 3 users = small team
|
- 3 users = small team
|
||||||
|
|
||||||
3. **Business (EUR 199)** is for scale
|
3. **Business (EUR 199)** is for growth
|
||||||
- Multi-vendor = manages multiple Letzshop shops
|
- Analytics and automation justify the price jump
|
||||||
- API = integrates with other systems
|
- 2,000 orders/month covers most high-volume vendors
|
||||||
- 10 users = real team
|
- API enables custom workflows
|
||||||
|
- No multi-vendor requirement (most don't need it)
|
||||||
|
|
||||||
**Price anchoring:** EUR 99 is the target tier. EUR 49 exists to capture price-sensitive leads. EUR 199 exists to make EUR 99 look reasonable.
|
4. **Enterprise (EUR 399+)** captures high-value clients
|
||||||
|
- White-label for agencies/resellers
|
||||||
|
- Multi-vendor for the rare cases that need it
|
||||||
|
- SLA and dedicated support for critical operations
|
||||||
|
|
||||||
|
**Price anchoring:** EUR 99 is the target tier. EUR 49 captures price-sensitive leads. EUR 199 is for growth. EUR 399+ makes EUR 199 look reasonable.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ from .company import Company
|
|||||||
from .content_page import ContentPage
|
from .content_page import ContentPage
|
||||||
from .customer import Customer, CustomerAddress
|
from .customer import Customer, CustomerAddress
|
||||||
from .inventory import Inventory
|
from .inventory import Inventory
|
||||||
|
from .invoice import (
|
||||||
|
Invoice,
|
||||||
|
InvoiceStatus,
|
||||||
|
VATRegime,
|
||||||
|
VendorInvoiceSettings,
|
||||||
|
)
|
||||||
from .letzshop import (
|
from .letzshop import (
|
||||||
LetzshopFulfillmentQueue,
|
LetzshopFulfillmentQueue,
|
||||||
LetzshopHistoricalImportJob,
|
LetzshopHistoricalImportJob,
|
||||||
@@ -44,6 +50,12 @@ from .order import Order, OrderItem
|
|||||||
from .order_item_exception import OrderItemException
|
from .order_item_exception import OrderItemException
|
||||||
from .product import Product
|
from .product import Product
|
||||||
from .product_translation import ProductTranslation
|
from .product_translation import ProductTranslation
|
||||||
|
from .subscription import (
|
||||||
|
SubscriptionStatus,
|
||||||
|
TierCode,
|
||||||
|
TIER_LIMITS,
|
||||||
|
VendorSubscription,
|
||||||
|
)
|
||||||
from .test_run import TestCollection, TestResult, TestRun
|
from .test_run import TestCollection, TestResult, TestRun
|
||||||
from .user import User
|
from .user import User
|
||||||
from .vendor import Role, Vendor, VendorUser
|
from .vendor import Role, Vendor, VendorUser
|
||||||
@@ -95,6 +107,11 @@ __all__ = [
|
|||||||
"MarketplaceImportError",
|
"MarketplaceImportError",
|
||||||
# Inventory
|
# Inventory
|
||||||
"Inventory",
|
"Inventory",
|
||||||
|
# Invoicing
|
||||||
|
"Invoice",
|
||||||
|
"InvoiceStatus",
|
||||||
|
"VATRegime",
|
||||||
|
"VendorInvoiceSettings",
|
||||||
# Orders
|
# Orders
|
||||||
"Order",
|
"Order",
|
||||||
"OrderItem",
|
"OrderItem",
|
||||||
@@ -104,6 +121,11 @@ __all__ = [
|
|||||||
"LetzshopFulfillmentQueue",
|
"LetzshopFulfillmentQueue",
|
||||||
"LetzshopSyncLog",
|
"LetzshopSyncLog",
|
||||||
"LetzshopHistoricalImportJob",
|
"LetzshopHistoricalImportJob",
|
||||||
|
# Subscription
|
||||||
|
"VendorSubscription",
|
||||||
|
"SubscriptionStatus",
|
||||||
|
"TierCode",
|
||||||
|
"TIER_LIMITS",
|
||||||
# Messaging
|
# Messaging
|
||||||
"Conversation",
|
"Conversation",
|
||||||
"ConversationParticipant",
|
"ConversationParticipant",
|
||||||
|
|||||||
215
models/database/invoice.py
Normal file
215
models/database/invoice.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# models/database/invoice.py
|
||||||
|
"""
|
||||||
|
Invoice database models for the OMS.
|
||||||
|
|
||||||
|
Provides models for:
|
||||||
|
- VendorInvoiceSettings: Per-vendor invoice configuration (company details, VAT, numbering)
|
||||||
|
- Invoice: Invoice records with snapshots of seller/buyer details
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.sqlite import JSON
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from models.database.base import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class VendorInvoiceSettings(Base, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Per-vendor invoice configuration.
|
||||||
|
|
||||||
|
Stores company details, VAT number, invoice numbering preferences,
|
||||||
|
and payment information for invoice generation.
|
||||||
|
|
||||||
|
One-to-one relationship with Vendor.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "vendor_invoice_settings"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
vendor_id = Column(
|
||||||
|
Integer, ForeignKey("vendors.id"), unique=True, nullable=False, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Legal company details for invoice header
|
||||||
|
company_name = Column(String(255), nullable=False) # Legal name for invoices
|
||||||
|
company_address = Column(String(255), nullable=True) # Street address
|
||||||
|
company_city = Column(String(100), nullable=True)
|
||||||
|
company_postal_code = Column(String(20), nullable=True)
|
||||||
|
company_country = Column(String(2), nullable=False, default="LU") # ISO country code
|
||||||
|
|
||||||
|
# VAT information
|
||||||
|
vat_number = Column(String(50), nullable=True) # e.g., "LU12345678"
|
||||||
|
is_vat_registered = Column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
# OSS (One-Stop-Shop) for EU VAT
|
||||||
|
is_oss_registered = Column(Boolean, default=False, nullable=False)
|
||||||
|
oss_registration_country = Column(String(2), nullable=True) # ISO country code
|
||||||
|
|
||||||
|
# Invoice numbering
|
||||||
|
invoice_prefix = Column(String(20), default="INV", nullable=False)
|
||||||
|
invoice_next_number = Column(Integer, default=1, nullable=False)
|
||||||
|
invoice_number_padding = Column(Integer, default=5, nullable=False) # e.g., INV00001
|
||||||
|
|
||||||
|
# Payment information
|
||||||
|
payment_terms = Column(Text, nullable=True) # e.g., "Payment due within 30 days"
|
||||||
|
bank_name = Column(String(255), nullable=True)
|
||||||
|
bank_iban = Column(String(50), nullable=True)
|
||||||
|
bank_bic = Column(String(20), nullable=True)
|
||||||
|
|
||||||
|
# Invoice footer
|
||||||
|
footer_text = Column(Text, nullable=True) # Custom footer text
|
||||||
|
|
||||||
|
# Default VAT rate for Luxembourg invoices (17% standard)
|
||||||
|
default_vat_rate = Column(Numeric(5, 2), default=17.00, nullable=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
vendor = relationship("Vendor", back_populates="invoice_settings")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<VendorInvoiceSettings(vendor_id={self.vendor_id}, company='{self.company_name}')>"
|
||||||
|
|
||||||
|
def get_next_invoice_number(self) -> str:
|
||||||
|
"""Generate the next invoice number and increment counter."""
|
||||||
|
number = str(self.invoice_next_number).zfill(self.invoice_number_padding)
|
||||||
|
return f"{self.invoice_prefix}{number}"
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceStatus(str, enum.Enum):
|
||||||
|
"""Invoice status enumeration."""
|
||||||
|
|
||||||
|
DRAFT = "draft"
|
||||||
|
ISSUED = "issued"
|
||||||
|
PAID = "paid"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class VATRegime(str, enum.Enum):
|
||||||
|
"""VAT regime for invoice calculation."""
|
||||||
|
|
||||||
|
DOMESTIC = "domestic" # Same country as seller
|
||||||
|
OSS = "oss" # EU cross-border with OSS registration
|
||||||
|
REVERSE_CHARGE = "reverse_charge" # B2B with valid VAT number
|
||||||
|
ORIGIN = "origin" # Cross-border without OSS (use origin VAT)
|
||||||
|
EXEMPT = "exempt" # VAT exempt
|
||||||
|
|
||||||
|
|
||||||
|
class Invoice(Base, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Invoice record with snapshots of seller/buyer details.
|
||||||
|
|
||||||
|
Stores complete invoice data including snapshots of seller and buyer
|
||||||
|
details at time of creation for audit purposes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "invoices"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
|
||||||
|
order_id = Column(Integer, ForeignKey("orders.id"), nullable=True, index=True)
|
||||||
|
|
||||||
|
# Invoice identification
|
||||||
|
invoice_number = Column(String(50), nullable=False)
|
||||||
|
invoice_date = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status = Column(String(20), default=InvoiceStatus.DRAFT.value, nullable=False)
|
||||||
|
|
||||||
|
# Seller details snapshot (captured at invoice creation)
|
||||||
|
seller_details = Column(JSON, nullable=False)
|
||||||
|
# Structure: {
|
||||||
|
# "company_name": str,
|
||||||
|
# "address": str,
|
||||||
|
# "city": str,
|
||||||
|
# "postal_code": str,
|
||||||
|
# "country": str,
|
||||||
|
# "vat_number": str | None
|
||||||
|
# }
|
||||||
|
|
||||||
|
# Buyer details snapshot (captured at invoice creation)
|
||||||
|
buyer_details = Column(JSON, nullable=False)
|
||||||
|
# Structure: {
|
||||||
|
# "name": str,
|
||||||
|
# "email": str,
|
||||||
|
# "address": str,
|
||||||
|
# "city": str,
|
||||||
|
# "postal_code": str,
|
||||||
|
# "country": str,
|
||||||
|
# "vat_number": str | None (for B2B)
|
||||||
|
# }
|
||||||
|
|
||||||
|
# Line items snapshot
|
||||||
|
line_items = Column(JSON, nullable=False)
|
||||||
|
# Structure: [{
|
||||||
|
# "description": str,
|
||||||
|
# "quantity": int,
|
||||||
|
# "unit_price_cents": int,
|
||||||
|
# "total_cents": int,
|
||||||
|
# "sku": str | None,
|
||||||
|
# "ean": str | None
|
||||||
|
# }]
|
||||||
|
|
||||||
|
# VAT information
|
||||||
|
vat_regime = Column(String(20), default=VATRegime.DOMESTIC.value, nullable=False)
|
||||||
|
destination_country = Column(String(2), nullable=True) # For OSS invoices
|
||||||
|
vat_rate = Column(Numeric(5, 2), nullable=False) # e.g., 17.00 for 17%
|
||||||
|
vat_rate_label = Column(String(50), nullable=True) # e.g., "Luxembourg Standard VAT"
|
||||||
|
|
||||||
|
# Amounts (stored in cents for precision)
|
||||||
|
currency = Column(String(3), default="EUR", nullable=False)
|
||||||
|
subtotal_cents = Column(Integer, nullable=False) # Before VAT
|
||||||
|
vat_amount_cents = Column(Integer, nullable=False) # VAT amount
|
||||||
|
total_cents = Column(Integer, nullable=False) # After VAT
|
||||||
|
|
||||||
|
# Payment information
|
||||||
|
payment_terms = Column(Text, nullable=True)
|
||||||
|
bank_details = Column(JSON, nullable=True) # IBAN, BIC snapshot
|
||||||
|
footer_text = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# PDF storage
|
||||||
|
pdf_generated_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
pdf_path = Column(String(500), nullable=True) # Path to stored PDF
|
||||||
|
|
||||||
|
# Notes
|
||||||
|
notes = Column(Text, nullable=True) # Internal notes
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
vendor = relationship("Vendor", back_populates="invoices")
|
||||||
|
order = relationship("Order", back_populates="invoices")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_invoice_vendor_number", "vendor_id", "invoice_number", unique=True),
|
||||||
|
Index("idx_invoice_vendor_date", "vendor_id", "invoice_date"),
|
||||||
|
Index("idx_invoice_status", "vendor_id", "status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Invoice(id={self.id}, number='{self.invoice_number}', status='{self.status}')>"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtotal(self) -> float:
|
||||||
|
"""Get subtotal in EUR."""
|
||||||
|
return self.subtotal_cents / 100
|
||||||
|
|
||||||
|
@property
|
||||||
|
def vat_amount(self) -> float:
|
||||||
|
"""Get VAT amount in EUR."""
|
||||||
|
return self.vat_amount_cents / 100
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> float:
|
||||||
|
"""Get total in EUR."""
|
||||||
|
return self.total_cents / 100
|
||||||
@@ -143,6 +143,9 @@ class Order(Base, TimestampMixin):
|
|||||||
items = relationship(
|
items = relationship(
|
||||||
"OrderItem", back_populates="order", cascade="all, delete-orphan"
|
"OrderItem", back_populates="order", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
invoices = relationship(
|
||||||
|
"Invoice", back_populates="order", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
# Composite indexes for common queries
|
# Composite indexes for common queries
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
|||||||
354
models/database/subscription.py
Normal file
354
models/database/subscription.py
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
# models/database/subscription.py
|
||||||
|
"""
|
||||||
|
Subscription database models for tier-based access control.
|
||||||
|
|
||||||
|
Provides models for:
|
||||||
|
- SubscriptionTier: Tier definitions with limits and features
|
||||||
|
- VendorSubscription: Per-vendor subscription tracking
|
||||||
|
|
||||||
|
Tier Structure:
|
||||||
|
- Essential (€49/mo): 100 orders/mo, 200 products, 1 user, LU invoicing
|
||||||
|
- Professional (€99/mo): 500 orders/mo, unlimited products, 3 users, EU VAT
|
||||||
|
- Business (€199/mo): 2000 orders/mo, unlimited products, 10 users, analytics, API
|
||||||
|
- Enterprise (€399+/mo): Unlimited, white-label, custom integrations
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.sqlite import JSON
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from models.database.base import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class TierCode(str, enum.Enum):
|
||||||
|
"""Subscription tier codes."""
|
||||||
|
|
||||||
|
ESSENTIAL = "essential"
|
||||||
|
PROFESSIONAL = "professional"
|
||||||
|
BUSINESS = "business"
|
||||||
|
ENTERPRISE = "enterprise"
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionStatus(str, enum.Enum):
|
||||||
|
"""Subscription status."""
|
||||||
|
|
||||||
|
TRIAL = "trial" # Free trial period
|
||||||
|
ACTIVE = "active" # Paid and active
|
||||||
|
PAST_DUE = "past_due" # Payment failed, grace period
|
||||||
|
CANCELLED = "cancelled" # Cancelled, access until period end
|
||||||
|
EXPIRED = "expired" # No longer active
|
||||||
|
|
||||||
|
|
||||||
|
# Tier limit definitions (hardcoded for now, could be moved to DB)
|
||||||
|
TIER_LIMITS = {
|
||||||
|
TierCode.ESSENTIAL: {
|
||||||
|
"name": "Essential",
|
||||||
|
"price_monthly_cents": 4900, # €49
|
||||||
|
"price_annual_cents": 49000, # €490 (2 months free)
|
||||||
|
"orders_per_month": 100,
|
||||||
|
"products_limit": 200,
|
||||||
|
"team_members": 1,
|
||||||
|
"order_history_months": 6,
|
||||||
|
"features": [
|
||||||
|
"letzshop_sync",
|
||||||
|
"inventory_basic",
|
||||||
|
"invoice_lu",
|
||||||
|
"customer_view",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TierCode.PROFESSIONAL: {
|
||||||
|
"name": "Professional",
|
||||||
|
"price_monthly_cents": 9900, # €99
|
||||||
|
"price_annual_cents": 99000, # €990
|
||||||
|
"orders_per_month": 500,
|
||||||
|
"products_limit": None, # Unlimited
|
||||||
|
"team_members": 3,
|
||||||
|
"order_history_months": 24,
|
||||||
|
"features": [
|
||||||
|
"letzshop_sync",
|
||||||
|
"inventory_locations",
|
||||||
|
"inventory_purchase_orders",
|
||||||
|
"invoice_lu",
|
||||||
|
"invoice_eu_vat",
|
||||||
|
"customer_view",
|
||||||
|
"customer_export",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TierCode.BUSINESS: {
|
||||||
|
"name": "Business",
|
||||||
|
"price_monthly_cents": 19900, # €199
|
||||||
|
"price_annual_cents": 199000, # €1990
|
||||||
|
"orders_per_month": 2000,
|
||||||
|
"products_limit": None, # Unlimited
|
||||||
|
"team_members": 10,
|
||||||
|
"order_history_months": None, # Unlimited
|
||||||
|
"features": [
|
||||||
|
"letzshop_sync",
|
||||||
|
"inventory_locations",
|
||||||
|
"inventory_purchase_orders",
|
||||||
|
"invoice_lu",
|
||||||
|
"invoice_eu_vat",
|
||||||
|
"invoice_bulk",
|
||||||
|
"customer_view",
|
||||||
|
"customer_export",
|
||||||
|
"analytics_dashboard",
|
||||||
|
"accounting_export",
|
||||||
|
"api_access",
|
||||||
|
"automation_rules",
|
||||||
|
"team_roles",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TierCode.ENTERPRISE: {
|
||||||
|
"name": "Enterprise",
|
||||||
|
"price_monthly_cents": 39900, # €399 starting
|
||||||
|
"price_annual_cents": None, # Custom
|
||||||
|
"orders_per_month": None, # Unlimited
|
||||||
|
"products_limit": None, # Unlimited
|
||||||
|
"team_members": None, # Unlimited
|
||||||
|
"order_history_months": None, # Unlimited
|
||||||
|
"features": [
|
||||||
|
"letzshop_sync",
|
||||||
|
"inventory_locations",
|
||||||
|
"inventory_purchase_orders",
|
||||||
|
"invoice_lu",
|
||||||
|
"invoice_eu_vat",
|
||||||
|
"invoice_bulk",
|
||||||
|
"customer_view",
|
||||||
|
"customer_export",
|
||||||
|
"analytics_dashboard",
|
||||||
|
"accounting_export",
|
||||||
|
"api_access",
|
||||||
|
"automation_rules",
|
||||||
|
"team_roles",
|
||||||
|
"white_label",
|
||||||
|
"multi_vendor",
|
||||||
|
"custom_integrations",
|
||||||
|
"sla_guarantee",
|
||||||
|
"dedicated_support",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class VendorSubscription(Base, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Per-vendor subscription tracking.
|
||||||
|
|
||||||
|
Tracks the vendor's subscription tier, billing period,
|
||||||
|
and usage counters for limit enforcement.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "vendor_subscriptions"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
vendor_id = Column(
|
||||||
|
Integer, ForeignKey("vendors.id"), unique=True, nullable=False, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tier
|
||||||
|
tier = Column(
|
||||||
|
String(20), default=TierCode.ESSENTIAL.value, nullable=False, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status = Column(
|
||||||
|
String(20), default=SubscriptionStatus.TRIAL.value, nullable=False, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Billing period
|
||||||
|
period_start = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
period_end = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
is_annual = Column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
# Trial info
|
||||||
|
trial_ends_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
# Usage counters (reset each billing period)
|
||||||
|
orders_this_period = Column(Integer, default=0, nullable=False)
|
||||||
|
orders_limit_reached_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
# Overrides (for custom enterprise deals)
|
||||||
|
custom_orders_limit = Column(Integer, nullable=True) # Override tier limit
|
||||||
|
custom_products_limit = Column(Integer, nullable=True)
|
||||||
|
custom_team_limit = Column(Integer, nullable=True)
|
||||||
|
|
||||||
|
# Payment info (for future Stripe integration)
|
||||||
|
stripe_customer_id = Column(String(100), nullable=True, index=True)
|
||||||
|
stripe_subscription_id = Column(String(100), nullable=True, index=True)
|
||||||
|
|
||||||
|
# Cancellation
|
||||||
|
cancelled_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
cancellation_reason = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
vendor = relationship("Vendor", back_populates="subscription")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_subscription_vendor_status", "vendor_id", "status"),
|
||||||
|
Index("idx_subscription_period", "period_start", "period_end"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<VendorSubscription(vendor_id={self.vendor_id}, tier='{self.tier}', status='{self.status}')>"
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Tier Limit Properties
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tier_limits(self) -> dict:
|
||||||
|
"""Get the limit definitions for current tier."""
|
||||||
|
return TIER_LIMITS.get(TierCode(self.tier), TIER_LIMITS[TierCode.ESSENTIAL])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def orders_limit(self) -> int | None:
|
||||||
|
"""Get effective orders limit (custom or tier default)."""
|
||||||
|
if self.custom_orders_limit is not None:
|
||||||
|
return self.custom_orders_limit
|
||||||
|
return self.tier_limits.get("orders_per_month")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def products_limit(self) -> int | None:
|
||||||
|
"""Get effective products limit (custom or tier default)."""
|
||||||
|
if self.custom_products_limit is not None:
|
||||||
|
return self.custom_products_limit
|
||||||
|
return self.tier_limits.get("products_limit")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def team_members_limit(self) -> int | None:
|
||||||
|
"""Get effective team members limit (custom or tier default)."""
|
||||||
|
if self.custom_team_limit is not None:
|
||||||
|
return self.custom_team_limit
|
||||||
|
return self.tier_limits.get("team_members")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def features(self) -> list[str]:
|
||||||
|
"""Get list of enabled features for current tier."""
|
||||||
|
return self.tier_limits.get("features", [])
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Status Checks
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
"""Check if subscription allows access."""
|
||||||
|
return self.status in [
|
||||||
|
SubscriptionStatus.TRIAL.value,
|
||||||
|
SubscriptionStatus.ACTIVE.value,
|
||||||
|
SubscriptionStatus.PAST_DUE.value, # Grace period
|
||||||
|
SubscriptionStatus.CANCELLED.value, # Until period end
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_trial(self) -> bool:
|
||||||
|
"""Check if currently in trial."""
|
||||||
|
return self.status == SubscriptionStatus.TRIAL.value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def trial_days_remaining(self) -> int | None:
|
||||||
|
"""Get remaining trial days."""
|
||||||
|
if not self.is_trial or not self.trial_ends_at:
|
||||||
|
return None
|
||||||
|
remaining = (self.trial_ends_at - datetime.now(UTC)).days
|
||||||
|
return max(0, remaining)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Limit Checks
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def can_create_order(self) -> tuple[bool, str | None]:
|
||||||
|
"""
|
||||||
|
Check if vendor can create/import another order.
|
||||||
|
|
||||||
|
Returns: (can_create, error_message)
|
||||||
|
"""
|
||||||
|
if not self.is_active:
|
||||||
|
return False, "Subscription is not active"
|
||||||
|
|
||||||
|
limit = self.orders_limit
|
||||||
|
if limit is None: # Unlimited
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
if self.orders_this_period >= limit:
|
||||||
|
return False, f"Monthly order limit reached ({limit} orders). Upgrade to continue."
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
def can_add_product(self, current_count: int) -> tuple[bool, str | None]:
|
||||||
|
"""
|
||||||
|
Check if vendor can add another product.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_count: Current number of products
|
||||||
|
|
||||||
|
Returns: (can_add, error_message)
|
||||||
|
"""
|
||||||
|
if not self.is_active:
|
||||||
|
return False, "Subscription is not active"
|
||||||
|
|
||||||
|
limit = self.products_limit
|
||||||
|
if limit is None: # Unlimited
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
if current_count >= limit:
|
||||||
|
return False, f"Product limit reached ({limit} products). Upgrade to add more."
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
def can_add_team_member(self, current_count: int) -> tuple[bool, str | None]:
|
||||||
|
"""
|
||||||
|
Check if vendor can add another team member.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_count: Current number of team members
|
||||||
|
|
||||||
|
Returns: (can_add, error_message)
|
||||||
|
"""
|
||||||
|
if not self.is_active:
|
||||||
|
return False, "Subscription is not active"
|
||||||
|
|
||||||
|
limit = self.team_members_limit
|
||||||
|
if limit is None: # Unlimited
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
if current_count >= limit:
|
||||||
|
return False, f"Team member limit reached ({limit} members). Upgrade to add more."
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
def has_feature(self, feature: str) -> bool:
|
||||||
|
"""Check if a feature is enabled for current tier."""
|
||||||
|
return feature in self.features
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Usage Tracking
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def increment_order_count(self) -> None:
|
||||||
|
"""Increment the order counter for this period."""
|
||||||
|
self.orders_this_period += 1
|
||||||
|
|
||||||
|
# Track when limit was first reached
|
||||||
|
limit = self.orders_limit
|
||||||
|
if limit and self.orders_this_period >= limit and not self.orders_limit_reached_at:
|
||||||
|
self.orders_limit_reached_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
def reset_period_counters(self) -> None:
|
||||||
|
"""Reset counters for new billing period."""
|
||||||
|
self.orders_this_period = 0
|
||||||
|
self.orders_limit_reached_at = None
|
||||||
@@ -143,6 +143,29 @@ class Vendor(Base, TimestampMixin):
|
|||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Invoice settings (one-to-one)
|
||||||
|
invoice_settings = relationship(
|
||||||
|
"VendorInvoiceSettings",
|
||||||
|
back_populates="vendor",
|
||||||
|
uselist=False,
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Invoices (one-to-many)
|
||||||
|
invoices = relationship(
|
||||||
|
"Invoice",
|
||||||
|
back_populates="vendor",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Subscription (one-to-one)
|
||||||
|
subscription = relationship(
|
||||||
|
"VendorSubscription",
|
||||||
|
back_populates="vendor",
|
||||||
|
uselist=False,
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
domains = relationship(
|
domains = relationship(
|
||||||
"VendorDomain",
|
"VendorDomain",
|
||||||
back_populates="vendor",
|
back_populates="vendor",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from . import (
|
|||||||
auth,
|
auth,
|
||||||
base,
|
base,
|
||||||
inventory,
|
inventory,
|
||||||
|
invoice,
|
||||||
marketplace_import_job,
|
marketplace_import_job,
|
||||||
marketplace_product,
|
marketplace_product,
|
||||||
message,
|
message,
|
||||||
@@ -19,6 +20,7 @@ from .base import * # Base Pydantic models
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"base",
|
"base",
|
||||||
"auth",
|
"auth",
|
||||||
|
"invoice",
|
||||||
"marketplace_product",
|
"marketplace_product",
|
||||||
"message",
|
"message",
|
||||||
"inventory",
|
"inventory",
|
||||||
|
|||||||
283
models/schema/invoice.py
Normal file
283
models/schema/invoice.py
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
# models/schema/invoice.py
|
||||||
|
"""
|
||||||
|
Pydantic schemas for invoice operations.
|
||||||
|
|
||||||
|
Supports invoice settings management and invoice generation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Invoice Settings Schemas
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class VendorInvoiceSettingsCreate(BaseModel):
|
||||||
|
"""Schema for creating vendor invoice settings."""
|
||||||
|
|
||||||
|
company_name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
company_address: str | None = Field(None, max_length=255)
|
||||||
|
company_city: str | None = Field(None, max_length=100)
|
||||||
|
company_postal_code: str | None = Field(None, max_length=20)
|
||||||
|
company_country: str = Field(default="LU", min_length=2, max_length=2)
|
||||||
|
|
||||||
|
vat_number: str | None = Field(None, max_length=50)
|
||||||
|
is_vat_registered: bool = True
|
||||||
|
|
||||||
|
is_oss_registered: bool = False
|
||||||
|
oss_registration_country: str | None = Field(None, min_length=2, max_length=2)
|
||||||
|
|
||||||
|
invoice_prefix: str = Field(default="INV", max_length=20)
|
||||||
|
invoice_number_padding: int = Field(default=5, ge=1, le=10)
|
||||||
|
|
||||||
|
payment_terms: str | None = None
|
||||||
|
bank_name: str | None = Field(None, max_length=255)
|
||||||
|
bank_iban: str | None = Field(None, max_length=50)
|
||||||
|
bank_bic: str | None = Field(None, max_length=20)
|
||||||
|
|
||||||
|
footer_text: str | None = None
|
||||||
|
default_vat_rate: Decimal = Field(default=Decimal("17.00"), ge=0, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class VendorInvoiceSettingsUpdate(BaseModel):
|
||||||
|
"""Schema for updating vendor invoice settings."""
|
||||||
|
|
||||||
|
company_name: str | None = Field(None, min_length=1, max_length=255)
|
||||||
|
company_address: str | None = Field(None, max_length=255)
|
||||||
|
company_city: str | None = Field(None, max_length=100)
|
||||||
|
company_postal_code: str | None = Field(None, max_length=20)
|
||||||
|
company_country: str | None = Field(None, min_length=2, max_length=2)
|
||||||
|
|
||||||
|
vat_number: str | None = None
|
||||||
|
is_vat_registered: bool | None = None
|
||||||
|
|
||||||
|
is_oss_registered: bool | None = None
|
||||||
|
oss_registration_country: str | None = None
|
||||||
|
|
||||||
|
invoice_prefix: str | None = Field(None, max_length=20)
|
||||||
|
invoice_number_padding: int | None = Field(None, ge=1, le=10)
|
||||||
|
|
||||||
|
payment_terms: str | None = None
|
||||||
|
bank_name: str | None = Field(None, max_length=255)
|
||||||
|
bank_iban: str | None = Field(None, max_length=50)
|
||||||
|
bank_bic: str | None = Field(None, max_length=20)
|
||||||
|
|
||||||
|
footer_text: str | None = None
|
||||||
|
default_vat_rate: Decimal | None = Field(None, ge=0, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class VendorInvoiceSettingsResponse(BaseModel):
|
||||||
|
"""Schema for vendor invoice settings response."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
vendor_id: int
|
||||||
|
|
||||||
|
company_name: str
|
||||||
|
company_address: str | None
|
||||||
|
company_city: str | None
|
||||||
|
company_postal_code: str | None
|
||||||
|
company_country: str
|
||||||
|
|
||||||
|
vat_number: str | None
|
||||||
|
is_vat_registered: bool
|
||||||
|
|
||||||
|
is_oss_registered: bool
|
||||||
|
oss_registration_country: str | None
|
||||||
|
|
||||||
|
invoice_prefix: str
|
||||||
|
invoice_next_number: int
|
||||||
|
invoice_number_padding: int
|
||||||
|
|
||||||
|
payment_terms: str | None
|
||||||
|
bank_name: str | None
|
||||||
|
bank_iban: str | None
|
||||||
|
bank_bic: str | None
|
||||||
|
|
||||||
|
footer_text: str | None
|
||||||
|
default_vat_rate: Decimal
|
||||||
|
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Invoice Line Item Schemas
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceLineItem(BaseModel):
|
||||||
|
"""Schema for invoice line item."""
|
||||||
|
|
||||||
|
description: str
|
||||||
|
quantity: int = Field(..., ge=1)
|
||||||
|
unit_price_cents: int
|
||||||
|
total_cents: int
|
||||||
|
sku: str | None = None
|
||||||
|
ean: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceLineItemResponse(BaseModel):
|
||||||
|
"""Schema for invoice line item in response."""
|
||||||
|
|
||||||
|
description: str
|
||||||
|
quantity: int
|
||||||
|
unit_price_cents: int
|
||||||
|
total_cents: int
|
||||||
|
sku: str | None = None
|
||||||
|
ean: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unit_price(self) -> float:
|
||||||
|
return self.unit_price_cents / 100
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> float:
|
||||||
|
return self.total_cents / 100
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Invoice Address Schemas
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceSellerDetails(BaseModel):
|
||||||
|
"""Seller details for invoice."""
|
||||||
|
|
||||||
|
company_name: str
|
||||||
|
address: str | None = None
|
||||||
|
city: str | None = None
|
||||||
|
postal_code: str | None = None
|
||||||
|
country: str
|
||||||
|
vat_number: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceBuyerDetails(BaseModel):
|
||||||
|
"""Buyer details for invoice."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
email: str | None = None
|
||||||
|
address: str | None = None
|
||||||
|
city: str | None = None
|
||||||
|
postal_code: str | None = None
|
||||||
|
country: str
|
||||||
|
vat_number: str | None = None # For B2B
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Invoice Schemas
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceCreate(BaseModel):
|
||||||
|
"""Schema for creating an invoice from an order."""
|
||||||
|
|
||||||
|
order_id: int
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceManualCreate(BaseModel):
|
||||||
|
"""Schema for creating a manual invoice (without order)."""
|
||||||
|
|
||||||
|
buyer_details: InvoiceBuyerDetails
|
||||||
|
line_items: list[InvoiceLineItem]
|
||||||
|
notes: str | None = None
|
||||||
|
payment_terms: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceResponse(BaseModel):
|
||||||
|
"""Schema for invoice response."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
vendor_id: int
|
||||||
|
order_id: int | None
|
||||||
|
|
||||||
|
invoice_number: str
|
||||||
|
invoice_date: datetime
|
||||||
|
status: str
|
||||||
|
|
||||||
|
seller_details: dict
|
||||||
|
buyer_details: dict
|
||||||
|
line_items: list[dict]
|
||||||
|
|
||||||
|
vat_regime: str
|
||||||
|
destination_country: str | None
|
||||||
|
vat_rate: Decimal
|
||||||
|
vat_rate_label: str | None
|
||||||
|
|
||||||
|
currency: str
|
||||||
|
subtotal_cents: int
|
||||||
|
vat_amount_cents: int
|
||||||
|
total_cents: int
|
||||||
|
|
||||||
|
payment_terms: str | None
|
||||||
|
bank_details: dict | None
|
||||||
|
footer_text: str | None
|
||||||
|
|
||||||
|
pdf_generated_at: datetime | None
|
||||||
|
pdf_path: str | None
|
||||||
|
|
||||||
|
notes: str | None
|
||||||
|
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtotal(self) -> float:
|
||||||
|
return self.subtotal_cents / 100
|
||||||
|
|
||||||
|
@property
|
||||||
|
def vat_amount(self) -> float:
|
||||||
|
return self.vat_amount_cents / 100
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> float:
|
||||||
|
return self.total_cents / 100
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceListResponse(BaseModel):
|
||||||
|
"""Schema for invoice list response (summary)."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
invoice_number: str
|
||||||
|
invoice_date: datetime
|
||||||
|
status: str
|
||||||
|
currency: str
|
||||||
|
total_cents: int
|
||||||
|
order_id: int | None
|
||||||
|
|
||||||
|
# Buyer name for display
|
||||||
|
buyer_name: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> float:
|
||||||
|
return self.total_cents / 100
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceStatusUpdate(BaseModel):
|
||||||
|
"""Schema for updating invoice status."""
|
||||||
|
|
||||||
|
status: str = Field(..., pattern="^(draft|issued|paid|cancelled)$")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Paginated Response
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceListPaginatedResponse(BaseModel):
|
||||||
|
"""Paginated invoice list response."""
|
||||||
|
|
||||||
|
items: list[InvoiceListResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
per_page: int
|
||||||
|
pages: int
|
||||||
193
models/schema/subscription.py
Normal file
193
models/schema/subscription.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# models/schema/subscription.py
|
||||||
|
"""
|
||||||
|
Pydantic schemas for subscription operations.
|
||||||
|
|
||||||
|
Supports subscription management and tier limit checks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tier Information Schemas
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TierFeatures(BaseModel):
|
||||||
|
"""Features included in a tier."""
|
||||||
|
|
||||||
|
letzshop_sync: bool = True
|
||||||
|
inventory_basic: bool = True
|
||||||
|
inventory_locations: bool = False
|
||||||
|
inventory_purchase_orders: bool = False
|
||||||
|
invoice_lu: bool = True
|
||||||
|
invoice_eu_vat: bool = False
|
||||||
|
invoice_bulk: bool = False
|
||||||
|
customer_view: bool = True
|
||||||
|
customer_export: bool = False
|
||||||
|
analytics_dashboard: bool = False
|
||||||
|
accounting_export: bool = False
|
||||||
|
api_access: bool = False
|
||||||
|
automation_rules: bool = False
|
||||||
|
team_roles: bool = False
|
||||||
|
white_label: bool = False
|
||||||
|
multi_vendor: bool = False
|
||||||
|
custom_integrations: bool = False
|
||||||
|
sla_guarantee: bool = False
|
||||||
|
dedicated_support: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class TierLimits(BaseModel):
|
||||||
|
"""Limits for a subscription tier."""
|
||||||
|
|
||||||
|
orders_per_month: int | None = Field(None, description="None = unlimited")
|
||||||
|
products_limit: int | None = Field(None, description="None = unlimited")
|
||||||
|
team_members: int | None = Field(None, description="None = unlimited")
|
||||||
|
order_history_months: int | None = Field(None, description="None = unlimited")
|
||||||
|
|
||||||
|
|
||||||
|
class TierInfo(BaseModel):
|
||||||
|
"""Full tier information."""
|
||||||
|
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
price_monthly_cents: int
|
||||||
|
price_annual_cents: int | None
|
||||||
|
limits: TierLimits
|
||||||
|
features: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Subscription Schemas
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionCreate(BaseModel):
|
||||||
|
"""Schema for creating a subscription (admin/internal use)."""
|
||||||
|
|
||||||
|
tier: str = Field(default="essential", pattern="^(essential|professional|business|enterprise)$")
|
||||||
|
is_annual: bool = False
|
||||||
|
trial_days: int = Field(default=14, ge=0, le=30)
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionUpdate(BaseModel):
|
||||||
|
"""Schema for updating a subscription."""
|
||||||
|
|
||||||
|
tier: str | None = Field(None, pattern="^(essential|professional|business|enterprise)$")
|
||||||
|
status: str | None = Field(None, pattern="^(trial|active|past_due|cancelled|expired)$")
|
||||||
|
is_annual: bool | None = None
|
||||||
|
custom_orders_limit: int | None = None
|
||||||
|
custom_products_limit: int | None = None
|
||||||
|
custom_team_limit: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionResponse(BaseModel):
|
||||||
|
"""Schema for subscription response."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
vendor_id: int
|
||||||
|
tier: str
|
||||||
|
status: str
|
||||||
|
|
||||||
|
period_start: datetime
|
||||||
|
period_end: datetime
|
||||||
|
is_annual: bool
|
||||||
|
|
||||||
|
trial_ends_at: datetime | None
|
||||||
|
orders_this_period: int
|
||||||
|
orders_limit_reached_at: datetime | None
|
||||||
|
|
||||||
|
# Effective limits (with custom overrides applied)
|
||||||
|
orders_limit: int | None
|
||||||
|
products_limit: int | None
|
||||||
|
team_members_limit: int | None
|
||||||
|
|
||||||
|
# Computed properties
|
||||||
|
is_active: bool
|
||||||
|
is_trial: bool
|
||||||
|
trial_days_remaining: int | None
|
||||||
|
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionUsage(BaseModel):
|
||||||
|
"""Current subscription usage statistics."""
|
||||||
|
|
||||||
|
orders_used: int
|
||||||
|
orders_limit: int | None
|
||||||
|
orders_remaining: int | None
|
||||||
|
orders_percent_used: float | None
|
||||||
|
|
||||||
|
products_used: int
|
||||||
|
products_limit: int | None
|
||||||
|
products_remaining: int | None
|
||||||
|
products_percent_used: float | None
|
||||||
|
|
||||||
|
team_members_used: int
|
||||||
|
team_members_limit: int | None
|
||||||
|
team_members_remaining: int | None
|
||||||
|
team_members_percent_used: float | None
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionStatusResponse(BaseModel):
|
||||||
|
"""Subscription status with usage and limits."""
|
||||||
|
|
||||||
|
subscription: SubscriptionResponse
|
||||||
|
usage: SubscriptionUsage
|
||||||
|
tier_info: TierInfo
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Limit Check Schemas
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class LimitCheckResult(BaseModel):
|
||||||
|
"""Result of a limit check."""
|
||||||
|
|
||||||
|
allowed: bool
|
||||||
|
limit: int | None
|
||||||
|
current: int
|
||||||
|
remaining: int | None
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CanCreateOrderResponse(BaseModel):
|
||||||
|
"""Response for order creation check."""
|
||||||
|
|
||||||
|
allowed: bool
|
||||||
|
orders_this_period: int
|
||||||
|
orders_limit: int | None
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CanAddProductResponse(BaseModel):
|
||||||
|
"""Response for product addition check."""
|
||||||
|
|
||||||
|
allowed: bool
|
||||||
|
products_count: int
|
||||||
|
products_limit: int | None
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CanAddTeamMemberResponse(BaseModel):
|
||||||
|
"""Response for team member addition check."""
|
||||||
|
|
||||||
|
allowed: bool
|
||||||
|
team_count: int
|
||||||
|
team_limit: int | None
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureCheckResponse(BaseModel):
|
||||||
|
"""Response for feature check."""
|
||||||
|
|
||||||
|
feature: str
|
||||||
|
enabled: bool
|
||||||
|
tier_required: str | None = None
|
||||||
|
message: str | None = None
|
||||||
@@ -22,5 +22,8 @@ python-multipart==0.0.20
|
|||||||
pandas==2.2.3
|
pandas==2.2.3
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
|
|
||||||
|
# PDF generation
|
||||||
|
weasyprint==62.3
|
||||||
|
|
||||||
# Environment and configuration
|
# Environment and configuration
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
Reference in New Issue
Block a user