diff --git a/.env.example b/.env.example index 0c179c30..04d14b02 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,7 @@ DEBUG=False # ============================================================================= # PROJECT INFORMATION # ============================================================================= -PROJECT_NAME=Wizamart - Multi-Vendor Marketplace Platform +PROJECT_NAME=Wizamart - Multi-Store Marketplace Platform DESCRIPTION=Multi-tenants multi-themes ecommerce application VERSION=2.2.0 @@ -134,17 +134,17 @@ EMAIL_DEBUG=false # ============================================================================= # PLATFORM LIMITS # ============================================================================= -MAX_VENDORS_PER_USER=5 -MAX_TEAM_MEMBERS_PER_VENDOR=50 +MAX_STORES_PER_USER=5 +MAX_TEAM_MEMBERS_PER_STORE=50 INVITATION_EXPIRY_DAYS=7 # ============================================================================= # DEMO/SEED DATA CONFIGURATION (Development only) # ============================================================================= -SEED_DEMO_VENDORS=3 -SEED_CUSTOMERS_PER_VENDOR=15 -SEED_PRODUCTS_PER_VENDOR=20 -SEED_ORDERS_PER_VENDOR=10 +SEED_DEMO_STORES=3 +SEED_CUSTOMERS_PER_STORE=15 +SEED_PRODUCTS_PER_STORE=20 +SEED_ORDERS_PER_STORE=10 # ============================================================================= # CELERY / REDIS TASK QUEUE diff --git a/CHANGELOG_2025-11-23.md b/CHANGELOG_2025-11-23.md index 6a9d83d2..a80f5f57 100644 --- a/CHANGELOG_2025-11-23.md +++ b/CHANGELOG_2025-11-23.md @@ -7,21 +7,21 @@ Major improvements to shop frontend functionality, landing pages, and cart syste ## Landing Page System ### Features Added -- ✅ Vendor landing pages at root URLs (`/`, `/vendors/{code}/`) +- ✅ Store landing pages at root URLs (`/`, `/stores/{code}/`) - ✅ Four template options: default, minimal, modern, full - ✅ CMS-powered content management via ContentPage model - ✅ Auto-redirect to `/shop/` when no landing page exists - ✅ Two-tier navigation: landing page (/) + shop (/shop/) ### Templates Created -1. `app/templates/vendor/landing-default.html` - Clean professional layout -2. `app/templates/vendor/landing-minimal.html` - Ultra-simple centered design -3. `app/templates/vendor/landing-modern.html` - Full-screen with animations -4. `app/templates/vendor/landing-full.html` - Split-screen with maximum features +1. `app/templates/store/landing-default.html` - Clean professional layout +2. `app/templates/store/landing-minimal.html` - Ultra-simple centered design +3. `app/templates/store/landing-modern.html` - Full-screen with animations +4. `app/templates/store/landing-full.html` - Split-screen with maximum features ### Supporting Files - `scripts/create_landing_page.py` - Interactive landing page creator -- `docs/features/vendor-landing-pages.md` - Complete usage guide +- `docs/features/store-landing-pages.md` - Complete usage guide - `docs/frontend/shop/navigation-flow.md` - Navigation architecture - `TEST_LANDING_PAGES.md` - Testing guide @@ -116,7 +116,7 @@ return { ### Duplicate /shop/ Prefix -**Problem:** Routes like `/vendors/wizamart/shop/shop/products/4` +**Problem:** Routes like `/stores/wizamart/shop/shop/products/4` **Root Cause:** ```python @@ -136,7 +136,7 @@ All routes in `shop_pages.py` fixed. ### Missing /shop/ in Template Links -**Problem:** Links went to `/vendors/wizamart/products` instead of `/shop/products` +**Problem:** Links went to `/stores/wizamart/products` instead of `/shop/products` **Fix:** Updated all templates: - `shop/base.html` - Header, footer, navigation @@ -149,7 +149,7 @@ All routes in `shop_pages.py` fixed. ### Navigation Architecture **Established two-tier system:** -- **Landing Page (/)** - Marketing, branding, vendor story +- **Landing Page (/)** - Marketing, branding, store story - **Shop (/shop/)** - E-commerce, products, cart, checkout **Navigation Pattern:** @@ -223,11 +223,11 @@ Comprehensive guide covering: - Interactive landing page creation - Template selection (default, minimal, modern, full) - Updates existing or creates new -- Vendor listing and status +- Store listing and status ## Commits Summary -1. `b7bf505` - feat: implement vendor landing pages with multi-template support and fix shop routing +1. `b7bf505` - feat: implement store landing pages with multi-template support and fix shop routing 2. `1df4f12` - fix: refactor product detail page to extend base template and use correct paths 3. `e94b3f9` - fix: resolve product detail page data access and add placeholder image 4. `6fa9ccb` - fix: use proper SVG placeholder image across all shop templates @@ -253,14 +253,14 @@ Comprehensive guide covering: - `models/database/content_page.py` - Added template field ### Created -- `app/templates/vendor/landing-default.html` -- `app/templates/vendor/landing-minimal.html` -- `app/templates/vendor/landing-modern.html` -- `app/templates/vendor/landing-full.html` +- `app/templates/store/landing-default.html` +- `app/templates/store/landing-minimal.html` +- `app/templates/store/landing-modern.html` +- `app/templates/store/landing-full.html` - `static/shop/img/placeholder.svg` - `scripts/create_landing_page.py` - `scripts/create_inventory.py` -- `docs/features/vendor-landing-pages.md` +- `docs/features/store-landing-pages.md` - `docs/frontend/shop/navigation-flow.md` - `docs/troubleshooting/shop-frontend.md` - `TEST_LANDING_PAGES.md` @@ -268,9 +268,9 @@ Comprehensive guide covering: ### Deleted - `app/api/v1/public/__init__.py` - Unused endpoints -- `app/api/v1/public/vendors/__init__.py` - Unused endpoints -- `app/api/v1/public/vendors/vendors.py` - Unused endpoints -- `app/templates/vendor/admin/*.html` - Moved to root vendor/ +- `app/api/v1/public/stores/__init__.py` - Unused endpoints +- `app/api/v1/public/stores/stores.py` - Unused endpoints +- `app/templates/store/admin/*.html` - Moved to root store/ ## Testing @@ -290,15 +290,15 @@ Comprehensive guide covering: ### Test URLs ``` Landing Pages: -- http://localhost:8000/vendors/wizamart/ -- http://localhost:8000/vendors/fashionhub/ -- http://localhost:8000/vendors/bookstore/ +- http://localhost:8000/stores/wizamart/ +- http://localhost:8000/stores/fashionhub/ +- http://localhost:8000/stores/bookstore/ Shop Pages: -- http://localhost:8000/vendors/wizamart/shop/ -- http://localhost:8000/vendors/wizamart/shop/products -- http://localhost:8000/vendors/wizamart/shop/products/1 -- http://localhost:8000/vendors/wizamart/shop/cart +- http://localhost:8000/stores/wizamart/shop/ +- http://localhost:8000/stores/wizamart/shop/products +- http://localhost:8000/stores/wizamart/shop/products/1 +- http://localhost:8000/stores/wizamart/shop/cart ``` ## Breaking Changes diff --git a/Makefile b/Makefile index 97c8dab1..48c48306 100644 --- a/Makefile +++ b/Makefile @@ -139,7 +139,7 @@ endif @echo "✅ Demo seeding completed" seed-demo-minimal: - @echo "🎪 Seeding demo data (minimal mode - 1 vendor only)..." + @echo "🎪 Seeding demo data (minimal mode - 1 store only)..." ifeq ($(DETECTED_OS),Windows) @set SEED_MODE=minimal&& $(PYTHON) scripts/seed_demo.py else @@ -226,59 +226,70 @@ test-db-status: # TESTING # ============================================================================= +# Test database URL +TEST_DB_URL := postgresql://test_user:test_password@localhost:5433/wizamart_test + +# Build pytest marker expression from module= and frontend= params +MARKER_EXPR := +ifdef module + MARKER_EXPR := -m "$(module)" +endif +ifdef frontend + ifdef module + MARKER_EXPR := -m "$(module) and $(frontend)" + else + MARKER_EXPR := -m "$(frontend)" + endif +endif + +# All testpaths +TEST_PATHS := tests/ + test: @docker compose -f docker-compose.test.yml up -d 2>/dev/null || true @sleep 2 - TEST_DATABASE_URL="postgresql://test_user:test_password@localhost:5433/wizamart_test" \ - $(PYTHON) -m pytest tests/ -v + TEST_DATABASE_URL="$(TEST_DB_URL)" \ + $(PYTHON) -m pytest $(TEST_PATHS) -v $(MARKER_EXPR) test-unit: @docker compose -f docker-compose.test.yml up -d 2>/dev/null || true @sleep 2 - TEST_DATABASE_URL="postgresql://test_user:test_password@localhost:5433/wizamart_test" \ - $(PYTHON) -m pytest tests/ -v -m unit +ifdef module + TEST_DATABASE_URL="$(TEST_DB_URL)" \ + $(PYTHON) -m pytest $(TEST_PATHS) -v -m "unit and $(module)" +else + TEST_DATABASE_URL="$(TEST_DB_URL)" \ + $(PYTHON) -m pytest $(TEST_PATHS) -v -m unit +endif test-integration: @docker compose -f docker-compose.test.yml up -d 2>/dev/null || true @sleep 2 - TEST_DATABASE_URL="postgresql://test_user:test_password@localhost:5433/wizamart_test" \ - $(PYTHON) -m pytest tests/ -v -m integration +ifdef module + TEST_DATABASE_URL="$(TEST_DB_URL)" \ + $(PYTHON) -m pytest $(TEST_PATHS) -v -m "integration and $(module)" +else + TEST_DATABASE_URL="$(TEST_DB_URL)" \ + $(PYTHON) -m pytest $(TEST_PATHS) -v -m integration +endif test-coverage: @docker compose -f docker-compose.test.yml up -d 2>/dev/null || true @sleep 2 - TEST_DATABASE_URL="postgresql://test_user:test_password@localhost:5433/wizamart_test" \ - $(PYTHON) -m pytest tests/ --cov=app --cov=models --cov=utils --cov=middleware --cov-report=html --cov-report=term-missing + TEST_DATABASE_URL="$(TEST_DB_URL)" \ + $(PYTHON) -m pytest $(TEST_PATHS) --cov=app --cov=models --cov=utils --cov=middleware --cov-report=html --cov-report=term-missing $(MARKER_EXPR) test-fast: @docker compose -f docker-compose.test.yml up -d 2>/dev/null || true @sleep 2 - TEST_DATABASE_URL="postgresql://test_user:test_password@localhost:5433/wizamart_test" \ - $(PYTHON) -m pytest tests/ -v -m "not slow" + TEST_DATABASE_URL="$(TEST_DB_URL)" \ + $(PYTHON) -m pytest $(TEST_PATHS) -v -m "not slow" $(MARKER_EXPR) test-slow: @docker compose -f docker-compose.test.yml up -d 2>/dev/null || true @sleep 2 - TEST_DATABASE_URL="postgresql://test_user:test_password@localhost:5433/wizamart_test" \ - $(PYTHON) -m pytest tests/ -v -m slow - -test-auth: - @docker compose -f docker-compose.test.yml up -d 2>/dev/null || true - @sleep 2 - TEST_DATABASE_URL="postgresql://test_user:test_password@localhost:5433/wizamart_test" \ - $(PYTHON) -m pytest tests/test_auth.py -v - -test-products: - @docker compose -f docker-compose.test.yml up -d 2>/dev/null || true - @sleep 2 - TEST_DATABASE_URL="postgresql://test_user:test_password@localhost:5433/wizamart_test" \ - $(PYTHON) -m pytest tests/test_products.py -v - -test-inventory: - @docker compose -f docker-compose.test.yml up -d 2>/dev/null || true - @sleep 2 - TEST_DATABASE_URL="postgresql://test_user:test_password@localhost:5433/wizamart_test" \ - $(PYTHON) -m pytest tests/test_inventory.py -v + TEST_DATABASE_URL="$(TEST_DB_URL)" \ + $(PYTHON) -m pytest $(TEST_PATHS) -v -m slow # ============================================================================= # CODE QUALITY @@ -325,10 +336,10 @@ endif arch-check-object: ifeq ($(DETECTED_OS),Windows) - @if "$(name)"=="" (echo Error: Please provide an object name. Usage: make arch-check-object name="company") else ($(PYTHON) scripts/validate_architecture.py -o "$(name)") + @if "$(name)"=="" (echo Error: Please provide an object name. Usage: make arch-check-object name="merchant") else ($(PYTHON) scripts/validate_architecture.py -o "$(name)") else @if [ -z "$(name)" ]; then \ - echo "Error: Please provide an object name. Usage: make arch-check-object name=\"company\""; \ + echo "Error: Please provide an object name. Usage: make arch-check-object name=\"merchant\""; \ else \ $(PYTHON) scripts/validate_architecture.py -o "$(name)"; \ fi @@ -383,15 +394,15 @@ tailwind-install: tailwind-dev: @echo "Building Tailwind CSS (development)..." $(TAILWIND_CLI) -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css - $(TAILWIND_CLI) -i static/vendor/css/tailwind.css -o static/vendor/css/tailwind.output.css + $(TAILWIND_CLI) -i static/store/css/tailwind.css -o static/store/css/tailwind.output.css $(TAILWIND_CLI) -i static/shop/css/tailwind.css -o static/shop/css/tailwind.output.css $(TAILWIND_CLI) -i static/platform/css/tailwind.css -o static/platform/css/tailwind.output.css - @echo "Tailwind CSS built (admin + vendor + shop + platform)" + @echo "Tailwind CSS built (admin + store + shop + platform)" tailwind-build: @echo "Building Tailwind CSS (production - minified)..." $(TAILWIND_CLI) -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css --minify - $(TAILWIND_CLI) -i static/vendor/css/tailwind.css -o static/vendor/css/tailwind.output.css --minify + $(TAILWIND_CLI) -i static/store/css/tailwind.css -o static/store/css/tailwind.output.css --minify $(TAILWIND_CLI) -i static/shop/css/tailwind.css -o static/shop/css/tailwind.output.css --minify $(TAILWIND_CLI) -i static/platform/css/tailwind.css -o static/platform/css/tailwind.output.css --minify @echo "Tailwind CSS built and minified for production" @@ -531,8 +542,8 @@ help: @echo " migrate-status - Show migration status" @echo " platform-install - First-time setup (validates config + migrate + init)" @echo " init-prod - Initialize platform (admin, CMS, pages, emails)" - @echo " seed-demo - Seed demo data (3 companies + vendors)" - @echo " seed-demo-minimal - Seed minimal demo (1 company + vendor)" + @echo " seed-demo - Seed demo data (3 merchants + stores)" + @echo " seed-demo-minimal - Seed minimal demo (1 merchant + store)" @echo " seed-demo-reset - DELETE ALL demo data and reseed" @echo " db-setup - Full dev setup (migrate + init-prod + seed-demo)" @echo " backup-db - Backup database" @@ -542,8 +553,13 @@ help: @echo " test-db-down - Stop test database" @echo " test-db-reset - Reset test database" @echo " test - Run all tests (auto-starts DB)" + @echo " test module=loyalty - Run tests for a specific module" + @echo " test-unit - Run unit tests only" + @echo " test-unit module=X - Run unit tests for module X" + @echo " test-integration - Run integration tests only" @echo " test-coverage - Run tests with coverage" @echo " test-fast - Run fast tests only" + @echo " test frontend=storefront - Run storefront tests" @echo "" @echo "=== CODE QUALITY ===" @echo " format - Format code with ruff" @@ -552,7 +568,7 @@ help: @echo " verify-imports - Verify critical imports haven't been removed" @echo " arch-check - Validate architecture patterns" @echo " arch-check-file file=\"path\" - Check a single file" - @echo " arch-check-object name=\"company\" - Check all files for an entity" + @echo " arch-check-object name=\"merchant\" - Check all files for an entity" @echo " check - Format + lint + verify imports" @echo " ci - Full CI pipeline (strict)" @echo " qa - Quality assurance (includes arch-check)" @@ -581,7 +597,7 @@ help: @echo " docker-down - Stop Docker containers" @echo "" @echo "=== UTILITIES ===" - @echo " urls - Show all platform/vendor/storefront URLs" + @echo " urls - Show all platform/store/storefront URLs" @echo " urls-dev - Show development URLs only" @echo " urls-prod - Show production URLs only" @echo " clean - Clean build artifacts" @@ -624,8 +640,8 @@ help-db: @echo "" @echo "DEMO DATA (Development Only - NEVER in production):" @echo "──────────────────────────────────────────────────────────" - @echo " seed-demo - Create 3 demo companies + vendors + data" - @echo " seed-demo-minimal - Create 1 demo company + vendor only" + @echo " seed-demo - Create 3 demo merchants + stores + data" + @echo " seed-demo-minimal - Create 1 demo merchant + store only" @echo " seed-demo-reset - DELETE ALL demo data and reseed (DANGEROUS!)" @echo "" @echo "UTILITY COMMANDS (Advanced - usually not needed):" diff --git a/README.md b/README.md index 4f9c391f..c52296f5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A comprehensive FastAPI-based product management system with JWT authentication, ## Overview -This FastAPI application provides a complete ecommerce backend solution designed for marketplace operations. The system supports multiple vendors (shops), centralized product catalogs, advanced inventory management, and automated CSV processing with background job handling. +This FastAPI application provides a complete ecommerce backend solution designed for marketplace operations. The system supports multiple stores (shops), centralized product catalogs, advanced inventory management, and automated CSV processing with background job handling. ### Key Features diff --git a/SECURITY.md b/SECURITY.md index 1273187e..1b52cf59 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -31,7 +31,7 @@ This application implements the following security measures: ### Authentication & Authorization - JWT-based authentication with token expiration - Role-based access control (RBAC) -- Vendor isolation (multi-tenant security) +- Store isolation (multi-tenant security) - Session management with secure cookies ### Data Protection diff --git a/TERMINOLOGY.md b/TERMINOLOGY.md new file mode 100644 index 00000000..8d8b4463 --- /dev/null +++ b/TERMINOLOGY.md @@ -0,0 +1,37 @@ +# Terminology Guide + +This document defines the standard terminology used throughout the Wizamart codebase. + +## Core Multi-Tenant Entities + +| Term | Definition | Database Table | Example | +|------|-----------|---------------|---------| +| **Platform** | The marketplace itself — the top-level entity that hosts merchants | `platforms` | "Letzshop.lu" | +| **Merchant** | A business entity that signs a contract with the platform | `merchants` | "Boulangerie Dupont SARL" | +| **Store** | A specific shop/location operated by a merchant | `stores` | "Boulangerie Dupont - Kirchberg" | +| **Storefront** | The customer-facing view of a store (URL namespace, not a model) | — | `https://dupont.letzshop.lu/` | + +## Entity Hierarchy + +``` +Platform +└── Merchant (the business / legal entity) + └── Store (the individual shop / brand) + └── Storefront (customer-facing view — URL namespace only) +``` + +## Historical Mapping + +These terms were renamed from the original codebase terminology: + +| Old Term | New Term | Reason | +|----------|----------|--------| +| Company | **Merchant** | "Merchant" is industry-standard (Shopify, Stripe, Square) for the business entity selling on a platform | +| Vendor | **Store** | "Vendor" in e-commerce commonly means "supplier" (someone who sells TO a platform), not a seller on a platform. "Store" is unambiguous. | + +## Usage Guidelines + +- Use **Merchant** when referring to the business entity, contracts, billing, legal matters +- Use **Store** when referring to the specific shop, its products, inventory, orders, and customer-facing operations +- Use **Storefront** only when referring to the customer-facing URL or display layer +- Never use "Company" or "Vendor" in new code — always use the terms above diff --git a/TEST_LANDING_PAGES.md b/TEST_LANDING_PAGES.md index 0a50fe46..a81fe6a6 100644 --- a/TEST_LANDING_PAGES.md +++ b/TEST_LANDING_PAGES.md @@ -2,16 +2,16 @@ ## ✅ Setup Complete! -Landing pages have been created for three vendors with different templates. +Landing pages have been created for three stores with different templates. ## 📍 Test URLs ### 1. WizaMart - Modern Template **Landing Page:** -- http://localhost:8000/vendors/wizamart/ +- http://localhost:8000/stores/wizamart/ **Shop Page:** -- http://localhost:8000/vendors/wizamart/shop/ +- http://localhost:8000/stores/wizamart/shop/ **What to expect:** - Full-screen hero section with animations @@ -23,10 +23,10 @@ Landing pages have been created for three vendors with different templates. ### 2. Fashion Hub - Minimal Template **Landing Page:** -- http://localhost:8000/vendors/fashionhub/ +- http://localhost:8000/stores/fashionhub/ **Shop Page:** -- http://localhost:8000/vendors/fashionhub/shop/ +- http://localhost:8000/stores/fashionhub/shop/ **What to expect:** - Ultra-simple centered design @@ -38,10 +38,10 @@ Landing pages have been created for three vendors with different templates. ### 3. The Book Store - Full Template **Landing Page:** -- http://localhost:8000/vendors/bookstore/ +- http://localhost:8000/stores/bookstore/ **Shop Page:** -- http://localhost:8000/vendors/bookstore/shop/ +- http://localhost:8000/stores/bookstore/shop/ **What to expect:** - Split-screen hero layout @@ -55,9 +55,9 @@ Landing pages have been created for three vendors with different templates. ## 🧪 Test Scenarios ### Test 1: Landing Page Display -1. Visit each vendor's landing page URL +1. Visit each store's landing page URL 2. Verify the correct template is rendered -3. Check that vendor name, logo, and theme colors appear correctly +3. Check that store name, logo, and theme colors appear correctly ### Test 2: Navigation 1. Click "Shop Now" / "Enter Shop" button on landing page @@ -66,7 +66,7 @@ Landing pages have been created for three vendors with different templates. 4. Should return to landing page ### Test 3: Theme Integration -1. Each vendor uses their theme colors +1. Each store uses their theme colors 2. Logo should display correctly 3. Dark mode toggle should work @@ -84,7 +84,7 @@ from app.core.database import SessionLocal from models.database.content_page import ContentPage db = SessionLocal() -page = db.query(ContentPage).filter(ContentPage.vendor_id == 1, ContentPage.slug == 'landing').first() +page = db.query(ContentPage).filter(ContentPage.store_id == 1, ContentPage.slug == 'landing').first() if page: db.delete(page) db.commit() @@ -93,8 +93,8 @@ db.close() " ``` -Then visit: http://localhost:8000/vendors/wizamart/ -- Should automatically redirect to: http://localhost:8000/vendors/wizamart/shop/ +Then visit: http://localhost:8000/stores/wizamart/ +- Should automatically redirect to: http://localhost:8000/stores/wizamart/shop/ --- @@ -128,11 +128,11 @@ create_landing_page('wizamart', template='modern') ## 📊 Current Setup -| Vendor | Subdomain | Template | Landing Page URL | +| Store | Subdomain | Template | Landing Page URL | |--------|-----------|----------|------------------| -| WizaMart | wizamart | **modern** | http://localhost:8000/vendors/wizamart/ | -| Fashion Hub | fashionhub | **minimal** | http://localhost:8000/vendors/fashionhub/ | -| The Book Store | bookstore | **full** | http://localhost:8000/vendors/bookstore/ | +| WizaMart | wizamart | **modern** | http://localhost:8000/stores/wizamart/ | +| Fashion Hub | fashionhub | **minimal** | http://localhost:8000/stores/fashionhub/ | +| The Book Store | bookstore | **full** | http://localhost:8000/stores/bookstore/ | --- @@ -141,7 +141,7 @@ create_landing_page('wizamart', template='modern') Check landing pages in database: ```bash -sqlite3 letzshop.db "SELECT id, vendor_id, slug, title, template, is_published FROM content_pages WHERE slug='landing';" +sqlite3 letzshop.db "SELECT id, store_id, slug, title, template, is_published FROM content_pages WHERE slug='landing';" ``` Expected output: @@ -158,22 +158,22 @@ Expected output: ### Landing Page Not Showing 1. Check database: `SELECT * FROM content_pages WHERE slug='landing';` 2. Verify `is_published=1` -3. Check vendor ID matches +3. Check store ID matches 4. Look at server logs for errors ### Wrong Template Rendering 1. Check `template` field in database -2. Verify template file exists: `app/templates/vendor/landing-{template}.html` +2. Verify template file exists: `app/templates/store/landing-{template}.html` 3. Restart server if needed ### Theme Not Applied -1. Verify vendor has theme: `SELECT * FROM vendor_themes WHERE vendor_id=1;` +1. Verify store has theme: `SELECT * FROM store_themes WHERE store_id=1;` 2. Check theme middleware is running (see server logs) 3. Inspect CSS variables in browser: `var(--color-primary)` ### 404 Error 1. Ensure server is running: `python main.py` or `make run` -2. Check middleware is detecting vendor (see logs) +2. Check middleware is detecting store (see logs) 3. Verify route registration in startup logs --- @@ -199,7 +199,7 @@ Once testing is complete: 1. **Create Landing Pages via Admin Panel** (future feature) 2. **Build Visual Page Builder** (Phase 2) 3. **Add More Templates** as needed -4. **Customize Content** for each vendor +4. **Customize Content** for each store For now, use the Python script to manage landing pages: ```bash diff --git a/alembic/env.py b/alembic/env.py index d05f46f4..94fc7adf 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -68,31 +68,31 @@ except ImportError as e: print(f" ✗ User model failed: {e}") # ---------------------------------------------------------------------------- -# VENDOR MODELS +# STORE MODELS # ---------------------------------------------------------------------------- try: - from app.modules.tenancy.models import Role, Vendor, VendorUser + from app.modules.tenancy.models import Role, Store, StoreUser - print(" ✓ Vendor models imported (3 models)") - print(" - Vendor") - print(" - VendorUser") + print(" ✓ Store models imported (3 models)") + print(" - Store") + print(" - StoreUser") print(" - Role") except ImportError as e: - print(f" ✗ Vendor models failed: {e}") + print(f" ✗ Store models failed: {e}") try: - from app.modules.tenancy.models import VendorDomain + from app.modules.tenancy.models import StoreDomain - print(" ✓ VendorDomain model imported") + print(" ✓ StoreDomain model imported") except ImportError as e: - print(f" ✗ VendorDomain model failed: {e}") + print(f" ✗ StoreDomain model failed: {e}") try: - from app.modules.cms.models import VendorTheme + from app.modules.cms.models import StoreTheme - print(" ✓ VendorTheme model imported") + print(" ✓ StoreTheme model imported") except ImportError as e: - print(f" ✗ VendorTheme model failed: {e}") + print(f" ✗ StoreTheme model failed: {e}") # ---------------------------------------------------------------------------- # CONTENT PAGE MODEL (CMS Module) diff --git a/alembic/versions/t001_rename_company_vendor_to_merchant_store.py b/alembic/versions/t001_rename_company_vendor_to_merchant_store.py new file mode 100644 index 00000000..c9bc03bc --- /dev/null +++ b/alembic/versions/t001_rename_company_vendor_to_merchant_store.py @@ -0,0 +1,220 @@ +"""Rename Company/Vendor to Merchant/Store terminology. + +Revision ID: t001_terminology +Revises: loyalty_003_phase2 +Create Date: 2026-02-06 22:00:00.000000 + +Major terminology migration: +- companies -> merchants +- vendors -> stores +- company_id -> merchant_id (in all child tables) +- vendor_id -> store_id (in all child tables) +- vendor_code -> store_code +- letzshop_vendor_id -> letzshop_store_id +- letzshop_vendor_slug -> letzshop_store_slug +- vendor_name -> store_name (in marketplace_products) +- All vendor-prefixed tables renamed to store-prefixed +- company_loyalty_settings -> merchant_loyalty_settings +- letzshop_vendor_cache -> letzshop_store_cache +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import text + +# revision identifiers, used by Alembic. +revision: str = "t001_terminology" +down_revision: Union[str, None] = "loyalty_003_phase2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _col_exists(table: str, col: str) -> bool: + """Check if column exists using raw SQL.""" + conn = op.get_bind() + result = conn.execute(text( + "SELECT 1 FROM information_schema.columns " + "WHERE table_schema='public' AND table_name=:t AND column_name=:c" + ), {"t": table, "c": col}) + return result.fetchone() is not None + + +def _table_exists(table: str) -> bool: + """Check if table exists using raw SQL.""" + conn = op.get_bind() + result = conn.execute(text( + "SELECT 1 FROM information_schema.tables " + "WHERE table_schema='public' AND table_name=:t" + ), {"t": table}) + return result.fetchone() is not None + + +def upgrade() -> None: + """Rename all Company/Vendor references to Merchant/Store.""" + + # ====================================================================== + # STEP 1: Rename columns in child tables FIRST (before renaming parent tables) + # ====================================================================== + + # --- company_id -> merchant_id --- + op.alter_column("vendors", "company_id", new_column_name="merchant_id") + op.alter_column("loyalty_programs", "company_id", new_column_name="merchant_id") + op.alter_column("loyalty_cards", "company_id", new_column_name="merchant_id") + op.alter_column("loyalty_transactions", "company_id", new_column_name="merchant_id") + op.alter_column("company_loyalty_settings", "company_id", new_column_name="merchant_id") + op.alter_column("staff_pins", "company_id", new_column_name="merchant_id") + + # --- vendor_id -> store_id (in all child tables) --- + op.alter_column("products", "vendor_id", new_column_name="store_id") + op.alter_column("customers", "vendor_id", new_column_name="store_id") + op.alter_column("customer_addresses", "vendor_id", new_column_name="store_id") + op.alter_column("orders", "vendor_id", new_column_name="store_id") + op.alter_column("order_item_exceptions", "vendor_id", new_column_name="store_id") + op.alter_column("invoices", "vendor_id", new_column_name="store_id") + op.alter_column("inventory", "vendor_id", new_column_name="store_id") + op.alter_column("inventory_transactions", "vendor_id", new_column_name="store_id") + op.alter_column("marketplace_import_jobs", "vendor_id", new_column_name="store_id") + op.alter_column("letzshop_fulfillment_queue", "vendor_id", new_column_name="store_id") + op.alter_column("letzshop_sync_logs", "vendor_id", new_column_name="store_id") + op.alter_column("letzshop_historical_import_jobs", "vendor_id", new_column_name="store_id") + op.alter_column("vendor_users", "vendor_id", new_column_name="store_id") + op.alter_column("roles", "vendor_id", new_column_name="store_id") + op.alter_column("vendor_domains", "vendor_id", new_column_name="store_id") + op.alter_column("vendor_platforms", "vendor_id", new_column_name="store_id") + op.alter_column("vendor_addons", "vendor_id", new_column_name="store_id") + op.alter_column("vendor_subscriptions", "vendor_id", new_column_name="store_id") + op.alter_column("billing_history", "vendor_id", new_column_name="store_id") + op.alter_column("content_pages", "vendor_id", new_column_name="store_id") + op.alter_column("vendor_themes", "vendor_id", new_column_name="store_id") + op.alter_column("media_files", "vendor_id", new_column_name="store_id") + op.alter_column("vendor_email_templates", "vendor_id", new_column_name="store_id") + op.alter_column("vendor_email_settings", "vendor_id", new_column_name="store_id") + op.alter_column("vendor_letzshop_credentials", "vendor_id", new_column_name="store_id") + op.alter_column("vendor_onboarding", "vendor_id", new_column_name="store_id") + op.alter_column("vendor_invoice_settings", "vendor_id", new_column_name="store_id") + op.alter_column("staff_pins", "vendor_id", new_column_name="store_id") + op.alter_column("loyalty_cards", "enrolled_at_vendor_id", new_column_name="enrolled_at_store_id") + op.alter_column("loyalty_transactions", "vendor_id", new_column_name="store_id") + + # Columns that may not exist yet (defined in models but not yet migrated) + if _col_exists("letzshop_fulfillment_queue", "claimed_by_vendor_id"): + op.alter_column("letzshop_fulfillment_queue", "claimed_by_vendor_id", new_column_name="claimed_by_store_id") + if _col_exists("admin_audit_logs", "vendor_id"): + op.alter_column("admin_audit_logs", "vendor_id", new_column_name="store_id") + if _col_exists("messages", "vendor_id"): + op.alter_column("messages", "vendor_id", new_column_name="store_id") + if _col_exists("conversations", "vendor_id"): + op.alter_column("conversations", "vendor_id", new_column_name="store_id") + if _table_exists("emails") and _col_exists("emails", "vendor_id"): + op.alter_column("emails", "vendor_id", new_column_name="store_id") + if _table_exists("carts") and _col_exists("carts", "vendor_id"): + op.alter_column("carts", "vendor_id", new_column_name="store_id") + + # --- Other vendor-prefixed columns --- + op.alter_column("vendors", "vendor_code", new_column_name="store_code") + op.alter_column("vendors", "letzshop_vendor_id", new_column_name="letzshop_store_id") + op.alter_column("vendors", "letzshop_vendor_slug", new_column_name="letzshop_store_slug") + op.alter_column("marketplace_products", "vendor_name", new_column_name="store_name") + + # ====================================================================== + # STEP 2: Rename parent tables + # ====================================================================== + op.rename_table("companies", "merchants") + op.rename_table("vendors", "stores") + + # ====================================================================== + # STEP 3: Rename vendor-prefixed child tables + # ====================================================================== + op.rename_table("vendor_users", "store_users") + op.rename_table("vendor_domains", "store_domains") + op.rename_table("vendor_platforms", "store_platforms") + op.rename_table("vendor_themes", "store_themes") + op.rename_table("vendor_email_templates", "store_email_templates") + op.rename_table("vendor_email_settings", "store_email_settings") + op.rename_table("vendor_addons", "store_addons") + op.rename_table("vendor_subscriptions", "store_subscriptions") + op.rename_table("vendor_letzshop_credentials", "store_letzshop_credentials") + op.rename_table("vendor_onboarding", "store_onboarding") + op.rename_table("vendor_invoice_settings", "store_invoice_settings") + op.rename_table("company_loyalty_settings", "merchant_loyalty_settings") + op.rename_table("letzshop_vendor_cache", "letzshop_store_cache") + + +def downgrade() -> None: + """Revert all Merchant/Store references back to Company/Vendor.""" + + # STEP 1: Revert table renames + op.rename_table("letzshop_store_cache", "letzshop_vendor_cache") + op.rename_table("merchant_loyalty_settings", "company_loyalty_settings") + op.rename_table("store_invoice_settings", "vendor_invoice_settings") + op.rename_table("store_onboarding", "vendor_onboarding") + op.rename_table("store_letzshop_credentials", "vendor_letzshop_credentials") + op.rename_table("store_subscriptions", "vendor_subscriptions") + op.rename_table("store_addons", "vendor_addons") + op.rename_table("store_email_settings", "vendor_email_settings") + op.rename_table("store_email_templates", "vendor_email_templates") + op.rename_table("store_themes", "vendor_themes") + op.rename_table("store_platforms", "vendor_platforms") + op.rename_table("store_domains", "vendor_domains") + op.rename_table("store_users", "vendor_users") + op.rename_table("stores", "vendors") + op.rename_table("merchants", "companies") + + # STEP 2: Revert column renames + op.alter_column("vendors", "store_code", new_column_name="vendor_code") + op.alter_column("vendors", "letzshop_store_id", new_column_name="letzshop_vendor_id") + op.alter_column("vendors", "letzshop_store_slug", new_column_name="letzshop_vendor_slug") + op.alter_column("marketplace_products", "store_name", new_column_name="vendor_name") + + op.alter_column("vendors", "merchant_id", new_column_name="company_id") + op.alter_column("loyalty_programs", "merchant_id", new_column_name="company_id") + op.alter_column("loyalty_cards", "merchant_id", new_column_name="company_id") + op.alter_column("loyalty_transactions", "merchant_id", new_column_name="company_id") + op.alter_column("company_loyalty_settings", "merchant_id", new_column_name="company_id") + op.alter_column("staff_pins", "merchant_id", new_column_name="company_id") + + op.alter_column("products", "store_id", new_column_name="vendor_id") + op.alter_column("customers", "store_id", new_column_name="vendor_id") + op.alter_column("customer_addresses", "store_id", new_column_name="vendor_id") + op.alter_column("orders", "store_id", new_column_name="vendor_id") + op.alter_column("order_item_exceptions", "store_id", new_column_name="vendor_id") + op.alter_column("invoices", "store_id", new_column_name="vendor_id") + op.alter_column("inventory", "store_id", new_column_name="vendor_id") + op.alter_column("inventory_transactions", "store_id", new_column_name="vendor_id") + op.alter_column("marketplace_import_jobs", "store_id", new_column_name="vendor_id") + op.alter_column("letzshop_fulfillment_queue", "store_id", new_column_name="vendor_id") + op.alter_column("letzshop_sync_logs", "store_id", new_column_name="vendor_id") + op.alter_column("letzshop_historical_import_jobs", "store_id", new_column_name="vendor_id") + op.alter_column("vendor_users", "store_id", new_column_name="vendor_id") + op.alter_column("roles", "store_id", new_column_name="vendor_id") + op.alter_column("vendor_domains", "store_id", new_column_name="vendor_id") + op.alter_column("vendor_platforms", "store_id", new_column_name="vendor_id") + op.alter_column("vendor_addons", "store_id", new_column_name="vendor_id") + op.alter_column("vendor_subscriptions", "store_id", new_column_name="vendor_id") + op.alter_column("billing_history", "store_id", new_column_name="vendor_id") + op.alter_column("content_pages", "store_id", new_column_name="vendor_id") + op.alter_column("vendor_themes", "store_id", new_column_name="vendor_id") + op.alter_column("media_files", "store_id", new_column_name="vendor_id") + op.alter_column("vendor_email_templates", "store_id", new_column_name="vendor_id") + op.alter_column("vendor_email_settings", "store_id", new_column_name="vendor_id") + op.alter_column("vendor_letzshop_credentials", "store_id", new_column_name="vendor_id") + op.alter_column("vendor_onboarding", "store_id", new_column_name="vendor_id") + op.alter_column("vendor_invoice_settings", "store_id", new_column_name="vendor_id") + op.alter_column("staff_pins", "store_id", new_column_name="vendor_id") + op.alter_column("loyalty_cards", "enrolled_at_store_id", new_column_name="enrolled_at_vendor_id") + op.alter_column("loyalty_transactions", "store_id", new_column_name="vendor_id") + + # Conditional columns + if _col_exists("letzshop_fulfillment_queue", "claimed_by_store_id"): + op.alter_column("letzshop_fulfillment_queue", "claimed_by_store_id", new_column_name="claimed_by_vendor_id") + if _col_exists("admin_audit_logs", "store_id"): + op.alter_column("admin_audit_logs", "store_id", new_column_name="vendor_id") + if _col_exists("messages", "store_id"): + op.alter_column("messages", "store_id", new_column_name="vendor_id") + if _col_exists("conversations", "store_id"): + op.alter_column("conversations", "store_id", new_column_name="vendor_id") + if _table_exists("emails") and _col_exists("emails", "store_id"): + op.alter_column("emails", "store_id", new_column_name="vendor_id") + if _table_exists("carts") and _col_exists("carts", "store_id"): + op.alter_column("carts", "store_id", new_column_name="vendor_id") diff --git a/app/api/deps.py b/app/api/deps.py index aff06bd4..ff154396 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -8,17 +8,22 @@ multi-tenant application, implementing dual token storage with proper isolation: ADMIN ROUTES (/admin/*): - Cookie: admin_token (path=/admin) OR Authorization header - Role: admin only -- Blocks: vendors, customers +- Blocks: stores, customers -VENDOR ROUTES (/vendor/*): -- Cookie: vendor_token (path=/vendor) OR Authorization header -- Role: vendor only +STORE ROUTES (/store/*): +- Cookie: store_token (path=/store) OR Authorization header +- Role: store only - Blocks: admins, customers +MERCHANT ROUTES (/merchants/*): +- Cookie: merchant_token (path=/merchants) OR Authorization header +- Role: store (merchant owners are store-role users who own merchants) +- Validates: User owns the merchant via Merchant.owner_user_id + CUSTOMER/SHOP ROUTES (/shop/account/*): - Cookie: customer_token (path=/shop) OR Authorization header - Role: customer only -- Blocks: admins, vendors +- Blocks: admins, stores - Note: Public shop pages (/shop/products, etc.) don't require auth This dual authentication approach supports: @@ -26,9 +31,9 @@ This dual authentication approach supports: - API calls: Use Authorization headers (explicit JavaScript control) The cookie path restrictions prevent cross-context cookie leakage: -- admin_token is NEVER sent to /vendor/* or /shop/* -- vendor_token is NEVER sent to /admin/* or /shop/* -- customer_token is NEVER sent to /admin/* or /vendor/* +- admin_token is NEVER sent to /store/* or /shop/* +- store_token is NEVER sent to /admin/* or /shop/* +- customer_token is NEVER sent to /admin/* or /store/* """ import logging @@ -42,17 +47,17 @@ from app.core.database import get_db from app.modules.tenancy.exceptions import ( AdminRequiredException, InsufficientPermissionsException, - InsufficientVendorPermissionsException, + InsufficientStorePermissionsException, InvalidTokenException, - UnauthorizedVendorAccessException, - VendorNotFoundException, - VendorOwnerOnlyException, + UnauthorizedStoreAccessException, + StoreNotFoundException, + StoreOwnerOnlyException, ) -from app.modules.tenancy.services.vendor_service import vendor_service +from app.modules.tenancy.services.store_service import store_service from middleware.auth import AuthManager from middleware.rate_limiter import RateLimiter from app.modules.tenancy.models import User as UserModel -from app.modules.tenancy.models import Vendor +from app.modules.tenancy.models import Store from models.schema.auth import UserContext from app.modules.enums import FrontendType @@ -123,7 +128,7 @@ def _get_user_model(user_context: UserContext, db: Session) -> UserModel: Get User database model from UserContext. Used internally by permission-checking functions that need - access to User model methods like has_vendor_permission(). + access to User model methods like has_store_permission(). Args: user_context: UserContext schema instance @@ -140,12 +145,12 @@ def _get_user_model(user_context: UserContext, db: Session) -> UserModel: raise InvalidTokenException("User not found") # Copy token attributes from context to model for compatibility - if user_context.token_vendor_id: - user.token_vendor_id = user_context.token_vendor_id - if user_context.token_vendor_code: - user.token_vendor_code = user_context.token_vendor_code - if user_context.token_vendor_role: - user.token_vendor_role = user_context.token_vendor_role + if user_context.token_store_id: + user.token_store_id = user_context.token_store_id + if user_context.token_store_code: + user.token_store_code = user_context.token_store_code + if user_context.token_store_role: + user.token_store_role = user_context.token_store_role return user @@ -201,7 +206,7 @@ def get_current_admin_from_cookie_or_header( ) raise AdminRequiredException("Admin privileges required") - return UserContext.from_user(user, include_vendor_context=False) + return UserContext.from_user(user, include_store_context=False) def get_current_admin_api( @@ -234,7 +239,7 @@ def get_current_admin_api( logger.warning(f"Non-admin user {user.username} attempted admin API") raise AdminRequiredException("Admin privileges required") - return UserContext.from_user(user, include_vendor_context=False) + return UserContext.from_user(user, include_store_context=False) # ============================================================================ @@ -315,8 +320,8 @@ def require_platform_access(platform_id: int): Platform admins can only access their assigned platforms. Usage: - @router.get("/platforms/{platform_id}/vendors") - def list_vendors( + @router.get("/platforms/{platform_id}/stores") + def list_stores( platform_id: int, admin: UserContext = Depends(require_platform_access(platform_id)) ): @@ -399,7 +404,7 @@ def get_admin_with_platform_context( # Super admins bypass platform context if user.is_super_admin: - return UserContext.from_user(user, include_vendor_context=False) + return UserContext.from_user(user, include_store_context=False) # Platform admins need platform_id in token if not hasattr(user, "token_platform_id"): @@ -422,7 +427,7 @@ def get_admin_with_platform_context( platform = db.query(Platform).filter(Platform.id == platform_id).first() request.state.admin_platform = platform - return UserContext.from_user(user, include_vendor_context=False) + return UserContext.from_user(user, include_store_context=False) # ============================================================================ @@ -443,13 +448,13 @@ def require_module_access(module_code: str, frontend_type: FrontendType): dependencies=[Depends(require_module_access("messaging", FrontendType.ADMIN))] ) - vendor_router = APIRouter( - dependencies=[Depends(require_module_access("billing", FrontendType.VENDOR))] + store_router = APIRouter( + dependencies=[Depends(require_module_access("billing", FrontendType.STORE))] ) Args: module_code: Module code to check (e.g., "billing", "marketplace") - frontend_type: Frontend type (ADMIN or VENDOR). Required to determine + frontend_type: Frontend type (ADMIN or STORE). Required to determine which authentication method to use. Returns: @@ -461,7 +466,8 @@ def require_module_access(module_code: str, frontend_type: FrontendType): request: Request, credentials: HTTPAuthorizationCredentials | None = Depends(security), admin_token: str | None = Cookie(None), - vendor_token: str | None = Cookie(None), + store_token: str | None = Cookie(None), + merchant_token: str | None = Cookie(None), db: Session = Depends(get_db), ) -> UserContext: user_context = None @@ -486,16 +492,27 @@ def require_module_access(module_code: str, frontend_type: FrontendType): except Exception: pass - # Handle vendor request - if not user_context and frontend_type == FrontendType.VENDOR: + # Handle store request + if not user_context and frontend_type == FrontendType.STORE: try: - user_context = get_current_vendor_from_cookie_or_header( - request, credentials, vendor_token, db + user_context = get_current_store_from_cookie_or_header( + request, credentials, store_token, db ) - # Get platform from vendor context - vendor = getattr(request.state, "vendor", None) - if vendor and hasattr(vendor, "platform_id") and vendor.platform_id: - platform_id = vendor.platform_id + # Get platform from store context + store = getattr(request.state, "store", None) + if store and hasattr(store, "platform_id") and store.platform_id: + platform_id = store.platform_id + except Exception: + pass + + # Handle merchant request + if not user_context and frontend_type == FrontendType.MERCHANT: + try: + user_context = get_current_merchant_from_cookie_or_header( + request, credentials, merchant_token, db + ) + # Merchant portal is platform-agnostic; module checks not enforced + return user_context except Exception: pass @@ -550,7 +567,7 @@ def require_menu_access(menu_item_id: str, frontend_type: "FrontendType"): Args: menu_item_id: Menu item identifier from registry - frontend_type: Which frontend (ADMIN or VENDOR) + frontend_type: Which frontend (ADMIN or STORE) Returns: Dependency function that validates menu access and returns User @@ -564,7 +581,7 @@ def require_menu_access(menu_item_id: str, frontend_type: "FrontendType"): request: Request, credentials: HTTPAuthorizationCredentials | None = Depends(security), admin_token: str | None = Cookie(None), - vendor_token: str | None = Cookie(None), + store_token: str | None = Cookie(None), db: Session = Depends(get_db), ) -> UserContext: # Get current user based on frontend type @@ -589,18 +606,18 @@ def require_menu_access(menu_item_id: str, frontend_type: "FrontendType"): return user_context user_id = None - elif frontend_type == FT.VENDOR: - user_context = get_current_vendor_from_cookie_or_header( - request, credentials, vendor_token, db + elif frontend_type == FT.STORE: + user_context = get_current_store_from_cookie_or_header( + request, credentials, store_token, db ) - # Vendor: get platform from vendor's platform association - vendor = getattr(request.state, "vendor", None) - if vendor and hasattr(vendor, "platform_id") and vendor.platform_id: - platform_id = vendor.platform_id + # Store: get platform from store's platform association + store = getattr(request.state, "store", None) + if store and hasattr(store, "platform_id") and store.platform_id: + platform_id = store.platform_id else: - # No platform context for vendor - allow access - # This handles edge cases where vendor doesn't have platform + # No platform context for store - allow access + # This handles edge cases where store doesn't have platform return user_context user_id = None @@ -642,128 +659,336 @@ def require_menu_access(menu_item_id: str, frontend_type: "FrontendType"): # ============================================================================ -# VENDOR AUTHENTICATION +# STORE AUTHENTICATION # ============================================================================ -def get_current_vendor_from_cookie_or_header( +def get_current_store_from_cookie_or_header( request: Request, credentials: HTTPAuthorizationCredentials | None = Depends(security), - vendor_token: str | None = Cookie(None), + store_token: str | None = Cookie(None), db: Session = Depends(get_db), ) -> UserContext: """ - Get current vendor user from vendor_token cookie or Authorization header. + Get current store user from store_token cookie or Authorization header. - Used for vendor HTML pages (/vendor/*) that need cookie-based auth. + Used for store HTML pages (/store/*) that need cookie-based auth. Priority: 1. Authorization header (API calls) - 2. vendor_token cookie (page navigation) + 2. store_token cookie (page navigation) Args: request: FastAPI request credentials: Optional Bearer token from header - vendor_token: Optional token from vendor_token cookie + store_token: Optional token from store_token cookie db: Database session Returns: - UserContext: Authenticated vendor user context + UserContext: Authenticated store user context Raises: InvalidTokenException: If no token or invalid token - InsufficientPermissionsException: If user is not vendor or is admin + InsufficientPermissionsException: If user is not store or is admin """ token, source = _get_token_from_request( - credentials, vendor_token, "vendor_token", str(request.url.path) + credentials, store_token, "store_token", str(request.url.path) ) if not token: - logger.warning(f"Vendor auth failed: No token for {request.url.path}") - raise InvalidTokenException("Vendor authentication required") + logger.warning(f"Store auth failed: No token for {request.url.path}") + raise InvalidTokenException("Store authentication required") # Validate token and get user user = _validate_user_token(token, db) - # CRITICAL: Block admins from vendor routes + # CRITICAL: Block admins from store routes if user.role == "admin": logger.warning( - f"Admin user {user.username} attempted vendor route: {request.url.path}" + f"Admin user {user.username} attempted store route: {request.url.path}" ) raise InsufficientPermissionsException( - "Vendor access only - admins cannot use vendor portal" + "Store access only - admins cannot use store portal" ) - # Verify user is vendor - if user.role != "vendor": + # Verify user is store + if user.role != "store": logger.warning( - f"Non-vendor user {user.username} attempted vendor route: {request.url.path}" + f"Non-store user {user.username} attempted store route: {request.url.path}" ) - raise InsufficientPermissionsException("Vendor privileges required") + raise InsufficientPermissionsException("Store privileges required") return UserContext.from_user(user) -def get_current_vendor_api( +def get_current_store_api( credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db), ) -> UserContext: """ - Get current vendor user from Authorization header ONLY. + Get current store user from Authorization header ONLY. - Used for vendor API endpoints that should not accept cookies. + Used for store API endpoints that should not accept cookies. Validates that: - 1. Token contains vendor context (token_vendor_id) - 2. User still has access to the vendor specified in the token + 1. Token contains store context (token_store_id) + 2. User still has access to the store specified in the token Args: credentials: Bearer token from Authorization header db: Database session Returns: - UserContext: Authenticated vendor user context (with token_vendor_id, token_vendor_code, token_vendor_role) + UserContext: Authenticated store user context (with token_store_id, token_store_code, token_store_role) Raises: - InvalidTokenException: If no token, invalid token, or missing vendor context - InsufficientPermissionsException: If user is not vendor or lost access to vendor + InvalidTokenException: If no token, invalid token, or missing store context + InsufficientPermissionsException: If user is not store or lost access to store """ if not credentials: raise InvalidTokenException("Authorization header required for API calls") user = _validate_user_token(credentials.credentials, db) - # Block admins from vendor API + # Block admins from store API if user.role == "admin": - logger.warning(f"Admin user {user.username} attempted vendor API") - raise InsufficientPermissionsException("Vendor access only") + logger.warning(f"Admin user {user.username} attempted store API") + raise InsufficientPermissionsException("Store access only") - if user.role != "vendor": - logger.warning(f"Non-vendor user {user.username} attempted vendor API") - raise InsufficientPermissionsException("Vendor privileges required") + if user.role != "store": + logger.warning(f"Non-store user {user.username} attempted store API") + raise InsufficientPermissionsException("Store privileges required") - # Require vendor context in token - if not hasattr(user, "token_vendor_id"): + # Require store context in token + if not hasattr(user, "token_store_id"): raise InvalidTokenException( - "Token missing vendor information. Please login again." + "Token missing store information. Please login again." ) - vendor_id = user.token_vendor_id + store_id = user.token_store_id - # Verify user still has access to this vendor - if not user.is_member_of(vendor_id): - logger.warning(f"User {user.username} lost access to vendor_id={vendor_id}") + # Verify user still has access to this store + if not user.is_member_of(store_id): + logger.warning(f"User {user.username} lost access to store_id={store_id}") raise InsufficientPermissionsException( - "Access to vendor has been revoked. Please login again." + "Access to store has been revoked. Please login again." ) logger.debug( - f"Vendor API access: user={user.username}, vendor_id={vendor_id}, " - f"vendor_code={getattr(user, 'token_vendor_code', 'N/A')}" + f"Store API access: user={user.username}, store_id={store_id}, " + f"store_code={getattr(user, 'token_store_code', 'N/A')}" ) return UserContext.from_user(user) +# ============================================================================ +# MERCHANT AUTHENTICATION (Billing Portal) +# ============================================================================ + + +def get_current_merchant_from_cookie_or_header( + request: Request, + credentials: HTTPAuthorizationCredentials | None = Depends(security), + merchant_token: str | None = Cookie(None), + db: Session = Depends(get_db), +) -> UserContext: + """ + Get current merchant user from merchant_token cookie or Authorization header. + + Used for merchant portal HTML pages (/merchants/*) that need cookie-based auth. + Validates that the user owns at least one merchant. + + Priority: + 1. Authorization header (API calls) + 2. merchant_token cookie (page navigation) + + Args: + request: FastAPI request + credentials: Optional Bearer token from header + merchant_token: Optional token from merchant_token cookie + db: Database session + + Returns: + UserContext: Authenticated merchant owner user context + + Raises: + InvalidTokenException: If no token or invalid token + InsufficientPermissionsException: If user doesn't own any merchants + """ + token, source = _get_token_from_request( + credentials, merchant_token, "merchant_token", str(request.url.path) + ) + + if not token: + logger.warning(f"Merchant auth failed: No token for {request.url.path}") + raise InvalidTokenException("Merchant authentication required") + + # Validate token and get user + user = _validate_user_token(token, db) + + # Verify user owns at least one merchant + from app.modules.tenancy.models import Merchant + merchant_count = ( + db.query(Merchant) + .filter( + Merchant.owner_user_id == user.id, + Merchant.is_active == True, # noqa: E712 + ) + .count() + ) + + if merchant_count == 0: + logger.warning( + f"User {user.username} attempted merchant route without owning any merchants: " + f"{request.url.path}" + ) + raise InsufficientPermissionsException( + "Merchant owner privileges required" + ) + + return UserContext.from_user(user) + + +def get_current_merchant_api( + request: Request, + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db), +) -> UserContext: + """ + Get current merchant user from Authorization header ONLY. + + Used for merchant API endpoints that should not accept cookies. + This prevents CSRF attacks on API endpoints. + + Args: + request: FastAPI request + credentials: Bearer token from Authorization header + db: Database session + + Returns: + UserContext: Authenticated merchant owner user context + + Raises: + InvalidTokenException: If no token or invalid token + InsufficientPermissionsException: If user doesn't own any merchants + """ + if not credentials: + raise InvalidTokenException("Authorization header required for API calls") + + user = _validate_user_token(credentials.credentials, db) + + # Verify user owns at least one merchant + from app.modules.tenancy.models import Merchant + merchant_count = ( + db.query(Merchant) + .filter( + Merchant.owner_user_id == user.id, + Merchant.is_active == True, # noqa: E712 + ) + .count() + ) + + if merchant_count == 0: + logger.warning(f"User {user.username} attempted merchant API without owning any merchants") + raise InsufficientPermissionsException( + "Merchant owner privileges required" + ) + + return UserContext.from_user(user) + + +def get_current_merchant_optional( + request: Request, + credentials: HTTPAuthorizationCredentials | None = Depends(security), + merchant_token: str | None = Cookie(None), + db: Session = Depends(get_db), +) -> UserContext | None: + """ + Get current merchant user, returning None if not authenticated. + + Used for login pages to check if user is already authenticated. + + Returns: + UserContext: Authenticated merchant owner if valid token exists + None: If no token, invalid token, or user doesn't own merchants + """ + token, source = _get_token_from_request( + credentials, merchant_token, "merchant_token", str(request.url.path) + ) + + if not token: + return None + + try: + user = _validate_user_token(token, db) + + # Verify user owns at least one merchant + from app.modules.tenancy.models import Merchant + merchant_count = ( + db.query(Merchant) + .filter( + Merchant.owner_user_id == user.id, + Merchant.is_active == True, # noqa: E712 + ) + .count() + ) + + if merchant_count > 0: + return UserContext.from_user(user) + except Exception: + pass + + return None + + +def require_merchant_owner(merchant_id: int): + """ + Dependency factory to require ownership of a specific merchant. + + Usage: + @router.get("/merchants/{merchant_id}/subscriptions") + def list_subscriptions( + merchant_id: int, + user: UserContext = Depends(require_merchant_owner(merchant_id)) + ): + ... + """ + + def _check_merchant_ownership( + request: Request, + credentials: HTTPAuthorizationCredentials | None = Depends(security), + merchant_token: str | None = Cookie(None), + db: Session = Depends(get_db), + ) -> UserContext: + user_context = get_current_merchant_from_cookie_or_header( + request, credentials, merchant_token, db + ) + + # Verify user owns this specific merchant + from app.modules.tenancy.models import Merchant + merchant = ( + db.query(Merchant) + .filter( + Merchant.id == merchant_id, + Merchant.owner_user_id == user_context.id, + Merchant.is_active == True, # noqa: E712 + ) + .first() + ) + + if not merchant: + raise InsufficientPermissionsException( + f"You do not own merchant {merchant_id}" + ) + + # Store merchant in request state for endpoint use + request.state.merchant = merchant + + return user_context + + return _check_merchant_ownership + + # ============================================================================ # CUSTOMER AUTHENTICATION (SHOP) # ============================================================================ @@ -777,11 +1002,11 @@ def _validate_customer_token(token: str, request: Request, db: Session): 1. Token signature and expiration 2. Token type is "customer" 3. Customer exists and is active - 4. Token vendor_id matches request vendor (URL-based) + 4. Token store_id matches request store (URL-based) Args: token: JWT token string - request: FastAPI request (for vendor context) + request: FastAPI request (for store context) db: Database session Returns: @@ -789,7 +1014,7 @@ def _validate_customer_token(token: str, request: Request, db: Session): Raises: InvalidTokenException: If token is invalid or expired - UnauthorizedVendorAccessException: If vendor mismatch + UnauthorizedStoreAccessException: If store mismatch """ from datetime import datetime @@ -822,8 +1047,8 @@ def _validate_customer_token(token: str, request: Request, db: Session): logger.warning(f"Expired customer token for customer_id={customer_id}") raise InvalidTokenException("Token has expired") - # Get vendor_id from token for validation - token_vendor_id = payload.get("vendor_id") + # Get store_id from token for validation + token_store_id = payload.get("store_id") except JWTError as e: logger.warning(f"JWT decode error: {str(e)}") @@ -840,17 +1065,17 @@ def _validate_customer_token(token: str, request: Request, db: Session): logger.warning(f"Inactive customer attempted access: {customer.email}") raise InvalidTokenException("Customer account is inactive") - # Validate vendor context matches token - # This prevents using a customer token from vendor A on vendor B's shop - request_vendor = getattr(request.state, "vendor", None) - if request_vendor and token_vendor_id: - if request_vendor.id != token_vendor_id: + # Validate store context matches token + # This prevents using a customer token from store A on store B's shop + request_store = getattr(request.state, "store", None) + if request_store and token_store_id: + if request_store.id != token_store_id: logger.warning( - f"Customer {customer.email} token vendor mismatch: " - f"token={token_vendor_id}, request={request_vendor.id}" + f"Customer {customer.email} token store mismatch: " + f"token={token_store_id}, request={request_store.id}" ) - raise UnauthorizedVendorAccessException( - vendor_code=request_vendor.vendor_code, + raise UnauthorizedStoreAccessException( + store_code=request_store.store_code, user_id=customer.id, ) @@ -872,7 +1097,7 @@ def get_current_customer_from_cookie_or_header( Used for shop account HTML pages (/shop/account/*) that need cookie-based auth. Note: Public shop pages (/shop/products, etc.) don't use this dependency. - Validates that token vendor_id matches request vendor (URL-based detection). + Validates that token store_id matches request store (URL-based detection). Priority: 1. Authorization header (API calls) @@ -889,7 +1114,7 @@ def get_current_customer_from_cookie_or_header( Raises: InvalidTokenException: If no token or invalid token - UnauthorizedVendorAccessException: If vendor mismatch + UnauthorizedStoreAccessException: If store mismatch """ token, source = _get_token_from_request( credentials, customer_token, "customer_token", str(request.url.path) @@ -911,10 +1136,10 @@ def get_current_customer_api( Get current customer from Authorization header ONLY. Used for shop API endpoints that should not accept cookies. - Validates that token vendor_id matches request vendor (URL-based detection). + Validates that token store_id matches request store (URL-based detection). Args: - request: FastAPI request (for vendor context) + request: FastAPI request (for store context) credentials: Bearer token from Authorization header db: Database session @@ -923,7 +1148,7 @@ def get_current_customer_api( Raises: InvalidTokenException: If no token or invalid token - UnauthorizedVendorAccessException: If vendor mismatch + UnauthorizedStoreAccessException: If store mismatch """ if not credentials: raise InvalidTokenException("Authorization header required for API calls") @@ -964,56 +1189,56 @@ def get_current_user( # ============================================================================ -# VENDOR OWNERSHIP VERIFICATION +# STORE OWNERSHIP VERIFICATION # ============================================================================ -def get_user_vendor( - vendor_code: str, - current_user: UserContext = Depends(get_current_vendor_from_cookie_or_header), +def get_user_store( + store_code: str, + current_user: UserContext = Depends(get_current_store_from_cookie_or_header), db: Session = Depends(get_db), -) -> Vendor: +) -> Store: """ - Get vendor and verify user ownership/membership. + Get store and verify user ownership/membership. - Ensures the current user has access to the specified vendor. - - Vendor owners can access their own vendor - - Team members can access their vendor + Ensures the current user has access to the specified store. + - Store owners can access their own store + - Team members can access their store - Admins are BLOCKED (use admin routes instead) Args: - vendor_code: Vendor code to look up - current_user: Current authenticated vendor user + store_code: Store code to look up + current_user: Current authenticated store user db: Database session Returns: - Vendor: Vendor object if user has access + Store: Store object if user has access Raises: - VendorNotFoundException: If vendor doesn't exist - UnauthorizedVendorAccessException: If user doesn't have access + StoreNotFoundException: If store doesn't exist + UnauthorizedStoreAccessException: If user doesn't have access """ from sqlalchemy.orm import joinedload - vendor = ( - db.query(Vendor) - .options(joinedload(Vendor.company)) - .filter(Vendor.vendor_code == vendor_code.upper()) + store = ( + db.query(Store) + .options(joinedload(Store.merchant)) + .filter(Store.store_code == store_code.upper()) .first() ) - if not vendor: - raise VendorNotFoundException(vendor_code) + if not store: + raise StoreNotFoundException(store_code) - # Check if user owns this vendor (via company ownership) - if vendor.company and vendor.company.owner_user_id == current_user.id: - return vendor + # Check if user owns this store (via merchant ownership) + if store.merchant and store.merchant.owner_user_id == current_user.id: + return store # Check if user is team member - # TODO: Add team member check when VendorUser relationship is set up + # TODO: Add team member check when StoreUser relationship is set up - # User doesn't have access to this vendor - raise UnauthorizedVendorAccessException(vendor_code, current_user.id) + # User doesn't have access to this store + raise UnauthorizedStoreAccessException(store_code, current_user.id) # ============================================================================ @@ -1021,48 +1246,48 @@ def get_user_vendor( # ============================================================================ -def require_vendor_permission(permission: str): +def require_store_permission(permission: str): """ - Dependency factory to require a specific vendor permission. + Dependency factory to require a specific store permission. - Uses token_vendor_id from JWT token (authenticated vendor API pattern). - The vendor object is loaded and stored in request.state.vendor for endpoint use. + Uses token_store_id from JWT token (authenticated store API pattern). + The store object is loaded and stored in request.state.store for endpoint use. Usage: @router.get("/products") def list_products( request: Request, - user: UserContext = Depends(require_vendor_permission("products.view")) + user: UserContext = Depends(require_store_permission("products.view")) ): - vendor = request.state.vendor # Vendor is set by this dependency + store = request.state.store # Store is set by this dependency ... """ def permission_checker( request: Request, db: Session = Depends(get_db), - current_user: UserContext = Depends(get_current_vendor_from_cookie_or_header), + current_user: UserContext = Depends(get_current_store_from_cookie_or_header), ) -> UserContext: - # Get vendor ID from JWT token - if not current_user.token_vendor_id: + # Get store ID from JWT token + if not current_user.token_store_id: raise InvalidTokenException( - "Token missing vendor information. Please login again." + "Token missing store information. Please login again." ) - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id - # Load vendor from database (raises VendorNotFoundException if not found) - vendor = vendor_service.get_vendor_by_id(db, vendor_id) + # Load store from database (raises StoreNotFoundException if not found) + store = store_service.get_store_by_id(db, store_id) - # Store vendor in request state for endpoint use - request.state.vendor = vendor + # Store store in request state for endpoint use + request.state.store = store # Check if user has permission (need User model for this) user_model = _get_user_model(current_user, db) - if not user_model.has_vendor_permission(vendor.id, permission): - raise InsufficientVendorPermissionsException( + if not user_model.has_store_permission(store.id, permission): + raise InsufficientStorePermissionsException( required_permission=permission, - vendor_code=vendor.vendor_code, + store_code=store.store_code, ) return current_user @@ -1070,100 +1295,100 @@ def require_vendor_permission(permission: str): return permission_checker -def require_vendor_owner( +def require_store_owner( request: Request, db: Session = Depends(get_db), - current_user: UserContext = Depends(get_current_vendor_from_cookie_or_header), + current_user: UserContext = Depends(get_current_store_from_cookie_or_header), ) -> UserContext: """ - Dependency to require vendor owner role. + Dependency to require store owner role. - Uses token_vendor_id from JWT token (authenticated vendor API pattern). - The vendor object is loaded and stored in request.state.vendor for endpoint use. + Uses token_store_id from JWT token (authenticated store API pattern). + The store object is loaded and stored in request.state.store for endpoint use. Usage: @router.delete("/team/{user_id}") def remove_team_member( request: Request, - user: UserContext = Depends(require_vendor_owner) + user: UserContext = Depends(require_store_owner) ): - vendor = request.state.vendor # Vendor is set by this dependency + store = request.state.store # Store is set by this dependency ... """ - # Get vendor ID from JWT token - if not current_user.token_vendor_id: + # Get store ID from JWT token + if not current_user.token_store_id: raise InvalidTokenException( - "Token missing vendor information. Please login again." + "Token missing store information. Please login again." ) - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id - # Load vendor from database (raises VendorNotFoundException if not found) - vendor = vendor_service.get_vendor_by_id(db, vendor_id) + # Load store from database (raises StoreNotFoundException if not found) + store = store_service.get_store_by_id(db, store_id) - # Store vendor in request state for endpoint use - request.state.vendor = vendor + # Store store in request state for endpoint use + request.state.store = store # Need User model for is_owner_of check user_model = _get_user_model(current_user, db) - if not user_model.is_owner_of(vendor.id): - raise VendorOwnerOnlyException( + if not user_model.is_owner_of(store.id): + raise StoreOwnerOnlyException( operation="team management", - vendor_code=vendor.vendor_code, + store_code=store.store_code, ) return current_user -def require_any_vendor_permission(*permissions: str): +def require_any_store_permission(*permissions: str): """ Dependency factory to require ANY of the specified permissions. - Uses token_vendor_id from JWT token (authenticated vendor API pattern). - The vendor object is loaded and stored in request.state.vendor for endpoint use. + Uses token_store_id from JWT token (authenticated store API pattern). + The store object is loaded and stored in request.state.store for endpoint use. Usage: @router.get("/dashboard") def dashboard( request: Request, - user: UserContext = Depends(require_any_vendor_permission( + user: UserContext = Depends(require_any_store_permission( "dashboard.view", "reports.view" )) ): - vendor = request.state.vendor # Vendor is set by this dependency + store = request.state.store # Store is set by this dependency ... """ def permission_checker( request: Request, db: Session = Depends(get_db), - current_user: UserContext = Depends(get_current_vendor_from_cookie_or_header), + current_user: UserContext = Depends(get_current_store_from_cookie_or_header), ) -> UserContext: - # Get vendor ID from JWT token - if not current_user.token_vendor_id: + # Get store ID from JWT token + if not current_user.token_store_id: raise InvalidTokenException( - "Token missing vendor information. Please login again." + "Token missing store information. Please login again." ) - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id - # Load vendor from database (raises VendorNotFoundException if not found) - vendor = vendor_service.get_vendor_by_id(db, vendor_id) + # Load store from database (raises StoreNotFoundException if not found) + store = store_service.get_store_by_id(db, store_id) - # Store vendor in request state for endpoint use - request.state.vendor = vendor + # Store store in request state for endpoint use + request.state.store = store # Check if user has ANY of the required permissions (need User model) user_model = _get_user_model(current_user, db) has_permission = any( - user_model.has_vendor_permission(vendor.id, perm) for perm in permissions + user_model.has_store_permission(store.id, perm) for perm in permissions ) if not has_permission: - raise InsufficientVendorPermissionsException( + raise InsufficientStorePermissionsException( required_permission=f"Any of: {', '.join(permissions)}", - vendor_code=vendor.vendor_code, + store_code=store.store_code, ) return current_user @@ -1171,57 +1396,57 @@ def require_any_vendor_permission(*permissions: str): return permission_checker -def require_all_vendor_permissions(*permissions: str): +def require_all_store_permissions(*permissions: str): """ Dependency factory to require ALL of the specified permissions. - Uses token_vendor_id from JWT token (authenticated vendor API pattern). - The vendor object is loaded and stored in request.state.vendor for endpoint use. + Uses token_store_id from JWT token (authenticated store API pattern). + The store object is loaded and stored in request.state.store for endpoint use. Usage: @router.post("/products/bulk-delete") def bulk_delete_products( request: Request, - user: UserContext = Depends(require_all_vendor_permissions( + user: UserContext = Depends(require_all_store_permissions( "products.view", "products.delete" )) ): - vendor = request.state.vendor # Vendor is set by this dependency + store = request.state.store # Store is set by this dependency ... """ def permission_checker( request: Request, db: Session = Depends(get_db), - current_user: UserContext = Depends(get_current_vendor_from_cookie_or_header), + current_user: UserContext = Depends(get_current_store_from_cookie_or_header), ) -> UserContext: - # Get vendor ID from JWT token - if not current_user.token_vendor_id: + # Get store ID from JWT token + if not current_user.token_store_id: raise InvalidTokenException( - "Token missing vendor information. Please login again." + "Token missing store information. Please login again." ) - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id - # Load vendor from database (raises VendorNotFoundException if not found) - vendor = vendor_service.get_vendor_by_id(db, vendor_id) + # Load store from database (raises StoreNotFoundException if not found) + store = store_service.get_store_by_id(db, store_id) - # Store vendor in request state for endpoint use - request.state.vendor = vendor + # Store store in request state for endpoint use + request.state.store = store # Check if user has ALL required permissions (need User model) user_model = _get_user_model(current_user, db) missing_permissions = [ perm for perm in permissions - if not user_model.has_vendor_permission(vendor.id, perm) + if not user_model.has_store_permission(store.id, perm) ] if missing_permissions: - raise InsufficientVendorPermissionsException( + raise InsufficientStorePermissionsException( required_permission=f"All of: {', '.join(permissions)}", - vendor_code=vendor.vendor_code, + store_code=store.store_code, ) return current_user @@ -1232,42 +1457,42 @@ def require_all_vendor_permissions(*permissions: str): def get_user_permissions( request: Request, db: Session = Depends(get_db), - current_user: UserContext = Depends(get_current_vendor_from_cookie_or_header), + current_user: UserContext = Depends(get_current_store_from_cookie_or_header), ) -> list: """ - Get all permissions for current user in current vendor. + Get all permissions for current user in current store. - Uses token_vendor_id from JWT token (authenticated vendor API pattern). - Also sets request.state.vendor for endpoint use. + Uses token_store_id from JWT token (authenticated store API pattern). + Also sets request.state.store for endpoint use. - Returns empty list if no vendor context in token. + Returns empty list if no store context in token. """ - # Get vendor ID from JWT token - if not current_user.token_vendor_id: + # Get store ID from JWT token + if not current_user.token_store_id: return [] - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id - # Load vendor from database - vendor = vendor_service.get_vendor_by_id(db, vendor_id) + # Load store from database + store = store_service.get_store_by_id(db, store_id) - # Store vendor in request state for endpoint use - request.state.vendor = vendor + # Store store in request state for endpoint use + request.state.store = store # Need User model for ownership and membership checks user_model = _get_user_model(current_user, db) # If owner, return all permissions - if user_model.is_owner_of(vendor.id): + if user_model.is_owner_of(store.id): from app.modules.tenancy.services.permission_discovery_service import ( permission_discovery_service, ) return list(permission_discovery_service.get_all_permission_ids()) - # Get permissions from vendor membership - for vm in user_model.vendor_memberships: - if vm.vendor_id == vendor.id and vm.is_active: + # Get permissions from store membership + for vm in user_model.store_memberships: + if vm.store_id == store.id and vm.is_active: return vm.get_all_permissions() return [] @@ -1317,7 +1542,7 @@ def get_current_admin_optional( # Verify user is admin if user.role == "admin": - return UserContext.from_user(user, include_vendor_context=False) + return UserContext.from_user(user, include_store_context=False) except Exception: # Invalid token or other error pass @@ -1325,34 +1550,34 @@ def get_current_admin_optional( return None -def get_current_vendor_optional( +def get_current_store_optional( request: Request, credentials: HTTPAuthorizationCredentials | None = Depends(security), - vendor_token: str | None = Cookie(None), + store_token: str | None = Cookie(None), db: Session = Depends(get_db), ) -> UserContext | None: """ - Get current vendor user from vendor_token cookie or Authorization header. + Get current store user from store_token cookie or Authorization header. Returns None instead of raising exceptions if not authenticated. Used for login pages to check if user is already authenticated. Priority: 1. Authorization header (API calls) - 2. vendor_token cookie (page navigation) + 2. store_token cookie (page navigation) Args: request: FastAPI request credentials: Optional Bearer token from header - vendor_token: Optional token from vendor_token cookie + store_token: Optional token from store_token cookie db: Database session Returns: - UserContext: Authenticated vendor user context if valid token exists - None: If no token, invalid token, or user is not vendor + UserContext: Authenticated store user context if valid token exists + None: If no token, invalid token, or user is not store """ token, source = _get_token_from_request( - credentials, vendor_token, "vendor_token", str(request.url.path) + credentials, store_token, "store_token", str(request.url.path) ) if not token: @@ -1362,8 +1587,8 @@ def get_current_vendor_optional( # Validate token and get user user = _validate_user_token(token, db) - # Verify user is vendor - if user.role == "vendor": + # Verify user is store + if user.role == "store": return UserContext.from_user(user) except Exception: # Invalid token or other error @@ -1396,7 +1621,7 @@ def get_current_customer_optional( Returns: CustomerContext: Authenticated customer context if valid token exists - None: If no token, invalid token, or vendor mismatch + None: If no token, invalid token, or store mismatch """ token, source = _get_token_from_request( credentials, customer_token, "customer_token", str(request.url.path) @@ -1406,8 +1631,8 @@ def get_current_customer_optional( return None try: - # Validate customer token (includes vendor validation) + # Validate customer token (includes store validation) return _validate_customer_token(token, request, db) except Exception: - # Invalid token, vendor mismatch, or other error + # Invalid token, store mismatch, or other error return None diff --git a/app/api/main.py b/app/api/main.py index 41bd7708..828992d5 100644 --- a/app/api/main.py +++ b/app/api/main.py @@ -4,13 +4,13 @@ API router configuration for multi-tenant ecommerce platform. This module provides: - API version 1 route aggregation -- Route organization by user type (admin, vendor, storefront) +- Route organization by user type (admin, store, storefront) - Auto-discovery of module routes """ from fastapi import APIRouter -from app.api.v1 import admin, platform, storefront, vendor, webhooks +from app.api.v1 import admin, merchant, platform, storefront, store, webhooks api_router = APIRouter() @@ -22,11 +22,11 @@ api_router = APIRouter() api_router.include_router(admin.router, prefix="/v1/admin", tags=["admin"]) # ============================================================================ -# VENDOR ROUTES (Vendor-scoped operations) -# Prefix: /api/v1/vendor +# STORE ROUTES (Store-scoped operations) +# Prefix: /api/v1/store # ============================================================================ -api_router.include_router(vendor.router, prefix="/v1/vendor", tags=["vendor"]) +api_router.include_router(store.router, prefix="/v1/store", tags=["store"]) # ============================================================================ # STOREFRONT ROUTES (Public customer-facing API) @@ -39,7 +39,7 @@ api_router.include_router(storefront.router, prefix="/v1/storefront", tags=["sto # ============================================================================ # PLATFORM ROUTES (Unauthenticated endpoints) # Prefix: /api/v1/platform -# Includes: /signup, /pricing, /letzshop-vendors, /language +# Includes: /signup, /pricing, /letzshop-stores, /language # ============================================================================ api_router.include_router(platform.router, prefix="/v1/platform", tags=["platform"]) @@ -51,3 +51,11 @@ api_router.include_router(platform.router, prefix="/v1/platform", tags=["platfor # ============================================================================ api_router.include_router(webhooks.router, prefix="/v1/webhooks", tags=["webhooks"]) + +# ============================================================================ +# MERCHANT ROUTES (Merchant billing portal) +# Prefix: /api/v1/merchants +# Includes: /subscriptions, /billing, /stores, /profile +# ============================================================================ + +api_router.include_router(merchant.router, prefix="/v1/merchants", tags=["merchants"]) diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py index 530fa36b..4432d581 100644 --- a/app/api/v1/__init__.py +++ b/app/api/v1/__init__.py @@ -3,6 +3,6 @@ API Version 1 - All endpoints """ -from . import admin, storefront, vendor +from . import admin, merchant, storefront, store -__all__ = ["admin", "vendor", "storefront"] +__all__ = ["admin", "merchant", "store", "storefront"] diff --git a/app/api/v1/admin/__init__.py b/app/api/v1/admin/__init__.py index e15cf15a..21f1e950 100644 --- a/app/api/v1/admin/__init__.py +++ b/app/api/v1/admin/__init__.py @@ -5,7 +5,7 @@ Admin API router aggregation. This module combines auto-discovered module routes for the admin API. All admin routes are now auto-discovered from modules: -- tenancy: auth, admin_users, users, companies, platforms, vendors, vendor_domains, modules, module_config +- tenancy: auth, admin_users, users, merchants, platforms, stores, store_domains, modules, module_config - core: dashboard, settings, menu_config - messaging: messages, notifications, email-templates - monitoring: logs, tasks, tests, code_quality, audit, platform-health @@ -13,8 +13,8 @@ All admin routes are now auto-discovered from modules: - inventory: stock management - orders: order management, fulfillment, exceptions - marketplace: letzshop integration, product sync -- catalog: vendor product catalog -- cms: content-pages, images, media, vendor-themes +- catalog: store product catalog +- cms: content-pages, images, media, store-themes - customers: customer management IMPORTANT: diff --git a/app/api/v1/merchant/__init__.py b/app/api/v1/merchant/__init__.py new file mode 100644 index 00000000..0bffa2e3 --- /dev/null +++ b/app/api/v1/merchant/__init__.py @@ -0,0 +1,47 @@ +# app/api/v1/merchant/__init__.py +""" +Merchant API router aggregation. + +This module combines auto-discovered module routes for the merchant API. + +Merchant routes provide the billing portal for business owners: +- billing: subscriptions, invoices, tier management, checkout +- tenancy: stores list, merchant profile + +IMPORTANT: +- This router is for JSON API endpoints only +- HTML page routes are mounted separately in main.py +- Module routes are auto-discovered from app/modules/{module}/routes/api/merchant.py +""" + +from fastapi import APIRouter + + +# Create merchant router +router = APIRouter() + + +# ============================================================================ +# Auto-discovered Module Routes +# ============================================================================ +# All routes from self-contained modules are auto-discovered and registered. +# Modules provide merchant routes at: routes/api/merchant.py + +from app.modules.routes import get_merchant_api_routes + +for route_info in get_merchant_api_routes(): + # Only pass prefix if custom_prefix is set (router already has internal prefix) + if route_info.custom_prefix: + router.include_router( + route_info.router, + prefix=route_info.custom_prefix, + tags=route_info.tags, + ) + else: + router.include_router( + route_info.router, + tags=route_info.tags, + ) + +# Export the router +__all__ = ["router"] diff --git a/app/api/v1/platform/__init__.py b/app/api/v1/platform/__init__.py index be41bdc4..244561fe 100644 --- a/app/api/v1/platform/__init__.py +++ b/app/api/v1/platform/__init__.py @@ -7,7 +7,7 @@ Includes: Auto-discovers and aggregates platform routes from self-contained modules: - billing: /pricing/* (subscription tiers and add-ons) -- marketplace: /letzshop-vendors/* (vendor lookup for signup) +- marketplace: /letzshop-stores/* (store lookup for signup) - core: /language/* (language preferences) These endpoints serve the marketing homepage, pricing pages, and signup flows. @@ -20,7 +20,7 @@ from app.modules.routes import get_platform_api_routes router = APIRouter() -# Cross-cutting signup flow (spans auth, vendors, billing, payments) +# Cross-cutting signup flow (spans auth, stores, billing, payments) router.include_router(signup.router, tags=["platform-signup"]) # Auto-discover platform routes from modules diff --git a/app/api/v1/platform/signup.py b/app/api/v1/platform/signup.py index a1a021ea..f0d8e657 100644 --- a/app/api/v1/platform/signup.py +++ b/app/api/v1/platform/signup.py @@ -4,7 +4,7 @@ Platform signup API endpoints. Handles the multi-step signup flow: 1. Start signup (select tier) -2. Claim Letzshop vendor (optional) +2. Claim Letzshop store (optional) 3. Create account 4. Setup payment (collect card via SetupIntent) 5. Complete signup (create subscription with trial) @@ -46,20 +46,20 @@ class SignupStartResponse(BaseModel): is_annual: bool -class ClaimVendorRequest(BaseModel): - """Claim Letzshop vendor.""" +class ClaimStoreRequest(BaseModel): + """Claim Letzshop store.""" session_id: str letzshop_slug: str - letzshop_vendor_id: str | None = None + letzshop_store_id: str | None = None -class ClaimVendorResponse(BaseModel): - """Response from vendor claim.""" +class ClaimStoreResponse(BaseModel): + """Response from store claim.""" session_id: str letzshop_slug: str - vendor_name: str | None + store_name: str | None class CreateAccountRequest(BaseModel): @@ -70,7 +70,7 @@ class CreateAccountRequest(BaseModel): password: str first_name: str last_name: str - company_name: str + merchant_name: str phone: str | None = None @@ -79,7 +79,7 @@ class CreateAccountResponse(BaseModel): session_id: str user_id: int - vendor_id: int + store_id: int stripe_customer_id: str @@ -108,8 +108,8 @@ class CompleteSignupResponse(BaseModel): """Response from signup completion.""" success: bool - vendor_code: str - vendor_id: int + store_code: str + store_id: int redirect_url: str trial_ends_at: str access_token: str | None = None # JWT token for automatic login @@ -140,28 +140,28 @@ async def start_signup(request: SignupStartRequest) -> SignupStartResponse: ) -@router.post("/signup/claim-vendor", response_model=ClaimVendorResponse) # public -async def claim_letzshop_vendor( - request: ClaimVendorRequest, +@router.post("/signup/claim-store", response_model=ClaimStoreResponse) # public +async def claim_letzshop_store( + request: ClaimStoreRequest, db: Session = Depends(get_db), -) -> ClaimVendorResponse: +) -> ClaimStoreResponse: """ - Claim a Letzshop vendor. + Claim a Letzshop store. Step 2 (optional): User claims their Letzshop shop. - This pre-fills vendor info during account creation. + This pre-fills store info during account creation. """ - vendor_name = platform_signup_service.claim_vendor( + store_name = platform_signup_service.claim_store( db=db, session_id=request.session_id, letzshop_slug=request.letzshop_slug, - letzshop_vendor_id=request.letzshop_vendor_id, + letzshop_store_id=request.letzshop_store_id, ) - return ClaimVendorResponse( + return ClaimStoreResponse( session_id=request.session_id, letzshop_slug=request.letzshop_slug, - vendor_name=vendor_name, + store_name=store_name, ) @@ -171,10 +171,10 @@ async def create_account( db: Session = Depends(get_db), ) -> CreateAccountResponse: """ - Create user and vendor accounts. + Create user and store accounts. Step 3: User provides account details. - Creates User, Company, Vendor, and Stripe Customer. + Creates User, Merchant, Store, and Stripe Customer. """ result = platform_signup_service.create_account( db=db, @@ -183,14 +183,14 @@ async def create_account( password=request.password, first_name=request.first_name, last_name=request.last_name, - company_name=request.company_name, + merchant_name=request.merchant_name, phone=request.phone, ) return CreateAccountResponse( session_id=request.session_id, user_id=result.user_id, - vendor_id=result.vendor_id, + store_id=result.store_id, stripe_customer_id=result.stripe_customer_id, ) @@ -233,23 +233,23 @@ async def complete_signup( ) # Set HTTP-only cookie for page navigation (same as login does) - # This enables the user to access vendor pages immediately after signup + # This enables the user to access store pages immediately after signup if result.access_token: response.set_cookie( - key="vendor_token", + key="store_token", value=result.access_token, httponly=True, # JavaScript cannot access (XSS protection) secure=should_use_secure_cookies(), # HTTPS only in production/staging samesite="lax", # CSRF protection max_age=3600 * 24, # 24 hours - path="/vendor", # RESTRICTED TO VENDOR ROUTES ONLY + path="/store", # RESTRICTED TO STORE ROUTES ONLY ) - logger.info(f"Set vendor_token cookie for new vendor {result.vendor_code}") + logger.info(f"Set store_token cookie for new store {result.store_code}") return CompleteSignupResponse( success=result.success, - vendor_code=result.vendor_code, - vendor_id=result.vendor_id, + store_code=result.store_code, + store_id=result.store_id, redirect_url=result.redirect_url, trial_ends_at=result.trial_ends_at, access_token=result.access_token, @@ -272,6 +272,6 @@ async def get_signup_session(session_id: str) -> dict: "tier_code": session.get("tier_code"), "is_annual": session.get("is_annual"), "letzshop_slug": session.get("letzshop_slug"), - "vendor_name": session.get("vendor_name"), + "store_name": session.get("store_name"), "created_at": session.get("created_at"), } diff --git a/app/api/v1/vendor/__init__.py b/app/api/v1/store/__init__.py similarity index 74% rename from app/api/v1/vendor/__init__.py rename to app/api/v1/store/__init__.py index cd2eeb5a..7445cbfa 100644 --- a/app/api/v1/vendor/__init__.py +++ b/app/api/v1/store/__init__.py @@ -1,12 +1,12 @@ -# app/api/v1/vendor/__init__.py +# app/api/v1/store/__init__.py """ -Vendor API router aggregation. +Store API router aggregation. -This module aggregates all vendor-related JSON API endpoints. +This module aggregates all store-related JSON API endpoints. IMPORTANT: - This router is for JSON API endpoints only -- HTML page routes are mounted separately in main.py at /vendor/* +- HTML page routes are mounted separately in main.py at /store/* - Do NOT include pages.router here - it causes route conflicts MODULE SYSTEM: @@ -14,30 +14,30 @@ Routes can be module-gated using require_module_access() dependency. For multi-tenant apps, module enablement is checked at request time based on platform context (not at route registration time). -Self-contained modules (auto-discovered from app/modules/{module}/routes/api/vendor.py): -- analytics: Vendor analytics and reporting -- billing: Subscription tiers, vendor billing, checkout, add-ons, features, usage +Self-contained modules (auto-discovered from app/modules/{module}/routes/api/store.py): +- analytics: Store analytics and reporting +- billing: Subscription tiers, store billing, checkout, add-ons, features, usage - inventory: Stock management, inventory tracking - orders: Order management, fulfillment, exceptions, invoices - marketplace: Letzshop integration, product sync, onboarding -- catalog: Vendor product catalog management +- catalog: Store product catalog management - cms: Content pages management, media library - customers: Customer management - payments: Payment configuration, Stripe connect, transactions -- tenancy: Vendor info, auth, profile, team management +- tenancy: Store info, auth, profile, team management - messaging: Messages, notifications, email settings, email templates - core: Dashboard, settings """ from fastapi import APIRouter -# Create vendor router +# Create store router router = APIRouter() # ============================================================================ # JSON API ROUTES ONLY # ============================================================================ -# All vendor routes are now auto-discovered from self-contained modules. +# All store routes are now auto-discovered from self-contained modules. # ============================================================================ @@ -47,9 +47,9 @@ router = APIRouter() # Modules include: billing, inventory, orders, marketplace, cms, customers, payments # Routes are sorted by priority, so catch-all routes (CMS) come last. -from app.modules.routes import get_vendor_api_routes +from app.modules.routes import get_store_api_routes -for route_info in get_vendor_api_routes(): +for route_info in get_store_api_routes(): # Only pass prefix if custom_prefix is set (router already has internal prefix) if route_info.custom_prefix: router.include_router( diff --git a/app/api/v1/storefront/__init__.py b/app/api/v1/storefront/__init__.py index 900227fa..847e03a4 100644 --- a/app/api/v1/storefront/__init__.py +++ b/app/api/v1/storefront/__init__.py @@ -3,7 +3,7 @@ Storefront API router aggregation. This module aggregates all storefront-related JSON API endpoints (public facing). -Uses vendor context from middleware - no vendor_id in URLs. +Uses store context from middleware - no store_id in URLs. AUTO-DISCOVERED MODULE ROUTES: - cart: Shopping cart operations diff --git a/app/core/config.py b/app/core/config.py index 38e420e6..89f257bd 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -27,18 +27,18 @@ class Settings(BaseSettings): # ============================================================================= # PROJECT INFORMATION # ============================================================================= - project_name: str = "Wizamart - Multi-Vendor Marketplace Platform" + project_name: str = "Wizamart - Multi-Store Marketplace Platform" version: str = "2.2.0" # Clean description without HTML description: str = """ - Marketplace product import and management system with multi-vendor support. + Marketplace product import and management system with multi-store support. **Features:** - JWT Authentication with role-based access - Multi-marketplace product import (CSV processing) - Inventory management across multiple locations - - Vendor management with individual configurations + - Store management with individual configurations **Documentation:** Visit /documentation for complete guides **API Testing:** Use /docs for interactive API exploration @@ -113,8 +113,8 @@ class Settings(BaseSettings): # ============================================================================= # PLATFORM LIMITS # ============================================================================= - max_vendors_per_user: int = 5 - max_team_members_per_vendor: int = 50 + max_stores_per_user: int = 5 + max_team_members_per_store: int = 50 invitation_expiry_days: int = 7 # ============================================================================= @@ -169,10 +169,10 @@ class Settings(BaseSettings): # DEMO/SEED DATA CONFIGURATION # ============================================================================= # Controls for demo data seeding - seed_demo_vendors: int = 3 # Number of demo vendors to create - seed_customers_per_vendor: int = 15 # Customers per vendor - seed_products_per_vendor: int = 20 # Products per vendor - seed_orders_per_vendor: int = 10 # Orders per vendor + seed_demo_stores: int = 3 # Number of demo stores to create + seed_customers_per_store: int = 15 # Customers per store + seed_products_per_store: int = 20 # Products per store + seed_orders_per_store: int = 10 # Orders per store # ============================================================================= # CELERY / REDIS TASK QUEUE diff --git a/app/core/frontend_detector.py b/app/core/frontend_detector.py index 584bd579..4e26d15c 100644 --- a/app/core/frontend_detector.py +++ b/app/core/frontend_detector.py @@ -7,15 +7,15 @@ Handles both development (path-based) and production (domain-based) routing. Detection priority: 1. Admin subdomain (admin.oms.lu) -2. Path-based admin/vendor (/admin/*, /vendor/*, /api/v1/admin/*) +2. Path-based admin/store (/admin/*, /store/*, /api/v1/admin/*) 3. Custom domain lookup (mybakery.lu -> STOREFRONT) -4. Vendor subdomain (wizamart.oms.lu -> STOREFRONT) +4. Store subdomain (wizamart.oms.lu -> STOREFRONT) 5. Storefront paths (/storefront/*, /api/v1/storefront/*) 6. Default to PLATFORM (marketing pages) This module unifies frontend detection that was previously duplicated across: - middleware/platform_context.py -- middleware/vendor_context.py +- middleware/store_context.py - middleware/context.py All middleware and routes should use FrontendDetector for frontend detection. @@ -36,19 +36,19 @@ class FrontendDetector: All path/domain detection logic should be centralized here. """ - # Reserved subdomains (not vendor shops) - RESERVED_SUBDOMAINS = frozenset({"www", "admin", "api", "vendor", "portal"}) + # Reserved subdomains (not store shops) + RESERVED_SUBDOMAINS = frozenset({"www", "admin", "api", "store", "portal"}) # Path patterns for each frontend type # Note: Order matters - more specific patterns should be checked first ADMIN_PATH_PREFIXES = ("/admin", "/api/v1/admin") - VENDOR_PATH_PREFIXES = ("/vendor/", "/api/v1/vendor") # Note: /vendor/ not /vendors/ + STORE_PATH_PREFIXES = ("/store/", "/api/v1/store") # Note: /store/ not /stores/ STOREFRONT_PATH_PREFIXES = ( "/storefront", "/api/v1/storefront", "/shop", # Legacy support "/api/v1/shop", # Legacy support - "/vendors/", # Path-based vendor access + "/stores/", # Path-based store access ) PLATFORM_PATH_PREFIXES = ("/api/v1/platform",) @@ -57,15 +57,15 @@ class FrontendDetector: cls, host: str, path: str, - has_vendor_context: bool = False, + has_store_context: bool = False, ) -> FrontendType: """ Detect frontend type from request. Args: host: Request host header (e.g., "oms.lu", "wizamart.oms.lu", "localhost:8000") - path: Request path (e.g., "/admin/vendors", "/storefront/products") - has_vendor_context: True if request.state.vendor is set (from middleware) + path: Request path (e.g., "/admin/stores", "/storefront/products") + has_store_context: True if request.state.store is set (from middleware) Returns: FrontendType enum value @@ -79,7 +79,7 @@ class FrontendDetector: "host": host, "path": path, "subdomain": subdomain, - "has_vendor_context": has_vendor_context, + "has_store_context": has_store_context, }, ) @@ -94,31 +94,32 @@ class FrontendDetector: logger.debug("[FRONTEND_DETECTOR] Detected ADMIN from path") return FrontendType.ADMIN - if cls._matches_any(path, cls.VENDOR_PATH_PREFIXES): - logger.debug("[FRONTEND_DETECTOR] Detected VENDOR from path") - return FrontendType.VENDOR - + # Check storefront BEFORE store since /api/v1/storefront starts with /api/v1/store if cls._matches_any(path, cls.STOREFRONT_PATH_PREFIXES): logger.debug("[FRONTEND_DETECTOR] Detected STOREFRONT from path") return FrontendType.STOREFRONT + if cls._matches_any(path, cls.STORE_PATH_PREFIXES): + logger.debug("[FRONTEND_DETECTOR] Detected STORE from path") + return FrontendType.STORE + if cls._matches_any(path, cls.PLATFORM_PATH_PREFIXES): logger.debug("[FRONTEND_DETECTOR] Detected PLATFORM from path") return FrontendType.PLATFORM - # 3. Vendor subdomain detection (wizamart.oms.lu) - # If subdomain exists and is not reserved -> it's a vendor shop + # 3. Store subdomain detection (wizamart.oms.lu) + # If subdomain exists and is not reserved -> it's a store shop if subdomain and subdomain not in cls.RESERVED_SUBDOMAINS: logger.debug( f"[FRONTEND_DETECTOR] Detected STOREFRONT from subdomain: {subdomain}" ) return FrontendType.STOREFRONT - # 4. Custom domain detection (handled by middleware setting vendor context) - # If vendor is set but no storefront path -> still storefront - if has_vendor_context: + # 4. Custom domain detection (handled by middleware setting store context) + # If store is set but no storefront path -> still storefront + if has_store_context: logger.debug( - "[FRONTEND_DETECTOR] Detected STOREFRONT from vendor context" + "[FRONTEND_DETECTOR] Detected STOREFRONT from store context" ) return FrontendType.STOREFRONT @@ -168,19 +169,19 @@ class FrontendDetector: return cls.detect(host, path) == FrontendType.ADMIN @classmethod - def is_vendor(cls, host: str, path: str) -> bool: - """Check if request targets vendor dashboard frontend.""" - return cls.detect(host, path) == FrontendType.VENDOR + def is_store(cls, host: str, path: str) -> bool: + """Check if request targets store dashboard frontend.""" + return cls.detect(host, path) == FrontendType.STORE @classmethod def is_storefront( cls, host: str, path: str, - has_vendor_context: bool = False, + has_store_context: bool = False, ) -> bool: """Check if request targets storefront frontend.""" - return cls.detect(host, path, has_vendor_context) == FrontendType.STOREFRONT + return cls.detect(host, path, has_store_context) == FrontendType.STOREFRONT @classmethod def is_platform(cls, host: str, path: str) -> bool: @@ -194,10 +195,10 @@ class FrontendDetector: # Convenience function for backwards compatibility -def get_frontend_type(host: str, path: str, has_vendor_context: bool = False) -> FrontendType: +def get_frontend_type(host: str, path: str, has_store_context: bool = False) -> FrontendType: """ Convenience function to detect frontend type. Wrapper around FrontendDetector.detect() for simpler imports. """ - return FrontendDetector.detect(host, path, has_vendor_context) + return FrontendDetector.detect(host, path, has_store_context) diff --git a/app/core/logging.py b/app/core/logging.py index 627ee62c..f58b1b54 100644 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -59,7 +59,7 @@ class DatabaseLogHandler(logging.Handler): # Extract context from record (if middleware added it) user_id = getattr(record, "user_id", None) - vendor_id = getattr(record, "vendor_id", None) + store_id = getattr(record, "store_id", None) request_id = getattr(record, "request_id", None) context = getattr(record, "context", None) @@ -77,7 +77,7 @@ class DatabaseLogHandler(logging.Handler): stack_trace=stack_trace, request_id=request_id, user_id=user_id, - vendor_id=vendor_id, + store_id=store_id, context=context, ) diff --git a/app/exceptions/__init__.py b/app/exceptions/__init__.py index 051a987f..0285f0c5 100644 --- a/app/exceptions/__init__.py +++ b/app/exceptions/__init__.py @@ -5,7 +5,7 @@ Base exception classes for the application. This module provides only framework-level exceptions. Domain-specific exceptions have been moved to their respective modules: -- tenancy: VendorNotFoundException, CompanyNotFoundException, etc. +- tenancy: StoreNotFoundException, MerchantNotFoundException, etc. - orders: OrderNotFoundException, InvoiceNotFoundException, etc. - inventory: InventoryNotFoundException, InsufficientInventoryException, etc. - billing: TierNotFoundException, SubscriptionNotFoundException, etc. @@ -23,7 +23,7 @@ Import pattern: # Domain exceptions (module-level) from app.modules.orders.exceptions import OrderNotFoundException - from app.modules.tenancy.exceptions import VendorNotFoundException + from app.modules.tenancy.exceptions import StoreNotFoundException """ # Base exceptions - these are the only exports from root diff --git a/app/exceptions/base.py b/app/exceptions/base.py index b8eeb371..7b31c4af 100644 --- a/app/exceptions/base.py +++ b/app/exceptions/base.py @@ -207,6 +207,6 @@ class ServiceUnavailableException(WizamartException): ) -# Note: Domain-specific exceptions like VendorNotFoundException, UserNotFoundException, etc. -# are defined in their respective domain modules (vendor.py, admin.py, etc.) +# Note: Domain-specific exceptions like StoreNotFoundException, UserNotFoundException, etc. +# are defined in their respective domain modules (store.py, admin.py, etc.) # to keep domain-specific logic separate from base exceptions. diff --git a/app/exceptions/error_renderer.py b/app/exceptions/error_renderer.py index f2bd57a7..fac59c16 100644 --- a/app/exceptions/error_renderer.py +++ b/app/exceptions/error_renderer.py @@ -159,7 +159,7 @@ class ErrorPageRenderer: # Map frontend type to folder name frontend_folders = { FrontendType.ADMIN: "admin", - FrontendType.VENDOR: "vendor", + FrontendType.STORE: "store", FrontendType.STOREFRONT: "storefront", FrontendType.PLATFORM: "fallback", # Platform uses fallback templates } @@ -234,16 +234,16 @@ class ErrorPageRenderer: """Get frontend-specific data for error templates.""" data = {} - # Add vendor information if available (for storefront frontend) + # Add store information if available (for storefront frontend) if frontend_type == FrontendType.STOREFRONT: - vendor = getattr(request.state, "vendor", None) - if vendor: - # Pass minimal vendor info for templates - data["vendor"] = { - "id": vendor.id, - "name": vendor.name, - "subdomain": vendor.subdomain, - "logo": getattr(vendor, "logo", None), + store = getattr(request.state, "store", None) + if store: + # Pass minimal store info for templates + data["store"] = { + "id": store.id, + "name": store.name, + "subdomain": store.subdomain, + "logo": getattr(store, "logo", None), } # Add theme information if available @@ -262,21 +262,21 @@ class ErrorPageRenderer: } # Calculate base_url for storefront links - vendor_context = getattr(request.state, "vendor_context", None) + store_context = getattr(request.state, "store_context", None) access_method = ( - vendor_context.get("detection_method", "unknown") - if vendor_context + store_context.get("detection_method", "unknown") + if store_context else "unknown" ) base_url = "/" - if access_method == "path" and vendor: - # Use the full_prefix from vendor_context to determine which pattern was used + if access_method == "path" and store: + # Use the full_prefix from store_context to determine which pattern was used full_prefix = ( - vendor_context.get("full_prefix", "/vendor/") - if vendor_context - else "/vendor/" + store_context.get("full_prefix", "/store/") + if store_context + else "/store/" ) - base_url = f"{full_prefix}{vendor.subdomain}/" + base_url = f"{full_prefix}{store.subdomain}/" data["base_url"] = base_url return data diff --git a/app/exceptions/handler.py b/app/exceptions/handler.py index a7ce615f..636ee5ee 100644 --- a/app/exceptions/handler.py +++ b/app/exceptions/handler.py @@ -36,18 +36,18 @@ def setup_exception_handlers(app): # This includes both: # - 401 errors: Not authenticated (expired/invalid token) # - 403 errors with specific auth codes: Authenticated but wrong context - # (e.g., vendor token on admin page, role mismatch) + # (e.g., store token on admin page, role mismatch) # These codes indicate the user should re-authenticate with correct credentials auth_redirect_error_codes = { # Auth-level errors "ADMIN_REQUIRED", "INSUFFICIENT_PERMISSIONS", "USER_NOT_ACTIVE", - # Vendor-level auth errors - "VENDOR_ACCESS_DENIED", - "UNAUTHORIZED_VENDOR_ACCESS", - "VENDOR_OWNER_ONLY", - "INSUFFICIENT_VENDOR_PERMISSIONS", + # Store-level auth errors + "STORE_ACCESS_DENIED", + "UNAUTHORIZED_STORE_ACCESS", + "STORE_OWNER_ONLY", + "INSUFFICIENT_STORE_PERMISSIONS", # Customer-level auth errors "CUSTOMER_NOT_AUTHORIZED", } @@ -385,7 +385,7 @@ def _redirect_to_login(request: Request) -> RedirectResponse: """ Redirect to appropriate login page based on request frontend type. - Uses FrontendType detection to determine admin vs vendor vs storefront login. + Uses FrontendType detection to determine admin vs store vs storefront login. Properly handles multi-access routing (domain, subdomain, path-based). """ frontend_type = get_frontend_type(request) @@ -393,50 +393,50 @@ def _redirect_to_login(request: Request) -> RedirectResponse: if frontend_type == FrontendType.ADMIN: logger.debug("Redirecting to /admin/login") return RedirectResponse(url="/admin/login", status_code=302) - if frontend_type == FrontendType.VENDOR: - # Extract vendor code from the request path - # Path format: /vendor/{vendor_code}/... + if frontend_type == FrontendType.STORE: + # Extract store code from the request path + # Path format: /store/{store_code}/... path_parts = request.url.path.split("/") - vendor_code = None + store_code = None - # Find vendor code in path - if len(path_parts) >= 3 and path_parts[1] == "vendor": - vendor_code = path_parts[2] + # Find store code in path + if len(path_parts) >= 3 and path_parts[1] == "store": + store_code = path_parts[2] # Fallback: try to get from request state - if not vendor_code: - vendor = getattr(request.state, "vendor", None) - if vendor: - vendor_code = vendor.subdomain + if not store_code: + store = getattr(request.state, "store", None) + if store: + store_code = store.subdomain - # Construct proper login URL with vendor code - if vendor_code: - login_url = f"/vendor/{vendor_code}/login" + # Construct proper login URL with store code + if store_code: + login_url = f"/store/{store_code}/login" else: - # Fallback if we can't determine vendor code - login_url = "/vendor/login" + # Fallback if we can't determine store code + login_url = "/store/login" logger.debug(f"Redirecting to {login_url}") return RedirectResponse(url=login_url, status_code=302) if frontend_type == FrontendType.STOREFRONT: # For storefront context, redirect to storefront login (customer login) # Calculate base_url for proper routing (supports domain, subdomain, and path-based access) - vendor = getattr(request.state, "vendor", None) - vendor_context = getattr(request.state, "vendor_context", None) + store = getattr(request.state, "store", None) + store_context = getattr(request.state, "store_context", None) access_method = ( - vendor_context.get("detection_method", "unknown") - if vendor_context + store_context.get("detection_method", "unknown") + if store_context else "unknown" ) base_url = "/" - if access_method == "path" and vendor: + if access_method == "path" and store: full_prefix = ( - vendor_context.get("full_prefix", "/vendor/") - if vendor_context - else "/vendor/" + store_context.get("full_prefix", "/store/") + if store_context + else "/store/" ) - base_url = f"{full_prefix}{vendor.subdomain}/" + base_url = f"{full_prefix}{store.subdomain}/" login_url = f"{base_url}storefront/account/login" logger.debug(f"Redirecting to {login_url}") diff --git a/app/handlers/stripe_webhook.py b/app/handlers/stripe_webhook.py index 910a2152..a93d27c8 100644 --- a/app/handlers/stripe_webhook.py +++ b/app/handlers/stripe_webhook.py @@ -18,12 +18,13 @@ from sqlalchemy.orm import Session from app.modules.billing.models import ( AddOnProduct, BillingHistory, + MerchantSubscription, StripeWebhookEvent, SubscriptionStatus, SubscriptionTier, - VendorAddOn, - VendorSubscription, + StoreAddOn, ) +from app.modules.tenancy.models import Store, StorePlatform logger = logging.getLogger(__name__) @@ -115,44 +116,66 @@ class StripeWebhookHandler: Handle checkout.session.completed event. Handles two types of checkouts: - 1. Subscription checkout - Updates VendorSubscription - 2. Add-on checkout - Creates VendorAddOn record + 1. Subscription checkout - Updates MerchantSubscription + 2. Add-on checkout - Creates StoreAddOn record """ session = event.data.object - vendor_id = session.metadata.get("vendor_id") + store_id = session.metadata.get("store_id") addon_code = session.metadata.get("addon_code") - if not vendor_id: - logger.warning(f"Checkout session {session.id} missing vendor_id") - return {"action": "skipped", "reason": "no vendor_id"} + if not store_id: + logger.warning(f"Checkout session {session.id} missing store_id") + return {"action": "skipped", "reason": "no store_id"} - vendor_id = int(vendor_id) + store_id = int(store_id) # Check if this is an add-on purchase if addon_code: - return self._handle_addon_checkout(db, session, vendor_id, addon_code) + return self._handle_addon_checkout(db, session, store_id, addon_code) # Otherwise, handle subscription checkout - return self._handle_subscription_checkout(db, session, vendor_id) + return self._handle_subscription_checkout(db, session, store_id) def _handle_subscription_checkout( - self, db: Session, session, vendor_id: int + self, db: Session, session, store_id: int ) -> dict: """Handle subscription checkout completion.""" + # Resolve store_id to merchant_id and platform_id + store = db.query(Store).filter(Store.id == store_id).first() + if not store: + logger.warning(f"No store found for store_id {store_id}") + return {"action": "skipped", "reason": "no store"} + + merchant_id = store.merchant_id + + sp = ( + db.query(StorePlatform.platform_id) + .filter(StorePlatform.store_id == store_id) + .first() + ) + if not sp: + logger.warning(f"No platform found for store {store_id}") + return {"action": "skipped", "reason": "no platform"} + + platform_id = sp[0] + subscription = ( - db.query(VendorSubscription) - .filter(VendorSubscription.vendor_id == vendor_id) + db.query(MerchantSubscription) + .filter( + MerchantSubscription.merchant_id == merchant_id, + MerchantSubscription.platform_id == platform_id, + ) .first() ) if not subscription: - logger.warning(f"No subscription found for vendor {vendor_id}") + logger.warning(f"No subscription found for merchant {merchant_id}") return {"action": "skipped", "reason": "no subscription"} # Update subscription with Stripe IDs subscription.stripe_customer_id = session.customer subscription.stripe_subscription_id = session.subscription - subscription.status = SubscriptionStatus.ACTIVE + subscription.status = SubscriptionStatus.ACTIVE.value # Get subscription details to set period dates if session.subscription: @@ -169,16 +192,16 @@ class StripeWebhookHandler: stripe_sub.trial_end, tz=timezone.utc ) - logger.info(f"Subscription checkout completed for vendor {vendor_id}") - return {"action": "activated", "vendor_id": vendor_id} + logger.info(f"Subscription checkout completed for merchant {merchant_id}") + return {"action": "activated", "merchant_id": merchant_id} def _handle_addon_checkout( - self, db: Session, session, vendor_id: int, addon_code: str + self, db: Session, session, store_id: int, addon_code: str ) -> dict: """ Handle add-on checkout completion. - Creates a VendorAddOn record for the purchased add-on. + Creates a StoreAddOn record for the purchased add-on. """ # Get the add-on product addon_product = ( @@ -191,27 +214,27 @@ class StripeWebhookHandler: logger.error(f"Add-on product '{addon_code}' not found") return {"action": "failed", "reason": f"addon '{addon_code}' not found"} - # Check if vendor already has this add-on active + # Check if store already has this add-on active existing_addon = ( - db.query(VendorAddOn) + db.query(StoreAddOn) .filter( - VendorAddOn.vendor_id == vendor_id, - VendorAddOn.addon_product_id == addon_product.id, - VendorAddOn.status == "active", + StoreAddOn.store_id == store_id, + StoreAddOn.addon_product_id == addon_product.id, + StoreAddOn.status == "active", ) .first() ) if existing_addon: logger.info( - f"Vendor {vendor_id} already has active add-on {addon_code}, " + f"Store {store_id} already has active add-on {addon_code}, " f"updating quantity" ) # For quantity-based add-ons, we could increment # For now, just log and return return { "action": "already_exists", - "vendor_id": vendor_id, + "store_id": store_id, "addon_code": addon_code, } @@ -249,9 +272,9 @@ class StripeWebhookHandler: except Exception as e: logger.warning(f"Could not retrieve subscription period: {e}") - # Create VendorAddOn record - vendor_addon = VendorAddOn( - vendor_id=vendor_id, + # Create StoreAddOn record + store_addon = StoreAddOn( + store_id=store_id, addon_product_id=addon_product.id, status="active", domain_name=domain_name, @@ -260,18 +283,18 @@ class StripeWebhookHandler: period_start=period_start, period_end=period_end, ) - db.add(vendor_addon) + db.add(store_addon) logger.info( - f"Add-on '{addon_code}' purchased by vendor {vendor_id}" + f"Add-on '{addon_code}' purchased by store {store_id}" + (f" for domain {domain_name}" if domain_name else "") ) return { "action": "addon_created", - "vendor_id": vendor_id, + "store_id": store_id, "addon_code": addon_code, - "addon_id": vendor_addon.id, + "addon_id": store_addon.id, "domain_name": domain_name, } @@ -284,8 +307,8 @@ class StripeWebhookHandler: # Find subscription by customer ID subscription = ( - db.query(VendorSubscription) - .filter(VendorSubscription.stripe_customer_id == customer_id) + db.query(MerchantSubscription) + .filter(MerchantSubscription.stripe_customer_id == customer_id) .first() ) @@ -303,8 +326,8 @@ class StripeWebhookHandler: stripe_sub.current_period_end, tz=timezone.utc ) - logger.info(f"Subscription created for vendor {subscription.vendor_id}") - return {"action": "created", "vendor_id": subscription.vendor_id} + logger.info(f"Subscription created for merchant {subscription.merchant_id}") + return {"action": "created", "merchant_id": subscription.merchant_id} def _handle_subscription_updated( self, db: Session, event: stripe.Event @@ -313,8 +336,8 @@ class StripeWebhookHandler: stripe_sub = event.data.object subscription = ( - db.query(VendorSubscription) - .filter(VendorSubscription.stripe_subscription_id == stripe_sub.id) + db.query(MerchantSubscription) + .filter(MerchantSubscription.stripe_subscription_id == stripe_sub.id) .first() ) @@ -345,22 +368,20 @@ class StripeWebhookHandler: # Check for tier change via price if stripe_sub.items.data: new_price_id = stripe_sub.items.data[0].price.id - if subscription.stripe_price_id != new_price_id: - # Price changed, look up new tier - tier = ( - db.query(SubscriptionTier) - .filter(SubscriptionTier.stripe_price_monthly_id == new_price_id) - .first() + # Look up new tier by Stripe price ID + tier = ( + db.query(SubscriptionTier) + .filter(SubscriptionTier.stripe_price_monthly_id == new_price_id) + .first() + ) + if tier: + subscription.tier_id = tier.id + logger.info( + f"Tier changed to {tier.code} for merchant {subscription.merchant_id}" ) - if tier: - subscription.tier = tier.code - logger.info( - f"Tier changed to {tier.code} for vendor {subscription.vendor_id}" - ) - subscription.stripe_price_id = new_price_id - logger.info(f"Subscription updated for vendor {subscription.vendor_id}") - return {"action": "updated", "vendor_id": subscription.vendor_id} + logger.info(f"Subscription updated for merchant {subscription.merchant_id}") + return {"action": "updated", "merchant_id": subscription.merchant_id} def _handle_subscription_deleted( self, db: Session, event: stripe.Event @@ -368,13 +389,13 @@ class StripeWebhookHandler: """ Handle customer.subscription.deleted event. - Cancels the subscription and all associated add-ons. + Cancels the subscription and all associated add-ons for the merchant's stores. """ stripe_sub = event.data.object subscription = ( - db.query(VendorSubscription) - .filter(VendorSubscription.stripe_subscription_id == stripe_sub.id) + db.query(MerchantSubscription) + .filter(MerchantSubscription.stripe_subscription_id == stripe_sub.id) .first() ) @@ -382,18 +403,25 @@ class StripeWebhookHandler: logger.warning(f"No subscription found for {stripe_sub.id}") return {"action": "skipped", "reason": "no subscription"} - vendor_id = subscription.vendor_id + merchant_id = subscription.merchant_id # Cancel the subscription - subscription.status = SubscriptionStatus.CANCELLED + subscription.status = SubscriptionStatus.CANCELLED.value subscription.cancelled_at = datetime.now(timezone.utc) - # Also cancel all active add-ons for this vendor + # Find all stores for this merchant, then cancel their add-ons + store_ids = [ + s.id + for s in db.query(Store.id) + .filter(Store.merchant_id == merchant_id) + .all() + ] + cancelled_addons = ( - db.query(VendorAddOn) + db.query(StoreAddOn) .filter( - VendorAddOn.vendor_id == vendor_id, - VendorAddOn.status == "active", + StoreAddOn.store_id.in_(store_ids), + StoreAddOn.status == "active", ) .all() ) @@ -405,12 +433,12 @@ class StripeWebhookHandler: addon_count += 1 if addon_count > 0: - logger.info(f"Cancelled {addon_count} add-ons for vendor {vendor_id}") + logger.info(f"Cancelled {addon_count} add-ons for merchant {merchant_id}") - logger.info(f"Subscription deleted for vendor {vendor_id}") + logger.info(f"Subscription deleted for merchant {merchant_id}") return { "action": "cancelled", - "vendor_id": vendor_id, + "merchant_id": merchant_id, "addons_cancelled": addon_count, } @@ -420,8 +448,8 @@ class StripeWebhookHandler: customer_id = invoice.customer subscription = ( - db.query(VendorSubscription) - .filter(VendorSubscription.stripe_customer_id == customer_id) + db.query(MerchantSubscription) + .filter(MerchantSubscription.stripe_customer_id == customer_id) .first() ) @@ -431,7 +459,7 @@ class StripeWebhookHandler: # Record billing history billing_record = BillingHistory( - vendor_id=subscription.vendor_id, + merchant_id=subscription.merchant_id, stripe_invoice_id=invoice.id, stripe_payment_intent_id=invoice.payment_intent, invoice_number=invoice.number, @@ -451,15 +479,10 @@ class StripeWebhookHandler: subscription.payment_retry_count = 0 subscription.last_payment_error = None - # Reset period counters if this is a new billing cycle - if subscription.status == SubscriptionStatus.ACTIVE: - subscription.orders_this_period = 0 - subscription.orders_limit_reached_at = None - - logger.info(f"Invoice paid for vendor {subscription.vendor_id}") + logger.info(f"Invoice paid for merchant {subscription.merchant_id}") return { "action": "recorded", - "vendor_id": subscription.vendor_id, + "merchant_id": subscription.merchant_id, "invoice_id": invoice.id, } @@ -469,8 +492,8 @@ class StripeWebhookHandler: customer_id = invoice.customer subscription = ( - db.query(VendorSubscription) - .filter(VendorSubscription.stripe_customer_id == customer_id) + db.query(MerchantSubscription) + .filter(MerchantSubscription.stripe_customer_id == customer_id) .first() ) @@ -479,7 +502,7 @@ class StripeWebhookHandler: return {"action": "skipped", "reason": "no subscription"} # Update subscription status - subscription.status = SubscriptionStatus.PAST_DUE + subscription.status = SubscriptionStatus.PAST_DUE.value subscription.payment_retry_count = (subscription.payment_retry_count or 0) + 1 # Store error message @@ -487,12 +510,12 @@ class StripeWebhookHandler: subscription.last_payment_error = invoice.last_payment_error.get("message") logger.warning( - f"Payment failed for vendor {subscription.vendor_id} " + f"Payment failed for merchant {subscription.merchant_id} " f"(retry #{subscription.payment_retry_count})" ) return { "action": "marked_past_due", - "vendor_id": subscription.vendor_id, + "merchant_id": subscription.merchant_id, "retry_count": subscription.payment_retry_count, } @@ -504,8 +527,8 @@ class StripeWebhookHandler: customer_id = invoice.customer subscription = ( - db.query(VendorSubscription) - .filter(VendorSubscription.stripe_customer_id == customer_id) + db.query(MerchantSubscription) + .filter(MerchantSubscription.stripe_customer_id == customer_id) .first() ) @@ -524,7 +547,7 @@ class StripeWebhookHandler: # Record as pending invoice billing_record = BillingHistory( - vendor_id=subscription.vendor_id, + merchant_id=subscription.merchant_id, stripe_invoice_id=invoice.id, invoice_number=invoice.number, invoice_date=datetime.fromtimestamp(invoice.created, tz=timezone.utc), @@ -542,24 +565,24 @@ class StripeWebhookHandler: ) db.add(billing_record) - return {"action": "recorded_pending", "vendor_id": subscription.vendor_id} + return {"action": "recorded_pending", "merchant_id": subscription.merchant_id} # ========================================================================= # Helpers # ========================================================================= - def _map_stripe_status(self, stripe_status: str) -> SubscriptionStatus: - """Map Stripe subscription status to internal status.""" + def _map_stripe_status(self, stripe_status: str) -> str: + """Map Stripe subscription status to internal status string.""" status_map = { - "active": SubscriptionStatus.ACTIVE, - "trialing": SubscriptionStatus.TRIAL, - "past_due": SubscriptionStatus.PAST_DUE, - "canceled": SubscriptionStatus.CANCELLED, - "unpaid": SubscriptionStatus.PAST_DUE, - "incomplete": SubscriptionStatus.TRIAL, # Treat as trial until complete - "incomplete_expired": SubscriptionStatus.EXPIRED, + "active": SubscriptionStatus.ACTIVE.value, + "trialing": SubscriptionStatus.TRIAL.value, + "past_due": SubscriptionStatus.PAST_DUE.value, + "canceled": SubscriptionStatus.CANCELLED.value, + "unpaid": SubscriptionStatus.PAST_DUE.value, + "incomplete": SubscriptionStatus.TRIAL.value, # Treat as trial until complete + "incomplete_expired": SubscriptionStatus.EXPIRED.value, } - return status_map.get(stripe_status, SubscriptionStatus.EXPIRED) + return status_map.get(stripe_status, SubscriptionStatus.EXPIRED.value) # Create handler instance diff --git a/app/modules/analytics/definition.py b/app/modules/analytics/definition.py index e7241e90..796a4968 100644 --- a/app/modules/analytics/definition.py +++ b/app/modules/analytics/definition.py @@ -10,20 +10,27 @@ from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDe from app.modules.enums import FrontendType -def _get_vendor_api_router(): - """Lazy import of vendor API router to avoid circular imports.""" - from app.modules.analytics.routes.api.vendor import router +def _get_store_api_router(): + """Lazy import of store API router to avoid circular imports.""" + from app.modules.analytics.routes.api.store import router return router -def _get_vendor_page_router(): - """Lazy import of vendor page router to avoid circular imports.""" - from app.modules.analytics.routes.pages.vendor import router +def _get_store_page_router(): + """Lazy import of store page router to avoid circular imports.""" + from app.modules.analytics.routes.pages.store import router return router +def _get_feature_provider(): + """Lazy import of feature provider to avoid circular imports.""" + from app.modules.analytics.services.analytics_features import analytics_feature_provider + + return analytics_feature_provider + + # Analytics module definition analytics_module = ModuleDefinition( code="analytics", @@ -62,13 +69,13 @@ analytics_module = ModuleDefinition( FrontendType.ADMIN: [ # Analytics appears in dashboard for admin ], - FrontendType.VENDOR: [ - "analytics", # Vendor analytics page + FrontendType.STORE: [ + "analytics", # Store analytics page ], }, # New module-driven menu definitions menus={ - FrontendType.VENDOR: [ + FrontendType.STORE: [ MenuSectionDefinition( id="main", label_key=None, @@ -80,7 +87,7 @@ analytics_module = ModuleDefinition( id="analytics", label_key="analytics.menu.analytics", icon="chart-bar", - route="/vendor/{vendor_code}/analytics", + route="/store/{store_code}/analytics", order=20, ), ], @@ -96,10 +103,11 @@ analytics_module = ModuleDefinition( models_path="app.modules.analytics.models", schemas_path="app.modules.analytics.schemas", exceptions_path="app.modules.analytics.exceptions", - # Module templates (namespaced as analytics/admin/*.html and analytics/vendor/*.html) + # Module templates (namespaced as analytics/admin/*.html and analytics/store/*.html) templates_path="templates", # Module-specific translations (accessible via analytics.* keys) locales_path="locales", + feature_provider=_get_feature_provider, ) @@ -111,11 +119,11 @@ def get_analytics_module_with_routers() -> ModuleDefinition: during module initialization. Routers: - - vendor_api_router: API endpoints for vendor analytics - - vendor_page_router: Page routes for vendor analytics dashboard + - store_api_router: API endpoints for store analytics + - store_page_router: Page routes for store analytics dashboard """ - analytics_module.vendor_api_router = _get_vendor_api_router() - analytics_module.vendor_page_router = _get_vendor_page_router() + analytics_module.store_api_router = _get_store_api_router() + analytics_module.store_page_router = _get_store_page_router() return analytics_module diff --git a/app/modules/analytics/routes/__init__.py b/app/modules/analytics/routes/__init__.py index b7605a8f..b08fa128 100644 --- a/app/modules/analytics/routes/__init__.py +++ b/app/modules/analytics/routes/__init__.py @@ -7,8 +7,8 @@ with module-based access control. NOTE: Routers are NOT auto-imported to avoid circular dependencies. Import directly from api/ or pages/ as needed: - from app.modules.analytics.routes.api import vendor_router as vendor_api_router - from app.modules.analytics.routes.pages import vendor_router as vendor_page_router + from app.modules.analytics.routes.api import store_router as store_api_router + from app.modules.analytics.routes.pages import store_router as store_page_router Note: Analytics module has no admin routes - admin uses dashboard. """ @@ -16,15 +16,15 @@ Note: Analytics module has no admin routes - admin uses dashboard. # Routers are imported on-demand to avoid circular dependencies # Do NOT add auto-imports here -__all__ = ["vendor_api_router", "vendor_page_router"] +__all__ = ["store_api_router", "store_page_router"] def __getattr__(name: str): """Lazy import routers to avoid circular dependencies.""" - if name == "vendor_api_router": - from app.modules.analytics.routes.api import vendor_router - return vendor_router - elif name == "vendor_page_router": - from app.modules.analytics.routes.pages import vendor_router - return vendor_router + if name == "store_api_router": + from app.modules.analytics.routes.api import store_router + return store_router + elif name == "store_page_router": + from app.modules.analytics.routes.pages import store_router + return store_router raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/app/modules/analytics/routes/api/__init__.py b/app/modules/analytics/routes/api/__init__.py index c427ad60..344c7361 100644 --- a/app/modules/analytics/routes/api/__init__.py +++ b/app/modules/analytics/routes/api/__init__.py @@ -3,9 +3,9 @@ Analytics module API routes. Provides REST API endpoints for analytics and reporting: -- Vendor API: Vendor-scoped analytics data +- Store API: Store-scoped analytics data """ -from app.modules.analytics.routes.api.vendor import router as vendor_router +from app.modules.analytics.routes.api.store import router as store_router -__all__ = ["vendor_router"] +__all__ = ["store_router"] diff --git a/app/modules/analytics/routes/api/store.py b/app/modules/analytics/routes/api/store.py new file mode 100644 index 00000000..2adfeaff --- /dev/null +++ b/app/modules/analytics/routes/api/store.py @@ -0,0 +1,58 @@ +# app/modules/analytics/routes/api/store.py +""" +Store Analytics API + +Store Context: Uses token_store_id from JWT token (authenticated store API pattern). +The get_current_store_api dependency guarantees token_store_id is present. + +Feature Requirements: +- basic_reports: Basic analytics (Essential tier) +- analytics_dashboard: Advanced analytics (Business tier) +""" + +import logging + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.api.deps import get_current_store_api, get_db, require_module_access +from app.modules.billing.dependencies.feature_gate import RequireFeature +from app.modules.analytics.services import stats_service +from app.modules.analytics.schemas import ( + StoreAnalyticsCatalog, + StoreAnalyticsImports, + StoreAnalyticsInventory, + StoreAnalyticsResponse, +) +from app.modules.enums import FrontendType +from app.modules.tenancy.models import User + +router = APIRouter( + prefix="/analytics", + dependencies=[Depends(require_module_access("analytics", FrontendType.STORE))], +) +store_router = router # Alias for discovery +logger = logging.getLogger(__name__) + + +@router.get("", response_model=StoreAnalyticsResponse) +def get_store_analytics( + period: str = Query("30d", description="Time period: 7d, 30d, 90d, 1y"), + current_user: User = Depends(get_current_store_api), + db: Session = Depends(get_db), + _: None = Depends(RequireFeature("basic_reports", "analytics_dashboard")), +): + """Get store analytics data for specified time period.""" + data = stats_service.get_store_analytics(db, current_user.token_store_id, period) + + return StoreAnalyticsResponse( + period=data["period"], + start_date=data["start_date"], + imports=StoreAnalyticsImports(count=data["imports"]["count"]), + catalog=StoreAnalyticsCatalog( + products_added=data["catalog"]["products_added"] + ), + inventory=StoreAnalyticsInventory( + total_locations=data["inventory"]["total_locations"] + ), + ) diff --git a/app/modules/analytics/routes/api/vendor.py b/app/modules/analytics/routes/api/vendor.py deleted file mode 100644 index 20874fa3..00000000 --- a/app/modules/analytics/routes/api/vendor.py +++ /dev/null @@ -1,59 +0,0 @@ -# app/modules/analytics/routes/api/vendor.py -""" -Vendor Analytics API - -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. - -Feature Requirements: -- basic_reports: Basic analytics (Essential tier) -- analytics_dashboard: Advanced analytics (Business tier) -""" - -import logging - -from fastapi import APIRouter, Depends, Query -from sqlalchemy.orm import Session - -from app.api.deps import get_current_vendor_api, get_db, require_module_access -from app.modules.billing.dependencies.feature_gate import RequireFeature -from app.modules.analytics.services import stats_service -from app.modules.analytics.schemas import ( - VendorAnalyticsCatalog, - VendorAnalyticsImports, - VendorAnalyticsInventory, - VendorAnalyticsResponse, -) -from app.modules.billing.models import FeatureCode -from app.modules.enums import FrontendType -from app.modules.tenancy.models import User - -router = APIRouter( - prefix="/analytics", - dependencies=[Depends(require_module_access("analytics", FrontendType.VENDOR))], -) -vendor_router = router # Alias for discovery -logger = logging.getLogger(__name__) - - -@router.get("", response_model=VendorAnalyticsResponse) -def get_vendor_analytics( - period: str = Query("30d", description="Time period: 7d, 30d, 90d, 1y"), - current_user: User = Depends(get_current_vendor_api), - db: Session = Depends(get_db), - _: None = Depends(RequireFeature(FeatureCode.BASIC_REPORTS, FeatureCode.ANALYTICS_DASHBOARD)), -): - """Get vendor analytics data for specified time period.""" - data = stats_service.get_vendor_analytics(db, current_user.token_vendor_id, period) - - return VendorAnalyticsResponse( - period=data["period"], - start_date=data["start_date"], - imports=VendorAnalyticsImports(count=data["imports"]["count"]), - catalog=VendorAnalyticsCatalog( - products_added=data["catalog"]["products_added"] - ), - inventory=VendorAnalyticsInventory( - total_locations=data["inventory"]["total_locations"] - ), - ) diff --git a/app/modules/analytics/routes/pages/vendor.py b/app/modules/analytics/routes/pages/store.py similarity index 59% rename from app/modules/analytics/routes/pages/vendor.py rename to app/modules/analytics/routes/pages/store.py index 5f3aca56..22e41588 100644 --- a/app/modules/analytics/routes/pages/vendor.py +++ b/app/modules/analytics/routes/pages/store.py @@ -1,8 +1,8 @@ -# app/modules/analytics/routes/pages/vendor.py +# app/modules/analytics/routes/pages/store.py """ -Analytics Vendor Page Routes (HTML rendering). +Analytics Store Page Routes (HTML rendering). -Vendor pages for analytics dashboard. +Store pages for analytics dashboard. """ import logging @@ -11,11 +11,11 @@ from fastapi import APIRouter, Depends, Path, Request from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session -from app.api.deps import get_current_vendor_from_cookie_or_header, get_db +from app.api.deps import get_current_store_from_cookie_or_header, get_db from app.modules.core.services.platform_settings_service import platform_settings_service # noqa: MOD-004 - shared platform service from app.templates_config import templates from app.modules.tenancy.models import User -from app.modules.tenancy.models import Vendor +from app.modules.tenancy.models import Store logger = logging.getLogger(__name__) @@ -23,41 +23,41 @@ router = APIRouter() # ============================================================================ -# HELPER: Build Vendor Dashboard Context +# HELPER: Build Store Dashboard Context # ============================================================================ -def get_vendor_context( +def get_store_context( request: Request, db: Session, current_user: User, - vendor_code: str, + store_code: str, **extra_context, ) -> dict: """ - Build template context for vendor dashboard pages. + Build template context for store dashboard pages. Resolves locale/currency using the platform settings service with - vendor override support. + store override support. """ - # Load vendor from database - vendor = db.query(Vendor).filter(Vendor.subdomain == vendor_code).first() + # Load store from database + store = db.query(Store).filter(Store.subdomain == store_code).first() # Get platform defaults platform_config = platform_settings_service.get_storefront_config(db) - # Resolve with vendor override + # Resolve with store override storefront_locale = platform_config["locale"] storefront_currency = platform_config["currency"] - if vendor and vendor.storefront_locale: - storefront_locale = vendor.storefront_locale + if store and store.storefront_locale: + storefront_locale = store.storefront_locale context = { "request": request, "user": current_user, - "vendor": vendor, - "vendor_code": vendor_code, + "store": store, + "store_code": store_code, "storefront_locale": storefront_locale, "storefront_currency": storefront_currency, **extra_context, @@ -72,12 +72,12 @@ def get_vendor_context( @router.get( - "/{vendor_code}/analytics", response_class=HTMLResponse, include_in_schema=False + "/{store_code}/analytics", response_class=HTMLResponse, include_in_schema=False ) -async def vendor_analytics_page( +async def store_analytics_page( request: Request, - vendor_code: str = Path(..., description="Vendor code"), - current_user: User = Depends(get_current_vendor_from_cookie_or_header), + store_code: str = Path(..., description="Store code"), + current_user: User = Depends(get_current_store_from_cookie_or_header), db: Session = Depends(get_db), ): """ @@ -85,6 +85,6 @@ async def vendor_analytics_page( JavaScript loads analytics data via API. """ return templates.TemplateResponse( - "analytics/vendor/analytics.html", - get_vendor_context(request, db, current_user, vendor_code), + "analytics/store/analytics.html", + get_store_context(request, db, current_user, store_code), ) diff --git a/app/modules/analytics/schemas/__init__.py b/app/modules/analytics/schemas/__init__.py index d177fc9e..9fd08af6 100644 --- a/app/modules/analytics/schemas/__init__.py +++ b/app/modules/analytics/schemas/__init__.py @@ -10,21 +10,21 @@ from app.modules.analytics.schemas.stats import ( MarketplaceStatsResponse, ImportStatsResponse, UserStatsResponse, - VendorStatsResponse, + StoreStatsResponse, ProductStatsResponse, PlatformStatsResponse, OrderStatsBasicResponse, AdminDashboardResponse, - VendorProductStats, - VendorOrderStats, - VendorCustomerStats, - VendorRevenueStats, - VendorInfo, - VendorDashboardStatsResponse, - VendorAnalyticsImports, - VendorAnalyticsCatalog, - VendorAnalyticsInventory, - VendorAnalyticsResponse, + StoreProductStats, + StoreOrderStats, + StoreCustomerStats, + StoreRevenueStats, + StoreInfo, + StoreDashboardStatsResponse, + StoreAnalyticsImports, + StoreAnalyticsCatalog, + StoreAnalyticsInventory, + StoreAnalyticsResponse, ValidatorStats, CodeQualityDashboardStatsResponse, CustomerStatsResponse, @@ -36,21 +36,21 @@ __all__ = [ "MarketplaceStatsResponse", "ImportStatsResponse", "UserStatsResponse", - "VendorStatsResponse", + "StoreStatsResponse", "ProductStatsResponse", "PlatformStatsResponse", "OrderStatsBasicResponse", "AdminDashboardResponse", - "VendorProductStats", - "VendorOrderStats", - "VendorCustomerStats", - "VendorRevenueStats", - "VendorInfo", - "VendorDashboardStatsResponse", - "VendorAnalyticsImports", - "VendorAnalyticsCatalog", - "VendorAnalyticsInventory", - "VendorAnalyticsResponse", + "StoreProductStats", + "StoreOrderStats", + "StoreCustomerStats", + "StoreRevenueStats", + "StoreInfo", + "StoreDashboardStatsResponse", + "StoreAnalyticsImports", + "StoreAnalyticsCatalog", + "StoreAnalyticsInventory", + "StoreAnalyticsResponse", "ValidatorStats", "CodeQualityDashboardStatsResponse", "CustomerStatsResponse", diff --git a/app/modules/analytics/schemas/stats.py b/app/modules/analytics/schemas/stats.py index 8498d508..5d63da39 100644 --- a/app/modules/analytics/schemas/stats.py +++ b/app/modules/analytics/schemas/stats.py @@ -24,50 +24,50 @@ from app.modules.core.schemas.dashboard import ( ProductStatsResponse, StatsResponse, UserStatsResponse, - VendorCustomerStats, - VendorDashboardStatsResponse, - VendorInfo, - VendorOrderStats, - VendorProductStats, - VendorRevenueStats, - VendorStatsResponse, + StoreCustomerStats, + StoreDashboardStatsResponse, + StoreInfo, + StoreOrderStats, + StoreProductStats, + StoreRevenueStats, + StoreStatsResponse, ) # ============================================================================ -# Vendor Analytics (Analytics-specific, not in core) +# Store Analytics (Analytics-specific, not in core) # ============================================================================ -class VendorAnalyticsImports(BaseModel): - """Vendor import analytics.""" +class StoreAnalyticsImports(BaseModel): + """Store import analytics.""" count: int = Field(0, description="Number of imports in period") -class VendorAnalyticsCatalog(BaseModel): - """Vendor catalog analytics.""" +class StoreAnalyticsCatalog(BaseModel): + """Store catalog analytics.""" products_added: int = Field(0, description="Products added in period") -class VendorAnalyticsInventory(BaseModel): - """Vendor inventory analytics.""" +class StoreAnalyticsInventory(BaseModel): + """Store inventory analytics.""" total_locations: int = Field(0, description="Total inventory locations") -class VendorAnalyticsResponse(BaseModel): - """Vendor analytics response schema. +class StoreAnalyticsResponse(BaseModel): + """Store analytics response schema. - Used by: GET /api/v1/vendor/analytics + Used by: GET /api/v1/store/analytics """ period: str = Field(..., description="Analytics period (e.g., '30d')") start_date: str = Field(..., description="Period start date") - imports: VendorAnalyticsImports - catalog: VendorAnalyticsCatalog - inventory: VendorAnalyticsInventory + imports: StoreAnalyticsImports + catalog: StoreAnalyticsCatalog + inventory: StoreAnalyticsInventory # ============================================================================ @@ -157,22 +157,22 @@ __all__ = [ "MarketplaceStatsResponse", "ImportStatsResponse", "UserStatsResponse", - "VendorStatsResponse", + "StoreStatsResponse", "ProductStatsResponse", "PlatformStatsResponse", "OrderStatsBasicResponse", "AdminDashboardResponse", - "VendorProductStats", - "VendorOrderStats", - "VendorCustomerStats", - "VendorRevenueStats", - "VendorInfo", - "VendorDashboardStatsResponse", + "StoreProductStats", + "StoreOrderStats", + "StoreCustomerStats", + "StoreRevenueStats", + "StoreInfo", + "StoreDashboardStatsResponse", # Analytics-specific schemas - "VendorAnalyticsImports", - "VendorAnalyticsCatalog", - "VendorAnalyticsInventory", - "VendorAnalyticsResponse", + "StoreAnalyticsImports", + "StoreAnalyticsCatalog", + "StoreAnalyticsInventory", + "StoreAnalyticsResponse", "ValidatorStats", "CodeQualityDashboardStatsResponse", "CustomerStatsResponse", diff --git a/app/modules/analytics/services/analytics_features.py b/app/modules/analytics/services/analytics_features.py new file mode 100644 index 00000000..cb6f7272 --- /dev/null +++ b/app/modules/analytics/services/analytics_features.py @@ -0,0 +1,113 @@ +# app/modules/analytics/services/analytics_features.py +""" +Analytics feature provider for the billing feature system. + +Declares analytics-related billable features (dashboard access, report types, +export capabilities). All features are binary (on/off) at the merchant level, +so no usage tracking queries are needed. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from sqlalchemy import func + +from app.modules.contracts.features import ( + FeatureDeclaration, + FeatureProviderProtocol, + FeatureScope, + FeatureType, + FeatureUsage, +) + +if TYPE_CHECKING: + from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + + +class AnalyticsFeatureProvider: + """Feature provider for the analytics module. + + Declares: + - analytics_dashboard: binary merchant-level feature for analytics dashboard access + - basic_reports: binary merchant-level feature for standard reports + - custom_reports: binary merchant-level feature for custom report builder + - export_reports: binary merchant-level feature for report data export + """ + + @property + def feature_category(self) -> str: + return "analytics" + + def get_feature_declarations(self) -> list[FeatureDeclaration]: + return [ + FeatureDeclaration( + code="analytics_dashboard", + name_key="analytics.features.analytics_dashboard.name", + description_key="analytics.features.analytics_dashboard.description", + category="analytics", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="bar-chart-2", + display_order=10, + ), + FeatureDeclaration( + code="basic_reports", + name_key="analytics.features.basic_reports.name", + description_key="analytics.features.basic_reports.description", + category="analytics", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="file-text", + display_order=20, + ), + FeatureDeclaration( + code="custom_reports", + name_key="analytics.features.custom_reports.name", + description_key="analytics.features.custom_reports.description", + category="analytics", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="pie-chart", + display_order=30, + ), + FeatureDeclaration( + code="export_reports", + name_key="analytics.features.export_reports.name", + description_key="analytics.features.export_reports.description", + category="analytics", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="download", + display_order=40, + ), + ] + + def get_store_usage( + self, + db: Session, + store_id: int, + ) -> list[FeatureUsage]: + # All analytics features are binary; no usage tracking needed + return [] + + def get_merchant_usage( + self, + db: Session, + merchant_id: int, + platform_id: int, + ) -> list[FeatureUsage]: + # All analytics features are binary; no usage tracking needed + return [] + + +# Singleton instance for module registration +analytics_feature_provider = AnalyticsFeatureProvider() + +__all__ = [ + "AnalyticsFeatureProvider", + "analytics_feature_provider", +] diff --git a/app/modules/analytics/services/stats_service.py b/app/modules/analytics/services/stats_service.py index 0bb786b8..935cfb21 100644 --- a/app/modules/analytics/services/stats_service.py +++ b/app/modules/analytics/services/stats_service.py @@ -6,7 +6,7 @@ This is the canonical location for the stats service. This module provides: - System-wide statistics (admin) -- Vendor-specific statistics +- Store-specific statistics - Marketplace analytics - Performance metrics """ @@ -18,14 +18,14 @@ from typing import Any from sqlalchemy import func from sqlalchemy.orm import Session -from app.modules.tenancy.exceptions import AdminOperationException, VendorNotFoundException +from app.modules.tenancy.exceptions import AdminOperationException, StoreNotFoundException from app.modules.customers.models.customer import Customer from app.modules.inventory.models import Inventory from app.modules.marketplace.models import MarketplaceImportJob, MarketplaceProduct from app.modules.orders.models import Order from app.modules.catalog.models import Product from app.modules.tenancy.models import User -from app.modules.tenancy.models import Vendor +from app.modules.tenancy.models import Store logger = logging.getLogger(__name__) @@ -34,41 +34,41 @@ class StatsService: """Service for statistics operations.""" # ======================================================================== - # VENDOR-SPECIFIC STATISTICS + # STORE-SPECIFIC STATISTICS # ======================================================================== - def get_vendor_stats(self, db: Session, vendor_id: int) -> dict[str, Any]: + def get_store_stats(self, db: Session, store_id: int) -> dict[str, Any]: """ - Get statistics for a specific vendor. + Get statistics for a specific store. Args: db: Database session - vendor_id: Vendor ID + store_id: Store ID Returns: - Dictionary with vendor statistics + Dictionary with store statistics Raises: - VendorNotFoundException: If vendor doesn't exist + StoreNotFoundException: If store doesn't exist AdminOperationException: If database query fails """ - # Verify vendor exists - vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() - if not vendor: - raise VendorNotFoundException(str(vendor_id), identifier_type="id") + # Verify store exists + store = db.query(Store).filter(Store.id == store_id).first() + if not store: + raise StoreNotFoundException(str(store_id), identifier_type="id") try: # Catalog statistics total_catalog_products = ( db.query(Product) - .filter(Product.vendor_id == vendor_id, Product.is_active == True) + .filter(Product.store_id == store_id, Product.is_active == True) .count() ) featured_products = ( db.query(Product) .filter( - Product.vendor_id == vendor_id, + Product.store_id == store_id, Product.is_featured == True, Product.is_active == True, ) @@ -76,33 +76,33 @@ class StatsService: ) # Staging statistics - # TODO: This is fragile - MarketplaceProduct uses vendor_name (string) not vendor_id - # Should add vendor_id foreign key to MarketplaceProduct for robust querying - # For now, matching by vendor name which could fail if names don't match exactly + # TODO: This is fragile - MarketplaceProduct uses store_name (string) not store_id + # Should add store_id foreign key to MarketplaceProduct for robust querying + # For now, matching by store name which could fail if names don't match exactly staging_products = ( db.query(MarketplaceProduct) - .filter(MarketplaceProduct.vendor_name == vendor.name) + .filter(MarketplaceProduct.store_name == store.name) .count() ) # Inventory statistics total_inventory = ( db.query(func.sum(Inventory.quantity)) - .filter(Inventory.vendor_id == vendor_id) + .filter(Inventory.store_id == store_id) .scalar() or 0 ) reserved_inventory = ( db.query(func.sum(Inventory.reserved_quantity)) - .filter(Inventory.vendor_id == vendor_id) + .filter(Inventory.store_id == store_id) .scalar() or 0 ) inventory_locations = ( db.query(func.count(func.distinct(Inventory.location))) - .filter(Inventory.vendor_id == vendor_id) + .filter(Inventory.store_id == store_id) .scalar() or 0 ) @@ -110,28 +110,28 @@ class StatsService: # Import statistics total_imports = ( db.query(MarketplaceImportJob) - .filter(MarketplaceImportJob.vendor_id == vendor_id) + .filter(MarketplaceImportJob.store_id == store_id) .count() ) successful_imports = ( db.query(MarketplaceImportJob) .filter( - MarketplaceImportJob.vendor_id == vendor_id, + MarketplaceImportJob.store_id == store_id, MarketplaceImportJob.status == "completed", ) .count() ) # Orders - total_orders = db.query(Order).filter(Order.vendor_id == vendor_id).count() + total_orders = db.query(Order).filter(Order.store_id == store_id).count() # Customers total_customers = ( - db.query(Customer).filter(Customer.vendor_id == vendor_id).count() + db.query(Customer).filter(Customer.store_id == store_id).count() ) - # Return flat structure compatible with VendorDashboardStatsResponse schema + # Return flat structure compatible with StoreDashboardStatsResponse schema # The endpoint will restructure this into nested format return { # Product stats @@ -167,41 +167,41 @@ class StatsService: "inventory_locations_count": inventory_locations, } - except VendorNotFoundException: + except StoreNotFoundException: raise except Exception as e: logger.error( - f"Failed to retrieve vendor statistics for vendor {vendor_id}: {str(e)}" + f"Failed to retrieve store statistics for store {store_id}: {str(e)}" ) raise AdminOperationException( - operation="get_vendor_stats", + operation="get_store_stats", reason=f"Database query failed: {str(e)}", - target_type="vendor", - target_id=str(vendor_id), + target_type="store", + target_id=str(store_id), ) - def get_vendor_analytics( - self, db: Session, vendor_id: int, period: str = "30d" + def get_store_analytics( + self, db: Session, store_id: int, period: str = "30d" ) -> dict[str, Any]: """ - Get a specific vendor analytics for a time period. + Get a specific store analytics for a time period. Args: db: Database session - vendor_id: Vendor ID + store_id: Store ID period: Time period (7d, 30d, 90d, 1y) Returns: Analytics data Raises: - VendorNotFoundException: If vendor doesn't exist + StoreNotFoundException: If store doesn't exist AdminOperationException: If database query fails """ - # Verify vendor exists - vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() - if not vendor: - raise VendorNotFoundException(str(vendor_id), identifier_type="id") + # Verify store exists + store = db.query(Store).filter(Store.id == store_id).first() + if not store: + raise StoreNotFoundException(str(store_id), identifier_type="id") try: # Parse period @@ -212,7 +212,7 @@ class StatsService: recent_imports = ( db.query(MarketplaceImportJob) .filter( - MarketplaceImportJob.vendor_id == vendor_id, + MarketplaceImportJob.store_id == store_id, MarketplaceImportJob.created_at >= start_date, ) .count() @@ -222,14 +222,14 @@ class StatsService: products_added = ( db.query(Product) .filter( - Product.vendor_id == vendor_id, Product.created_at >= start_date + Product.store_id == store_id, Product.created_at >= start_date ) .count() ) # Inventory changes inventory_entries = ( - db.query(Inventory).filter(Inventory.vendor_id == vendor_id).count() + db.query(Inventory).filter(Inventory.store_id == store_id).count() ) return { @@ -246,59 +246,59 @@ class StatsService: }, } - except VendorNotFoundException: + except StoreNotFoundException: raise except Exception as e: logger.error( - f"Failed to retrieve vendor analytics for vendor {vendor_id}: {str(e)}" + f"Failed to retrieve store analytics for store {store_id}: {str(e)}" ) raise AdminOperationException( - operation="get_vendor_analytics", + operation="get_store_analytics", reason=f"Database query failed: {str(e)}", - target_type="vendor", - target_id=str(vendor_id), + target_type="store", + target_id=str(store_id), ) - def get_vendor_statistics(self, db: Session) -> dict: - """Get vendor statistics for admin dashboard. + def get_store_statistics(self, db: Session) -> dict: + """Get store statistics for admin dashboard. - Returns dict compatible with VendorStatsResponse schema. + Returns dict compatible with StoreStatsResponse schema. Keys: total, verified, pending, inactive (mapped from internal names) """ try: - total_vendors = db.query(Vendor).count() - active_vendors = db.query(Vendor).filter(Vendor.is_active == True).count() - verified_vendors = ( - db.query(Vendor).filter(Vendor.is_verified == True).count() + total_stores = db.query(Store).count() + active_stores = db.query(Store).filter(Store.is_active == True).count() + verified_stores = ( + db.query(Store).filter(Store.is_verified == True).count() ) - inactive_vendors = total_vendors - active_vendors + inactive_stores = total_stores - active_stores # Pending = active but not yet verified - pending_vendors = ( - db.query(Vendor) - .filter(Vendor.is_active == True, Vendor.is_verified == False) + pending_stores = ( + db.query(Store) + .filter(Store.is_active == True, Store.is_verified == False) .count() ) return { - # Schema-compatible fields (VendorStatsResponse) - "total": total_vendors, - "verified": verified_vendors, - "pending": pending_vendors, - "inactive": inactive_vendors, + # Schema-compatible fields (StoreStatsResponse) + "total": total_stores, + "verified": verified_stores, + "pending": pending_stores, + "inactive": inactive_stores, # Legacy fields for backward compatibility - "total_vendors": total_vendors, - "active_vendors": active_vendors, - "inactive_vendors": inactive_vendors, - "verified_vendors": verified_vendors, - "pending_vendors": pending_vendors, + "total_stores": total_stores, + "active_stores": active_stores, + "inactive_stores": inactive_stores, + "verified_stores": verified_stores, + "pending_stores": pending_stores, "verification_rate": ( - (verified_vendors / total_vendors * 100) if total_vendors > 0 else 0 + (verified_stores / total_stores * 100) if total_stores > 0 else 0 ), } except Exception as e: - logger.error(f"Failed to get vendor statistics: {str(e)}") + logger.error(f"Failed to get store statistics: {str(e)}") raise AdminOperationException( - operation="get_vendor_statistics", reason="Database query failed" + operation="get_store_statistics", reason="Database query failed" ) # ======================================================================== @@ -319,8 +319,8 @@ class StatsService: AdminOperationException: If database query fails """ try: - # Vendors - total_vendors = db.query(Vendor).filter(Vendor.is_active == True).count() + # Stores + total_stores = db.query(Store).filter(Store.is_active == True).count() # Products total_catalog_products = db.query(Product).count() @@ -343,7 +343,7 @@ class StatsService: "unique_brands": unique_brands, "unique_categories": unique_categories, "unique_marketplaces": unique_marketplaces, - "unique_vendors": total_vendors, + "unique_stores": total_stores, "total_inventory_entries": inventory_stats.get("total_entries", 0), "total_inventory_quantity": inventory_stats.get("total_quantity", 0), } @@ -373,8 +373,8 @@ class StatsService: db.query( MarketplaceProduct.marketplace, func.count(MarketplaceProduct.id).label("total_products"), - func.count(func.distinct(MarketplaceProduct.vendor_name)).label( - "unique_vendors" + func.count(func.distinct(MarketplaceProduct.store_name)).label( + "unique_stores" ), func.count(func.distinct(MarketplaceProduct.brand)).label( "unique_brands" @@ -389,7 +389,7 @@ class StatsService: { "marketplace": stat.marketplace, "total_products": stat.total_products, - "unique_vendors": stat.unique_vendors, + "unique_stores": stat.unique_stores, "unique_brands": stat.unique_brands, } for stat in marketplace_stats diff --git a/app/modules/analytics/services/usage_service.py b/app/modules/analytics/services/usage_service.py index e294b294..8001ac80 100644 --- a/app/modules/analytics/services/usage_service.py +++ b/app/modules/analytics/services/usage_service.py @@ -2,12 +2,13 @@ """ Usage and limits service. -This is the canonical location for the usage service. - Provides methods for: - Getting current usage vs limits - Calculating upgrade recommendations - Checking limits before actions + +Uses the feature provider system for usage counting +and feature_service for limit resolution. """ import logging @@ -17,8 +18,8 @@ from sqlalchemy import func from sqlalchemy.orm import Session from app.modules.catalog.models import Product -from app.modules.billing.models import SubscriptionTier, VendorSubscription -from app.modules.tenancy.models import VendorUser +from app.modules.billing.models import MerchantSubscription, SubscriptionTier +from app.modules.tenancy.models import StoreUser logger = logging.getLogger(__name__) @@ -87,22 +88,26 @@ class LimitCheckData: class UsageService: """Service for usage and limits management.""" - def get_vendor_usage(self, db: Session, vendor_id: int) -> UsageData: + def _resolve_store_to_subscription( + self, db: Session, store_id: int + ) -> MerchantSubscription | None: + """Resolve store_id to MerchantSubscription.""" + from app.modules.billing.services.subscription_service import subscription_service + return subscription_service.get_subscription_for_store(db, store_id) + + def get_store_usage(self, db: Session, store_id: int) -> UsageData: """ - Get comprehensive usage data for a vendor. + Get comprehensive usage data for a store. Returns current usage, limits, and upgrade recommendations. """ - from app.modules.billing.services.subscription_service import subscription_service - - # Get subscription - subscription = subscription_service.get_or_create_subscription(db, vendor_id) + subscription = self._resolve_store_to_subscription(db, store_id) # Get current tier - tier = self._get_tier(db, subscription) + tier = subscription.tier if subscription else None # Calculate usage metrics - usage_metrics = self._calculate_usage_metrics(db, vendor_id, subscription) + usage_metrics = self._calculate_usage_metrics(db, store_id, subscription) # Check for approaching/reached limits has_limits_approaching = any(m.is_approaching_limit for m in usage_metrics) @@ -122,11 +127,15 @@ class UsageService: usage_metrics, has_limits_reached, has_limits_approaching ) + tier_code = tier.code if tier else "unknown" + tier_name = tier.name if tier else "Unknown" + tier_price = tier.price_monthly_cents if tier else 0 + return UsageData( tier=TierInfoData( - code=tier.code if tier else subscription.tier, - name=tier.name if tier else subscription.tier.title(), - price_monthly_cents=tier.price_monthly_cents if tier else 0, + code=tier_code, + name=tier_name, + price_monthly_cents=tier_price, is_highest_tier=is_highest_tier, ), usage=usage_metrics, @@ -138,68 +147,55 @@ class UsageService: ) def check_limit( - self, db: Session, vendor_id: int, limit_type: str + self, db: Session, store_id: int, limit_type: str ) -> LimitCheckData: """ Check a specific limit before performing an action. Args: db: Database session - vendor_id: Vendor ID - limit_type: One of "orders", "products", "team_members" - - Returns: - LimitCheckData with proceed status and upgrade info + store_id: Store ID + limit_type: Feature code (e.g., "orders_per_month", "products_limit", "team_members") """ - from app.modules.billing.services.subscription_service import subscription_service + from app.modules.billing.services.feature_service import feature_service - if limit_type == "orders": - can_proceed, message = subscription_service.can_create_order(db, vendor_id) - subscription = subscription_service.get_subscription(db, vendor_id) - current = subscription.orders_this_period if subscription else 0 - limit = subscription.orders_limit if subscription else 0 + # Map legacy limit_type names to feature codes + feature_code_map = { + "orders": "orders_per_month", + "products": "products_limit", + "team_members": "team_members", + } + feature_code = feature_code_map.get(limit_type, limit_type) - elif limit_type == "products": - can_proceed, message = subscription_service.can_add_product(db, vendor_id) - subscription = subscription_service.get_subscription(db, vendor_id) - current = self._get_product_count(db, vendor_id) - limit = subscription.products_limit if subscription else 0 + can_proceed, message = feature_service.check_resource_limit( + db, feature_code, store_id=store_id + ) - elif limit_type == "team_members": - can_proceed, message = subscription_service.can_add_team_member(db, vendor_id) - subscription = subscription_service.get_subscription(db, vendor_id) - current = self._get_team_member_count(db, vendor_id) - limit = subscription.team_members_limit if subscription else 0 + # Get current usage for response + current = 0 + limit = None + if feature_code == "products_limit": + current = self._get_product_count(db, store_id) + elif feature_code == "team_members": + current = self._get_team_member_count(db, store_id) - else: - return LimitCheckData( - limit_type=limit_type, - can_proceed=True, - current=0, - limit=None, - percentage=0, - message=f"Unknown limit type: {limit_type}", - upgrade_tier_code=None, - upgrade_tier_name=None, - ) + # Get effective limit + subscription = self._resolve_store_to_subscription(db, store_id) + if subscription and subscription.tier: + limit = subscription.tier.get_limit_for_feature(feature_code) - # Calculate percentage - is_unlimited = limit is None or limit < 0 - percentage = 0 if is_unlimited else (current / limit * 100 if limit > 0 else 100) + is_unlimited = limit is None + percentage = 0 if is_unlimited else (current / limit * 100 if limit and limit > 0 else 100) # Get upgrade info if at limit upgrade_tier_code = None upgrade_tier_name = None - if not can_proceed: - subscription = subscription_service.get_subscription(db, vendor_id) - current_tier = subscription.tier_obj if subscription else None - - if current_tier: - next_tier = self._get_next_tier(db, current_tier) - if next_tier: - upgrade_tier_code = next_tier.code - upgrade_tier_name = next_tier.name + if not can_proceed and subscription and subscription.tier: + next_tier = self._get_next_tier(db, subscription.tier) + if next_tier: + upgrade_tier_code = next_tier.code + upgrade_tier_name = next_tier.name return LimitCheckData( limit_type=limit_type, @@ -216,111 +212,83 @@ class UsageService: # Private Helper Methods # ========================================================================= - def _get_tier( - self, db: Session, subscription: VendorSubscription - ) -> SubscriptionTier | None: - """Get tier from subscription or query by code.""" - tier = subscription.tier_obj - if not tier: - tier = ( - db.query(SubscriptionTier) - .filter(SubscriptionTier.code == subscription.tier) - .first() - ) - return tier - - def _get_product_count(self, db: Session, vendor_id: int) -> int: - """Get product count for vendor.""" + def _get_product_count(self, db: Session, store_id: int) -> int: + """Get product count for store.""" return ( db.query(func.count(Product.id)) - .filter(Product.vendor_id == vendor_id) + .filter(Product.store_id == store_id) .scalar() or 0 ) - def _get_team_member_count(self, db: Session, vendor_id: int) -> int: - """Get active team member count for vendor.""" + def _get_team_member_count(self, db: Session, store_id: int) -> int: + """Get active team member count for store.""" return ( - db.query(func.count(VendorUser.id)) - .filter(VendorUser.vendor_id == vendor_id, VendorUser.is_active == True) # noqa: E712 + db.query(func.count(StoreUser.id)) + .filter(StoreUser.store_id == store_id, StoreUser.is_active == True) # noqa: E712 .scalar() or 0 ) def _calculate_usage_metrics( - self, db: Session, vendor_id: int, subscription: VendorSubscription + self, db: Session, store_id: int, subscription: MerchantSubscription | None ) -> list[UsageMetricData]: - """Calculate all usage metrics for a vendor.""" + """Calculate all usage metrics for a store using TierFeatureLimit.""" metrics = [] + tier = subscription.tier if subscription else None - # Orders this period - orders_current = subscription.orders_this_period or 0 - orders_limit = subscription.orders_limit - orders_unlimited = orders_limit is None or orders_limit < 0 - orders_percentage = ( - 0 - if orders_unlimited - else (orders_current / orders_limit * 100 if orders_limit > 0 else 100) - ) + # Define the quantitative features to track + feature_configs = [ + ("orders_per_month", "orders", lambda: self._get_orders_this_period(db, store_id, subscription)), + ("products_limit", "products", lambda: self._get_product_count(db, store_id)), + ("team_members", "team_members", lambda: self._get_team_member_count(db, store_id)), + ] - metrics.append( - UsageMetricData( - name="orders", - current=orders_current, - limit=None if orders_unlimited else orders_limit, - percentage=orders_percentage, - is_unlimited=orders_unlimited, - is_at_limit=not orders_unlimited and orders_current >= orders_limit, - is_approaching_limit=not orders_unlimited and orders_percentage >= 80, + for feature_code, display_name, count_fn in feature_configs: + current = count_fn() + limit = tier.get_limit_for_feature(feature_code) if tier else 0 + is_unlimited = limit is None + percentage = ( + 0 + if is_unlimited + else (current / limit * 100 if limit and limit > 0 else 100) ) - ) - # Products - products_count = self._get_product_count(db, vendor_id) - products_limit = subscription.products_limit - products_unlimited = products_limit is None or products_limit < 0 - products_percentage = ( - 0 - if products_unlimited - else (products_count / products_limit * 100 if products_limit > 0 else 100) - ) - - metrics.append( - UsageMetricData( - name="products", - current=products_count, - limit=None if products_unlimited else products_limit, - percentage=products_percentage, - is_unlimited=products_unlimited, - is_at_limit=not products_unlimited and products_count >= products_limit, - is_approaching_limit=not products_unlimited and products_percentage >= 80, + metrics.append( + UsageMetricData( + name=display_name, + current=current, + limit=None if is_unlimited else limit, + percentage=percentage, + is_unlimited=is_unlimited, + is_at_limit=not is_unlimited and limit is not None and current >= limit, + is_approaching_limit=not is_unlimited and percentage >= 80, + ) ) - ) - - # Team members - team_count = self._get_team_member_count(db, vendor_id) - team_limit = subscription.team_members_limit - team_unlimited = team_limit is None or team_limit < 0 - team_percentage = ( - 0 - if team_unlimited - else (team_count / team_limit * 100 if team_limit > 0 else 100) - ) - - metrics.append( - UsageMetricData( - name="team_members", - current=team_count, - limit=None if team_unlimited else team_limit, - percentage=team_percentage, - is_unlimited=team_unlimited, - is_at_limit=not team_unlimited and team_count >= team_limit, - is_approaching_limit=not team_unlimited and team_percentage >= 80, - ) - ) return metrics + def _get_orders_this_period( + self, db: Session, store_id: int, subscription: MerchantSubscription | None + ) -> int: + """Get order count for the current billing period.""" + from app.modules.orders.models import Order + + period_start = subscription.period_start if subscription else None + if not period_start: + from datetime import datetime, UTC + period_start = datetime.now(UTC).replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + return ( + db.query(func.count(Order.id)) + .filter( + Order.store_id == store_id, + Order.created_at >= period_start, + ) + .scalar() + or 0 + ) + def _get_next_tier( self, db: Session, current_tier: SubscriptionTier | None ) -> SubscriptionTier | None: @@ -343,50 +311,26 @@ class UsageService: """Build upgrade tier information with benefits.""" benefits = [] - # Numeric limit benefits - if next_tier.orders_per_month and ( - not current_tier - or ( - current_tier.orders_per_month - and next_tier.orders_per_month > current_tier.orders_per_month - ) - ): - if next_tier.orders_per_month < 0: - benefits.append("Unlimited orders per month") - else: - benefits.append(f"{next_tier.orders_per_month:,} orders/month") - - if next_tier.products_limit and ( - not current_tier - or ( - current_tier.products_limit - and next_tier.products_limit > current_tier.products_limit - ) - ): - if next_tier.products_limit < 0: - benefits.append("Unlimited products") - else: - benefits.append(f"{next_tier.products_limit:,} products") - - if next_tier.team_members and ( - not current_tier - or ( - current_tier.team_members - and next_tier.team_members > current_tier.team_members - ) - ): - if next_tier.team_members < 0: - benefits.append("Unlimited team members") - else: - benefits.append(f"{next_tier.team_members} team members") - - # Feature benefits - current_features = ( - set(current_tier.features) if current_tier and current_tier.features else set() - ) - next_features = set(next_tier.features) if next_tier.features else set() + current_features = current_tier.get_feature_codes() if current_tier else set() + next_features = next_tier.get_feature_codes() new_features = next_features - current_features + # Numeric limit improvements + limit_features = [ + ("orders_per_month", "orders/month"), + ("products_limit", "products"), + ("team_members", "team members"), + ] + for feature_code, label in limit_features: + next_limit = next_tier.get_limit_for_feature(feature_code) + current_limit = current_tier.get_limit_for_feature(feature_code) if current_tier else 0 + + if next_limit is None and (current_limit is not None and current_limit != 0): + benefits.append(f"Unlimited {label}") + elif next_limit is not None and (current_limit is None or next_limit > (current_limit or 0)): + benefits.append(f"{next_limit:,} {label}") + + # Binary feature benefits feature_names = { "analytics_dashboard": "Advanced Analytics", "api_access": "API Access", diff --git a/app/modules/analytics/static/vendor/js/analytics.js b/app/modules/analytics/static/store/js/analytics.js similarity index 78% rename from app/modules/analytics/static/vendor/js/analytics.js rename to app/modules/analytics/static/store/js/analytics.js index ad7ac13e..09636f92 100644 --- a/app/modules/analytics/static/vendor/js/analytics.js +++ b/app/modules/analytics/static/store/js/analytics.js @@ -1,16 +1,16 @@ -// app/modules/analytics/static/vendor/js/analytics.js +// app/modules/analytics/static/store/js/analytics.js /** - * Vendor analytics and reports page logic + * Store analytics and reports page logic * View business metrics and performance data */ -const vendorAnalyticsLog = window.LogConfig.loggers.vendorAnalytics || - window.LogConfig.createLogger('vendorAnalytics', false); +const storeAnalyticsLog = window.LogConfig.loggers.storeAnalytics || + window.LogConfig.createLogger('storeAnalytics', false); -vendorAnalyticsLog.info('Loading...'); +storeAnalyticsLog.info('Loading...'); -function vendorAnalytics() { - vendorAnalyticsLog.info('vendorAnalytics() called'); +function storeAnalytics() { + storeAnalyticsLog.info('storeAnalytics() called'); return { // Inherit base layout state @@ -36,7 +36,7 @@ function vendorAnalytics() { analytics: null, stats: null, - // Dashboard stats (from vendor stats endpoint) + // Dashboard stats (from store stats endpoint) dashboardStats: { total_products: 0, active_products: 0, @@ -49,16 +49,16 @@ function vendorAnalytics() { }, async init() { - vendorAnalyticsLog.info('Analytics init() called'); + storeAnalyticsLog.info('Analytics init() called'); // Guard against multiple initialization - if (window._vendorAnalyticsInitialized) { - vendorAnalyticsLog.warn('Already initialized, skipping'); + if (window._storeAnalyticsInitialized) { + storeAnalyticsLog.warn('Already initialized, skipping'); return; } - window._vendorAnalyticsInitialized = true; + window._storeAnalyticsInitialized = true; - // IMPORTANT: Call parent init first to set vendorCode from URL + // IMPORTANT: Call parent init first to set storeCode from URL const parentInit = data().init; if (parentInit) { await parentInit.call(this); @@ -67,13 +67,13 @@ function vendorAnalytics() { try { await this.loadAllData(); } catch (error) { - vendorAnalyticsLog.error('Init failed:', error); + storeAnalyticsLog.error('Init failed:', error); this.error = 'Failed to initialize analytics page'; } finally { this.loading = false; } - vendorAnalyticsLog.info('Analytics initialization complete'); + storeAnalyticsLog.info('Analytics initialization complete'); }, /** @@ -93,9 +93,9 @@ function vendorAnalytics() { this.analytics = analyticsResponse; this.dashboardStats = statsResponse; - vendorAnalyticsLog.info('Loaded analytics data'); + storeAnalyticsLog.info('Loaded analytics data'); } catch (error) { - vendorAnalyticsLog.error('Failed to load data:', error); + storeAnalyticsLog.error('Failed to load data:', error); this.error = error.message || 'Failed to load analytics data'; } finally { this.loading = false; @@ -107,12 +107,12 @@ function vendorAnalytics() { */ async fetchAnalytics() { try { - const response = await apiClient.get(`/vendor/analytics?period=${this.period}`); + const response = await apiClient.get(`/store/analytics?period=${this.period}`); return response; } catch (error) { // Analytics might require feature access if (error.status === 403) { - vendorAnalyticsLog.warn('Analytics feature not available'); + storeAnalyticsLog.warn('Analytics feature not available'); return null; } throw error; @@ -124,7 +124,7 @@ function vendorAnalytics() { */ async fetchStats() { try { - const response = await apiClient.get(`/vendor/dashboard/stats`); + const response = await apiClient.get(`/store/dashboard/stats`); return { total_products: response.catalog?.total_products || 0, active_products: response.catalog?.active_products || 0, @@ -136,7 +136,7 @@ function vendorAnalytics() { low_stock_count: response.inventory?.low_stock_count || 0 }; } catch (error) { - vendorAnalyticsLog.error('Failed to fetch stats:', error); + storeAnalyticsLog.error('Failed to fetch stats:', error); return this.dashboardStats; } }, @@ -149,7 +149,7 @@ function vendorAnalytics() { try { await this.loadAllData(); } catch (error) { - vendorAnalyticsLog.error('Failed to change period:', error); + storeAnalyticsLog.error('Failed to change period:', error); } }, @@ -166,7 +166,7 @@ function vendorAnalytics() { */ formatNumber(num) { if (num === null || num === undefined) return '0'; - const locale = window.VENDOR_CONFIG?.locale || 'en-GB'; + const locale = window.STORE_CONFIG?.locale || 'en-GB'; return num.toLocaleString(locale); }, diff --git a/app/modules/analytics/templates/analytics/vendor/analytics.html b/app/modules/analytics/templates/analytics/store/analytics.html similarity index 97% rename from app/modules/analytics/templates/analytics/vendor/analytics.html rename to app/modules/analytics/templates/analytics/store/analytics.html index 9a5df9d2..296fd7d9 100644 --- a/app/modules/analytics/templates/analytics/vendor/analytics.html +++ b/app/modules/analytics/templates/analytics/store/analytics.html @@ -1,11 +1,11 @@ -{# app/modules/analytics/templates/analytics/vendor/analytics.html #} -{% extends "vendor/base.html" %} +{# app/modules/analytics/templates/analytics/store/analytics.html #} +{% extends "store/base.html" %} {% from 'shared/macros/headers.html' import page_header_flex, refresh_button %} {% from 'shared/macros/alerts.html' import loading_state, error_state %} {% block title %}Analytics{% endblock %} -{% block alpine_data %}vendorAnalytics(){% endblock %} +{% block alpine_data %}storeAnalytics(){% endblock %} {% block content %} @@ -164,7 +164,7 @@
Upgrade your plan to access detailed analytics including import trends, product performance, and more.
- View Plans @@ -227,5 +227,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/app/modules/base.py b/app/modules/base.py index a330430b..0c45bbbc 100644 --- a/app/modules/base.py +++ b/app/modules/base.py @@ -45,6 +45,7 @@ if TYPE_CHECKING: from pydantic import BaseModel from app.modules.contracts.audit import AuditProviderProtocol + from app.modules.contracts.features import FeatureProviderProtocol from app.modules.contracts.metrics import MetricsProviderProtocol from app.modules.contracts.widgets import DashboardWidgetProviderProtocol @@ -65,7 +66,7 @@ class MenuItemDefinition: id: Unique identifier (e.g., "catalog.products", "orders.list") label_key: i18n key for the menu item label icon: Lucide icon name (e.g., "box", "shopping-cart") - route: URL path (can include placeholders like {vendor_code}) + route: URL path (can include placeholders like {store_code}) order: Sort order within section (lower = higher priority) is_mandatory: If True, cannot be hidden by user preferences requires_permission: Permission code required to see this item @@ -157,7 +158,7 @@ class PermissionDefinition: label_key: i18n key for the permission label description_key: i18n key for permission description category: Grouping category for UI organization (e.g., "products", "orders") - is_owner_only: If True, only vendor owners can have this permission + is_owner_only: If True, only store owners can have this permission Example: PermissionDefinition( @@ -251,7 +252,7 @@ class ModuleDefinition: # Routes admin_router: FastAPI router for admin routes - vendor_router: FastAPI router for vendor routes + store_router: FastAPI router for store routes # Lifecycle hooks on_enable: Called when module is enabled for a platform @@ -277,7 +278,7 @@ class ModuleDefinition: features=["subscription_management", "billing_history", "stripe_integration"], menu_items={ FrontendType.ADMIN: ["subscription-tiers", "subscriptions", "billing-history"], - FrontendType.VENDOR: ["billing"], + FrontendType.STORE: ["billing"], }, ) @@ -347,7 +348,7 @@ class ModuleDefinition: # Routes (registered dynamically) # ========================================================================= admin_router: "APIRouter | None" = None - vendor_router: "APIRouter | None" = None + store_router: "APIRouter | None" = None # ========================================================================= # Lifecycle Hooks @@ -455,6 +456,27 @@ class ModuleDefinition: # The provider will be discovered by core's AuditAggregator service. audit_provider: "Callable[[], AuditProviderProtocol] | None" = None + # ========================================================================= + # Feature Provider (Module-Driven Billable Features) + # ========================================================================= + # Callable that returns a FeatureProviderProtocol implementation. + # Use a callable (factory function) to enable lazy loading and avoid + # circular imports. Each module can declare its billable features + # and provide usage tracking for limit enforcement. + # + # Example: + # def _get_feature_provider(): + # from app.modules.catalog.services.catalog_features import catalog_feature_provider + # return catalog_feature_provider + # + # catalog_module = ModuleDefinition( + # code="catalog", + # feature_provider=_get_feature_provider, + # ) + # + # The provider will be discovered by billing's FeatureAggregator service. + feature_provider: "Callable[[], FeatureProviderProtocol] | None" = None + # ========================================================================= # Menu Item Methods (Legacy - uses menu_items dict of IDs) # ========================================================================= @@ -798,7 +820,7 @@ class ModuleDefinition: Get context contribution from this module for a frontend type. Args: - frontend_type: The frontend type (PLATFORM, ADMIN, VENDOR, STOREFRONT) + frontend_type: The frontend type (PLATFORM, ADMIN, STORE, STOREFRONT) request: FastAPI Request object db: Database session platform: Platform object (may be None for some contexts) @@ -885,6 +907,28 @@ class ModuleDefinition: return None return self.audit_provider() + # ========================================================================= + # Feature Provider Methods + # ========================================================================= + + def has_feature_provider(self) -> bool: + """Check if this module has a feature provider.""" + return self.feature_provider is not None + + def get_feature_provider_instance(self) -> "FeatureProviderProtocol | None": + """ + Get the feature provider instance for this module. + + Calls the feature_provider factory function to get the provider. + Returns None if no provider is configured. + + Returns: + FeatureProviderProtocol instance, or None + """ + if self.feature_provider is None: + return None + return self.feature_provider() + # ========================================================================= # Magic Methods # ========================================================================= diff --git a/app/modules/billing/__init__.py b/app/modules/billing/__init__.py index e67c49c0..db870f67 100644 --- a/app/modules/billing/__init__.py +++ b/app/modules/billing/__init__.py @@ -3,24 +3,25 @@ Billing Module - Subscription and payment management. This module provides: -- Subscription tier management -- Vendor subscription CRUD +- Merchant-level subscription management (per merchant per platform) +- Subscription tier management with TierFeatureLimit - Billing history and invoices - Stripe integration - Scheduled tasks for subscription lifecycle Routes: - Admin: /api/v1/admin/subscriptions/* -- Vendor: /api/v1/vendor/billing/* +- Store: /api/v1/store/billing/* +- Merchant: /api/v1/merchants/billing/* Menu Items: - Admin: subscription-tiers, subscriptions, billing-history -- Vendor: billing, invoices +- Store: billing, invoices Usage: from app.modules.billing import billing_module from app.modules.billing.services import subscription_service, stripe_service - from app.modules.billing.models import VendorSubscription, SubscriptionTier + from app.modules.billing.models import MerchantSubscription, SubscriptionTier from app.modules.billing.exceptions import TierLimitExceededException """ diff --git a/app/modules/billing/definition.py b/app/modules/billing/definition.py index 8ceb71b4..17d8c0a6 100644 --- a/app/modules/billing/definition.py +++ b/app/modules/billing/definition.py @@ -27,23 +27,34 @@ def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any Returns pricing tier data for the marketing pricing page. """ from app.core.config import settings - from app.modules.billing.models import TIER_LIMITS, TierCode + from app.modules.billing.models import SubscriptionTier, TierCode + + tiers_db = ( + db.query(SubscriptionTier) + .filter( + SubscriptionTier.is_active == True, # noqa: E712 + SubscriptionTier.is_public == True, # noqa: E712 + ) + .order_by(SubscriptionTier.display_order) + .all() + ) tiers = [] - for tier_code, limits in TIER_LIMITS.items(): + for tier in tiers_db: + feature_codes = sorted(tier.get_feature_codes()) tiers.append({ - "code": tier_code.value, - "name": limits["name"], - "price_monthly": limits["price_monthly_cents"] / 100, - "price_annual": (limits["price_annual_cents"] / 100) - if limits.get("price_annual_cents") + "code": tier.code, + "name": tier.name, + "price_monthly": tier.price_monthly_cents / 100, + "price_annual": (tier.price_annual_cents / 100) + if tier.price_annual_cents else None, - "orders_per_month": limits.get("orders_per_month"), - "products_limit": limits.get("products_limit"), - "team_members": limits.get("team_members"), - "features": limits.get("features", []), - "is_popular": tier_code == TierCode.PROFESSIONAL, - "is_enterprise": tier_code == TierCode.ENTERPRISE, + "feature_codes": feature_codes, + "products_limit": tier.get_limit_for_feature("products_limit"), + "orders_per_month": tier.get_limit_for_feature("orders_per_month"), + "team_members": tier.get_limit_for_feature("team_members"), + "is_popular": tier.code == TierCode.PROFESSIONAL.value, + "is_enterprise": tier.code == TierCode.ENTERPRISE.value, }) return { @@ -65,11 +76,18 @@ def _get_admin_router(): return admin_router -def _get_vendor_router(): - """Lazy import of vendor router to avoid circular imports.""" - from app.modules.billing.routes.api.vendor import vendor_router +def _get_store_router(): + """Lazy import of store router to avoid circular imports.""" + from app.modules.billing.routes.api.store import store_router - return vendor_router + return store_router + + +def _get_feature_provider(): + """Lazy import of feature provider to avoid circular imports.""" + from app.modules.billing.services.billing_features import billing_feature_provider + + return billing_feature_provider # Billing module definition @@ -77,7 +95,7 @@ billing_module = ModuleDefinition( code="billing", name="Billing & Subscriptions", description=( - "Core subscription management, tier limits, vendor billing, and invoice history. " + "Core subscription management, tier limits, store billing, and invoice history. " "Provides tier-based feature gating used throughout the platform. " "Uses the payments module for actual payment processing." ), @@ -88,8 +106,8 @@ billing_module = ModuleDefinition( "billing_history", # View invoices and payment history "invoice_generation", # Generate and download invoices "subscription_analytics", # Subscription stats and metrics - "trial_management", # Manage vendor trial periods - "limit_overrides", # Override tier limits per vendor + "trial_management", # Manage store trial periods + "limit_overrides", # Override tier limits per store ], # Module-driven permissions permissions=[ @@ -127,12 +145,12 @@ billing_module = ModuleDefinition( menu_items={ FrontendType.ADMIN: [ "subscription-tiers", # Manage tier definitions - "subscriptions", # View/manage vendor subscriptions + "subscriptions", # View/manage store subscriptions "billing-history", # View all invoices ], - FrontendType.VENDOR: [ - "billing", # Vendor billing dashboard - "invoices", # Vendor invoice history + FrontendType.STORE: [ + "billing", # Store billing dashboard + "invoices", # Store invoice history ], }, # New module-driven menu definitions @@ -153,7 +171,7 @@ billing_module = ModuleDefinition( ), MenuItemDefinition( id="subscriptions", - label_key="billing.menu.vendor_subscriptions", + label_key="billing.menu.store_subscriptions", icon="credit-card", route="/admin/subscriptions", order=20, @@ -168,7 +186,7 @@ billing_module = ModuleDefinition( ], ), ], - FrontendType.VENDOR: [ + FrontendType.STORE: [ MenuSectionDefinition( id="sales", label_key="billing.menu.sales_orders", @@ -179,7 +197,7 @@ billing_module = ModuleDefinition( id="invoices", label_key="billing.menu.invoices", icon="currency-euro", - route="/vendor/{vendor_code}/invoices", + route="/store/{store_code}/invoices", order=30, ), ], @@ -194,7 +212,7 @@ billing_module = ModuleDefinition( id="billing", label_key="billing.menu.billing", icon="credit-card", - route="/vendor/{vendor_code}/billing", + route="/store/{store_code}/billing", order=30, ), ], @@ -244,6 +262,8 @@ billing_module = ModuleDefinition( options={"queue": "scheduled"}, ), ], + # Feature provider for feature flags + feature_provider=_get_feature_provider, ) @@ -255,7 +275,7 @@ def get_billing_module_with_routers() -> ModuleDefinition: during module initialization. """ billing_module.admin_router = _get_admin_router() - billing_module.vendor_router = _get_vendor_router() + billing_module.store_router = _get_store_router() return billing_module diff --git a/app/modules/billing/dependencies/feature_gate.py b/app/modules/billing/dependencies/feature_gate.py index 7f057bad..bcd7233d 100644 --- a/app/modules/billing/dependencies/feature_gate.py +++ b/app/modules/billing/dependencies/feature_gate.py @@ -1,44 +1,50 @@ -# app/core/feature_gate.py +# app/modules/billing/dependencies/feature_gate.py """ Feature gating decorator and dependencies for tier-based access control. +Resolves store → merchant → subscription → tier → TierFeatureLimit. + Provides: - @require_feature decorator for endpoints - RequireFeature dependency for flexible usage +- RequireWithinLimit dependency for quantitative checks - FeatureNotAvailableError exception with upgrade info Usage: # As decorator (simple) @router.get("/analytics") - @require_feature(FeatureCode.ANALYTICS_DASHBOARD) + @require_feature("analytics_dashboard") def get_analytics(...): ... # As dependency (more control) @router.get("/analytics") def get_analytics( - _: None = Depends(RequireFeature(FeatureCode.ANALYTICS_DASHBOARD)), + _: None = Depends(RequireFeature("analytics_dashboard")), ... ): ... - # Multiple features (any one required) - @require_feature(FeatureCode.ANALYTICS_DASHBOARD, FeatureCode.BASIC_REPORTS) - def get_reports(...): + # Quantitative limit check + @router.post("/products") + def create_product( + _: None = Depends(RequireWithinLimit("products_limit")), + ... + ): ... """ +import asyncio import functools import logging from typing import Callable -from fastapi import Depends, HTTPException, Request +from fastapi import Depends, HTTPException from sqlalchemy.orm import Session -from app.api.deps import get_current_vendor_api +from app.api.deps import get_current_store_api from app.core.database import get_db from app.modules.billing.services.feature_service import feature_service -from app.modules.billing.models import FeatureCode from app.modules.tenancy.models import User logger = logging.getLogger(__name__) @@ -46,7 +52,7 @@ logger = logging.getLogger(__name__) class FeatureNotAvailableError(HTTPException): """ - Exception raised when a feature is not available for the vendor's tier. + Exception raised when a feature is not available for the merchant's tier. Includes upgrade information for the frontend to display. """ @@ -61,7 +67,7 @@ class FeatureNotAvailableError(HTTPException): ): detail = { "error": "feature_not_available", - "message": f"This feature requires an upgrade to access.", + "message": "This feature requires an upgrade to access.", "feature_code": feature_code, "feature_name": feature_name, "upgrade": { @@ -77,16 +83,9 @@ class FeatureNotAvailableError(HTTPException): class RequireFeature: """ - Dependency class that checks if vendor has access to a feature. + Dependency class that checks if store's merchant has access to a feature. - Can be used as a FastAPI dependency: - @router.get("/analytics") - def get_analytics( - _: None = Depends(RequireFeature("analytics_dashboard")), - current_user: User = Depends(get_current_vendor_api), - db: Session = Depends(get_db), - ): - ... + Resolves store → merchant → subscription → tier → TierFeatureLimit. Args: *feature_codes: One or more feature codes. Access granted if ANY is available. @@ -99,58 +98,67 @@ class RequireFeature: def __call__( self, - current_user: User = Depends(get_current_vendor_api), + current_user: User = Depends(get_current_store_api), db: Session = Depends(get_db), ) -> None: - """Check if vendor has access to any of the required features.""" - vendor_id = current_user.token_vendor_id + """Check if store's merchant has access to any of the required features.""" + store_id = current_user.token_store_id - # Check if vendor has ANY of the required features for feature_code in self.feature_codes: - if feature_service.has_feature(db, vendor_id, feature_code): + if feature_service.has_feature_for_store(db, store_id, feature_code): return None - # None of the features are available - get upgrade info for first one + # None of the features are available feature_code = self.feature_codes[0] - upgrade_info = feature_service.get_feature_upgrade_info(db, feature_code) + raise FeatureNotAvailableError(feature_code=feature_code) - if upgrade_info: - raise FeatureNotAvailableError( - feature_code=feature_code, - feature_name=upgrade_info.feature_name, - required_tier_code=upgrade_info.required_tier_code, - required_tier_name=upgrade_info.required_tier_name, - required_tier_price_cents=upgrade_info.required_tier_price_monthly_cents, + +class RequireWithinLimit: + """ + Dependency that checks a quantitative resource limit. + + Resolves store → merchant → subscription → tier → TierFeatureLimit, + then checks current usage against the limit. + + Args: + feature_code: The quantitative feature to check (e.g., "products_limit") + """ + + def __init__(self, feature_code: str): + self.feature_code = feature_code + + def __call__( + self, + current_user: User = Depends(get_current_store_api), + db: Session = Depends(get_db), + ) -> None: + """Check if the resource limit allows adding more items.""" + store_id = current_user.token_store_id + + allowed, message = feature_service.check_resource_limit( + db, self.feature_code, store_id=store_id + ) + + if not allowed: + raise HTTPException( + status_code=403, + detail={ + "error": "limit_exceeded", + "message": message, + "feature_code": self.feature_code, + }, ) - else: - # Feature not found in registry - raise FeatureNotAvailableError(feature_code=feature_code) def require_feature(*feature_codes: str) -> Callable: """ Decorator to require one or more features for an endpoint. - The decorated endpoint will return 403 with upgrade info if the vendor + The decorated endpoint will return 403 if the store's merchant doesn't have access to ANY of the specified features. Args: *feature_codes: One or more feature codes. Access granted if ANY is available. - - Example: - @router.get("/analytics/dashboard") - @require_feature(FeatureCode.ANALYTICS_DASHBOARD) - async def get_analytics_dashboard( - current_user: User = Depends(get_current_vendor_api), - db: Session = Depends(get_db), - ): - ... - - # Multiple features (any one is sufficient) - @router.get("/reports") - @require_feature(FeatureCode.ANALYTICS_DASHBOARD, FeatureCode.BASIC_REPORTS) - async def get_reports(...): - ... """ if not feature_codes: raise ValueError("At least one feature code is required") @@ -158,48 +166,25 @@ def require_feature(*feature_codes: str) -> Callable: def decorator(func: Callable) -> Callable: @functools.wraps(func) async def async_wrapper(*args, **kwargs): - # Extract dependencies from kwargs db = kwargs.get("db") current_user = kwargs.get("current_user") - if not db or not current_user: - # Try to get from request if not in kwargs - request = kwargs.get("request") - if request and hasattr(request, "state"): - db = getattr(request.state, "db", None) - current_user = getattr(request.state, "user", None) - if not db or not current_user: raise HTTPException( status_code=500, detail="Feature check failed: missing db or current_user dependency", ) - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id - # Check if vendor has ANY of the required features for feature_code in feature_codes: - if feature_service.has_feature(db, vendor_id, feature_code): + if feature_service.has_feature_for_store(db, store_id, feature_code): return await func(*args, **kwargs) - # None available - raise with upgrade info - feature_code = feature_codes[0] - upgrade_info = feature_service.get_feature_upgrade_info(db, feature_code) - - if upgrade_info: - raise FeatureNotAvailableError( - feature_code=feature_code, - feature_name=upgrade_info.feature_name, - required_tier_code=upgrade_info.required_tier_code, - required_tier_name=upgrade_info.required_tier_name, - required_tier_price_cents=upgrade_info.required_tier_price_monthly_cents, - ) - else: - raise FeatureNotAvailableError(feature_code=feature_code) + raise FeatureNotAvailableError(feature_code=feature_codes[0]) @functools.wraps(func) def sync_wrapper(*args, **kwargs): - # Extract dependencies from kwargs db = kwargs.get("db") current_user = kwargs.get("current_user") @@ -209,30 +194,13 @@ def require_feature(*feature_codes: str) -> Callable: detail="Feature check failed: missing db or current_user dependency", ) - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id - # Check if vendor has ANY of the required features for feature_code in feature_codes: - if feature_service.has_feature(db, vendor_id, feature_code): + if feature_service.has_feature_for_store(db, store_id, feature_code): return func(*args, **kwargs) - # None available - raise with upgrade info - feature_code = feature_codes[0] - upgrade_info = feature_service.get_feature_upgrade_info(db, feature_code) - - if upgrade_info: - raise FeatureNotAvailableError( - feature_code=feature_code, - feature_name=upgrade_info.feature_name, - required_tier_code=upgrade_info.required_tier_code, - required_tier_name=upgrade_info.required_tier_name, - required_tier_price_cents=upgrade_info.required_tier_price_monthly_cents, - ) - else: - raise FeatureNotAvailableError(feature_code=feature_code) - - # Return appropriate wrapper based on whether func is async - import asyncio + raise FeatureNotAvailableError(feature_code=feature_codes[0]) if asyncio.iscoroutinefunction(func): return async_wrapper @@ -242,13 +210,9 @@ def require_feature(*feature_codes: str) -> Callable: return decorator -# ============================================================================ -# Convenience Exports -# ============================================================================ - __all__ = [ "require_feature", "RequireFeature", + "RequireWithinLimit", "FeatureNotAvailableError", - "FeatureCode", ] diff --git a/app/modules/billing/exceptions.py b/app/modules/billing/exceptions.py index e79293de..61f78bbd 100644 --- a/app/modules/billing/exceptions.py +++ b/app/modules/billing/exceptions.py @@ -74,10 +74,10 @@ BillingServiceError = BillingException class SubscriptionNotFoundException(ResourceNotFoundException): """Raised when a subscription is not found.""" - def __init__(self, vendor_id: int): + def __init__(self, store_id: int): super().__init__( resource_type="Subscription", - identifier=str(vendor_id), + identifier=str(store_id), error_code="SUBSCRIPTION_NOT_FOUND", ) diff --git a/app/modules/billing/locales/de.json b/app/modules/billing/locales/de.json index 0a2c39af..03056ad0 100644 --- a/app/modules/billing/locales/de.json +++ b/app/modules/billing/locales/de.json @@ -105,5 +105,23 @@ "orders_exceeded": "Monatliches Bestelllimit erreicht. Upgrade für mehr.", "products_exceeded": "Produktlimit erreicht. Upgrade für mehr.", "team_exceeded": "Teammitgliederlimit erreicht. Upgrade für mehr." + }, + "features": { + "subscription_management": { + "name": "Abonnementverwaltung", + "description": "Abonnementstufen und Abrechnung verwalten" + }, + "payment_processing": { + "name": "Zahlungsabwicklung", + "description": "Zahlungen über Stripe abwickeln" + }, + "invoicing": { + "name": "Rechnungsstellung", + "description": "Rechnungen erstellen und verwalten" + }, + "usage_tracking": { + "name": "Nutzungsverfolgung", + "description": "Funktionsnutzung gegen Stufenlimits verfolgen" + } } } diff --git a/app/modules/billing/locales/en.json b/app/modules/billing/locales/en.json index ffab8e16..ece47f56 100644 --- a/app/modules/billing/locales/en.json +++ b/app/modules/billing/locales/en.json @@ -105,5 +105,23 @@ "orders_exceeded": "Monthly order limit reached. Upgrade to continue.", "products_exceeded": "Product limit reached. Upgrade to add more.", "team_exceeded": "Team member limit reached. Upgrade to add more." + }, + "features": { + "subscription_management": { + "name": "Subscription Management", + "description": "Manage subscription tiers and billing" + }, + "payment_processing": { + "name": "Payment Processing", + "description": "Process payments via Stripe" + }, + "invoicing": { + "name": "Invoicing", + "description": "Generate and manage invoices" + }, + "usage_tracking": { + "name": "Usage Tracking", + "description": "Track feature usage against tier limits" + } } } diff --git a/app/modules/billing/locales/fr.json b/app/modules/billing/locales/fr.json index 1829825a..f9d4fd47 100644 --- a/app/modules/billing/locales/fr.json +++ b/app/modules/billing/locales/fr.json @@ -105,5 +105,23 @@ "orders_exceeded": "Limite mensuelle de commandes atteinte. Passez à un niveau supérieur.", "products_exceeded": "Limite de produits atteinte. Passez à un niveau supérieur.", "team_exceeded": "Limite de membres d'équipe atteinte. Passez à un niveau supérieur." + }, + "features": { + "subscription_management": { + "name": "Gestion des abonnements", + "description": "Gérer les niveaux d'abonnement et la facturation" + }, + "payment_processing": { + "name": "Traitement des paiements", + "description": "Traiter les paiements via Stripe" + }, + "invoicing": { + "name": "Facturation", + "description": "Générer et gérer les factures" + }, + "usage_tracking": { + "name": "Suivi d'utilisation", + "description": "Suivre l'utilisation des fonctionnalités par rapport aux limites du niveau" + } } } diff --git a/app/modules/billing/locales/lb.json b/app/modules/billing/locales/lb.json index 2622d438..44a75534 100644 --- a/app/modules/billing/locales/lb.json +++ b/app/modules/billing/locales/lb.json @@ -105,5 +105,23 @@ "orders_exceeded": "Monatlech Bestellungslimit erreecht. Upgrade fir méi.", "products_exceeded": "Produktlimit erreecht. Upgrade fir méi.", "team_exceeded": "Teammemberlimit erreecht. Upgrade fir méi." + }, + "features": { + "subscription_management": { + "name": "Abonnementverwaltung", + "description": "Abonnementstufen an Ofrechnung verwalten" + }, + "payment_processing": { + "name": "Zuelungsofwécklung", + "description": "Zuelungen iwwer Stripe ofwéckelen" + }, + "invoicing": { + "name": "Rechnungsstellung", + "description": "Rechnungen erstellen an verwalten" + }, + "usage_tracking": { + "name": "Notzungsverfolgung", + "description": "Funktiounsnotzung géint Stuflimiten verfolgen" + } } } diff --git a/app/modules/billing/migrations/versions/billing_001_merchant_subscriptions_and_feature_limits.py b/app/modules/billing/migrations/versions/billing_001_merchant_subscriptions_and_feature_limits.py new file mode 100644 index 00000000..2f7612ee --- /dev/null +++ b/app/modules/billing/migrations/versions/billing_001_merchant_subscriptions_and_feature_limits.py @@ -0,0 +1,179 @@ +# app/modules/billing/migrations/versions/billing_001_merchant_subscriptions_and_feature_limits.py +""" +Merchant subscriptions and feature limits migration. + +Creates: +- merchant_subscriptions table (replaces store_subscriptions) +- tier_feature_limits table (replaces hardcoded limit columns) +- merchant_feature_overrides table (replaces custom_*_limit columns) + +Drops: +- store_subscriptions table +- features table + +Alters: +- subscription_tiers: removes limit columns and features JSON + +Revision ID: billing_001 +""" + +from alembic import op +import sqlalchemy as sa + + +# Revision identifiers +revision = "billing_001" +down_revision = None +branch_labels = ("billing",) +depends_on = None + + +def upgrade() -> None: + # ======================================================================== + # Create merchant_subscriptions table + # ======================================================================== + op.create_table( + "merchant_subscriptions", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("platform_id", sa.Integer(), sa.ForeignKey("platforms.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("tier_id", sa.Integer(), sa.ForeignKey("subscription_tiers.id", ondelete="SET NULL"), nullable=True, index=True), + sa.Column("status", sa.String(20), nullable=False, server_default="trial", index=True), + sa.Column("is_annual", sa.Boolean(), nullable=False, server_default="0"), + sa.Column("period_start", sa.DateTime(timezone=True), nullable=False), + sa.Column("period_end", sa.DateTime(timezone=True), nullable=False), + sa.Column("trial_ends_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("stripe_customer_id", sa.String(100), nullable=True, index=True), + sa.Column("stripe_subscription_id", sa.String(100), nullable=True, index=True), + sa.Column("stripe_payment_method_id", sa.String(100), nullable=True), + sa.Column("payment_retry_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("last_payment_error", sa.Text(), nullable=True), + sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("cancellation_reason", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("merchant_id", "platform_id", name="uq_merchant_platform_subscription"), + ) + op.create_index("idx_merchant_sub_status", "merchant_subscriptions", ["merchant_id", "status"]) + op.create_index("idx_merchant_sub_platform", "merchant_subscriptions", ["platform_id", "status"]) + + # ======================================================================== + # Create tier_feature_limits table + # ======================================================================== + op.create_table( + "tier_feature_limits", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("tier_id", sa.Integer(), sa.ForeignKey("subscription_tiers.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("feature_code", sa.String(80), nullable=False, index=True), + sa.Column("limit_value", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("tier_id", "feature_code", name="uq_tier_feature_code"), + ) + op.create_index("idx_tier_feature_lookup", "tier_feature_limits", ["tier_id", "feature_code"]) + + # ======================================================================== + # Create merchant_feature_overrides table + # ======================================================================== + op.create_table( + "merchant_feature_overrides", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("platform_id", sa.Integer(), sa.ForeignKey("platforms.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("feature_code", sa.String(80), nullable=False, index=True), + sa.Column("limit_value", sa.Integer(), nullable=True), + sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default="1"), + sa.Column("reason", sa.String(255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("merchant_id", "platform_id", "feature_code", name="uq_merchant_platform_feature"), + ) + op.create_index("idx_merchant_override_lookup", "merchant_feature_overrides", ["merchant_id", "platform_id", "feature_code"]) + + # ======================================================================== + # Drop legacy tables + # ======================================================================== + op.drop_table("store_subscriptions") + op.drop_table("features") + + # ======================================================================== + # Remove legacy columns from subscription_tiers + # ======================================================================== + with op.batch_alter_table("subscription_tiers") as batch_op: + batch_op.drop_column("orders_per_month") + batch_op.drop_column("products_limit") + batch_op.drop_column("team_members") + batch_op.drop_column("order_history_months") + batch_op.drop_column("cms_pages_limit") + batch_op.drop_column("cms_custom_pages_limit") + batch_op.drop_column("features") + + # ======================================================================== + # Update stripe_webhook_events FK to merchant_subscriptions + # ======================================================================== + with op.batch_alter_table("stripe_webhook_events") as batch_op: + batch_op.drop_column("subscription_id") + batch_op.add_column( + sa.Column("merchant_subscription_id", sa.Integer(), + sa.ForeignKey("merchant_subscriptions.id"), nullable=True, index=True) + ) + + # ======================================================================== + # Add merchant_id to billing_history + # ======================================================================== + with op.batch_alter_table("billing_history") as batch_op: + batch_op.add_column( + sa.Column("merchant_id", sa.Integer(), + sa.ForeignKey("merchants.id"), nullable=True, index=True) + ) + + +def downgrade() -> None: + # Remove merchant_id from billing_history + with op.batch_alter_table("billing_history") as batch_op: + batch_op.drop_column("merchant_id") + + # Restore subscription_id on stripe_webhook_events + with op.batch_alter_table("stripe_webhook_events") as batch_op: + batch_op.drop_column("merchant_subscription_id") + batch_op.add_column( + sa.Column("subscription_id", sa.Integer(), + sa.ForeignKey("store_subscriptions.id"), nullable=True, index=True) + ) + + # Restore columns on subscription_tiers + with op.batch_alter_table("subscription_tiers") as batch_op: + batch_op.add_column(sa.Column("orders_per_month", sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column("products_limit", sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column("team_members", sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column("order_history_months", sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column("cms_pages_limit", sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column("cms_custom_pages_limit", sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column("features", sa.JSON(), nullable=True)) + + # Recreate features table + op.create_table( + "features", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("code", sa.String(50), unique=True, nullable=False), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("category", sa.String(50), nullable=False), + sa.Column("is_active", sa.Boolean(), server_default="1"), + ) + + # Recreate store_subscriptions table + op.create_table( + "store_subscriptions", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), unique=True, nullable=False), + sa.Column("tier", sa.String(20), nullable=False, server_default="essential"), + sa.Column("status", sa.String(20), nullable=False, server_default="trial"), + sa.Column("period_start", sa.DateTime(timezone=True), nullable=False), + sa.Column("period_end", sa.DateTime(timezone=True), nullable=False), + ) + + # Drop new tables + op.drop_table("merchant_feature_overrides") + op.drop_table("tier_feature_limits") + op.drop_table("merchant_subscriptions") diff --git a/app/modules/billing/models/feature.py b/app/modules/billing/models/feature.py deleted file mode 100644 index 07162bfc..00000000 --- a/app/modules/billing/models/feature.py +++ /dev/null @@ -1,200 +0,0 @@ -# app/modules/billing/models/feature.py -""" -Feature registry for tier-based access control. - -Provides a database-driven feature registry that allows: -- Dynamic feature-to-tier assignment (no code changes needed) -- UI metadata for frontend rendering -- Feature categorization for organization -- Upgrade prompts with tier info - -Features are assigned to tiers via the SubscriptionTier.features JSON array. -This model provides the metadata and acts as a registry of all available features. -""" - -import enum -from datetime import UTC, datetime - -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, Integer, String, Text -from sqlalchemy.orm import relationship - -from app.core.database import Base -from models.database.base import TimestampMixin - - -class FeatureCategory(str, enum.Enum): - """Feature categories for organization.""" - - ORDERS = "orders" - INVENTORY = "inventory" - ANALYTICS = "analytics" - INVOICING = "invoicing" - INTEGRATIONS = "integrations" - TEAM = "team" - BRANDING = "branding" - CUSTOMERS = "customers" - CMS = "cms" - - -class FeatureUILocation(str, enum.Enum): - """Where the feature appears in the UI.""" - - SIDEBAR = "sidebar" # Main navigation item - DASHBOARD = "dashboard" # Dashboard widget/section - SETTINGS = "settings" # Settings page option - API = "api" # API-only feature (no UI) - INLINE = "inline" # Inline feature within a page - - -class Feature(Base, TimestampMixin): - """ - Feature registry for tier-based access control. - - Each feature represents a capability that can be enabled/disabled per tier. - The actual tier assignment is stored in SubscriptionTier.features as a JSON - array of feature codes. This table provides metadata for: - - UI rendering (icons, labels, locations) - - Upgrade prompts (which tier unlocks this?) - - Admin management (description, categorization) - - Example features: - - analytics_dashboard: Full analytics with charts - - api_access: REST API access for integrations - - team_roles: Role-based permissions for team members - - automation_rules: Automatic order processing rules - """ - - __tablename__ = "features" - - id = Column(Integer, primary_key=True, index=True) - - # Unique identifier used in code and tier.features JSON - code = Column(String(50), unique=True, nullable=False, index=True) - - # Display info - name = Column(String(100), nullable=False) - description = Column(Text, nullable=True) - - # Categorization - category = Column(String(50), nullable=False, index=True) - - # UI metadata - tells frontend how to render - ui_location = Column(String(50), nullable=True) # sidebar, dashboard, settings, api - ui_icon = Column(String(50), nullable=True) # Icon name (e.g., "chart-bar") - ui_route = Column(String(100), nullable=True) # Route pattern (e.g., "/vendor/{code}/analytics") - ui_badge_text = Column(String(20), nullable=True) # Badge to show (e.g., "Pro", "New") - - # Minimum tier that includes this feature (for upgrade prompts) - # This is denormalized for performance - the actual assignment is in SubscriptionTier.features - minimum_tier_id = Column( - Integer, ForeignKey("subscription_tiers.id"), nullable=True, index=True - ) - minimum_tier = relationship("SubscriptionTier", foreign_keys=[minimum_tier_id]) - - # Status - is_active = Column(Boolean, default=True, nullable=False) # Feature available at all - is_visible = Column(Boolean, default=True, nullable=False) # Show in UI even if locked - display_order = Column(Integer, default=0, nullable=False) # Sort order within category - - # Indexes - __table_args__ = ( - Index("idx_feature_category_order", "category", "display_order"), - Index("idx_feature_active_visible", "is_active", "is_visible"), - ) - - def __repr__(self) -> str: - return f"View your invoices and payment history.
+| Date | +Invoice # | +Amount | +Status | +Actions | +
|---|---|---|---|---|
| + + Loading invoices... + | +||||
| + No invoices found. + | +||||
| + | + | + + | ++ + | +
+
+
+
+ View
+
+
+
+ PDF
+
+
+ |
+
Here is an overview of your account.
+Active Subscriptions
+--
+Total Stores
+--
+Current Plan
+--
++ · + Renews +
+No active subscriptions.
+Sign in to manage your account
++ © 2026 Wizamart. All rights reserved. +
+Manage your platform subscriptions and plans.
+| Platform | +Tier | +Status | +Period End | +Actions | +
|---|---|---|---|---|
| + + Loading subscriptions... + | +||||
| + No subscriptions found. + | +||||
| + + + | ++ + | ++ + | ++ | + + View Details + + | +