From 6232bb47f6569c8568f53df05d288df946a8037d Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Wed, 24 Dec 2025 18:15:27 +0100 Subject: [PATCH] feat: add invoicing system and subscription tier enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Makefile | 4 +- .../h6c7d8e9f0a1_add_invoice_tables.py | 198 ++++++ .../i7d8e9f0a1b2_add_vendor_subscriptions.py | 148 ++++ app/api/v1/vendor/__init__.py | 2 + app/api/v1/vendor/invoices.py | 369 ++++++++++ app/services/invoice_pdf_service.py | 164 +++++ app/services/invoice_service.py | 666 ++++++++++++++++++ app/services/letzshop/order_service.py | 19 + app/services/order_service.py | 30 + app/services/subscription_service.py | 512 ++++++++++++++ app/templates/invoices/invoice.html | 470 ++++++++++++ app/templates/platform/homepage-modern.html | 642 +++++++++++++---- docs/marketing/pricing.md | 168 +++-- .../strategy/back-office-positioning.md | 93 ++- models/database/__init__.py | 22 + models/database/invoice.py | 215 ++++++ models/database/order.py | 3 + models/database/subscription.py | 354 ++++++++++ models/database/vendor.py | 23 + models/schema/__init__.py | 2 + models/schema/invoice.py | 283 ++++++++ models/schema/subscription.py | 193 +++++ requirements.txt | 3 + 23 files changed, 4342 insertions(+), 241 deletions(-) create mode 100644 alembic/versions/h6c7d8e9f0a1_add_invoice_tables.py create mode 100644 alembic/versions/i7d8e9f0a1b2_add_vendor_subscriptions.py create mode 100644 app/api/v1/vendor/invoices.py create mode 100644 app/services/invoice_pdf_service.py create mode 100644 app/services/invoice_service.py create mode 100644 app/services/subscription_service.py create mode 100644 app/templates/invoices/invoice.html create mode 100644 models/database/invoice.py create mode 100644 models/database/subscription.py create mode 100644 models/schema/invoice.py create mode 100644 models/schema/subscription.py diff --git a/Makefile b/Makefile index 66df5892..cdf38d87 100644 --- a/Makefile +++ b/Makefile @@ -44,7 +44,7 @@ setup: install-all migrate-up init-prod # ============================================================================= dev: - $(PYTHON) -m uvicorn main:app --reload --host 0.0.0.0 --port 8000 + $(PYTHON) -m uvicorn main:app --reload --host 0.0.0.0 --port 9999 # ============================================================================= # DATABASE MIGRATIONS @@ -274,7 +274,7 @@ qa: format lint arch-check test-coverage docs-check docs-serve: @echo "Starting documentation server..." - $(PYTHON) -m mkdocs serve --dev-addr=0.0.0.0:8001 + $(PYTHON) -m mkdocs serve --dev-addr=0.0.0.0:9991 docs-build: @echo "Building documentation..." diff --git a/alembic/versions/h6c7d8e9f0a1_add_invoice_tables.py b/alembic/versions/h6c7d8e9f0a1_add_invoice_tables.py new file mode 100644 index 00000000..b88cabc2 --- /dev/null +++ b/alembic/versions/h6c7d8e9f0a1_add_invoice_tables.py @@ -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") diff --git a/alembic/versions/i7d8e9f0a1b2_add_vendor_subscriptions.py b/alembic/versions/i7d8e9f0a1b2_add_vendor_subscriptions.py new file mode 100644 index 00000000..cff6e188 --- /dev/null +++ b/alembic/versions/i7d8e9f0a1b2_add_vendor_subscriptions.py @@ -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") diff --git a/app/api/v1/vendor/__init__.py b/app/api/v1/vendor/__init__.py index cde7dd00..142c84cc 100644 --- a/app/api/v1/vendor/__init__.py +++ b/app/api/v1/vendor/__init__.py @@ -21,6 +21,7 @@ from . import ( dashboard, info, inventory, + invoices, letzshop, marketplace, media, @@ -59,6 +60,7 @@ router.include_router(settings.router, tags=["vendor-settings"]) router.include_router(products.router, tags=["vendor-products"]) router.include_router(orders.router, tags=["vendor-orders"]) router.include_router(order_item_exceptions.router, tags=["vendor-order-exceptions"]) +router.include_router(invoices.router, tags=["vendor-invoices"]) router.include_router(customers.router, tags=["vendor-customers"]) router.include_router(team.router, tags=["vendor-team"]) router.include_router(inventory.router, tags=["vendor-inventory"]) diff --git a/app/api/v1/vendor/invoices.py b/app/api/v1/vendor/invoices.py new file mode 100644 index 00000000..c17621f7 --- /dev/null +++ b/app/api/v1/vendor/invoices.py @@ -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)}") diff --git a/app/services/invoice_pdf_service.py b/app/services/invoice_pdf_service.py new file mode 100644 index 00000000..1ce4ba01 --- /dev/null +++ b/app/services/invoice_pdf_service.py @@ -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() diff --git a/app/services/invoice_service.py b/app/services/invoice_service.py new file mode 100644 index 00000000..e8732f05 --- /dev/null +++ b/app/services/invoice_service.py @@ -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() diff --git a/app/services/letzshop/order_service.py b/app/services/letzshop/order_service.py index 000b9944..116d59fb 100644 --- a/app/services/letzshop/order_service.py +++ b/app/services/letzshop/order_service.py @@ -15,6 +15,7 @@ from sqlalchemy import String, and_, func, or_ from sqlalchemy.orm import Session from app.services.order_service import order_service as unified_order_service +from app.services.subscription_service import subscription_service from models.database.letzshop import ( LetzshopFulfillmentQueue, LetzshopHistoricalImportJob, @@ -792,6 +793,7 @@ class LetzshopOrderService: "updated": 0, "skipped": 0, "errors": 0, + "limit_exceeded": 0, "products_matched": 0, "products_not_found": 0, "eans_processed": set(), @@ -800,6 +802,10 @@ class LetzshopOrderService: "error_messages": [], } + # Get subscription usage upfront for batch efficiency + usage = subscription_service.get_usage(self.db, vendor_id) + orders_remaining = usage.orders_remaining # None = unlimited + for i, shipment in enumerate(shipments): shipment_id = shipment.get("id") if not shipment_id: @@ -844,11 +850,24 @@ class LetzshopOrderService: else: stats["skipped"] += 1 else: + # Check tier limit before creating order + if orders_remaining is not None and orders_remaining <= 0: + stats["limit_exceeded"] += 1 + stats["error_messages"].append( + f"Shipment {shipment_id}: Order limit reached" + ) + continue + # Create new order using unified service try: self.create_order(vendor_id, shipment) self.db.commit() # noqa: SVC-006 - background task needs incremental commits stats["imported"] += 1 + + # Decrement remaining count for batch efficiency + if orders_remaining is not None: + orders_remaining -= 1 + except Exception as e: self.db.rollback() # Rollback failed order stats["errors"] += 1 diff --git a/app/services/order_service.py b/app/services/order_service.py index bfef78d8..be75570b 100644 --- a/app/services/order_service.py +++ b/app/services/order_service.py @@ -31,6 +31,10 @@ from app.exceptions import ( ValidationException, ) from app.services.order_item_exception_service import order_item_exception_service +from app.services.subscription_service import ( + subscription_service, + TierLimitExceededException, +) from app.utils.money import Money, cents_to_euros, euros_to_cents from models.database.customer import Customer from models.database.marketplace_product import MarketplaceProduct @@ -271,7 +275,11 @@ class OrderService: Raises: ValidationException: If order data is invalid InsufficientInventoryException: If not enough inventory + TierLimitExceededException: If vendor has reached order limit """ + # Check tier limit before creating order + subscription_service.check_order_limit(db, vendor_id) + try: # Get or create customer if order_data.customer_id: @@ -428,6 +436,9 @@ class OrderService: db.flush() db.refresh(order) + # Increment order count for subscription tracking + subscription_service.increment_order_count(db, vendor_id) + logger.info( f"Order {order.order_number} created for vendor {vendor_id}, " f"total: EUR {cents_to_euros(total_amount_cents):.2f}" @@ -439,6 +450,7 @@ class OrderService: ValidationException, InsufficientInventoryException, CustomerNotFoundException, + TierLimitExceededException, ): raise except Exception as e: @@ -450,6 +462,7 @@ class OrderService: db: Session, vendor_id: int, shipment_data: dict[str, Any], + skip_limit_check: bool = False, ) -> Order: """ Create an order from Letzshop shipment data. @@ -462,13 +475,27 @@ class OrderService: db: Database session vendor_id: Vendor ID shipment_data: Raw shipment data from Letzshop API + skip_limit_check: If True, skip tier limit check (for batch imports + that check limit upfront) Returns: Created Order object Raises: ValidationException: If product not found by GTIN + TierLimitExceededException: If vendor has reached order limit """ + # Check tier limit before creating order (unless skipped for batch ops) + if not skip_limit_check: + can_create, message = subscription_service.can_create_order(db, vendor_id) + if not can_create: + raise TierLimitExceededException( + message=message or "Order limit exceeded", + limit_type="orders", + current=0, # Will be filled by caller if needed + limit=0, + ) + order_data = shipment_data.get("order", {}) # Generate order number using Letzshop order number @@ -777,6 +804,9 @@ class OrderService: f"order {order.order_number}" ) + # Increment order count for subscription tracking + subscription_service.increment_order_count(db, vendor_id) + logger.info( f"Letzshop order {order.order_number} created for vendor {vendor_id}, " f"status: {status}, items: {len(inventory_units)}" diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py new file mode 100644 index 00000000..11e6b62b --- /dev/null +++ b/app/services/subscription_service.py @@ -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() diff --git a/app/templates/invoices/invoice.html b/app/templates/invoices/invoice.html new file mode 100644 index 00000000..0ed2bd50 --- /dev/null +++ b/app/templates/invoices/invoice.html @@ -0,0 +1,470 @@ + + + + + + Invoice {{ invoice.invoice_number }} + + + +
+ +
+
+
{{ seller.company_name }}
+
+ {% if seller.address %}{{ seller.address }}
{% endif %} + {% if seller.postal_code or seller.city %} + {{ seller.postal_code }} {{ seller.city }}
+ {% endif %} + {{ seller.country }} + {% if seller.vat_number %} +
VAT: {{ seller.vat_number }} + {% endif %} +
+
+
+

INVOICE

+
+ {{ invoice.invoice_number }}
+ Date: {{ invoice.invoice_date.strftime('%d/%m/%Y') }}
+ {{ invoice.status }} +
+
+
+ + +
+
+
Bill To
+
+
{{ buyer.name }}
+
+ {% if buyer.get('company') %}{{ buyer.company }}
{% endif %} + {% if buyer.address %}{{ buyer.address }}
{% endif %} + {% if buyer.postal_code or buyer.city %} + {{ buyer.postal_code }} {{ buyer.city }}
+ {% endif %} + {{ buyer.country }} + {% if buyer.email %}
{{ buyer.email }}{% endif %} +
+ {% if buyer.vat_number %} +
VAT: {{ buyer.vat_number }}
+ {% endif %} +
+
+ {% if invoice.order_id %} +
+
Order Reference
+
+
+ Order #{{ invoice.order_id }}
+ Currency: {{ invoice.currency }} +
+
+
+ {% endif %} +
+ + + {% if invoice.vat_regime == 'reverse_charge' %} +
+ Reverse Charge: VAT to be accounted for by the recipient pursuant to Article 196 of Council Directive 2006/112/EC. +
+ {% elif invoice.vat_regime == 'oss' %} +
+ OSS Invoice: VAT charged at {{ invoice.vat_rate }}% ({{ invoice.destination_country }} rate) under One-Stop-Shop scheme. +
+ {% elif invoice.vat_regime == 'exempt' %} +
+ VAT Exempt: Export outside EU - VAT not applicable. +
+ {% endif %} + + + + + + + + + + + + + {% for item in line_items %} + + + + + + + {% endfor %} + +
DescriptionQtyUnit PriceTotal
+ {{ item.description }} + {% if item.sku or item.ean %} + + {% if item.sku %}SKU: {{ item.sku }}{% endif %} + {% if item.sku and item.ean %} | {% endif %} + {% if item.ean %}EAN: {{ item.ean }}{% endif %} + + {% endif %} + {{ item.quantity }}{{ "%.2f"|format(item.unit_price_cents / 100) }} {{ invoice.currency }}{{ "%.2f"|format(item.total_cents / 100) }} {{ invoice.currency }}
+ + +
+
+
+ Subtotal + {{ "%.2f"|format(invoice.subtotal_cents / 100) }} {{ invoice.currency }} +
+
+ + VAT ({{ invoice.vat_rate }}%) + {% if invoice.vat_rate_label %}
{{ invoice.vat_rate_label }}{% endif %} +
+ {{ "%.2f"|format(invoice.vat_amount_cents / 100) }} {{ invoice.currency }} +
+
+ Total + {{ "%.2f"|format(invoice.total_cents / 100) }} {{ invoice.currency }} +
+
+
+ + + {% if bank_details or payment_terms %} +
+
Payment Information
+
+ {% if payment_terms %} +

{{ payment_terms }}

+ {% endif %} + {% if bank_details %} + {% if bank_details.bank_name %} + Bank: {{ bank_details.bank_name }}
+ {% endif %} + {% if bank_details.iban %} + IBAN: {{ bank_details.iban }}
+ {% endif %} + {% if bank_details.bic %} + BIC: {{ bank_details.bic }} + {% endif %} + {% endif %} +
+
+ {% endif %} + + + +
+ + diff --git a/app/templates/platform/homepage-modern.html b/app/templates/platform/homepage-modern.html index 564abe86..f9804930 100644 --- a/app/templates/platform/homepage-modern.html +++ b/app/templates/platform/homepage-modern.html @@ -1,112 +1,127 @@ {# app/templates/platform/homepage-modern.html #} -{# Modern/trendy platform homepage template with animations #} +{# Wizamart OMS - Luxembourg-focused homepage inspired by Veeqo #} {% extends "platform/base.html" %} {% block title %} - {% if page %}{{ page.title }}{% else %}Home{% endif %} - Marketplace + Wizamart - The Back-Office for Letzshop Sellers {% endblock %} {% block extra_head %} {% endblock %} {% block content %} - + -
- {# Decorative elements #} -
-
-
+
+ {# Background pattern #} +
+
{# Left column - Content #}
- {% if page %} -

- {{ page.title }} -

-
- {{ page.content | safe }} -
- {% else %} -
- โœจ The Future of E-Commerce -
-

- Build Your
- - Dream Store - -

-

- Launch a stunning online marketplace in minutes. No coding required. Scale effortlessly. -

- {% endif %} +
+ ๐Ÿ‡ฑ๐Ÿ‡บ Built for Luxembourg E-Commerce +
-
+

+ The Back-Office
+ + Letzshop Doesn't Give You + +

+ +

+ Sync orders, manage inventory, generate invoices with correct VAT, and own your customer data. All in one place. +

+ + - {# Stats #} -
-
-
10K+
-
Active Vendors
-
-
-
50M+
-
Products Sold
-
-
-
99.9%
-
Uptime
-
-
+

+ No credit card required. Setup in 5 minutes. Cancel anytime. +

- {# Right column - Visual element #} -
- + -
+
+
+
+ Official Integration +
+ ๐Ÿ›’ + Letzshop.lu +
+ Connect in 2 minutes +
+
+
+ + + + +
-
- โœจ Features -
-

- Everything You Need +

+ Sound Familiar?

- Powerful features to help you succeed in the digital marketplace + These are the daily frustrations of Letzshop sellers +

+
+ +
+
+
๐Ÿ“‹
+

Manual Order Entry

+

Copy-pasting orders from Letzshop to spreadsheets. Every. Single. Day.

+
+ +
+
๐Ÿ“ฆ
+

Inventory Chaos

+

Stock in Letzshop doesn't match reality. Overselling happens.

+
+ +
+
๐Ÿงพ
+

Wrong VAT Invoices

+

EU customers need correct VAT. Your accountant keeps complaining.

+
+ +
+
๐Ÿ‘ฅ
+

Lost Customers

+

Letzshop owns your customer data. You can't retarget or build loyalty.

+
+
+
+
+ + + + +
+
+
+
+ How It Works +
+

+ From Chaos to Control in 4 Steps +

+
+ +
+ {# Step 1 #} +
+
+
1
+

Connect Letzshop

+

Enter your Letzshop API credentials. Done in 2 minutes, no technical skills needed.

+
+ +
+ + {# Step 2 #} +
+
+
2
+

Orders Flow In

+

Orders sync automatically. Confirm and add tracking directly from Wizamart.

+
+ +
+ + {# Step 3 #} +
+
+
3
+

Generate Invoices

+

One click to create compliant PDF invoices with correct VAT for any EU country.

+
+ +
+ + {# Step 4 #} +
+
+
4
+

Grow Your Business

+

Export customers for marketing. Track inventory. Focus on selling, not spreadsheets.

+
+
+
+
+
+ + + + +
+
+
+
+ Features +
+

+ Everything a Letzshop Seller Needs +

+

+ The operational tools Letzshop doesn't provide

- {# Feature cards with hover effects #} -
-
+ {# Feature 1: Order Sync #} +
+
- +
-

- Blazing Fast -

-

- Optimized for performance with sub-second page loads and instant search results. -

+

Automatic Order Sync

+

Orders from Letzshop appear instantly. Confirm orders and sync tracking numbers back automatically.

+
    +
  • Real-time sync
  • +
  • One-click confirmation
  • +
  • Tracking number sync
  • +
-
-
+ {# Feature 2: Inventory #} +
+
- +
-

- Bank-Level Security -

-

- Enterprise-grade encryption and security measures to protect your business. -

+

Real Inventory Management

+

One source of truth for all stock. Locations, reservations, and incoming stock tracking.

+
    +
  • Product locations (bins)
  • +
  • Stock reservations
  • +
  • Low stock alerts
  • +
-
-
+ {# Feature 3: Invoicing #} +
+
- +
-

- Fully Customizable -

-

- Brand your store with custom themes, colors, fonts, and layouts. -

+

Smart VAT Invoicing

+

Generate PDF invoices with correct VAT rates. Luxembourg, EU countries, B2B reverse charge.

+
    +
  • Luxembourg 17% VAT
  • +
  • EU destination VAT (OSS)
  • +
  • B2B reverse charge
  • +
-
-
+ {# Feature 4: Customers #} +
+
- +
-

- Analytics & Insights -

-

- Powerful analytics to track sales, customer behavior, and growth metrics. -

+

Own Your Customers

+

All customer data in your database. Export to Mailchimp for marketing campaigns.

+
    +
  • Order history per customer
  • +
  • Lifetime value tracking
  • +
  • CSV export for marketing
  • +
-
-
+ {# Feature 5: Team #} +
+
- +
-

- Mobile-First Design -

-

- Beautiful, responsive design that works perfectly on all devices. -

+

Team Management

+

Invite team members with role-based permissions. Everyone works from one dashboard.

+
    +
  • Multiple users
  • +
  • Role-based access
  • +
  • Activity logging
  • +
-
-
+ {# Feature 6: Purchase Orders #} +
+
- +
-

- 24/7 Support -

-

- Round-the-clock customer support to help you succeed at every step. -

+

Purchase Orders

+

Track incoming stock from suppliers. Know what's on order and when it arrives.

+
    +
  • Track supplier orders
  • +
  • Expected arrival dates
  • +
  • Receive and update stock
  • +
- + -
-
+
+
+
+
+ Pricing +
+

+ Simple, Transparent Pricing +

+

+ No per-order fees. No hidden costs. Flat monthly rate. +

+
-
-

- Start Your Journey Today +
+ {# Essential #} +
+

Essential

+

For solo vendors getting started

+
+ EUR 49 + /month +
+
    +
  • + + 100 orders/month +
  • +
  • + + 200 products +
  • +
  • + + Luxembourg VAT invoices +
  • +
  • + + 1 team member +
  • +
+ + Start Free Trial + +
+ + {# Professional - Highlighted #} +
+
+ MOST POPULAR +
+

Professional

+

For growing multi-channel sellers

+
+ EUR 99 + /month +
+
    +
  • + + 500 orders/month +
  • +
  • + + Unlimited products +
  • +
  • + + EU VAT invoices +
  • +
  • + + Product locations +
  • +
  • + + Purchase orders +
  • +
  • + + Customer export +
  • +
  • + + 3 team members +
  • +
+ + Start Free Trial + +
+ + {# Business #} +
+

Business

+

For high-volume operations

+
+ EUR 199 + /month +
+
    +
  • + + 2,000 orders/month +
  • +
  • + + Everything in Professional +
  • +
  • + + Analytics dashboard +
  • +
  • + + API access +
  • +
  • + + Accounting export +
  • +
  • + + 10 team members +
  • +
+ + Start Free Trial + +
+ + {# Enterprise #} +
+

Enterprise

+

For large operations & agencies

+
+ EUR 399+ + /month +
+
    +
  • + + Unlimited orders +
  • +
  • + + Everything in Business +
  • +
  • + + White-label option +
  • +
  • + + Custom integrations +
  • +
  • + + 99.9% SLA +
  • +
  • + + Dedicated support +
  • +
+ + Contact Sales + +
+
+ +

+ All plans include a 14-day free trial. No credit card required. +

+

+
+ + + + +
+
+
+ Built for Luxembourg +
+ +
+ "Finally, a tool that understands what Letzshop sellers actually need. No more spreadsheets, no more VAT headaches." +
+ +
+
+ ๐Ÿ‘ฉ +
+
+
Marie L.
+
Letzshop Vendor, Luxembourg City
+
+
+
+
+ + + + +
+
+

+ Ready to Take Control of Your Letzshop Business?

-

- Join thousands of successful vendors on our platform +

+ Join Luxembourg vendors who've stopped fighting spreadsheets and started growing their business.

-

- No credit card required ยท Free 14-day trial ยท Cancel anytime +

+ No credit card required. Setup in 5 minutes. Full Professional features during trial.

diff --git a/docs/marketing/pricing.md b/docs/marketing/pricing.md index 8ab950ef..6d7daddf 100644 --- a/docs/marketing/pricing.md +++ b/docs/marketing/pricing.md @@ -4,7 +4,7 @@ **"Lightweight OMS for Letzshop Sellers"** -A focused Order Management System that works alongside Letzshop, not instead of it. Provides the operational tools Letzshop lacks: real inventory, correct invoicing, customer ownership. +A focused Order Management System built specifically for Luxembourg e-commerce. Works alongside Letzshop, not instead of it. Provides the operational tools Letzshop lacks: real inventory, correct invoicing, customer ownership. --- @@ -79,80 +79,104 @@ A focused Order Management System that works alongside Letzshop, not instead of **Upgrade Triggers:** - Exceeds 500 orders/month -- Multiple Letzshop vendor accounts -- Needs API for integrations +- Needs analytics and reporting - Team grows beyond 3 +- Accountant needs structured exports --- ### Business - EUR 199/month -**Target:** High-volume vendors, teams, multiple operations +**Target:** High-volume vendors, teams, data-driven operations | Feature | Limit | |---------|-------| -| Letzshop Order Sync | Unlimited orders | +| Letzshop Order Sync | Up to 2,000 orders/month | | Everything in Professional | Included | -| **Multi-Vendor Support** | Multiple Letzshop accounts | -| **API Access** | For custom integrations | +| **Analytics Dashboard** | Sales trends, top products, customer insights | | **Accounting Export** | CSV/XML for accounting software | +| **Bulk Operations** | Mass product updates, bulk invoicing | +| **Advanced Reporting** | Custom date ranges, exportable reports | +| **API Access** | For custom integrations | +| **Automation Rules** | Auto-confirm orders, low-stock alerts | | Team Members | 10 users | | Team Roles & Permissions | Full access control | | Order History | Unlimited | -| Phone Support | Available | -| Dedicated Onboarding | 2-hour session included | +| Priority Support | 4h response, phone available | -**Value Proposition:** "Enterprise-grade OMS at SMB pricing." +**Value Proposition:** "Scale with confidence. Analytics, automation, and full control." + +**Upgrade Triggers:** +- Exceeds 2,000 orders/month +- Needs white-label or custom integrations +- Requires SLA guarantee +- Multiple brands/operations --- -### Enterprise - Custom Pricing +### Enterprise - EUR 399+/month (Custom) -**Target:** Large operations, special requirements +**Target:** Large operations, special requirements, agencies | Feature | Notes | |---------|-------| | Everything in Business | Included | +| Unlimited Orders | No volume limits | | Unlimited Team Members | As needed | -| Custom Integrations | Built to spec | -| SLA Guarantee | 99.9% uptime | -| Dedicated Account Manager | Named contact | -| On-Premise Option | If required | +| **White-Label Option** | Remove Wizamart branding | +| **Custom Integrations** | Connect to ERP, accounting software | +| **Multi-Vendor Support** | Multiple Letzshop accounts under one dashboard | +| **SLA Guarantee** | 99.9% uptime commitment | +| **Dedicated Account Manager** | Named contact | +| **On-Premise Option** | For security-sensitive clients | +| **Custom Development** | Bespoke features on request | -**Starting at EUR 499/month** - Contact for quote +**Starting at EUR 399/month** - Contact for quote --- ## Feature Comparison Matrix -| Feature | Essential | Professional | Business | -|---------|:---------:|:------------:|:--------:| +| Feature | Essential | Professional | Business | Enterprise | +|---------|:---------:|:------------:|:--------:|:----------:| | **Orders** | -| Letzshop Sync | 100/mo | 500/mo | Unlimited | -| Order Confirmation | โœ“ | โœ“ | โœ“ | -| Tracking Sync | โœ“ | โœ“ | โœ“ | -| Order History | 6 months | 24 months | Unlimited | +| Letzshop Sync | 100/mo | 500/mo | 2,000/mo | Unlimited | +| Order Confirmation | Yes | Yes | Yes | Yes | +| Tracking Sync | Yes | Yes | Yes | Yes | +| Order History | 6 months | 24 months | Unlimited | Unlimited | | **Inventory** | -| Product Limit | 200 | Unlimited | Unlimited | -| Stock Levels | โœ“ | โœ“ | โœ“ | -| Product Locations | - | โœ“ | โœ“ | -| Incoming Stock / PO | - | โœ“ | โœ“ | +| Product Limit | 200 | Unlimited | Unlimited | Unlimited | +| Stock Levels | Yes | Yes | Yes | Yes | +| Product Locations | - | Yes | Yes | Yes | +| Incoming Stock / PO | - | Yes | Yes | Yes | | **Invoicing** | -| Luxembourg Invoice | โœ“ | โœ“ | โœ“ | -| EU VAT Invoice | - | โœ“ | โœ“ | -| Accounting Export | - | - | โœ“ | +| Luxembourg Invoice | Yes | Yes | Yes | Yes | +| EU VAT Invoice | - | Yes | Yes | Yes | +| Bulk Invoicing | - | - | Yes | Yes | +| Accounting Export | - | - | Yes | Yes | | **Customers** | -| Customer List | View | View + Export | View + Export | +| Customer List | View | View + Export | View + Export | View + Export | +| **Analytics & Reporting** | +| Basic Stats | Yes | Yes | Yes | Yes | +| Analytics Dashboard | - | - | Yes | Yes | +| Advanced Reporting | - | - | Yes | Yes | +| Custom Reports | - | - | - | Yes | +| **Automation** | +| Low Stock Alerts | - | - | Yes | Yes | +| Auto-Confirm Orders | - | - | Yes | Yes | +| Custom Automation | - | - | - | Yes | | **Team** | -| Users | 1 | 3 | 10 | -| Roles & Permissions | - | - | โœ“ | -| **Operations** | -| Multi-Vendor | - | - | โœ“ | -| API Access | - | - | โœ“ | +| Users | 1 | 3 | 10 | Unlimited | +| Roles & Permissions | - | - | Yes | Yes | +| **Integration** | +| API Access | - | - | Yes | Yes | +| Custom Integrations | - | - | - | Yes | +| White-Label | - | - | - | Yes | | **Support** | -| Email | 72h | 24h | 24h | -| Phone | - | - | โœ“ | -| Onboarding | - | - | Included | +| Email | 72h | 24h | 4h | 4h | +| Phone | - | - | Yes | Yes | +| Dedicated Manager | - | - | - | Yes | +| SLA | - | - | - | 99.9% | --- @@ -173,6 +197,7 @@ A focused Order Management System that works alongside Letzshop, not instead of | Essential | EUR 49/mo | EUR 490/year (EUR 41/mo) | | Professional | EUR 99/mo | EUR 990/year (EUR 82/mo) | | Business | EUR 199/mo | EUR 1,990/year (EUR 166/mo) | +| Enterprise | Custom | Contact for annual pricing | --- @@ -191,8 +216,8 @@ A focused Order Management System that works alongside Letzshop, not instead of | Add-On | Price | Description | |--------|-------|-------------| | Additional Team Member | +EUR 9/user/mo | Beyond plan limit | -| Additional Vendor Account | +EUR 29/mo | For Professional tier | -| Extended History | +EUR 19/mo | Unlimited history for Essential | +| Extended History | +EUR 19/mo | Unlimited history for Essential/Professional | +| Extra Order Capacity | +EUR 29/100 orders | Beyond plan limit | --- @@ -210,23 +235,43 @@ A focused Order Management System that works alongside Letzshop, not instead of - Most active vendors will need this - Price feels reasonable for operational software -3. **Business (EUR 199) provides anchor** - - Makes Professional look affordable - - Captures high-value customers - - Multi-vendor is a clear enterprise need +3. **Business (EUR 199) is for growth** + - Analytics and automation justify the jump + - 2,000 orders/month covers most high-volume vendors + - API access enables custom workflows -4. **No per-order fees** +4. **Enterprise (EUR 399+) captures high-value clients** + - White-label and SLA for agencies/resellers + - Custom integrations for complex operations + - Multi-vendor for the rare but valuable cases + +5. **No per-order fees** - Unlike Letzshop's commission model - The more you sell, the better the value - Predictable costs for budgeting -5. **Annual = lower churn** +6. **Annual = lower churn** - 17% discount is meaningful - 2 months free is easy to understand - Reduces monthly cancellation opportunity --- +## Upgrade Path Logic + +``` +Essential -> Professional + Triggers: Ships to EU (VAT) | 100+ orders | Team access needed + +Professional -> Business + Triggers: 500+ orders | Needs analytics | Accountant needs exports + +Business -> Enterprise + Triggers: 2,000+ orders | White-label | SLA required | Custom integrations +``` + +--- + ## Competitive Positioning | Alternative | Their Model | Our Advantage | @@ -244,21 +289,27 @@ A focused Order Management System that works alongside Letzshop, not instead of ### Qualifying Questions 1. "How many orders do you process per month?" - - <100 โ†’ Essential - - 100-500 โ†’ Professional - - 500+ โ†’ Business + - <100 -> Essential + - 100-500 -> Professional + - 500-2000 -> Business + - 2000+ -> Enterprise 2. "Do you ship to other EU countries?" - - Yes โ†’ Professional minimum (VAT invoicing) - - No โ†’ Essential may suffice + - Yes -> Professional minimum (VAT invoicing) + - No -> Essential may suffice 3. "How many people handle orders?" - - 1 โ†’ Essential - - 2-3 โ†’ Professional - - 4+ โ†’ Business + - 1 -> Essential + - 2-3 -> Professional + - 4-10 -> Business + - 10+ -> Enterprise -4. "Do you have multiple Letzshop accounts?" - - Yes โ†’ Business +4. "Do you need analytics and reporting?" + - Yes -> Business minimum + +5. "Do you need to connect to other systems (ERP, accounting)?" + - API needed -> Business + - Custom integration -> Enterprise ### Objection Handling @@ -270,3 +321,6 @@ A focused Order Management System that works alongside Letzshop, not instead of **"Why do I need Professional for EU invoices?"** > "EU VAT rules are complex - wrong rate, wrong country, wrong invoice = compliance issues. We handle this automatically so your accountant doesn't have to fix it." + +**"Business seems expensive"** +> "At 500+ orders/month, you're doing at least EUR 25,000/month in revenue. EUR 199 is less than 1% of that, and the analytics alone will help you find opportunities to grow." diff --git a/docs/marketing/strategy/back-office-positioning.md b/docs/marketing/strategy/back-office-positioning.md index 54955116..beb6403a 100644 --- a/docs/marketing/strategy/back-office-positioning.md +++ b/docs/marketing/strategy/back-office-positioning.md @@ -167,22 +167,22 @@ The goal is natural upgrade pressure based on **growth**, not artificial limits. | Growth Signal | Triggers Upgrade To | |---------------|---------------------| | More orders | Higher tier (volume) | -| Shipping to EU | Pro (VAT invoicing) | -| Team grows | Pro/Business (users) | -| More products | Pro (locations, PO) | -| Multiple shops | Business (multi-vendor) | +| Shipping to EU | Professional (VAT invoicing) | +| Team grows | Professional/Business (users) | +| Needs analytics | Business (reporting) | +| Custom integrations | Enterprise (API/white-label) | --- ### Essential - EUR 49/month -**Target:** Solo vendors, getting started, Letzshop + 1 channel +**Target:** Solo vendors, getting started, Letzshop-focused | Feature | Limit | |---------|-------| | Letzshop Order Sync | Up to 100 orders/month | | Inventory Management | Up to 200 products | -| Basic Invoices (Luxembourg) | Included | +| Basic Invoices (Luxembourg VAT) | Included | | Team Members | 1 user | | Order History | 6 months | | Email Support | 72h response | @@ -207,44 +207,69 @@ The goal is natural upgrade pressure based on **growth**, not artificial limits. | Order History | 24 months | | Priority Email Support | 24h response | -**Why they upgrade:** Multiple team members, multiple Letzshop accounts, need API. +**Why they upgrade:** Hit 500 orders, need analytics/reporting, team grows beyond 3. --- ### Business - EUR 199/month -**Target:** Teams, high volume, multiple vendor accounts +**Target:** High-volume vendors, teams, data-driven operations | Feature | Limit | |---------|-------| -| Letzshop Order Sync | Unlimited orders | +| Letzshop Order Sync | Up to 2,000 orders/month | | Everything in Professional | Included | -| **Multi-Vendor Support** | Multiple Letzshop accounts | +| **Analytics Dashboard** | Sales trends, top products, insights | +| **Accounting Export** | CSV/XML for accounting software | +| **Bulk Operations** | Mass updates, bulk invoicing | +| **Advanced Reporting** | Custom date ranges, exports | | **API Access** | For custom integrations | -| **Accounting Export** | CSV/XML for accountants | +| **Automation Rules** | Auto-confirm, low-stock alerts | | Team Members | 10 users | | Team Roles & Permissions | Full RBAC | | Order History | Unlimited | -| Phone Support | Available | -| Dedicated Onboarding | Included | +| Priority Support | 4h response, phone available | + +**Why they upgrade:** Hit 2,000 orders, need white-label, need SLA, custom integrations. + +--- + +### Enterprise - EUR 399+/month (Custom) + +**Target:** Large operations, agencies, special requirements + +| Feature | Notes | +|---------|-------| +| Everything in Business | Included | +| Unlimited Orders | No volume limits | +| Unlimited Team Members | As needed | +| **White-Label Option** | Remove Wizamart branding | +| **Custom Integrations** | Connect to ERP, accounting software | +| **Multi-Vendor Support** | Multiple Letzshop accounts | +| **SLA Guarantee** | 99.9% uptime | +| **Dedicated Account Manager** | Named contact | --- ### Feature Access by Tier -| Feature | Essential | Professional | Business | -|---------|:---------:|:------------:|:--------:| -| Letzshop Order Sync | 100/mo | 500/mo | Unlimited | -| Inventory Management | 200 SKU | Unlimited | Unlimited | -| Product Locations | - | โœ“ | โœ“ | -| Incoming Stock / PO | - | โœ“ | โœ“ | -| Basic Invoice (LU) | โœ“ | โœ“ | โœ“ | -| EU VAT Invoice | - | โœ“ | โœ“ | -| Customer List | View | + Export | + Export | -| Team Members | 1 | 3 | 10 | -| Multi-Vendor | - | - | โœ“ | -| API Access | - | - | โœ“ | -| Order History | 6 mo | 24 mo | Unlimited | +| Feature | Essential | Professional | Business | Enterprise | +|---------|:---------:|:------------:|:--------:|:----------:| +| Letzshop Order Sync | 100/mo | 500/mo | 2,000/mo | Unlimited | +| Inventory Management | 200 SKU | Unlimited | Unlimited | Unlimited | +| Product Locations | - | Yes | Yes | Yes | +| Incoming Stock / PO | - | Yes | Yes | Yes | +| Basic Invoice (LU) | Yes | Yes | Yes | Yes | +| EU VAT Invoice | - | Yes | Yes | Yes | +| Customer List | View | + Export | + Export | + Export | +| Analytics Dashboard | - | - | Yes | Yes | +| Accounting Export | - | - | Yes | Yes | +| API Access | - | - | Yes | Yes | +| Automation Rules | - | - | Yes | Yes | +| White-Label | - | - | - | Yes | +| Multi-Vendor | - | - | - | Yes | +| Team Members | 1 | 3 | 10 | Unlimited | +| Order History | 6 mo | 24 mo | Unlimited | Unlimited | --- @@ -260,12 +285,18 @@ The goal is natural upgrade pressure based on **growth**, not artificial limits. - Product locations + PO = operational maturity - 3 users = small team -3. **Business (EUR 199)** is for scale - - Multi-vendor = manages multiple Letzshop shops - - API = integrates with other systems - - 10 users = real team +3. **Business (EUR 199)** is for growth + - Analytics and automation justify the price jump + - 2,000 orders/month covers most high-volume vendors + - API enables custom workflows + - No multi-vendor requirement (most don't need it) -**Price anchoring:** EUR 99 is the target tier. EUR 49 exists to capture price-sensitive leads. EUR 199 exists to make EUR 99 look reasonable. +4. **Enterprise (EUR 399+)** captures high-value clients + - White-label for agencies/resellers + - Multi-vendor for the rare cases that need it + - SLA and dedicated support for critical operations + +**Price anchoring:** EUR 99 is the target tier. EUR 49 captures price-sensitive leads. EUR 199 is for growth. EUR 399+ makes EUR 199 look reasonable. --- diff --git a/models/database/__init__.py b/models/database/__init__.py index 66c93616..3d264ef5 100644 --- a/models/database/__init__.py +++ b/models/database/__init__.py @@ -19,6 +19,12 @@ from .company import Company from .content_page import ContentPage from .customer import Customer, CustomerAddress from .inventory import Inventory +from .invoice import ( + Invoice, + InvoiceStatus, + VATRegime, + VendorInvoiceSettings, +) from .letzshop import ( LetzshopFulfillmentQueue, LetzshopHistoricalImportJob, @@ -44,6 +50,12 @@ from .order import Order, OrderItem from .order_item_exception import OrderItemException from .product import Product from .product_translation import ProductTranslation +from .subscription import ( + SubscriptionStatus, + TierCode, + TIER_LIMITS, + VendorSubscription, +) from .test_run import TestCollection, TestResult, TestRun from .user import User from .vendor import Role, Vendor, VendorUser @@ -95,6 +107,11 @@ __all__ = [ "MarketplaceImportError", # Inventory "Inventory", + # Invoicing + "Invoice", + "InvoiceStatus", + "VATRegime", + "VendorInvoiceSettings", # Orders "Order", "OrderItem", @@ -104,6 +121,11 @@ __all__ = [ "LetzshopFulfillmentQueue", "LetzshopSyncLog", "LetzshopHistoricalImportJob", + # Subscription + "VendorSubscription", + "SubscriptionStatus", + "TierCode", + "TIER_LIMITS", # Messaging "Conversation", "ConversationParticipant", diff --git a/models/database/invoice.py b/models/database/invoice.py new file mode 100644 index 00000000..e0435617 --- /dev/null +++ b/models/database/invoice.py @@ -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"" + + 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"" + + @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 diff --git a/models/database/order.py b/models/database/order.py index 88bc5a2e..dd7467da 100644 --- a/models/database/order.py +++ b/models/database/order.py @@ -143,6 +143,9 @@ class Order(Base, TimestampMixin): items = relationship( "OrderItem", back_populates="order", cascade="all, delete-orphan" ) + invoices = relationship( + "Invoice", back_populates="order", cascade="all, delete-orphan" + ) # Composite indexes for common queries __table_args__ = ( diff --git a/models/database/subscription.py b/models/database/subscription.py new file mode 100644 index 00000000..703d3d43 --- /dev/null +++ b/models/database/subscription.py @@ -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"" + + # ========================================================================= + # 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 diff --git a/models/database/vendor.py b/models/database/vendor.py index 9a943ec7..12d73813 100644 --- a/models/database/vendor.py +++ b/models/database/vendor.py @@ -143,6 +143,29 @@ class Vendor(Base, TimestampMixin): cascade="all, delete-orphan", ) + # Invoice settings (one-to-one) + invoice_settings = relationship( + "VendorInvoiceSettings", + back_populates="vendor", + uselist=False, + cascade="all, delete-orphan", + ) + + # Invoices (one-to-many) + invoices = relationship( + "Invoice", + back_populates="vendor", + cascade="all, delete-orphan", + ) + + # Subscription (one-to-one) + subscription = relationship( + "VendorSubscription", + back_populates="vendor", + uselist=False, + cascade="all, delete-orphan", + ) + domains = relationship( "VendorDomain", back_populates="vendor", diff --git a/models/schema/__init__.py b/models/schema/__init__.py index 225037ad..803e8e05 100644 --- a/models/schema/__init__.py +++ b/models/schema/__init__.py @@ -6,6 +6,7 @@ from . import ( auth, base, inventory, + invoice, marketplace_import_job, marketplace_product, message, @@ -19,6 +20,7 @@ from .base import * # Base Pydantic models __all__ = [ "base", "auth", + "invoice", "marketplace_product", "message", "inventory", diff --git a/models/schema/invoice.py b/models/schema/invoice.py new file mode 100644 index 00000000..0fc378eb --- /dev/null +++ b/models/schema/invoice.py @@ -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 diff --git a/models/schema/subscription.py b/models/schema/subscription.py new file mode 100644 index 00000000..deed1618 --- /dev/null +++ b/models/schema/subscription.py @@ -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 diff --git a/requirements.txt b/requirements.txt index c7ce5cef..bd6055e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,5 +22,8 @@ python-multipart==0.0.20 pandas==2.2.3 requests==2.32.3 +# PDF generation +weasyprint==62.3 + # Environment and configuration python-dotenv==1.0.1 \ No newline at end of file