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"" - - def to_dict(self) -> dict: - """Convert to dictionary for API responses.""" - return { - "id": self.id, - "code": self.code, - "name": self.name, - "description": self.description, - "category": self.category, - "ui_location": self.ui_location, - "ui_icon": self.ui_icon, - "ui_route": self.ui_route, - "ui_badge_text": self.ui_badge_text, - "minimum_tier_code": self.minimum_tier.code if self.minimum_tier else None, - "minimum_tier_name": self.minimum_tier.name if self.minimum_tier else None, - "is_active": self.is_active, - "is_visible": self.is_visible, - "display_order": self.display_order, - } - - -# ============================================================================ -# Feature Code Constants -# ============================================================================ -# These constants are used throughout the codebase for type safety. -# The actual feature definitions and tier assignments are in the database. - - -class FeatureCode: - """ - Feature code constants for use in @require_feature decorator and checks. - - Usage: - @require_feature(FeatureCode.ANALYTICS_DASHBOARD) - def get_analytics(...): - ... - - if feature_service.has_feature(db, vendor_id, FeatureCode.API_ACCESS): - ... - """ - - # Orders - ORDER_MANAGEMENT = "order_management" - ORDER_BULK_ACTIONS = "order_bulk_actions" - ORDER_EXPORT = "order_export" - AUTOMATION_RULES = "automation_rules" - - # Inventory - INVENTORY_BASIC = "inventory_basic" - INVENTORY_LOCATIONS = "inventory_locations" - INVENTORY_PURCHASE_ORDERS = "inventory_purchase_orders" - LOW_STOCK_ALERTS = "low_stock_alerts" - - # Analytics - BASIC_REPORTS = "basic_reports" - ANALYTICS_DASHBOARD = "analytics_dashboard" - CUSTOM_REPORTS = "custom_reports" - EXPORT_REPORTS = "export_reports" - - # Invoicing - INVOICE_LU = "invoice_lu" - INVOICE_EU_VAT = "invoice_eu_vat" - INVOICE_BULK = "invoice_bulk" - ACCOUNTING_EXPORT = "accounting_export" - - # Integrations - LETZSHOP_SYNC = "letzshop_sync" - API_ACCESS = "api_access" - WEBHOOKS = "webhooks" - CUSTOM_INTEGRATIONS = "custom_integrations" - - # Team - SINGLE_USER = "single_user" - TEAM_BASIC = "team_basic" - TEAM_ROLES = "team_roles" - AUDIT_LOG = "audit_log" - - # Branding - BASIC_SHOP = "basic_shop" - CUSTOM_DOMAIN = "custom_domain" - WHITE_LABEL = "white_label" - - # Customers - CUSTOMER_VIEW = "customer_view" - CUSTOMER_EXPORT = "customer_export" - CUSTOMER_MESSAGING = "customer_messaging" - - # CMS - CMS_BASIC = "cms_basic" # Basic CMS functionality (override defaults) - CMS_CUSTOM_PAGES = "cms_custom_pages" # Create custom pages beyond defaults - CMS_UNLIMITED_PAGES = "cms_unlimited_pages" # No page limit - CMS_TEMPLATES = "cms_templates" # Access to page templates - CMS_SEO = "cms_seo" # Advanced SEO features - CMS_SCHEDULING = "cms_scheduling" # Schedule page publish/unpublish diff --git a/app/modules/billing/models/merchant_subscription.py b/app/modules/billing/models/merchant_subscription.py new file mode 100644 index 00000000..0e95e801 --- /dev/null +++ b/app/modules/billing/models/merchant_subscription.py @@ -0,0 +1,164 @@ +# app/modules/billing/models/merchant_subscription.py +""" +Merchant-level subscription model. + +Replaces StoreSubscription with merchant-level billing: +- One subscription per merchant per platform +- Merchant is the billing entity (not the store) +- Stores inherit features/limits from their merchant's subscription +""" + +from datetime import UTC, datetime + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + ForeignKey, + Index, + Integer, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import relationship + +from app.core.database import Base +from app.modules.billing.models.subscription import SubscriptionStatus +from models.database.base import TimestampMixin + + +class MerchantSubscription(Base, TimestampMixin): + """ + Per-merchant, per-platform subscription tracking. + + The merchant (legal entity) subscribes and pays, not the store. + A merchant can own multiple stores and subscribe per-platform. + + Example: + Merchant "Boucherie Luxembourg" subscribes to: + - Wizamart OMS (Professional tier) + - Loyalty+ (Essential tier) + + Their stores inherit features from the merchant's subscription. + """ + + __tablename__ = "merchant_subscriptions" + + id = Column(Integer, primary_key=True, index=True) + + # Who pays + merchant_id = Column( + Integer, + ForeignKey("merchants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + # Which platform + platform_id = Column( + Integer, + ForeignKey("platforms.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + # Which tier + tier_id = Column( + Integer, + ForeignKey("subscription_tiers.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + + # Status + status = Column( + String(20), + default=SubscriptionStatus.TRIAL.value, + nullable=False, + index=True, + ) + + # Billing period + is_annual = Column(Boolean, default=False, nullable=False) + period_start = Column(DateTime(timezone=True), nullable=False) + period_end = Column(DateTime(timezone=True), nullable=False) + + # Trial info + trial_ends_at = Column(DateTime(timezone=True), nullable=True) + + # Stripe integration (per merchant) + stripe_customer_id = Column(String(100), nullable=True, index=True) + stripe_subscription_id = Column(String(100), nullable=True, index=True) + stripe_payment_method_id = Column(String(100), nullable=True) + + # Payment failure tracking + payment_retry_count = Column(Integer, default=0, nullable=False) + last_payment_error = Column(Text, nullable=True) + + # Cancellation + cancelled_at = Column(DateTime(timezone=True), nullable=True) + cancellation_reason = Column(Text, nullable=True) + + # Relationships + merchant = relationship( + "Merchant", + backref="subscriptions", + foreign_keys=[merchant_id], + ) + platform = relationship( + "Platform", + foreign_keys=[platform_id], + ) + tier = relationship( + "SubscriptionTier", + foreign_keys=[tier_id], + ) + + __table_args__ = ( + UniqueConstraint( + "merchant_id", "platform_id", + name="uq_merchant_platform_subscription", + ), + Index("idx_merchant_sub_status", "merchant_id", "status"), + Index("idx_merchant_sub_platform", "platform_id", "status"), + ) + + def __repr__(self): + return ( + f"" + ) + + # ========================================================================= + # Status Checks + # ========================================================================= + + @property + def is_active(self) -> bool: + """Check if subscription allows access.""" + return self.status in [ + SubscriptionStatus.TRIAL.value, + SubscriptionStatus.ACTIVE.value, + SubscriptionStatus.PAST_DUE.value, + SubscriptionStatus.CANCELLED.value, + ] + + @property + def is_trial(self) -> bool: + """Check if currently in trial.""" + return self.status == SubscriptionStatus.TRIAL.value + + @property + def trial_days_remaining(self) -> int | None: + """Get remaining trial days.""" + if not self.is_trial or not self.trial_ends_at: + return None + remaining = (self.trial_ends_at - datetime.now(UTC)).days + return max(0, remaining) + + +__all__ = ["MerchantSubscription"] diff --git a/app/modules/billing/models/subscription.py b/app/modules/billing/models/subscription.py index 949956a4..9e8d97ad 100644 --- a/app/modules/billing/models/subscription.py +++ b/app/modules/billing/models/subscription.py @@ -4,17 +4,13 @@ Subscription database models for tier-based access control. Provides models for: - SubscriptionTier: Database-driven tier definitions with Stripe integration -- VendorSubscription: Per-vendor subscription tracking - AddOnProduct: Purchasable add-ons (domains, SSL, email packages) -- VendorAddOn: Add-ons purchased by each vendor +- StoreAddOn: Add-ons purchased by each store - StripeWebhookEvent: Idempotency tracking for webhook processing - BillingHistory: Invoice and payment history -Tier Structure: -- Essential (€49/mo): 100 orders/mo, 200 products, 1 user, LU invoicing -- Professional (€99/mo): 500 orders/mo, unlimited products, 3 users, EU VAT -- Business (€199/mo): 2000 orders/mo, unlimited products, 10 users, analytics, API -- Enterprise (€399+/mo): Unlimited, white-label, custom integrations +Merchant-level subscriptions are in merchant_subscription.py. +Feature limits per tier are in tier_feature_limit.py. """ import enum @@ -83,7 +79,8 @@ class SubscriptionTier(Base, TimestampMixin): """ Database-driven tier definitions with Stripe integration. - Replaces the hardcoded TIER_LIMITS dict for dynamic tier management. + Feature limits are now stored in the TierFeatureLimit table + (one row per feature per tier) instead of hardcoded columns. Can be: - Global tier (platform_id=NULL): Available to all platforms @@ -111,27 +108,6 @@ class SubscriptionTier(Base, TimestampMixin): price_monthly_cents = Column(Integer, nullable=False) price_annual_cents = Column(Integer, nullable=True) # Null for enterprise/custom - # Limits (null = unlimited) - orders_per_month = Column(Integer, nullable=True) - products_limit = Column(Integer, nullable=True) - team_members = Column(Integer, nullable=True) - order_history_months = Column(Integer, nullable=True) - - # CMS Limits (null = unlimited) - cms_pages_limit = Column( - Integer, - nullable=True, - comment="Total CMS pages limit (NULL = unlimited)", - ) - cms_custom_pages_limit = Column( - Integer, - nullable=True, - comment="Custom pages limit, excluding overrides (NULL = unlimited)", - ) - - # Features (JSON array of feature codes) - features = Column(JSON, default=list) - # Stripe Product/Price IDs stripe_product_id = Column(String(100), nullable=True) stripe_price_monthly_id = Column(String(100), nullable=True) @@ -149,7 +125,14 @@ class SubscriptionTier(Base, TimestampMixin): foreign_keys=[platform_id], ) - # Unique constraint: tier code must be unique per platform (or globally if NULL) + # Feature limits (one row per feature) + feature_limits = relationship( + "TierFeatureLimit", + back_populates="tier", + cascade="all, delete-orphan", + lazy="selectin", + ) + __table_args__ = ( Index("idx_tier_platform_active", "platform_id", "is_active"), ) @@ -158,20 +141,20 @@ class SubscriptionTier(Base, TimestampMixin): platform_info = f", platform_id={self.platform_id}" if self.platform_id else "" return f"" - def to_dict(self) -> dict: - """Convert tier to dictionary (compatible with TIER_LIMITS format).""" - return { - "name": self.name, - "price_monthly_cents": self.price_monthly_cents, - "price_annual_cents": self.price_annual_cents, - "orders_per_month": self.orders_per_month, - "products_limit": self.products_limit, - "team_members": self.team_members, - "order_history_months": self.order_history_months, - "cms_pages_limit": self.cms_pages_limit, - "cms_custom_pages_limit": self.cms_custom_pages_limit, - "features": self.features or [], - } + def get_feature_codes(self) -> set[str]: + """Get all feature codes enabled for this tier.""" + return {fl.feature_code for fl in (self.feature_limits or [])} + + def get_limit_for_feature(self, feature_code: str) -> int | None: + """Get the limit value for a specific feature (None = unlimited).""" + for fl in (self.feature_limits or []): + if fl.feature_code == feature_code: + return fl.limit_value + return None + + def has_feature(self, feature_code: str) -> bool: + """Check if this tier includes a specific feature.""" + return feature_code in self.get_feature_codes() # ============================================================================ @@ -217,21 +200,21 @@ class AddOnProduct(Base, TimestampMixin): # ============================================================================ -# VendorAddOn - Add-ons purchased by vendor +# StoreAddOn - Add-ons purchased by store # ============================================================================ -class VendorAddOn(Base, TimestampMixin): +class StoreAddOn(Base, TimestampMixin): """ - Add-ons purchased by a vendor. + Add-ons purchased by a store. Tracks active add-on subscriptions and their billing status. """ - __tablename__ = "vendor_addons" + __tablename__ = "store_addons" id = Column(Integer, primary_key=True, index=True) - vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True) + store_id = Column(Integer, ForeignKey("stores.id"), nullable=False, index=True) addon_product_id = Column( Integer, ForeignKey("addon_products.id"), nullable=False, index=True ) @@ -256,16 +239,16 @@ class VendorAddOn(Base, TimestampMixin): cancelled_at = Column(DateTime(timezone=True), nullable=True) # Relationships - vendor = relationship("Vendor", back_populates="addons") + store = relationship("Store", back_populates="addons") addon_product = relationship("AddOnProduct") __table_args__ = ( - Index("idx_vendor_addon_status", "vendor_id", "status"), - Index("idx_vendor_addon_product", "vendor_id", "addon_product_id"), + Index("idx_vendor_addon_status", "store_id", "status"), + Index("idx_vendor_addon_product", "store_id", "addon_product_id"), ) def __repr__(self): - return f"" + return f"" # ============================================================================ @@ -295,9 +278,9 @@ class StripeWebhookEvent(Base, TimestampMixin): payload_encrypted = Column(Text, nullable=True) # Related entities (for quick lookup) - vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=True, index=True) - subscription_id = Column( - Integer, ForeignKey("vendor_subscriptions.id"), nullable=True, index=True + store_id = Column(Integer, ForeignKey("stores.id"), nullable=True, index=True) + merchant_subscription_id = Column( + Integer, ForeignKey("merchant_subscriptions.id"), nullable=True, index=True ) __table_args__ = (Index("idx_webhook_event_type_status", "event_type", "status"),) @@ -313,7 +296,7 @@ class StripeWebhookEvent(Base, TimestampMixin): class BillingHistory(Base, TimestampMixin): """ - Invoice and payment history for vendors. + Invoice and payment history for merchants. Stores Stripe invoice data for display and reporting. """ @@ -321,7 +304,10 @@ class BillingHistory(Base, TimestampMixin): __tablename__ = "billing_history" id = Column(Integer, primary_key=True, index=True) - vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True) + store_id = Column(Integer, ForeignKey("stores.id"), nullable=True, index=True) + + # Merchant association (billing is now merchant-level) + merchant_id = Column(Integer, ForeignKey("merchants.id"), nullable=True, index=True) # Stripe references stripe_invoice_id = Column(String(100), unique=True, nullable=True, index=True) @@ -351,351 +337,15 @@ class BillingHistory(Base, TimestampMixin): line_items = Column(JSON, nullable=True) # Relationships - vendor = relationship("Vendor", back_populates="billing_history") + store = relationship("Store", back_populates="billing_history") __table_args__ = ( - Index("idx_billing_vendor_date", "vendor_id", "invoice_date"), - Index("idx_billing_status", "vendor_id", "status"), + Index("idx_billing_store_date", "store_id", "invoice_date"), + Index("idx_billing_status", "store_id", "status"), ) def __repr__(self): - return f"" - - -# ============================================================================ -# Legacy TIER_LIMITS (kept for backward compatibility during migration) -# ============================================================================ - -# Tier limit definitions (hardcoded for now, could be moved to DB) -TIER_LIMITS = { - TierCode.ESSENTIAL: { - "name": "Essential", - "price_monthly_cents": 4900, # €49 - "price_annual_cents": 49000, # €490 (2 months free) - "orders_per_month": 100, - "products_limit": 200, - "team_members": 1, - "order_history_months": 6, - "features": [ - "letzshop_sync", - "inventory_basic", - "invoice_lu", - "customer_view", - ], - }, - TierCode.PROFESSIONAL: { - "name": "Professional", - "price_monthly_cents": 9900, # €99 - "price_annual_cents": 99000, # €990 - "orders_per_month": 500, - "products_limit": None, # Unlimited - "team_members": 3, - "order_history_months": 24, - "features": [ - "letzshop_sync", - "inventory_locations", - "inventory_purchase_orders", - "invoice_lu", - "invoice_eu_vat", - "customer_view", - "customer_export", - ], - }, - TierCode.BUSINESS: { - "name": "Business", - "price_monthly_cents": 19900, # €199 - "price_annual_cents": 199000, # €1990 - "orders_per_month": 2000, - "products_limit": None, # Unlimited - "team_members": 10, - "order_history_months": None, # Unlimited - "features": [ - "letzshop_sync", - "inventory_locations", - "inventory_purchase_orders", - "invoice_lu", - "invoice_eu_vat", - "invoice_bulk", - "customer_view", - "customer_export", - "analytics_dashboard", - "accounting_export", - "api_access", - "automation_rules", - "team_roles", - ], - }, - TierCode.ENTERPRISE: { - "name": "Enterprise", - "price_monthly_cents": 39900, # €399 starting - "price_annual_cents": None, # Custom - "orders_per_month": None, # Unlimited - "products_limit": None, # Unlimited - "team_members": None, # Unlimited - "order_history_months": None, # Unlimited - "features": [ - "letzshop_sync", - "inventory_locations", - "inventory_purchase_orders", - "invoice_lu", - "invoice_eu_vat", - "invoice_bulk", - "customer_view", - "customer_export", - "analytics_dashboard", - "accounting_export", - "api_access", - "automation_rules", - "team_roles", - "white_label", - "multi_vendor", - "custom_integrations", - "sla_guarantee", - "dedicated_support", - ], - }, -} - - -class VendorSubscription(Base, TimestampMixin): - """ - Per-vendor subscription tracking. - - Tracks the vendor's subscription tier, billing period, - and usage counters for limit enforcement. - """ - - __tablename__ = "vendor_subscriptions" - - id = Column(Integer, primary_key=True, index=True) - vendor_id = Column( - Integer, ForeignKey("vendors.id"), unique=True, nullable=False, index=True - ) - - # Tier - tier_id is the FK, tier (code) kept for backwards compatibility - tier_id = Column( - Integer, ForeignKey("subscription_tiers.id"), nullable=True, index=True - ) - tier = Column( - String(20), default=TierCode.ESSENTIAL.value, nullable=False, index=True - ) - - # Status - status = Column( - String(20), default=SubscriptionStatus.TRIAL.value, nullable=False, index=True - ) - - # Billing period - period_start = Column(DateTime(timezone=True), nullable=False) - period_end = Column(DateTime(timezone=True), nullable=False) - is_annual = Column(Boolean, default=False, nullable=False) - - # Trial info - trial_ends_at = Column(DateTime(timezone=True), nullable=True) - - # Card collection tracking (for trials that require card upfront) - card_collected_at = Column(DateTime(timezone=True), nullable=True) - - # Usage counters (reset each billing period) - orders_this_period = Column(Integer, default=0, nullable=False) - orders_limit_reached_at = Column(DateTime(timezone=True), nullable=True) - - # Overrides (for custom enterprise deals) - custom_orders_limit = Column(Integer, nullable=True) # Override tier limit - custom_products_limit = Column(Integer, nullable=True) - custom_team_limit = Column(Integer, nullable=True) - - # Payment info (Stripe integration) - stripe_customer_id = Column(String(100), nullable=True, index=True) - stripe_subscription_id = Column(String(100), nullable=True, index=True) - stripe_price_id = Column(String(100), nullable=True) # Current price being billed - stripe_payment_method_id = Column(String(100), nullable=True) # Default payment method - - # Proration and upgrade/downgrade tracking - proration_behavior = Column(String(50), default="create_prorations") - scheduled_tier_change = Column(String(30), nullable=True) # Pending tier change - scheduled_change_at = Column(DateTime(timezone=True), nullable=True) - - # Payment failure tracking - payment_retry_count = Column(Integer, default=0, nullable=False) - last_payment_error = Column(Text, nullable=True) - - # Cancellation - cancelled_at = Column(DateTime(timezone=True), nullable=True) - cancellation_reason = Column(Text, nullable=True) - - # Relationships - vendor = relationship("Vendor", back_populates="subscription") - tier_obj = relationship("SubscriptionTier", backref="subscriptions") - - __table_args__ = ( - Index("idx_subscription_vendor_status", "vendor_id", "status"), - Index("idx_subscription_period", "period_start", "period_end"), - ) - - def __repr__(self): - return f"" - - # ========================================================================= - # Tier Limit Properties - # ========================================================================= - - @property - def tier_limits(self) -> dict: - """Get the limit definitions for current tier. - - Uses database tier (tier_obj) if available, otherwise falls back - to hardcoded TIER_LIMITS for backwards compatibility. - """ - # Use database tier if relationship is loaded - if self.tier_obj is not None: - return { - "orders_per_month": self.tier_obj.orders_per_month, - "products_limit": self.tier_obj.products_limit, - "team_members": self.tier_obj.team_members, - "features": self.tier_obj.features or [], - } - # Fall back to hardcoded limits - return TIER_LIMITS.get(TierCode(self.tier), TIER_LIMITS[TierCode.ESSENTIAL]) - - @property - def orders_limit(self) -> int | None: - """Get effective orders limit (custom or tier default).""" - if self.custom_orders_limit is not None: - return self.custom_orders_limit - return self.tier_limits.get("orders_per_month") - - @property - def products_limit(self) -> int | None: - """Get effective products limit (custom or tier default).""" - if self.custom_products_limit is not None: - return self.custom_products_limit - return self.tier_limits.get("products_limit") - - @property - def team_members_limit(self) -> int | None: - """Get effective team members limit (custom or tier default).""" - if self.custom_team_limit is not None: - return self.custom_team_limit - return self.tier_limits.get("team_members") - - @property - def features(self) -> list[str]: - """Get list of enabled features for current tier.""" - return self.tier_limits.get("features", []) - - # ========================================================================= - # Status Checks - # ========================================================================= - - @property - def is_active(self) -> bool: - """Check if subscription allows access.""" - return self.status in [ - SubscriptionStatus.TRIAL.value, - SubscriptionStatus.ACTIVE.value, - SubscriptionStatus.PAST_DUE.value, # Grace period - SubscriptionStatus.CANCELLED.value, # Until period end - ] - - @property - def is_trial(self) -> bool: - """Check if currently in trial.""" - return self.status == SubscriptionStatus.TRIAL.value - - @property - def trial_days_remaining(self) -> int | None: - """Get remaining trial days.""" - if not self.is_trial or not self.trial_ends_at: - return None - remaining = (self.trial_ends_at - datetime.now(UTC)).days - return max(0, remaining) - - # ========================================================================= - # Limit Checks - # ========================================================================= - - def can_create_order(self) -> tuple[bool, str | None]: - """ - Check if vendor can create/import another order. - - Returns: (can_create, error_message) - """ - if not self.is_active: - return False, "Subscription is not active" - - limit = self.orders_limit - if limit is None: # Unlimited - return True, None - - if self.orders_this_period >= limit: - return False, f"Monthly order limit reached ({limit} orders). Upgrade to continue." - - return True, None - - def can_add_product(self, current_count: int) -> tuple[bool, str | None]: - """ - Check if vendor can add another product. - - Args: - current_count: Current number of products - - Returns: (can_add, error_message) - """ - if not self.is_active: - return False, "Subscription is not active" - - limit = self.products_limit - if limit is None: # Unlimited - return True, None - - if current_count >= limit: - return False, f"Product limit reached ({limit} products). Upgrade to add more." - - return True, None - - def can_add_team_member(self, current_count: int) -> tuple[bool, str | None]: - """ - Check if vendor can add another team member. - - Args: - current_count: Current number of team members - - Returns: (can_add, error_message) - """ - if not self.is_active: - return False, "Subscription is not active" - - limit = self.team_members_limit - if limit is None: # Unlimited - return True, None - - if current_count >= limit: - return False, f"Team member limit reached ({limit} members). Upgrade to add more." - - return True, None - - def has_feature(self, feature: str) -> bool: - """Check if a feature is enabled for current tier.""" - return feature in self.features - - # ========================================================================= - # Usage Tracking - # ========================================================================= - - def increment_order_count(self) -> None: - """Increment the order counter for this period.""" - self.orders_this_period += 1 - - # Track when limit was first reached - limit = self.orders_limit - if limit and self.orders_this_period >= limit and not self.orders_limit_reached_at: - self.orders_limit_reached_at = datetime.now(UTC) - - def reset_period_counters(self) -> None: - """Reset counters for new billing period.""" - self.orders_this_period = 0 - self.orders_limit_reached_at = None + return f"" # ============================================================================ @@ -716,10 +366,10 @@ class CapacitySnapshot(Base, TimestampMixin): id = Column(Integer, primary_key=True, index=True) snapshot_date = Column(DateTime(timezone=True), nullable=False, unique=True, index=True) - # Vendor metrics - total_vendors = Column(Integer, default=0, nullable=False) - active_vendors = Column(Integer, default=0, nullable=False) - trial_vendors = Column(Integer, default=0, nullable=False) + # Store metrics + total_stores = Column(Integer, default=0, nullable=False) + active_stores = Column(Integer, default=0, nullable=False) + trial_stores = Column(Integer, default=0, nullable=False) # Subscription metrics total_subscriptions = Column(Integer, default=0, nullable=False) @@ -753,4 +403,4 @@ class CapacitySnapshot(Base, TimestampMixin): ) def __repr__(self) -> str: - return f"" + return f"" diff --git a/app/modules/billing/models/tier_feature_limit.py b/app/modules/billing/models/tier_feature_limit.py new file mode 100644 index 00000000..bbb08380 --- /dev/null +++ b/app/modules/billing/models/tier_feature_limit.py @@ -0,0 +1,145 @@ +# app/modules/billing/models/tier_feature_limit.py +""" +Feature limit models for tier-based and merchant-level access control. + +Provides: +- TierFeatureLimit: Per-tier, per-feature limits (replaces hardcoded limit columns) +- MerchantFeatureOverride: Per-merchant overrides for admin-set exceptions +""" + +from sqlalchemy import ( + Boolean, + Column, + ForeignKey, + Index, + Integer, + String, + UniqueConstraint, +) +from sqlalchemy.orm import relationship + +from app.core.database import Base +from models.database.base import TimestampMixin + + +class TierFeatureLimit(Base, TimestampMixin): + """ + Per-tier, per-feature limit definition. + + Replaces hardcoded limit columns on SubscriptionTier (orders_per_month, + products_limit, etc.) and the features JSON array. + + For BINARY features: presence in this table = feature enabled for tier. + For QUANTITATIVE features: limit_value is the cap (NULL = unlimited). + + Example: + TierFeatureLimit(tier_id=1, feature_code="products_limit", limit_value=200) + TierFeatureLimit(tier_id=1, feature_code="analytics_dashboard", limit_value=None) + """ + + __tablename__ = "tier_feature_limits" + + id = Column(Integer, primary_key=True, index=True) + + tier_id = Column( + Integer, + ForeignKey("subscription_tiers.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + feature_code = Column(String(80), nullable=False, index=True) + + # For QUANTITATIVE: cap value (NULL = unlimited) + # For BINARY: ignored (presence means enabled) + limit_value = Column(Integer, nullable=True) + + # Relationships + tier = relationship( + "SubscriptionTier", + back_populates="feature_limits", + foreign_keys=[tier_id], + ) + + __table_args__ = ( + UniqueConstraint( + "tier_id", "feature_code", + name="uq_tier_feature_code", + ), + Index("idx_tier_feature_lookup", "tier_id", "feature_code"), + ) + + def __repr__(self): + limit = f", limit={self.limit_value}" if self.limit_value is not None else "" + return f"" + + +class MerchantFeatureOverride(Base, TimestampMixin): + """ + Per-merchant, per-platform feature override. + + Allows admins to override tier limits for specific merchants. + For example, giving a merchant 500 products instead of tier's 200. + + Example: + MerchantFeatureOverride( + merchant_id=1, + platform_id=1, + feature_code="products_limit", + limit_value=500, + reason="Enterprise deal - custom product limit", + ) + """ + + __tablename__ = "merchant_feature_overrides" + + id = Column(Integer, primary_key=True, index=True) + + merchant_id = Column( + Integer, + ForeignKey("merchants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + platform_id = Column( + Integer, + ForeignKey("platforms.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + feature_code = Column(String(80), nullable=False, index=True) + + # Override limit (NULL = unlimited) + limit_value = Column(Integer, nullable=True) + + # Force enable/disable (overrides tier assignment) + is_enabled = Column(Boolean, default=True, nullable=False) + + # Admin note explaining the override + reason = Column(String(255), nullable=True) + + # Relationships + merchant = relationship("Merchant", foreign_keys=[merchant_id]) + platform = relationship("Platform", foreign_keys=[platform_id]) + + __table_args__ = ( + UniqueConstraint( + "merchant_id", "platform_id", "feature_code", + name="uq_merchant_platform_feature", + ), + Index("idx_merchant_override_lookup", "merchant_id", "platform_id", "feature_code"), + ) + + def __repr__(self): + return ( + f"" + ) + + +__all__ = ["TierFeatureLimit", "MerchantFeatureOverride"] diff --git a/app/modules/billing/routes/__init__.py b/app/modules/billing/routes/__init__.py index 90572b6c..00bb7c58 100644 --- a/app/modules/billing/routes/__init__.py +++ b/app/modules/billing/routes/__init__.py @@ -9,6 +9,6 @@ Structure: - routes/pages/ - HTML page rendering (templates) """ -from app.modules.billing.routes.api import admin_router, vendor_router +from app.modules.billing.routes.api import admin_router, store_router -__all__ = ["admin_router", "vendor_router"] +__all__ = ["admin_router", "store_router"] diff --git a/app/modules/billing/routes/api/__init__.py b/app/modules/billing/routes/api/__init__.py index 12f875e9..0c7c982f 100644 --- a/app/modules/billing/routes/api/__init__.py +++ b/app/modules/billing/routes/api/__init__.py @@ -3,13 +3,15 @@ Billing module API routes. Provides REST API endpoints for subscription and billing management: -- Admin API: Subscription tier management, vendor subscriptions, billing history, features -- Vendor API: Subscription status, tier comparison, invoices, features +- Admin API: Subscription tier management, merchant subscriptions, billing history, features +- Store API: Subscription status, tier comparison, invoices, features +- Merchant API: Merchant billing portal (subscriptions, invoices, checkout) -Each main router (admin.py, vendor.py) aggregates its related sub-routers internally. +Each main router (admin.py, store.py) aggregates its related sub-routers internally. +Merchant routes are auto-discovered from merchant.py. """ from app.modules.billing.routes.api.admin import admin_router -from app.modules.billing.routes.api.vendor import vendor_router +from app.modules.billing.routes.api.store import store_router -__all__ = ["admin_router", "vendor_router"] +__all__ = ["admin_router", "store_router"] diff --git a/app/modules/billing/routes/api/admin_features.py b/app/modules/billing/routes/api/admin_features.py index 5aa1d2dd..155cd975 100644 --- a/app/modules/billing/routes/api/admin_features.py +++ b/app/modules/billing/routes/api/admin_features.py @@ -1,25 +1,32 @@ # app/modules/billing/routes/api/admin_features.py """ -Admin feature management endpoints. +Admin feature management endpoints (provider-based system). Provides endpoints for: -- Listing all features with their tier assignments -- Updating tier feature assignments -- Managing feature metadata -- Viewing feature usage statistics +- Browsing the discovered feature catalog from module providers +- Managing per-tier feature limits (TierFeatureLimit) +- Managing per-merchant feature overrides (MerchantFeatureOverride) All routes require module access control for the 'billing' module. """ import logging -from fastapi import APIRouter, Depends, Query -from pydantic import BaseModel +from fastapi import APIRouter, Depends, HTTPException, Path from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api, require_module_access from app.core.database import get_db -from app.modules.billing.services.feature_service import feature_service +from app.modules.billing.services.feature_aggregator import feature_aggregator +from app.modules.billing.models.tier_feature_limit import TierFeatureLimit, MerchantFeatureOverride +from app.modules.billing.models import SubscriptionTier +from app.modules.billing.schemas import ( + FeatureDeclarationResponse, + FeatureCatalogResponse, + TierFeatureLimitEntry, + MerchantFeatureOverrideEntry, + MerchantFeatureOverrideResponse, +) from app.modules.enums import FrontendType from models.schema.auth import UserContext @@ -30,285 +37,274 @@ admin_features_router = APIRouter( logger = logging.getLogger(__name__) -# ============================================================================ -# Response Schemas -# ============================================================================ - - -class FeatureResponse(BaseModel): - """Feature information for admin.""" - - id: int - code: str - name: str - description: str | None = None - category: str - ui_location: str | None = None - ui_icon: str | None = None - ui_route: str | None = None - ui_badge_text: str | None = None - minimum_tier_id: int | None = None - minimum_tier_code: str | None = None - minimum_tier_name: str | None = None - is_active: bool - is_visible: bool - display_order: int - - -class FeatureListResponse(BaseModel): - """List of features.""" - - features: list[FeatureResponse] - total: int - - -class TierFeaturesResponse(BaseModel): - """Tier with its features.""" - - id: int - code: str - name: str - description: str | None = None - features: list[str] - feature_count: int - - -class TierListWithFeaturesResponse(BaseModel): - """All tiers with their features.""" - - tiers: list[TierFeaturesResponse] - - -class UpdateTierFeaturesRequest(BaseModel): - """Request to update tier features.""" - - feature_codes: list[str] - - -class UpdateFeatureRequest(BaseModel): - """Request to update feature metadata.""" - - name: str | None = None - description: str | None = None - category: str | None = None - ui_location: str | None = None - ui_icon: str | None = None - ui_route: str | None = None - ui_badge_text: str | None = None - minimum_tier_code: str | None = None - is_active: bool | None = None - is_visible: bool | None = None - display_order: int | None = None - - -class CategoryListResponse(BaseModel): - """List of feature categories.""" - - categories: list[str] - - -class TierFeatureDetailResponse(BaseModel): - """Tier features with full details.""" - - tier_code: str - tier_name: str - features: list[dict] - feature_count: int - - # ============================================================================ # Helper Functions # ============================================================================ -def _feature_to_response(feature) -> FeatureResponse: - """Convert Feature model to response.""" - return FeatureResponse( - id=feature.id, - code=feature.code, - name=feature.name, - description=feature.description, - category=feature.category, - ui_location=feature.ui_location, - ui_icon=feature.ui_icon, - ui_route=feature.ui_route, - ui_badge_text=feature.ui_badge_text, - minimum_tier_id=feature.minimum_tier_id, - minimum_tier_code=feature.minimum_tier.code if feature.minimum_tier else None, - minimum_tier_name=feature.minimum_tier.name if feature.minimum_tier else None, - is_active=feature.is_active, - is_visible=feature.is_visible, - display_order=feature.display_order, +def _get_tier_or_404(db: Session, tier_code: str) -> SubscriptionTier: + """Look up a SubscriptionTier by code, raising 404 if not found.""" + tier = ( + db.query(SubscriptionTier) + .filter(SubscriptionTier.code == tier_code) + .first() + ) + if not tier: + raise HTTPException(status_code=404, detail=f"Tier '{tier_code}' not found") + return tier + + +def _declaration_to_response(decl) -> FeatureDeclarationResponse: + """Convert a FeatureDeclaration dataclass to its Pydantic response schema.""" + return FeatureDeclarationResponse( + code=decl.code, + name_key=decl.name_key, + description_key=decl.description_key, + category=decl.category, + feature_type=decl.feature_type.value, + scope=decl.scope.value, + default_limit=decl.default_limit, + unit_key=decl.unit_key, + is_per_period=decl.is_per_period, + ui_icon=decl.ui_icon, + display_order=decl.display_order, ) # ============================================================================ -# Endpoints +# Feature Catalog Endpoints # ============================================================================ -@admin_features_router.get("", response_model=FeatureListResponse) -def list_features( - category: str | None = Query(None, description="Filter by category"), - active_only: bool = Query(False, description="Only active features"), +@admin_features_router.get("/catalog", response_model=FeatureCatalogResponse) +def get_feature_catalog( + current_user: UserContext = Depends(get_current_admin_api), +): + """ + Return all discovered features from module providers, grouped by category. + + Features are declared by modules via FeatureProviderProtocol and + aggregated at startup. This endpoint does not require a database query. + """ + by_category = feature_aggregator.get_declarations_by_category() + + features: dict[str, list[FeatureDeclarationResponse]] = {} + total_count = 0 + for category, declarations in by_category.items(): + features[category] = [_declaration_to_response(d) for d in declarations] + total_count += len(declarations) + + return FeatureCatalogResponse(features=features, total_count=total_count) + + +# ============================================================================ +# Tier Feature Limit Endpoints +# ============================================================================ + + +@admin_features_router.get( + "/tiers/{tier_code}/limits", + response_model=list[TierFeatureLimitEntry], +) +def get_tier_feature_limits( + tier_code: str = Path(..., description="Tier code"), current_user: UserContext = Depends(get_current_admin_api), db: Session = Depends(get_db), ): - """List all features with their tier assignments.""" - features = feature_service.get_all_features( - db, category=category, active_only=active_only + """ + Get the feature limits configured for a specific tier. + + Returns all TierFeatureLimit rows associated with the tier, + each containing a feature_code and its optional limit_value. + """ + tier = _get_tier_or_404(db, tier_code) + + rows = ( + db.query(TierFeatureLimit) + .filter(TierFeatureLimit.tier_id == tier.id) + .order_by(TierFeatureLimit.feature_code) + .all() ) - return FeatureListResponse( - features=[_feature_to_response(f) for f in features], - total=len(features), - ) + return [ + TierFeatureLimitEntry( + feature_code=row.feature_code, + limit_value=row.limit_value, + enabled=True, + ) + for row in rows + ] -@admin_features_router.get("/categories", response_model=CategoryListResponse) -def list_categories( - current_user: UserContext = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """List all feature categories.""" - categories = feature_service.get_categories(db) - return CategoryListResponse(categories=categories) - - -@admin_features_router.get("/tiers", response_model=TierListWithFeaturesResponse) -def list_tiers_with_features( - current_user: UserContext = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """List all tiers with their feature assignments.""" - tiers = feature_service.get_all_tiers_with_features(db) - - return TierListWithFeaturesResponse( - tiers=[ - TierFeaturesResponse( - id=t.id, - code=t.code, - name=t.name, - description=t.description, - features=t.features or [], - feature_count=len(t.features or []), - ) - for t in tiers - ] - ) - - -@admin_features_router.get("/{feature_code}", response_model=FeatureResponse) -def get_feature( - feature_code: str, +@admin_features_router.put( + "/tiers/{tier_code}/limits", + response_model=list[TierFeatureLimitEntry], +) +def upsert_tier_feature_limits( + entries: list[TierFeatureLimitEntry], + tier_code: str = Path(..., description="Tier code"), current_user: UserContext = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """ - Get a single feature by code. + Replace the feature limits for a tier. - Raises 404 if feature not found. + Deletes all existing TierFeatureLimit rows for this tier and + inserts the provided entries. Only entries with enabled=True + are persisted (disabled entries are simply omitted). """ - feature = feature_service.get_feature_by_code(db, feature_code) + tier = _get_tier_or_404(db, tier_code) - if not feature: - from app.modules.billing.exceptions import FeatureNotFoundError + # Validate feature codes against the catalog + submitted_codes = {e.feature_code for e in entries} + invalid_codes = feature_aggregator.validate_feature_codes(submitted_codes) + if invalid_codes: + raise HTTPException( + status_code=422, + detail=f"Unknown feature codes: {sorted(invalid_codes)}", + ) - raise FeatureNotFoundError(feature_code) + # Delete existing limits for this tier + db.query(TierFeatureLimit).filter(TierFeatureLimit.tier_id == tier.id).delete() - return _feature_to_response(feature) + # Insert new limits (only enabled entries) + new_rows = [] + for entry in entries: + if not entry.enabled: + continue + row = TierFeatureLimit( + tier_id=tier.id, + feature_code=entry.feature_code, + limit_value=entry.limit_value, + ) + db.add(row) + new_rows.append(row) - -@admin_features_router.put("/{feature_code}", response_model=FeatureResponse) -def update_feature( - feature_code: str, - request: UpdateFeatureRequest, - current_user: UserContext = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """ - Update feature metadata. - - Raises 404 if feature not found, 400 if tier code is invalid. - """ - feature = feature_service.update_feature( - db, - feature_code, - name=request.name, - description=request.description, - category=request.category, - ui_location=request.ui_location, - ui_icon=request.ui_icon, - ui_route=request.ui_route, - ui_badge_text=request.ui_badge_text, - minimum_tier_code=request.minimum_tier_code, - is_active=request.is_active, - is_visible=request.is_visible, - display_order=request.display_order, - ) - - db.commit() - db.refresh(feature) - - logger.info(f"Updated feature {feature_code} by admin {current_user.id}") - - return _feature_to_response(feature) - - -@admin_features_router.put("/tiers/{tier_code}/features", response_model=TierFeaturesResponse) -def update_tier_features( - tier_code: str, - request: UpdateTierFeaturesRequest, - current_user: UserContext = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """ - Update features assigned to a tier. - - Raises 404 if tier not found, 422 if any feature codes are invalid. - """ - tier = feature_service.update_tier_features(db, tier_code, request.feature_codes) db.commit() logger.info( - f"Updated tier {tier_code} features to {len(request.feature_codes)} features " - f"by admin {current_user.id}" + "Admin %s replaced tier '%s' feature limits (%d entries)", + current_user.id, + tier_code, + len(new_rows), ) - return TierFeaturesResponse( - id=tier.id, - code=tier.code, - name=tier.name, - description=tier.description, - features=tier.features or [], - feature_count=len(tier.features or []), - ) + return [ + TierFeatureLimitEntry( + feature_code=row.feature_code, + limit_value=row.limit_value, + enabled=True, + ) + for row in new_rows + ] -@admin_features_router.get("/tiers/{tier_code}/features", response_model=TierFeatureDetailResponse) -def get_tier_features( - tier_code: str, +# ============================================================================ +# Merchant Feature Override Endpoints +# ============================================================================ + + +@admin_features_router.get( + "/merchants/{merchant_id}/overrides", + response_model=list[MerchantFeatureOverrideResponse], +) +def get_merchant_feature_overrides( + merchant_id: int = Path(..., description="Merchant ID"), current_user: UserContext = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """ - Get features assigned to a specific tier with full details. + Get all feature overrides for a specific merchant. - Raises 404 if tier not found. + Returns MerchantFeatureOverride rows that allow per-merchant + exceptions to the default tier limits (e.g. granting extra products). """ - tier, features = feature_service.get_tier_features_with_details(db, tier_code) - - return TierFeatureDetailResponse( - tier_code=tier.code, - tier_name=tier.name, - features=[ - { - "code": f.code, - "name": f.name, - "category": f.category, - "description": f.description, - } - for f in features - ], - feature_count=len(features), + rows = ( + db.query(MerchantFeatureOverride) + .filter(MerchantFeatureOverride.merchant_id == merchant_id) + .order_by(MerchantFeatureOverride.feature_code) + .all() ) + + return [MerchantFeatureOverrideResponse.model_validate(row) for row in rows] + + +@admin_features_router.put( + "/merchants/{merchant_id}/overrides", + response_model=list[MerchantFeatureOverrideResponse], +) +def upsert_merchant_feature_overrides( + entries: list[MerchantFeatureOverrideEntry], + merchant_id: int = Path(..., description="Merchant ID"), + current_user: UserContext = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """ + Set feature overrides for a merchant. + + Upserts MerchantFeatureOverride rows: if an override already exists + for the (merchant_id, platform_id, feature_code) triple, it is updated; + otherwise a new row is created. + + The platform_id is derived from the admin's current platform context. + """ + platform_id = current_user.token_platform_id + if not platform_id: + raise HTTPException( + status_code=400, + detail="Platform context required. Select a platform first.", + ) + + # Validate feature codes against the catalog + submitted_codes = {e.feature_code for e in entries} + invalid_codes = feature_aggregator.validate_feature_codes(submitted_codes) + if invalid_codes: + raise HTTPException( + status_code=422, + detail=f"Unknown feature codes: {sorted(invalid_codes)}", + ) + + results = [] + for entry in entries: + existing = ( + db.query(MerchantFeatureOverride) + .filter( + MerchantFeatureOverride.merchant_id == merchant_id, + MerchantFeatureOverride.platform_id == platform_id, + MerchantFeatureOverride.feature_code == entry.feature_code, + ) + .first() + ) + + if existing: + existing.limit_value = entry.limit_value + existing.is_enabled = entry.is_enabled + existing.reason = entry.reason + results.append(existing) + else: + row = MerchantFeatureOverride( + merchant_id=merchant_id, + platform_id=platform_id, + feature_code=entry.feature_code, + limit_value=entry.limit_value, + is_enabled=entry.is_enabled, + reason=entry.reason, + ) + db.add(row) + results.append(row) + + db.commit() + + # Refresh to populate server-generated fields (id, timestamps) + for row in results: + db.refresh(row) + + logger.info( + "Admin %s upserted %d feature overrides for merchant %d on platform %d", + current_user.id, + len(results), + merchant_id, + platform_id, + ) + + return [MerchantFeatureOverrideResponse.model_validate(row) for row in results] diff --git a/app/modules/billing/routes/api/merchant.py b/app/modules/billing/routes/api/merchant.py new file mode 100644 index 00000000..926a396b --- /dev/null +++ b/app/modules/billing/routes/api/merchant.py @@ -0,0 +1,277 @@ +# app/modules/billing/routes/api/merchant.py +""" +Merchant billing API endpoints for the merchant portal. + +Provides subscription management and billing operations for merchant owners: +- View subscriptions across all platforms +- Subscription detail and tier info per platform +- Stripe checkout session creation +- Invoice history + +Authentication: merchant_token cookie or Authorization header. +The user must own at least one active merchant (validated by +get_current_merchant_from_cookie_or_header). + +Auto-discovered by the route system (merchant.py in routes/api/ triggers +registration under /api/v1/merchants/billing/*). +""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request +from sqlalchemy.orm import Session + +from app.api.deps import get_current_merchant_from_cookie_or_header +from app.core.database import get_db +from app.modules.billing.schemas import ( + CheckoutRequest, + CheckoutResponse, + MerchantSubscriptionResponse, + TierInfo, +) +from app.modules.billing.services.billing_service import billing_service +from app.modules.billing.services.subscription_service import subscription_service +from app.modules.tenancy.models import Merchant +from models.schema.auth import UserContext + +logger = logging.getLogger(__name__) + +ROUTE_CONFIG = { + "prefix": "/billing", +} + +router = APIRouter() + + +# ============================================================================ +# Helpers +# ============================================================================ + + +def _get_user_merchant(db: Session, user_context: UserContext) -> Merchant: + """ + Get the first active merchant owned by the current user. + + Args: + db: Database session + user_context: Authenticated user context + + Returns: + Merchant: The user's active merchant + + Raises: + HTTPException 404: If the user has no active merchants + """ + merchant = ( + db.query(Merchant) + .filter( + Merchant.owner_user_id == user_context.id, + Merchant.is_active == True, # noqa: E712 + ) + .first() + ) + + if not merchant: + raise HTTPException(status_code=404, detail="No active merchant found") + + return merchant + + +# ============================================================================ +# Subscription Endpoints +# ============================================================================ + + +@router.get("/subscriptions") +def list_merchant_subscriptions( + request: Request, + current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + List all subscriptions for the current merchant. + + Returns subscriptions across all platforms the merchant is subscribed to, + including tier information and status. + """ + merchant = _get_user_merchant(db, current_user) + subscriptions = subscription_service.get_merchant_subscriptions(db, merchant.id) + + return { + "subscriptions": [ + MerchantSubscriptionResponse.model_validate(sub) + for sub in subscriptions + ], + "total": len(subscriptions), + } + + +@router.get("/subscriptions/{platform_id}") +def get_merchant_subscription( + request: Request, + platform_id: int = Path(..., description="Platform ID"), + current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Get subscription detail for a specific platform. + + Returns the subscription with tier information for the given platform. + """ + merchant = _get_user_merchant(db, current_user) + subscription = subscription_service.get_merchant_subscription( + db, merchant.id, platform_id + ) + + if not subscription: + raise HTTPException( + status_code=404, + detail=f"No subscription found for platform {platform_id}", + ) + + tier_info = None + if subscription.tier: + tier = subscription.tier + tier_info = TierInfo( + code=tier.code, + name=tier.name, + description=tier.description, + price_monthly_cents=tier.price_monthly_cents, + price_annual_cents=tier.price_annual_cents, + feature_codes=tier.get_feature_codes() if hasattr(tier, "get_feature_codes") else [], + ) + + return { + "subscription": MerchantSubscriptionResponse.model_validate(subscription), + "tier": tier_info, + } + + +@router.get("/subscriptions/{platform_id}/tiers") +def get_available_tiers( + request: Request, + platform_id: int = Path(..., description="Platform ID"), + current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Get available tiers for upgrade on a specific platform. + + Returns all public tiers with upgrade/downgrade flags relative to + the merchant's current tier. + """ + merchant = _get_user_merchant(db, current_user) + subscription = subscription_service.get_merchant_subscription( + db, merchant.id, platform_id + ) + + current_tier_id = subscription.tier_id if subscription else None + tier_list, tier_order = billing_service.get_available_tiers( + db, current_tier_id, platform_id + ) + + current_tier_code = None + if subscription and subscription.tier: + current_tier_code = subscription.tier.code + + return { + "tiers": tier_list, + "current_tier": current_tier_code, + } + + +@router.post( + "/subscriptions/{platform_id}/checkout", + response_model=CheckoutResponse, +) +def create_checkout_session( + request: Request, + checkout_data: CheckoutRequest, + platform_id: int = Path(..., description="Platform ID"), + current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Create a Stripe checkout session for the merchant's subscription. + + Starts a new subscription or upgrades an existing one to the + requested tier. + """ + merchant = _get_user_merchant(db, current_user) + + # Build success/cancel URLs from request + base_url = str(request.base_url).rstrip("/") + success_url = f"{base_url}/merchants/billing/subscriptions/{platform_id}?checkout=success" + cancel_url = f"{base_url}/merchants/billing/subscriptions/{platform_id}?checkout=cancelled" + + result = billing_service.create_checkout_session( + db=db, + merchant_id=merchant.id, + platform_id=platform_id, + tier_code=checkout_data.tier_code, + is_annual=checkout_data.is_annual, + success_url=success_url, + cancel_url=cancel_url, + ) + + db.commit() + + logger.info( + f"Merchant {merchant.id} ({merchant.name}) created checkout session " + f"for tier={checkout_data.tier_code} on platform={platform_id}" + ) + + return CheckoutResponse( + checkout_url=result["checkout_url"], + session_id=result["session_id"], + ) + + +# ============================================================================ +# Invoice Endpoints +# ============================================================================ + + +@router.get("/invoices") +def get_invoices( + request: Request, + skip: int = Query(0, ge=0, description="Number of records to skip"), + limit: int = Query(20, ge=1, le=100, description="Max records to return"), + current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Get invoice history for the current merchant. + + Returns paginated billing history entries ordered by date descending. + """ + merchant = _get_user_merchant(db, current_user) + + invoices, total = billing_service.get_invoices( + db, merchant.id, skip=skip, limit=limit + ) + + return { + "invoices": [ + { + "id": inv.id, + "invoice_number": inv.invoice_number, + "invoice_date": inv.invoice_date.isoformat(), + "due_date": inv.due_date.isoformat() if inv.due_date else None, + "subtotal_cents": inv.subtotal_cents, + "tax_cents": inv.tax_cents, + "total_cents": inv.total_cents, + "amount_paid_cents": inv.amount_paid_cents, + "currency": inv.currency, + "status": inv.status, + "pdf_url": inv.invoice_pdf_url, + "hosted_url": inv.hosted_invoice_url, + "description": inv.description, + "created_at": inv.created_at.isoformat() if inv.created_at else None, + } + for inv in invoices + ], + "total": total, + "skip": skip, + "limit": limit, + } diff --git a/app/modules/billing/routes/api/platform.py b/app/modules/billing/routes/api/platform.py index ab7123c8..8ad1238b 100644 --- a/app/modules/billing/routes/api/platform.py +++ b/app/modules/billing/routes/api/platform.py @@ -15,7 +15,7 @@ from sqlalchemy.orm import Session from app.core.database import get_db from app.exceptions import ResourceNotFoundException from app.modules.billing.services.platform_pricing_service import platform_pricing_service -from app.modules.billing.models import TierCode +from app.modules.billing.models import TierCode, SubscriptionTier router = APIRouter(prefix="/pricing") @@ -39,17 +39,16 @@ class TierResponse(BaseModel): code: str name: str description: str | None - price_monthly: float # Price in euros - price_annual: float | None # Price in euros (null for enterprise) + price_monthly: float + price_annual: float | None price_monthly_cents: int price_annual_cents: int | None - orders_per_month: int | None # None = unlimited - products_limit: int | None # None = unlimited - team_members: int | None # None = unlimited - order_history_months: int | None # None = unlimited - features: list[str] - is_popular: bool = False # Highlight as recommended - is_enterprise: bool = False # Contact sales + feature_codes: list[str] = [] + products_limit: int | None = None + orders_per_month: int | None = None + team_members: int | None = None + is_popular: bool = False + is_enterprise: bool = False class Config: from_attributes = True @@ -101,7 +100,7 @@ FEATURE_DESCRIPTIONS = { "automation_rules": "Automation Rules", "team_roles": "Team Roles & Permissions", "white_label": "White-Label Option", - "multi_vendor": "Multi-Vendor Support", + "multi_store": "Multi-Store Support", "custom_integrations": "Custom Integrations", "sla_guarantee": "SLA Guarantee", "dedicated_support": "Dedicated Account Manager", @@ -113,45 +112,24 @@ FEATURE_DESCRIPTIONS = { # ============================================================================= -def _tier_to_response(tier, is_from_db: bool = True) -> TierResponse: - """Convert a tier (from DB or hardcoded) to TierResponse.""" - if is_from_db: - return TierResponse( - code=tier.code, - name=tier.name, - description=tier.description, - price_monthly=tier.price_monthly_cents / 100, - price_annual=(tier.price_annual_cents / 100) if tier.price_annual_cents else None, - price_monthly_cents=tier.price_monthly_cents, - price_annual_cents=tier.price_annual_cents, - orders_per_month=tier.orders_per_month, - products_limit=tier.products_limit, - team_members=tier.team_members, - order_history_months=tier.order_history_months, - features=tier.features or [], - is_popular=tier.code == TierCode.PROFESSIONAL.value, - is_enterprise=tier.code == TierCode.ENTERPRISE.value, - ) - else: - # Hardcoded tier format - tier_enum = tier["tier_enum"] - limits = tier["limits"] - return TierResponse( - code=tier_enum.value, - name=limits["name"], - description=None, - price_monthly=limits["price_monthly_cents"] / 100, - price_annual=(limits["price_annual_cents"] / 100) if limits.get("price_annual_cents") else None, - price_monthly_cents=limits["price_monthly_cents"], - price_annual_cents=limits.get("price_annual_cents"), - orders_per_month=limits.get("orders_per_month"), - products_limit=limits.get("products_limit"), - team_members=limits.get("team_members"), - order_history_months=limits.get("order_history_months"), - features=limits.get("features", []), - is_popular=tier_enum == TierCode.PROFESSIONAL, - is_enterprise=tier_enum == TierCode.ENTERPRISE, - ) +def _tier_to_response(tier: SubscriptionTier) -> TierResponse: + """Convert a SubscriptionTier to TierResponse.""" + feature_codes = sorted(tier.get_feature_codes()) + return TierResponse( + code=tier.code, + name=tier.name, + description=tier.description, + price_monthly=tier.price_monthly_cents / 100, + price_annual=(tier.price_annual_cents / 100) if tier.price_annual_cents else None, + price_monthly_cents=tier.price_monthly_cents, + price_annual_cents=tier.price_annual_cents, + 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, + ) def _addon_to_response(addon) -> AddOnResponse: @@ -176,47 +154,18 @@ def _addon_to_response(addon) -> AddOnResponse: @router.get("/tiers", response_model=list[TierResponse]) # public def get_tiers(db: Session = Depends(get_db)) -> list[TierResponse]: - """ - Get all public subscription tiers. - - Returns tiers from database if available, falls back to hardcoded TIER_LIMITS. - """ - # Try to get from database first + """Get all public subscription tiers.""" db_tiers = platform_pricing_service.get_public_tiers(db) - - if db_tiers: - return [_tier_to_response(tier, is_from_db=True) for tier in db_tiers] - - # Fallback to hardcoded tiers - from app.modules.billing.models import TIER_LIMITS - - tiers = [] - for tier_code in TIER_LIMITS: - tier_data = platform_pricing_service.get_tier_from_hardcoded(tier_code.value) - if tier_data: - tiers.append(_tier_to_response(tier_data, is_from_db=False)) - - return tiers + return [_tier_to_response(tier) for tier in db_tiers] @router.get("/tiers/{tier_code}", response_model=TierResponse) # public def get_tier(tier_code: str, db: Session = Depends(get_db)) -> TierResponse: """Get a specific tier by code.""" - # Try database first tier = platform_pricing_service.get_tier_by_code(db, tier_code) - - if tier: - return _tier_to_response(tier, is_from_db=True) - - # Fallback to hardcoded - tier_data = platform_pricing_service.get_tier_from_hardcoded(tier_code) - if tier_data: - return _tier_to_response(tier_data, is_from_db=False) - - raise ResourceNotFoundException( - resource_type="SubscriptionTier", - identifier=tier_code, - ) + if not tier: + raise ResourceNotFoundException(resource_type="SubscriptionTier", identifier=tier_code) + return _tier_to_response(tier) @router.get("/addons", response_model=list[AddOnResponse]) # public diff --git a/app/modules/billing/routes/api/vendor.py b/app/modules/billing/routes/api/store.py similarity index 55% rename from app/modules/billing/routes/api/vendor.py rename to app/modules/billing/routes/api/store.py index bb73083a..cb523219 100644 --- a/app/modules/billing/routes/api/vendor.py +++ b/app/modules/billing/routes/api/store.py @@ -1,22 +1,19 @@ -# app/modules/billing/routes/vendor.py +# app/modules/billing/routes/api/store.py """ -Billing module vendor routes. +Billing module store routes. -This module wraps the existing vendor billing routes and adds -module-based access control. The actual route implementations remain -in app/api/v1/vendor/billing.py for now, but are accessed through -this module-aware router. - -Future: Move all route implementations here for full module isolation. +Provides subscription status, tier listing, and invoice history +for store-level users. Resolves store_id to (merchant_id, platform_id) +for all billing service calls. """ import logging -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy.orm import Session -from app.api.deps import get_current_vendor_api, require_module_access +from app.api.deps import get_current_store_api, require_module_access from app.core.config import settings from app.core.database import get_db from app.modules.billing.services import billing_service, subscription_service @@ -25,20 +22,42 @@ from app.modules.tenancy.models import User logger = logging.getLogger(__name__) -# Vendor router with module access control -vendor_router = APIRouter( +# Store router with module access control +store_router = APIRouter( prefix="/billing", - dependencies=[Depends(require_module_access("billing", FrontendType.VENDOR))], + dependencies=[Depends(require_module_access("billing", FrontendType.STORE))], ) # ============================================================================ -# Schemas (re-exported from original module) +# Helpers +# ============================================================================ + + +def _resolve_store_to_merchant(db: Session, store_id: int) -> tuple[int, int]: + """Resolve store_id to (merchant_id, platform_id).""" + from app.modules.tenancy.models import Store, StorePlatform + + store = db.query(Store).filter(Store.id == store_id).first() + if not store or not store.merchant_id: + raise HTTPException(status_code=404, detail="Store not found") + + sp = db.query(StorePlatform.platform_id).filter( + StorePlatform.store_id == store_id + ).first() + if not sp: + raise HTTPException(status_code=404, detail="Store not linked to platform") + + return store.merchant_id, sp[0] + + +# ============================================================================ +# Schemas # ============================================================================ class SubscriptionStatusResponse(BaseModel): - """Current subscription status and usage.""" + """Current subscription status.""" tier_code: str tier_name: str @@ -49,21 +68,9 @@ class SubscriptionStatusResponse(BaseModel): period_end: str | None = None cancelled_at: str | None = None cancellation_reason: str | None = None - - # Usage - orders_this_period: int - orders_limit: int | None - orders_remaining: int | None - products_count: int - products_limit: int | None - products_remaining: int | None - team_count: int - team_limit: int | None - team_remaining: int | None - - # Payment has_payment_method: bool last_payment_error: str | None = None + feature_codes: list[str] = [] class Config: from_attributes = True @@ -77,10 +84,7 @@ class TierResponse(BaseModel): description: str | None = None price_monthly_cents: int price_annual_cents: int | None = None - orders_per_month: int | None = None - products_limit: int | None = None - team_members: int | None = None - features: list[str] = [] + feature_codes: list[str] = [] is_current: bool = False can_upgrade: bool = False can_downgrade: bool = False @@ -120,22 +124,24 @@ class InvoiceListResponse(BaseModel): # ============================================================================ -@vendor_router.get("/subscription", response_model=SubscriptionStatusResponse) +@store_router.get("/subscription", response_model=SubscriptionStatusResponse) def get_subscription_status( - current_user: User = Depends(get_current_vendor_api), + current_user: User = Depends(get_current_store_api), db: Session = Depends(get_db), ): - """Get current subscription status and usage metrics.""" - vendor_id = current_user.token_vendor_id + """Get current subscription status.""" + store_id = current_user.token_store_id + merchant_id, platform_id = _resolve_store_to_merchant(db, store_id) - usage = subscription_service.get_usage_summary(db, vendor_id) - subscription, tier = billing_service.get_subscription_with_tier(db, vendor_id) + subscription, tier = billing_service.get_subscription_with_tier(db, merchant_id, platform_id) + + feature_codes = sorted(tier.get_feature_codes()) if tier else [] return SubscriptionStatusResponse( - tier_code=subscription.tier, - tier_name=tier.name if tier else subscription.tier.title(), - status=subscription.status.value, - is_trial=subscription.is_in_trial(), + tier_code=tier.code if tier else "unknown", + tier_name=tier.name if tier else "Unknown", + status=subscription.status, + is_trial=subscription.status == "trial", trial_ends_at=subscription.trial_ends_at.isoformat() if subscription.trial_ends_at else None, @@ -149,48 +155,44 @@ def get_subscription_status( if subscription.cancelled_at else None, cancellation_reason=subscription.cancellation_reason, - orders_this_period=usage.orders_this_period, - orders_limit=usage.orders_limit, - orders_remaining=usage.orders_remaining, - products_count=usage.products_count, - products_limit=usage.products_limit, - products_remaining=usage.products_remaining, - team_count=usage.team_count, - team_limit=usage.team_limit, - team_remaining=usage.team_remaining, has_payment_method=bool(subscription.stripe_payment_method_id), last_payment_error=subscription.last_payment_error, + feature_codes=feature_codes, ) -@vendor_router.get("/tiers", response_model=TierListResponse) +@store_router.get("/tiers", response_model=TierListResponse) def get_available_tiers( - current_user: User = Depends(get_current_vendor_api), + current_user: User = Depends(get_current_store_api), db: Session = Depends(get_db), ): """Get available subscription tiers for upgrade/downgrade.""" - vendor_id = current_user.token_vendor_id - subscription = subscription_service.get_or_create_subscription(db, vendor_id) - current_tier = subscription.tier + store_id = current_user.token_store_id + merchant_id, platform_id = _resolve_store_to_merchant(db, store_id) - tier_list, _ = billing_service.get_available_tiers(db, current_tier) + subscription = subscription_service.get_or_create_subscription(db, merchant_id, platform_id) + current_tier_id = subscription.tier_id + + tier_list, _ = billing_service.get_available_tiers(db, current_tier_id, platform_id) tier_responses = [TierResponse(**tier_data) for tier_data in tier_list] + current_tier_code = subscription.tier.code if subscription.tier else "unknown" - return TierListResponse(tiers=tier_responses, current_tier=current_tier) + return TierListResponse(tiers=tier_responses, current_tier=current_tier_code) -@vendor_router.get("/invoices", response_model=InvoiceListResponse) +@store_router.get("/invoices", response_model=InvoiceListResponse) def get_invoices( skip: int = Query(0, ge=0), limit: int = Query(20, ge=1, le=100), - current_user: User = Depends(get_current_vendor_api), + current_user: User = Depends(get_current_store_api), db: Session = Depends(get_db), ): """Get invoice history.""" - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id + merchant_id, platform_id = _resolve_store_to_merchant(db, store_id) - invoices, total = billing_service.get_invoices(db, vendor_id, skip=skip, limit=limit) + invoices, total = billing_service.get_invoices(db, merchant_id, skip=skip, limit=limit) invoice_responses = [ InvoiceResponse( @@ -211,22 +213,17 @@ def get_invoices( return InvoiceListResponse(invoices=invoice_responses, total=total) -# NOTE: Additional endpoints (checkout, portal, cancel, addons, etc.) -# are still handled by app/api/v1/vendor/billing.py for now. -# They can be migrated here as part of a larger refactoring effort. - - # ============================================================================ # Aggregate Sub-Routers # ============================================================================ -# Include all billing-related vendor sub-routers +# Include all billing-related store sub-routers -from app.modules.billing.routes.api.vendor_features import vendor_features_router -from app.modules.billing.routes.api.vendor_checkout import vendor_checkout_router -from app.modules.billing.routes.api.vendor_addons import vendor_addons_router -from app.modules.billing.routes.api.vendor_usage import vendor_usage_router +from app.modules.billing.routes.api.store_features import store_features_router +from app.modules.billing.routes.api.store_checkout import store_checkout_router +from app.modules.billing.routes.api.store_addons import store_addons_router +from app.modules.billing.routes.api.store_usage import store_usage_router -vendor_router.include_router(vendor_features_router, tags=["vendor-features"]) -vendor_router.include_router(vendor_checkout_router, tags=["vendor-billing"]) -vendor_router.include_router(vendor_addons_router, tags=["vendor-billing-addons"]) -vendor_router.include_router(vendor_usage_router, tags=["vendor-usage"]) +store_router.include_router(store_features_router, tags=["store-features"]) +store_router.include_router(store_checkout_router, tags=["store-billing"]) +store_router.include_router(store_addons_router, tags=["store-billing-addons"]) +store_router.include_router(store_usage_router, tags=["store-usage"]) diff --git a/app/modules/billing/routes/api/vendor_addons.py b/app/modules/billing/routes/api/store_addons.py similarity index 70% rename from app/modules/billing/routes/api/vendor_addons.py rename to app/modules/billing/routes/api/store_addons.py index cf24db67..4436d2f8 100644 --- a/app/modules/billing/routes/api/vendor_addons.py +++ b/app/modules/billing/routes/api/store_addons.py @@ -1,10 +1,10 @@ -# app/modules/billing/routes/api/vendor_addons.py +# app/modules/billing/routes/api/store_addons.py """ -Vendor add-on management endpoints. +Store add-on management endpoints. Provides: - List available add-ons -- Get vendor's purchased add-ons +- Get store's purchased add-ons - Purchase add-on - Cancel add-on @@ -17,16 +17,16 @@ from fastapi import APIRouter, Depends, Query from pydantic import BaseModel from sqlalchemy.orm import Session -from app.api.deps import get_current_vendor_api, require_module_access +from app.api.deps import get_current_store_api, require_module_access from app.core.config import settings from app.core.database import get_db from app.modules.billing.services import billing_service from app.modules.enums import FrontendType from models.schema.auth import UserContext -vendor_addons_router = APIRouter( +store_addons_router = APIRouter( prefix="/addons", - dependencies=[Depends(require_module_access("billing", FrontendType.VENDOR))], + dependencies=[Depends(require_module_access("billing", FrontendType.STORE))], ) logger = logging.getLogger(__name__) @@ -50,8 +50,8 @@ class AddOnResponse(BaseModel): quantity_value: int | None = None -class VendorAddOnResponse(BaseModel): - """Vendor's purchased add-on.""" +class StoreAddOnResponse(BaseModel): + """Store's purchased add-on.""" id: int addon_code: str @@ -83,10 +83,10 @@ class AddOnCancelResponse(BaseModel): # ============================================================================ -@vendor_addons_router.get("", response_model=list[AddOnResponse]) +@store_addons_router.get("", response_model=list[AddOnResponse]) def get_available_addons( category: str | None = Query(None, description="Filter by category"), - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """Get available add-on products.""" @@ -108,18 +108,18 @@ def get_available_addons( ] -@vendor_addons_router.get("/my-addons", response_model=list[VendorAddOnResponse]) -def get_vendor_addons( - current_user: UserContext = Depends(get_current_vendor_api), +@store_addons_router.get("/my-addons", response_model=list[StoreAddOnResponse]) +def get_store_addons( + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): - """Get vendor's purchased add-ons.""" - vendor_id = current_user.token_vendor_id + """Get store's purchased add-ons.""" + store_id = current_user.token_store_id - vendor_addons = billing_service.get_vendor_addons(db, vendor_id) + store_addons = billing_service.get_store_addons(db, store_id) return [ - VendorAddOnResponse( + StoreAddOnResponse( id=va.id, addon_code=va.addon_product.code, addon_name=va.addon_product.name, @@ -129,28 +129,28 @@ def get_vendor_addons( period_start=va.period_start.isoformat() if va.period_start else None, period_end=va.period_end.isoformat() if va.period_end else None, ) - for va in vendor_addons + for va in store_addons ] -@vendor_addons_router.post("/purchase") +@store_addons_router.post("/purchase") def purchase_addon( request: AddOnPurchaseRequest, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """Purchase an add-on product.""" - vendor_id = current_user.token_vendor_id - vendor = billing_service.get_vendor(db, vendor_id) + store_id = current_user.token_store_id + store = billing_service.get_store(db, store_id) # Build URLs base_url = f"https://{settings.platform_domain}" - success_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?addon_success=true" - cancel_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?addon_cancelled=true" + success_url = f"{base_url}/store/{store.store_code}/billing?addon_success=true" + cancel_url = f"{base_url}/store/{store.store_code}/billing?addon_cancelled=true" result = billing_service.purchase_addon( db=db, - vendor_id=vendor_id, + store_id=store_id, addon_code=request.addon_code, domain_name=request.domain_name, quantity=request.quantity, @@ -162,16 +162,16 @@ def purchase_addon( return result -@vendor_addons_router.delete("/{addon_id}", response_model=AddOnCancelResponse) +@store_addons_router.delete("/{addon_id}", response_model=AddOnCancelResponse) def cancel_addon( addon_id: int, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """Cancel a purchased add-on.""" - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id - result = billing_service.cancel_addon(db, vendor_id, addon_id) + result = billing_service.cancel_addon(db, store_id, addon_id) db.commit() return AddOnCancelResponse( diff --git a/app/modules/billing/routes/api/vendor_checkout.py b/app/modules/billing/routes/api/store_checkout.py similarity index 54% rename from app/modules/billing/routes/api/vendor_checkout.py rename to app/modules/billing/routes/api/store_checkout.py index 9b90ca48..d79b2a91 100644 --- a/app/modules/billing/routes/api/vendor_checkout.py +++ b/app/modules/billing/routes/api/store_checkout.py @@ -1,6 +1,6 @@ -# app/modules/billing/routes/api/vendor_checkout.py +# app/modules/billing/routes/api/store_checkout.py """ -Vendor checkout and subscription management endpoints. +Store checkout and subscription management endpoints. Provides: - Stripe checkout session creation @@ -10,27 +10,50 @@ Provides: - Tier changes (upgrade/downgrade) All routes require module access control for the 'billing' module. +Resolves store_id to (merchant_id, platform_id) for all billing service calls. """ import logging -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session -from app.api.deps import get_current_vendor_api, require_module_access +from app.api.deps import get_current_store_api, require_module_access from app.core.config import settings from app.core.database import get_db from app.modules.billing.services import billing_service, subscription_service from app.modules.enums import FrontendType from models.schema.auth import UserContext -vendor_checkout_router = APIRouter( - dependencies=[Depends(require_module_access("billing", FrontendType.VENDOR))], +store_checkout_router = APIRouter( + dependencies=[Depends(require_module_access("billing", FrontendType.STORE))], ) logger = logging.getLogger(__name__) +# ============================================================================ +# Helpers +# ============================================================================ + + +def _resolve_store_to_merchant(db: Session, store_id: int) -> tuple[int, int]: + """Resolve store_id to (merchant_id, platform_id).""" + from app.modules.tenancy.models import Store, StorePlatform + + store = db.query(Store).filter(Store.id == store_id).first() + if not store or not store.merchant_id: + raise HTTPException(status_code=404, detail="Store not found") + + sp = db.query(StorePlatform.platform_id).filter( + StorePlatform.store_id == store_id + ).first() + if not sp: + raise HTTPException(status_code=404, detail="Store not linked to platform") + + return store.merchant_id, sp[0] + + # ============================================================================ # Schemas # ============================================================================ @@ -99,24 +122,28 @@ class ChangeTierResponse(BaseModel): # ============================================================================ -@vendor_checkout_router.post("/checkout", response_model=CheckoutResponse) +@store_checkout_router.post("/checkout", response_model=CheckoutResponse) def create_checkout_session( request: CheckoutRequest, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """Create a Stripe checkout session for subscription.""" - vendor_id = current_user.token_vendor_id - vendor = billing_service.get_vendor(db, vendor_id) + store_id = current_user.token_store_id + merchant_id, platform_id = _resolve_store_to_merchant(db, store_id) + + from app.modules.tenancy.models import Store + + store = db.query(Store).filter(Store.id == store_id).first() - # Build URLs base_url = f"https://{settings.platform_domain}" - success_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?success=true" - cancel_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?cancelled=true" + success_url = f"{base_url}/store/{store.store_code}/billing?success=true" + cancel_url = f"{base_url}/store/{store.store_code}/billing?cancelled=true" result = billing_service.create_checkout_session( db=db, - vendor_id=vendor_id, + merchant_id=merchant_id, + platform_id=platform_id, tier_code=request.tier_code, is_annual=request.is_annual, success_url=success_url, @@ -127,33 +154,39 @@ def create_checkout_session( return CheckoutResponse(checkout_url=result["checkout_url"], session_id=result["session_id"]) -@vendor_checkout_router.post("/portal", response_model=PortalResponse) +@store_checkout_router.post("/portal", response_model=PortalResponse) def create_portal_session( - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """Create a Stripe customer portal session.""" - vendor_id = current_user.token_vendor_id - vendor = billing_service.get_vendor(db, vendor_id) - return_url = f"https://{settings.platform_domain}/vendor/{vendor.vendor_code}/billing" + store_id = current_user.token_store_id + merchant_id, platform_id = _resolve_store_to_merchant(db, store_id) - result = billing_service.create_portal_session(db, vendor_id, return_url) + from app.modules.tenancy.models import Store + + store = db.query(Store).filter(Store.id == store_id).first() + return_url = f"https://{settings.platform_domain}/store/{store.store_code}/billing" + + result = billing_service.create_portal_session(db, merchant_id, platform_id, return_url) return PortalResponse(portal_url=result["portal_url"]) -@vendor_checkout_router.post("/cancel", response_model=CancelResponse) +@store_checkout_router.post("/cancel", response_model=CancelResponse) def cancel_subscription( request: CancelRequest, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """Cancel subscription.""" - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id + merchant_id, platform_id = _resolve_store_to_merchant(db, store_id) result = billing_service.cancel_subscription( db=db, - vendor_id=vendor_id, + merchant_id=merchant_id, + platform_id=platform_id, reason=request.reason, immediately=request.immediately, ) @@ -165,29 +198,31 @@ def cancel_subscription( ) -@vendor_checkout_router.post("/reactivate") +@store_checkout_router.post("/reactivate") def reactivate_subscription( - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """Reactivate a cancelled subscription.""" - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id + merchant_id, platform_id = _resolve_store_to_merchant(db, store_id) - result = billing_service.reactivate_subscription(db, vendor_id) + result = billing_service.reactivate_subscription(db, merchant_id, platform_id) db.commit() return result -@vendor_checkout_router.get("/upcoming-invoice", response_model=UpcomingInvoiceResponse) +@store_checkout_router.get("/upcoming-invoice", response_model=UpcomingInvoiceResponse) def get_upcoming_invoice( - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """Preview the upcoming invoice.""" - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id + merchant_id, platform_id = _resolve_store_to_merchant(db, store_id) - result = billing_service.get_upcoming_invoice(db, vendor_id) + result = billing_service.get_upcoming_invoice(db, merchant_id, platform_id) return UpcomingInvoiceResponse( amount_due_cents=result.get("amount_due_cents", 0), @@ -197,18 +232,20 @@ def get_upcoming_invoice( ) -@vendor_checkout_router.post("/change-tier", response_model=ChangeTierResponse) +@store_checkout_router.post("/change-tier", response_model=ChangeTierResponse) def change_tier( request: ChangeTierRequest, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """Change subscription tier (upgrade/downgrade).""" - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id + merchant_id, platform_id = _resolve_store_to_merchant(db, store_id) result = billing_service.change_tier( db=db, - vendor_id=vendor_id, + merchant_id=merchant_id, + platform_id=platform_id, new_tier_code=request.tier_code, is_annual=request.is_annual, ) diff --git a/app/modules/billing/routes/api/store_features.py b/app/modules/billing/routes/api/store_features.py new file mode 100644 index 00000000..83192df3 --- /dev/null +++ b/app/modules/billing/routes/api/store_features.py @@ -0,0 +1,381 @@ +# app/modules/billing/routes/api/store_features.py +""" +Store features API endpoints. + +Provides feature availability information for the frontend to: +- Show/hide UI elements based on tier +- Display upgrade prompts for unavailable features +- Load feature metadata for dynamic rendering + +Endpoints: +- GET /features/available - List of feature codes (for quick checks) +- GET /features - Full feature list with availability and metadata +- GET /features/{code} - Single feature details with upgrade info +- GET /features/categories - List feature categories +- GET /features/check/{code} - Quick boolean feature check + +All routes require module access control for the 'billing' module. +""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.api.deps import get_current_store_api, require_module_access +from app.core.database import get_db +from app.modules.billing.exceptions import FeatureNotFoundError +from app.modules.billing.services.feature_aggregator import feature_aggregator +from app.modules.billing.services.feature_service import feature_service +from app.modules.billing.services.subscription_service import subscription_service +from app.modules.enums import FrontendType +from models.schema.auth import UserContext + +store_features_router = APIRouter( + prefix="/features", + dependencies=[Depends(require_module_access("billing", FrontendType.STORE))], +) +logger = logging.getLogger(__name__) + + +# ============================================================================ +# Helpers +# ============================================================================ + + +def _resolve_store_to_merchant(db: Session, store_id: int) -> tuple[int, int]: + """Resolve store_id to (merchant_id, platform_id).""" + from app.modules.tenancy.models import Store, StorePlatform + + store = db.query(Store).filter(Store.id == store_id).first() + if not store or not store.merchant_id: + raise HTTPException(status_code=404, detail="Store not found") + + sp = db.query(StorePlatform.platform_id).filter( + StorePlatform.store_id == store_id + ).first() + if not sp: + raise HTTPException(status_code=404, detail="Store not linked to platform") + + return store.merchant_id, sp[0] + + +# ============================================================================ +# Response Schemas +# ============================================================================ + + +class FeatureCodeListResponse(BaseModel): + """Simple list of available feature codes for quick checks.""" + + features: list[str] + tier_code: str + tier_name: str + + +class FeatureResponse(BaseModel): + """Full feature information.""" + + code: str + name: str + description: str | None = None + category: str + feature_type: str | None = None + ui_icon: str | None = None + is_available: bool + + +class FeatureListResponse(BaseModel): + """List of features with metadata.""" + + features: list[FeatureResponse] + available_count: int + total_count: int + tier_code: str + tier_name: str + + +class FeatureDetailResponse(BaseModel): + """Single feature detail with upgrade info.""" + + code: str + name: str + description: str | None = None + category: str + feature_type: str | None = None + ui_icon: str | None = None + is_available: bool + # Upgrade info (only if not available) + upgrade_tier_code: str | None = None + upgrade_tier_name: str | None = None + upgrade_tier_price_monthly_cents: int | None = None + + +class CategoryListResponse(BaseModel): + """List of feature categories.""" + + categories: list[str] + + +class FeatureGroupedResponse(BaseModel): + """Features grouped by category.""" + + categories: dict[str, list[FeatureResponse]] + available_count: int + total_count: int + + +class FeatureCheckResponse(BaseModel): + """Quick feature availability check response.""" + + has_feature: bool + feature_code: str + + +# ============================================================================ +# Internal Helpers +# ============================================================================ + + +def _get_tier_info(db: Session, store_id: int) -> tuple[str, str]: + """Get (tier_code, tier_name) for a store's subscription.""" + sub = subscription_service.get_subscription_for_store(db, store_id) + if sub and sub.tier: + return sub.tier.code, sub.tier.name + return "unknown", "Unknown" + + +def _declaration_to_feature_response( + decl, is_available: bool +) -> FeatureResponse: + """Map a FeatureDeclaration to a FeatureResponse.""" + return FeatureResponse( + code=decl.code, + name=decl.name_key, + description=decl.description_key, + category=decl.category, + feature_type=decl.feature_type.value if decl.feature_type else None, + ui_icon=decl.ui_icon, + is_available=is_available, + ) + + +# ============================================================================ +# Endpoints +# ============================================================================ + + +@store_features_router.get("/available", response_model=FeatureCodeListResponse) +def get_available_features( + current_user: UserContext = Depends(get_current_store_api), + db: Session = Depends(get_db), +): + """ + Get list of feature codes available to store. + + This is a lightweight endpoint for quick feature checks. + Use this to populate a frontend feature store on app init. + + Returns: + List of feature codes the store has access to + """ + store_id = current_user.token_store_id + merchant_id, platform_id = _resolve_store_to_merchant(db, store_id) + + # Get available feature codes + feature_codes = feature_service.get_merchant_feature_codes(db, merchant_id, platform_id) + + # Get tier info + tier_code, tier_name = _get_tier_info(db, store_id) + + return FeatureCodeListResponse( + features=sorted(feature_codes), + tier_code=tier_code, + tier_name=tier_name, + ) + + +@store_features_router.get("", response_model=FeatureListResponse) +def get_features( + category: str | None = Query(None, description="Filter by category"), + include_unavailable: bool = Query(True, description="Include features not available to store"), + current_user: UserContext = Depends(get_current_store_api), + db: Session = Depends(get_db), +): + """ + Get all features with availability status and metadata. + + This is a comprehensive endpoint for building feature-gated UIs. + Each feature includes: + - Availability status + - UI metadata (icon) + - Feature type (binary/quantitative) + + Args: + category: Filter to specific category (orders, inventory, etc.) + include_unavailable: Whether to include locked features + + Returns: + List of features with metadata and availability + """ + store_id = current_user.token_store_id + merchant_id, platform_id = _resolve_store_to_merchant(db, store_id) + + # Get all declarations and available codes + all_declarations = feature_aggregator.get_all_declarations() + available_codes = feature_service.get_merchant_feature_codes(db, merchant_id, platform_id) + + # Build feature list + features = [] + for code, decl in sorted( + all_declarations.items(), key=lambda x: (x[1].category, x[1].display_order) + ): + # Filter by category if specified + if category and decl.category != category: + continue + + is_available = code in available_codes + + # Skip unavailable if not requested + if not include_unavailable and not is_available: + continue + + features.append(_declaration_to_feature_response(decl, is_available)) + + available_count = sum(1 for f in features if f.is_available) + + # Get tier info + tier_code, tier_name = _get_tier_info(db, store_id) + + return FeatureListResponse( + features=features, + available_count=available_count, + total_count=len(features), + tier_code=tier_code, + tier_name=tier_name, + ) + + +@store_features_router.get("/categories", response_model=CategoryListResponse) +def get_feature_categories( + current_user: UserContext = Depends(get_current_store_api), + db: Session = Depends(get_db), +): + """ + Get list of feature categories. + + Returns: + List of category names + """ + by_category = feature_aggregator.get_declarations_by_category() + return CategoryListResponse(categories=sorted(by_category.keys())) + + +@store_features_router.get("/grouped", response_model=FeatureGroupedResponse) +def get_features_grouped( + current_user: UserContext = Depends(get_current_store_api), + db: Session = Depends(get_db), +): + """ + Get features grouped by category. + + Useful for rendering feature comparison tables or settings pages. + """ + store_id = current_user.token_store_id + merchant_id, platform_id = _resolve_store_to_merchant(db, store_id) + + # Get declarations grouped by category and available codes + by_category = feature_aggregator.get_declarations_by_category() + available_codes = feature_service.get_merchant_feature_codes(db, merchant_id, platform_id) + + # Convert to response format + categories_response: dict[str, list[FeatureResponse]] = {} + total = 0 + available = 0 + + for category, declarations in sorted(by_category.items()): + category_features = [] + for decl in declarations: + is_available = decl.code in available_codes + category_features.append( + _declaration_to_feature_response(decl, is_available) + ) + total += 1 + if is_available: + available += 1 + categories_response[category] = category_features + + return FeatureGroupedResponse( + categories=categories_response, + available_count=available, + total_count=total, + ) + + +@store_features_router.get("/check/{feature_code}", response_model=FeatureCheckResponse) +def check_feature( + feature_code: str, + current_user: UserContext = Depends(get_current_store_api), + db: Session = Depends(get_db), +): + """ + Quick check if store has access to a feature. + + Returns simple boolean response for inline checks. + Uses has_feature_for_store which resolves store -> merchant internally. + + Args: + feature_code: The feature code + + Returns: + has_feature and feature_code + """ + store_id = current_user.token_store_id + has = feature_service.has_feature_for_store(db, store_id, feature_code) + + return FeatureCheckResponse(has_feature=has, feature_code=feature_code) + + +@store_features_router.get("/{feature_code}", response_model=FeatureDetailResponse) +def get_feature_detail( + feature_code: str, + current_user: UserContext = Depends(get_current_store_api), + db: Session = Depends(get_db), +): + """ + Get detailed information about a specific feature. + + Includes upgrade information if the feature is not available. + Use this for upgrade prompts and feature explanation modals. + + Args: + feature_code: The feature code + + Returns: + Feature details with upgrade info if locked + """ + store_id = current_user.token_store_id + merchant_id, platform_id = _resolve_store_to_merchant(db, store_id) + + # Get feature declaration + decl = feature_aggregator.get_declaration(feature_code) + if not decl: + raise FeatureNotFoundError(feature_code) + + # Check availability + is_available = feature_service.has_feature(db, merchant_id, platform_id, feature_code) + + # Build response + return FeatureDetailResponse( + code=decl.code, + name=decl.name_key, + description=decl.description_key, + category=decl.category, + feature_type=decl.feature_type.value if decl.feature_type else None, + ui_icon=decl.ui_icon, + is_available=is_available, + # Upgrade info fields are left as None since the new service + # does not provide tier-comparison upgrade suggestions. + # This can be extended when upgrade flow is implemented. + ) diff --git a/app/modules/billing/routes/api/vendor_usage.py b/app/modules/billing/routes/api/store_usage.py similarity index 85% rename from app/modules/billing/routes/api/vendor_usage.py rename to app/modules/billing/routes/api/store_usage.py index c5e320f7..29782655 100644 --- a/app/modules/billing/routes/api/vendor_usage.py +++ b/app/modules/billing/routes/api/store_usage.py @@ -1,13 +1,13 @@ -# app/modules/billing/routes/api/vendor_usage.py +# app/modules/billing/routes/api/store_usage.py """ -Vendor usage and limits API endpoints. +Store usage and limits API endpoints. Provides endpoints for: - Current usage vs limits - Upgrade recommendations - Approaching limit warnings -Migrated from app/api/v1/vendor/usage.py to billing module. +Migrated from app/api/v1/store/usage.py to billing module. """ import logging @@ -16,15 +16,15 @@ from fastapi import APIRouter, Depends from pydantic import BaseModel from sqlalchemy.orm import Session -from app.api.deps import get_current_vendor_api, require_module_access +from app.api.deps import get_current_store_api, require_module_access from app.core.database import get_db from app.modules.analytics.services.usage_service import usage_service from app.modules.enums import FrontendType from models.schema.auth import UserContext -vendor_usage_router = APIRouter( +store_usage_router = APIRouter( prefix="/usage", - dependencies=[Depends(require_module_access("billing", FrontendType.VENDOR))], + dependencies=[Depends(require_module_access("billing", FrontendType.STORE))], ) logger = logging.getLogger(__name__) @@ -95,9 +95,9 @@ class LimitCheckResponse(BaseModel): # ============================================================================ -@vendor_usage_router.get("", response_model=UsageResponse) +@store_usage_router.get("", response_model=UsageResponse) def get_usage( - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ @@ -106,10 +106,10 @@ def get_usage( Returns comprehensive usage info for displaying in dashboard and determining when to show upgrade prompts. """ - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id # Get usage data from service - usage_data = usage_service.get_vendor_usage(db, vendor_id) + usage_data = usage_service.get_store_usage(db, store_id) # Convert to response return UsageResponse( @@ -149,10 +149,10 @@ def get_usage( ) -@vendor_usage_router.get("/check/{limit_type}", response_model=LimitCheckResponse) +@store_usage_router.get("/check/{limit_type}", response_model=LimitCheckResponse) def check_limit( limit_type: str, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ @@ -166,10 +166,10 @@ def check_limit( Returns: Whether the action can proceed and upgrade info if not """ - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id # Check limit using service - check_data = usage_service.check_limit(db, vendor_id, limit_type) + check_data = usage_service.check_limit(db, store_id, limit_type) return LimitCheckResponse( limit_type=check_data.limit_type, diff --git a/app/modules/billing/routes/api/vendor_features.py b/app/modules/billing/routes/api/vendor_features.py deleted file mode 100644 index 1922a1f9..00000000 --- a/app/modules/billing/routes/api/vendor_features.py +++ /dev/null @@ -1,354 +0,0 @@ -# app/modules/billing/routes/api/vendor_features.py -""" -Vendor features API endpoints. - -Provides feature availability information for the frontend to: -- Show/hide UI elements based on tier -- Display upgrade prompts for unavailable features -- Load feature metadata for dynamic rendering - -Endpoints: -- GET /features/available - List of feature codes (for quick checks) -- GET /features - Full feature list with availability and metadata -- GET /features/{code} - Single feature details with upgrade info -- GET /features/categories - List feature categories - -All routes require module access control for the 'billing' module. -""" - -import logging - -from fastapi import APIRouter, Depends, Query -from pydantic import BaseModel -from sqlalchemy.orm import Session - -from app.api.deps import get_current_vendor_api, require_module_access -from app.core.database import get_db -from app.modules.billing.exceptions import FeatureNotFoundError -from app.modules.billing.services.feature_service import feature_service -from app.modules.enums import FrontendType -from models.schema.auth import UserContext - -vendor_features_router = APIRouter( - prefix="/features", - dependencies=[Depends(require_module_access("billing", FrontendType.VENDOR))], -) -logger = logging.getLogger(__name__) - - -# ============================================================================ -# Response Schemas -# ============================================================================ - - -class FeatureCodeListResponse(BaseModel): - """Simple list of available feature codes for quick checks.""" - - features: list[str] - tier_code: str - tier_name: str - - -class FeatureResponse(BaseModel): - """Full feature information.""" - - code: str - name: str - description: str | None = None - category: str - ui_location: str | None = None - ui_icon: str | None = None - ui_route: str | None = None - ui_badge_text: str | None = None - is_available: bool - minimum_tier_code: str | None = None - minimum_tier_name: str | None = None - - -class FeatureListResponse(BaseModel): - """List of features with metadata.""" - - features: list[FeatureResponse] - available_count: int - total_count: int - tier_code: str - tier_name: str - - -class FeatureDetailResponse(BaseModel): - """Single feature detail with upgrade info.""" - - code: str - name: str - description: str | None = None - category: str - ui_location: str | None = None - ui_icon: str | None = None - ui_route: str | None = None - is_available: bool - # Upgrade info (only if not available) - upgrade_tier_code: str | None = None - upgrade_tier_name: str | None = None - upgrade_tier_price_monthly_cents: int | None = None - - -class CategoryListResponse(BaseModel): - """List of feature categories.""" - - categories: list[str] - - -class FeatureGroupedResponse(BaseModel): - """Features grouped by category.""" - - categories: dict[str, list[FeatureResponse]] - available_count: int - total_count: int - - -class FeatureCheckResponse(BaseModel): - """Quick feature availability check response.""" - - has_feature: bool - feature_code: str - - -# ============================================================================ -# Endpoints -# ============================================================================ - - -@vendor_features_router.get("/available", response_model=FeatureCodeListResponse) -def get_available_features( - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """ - Get list of feature codes available to vendor. - - This is a lightweight endpoint for quick feature checks. - Use this to populate a frontend feature store on app init. - - Returns: - List of feature codes the vendor has access to - """ - vendor_id = current_user.token_vendor_id - - # Get subscription for tier info - from app.modules.billing.services.subscription_service import subscription_service - - subscription = subscription_service.get_or_create_subscription(db, vendor_id) - tier = subscription.tier_obj - - # Get available features - feature_codes = feature_service.get_available_feature_codes(db, vendor_id) - - return FeatureCodeListResponse( - features=feature_codes, - tier_code=subscription.tier, - tier_name=tier.name if tier else subscription.tier.title(), - ) - - -@vendor_features_router.get("", response_model=FeatureListResponse) -def get_features( - category: str | None = Query(None, description="Filter by category"), - include_unavailable: bool = Query(True, description="Include features not available to vendor"), - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """ - Get all features with availability status and metadata. - - This is a comprehensive endpoint for building feature-gated UIs. - Each feature includes: - - Availability status - - UI metadata (icon, route, location) - - Minimum tier required - - Args: - category: Filter to specific category (orders, inventory, etc.) - include_unavailable: Whether to include locked features - - Returns: - List of features with metadata and availability - """ - vendor_id = current_user.token_vendor_id - - # Get subscription for tier info - from app.modules.billing.services.subscription_service import subscription_service - - subscription = subscription_service.get_or_create_subscription(db, vendor_id) - tier = subscription.tier_obj - - # Get features - features = feature_service.get_vendor_features( - db, - vendor_id, - category=category, - include_unavailable=include_unavailable, - ) - - available_count = sum(1 for f in features if f.is_available) - - return FeatureListResponse( - features=[ - FeatureResponse( - code=f.code, - name=f.name, - description=f.description, - category=f.category, - ui_location=f.ui_location, - ui_icon=f.ui_icon, - ui_route=f.ui_route, - ui_badge_text=f.ui_badge_text, - is_available=f.is_available, - minimum_tier_code=f.minimum_tier_code, - minimum_tier_name=f.minimum_tier_name, - ) - for f in features - ], - available_count=available_count, - total_count=len(features), - tier_code=subscription.tier, - tier_name=tier.name if tier else subscription.tier.title(), - ) - - -@vendor_features_router.get("/categories", response_model=CategoryListResponse) -def get_feature_categories( - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """ - Get list of feature categories. - - Returns: - List of category names - """ - categories = feature_service.get_categories(db) - return CategoryListResponse(categories=categories) - - -@vendor_features_router.get("/grouped", response_model=FeatureGroupedResponse) -def get_features_grouped( - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """ - Get features grouped by category. - - Useful for rendering feature comparison tables or settings pages. - """ - vendor_id = current_user.token_vendor_id - - grouped = feature_service.get_features_grouped_by_category(db, vendor_id) - - # Convert to response format - categories_response = {} - total = 0 - available = 0 - - for category, features in grouped.items(): - categories_response[category] = [ - FeatureResponse( - code=f.code, - name=f.name, - description=f.description, - category=f.category, - ui_location=f.ui_location, - ui_icon=f.ui_icon, - ui_route=f.ui_route, - ui_badge_text=f.ui_badge_text, - is_available=f.is_available, - minimum_tier_code=f.minimum_tier_code, - minimum_tier_name=f.minimum_tier_name, - ) - for f in features - ] - total += len(features) - available += sum(1 for f in features if f.is_available) - - return FeatureGroupedResponse( - categories=categories_response, - available_count=available, - total_count=total, - ) - - -@vendor_features_router.get("/{feature_code}", response_model=FeatureDetailResponse) -def get_feature_detail( - feature_code: str, - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """ - Get detailed information about a specific feature. - - Includes upgrade information if the feature is not available. - Use this for upgrade prompts and feature explanation modals. - - Args: - feature_code: The feature code - - Returns: - Feature details with upgrade info if locked - """ - vendor_id = current_user.token_vendor_id - - # Get feature - feature = feature_service.get_feature_by_code(db, feature_code) - if not feature: - raise FeatureNotFoundError(feature_code) - - # Check availability - is_available = feature_service.has_feature(db, vendor_id, feature_code) - - # Get upgrade info if not available - upgrade_tier_code = None - upgrade_tier_name = None - upgrade_tier_price = None - - if not is_available: - upgrade_info = feature_service.get_feature_upgrade_info(db, feature_code) - if upgrade_info: - upgrade_tier_code = upgrade_info.required_tier_code - upgrade_tier_name = upgrade_info.required_tier_name - upgrade_tier_price = upgrade_info.required_tier_price_monthly_cents - - return FeatureDetailResponse( - code=feature.code, - name=feature.name, - description=feature.description, - category=feature.category, - ui_location=feature.ui_location, - ui_icon=feature.ui_icon, - ui_route=feature.ui_route, - is_available=is_available, - upgrade_tier_code=upgrade_tier_code, - upgrade_tier_name=upgrade_tier_name, - upgrade_tier_price_monthly_cents=upgrade_tier_price, - ) - - -@vendor_features_router.get("/check/{feature_code}", response_model=FeatureCheckResponse) -def check_feature( - feature_code: str, - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """ - Quick check if vendor has access to a feature. - - Returns simple boolean response for inline checks. - - Args: - feature_code: The feature code - - Returns: - has_feature and feature_code - """ - vendor_id = current_user.token_vendor_id - has_feature = feature_service.has_feature(db, vendor_id, feature_code) - - return FeatureCheckResponse(has_feature=has_feature, feature_code=feature_code) diff --git a/app/modules/billing/routes/pages/admin.py b/app/modules/billing/routes/pages/admin.py index 00176be8..f1c97553 100644 --- a/app/modules/billing/routes/pages/admin.py +++ b/app/modules/billing/routes/pages/admin.py @@ -53,8 +53,8 @@ async def admin_subscriptions_page( db: Session = Depends(get_db), ): """ - Render vendor subscriptions management page. - Shows all vendor subscriptions with status and usage. + Render store subscriptions management page. + Shows all store subscriptions with status and usage. """ return templates.TemplateResponse( "billing/admin/subscriptions.html", @@ -72,7 +72,7 @@ async def admin_billing_history_page( ): """ Render billing history page. - Shows invoices and payments across all vendors. + Shows invoices and payments across all stores. """ return templates.TemplateResponse( "billing/admin/billing-history.html", diff --git a/app/modules/billing/routes/pages/merchant.py b/app/modules/billing/routes/pages/merchant.py new file mode 100644 index 00000000..3824a3fb --- /dev/null +++ b/app/modules/billing/routes/pages/merchant.py @@ -0,0 +1,198 @@ +# app/modules/billing/routes/pages/merchant.py +""" +Merchant Billing Page Routes (HTML rendering). + +Page routes for the merchant billing portal: +- Dashboard (overview of stores, subscriptions) +- Subscriptions list +- Subscription detail per platform +- Billing history / invoices +- Login page + +Authentication: merchant_token cookie or Authorization header. +Login page uses optional auth to check if already logged in. + +Auto-discovered by the route system (merchant.py in routes/pages/ triggers +registration under /merchants/billing/*). +""" + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy.orm import Session + +from app.api.deps import ( + get_current_merchant_from_cookie_or_header, + get_current_merchant_optional, +) +from app.core.database import get_db +from app.modules.core.utils.page_context import get_context_for_frontend +from app.modules.enums import FrontendType +from app.templates_config import templates +from models.schema.auth import UserContext + +ROUTE_CONFIG = { + "prefix": "/billing", +} + +router = APIRouter() + + +# ============================================================================ +# Helper +# ============================================================================ + + +def _get_merchant_context( + request: Request, + db: Session, + current_user: UserContext, + **extra_context, +) -> dict: + """ + Build template context for merchant portal pages. + + Uses the module-driven context builder with FrontendType.MERCHANT, + and adds the authenticated user to the context. + + Args: + request: FastAPI request + db: Database session + current_user: Authenticated merchant user context + **extra_context: Additional template variables + + Returns: + Dict of context variables for template rendering + """ + return get_context_for_frontend( + FrontendType.MERCHANT, + request, + db, + user=current_user, + **extra_context, + ) + + +# ============================================================================ +# DASHBOARD +# ============================================================================ + + +@router.get("/", response_class=HTMLResponse, include_in_schema=False) +async def merchant_dashboard_page( + request: Request, + current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render merchant dashboard page. + + Shows an overview of the merchant's stores and subscriptions. + """ + context = _get_merchant_context(request, db, current_user) + return templates.TemplateResponse( + "billing/merchant/dashboard.html", + context, + ) + + +# ============================================================================ +# SUBSCRIPTIONS +# ============================================================================ + + +@router.get("/subscriptions", response_class=HTMLResponse, include_in_schema=False) +async def merchant_subscriptions_page( + request: Request, + current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render merchant subscriptions list page. + + Shows all subscriptions across platforms with status and tier info. + """ + context = _get_merchant_context(request, db, current_user) + return templates.TemplateResponse( + "billing/merchant/subscriptions.html", + context, + ) + + +@router.get( + "/subscriptions/{platform_id}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def merchant_subscription_detail_page( + request: Request, + platform_id: int = Path(..., description="Platform ID"), + current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render subscription detail page for a specific platform. + + Shows subscription status, tier details, usage, and upgrade options. + """ + context = _get_merchant_context( + request, db, current_user, platform_id=platform_id + ) + return templates.TemplateResponse( + "billing/merchant/subscription-detail.html", + context, + ) + + +# ============================================================================ +# BILLING HISTORY +# ============================================================================ + + +@router.get("/billing", response_class=HTMLResponse, include_in_schema=False) +async def merchant_billing_history_page( + request: Request, + current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render billing history page. + + Shows invoice history and payment records for the merchant. + """ + context = _get_merchant_context(request, db, current_user) + return templates.TemplateResponse( + "billing/merchant/billing-history.html", + context, + ) + + +# ============================================================================ +# LOGIN +# ============================================================================ + + +@router.get("/login", response_class=HTMLResponse, include_in_schema=False) +async def merchant_login_page( + request: Request, + current_user: UserContext | None = Depends(get_current_merchant_optional), + db: Session = Depends(get_db), +): + """ + Render merchant login page. + + If the user is already authenticated as a merchant owner, + redirects to the merchant dashboard. + """ + # Redirect to dashboard if already logged in + if current_user is not None: + return RedirectResponse(url="/merchants/billing/", status_code=302) + + context = get_context_for_frontend( + FrontendType.MERCHANT, + request, + db, + ) + return templates.TemplateResponse( + "billing/merchant/login.html", + context, + ) diff --git a/app/modules/billing/routes/pages/platform.py b/app/modules/billing/routes/pages/platform.py index 1866ca45..03f85bbd 100644 --- a/app/modules/billing/routes/pages/platform.py +++ b/app/modules/billing/routes/pages/platform.py @@ -13,34 +13,41 @@ from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session from app.core.database import get_db -from app.modules.billing.models import TIER_LIMITS, TierCode from app.modules.core.utils.page_context import get_platform_context from app.templates_config import templates router = APIRouter() -def _get_tiers_data() -> list[dict]: - """Build tier data for display in templates.""" - tiers = [] - for tier_code, limits in TIER_LIMITS.items(): - 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") - else None, - "orders_per_month": limits.get("orders_per_month"), - "products_limit": limits.get("products_limit"), - "team_members": limits.get("team_members"), - "order_history_months": limits.get("order_history_months"), - "features": limits.get("features", []), - "is_popular": tier_code == TierCode.PROFESSIONAL, - "is_enterprise": tier_code == TierCode.ENTERPRISE, - } +def _get_tiers_data(db: Session) -> list[dict]: + """Build tier data for display in templates from database.""" + from app.modules.billing.models import SubscriptionTier, TierCode + + tiers_db = ( + db.query(SubscriptionTier) + .filter( + SubscriptionTier.is_active == True, + SubscriptionTier.is_public == True, ) + .order_by(SubscriptionTier.display_order) + .all() + ) + + tiers = [] + for tier in tiers_db: + feature_codes = sorted(tier.get_feature_codes()) + tiers.append({ + "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, + "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 tiers @@ -58,7 +65,7 @@ async def pricing_page( Standalone pricing page with detailed tier comparison. """ context = get_platform_context(request, db) - context["tiers"] = _get_tiers_data() + context["tiers"] = _get_tiers_data(db) context["page_title"] = "Pricing" return templates.TemplateResponse( @@ -90,7 +97,7 @@ async def signup_page( context["page_title"] = "Start Your Free Trial" context["selected_tier"] = tier context["is_annual"] = annual - context["tiers"] = _get_tiers_data() + context["tiers"] = _get_tiers_data(db) return templates.TemplateResponse( "billing/platform/signup.html", @@ -103,7 +110,7 @@ async def signup_page( ) async def signup_success_page( request: Request, - vendor_code: str | None = None, + store_code: str | None = None, db: Session = Depends(get_db), ): """ @@ -113,7 +120,7 @@ async def signup_success_page( """ context = get_platform_context(request, db) context["page_title"] = "Welcome to Wizamart!" - context["vendor_code"] = vendor_code + context["store_code"] = store_code return templates.TemplateResponse( "billing/platform/signup-success.html", diff --git a/app/modules/billing/routes/pages/store.py b/app/modules/billing/routes/pages/store.py new file mode 100644 index 00000000..c6e70370 --- /dev/null +++ b/app/modules/billing/routes/pages/store.py @@ -0,0 +1,62 @@ +# app/modules/billing/routes/pages/store.py +""" +Billing Store Page Routes (HTML rendering). + +Store pages for billing management: +- Billing dashboard +- Invoices +""" + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_current_store_from_cookie_or_header, get_db +from app.modules.core.utils.page_context import get_store_context +from app.templates_config import templates +from app.modules.tenancy.models import User + +router = APIRouter() + + +# ============================================================================ +# BILLING ROUTES +# ============================================================================ + + +@router.get( + "/{store_code}/billing", response_class=HTMLResponse, include_in_schema=False +) +async def store_billing_page( + request: Request, + store_code: str = Path(..., description="Store code"), + current_user: User = Depends(get_current_store_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render billing and subscription management page. + JavaScript loads subscription status, tiers, and invoices via API. + """ + return templates.TemplateResponse( + "billing/store/billing.html", + get_store_context(request, db, current_user, store_code), + ) + + +@router.get( + "/{store_code}/invoices", response_class=HTMLResponse, include_in_schema=False +) +async def store_invoices_page( + request: Request, + store_code: str = Path(..., description="Store code"), + current_user: User = Depends(get_current_store_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render invoices management page. + JavaScript loads invoices via API. + """ + return templates.TemplateResponse( + "orders/store/invoices.html", + get_store_context(request, db, current_user, store_code), + ) diff --git a/app/modules/billing/routes/pages/vendor.py b/app/modules/billing/routes/pages/vendor.py deleted file mode 100644 index 0e475bf5..00000000 --- a/app/modules/billing/routes/pages/vendor.py +++ /dev/null @@ -1,62 +0,0 @@ -# app/modules/billing/routes/pages/vendor.py -""" -Billing Vendor Page Routes (HTML rendering). - -Vendor pages for billing management: -- Billing dashboard -- Invoices -""" - -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.modules.core.utils.page_context import get_vendor_context -from app.templates_config import templates -from app.modules.tenancy.models import User - -router = APIRouter() - - -# ============================================================================ -# BILLING ROUTES -# ============================================================================ - - -@router.get( - "/{vendor_code}/billing", response_class=HTMLResponse, include_in_schema=False -) -async def vendor_billing_page( - request: Request, - vendor_code: str = Path(..., description="Vendor code"), - current_user: User = Depends(get_current_vendor_from_cookie_or_header), - db: Session = Depends(get_db), -): - """ - Render billing and subscription management page. - JavaScript loads subscription status, tiers, and invoices via API. - """ - return templates.TemplateResponse( - "billing/vendor/billing.html", - get_vendor_context(request, db, current_user, vendor_code), - ) - - -@router.get( - "/{vendor_code}/invoices", response_class=HTMLResponse, include_in_schema=False -) -async def vendor_invoices_page( - request: Request, - vendor_code: str = Path(..., description="Vendor code"), - current_user: User = Depends(get_current_vendor_from_cookie_or_header), - db: Session = Depends(get_db), -): - """ - Render invoices management page. - JavaScript loads invoices via API. - """ - return templates.TemplateResponse( - "orders/vendor/invoices.html", - get_vendor_context(request, db, current_user, vendor_code), - ) diff --git a/app/modules/billing/schemas/billing.py b/app/modules/billing/schemas/billing.py index b0f9bd87..5739f39d 100644 --- a/app/modules/billing/schemas/billing.py +++ b/app/modules/billing/schemas/billing.py @@ -2,7 +2,7 @@ """ Pydantic schemas for billing and subscription operations. -Used for both vendor billing endpoints and admin subscription management. +Used for admin subscription management and merchant-level billing. """ from datetime import datetime @@ -15,6 +15,14 @@ from pydantic import BaseModel, ConfigDict, Field # ============================================================================ +class TierFeatureLimitEntry(BaseModel): + """Feature limit entry for tier management.""" + + feature_code: str + limit_value: int | None = Field(None, description="None = unlimited for quantitative, ignored for binary") + enabled: bool = True + + class SubscriptionTierBase(BaseModel): """Base schema for subscription tier.""" @@ -23,23 +31,19 @@ class SubscriptionTierBase(BaseModel): description: str | None = None price_monthly_cents: int = Field(..., ge=0) price_annual_cents: int | None = Field(None, ge=0) - orders_per_month: int | None = Field(None, ge=0) - products_limit: int | None = Field(None, ge=0) - team_members: int | None = Field(None, ge=0) - order_history_months: int | None = Field(None, ge=0) - features: list[str] = Field(default_factory=list) stripe_product_id: str | None = None stripe_price_monthly_id: str | None = None stripe_price_annual_id: str | None = None display_order: int = 0 is_active: bool = True is_public: bool = True + platform_id: int | None = None class SubscriptionTierCreate(SubscriptionTierBase): """Schema for creating a subscription tier.""" - pass + feature_limits: list[TierFeatureLimitEntry] = Field(default_factory=list) class SubscriptionTierUpdate(BaseModel): @@ -49,29 +53,37 @@ class SubscriptionTierUpdate(BaseModel): description: str | None = None price_monthly_cents: int | None = Field(None, ge=0) price_annual_cents: int | None = Field(None, ge=0) - orders_per_month: int | None = None - products_limit: int | None = None - team_members: int | None = None - order_history_months: int | None = None - features: list[str] | None = None stripe_product_id: str | None = None stripe_price_monthly_id: str | None = None stripe_price_annual_id: str | None = None display_order: int | None = None is_active: bool | None = None is_public: bool | None = None + feature_limits: list[TierFeatureLimitEntry] | None = None -class SubscriptionTierResponse(SubscriptionTierBase): +class SubscriptionTierResponse(BaseModel): """Schema for subscription tier response.""" model_config = ConfigDict(from_attributes=True) id: int + code: str + name: str + description: str | None = None + price_monthly_cents: int + price_annual_cents: int | None = None + platform_id: int | None = None + stripe_product_id: str | None = None + stripe_price_monthly_id: str | None = None + stripe_price_annual_id: str | None = None + display_order: int + is_active: bool + is_public: bool + feature_codes: list[str] = Field(default_factory=list) created_at: datetime updated_at: datetime - # Computed fields for display @property def price_monthly_display(self) -> str: """Format monthly price for display.""" @@ -93,95 +105,107 @@ class SubscriptionTierListResponse(BaseModel): # ============================================================================ -# Vendor Subscription Schemas +# Merchant Subscription Schemas (Admin View) # ============================================================================ -class VendorSubscriptionResponse(BaseModel): - """Schema for vendor subscription response.""" +class MerchantSubscriptionAdminResponse(BaseModel): + """Merchant subscription response for admin views.""" model_config = ConfigDict(from_attributes=True) id: int - vendor_id: int - tier: str - status: str + merchant_id: int + platform_id: int + tier_id: int | None = None - # Period info + status: str + is_annual: bool period_start: datetime period_end: datetime - is_annual: bool trial_ends_at: datetime | None = None - # Usage - orders_this_period: int - orders_limit_reached_at: datetime | None = None - - # Limits (effective) - orders_limit: int | None = None - products_limit: int | None = None - team_members_limit: int | None = None - - # Custom overrides - custom_orders_limit: int | None = None - custom_products_limit: int | None = None - custom_team_limit: int | None = None - - # Stripe stripe_customer_id: str | None = None stripe_subscription_id: str | None = None - # Cancellation cancelled_at: datetime | None = None cancellation_reason: str | None = None - # Timestamps + payment_retry_count: int = 0 + last_payment_error: str | None = None + created_at: datetime updated_at: datetime -class VendorSubscriptionWithVendor(VendorSubscriptionResponse): - """Subscription response with vendor info.""" +class MerchantSubscriptionWithMerchant(MerchantSubscriptionAdminResponse): + """Subscription response with merchant info.""" - vendor_name: str - vendor_code: str - - # Usage counts (for admin display) - products_count: int | None = None - team_count: int | None = None + merchant_name: str = "" + platform_name: str = "" + tier_name: str | None = None -class VendorSubscriptionListResponse(BaseModel): - """Response for listing vendor subscriptions.""" +class MerchantSubscriptionListResponse(BaseModel): + """Response for listing merchant subscriptions.""" - subscriptions: list[VendorSubscriptionWithVendor] + subscriptions: list[MerchantSubscriptionWithMerchant] total: int page: int per_page: int pages: int -class VendorSubscriptionCreate(BaseModel): - """Schema for admin creating a vendor subscription.""" +class MerchantSubscriptionAdminCreate(BaseModel): + """Schema for admin creating a merchant subscription.""" - tier: str = "essential" + merchant_id: int + platform_id: int + tier_code: str = "essential" status: str = "trial" trial_days: int = 14 is_annual: bool = False -class VendorSubscriptionUpdate(BaseModel): - """Schema for admin updating a vendor subscription.""" +class MerchantSubscriptionAdminUpdate(BaseModel): + """Schema for admin updating a merchant subscription.""" - tier: str | None = None + tier_code: str | None = None status: str | None = None - custom_orders_limit: int | None = None - custom_products_limit: int | None = None - custom_team_limit: int | None = None trial_ends_at: datetime | None = None cancellation_reason: str | None = None +# ============================================================================ +# Merchant Feature Override Schemas +# ============================================================================ + + +class MerchantFeatureOverrideEntry(BaseModel): + """Feature override for a specific merchant.""" + + feature_code: str + limit_value: int | None = None + is_enabled: bool = True + reason: str | None = None + + +class MerchantFeatureOverrideResponse(BaseModel): + """Response for merchant feature override.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + merchant_id: int + platform_id: int + feature_code: str + limit_value: int | None = None + is_enabled: bool + reason: str | None = None + created_at: datetime + updated_at: datetime + + # ============================================================================ # Billing History Schemas # ============================================================================ @@ -193,7 +217,8 @@ class BillingHistoryResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: int - vendor_id: int + store_id: int | None = None + merchant_id: int | None = None stripe_invoice_id: str | None = None invoice_number: str | None = None invoice_date: datetime @@ -225,17 +250,16 @@ class BillingHistoryResponse(BaseModel): return f"€{self.total_cents / 100:.2f}" -class BillingHistoryWithVendor(BillingHistoryResponse): - """Billing history with vendor info.""" +class BillingHistoryWithMerchant(BillingHistoryResponse): + """Billing history with merchant info.""" - vendor_name: str - vendor_code: str + merchant_name: str = "" class BillingHistoryListResponse(BaseModel): """Response for listing billing history.""" - invoices: list[BillingHistoryWithVendor] + invoices: list[BillingHistoryResponse] total: int page: int per_page: int @@ -298,3 +322,31 @@ class SubscriptionStatsResponse(BaseModel): def arr_display(self) -> str: """Format ARR for display.""" return f"€{self.arr_cents / 100:,.2f}" + + +# ============================================================================ +# Feature Catalog Schemas +# ============================================================================ + + +class FeatureDeclarationResponse(BaseModel): + """Feature declaration for admin display.""" + + code: str + name_key: str + description_key: str + category: str + feature_type: str + scope: str + default_limit: int | None = None + unit_key: str | None = None + is_per_period: bool = False + ui_icon: str | None = None + display_order: int = 0 + + +class FeatureCatalogResponse(BaseModel): + """All discovered features grouped by category.""" + + features: dict[str, list[FeatureDeclarationResponse]] + total_count: int diff --git a/app/modules/billing/services/admin_subscription_service.py b/app/modules/billing/services/admin_subscription_service.py index 6da20d90..61a171bd 100644 --- a/app/modules/billing/services/admin_subscription_service.py +++ b/app/modules/billing/services/admin_subscription_service.py @@ -4,7 +4,7 @@ Admin Subscription Service. Handles subscription management operations for platform administrators: - Subscription tier CRUD -- Vendor subscription management +- Merchant subscription management - Billing history queries - Subscription analytics """ @@ -23,12 +23,11 @@ from app.exceptions import ( from app.modules.billing.exceptions import TierNotFoundException from app.modules.billing.models import ( BillingHistory, + MerchantSubscription, SubscriptionStatus, SubscriptionTier, - VendorSubscription, ) -from app.modules.catalog.models import Product -from app.modules.tenancy.models import Vendor, VendorUser +from app.modules.tenancy.models import Merchant logger = logging.getLogger(__name__) @@ -99,12 +98,12 @@ class AdminSubscriptionService: """Soft-delete a subscription tier.""" tier = self.get_tier_by_code(db, tier_code) - # Check if any active subscriptions use this tier + # Check if any active subscriptions use this tier (by tier_id FK) active_subs = ( - db.query(VendorSubscription) + db.query(MerchantSubscription) .filter( - VendorSubscription.tier == tier_code, - VendorSubscription.status.in_([ + MerchantSubscription.tier_id == tier.id, + MerchantSubscription.status.in_([ SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value, ]), @@ -122,7 +121,7 @@ class AdminSubscriptionService: logger.info(f"Soft-deleted subscription tier: {tier.code}") # ========================================================================= - # Vendor Subscriptions + # Merchant Subscriptions # ========================================================================= def list_subscriptions( @@ -134,19 +133,21 @@ class AdminSubscriptionService: tier: str | None = None, search: str | None = None, ) -> dict: - """List vendor subscriptions with filtering and pagination.""" + """List merchant subscriptions with filtering and pagination.""" query = ( - db.query(VendorSubscription, Vendor) - .join(Vendor, VendorSubscription.vendor_id == Vendor.id) + db.query(MerchantSubscription, Merchant) + .join(Merchant, MerchantSubscription.merchant_id == Merchant.id) ) # Apply filters if status: - query = query.filter(VendorSubscription.status == status) + query = query.filter(MerchantSubscription.status == status) if tier: - query = query.filter(VendorSubscription.tier == tier) + query = query.join( + SubscriptionTier, MerchantSubscription.tier_id == SubscriptionTier.id + ).filter(SubscriptionTier.code == tier) if search: - query = query.filter(Vendor.name.ilike(f"%{search}%")) + query = query.filter(Merchant.name.ilike(f"%{search}%")) # Count total total = query.count() @@ -154,7 +155,7 @@ class AdminSubscriptionService: # Paginate offset = (page - 1) * per_page results = ( - query.order_by(VendorSubscription.created_at.desc()) + query.order_by(MerchantSubscription.created_at.desc()) .offset(offset) .limit(per_page) .all() @@ -168,68 +169,44 @@ class AdminSubscriptionService: "pages": ceil(total / per_page) if total > 0 else 0, } - def get_subscription(self, db: Session, vendor_id: int) -> tuple: - """Get subscription for a specific vendor.""" + def get_subscription( + self, db: Session, merchant_id: int, platform_id: int + ) -> tuple: + """Get subscription for a specific merchant on a platform.""" result = ( - db.query(VendorSubscription, Vendor) - .join(Vendor, VendorSubscription.vendor_id == Vendor.id) - .filter(VendorSubscription.vendor_id == vendor_id) + db.query(MerchantSubscription, Merchant) + .join(Merchant, MerchantSubscription.merchant_id == Merchant.id) + .filter( + MerchantSubscription.merchant_id == merchant_id, + MerchantSubscription.platform_id == platform_id, + ) .first() ) if not result: - raise ResourceNotFoundException("Subscription", str(vendor_id)) + raise ResourceNotFoundException( + "Subscription", + f"merchant_id={merchant_id}, platform_id={platform_id}", + ) return result def update_subscription( - self, db: Session, vendor_id: int, update_data: dict + self, db: Session, merchant_id: int, platform_id: int, update_data: dict ) -> tuple: - """Update a vendor's subscription.""" - result = self.get_subscription(db, vendor_id) - sub, vendor = result + """Update a merchant's subscription.""" + result = self.get_subscription(db, merchant_id, platform_id) + sub, merchant = result for field, value in update_data.items(): setattr(sub, field, value) logger.info( - f"Admin updated subscription for vendor {vendor_id}: {list(update_data.keys())}" + f"Admin updated subscription for merchant {merchant_id} " + f"on platform {platform_id}: {list(update_data.keys())}" ) - return sub, vendor - - def get_vendor(self, db: Session, vendor_id: int) -> Vendor: - """Get a vendor by ID.""" - vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() - - if not vendor: - raise ResourceNotFoundException("Vendor", str(vendor_id)) - - return vendor - - def get_vendor_usage_counts(self, db: Session, vendor_id: int) -> dict: - """Get usage counts (products and team members) for a vendor.""" - products_count = ( - db.query(func.count(Product.id)) - .filter(Product.vendor_id == vendor_id) - .scalar() - or 0 - ) - - team_count = ( - db.query(func.count(VendorUser.id)) - .filter( - VendorUser.vendor_id == vendor_id, - VendorUser.is_active == True, # noqa: E712 - ) - .scalar() - or 0 - ) - - return { - "products_count": products_count, - "team_count": team_count, - } + return sub, merchant # ========================================================================= # Billing History @@ -240,17 +217,17 @@ class AdminSubscriptionService: db: Session, page: int = 1, per_page: int = 20, - vendor_id: int | None = None, + merchant_id: int | None = None, status: str | None = None, ) -> dict: - """List billing history across all vendors.""" + """List billing history across all merchants.""" query = ( - db.query(BillingHistory, Vendor) - .join(Vendor, BillingHistory.vendor_id == Vendor.id) + db.query(BillingHistory, Merchant) + .join(Merchant, BillingHistory.merchant_id == Merchant.id) ) - if vendor_id: - query = query.filter(BillingHistory.vendor_id == vendor_id) + if merchant_id: + query = query.filter(BillingHistory.merchant_id == merchant_id) if status: query = query.filter(BillingHistory.status == status) @@ -280,8 +257,11 @@ class AdminSubscriptionService: """Get subscription statistics for admin dashboard.""" # Count by status status_counts = ( - db.query(VendorSubscription.status, func.count(VendorSubscription.id)) - .group_by(VendorSubscription.status) + db.query( + MerchantSubscription.status, + func.count(MerchantSubscription.id), + ) + .group_by(MerchantSubscription.status) .all() ) @@ -294,52 +274,59 @@ class AdminSubscriptionService: "expired_count": 0, } - for status, count in status_counts: + for sub_status, count in status_counts: stats["total_subscriptions"] += count - if status == SubscriptionStatus.ACTIVE.value: + if sub_status == SubscriptionStatus.ACTIVE.value: stats["active_count"] = count - elif status == SubscriptionStatus.TRIAL.value: + elif sub_status == SubscriptionStatus.TRIAL.value: stats["trial_count"] = count - elif status == SubscriptionStatus.PAST_DUE.value: + elif sub_status == SubscriptionStatus.PAST_DUE.value: stats["past_due_count"] = count - elif status == SubscriptionStatus.CANCELLED.value: + elif sub_status == SubscriptionStatus.CANCELLED.value: stats["cancelled_count"] = count - elif status == SubscriptionStatus.EXPIRED.value: + elif sub_status == SubscriptionStatus.EXPIRED.value: stats["expired_count"] = count - # Count by tier + # Count by tier (join with SubscriptionTier to get tier name) tier_counts = ( - db.query(VendorSubscription.tier, func.count(VendorSubscription.id)) + db.query(SubscriptionTier.name, func.count(MerchantSubscription.id)) + .join( + SubscriptionTier, + MerchantSubscription.tier_id == SubscriptionTier.id, + ) .filter( - VendorSubscription.status.in_([ + MerchantSubscription.status.in_([ SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value, ]) ) - .group_by(VendorSubscription.tier) + .group_by(SubscriptionTier.name) .all() ) - tier_distribution = {tier: count for tier, count in tier_counts} + tier_distribution = {tier_name: count for tier_name, count in tier_counts} # Calculate MRR (Monthly Recurring Revenue) mrr_cents = 0 arr_cents = 0 active_subs = ( - db.query(VendorSubscription, SubscriptionTier) - .join(SubscriptionTier, VendorSubscription.tier == SubscriptionTier.code) - .filter(VendorSubscription.status == SubscriptionStatus.ACTIVE.value) + db.query(MerchantSubscription, SubscriptionTier) + .join( + SubscriptionTier, + MerchantSubscription.tier_id == SubscriptionTier.id, + ) + .filter(MerchantSubscription.status == SubscriptionStatus.ACTIVE.value) .all() ) - for sub, tier in active_subs: - if sub.is_annual and tier.price_annual_cents: - mrr_cents += tier.price_annual_cents // 12 - arr_cents += tier.price_annual_cents + for sub, sub_tier in active_subs: + if sub.is_annual and sub_tier.price_annual_cents: + mrr_cents += sub_tier.price_annual_cents // 12 + arr_cents += sub_tier.price_annual_cents else: - mrr_cents += tier.price_monthly_cents - arr_cents += tier.price_monthly_cents * 12 + mrr_cents += sub_tier.price_monthly_cents + arr_cents += sub_tier.price_monthly_cents * 12 stats["tier_distribution"] = tier_distribution stats["mrr_cents"] = mrr_cents diff --git a/app/modules/billing/services/billing_features.py b/app/modules/billing/services/billing_features.py new file mode 100644 index 00000000..92118eb8 --- /dev/null +++ b/app/modules/billing/services/billing_features.py @@ -0,0 +1,141 @@ +# app/modules/billing/services/billing_features.py +""" +Billing feature provider for the billing feature system. + +Declares billing-related billable features (invoicing, accounting export, +basic shop, custom domain, white label) for feature gating. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from app.modules.contracts.features import ( + FeatureDeclaration, + FeatureProviderProtocol, + FeatureScope, + FeatureType, + FeatureUsage, +) + +if TYPE_CHECKING: + from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + + +class BillingFeatureProvider: + """Feature provider for the billing module. + + Declares: + - invoice_lu: binary merchant-level feature for Luxembourg invoicing + - invoice_eu_vat: binary merchant-level feature for EU VAT invoicing + - invoice_bulk: binary merchant-level feature for bulk invoice generation + - accounting_export: binary merchant-level feature for accounting data export + - basic_shop: binary merchant-level feature for basic shop functionality + - custom_domain: binary merchant-level feature for custom domain support + - white_label: binary merchant-level feature for white-label branding + """ + + @property + def feature_category(self) -> str: + return "billing" + + def get_feature_declarations(self) -> list[FeatureDeclaration]: + return [ + FeatureDeclaration( + code="invoice_lu", + name_key="billing.features.invoice_lu.name", + description_key="billing.features.invoice_lu.description", + category="billing", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="file-text", + display_order=10, + ), + FeatureDeclaration( + code="invoice_eu_vat", + name_key="billing.features.invoice_eu_vat.name", + description_key="billing.features.invoice_eu_vat.description", + category="billing", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="globe", + display_order=20, + ), + FeatureDeclaration( + code="invoice_bulk", + name_key="billing.features.invoice_bulk.name", + description_key="billing.features.invoice_bulk.description", + category="billing", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="layers", + display_order=30, + ), + FeatureDeclaration( + code="accounting_export", + name_key="billing.features.accounting_export.name", + description_key="billing.features.accounting_export.description", + category="billing", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="download", + display_order=40, + ), + FeatureDeclaration( + code="basic_shop", + name_key="billing.features.basic_shop.name", + description_key="billing.features.basic_shop.description", + category="billing", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="shopping-bag", + display_order=50, + ), + FeatureDeclaration( + code="custom_domain", + name_key="billing.features.custom_domain.name", + description_key="billing.features.custom_domain.description", + category="billing", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="globe", + display_order=60, + ), + FeatureDeclaration( + code="white_label", + name_key="billing.features.white_label.name", + description_key="billing.features.white_label.description", + category="billing", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="award", + display_order=70, + ), + ] + + def get_store_usage( + self, + db: Session, + store_id: int, + ) -> list[FeatureUsage]: + return [] + + def get_merchant_usage( + self, + db: Session, + merchant_id: int, + platform_id: int, + ) -> list[FeatureUsage]: + return [] + + +# Singleton instance for module registration +billing_feature_provider = BillingFeatureProvider() + +__all__ = [ + "BillingFeatureProvider", + "billing_feature_provider", +] diff --git a/app/modules/billing/services/billing_service.py b/app/modules/billing/services/billing_service.py index c9c56a0f..b3e108d3 100644 --- a/app/modules/billing/services/billing_service.py +++ b/app/modules/billing/services/billing_service.py @@ -3,10 +3,11 @@ Billing service for subscription and payment operations. Provides: -- Subscription status and usage queries +- Subscription status and usage queries (merchant-level) - Tier management - Invoice history - Add-on management +- Stripe checkout and portal session management """ import logging @@ -19,9 +20,9 @@ from app.modules.billing.services.subscription_service import subscription_servi from app.modules.billing.models import ( AddOnProduct, BillingHistory, + MerchantSubscription, SubscriptionTier, - VendorAddOn, - VendorSubscription, + StoreAddOn, ) from app.modules.billing.exceptions import ( BillingServiceError, @@ -31,7 +32,6 @@ from app.modules.billing.exceptions import ( SubscriptionNotCancelledError, TierNotFoundError, ) -from app.modules.tenancy.models import Vendor logger = logging.getLogger(__name__) @@ -40,26 +40,21 @@ class BillingService: """Service for billing operations.""" def get_subscription_with_tier( - self, db: Session, vendor_id: int - ) -> tuple[VendorSubscription, SubscriptionTier | None]: + self, db: Session, merchant_id: int, platform_id: int + ) -> tuple[MerchantSubscription, SubscriptionTier | None]: """ - Get subscription and its tier info. + Get merchant subscription and its tier info. Returns: Tuple of (subscription, tier) where tier may be None """ - subscription = subscription_service.get_or_create_subscription(db, vendor_id) - - tier = ( - db.query(SubscriptionTier) - .filter(SubscriptionTier.code == subscription.tier) - .first() + subscription = subscription_service.get_or_create_subscription( + db, merchant_id, platform_id ) - - return subscription, tier + return subscription, subscription.tier def get_available_tiers( - self, db: Session, current_tier: str + self, db: Session, current_tier_id: int | None, platform_id: int | None = None ) -> tuple[list[dict], dict[str, int]]: """ Get all available tiers with upgrade/downgrade flags. @@ -67,32 +62,26 @@ class BillingService: Returns: Tuple of (tier_list, tier_order_map) """ - tiers = ( - db.query(SubscriptionTier) - .filter( - SubscriptionTier.is_active == True, # noqa: E712 - SubscriptionTier.is_public == True, # noqa: E712 - ) - .order_by(SubscriptionTier.display_order) - .all() - ) + tiers = subscription_service.get_all_tiers(db, platform_id=platform_id) tier_order = {t.code: t.display_order for t in tiers} - current_order = tier_order.get(current_tier, 0) + current_order = 0 + for t in tiers: + if t.id == current_tier_id: + current_order = t.display_order + break tier_list = [] for tier in tiers: + feature_codes = tier.get_feature_codes() tier_list.append({ "code": tier.code, "name": tier.name, "description": tier.description, "price_monthly_cents": tier.price_monthly_cents, "price_annual_cents": tier.price_annual_cents, - "orders_per_month": tier.orders_per_month, - "products_limit": tier.products_limit, - "team_members": tier.team_members, - "features": tier.features or [], - "is_current": tier.code == current_tier, + "feature_codes": sorted(feature_codes), + "is_current": tier.id == current_tier_id, "can_upgrade": tier.display_order > current_order, "can_downgrade": tier.display_order < current_order, }) @@ -120,32 +109,18 @@ class BillingService: return tier - def get_vendor(self, db: Session, vendor_id: int) -> Vendor: - """ - Get vendor by ID. - - Raises: - VendorNotFoundException from app.exceptions - """ - from app.modules.tenancy.exceptions import VendorNotFoundException - - vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() - if not vendor: - raise VendorNotFoundException(str(vendor_id), identifier_type="id") - - return vendor - def create_checkout_session( self, db: Session, - vendor_id: int, + merchant_id: int, + platform_id: int, tier_code: str, is_annual: bool, success_url: str, cancel_url: str, ) -> dict: """ - Create a Stripe checkout session. + Create a Stripe checkout session for a merchant subscription. Returns: Dict with checkout_url and session_id @@ -158,7 +133,6 @@ class BillingService: if not stripe_service.is_configured: raise PaymentSystemNotConfiguredError() - vendor = self.get_vendor(db, vendor_id) tier = self.get_tier_by_code(db, tier_code) price_id = ( @@ -171,15 +145,21 @@ class BillingService: raise StripePriceNotConfiguredError(tier_code) # Check if this is a new subscription (for trial) - existing_sub = subscription_service.get_subscription(db, vendor_id) + existing_sub = subscription_service.get_merchant_subscription( + db, merchant_id, platform_id + ) trial_days = None if not existing_sub or not existing_sub.stripe_subscription_id: from app.core.config import settings trial_days = settings.stripe_trial_days + # Get merchant for Stripe customer creation + from app.modules.tenancy.models import Merchant + merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first() + session = stripe_service.create_checkout_session( db=db, - vendor=vendor, + store=merchant, # Stripe service uses store for customer creation price_id=price_id, success_url=success_url, cancel_url=cancel_url, @@ -187,8 +167,10 @@ class BillingService: ) # Update subscription with tier info - subscription = subscription_service.get_or_create_subscription(db, vendor_id) - subscription.tier = tier_code + subscription = subscription_service.get_or_create_subscription( + db, merchant_id, platform_id + ) + subscription.tier_id = tier.id subscription.is_annual = is_annual return { @@ -196,7 +178,9 @@ class BillingService: "session_id": session.id, } - def create_portal_session(self, db: Session, vendor_id: int, return_url: str) -> dict: + def create_portal_session( + self, db: Session, merchant_id: int, platform_id: int, return_url: str + ) -> dict: """ Create a Stripe customer portal session. @@ -210,7 +194,9 @@ class BillingService: if not stripe_service.is_configured: raise PaymentSystemNotConfiguredError() - subscription = subscription_service.get_subscription(db, vendor_id) + subscription = subscription_service.get_merchant_subscription( + db, merchant_id, platform_id + ) if not subscription or not subscription.stripe_customer_id: raise NoActiveSubscriptionError() @@ -223,15 +209,17 @@ class BillingService: return {"portal_url": session.url} def get_invoices( - self, db: Session, vendor_id: int, skip: int = 0, limit: int = 20 + self, db: Session, merchant_id: int, skip: int = 0, limit: int = 20 ) -> tuple[list[BillingHistory], int]: """ - Get invoice history for a vendor. + Get invoice history for a merchant. Returns: Tuple of (invoices, total_count) """ - query = db.query(BillingHistory).filter(BillingHistory.vendor_id == vendor_id) + query = db.query(BillingHistory).filter( + BillingHistory.merchant_id == merchant_id + ) total = query.count() @@ -255,16 +243,21 @@ class BillingService: return query.order_by(AddOnProduct.display_order).all() - def get_vendor_addons(self, db: Session, vendor_id: int) -> list[VendorAddOn]: - """Get vendor's purchased add-ons.""" + def get_store_addons(self, db: Session, store_id: int) -> list[StoreAddOn]: + """Get store's purchased add-ons.""" return ( - db.query(VendorAddOn) - .filter(VendorAddOn.vendor_id == vendor_id) + db.query(StoreAddOn) + .filter(StoreAddOn.store_id == store_id) .all() ) def cancel_subscription( - self, db: Session, vendor_id: int, reason: str | None, immediately: bool + self, + db: Session, + merchant_id: int, + platform_id: int, + reason: str | None, + immediately: bool, ) -> dict: """ Cancel a subscription. @@ -275,7 +268,9 @@ class BillingService: Raises: NoActiveSubscriptionError: If no subscription to cancel """ - subscription = subscription_service.get_subscription(db, vendor_id) + subscription = subscription_service.get_merchant_subscription( + db, merchant_id, platform_id + ) if not subscription or not subscription.stripe_subscription_id: raise NoActiveSubscriptionError() @@ -303,7 +298,9 @@ class BillingService: "effective_date": effective_date, } - def reactivate_subscription(self, db: Session, vendor_id: int) -> dict: + def reactivate_subscription( + self, db: Session, merchant_id: int, platform_id: int + ) -> dict: """ Reactivate a cancelled subscription. @@ -314,7 +311,9 @@ class BillingService: NoActiveSubscriptionError: If no subscription SubscriptionNotCancelledError: If not cancelled """ - subscription = subscription_service.get_subscription(db, vendor_id) + subscription = subscription_service.get_merchant_subscription( + db, merchant_id, platform_id + ) if not subscription or not subscription.stripe_subscription_id: raise NoActiveSubscriptionError() @@ -330,7 +329,9 @@ class BillingService: return {"message": "Subscription reactivated successfully"} - def get_upcoming_invoice(self, db: Session, vendor_id: int) -> dict: + def get_upcoming_invoice( + self, db: Session, merchant_id: int, platform_id: int + ) -> dict: """ Get upcoming invoice preview. @@ -340,13 +341,14 @@ class BillingService: Raises: NoActiveSubscriptionError: If no subscription with customer ID """ - subscription = subscription_service.get_subscription(db, vendor_id) + subscription = subscription_service.get_merchant_subscription( + db, merchant_id, platform_id + ) if not subscription or not subscription.stripe_customer_id: raise NoActiveSubscriptionError() if not stripe_service.is_configured: - # Return empty preview if Stripe not configured return { "amount_due_cents": 0, "currency": "EUR", @@ -385,7 +387,8 @@ class BillingService: def change_tier( self, db: Session, - vendor_id: int, + merchant_id: int, + platform_id: int, new_tier_code: str, is_annual: bool, ) -> dict: @@ -400,7 +403,9 @@ class BillingService: NoActiveSubscriptionError: If no subscription StripePriceNotConfiguredError: If price not configured """ - subscription = subscription_service.get_subscription(db, vendor_id) + subscription = subscription_service.get_merchant_subscription( + db, merchant_id, platform_id + ) if not subscription or not subscription.stripe_subscription_id: raise NoActiveSubscriptionError() @@ -424,13 +429,12 @@ class BillingService: ) # Update local subscription - old_tier = subscription.tier - subscription.tier = new_tier_code + old_tier_id = subscription.tier_id subscription.tier_id = tier.id subscription.is_annual = is_annual subscription.updated_at = datetime.utcnow() - is_upgrade = self._is_upgrade(db, old_tier, new_tier_code) + is_upgrade = self._is_upgrade(db, old_tier_id, tier.id) return { "message": f"Subscription {'upgraded' if is_upgrade else 'changed'} to {tier.name}", @@ -438,10 +442,13 @@ class BillingService: "effective_immediately": True, } - def _is_upgrade(self, db: Session, old_tier: str, new_tier: str) -> bool: - """Check if tier change is an upgrade.""" - old = db.query(SubscriptionTier).filter(SubscriptionTier.code == old_tier).first() - new = db.query(SubscriptionTier).filter(SubscriptionTier.code == new_tier).first() + def _is_upgrade(self, db: Session, old_tier_id: int | None, new_tier_id: int | None) -> bool: + """Check if tier change is an upgrade based on display_order.""" + if not old_tier_id or not new_tier_id: + return False + + old = db.query(SubscriptionTier).filter(SubscriptionTier.id == old_tier_id).first() + new = db.query(SubscriptionTier).filter(SubscriptionTier.id == new_tier_id).first() if not old or not new: return False @@ -451,7 +458,7 @@ class BillingService: def purchase_addon( self, db: Session, - vendor_id: int, + store_id: int, addon_code: str, domain_name: str | None, quantity: int, @@ -466,7 +473,7 @@ class BillingService: Raises: PaymentSystemNotConfiguredError: If Stripe not configured - AddonNotFoundError: If addon doesn't exist + BillingServiceError: If addon doesn't exist """ if not stripe_service.is_configured: raise PaymentSystemNotConfiguredError() @@ -486,13 +493,12 @@ class BillingService: if not addon.stripe_price_id: raise BillingServiceError(f"Stripe price not configured for add-on '{addon_code}'") - vendor = self.get_vendor(db, vendor_id) - subscription = subscription_service.get_or_create_subscription(db, vendor_id) + from app.modules.tenancy.models import Store + store = db.query(Store).filter(Store.id == store_id).first() - # Create checkout session for add-on session = stripe_service.create_checkout_session( db=db, - vendor=vendor, + store=store, price_id=addon.stripe_price_id, success_url=success_url, cancel_url=cancel_url, @@ -508,7 +514,7 @@ class BillingService: "session_id": session.id, } - def cancel_addon(self, db: Session, vendor_id: int, addon_id: int) -> dict: + def cancel_addon(self, db: Session, store_id: int, addon_id: int) -> dict: """ Cancel a purchased add-on. @@ -516,32 +522,32 @@ class BillingService: Dict with message and addon_code Raises: - BillingServiceError: If addon not found or not owned by vendor + BillingServiceError: If addon not found or not owned by store """ - vendor_addon = ( - db.query(VendorAddOn) + store_addon = ( + db.query(StoreAddOn) .filter( - VendorAddOn.id == addon_id, - VendorAddOn.vendor_id == vendor_id, + StoreAddOn.id == addon_id, + StoreAddOn.store_id == store_id, ) .first() ) - if not vendor_addon: + if not store_addon: raise BillingServiceError("Add-on not found") - addon_code = vendor_addon.addon_product.code + addon_code = store_addon.addon_product.code # Cancel in Stripe if applicable - if stripe_service.is_configured and vendor_addon.stripe_subscription_item_id: + if stripe_service.is_configured and store_addon.stripe_subscription_item_id: try: - stripe_service.cancel_subscription_item(vendor_addon.stripe_subscription_item_id) + stripe_service.cancel_subscription_item(store_addon.stripe_subscription_item_id) except Exception as e: logger.warning(f"Failed to cancel addon in Stripe: {e}") # Mark as cancelled - vendor_addon.status = "cancelled" - vendor_addon.cancelled_at = datetime.utcnow() + store_addon.status = "cancelled" + store_addon.cancelled_at = datetime.utcnow() return { "message": "Add-on cancelled successfully", diff --git a/app/modules/billing/services/capacity_forecast_service.py b/app/modules/billing/services/capacity_forecast_service.py index dea91db6..d817ebd0 100644 --- a/app/modules/billing/services/capacity_forecast_service.py +++ b/app/modules/billing/services/capacity_forecast_service.py @@ -19,22 +19,22 @@ from sqlalchemy.orm import Session from app.modules.catalog.models import Product from app.modules.billing.models import ( CapacitySnapshot, + MerchantSubscription, SubscriptionStatus, - VendorSubscription, ) -from app.modules.tenancy.models import Vendor, VendorUser +from app.modules.tenancy.models import Store, StoreUser logger = logging.getLogger(__name__) # Scaling thresholds based on capacity-planning.md INFRASTRUCTURE_SCALING = [ - {"name": "Starter", "max_vendors": 50, "max_products": 10_000, "cost_monthly": 30}, - {"name": "Small", "max_vendors": 100, "max_products": 30_000, "cost_monthly": 80}, - {"name": "Medium", "max_vendors": 300, "max_products": 100_000, "cost_monthly": 150}, - {"name": "Large", "max_vendors": 500, "max_products": 250_000, "cost_monthly": 350}, - {"name": "Scale", "max_vendors": 1000, "max_products": 500_000, "cost_monthly": 700}, - {"name": "Enterprise", "max_vendors": None, "max_products": None, "cost_monthly": 1500}, + {"name": "Starter", "max_stores": 50, "max_products": 10_000, "cost_monthly": 30}, + {"name": "Small", "max_stores": 100, "max_products": 30_000, "cost_monthly": 80}, + {"name": "Medium", "max_stores": 300, "max_products": 100_000, "cost_monthly": 150}, + {"name": "Large", "max_stores": 500, "max_products": 250_000, "cost_monthly": 350}, + {"name": "Scale", "max_stores": 1000, "max_products": 500_000, "cost_monthly": 700}, + {"name": "Enterprise", "max_stores": None, "max_products": None, "cost_monthly": 1500}, ] @@ -64,25 +64,25 @@ class CapacityForecastService: return existing # Gather metrics - total_vendors = db.query(func.count(Vendor.id)).scalar() or 0 - active_vendors = ( - db.query(func.count(Vendor.id)) - .filter(Vendor.is_active == True) # noqa: E712 + total_stores = db.query(func.count(Store.id)).scalar() or 0 + active_stores = ( + db.query(func.count(Store.id)) + .filter(Store.is_active == True) # noqa: E712 .scalar() or 0 ) # Subscription metrics - total_subs = db.query(func.count(VendorSubscription.id)).scalar() or 0 + total_subs = db.query(func.count(MerchantSubscription.id)).scalar() or 0 active_subs = ( - db.query(func.count(VendorSubscription.id)) - .filter(VendorSubscription.status.in_(["active", "trial"])) + db.query(func.count(MerchantSubscription.id)) + .filter(MerchantSubscription.status.in_(["active", "trial"])) .scalar() or 0 ) - trial_vendors = ( - db.query(func.count(VendorSubscription.id)) - .filter(VendorSubscription.status == SubscriptionStatus.TRIAL.value) + trial_stores = ( + db.query(func.count(MerchantSubscription.id)) + .filter(MerchantSubscription.status == SubscriptionStatus.TRIAL.value) .scalar() or 0 ) @@ -90,17 +90,20 @@ class CapacityForecastService: # Resource metrics total_products = db.query(func.count(Product.id)).scalar() or 0 total_team = ( - db.query(func.count(VendorUser.id)) - .filter(VendorUser.is_active == True) # noqa: E712 + db.query(func.count(StoreUser.id)) + .filter(StoreUser.is_active == True) # noqa: E712 .scalar() or 0 ) # Orders this month start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - total_orders = sum( - s.orders_this_period - for s in db.query(VendorSubscription).all() + from app.modules.orders.models import Order + + total_orders = ( + db.query(func.count(Order.id)) + .filter(Order.created_at >= start_of_month) + .scalar() or 0 ) # Storage metrics @@ -127,9 +130,9 @@ class CapacityForecastService: # Create snapshot snapshot = CapacitySnapshot( snapshot_date=today, - total_vendors=total_vendors, - active_vendors=active_vendors, - trial_vendors=trial_vendors, + total_stores=total_stores, + active_stores=active_stores, + trial_stores=trial_stores, total_subscriptions=total_subs, active_subscriptions=active_subs, total_products=total_products, @@ -203,7 +206,7 @@ class CapacityForecastService: } trends = { - "vendors": calc_growth("active_vendors"), + "stores": calc_growth("active_stores"), "products": calc_growth("total_products"), "orders": calc_growth("total_orders_month"), "team_members": calc_growth("total_team_members"), @@ -245,7 +248,7 @@ class CapacityForecastService: "severity": "warning", "title": "Product capacity approaching limit", "description": f"Currently at {products['utilization_percent']:.0f}% of theoretical product capacity", - "action": "Consider upgrading vendor tiers or adding capacity", + "action": "Consider upgrading store tiers or adding capacity", }) # Check infrastructure tier @@ -262,15 +265,15 @@ class CapacityForecastService: # Check growth rate if trends.get("trends"): - vendor_growth = trends["trends"].get("vendors", {}) - if vendor_growth.get("monthly_projection", 0) > 0: - monthly_rate = vendor_growth.get("growth_rate_percent", 0) + store_growth = trends["trends"].get("stores", {}) + if store_growth.get("monthly_projection", 0) > 0: + monthly_rate = store_growth.get("growth_rate_percent", 0) if monthly_rate > 20: recommendations.append({ "category": "growth", "severity": "info", - "title": "High vendor growth rate", - "description": f"Vendor base growing at {monthly_rate:.1f}% over last 30 days", + "title": "High store growth rate", + "description": f"Store base growing at {monthly_rate:.1f}% over last 30 days", "action": "Ensure infrastructure can scale to meet demand", }) diff --git a/app/modules/billing/services/feature_aggregator.py b/app/modules/billing/services/feature_aggregator.py new file mode 100644 index 00000000..7192d092 --- /dev/null +++ b/app/modules/billing/services/feature_aggregator.py @@ -0,0 +1,255 @@ +# app/modules/billing/services/feature_aggregator.py +""" +Feature aggregator service for cross-module feature discovery and usage tracking. + +Discovers FeatureProviderProtocol implementations from all modules, +caches declarations, and provides aggregated usage data. + +Usage: + from app.modules.billing.services.feature_aggregator import feature_aggregator + + # Get all declared features + declarations = feature_aggregator.get_all_declarations() + + # Get usage for a store + usage = feature_aggregator.get_store_usage(db, store_id) + + # Check a limit + allowed, message = feature_aggregator.check_limit(db, "products_limit", store_id=store_id) +""" + +import logging +from typing import TYPE_CHECKING + +from app.modules.contracts.features import ( + FeatureDeclaration, + FeatureProviderProtocol, + FeatureScope, + FeatureType, + FeatureUsage, +) + +if TYPE_CHECKING: + from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + + +class FeatureAggregatorService: + """ + Singleton service that discovers and aggregates feature providers from all modules. + + Discovers feature_provider from all modules via app.modules.registry.MODULES. + Caches declarations (they're static and don't change at runtime). + """ + + def __init__(self): + self._declarations_cache: dict[str, FeatureDeclaration] | None = None + self._providers_cache: list[FeatureProviderProtocol] | None = None + + def _discover_providers(self) -> list[FeatureProviderProtocol]: + """Discover all feature providers from registered modules.""" + if self._providers_cache is not None: + return self._providers_cache + + from app.modules.registry import MODULES + + providers = [] + for module in MODULES.values(): + if module.has_feature_provider(): + try: + provider = module.get_feature_provider_instance() + if provider is not None: + providers.append(provider) + logger.debug( + f"Discovered feature provider from module '{module.code}': " + f"category='{provider.feature_category}'" + ) + except Exception as e: + logger.error( + f"Failed to load feature provider from module '{module.code}': {e}" + ) + + self._providers_cache = providers + logger.info(f"Discovered {len(providers)} feature providers") + return providers + + def _build_declarations(self) -> dict[str, FeatureDeclaration]: + """Build and cache the feature declarations map.""" + if self._declarations_cache is not None: + return self._declarations_cache + + declarations: dict[str, FeatureDeclaration] = {} + for provider in self._discover_providers(): + try: + for decl in provider.get_feature_declarations(): + if decl.code in declarations: + logger.warning( + f"Duplicate feature code '{decl.code}' from " + f"category '{provider.feature_category}' " + f"(already declared by '{declarations[decl.code].category}')" + ) + continue + declarations[decl.code] = decl + except Exception as e: + logger.error( + f"Failed to get declarations from provider " + f"'{provider.feature_category}': {e}" + ) + + self._declarations_cache = declarations + logger.info(f"Built feature catalog: {len(declarations)} features") + return declarations + + # ========================================================================= + # Public API — Declarations + # ========================================================================= + + def get_all_declarations(self) -> dict[str, FeatureDeclaration]: + """ + Get all feature declarations from all modules. + + Returns: + Dict mapping feature_code -> FeatureDeclaration + """ + return self._build_declarations() + + def get_declaration(self, feature_code: str) -> FeatureDeclaration | None: + """Get a single feature declaration by code.""" + return self._build_declarations().get(feature_code) + + def get_declarations_by_category(self) -> dict[str, list[FeatureDeclaration]]: + """ + Get feature declarations grouped by category. + + Returns: + Dict mapping category -> list of FeatureDeclaration, sorted by display_order + """ + by_category: dict[str, list[FeatureDeclaration]] = {} + for decl in self._build_declarations().values(): + by_category.setdefault(decl.category, []).append(decl) + + # Sort each category by display_order + for category in by_category: + by_category[category].sort(key=lambda d: d.display_order) + + return by_category + + def validate_feature_codes(self, codes: set[str]) -> set[str]: + """ + Validate feature codes against known declarations. + + Args: + codes: Set of feature codes to validate + + Returns: + Set of invalid codes (empty if all valid) + """ + known = set(self._build_declarations().keys()) + return codes - known + + # ========================================================================= + # Public API — Usage + # ========================================================================= + + def get_store_usage(self, db: "Session", store_id: int) -> dict[str, FeatureUsage]: + """ + Get current usage for a specific store across all providers. + + Args: + db: Database session + store_id: Store ID + + Returns: + Dict mapping feature_code -> FeatureUsage + """ + usage: dict[str, FeatureUsage] = {} + for provider in self._discover_providers(): + try: + for item in provider.get_store_usage(db, store_id): + usage[item.feature_code] = item + except Exception as e: + logger.error( + f"Failed to get store usage from provider " + f"'{provider.feature_category}': {e}" + ) + return usage + + def get_merchant_usage( + self, db: "Session", merchant_id: int, platform_id: int + ) -> dict[str, FeatureUsage]: + """ + Get current usage aggregated across all merchant's stores. + + Args: + db: Database session + merchant_id: Merchant ID + platform_id: Platform ID + + Returns: + Dict mapping feature_code -> FeatureUsage + """ + usage: dict[str, FeatureUsage] = {} + for provider in self._discover_providers(): + try: + for item in provider.get_merchant_usage(db, merchant_id, platform_id): + usage[item.feature_code] = item + except Exception as e: + logger.error( + f"Failed to get merchant usage from provider " + f"'{provider.feature_category}': {e}" + ) + return usage + + def get_usage_for_feature( + self, + db: "Session", + feature_code: str, + store_id: int | None = None, + merchant_id: int | None = None, + platform_id: int | None = None, + ) -> FeatureUsage | None: + """ + Get usage for a specific feature, respecting its scope. + + Args: + db: Database session + feature_code: Feature code to check + store_id: Store ID (for STORE-scoped features) + merchant_id: Merchant ID (for MERCHANT-scoped features) + platform_id: Platform ID (for MERCHANT-scoped features) + + Returns: + FeatureUsage or None if not found + """ + decl = self.get_declaration(feature_code) + if not decl or decl.feature_type != FeatureType.QUANTITATIVE: + return None + + if decl.scope == FeatureScope.STORE and store_id is not None: + usage = self.get_store_usage(db, store_id) + return usage.get(feature_code) + elif decl.scope == FeatureScope.MERCHANT and merchant_id is not None and platform_id is not None: + usage = self.get_merchant_usage(db, merchant_id, platform_id) + return usage.get(feature_code) + + return None + + # ========================================================================= + # Cache Management + # ========================================================================= + + def invalidate_cache(self) -> None: + """Invalidate all caches. Call when modules are added/removed.""" + self._declarations_cache = None + self._providers_cache = None + logger.debug("Feature aggregator cache invalidated") + + +# Singleton instance +feature_aggregator = FeatureAggregatorService() + +__all__ = [ + "feature_aggregator", + "FeatureAggregatorService", +] diff --git a/app/modules/billing/services/platform_pricing_service.py b/app/modules/billing/services/platform_pricing_service.py index 64ad237a..615ec13c 100644 --- a/app/modules/billing/services/platform_pricing_service.py +++ b/app/modules/billing/services/platform_pricing_service.py @@ -10,8 +10,6 @@ from sqlalchemy.orm import Session from app.modules.billing.models import ( AddOnProduct, SubscriptionTier, - TIER_LIMITS, - TierCode, ) @@ -19,12 +17,7 @@ class PlatformPricingService: """Service for handling pricing data operations.""" def get_public_tiers(self, db: Session) -> list[SubscriptionTier]: - """ - Get all public subscription tiers from the database. - - Returns: - List of active, public subscription tiers ordered by display_order - """ + """Get all public subscription tiers from the database.""" return ( db.query(SubscriptionTier) .filter( @@ -36,16 +29,7 @@ class PlatformPricingService: ) def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier | None: - """ - Get a specific tier by code from the database. - - Args: - db: Database session - tier_code: The tier code to look up - - Returns: - SubscriptionTier if found, None otherwise - """ + """Get a specific tier by code from the database.""" return ( db.query(SubscriptionTier) .filter( @@ -55,33 +39,8 @@ class PlatformPricingService: .first() ) - def get_tier_from_hardcoded(self, tier_code: str) -> dict | None: - """ - Get tier limits from hardcoded TIER_LIMITS. - - Args: - tier_code: The tier code to look up - - Returns: - Dict with tier limits if valid code, None otherwise - """ - try: - tier_enum = TierCode(tier_code) - limits = TIER_LIMITS[tier_enum] - return { - "tier_enum": tier_enum, - "limits": limits, - } - except ValueError: - return None - def get_active_addons(self, db: Session) -> list[AddOnProduct]: - """ - Get all active add-on products from the database. - - Returns: - List of active add-on products ordered by category and display_order - """ + """Get all active add-on products from the database.""" return ( db.query(AddOnProduct) .filter(AddOnProduct.is_active == True) diff --git a/app/modules/billing/services/stripe_service.py b/app/modules/billing/services/stripe_service.py index 6e7ca652..a58d63a4 100644 --- a/app/modules/billing/services/stripe_service.py +++ b/app/modules/billing/services/stripe_service.py @@ -23,11 +23,11 @@ from app.modules.billing.exceptions import ( ) from app.modules.billing.models import ( BillingHistory, + MerchantSubscription, SubscriptionStatus, SubscriptionTier, - VendorSubscription, ) -from app.modules.tenancy.models import Vendor +from app.modules.tenancy.models import Store logger = logging.getLogger(__name__) @@ -63,32 +63,32 @@ class StripeService: def create_customer( self, - vendor: Vendor, + store: Store, email: str, name: str | None = None, metadata: dict | None = None, ) -> str: """ - Create a Stripe customer for a vendor. + Create a Stripe customer for a store. Returns the Stripe customer ID. """ self._check_configured() customer_metadata = { - "vendor_id": str(vendor.id), - "vendor_code": vendor.vendor_code, + "store_id": str(store.id), + "store_code": store.store_code, **(metadata or {}), } customer = stripe.Customer.create( email=email, - name=name or vendor.name, + name=name or store.name, metadata=customer_metadata, ) logger.info( - f"Created Stripe customer {customer.id} for vendor {vendor.vendor_code}" + f"Created Stripe customer {customer.id} for store {store.store_code}" ) return customer.id @@ -271,7 +271,7 @@ class StripeService: def create_checkout_session( self, db: Session, - vendor: Vendor, + store: Store, price_id: str, success_url: str, cancel_url: str, @@ -284,7 +284,7 @@ class StripeService: Args: db: Database session - vendor: Vendor to create checkout for + store: Store to create checkout for price_id: Stripe price ID success_url: URL to redirect on success cancel_url: URL to redirect on cancel @@ -298,29 +298,38 @@ class StripeService: self._check_configured() # Get or create Stripe customer - subscription = ( - db.query(VendorSubscription) - .filter(VendorSubscription.vendor_id == vendor.id) - .first() - ) + from app.modules.tenancy.models import StorePlatform + + sp = db.query(StorePlatform.platform_id).filter(StorePlatform.store_id == store.id).first() + platform_id = sp[0] if sp else None + subscription = None + if store.merchant_id and platform_id: + subscription = ( + db.query(MerchantSubscription) + .filter( + MerchantSubscription.merchant_id == store.merchant_id, + MerchantSubscription.platform_id == platform_id, + ) + .first() + ) if subscription and subscription.stripe_customer_id: customer_id = subscription.stripe_customer_id else: - # Get vendor owner email - from app.modules.tenancy.models import VendorUser + # Get store owner email + from app.modules.tenancy.models import StoreUser owner = ( - db.query(VendorUser) + db.query(StoreUser) .filter( - VendorUser.vendor_id == vendor.id, - VendorUser.is_owner == True, + StoreUser.store_id == store.id, + StoreUser.is_owner == True, ) .first() ) email = owner.user.email if owner and owner.user else None - customer_id = self.create_customer(vendor, email or f"{vendor.vendor_code}@placeholder.com") + customer_id = self.create_customer(store, email or f"{store.store_code}@placeholder.com") # Store the customer ID if subscription: @@ -329,8 +338,9 @@ class StripeService: # Build metadata session_metadata = { - "vendor_id": str(vendor.id), - "vendor_code": vendor.vendor_code, + "store_id": str(store.id), + "store_code": store.store_code, + "merchant_id": str(store.merchant_id) if store.merchant_id else "", } if metadata: session_metadata.update(metadata) @@ -348,7 +358,7 @@ class StripeService: session_data["subscription_data"] = {"trial_period_days": trial_days} session = stripe.checkout.Session.create(**session_data) - logger.info(f"Created checkout session {session.id} for vendor {vendor.vendor_code}") + logger.info(f"Created checkout session {session.id} for store {store.store_code}") return session def create_portal_session( diff --git a/app/modules/billing/services/subscription_service.py b/app/modules/billing/services/subscription_service.py index 7b97cbae..fe893dc0 100644 --- a/app/modules/billing/services/subscription_service.py +++ b/app/modules/billing/services/subscription_service.py @@ -1,152 +1,54 @@ # app/modules/billing/services/subscription_service.py """ -Subscription service for tier-based access control. +Subscription service for merchant-level subscription management. Handles: -- Subscription creation and management -- Tier limit enforcement -- Usage tracking -- Feature gating +- MerchantSubscription creation and management +- Tier lookup and resolution +- Store → merchant → subscription resolution + +Limit checks are now handled by feature_service.check_resource_limit(). +Modules own their own limit checks (catalog, orders, tenancy, etc.). Usage: from app.modules.billing.services import subscription_service - # Check if vendor can create an order - can_create, message = subscription_service.can_create_order(db, vendor_id) + # Get merchant subscription + sub = subscription_service.get_merchant_subscription(db, merchant_id, platform_id) - # Increment order counter after successful order - subscription_service.increment_order_count(db, vendor_id) + # Create merchant subscription + sub = subscription_service.create_merchant_subscription(db, merchant_id, platform_id, tier_code) + + # Resolve store to merchant subscription + sub = subscription_service.get_subscription_for_store(db, store_id) """ import logging from datetime import UTC, datetime, timedelta -from typing import Any -from sqlalchemy import func -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload from app.modules.billing.exceptions import ( - FeatureNotAvailableException, SubscriptionNotFoundException, - TierLimitExceededException, + TierLimitExceededException, # Re-exported for backward compatibility ) from app.modules.billing.models import ( + MerchantSubscription, SubscriptionStatus, SubscriptionTier, - TIER_LIMITS, TierCode, - VendorSubscription, ) -from app.modules.billing.schemas import ( - SubscriptionCreate, - SubscriptionUpdate, - SubscriptionUsage, - TierInfo, - TierLimits, - UsageSummary, -) -from app.modules.catalog.models import Product -from app.modules.tenancy.models import Vendor, VendorUser logger = logging.getLogger(__name__) class SubscriptionService: - """Service for subscription and tier limit operations.""" + """Service for merchant-level subscription management.""" # ========================================================================= # Tier Information # ========================================================================= - def get_tier_info(self, tier_code: str, db: Session | None = None) -> TierInfo: - """ - Get full tier information. - - Queries database if db session provided, otherwise falls back to TIER_LIMITS. - """ - # Try database first if session provided - if db is not None: - db_tier = self.get_tier_by_code(db, tier_code) - if db_tier: - return TierInfo( - code=db_tier.code, - name=db_tier.name, - price_monthly_cents=db_tier.price_monthly_cents, - price_annual_cents=db_tier.price_annual_cents, - limits=TierLimits( - orders_per_month=db_tier.orders_per_month, - products_limit=db_tier.products_limit, - team_members=db_tier.team_members, - order_history_months=db_tier.order_history_months, - ), - features=db_tier.features or [], - ) - - # Fallback to hardcoded TIER_LIMITS - return self._get_tier_from_legacy(tier_code) - - def _get_tier_from_legacy(self, tier_code: str) -> TierInfo: - """Get tier info from hardcoded TIER_LIMITS (fallback).""" - try: - tier = TierCode(tier_code) - except ValueError: - tier = TierCode.ESSENTIAL - - limits = TIER_LIMITS[tier] - return TierInfo( - code=tier.value, - name=limits["name"], - price_monthly_cents=limits["price_monthly_cents"], - price_annual_cents=limits.get("price_annual_cents"), - limits=TierLimits( - orders_per_month=limits.get("orders_per_month"), - products_limit=limits.get("products_limit"), - team_members=limits.get("team_members"), - order_history_months=limits.get("order_history_months"), - ), - features=limits.get("features", []), - ) - - def get_all_tiers(self, db: Session | None = None) -> list[TierInfo]: - """ - Get information for all tiers. - - Queries database if db session provided, otherwise falls back to TIER_LIMITS. - """ - if db is not None: - db_tiers = ( - db.query(SubscriptionTier) - .filter( - SubscriptionTier.is_active == True, # noqa: E712 - SubscriptionTier.is_public == True, # noqa: E712 - ) - .order_by(SubscriptionTier.display_order) - .all() - ) - if db_tiers: - return [ - TierInfo( - code=t.code, - name=t.name, - price_monthly_cents=t.price_monthly_cents, - price_annual_cents=t.price_annual_cents, - limits=TierLimits( - orders_per_month=t.orders_per_month, - products_limit=t.products_limit, - team_members=t.team_members, - order_history_months=t.order_history_months, - ), - features=t.features or [], - ) - for t in db_tiers - ] - - # Fallback to hardcoded - return [ - self._get_tier_from_legacy(tier.value) - for tier in TierCode - ] - def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier | None: """Get subscription tier by code.""" return ( @@ -160,73 +62,164 @@ class SubscriptionService: tier = self.get_tier_by_code(db, tier_code) return tier.id if tier else None + def get_all_tiers( + self, db: Session, platform_id: int | None = None + ) -> list[SubscriptionTier]: + """ + Get all active, public tiers. + + If platform_id is provided, returns tiers for that platform + plus global tiers (platform_id=NULL). + """ + query = db.query(SubscriptionTier).filter( + SubscriptionTier.is_active == True, # noqa: E712 + SubscriptionTier.is_public == True, # noqa: E712 + ) + + if platform_id is not None: + query = query.filter( + (SubscriptionTier.platform_id == platform_id) + | (SubscriptionTier.platform_id.is_(None)) + ) + + return query.order_by(SubscriptionTier.display_order).all() + # ========================================================================= - # Subscription CRUD + # Merchant Subscription CRUD # ========================================================================= - def get_subscription( - self, db: Session, vendor_id: int - ) -> VendorSubscription | None: - """Get vendor subscription.""" + def get_merchant_subscription( + self, db: Session, merchant_id: int, platform_id: int + ) -> MerchantSubscription | None: + """Get merchant subscription for a specific platform.""" return ( - db.query(VendorSubscription) - .filter(VendorSubscription.vendor_id == vendor_id) + db.query(MerchantSubscription) + .options( + joinedload(MerchantSubscription.tier) + .joinedload(SubscriptionTier.feature_limits) + ) + .filter( + MerchantSubscription.merchant_id == merchant_id, + MerchantSubscription.platform_id == platform_id, + ) .first() ) + def get_merchant_subscriptions( + self, db: Session, merchant_id: int + ) -> list[MerchantSubscription]: + """Get all subscriptions for a merchant across platforms.""" + return ( + db.query(MerchantSubscription) + .options( + joinedload(MerchantSubscription.tier), + joinedload(MerchantSubscription.platform), + ) + .filter(MerchantSubscription.merchant_id == merchant_id) + .all() + ) + + def get_subscription_for_store( + self, db: Session, store_id: int + ) -> MerchantSubscription | None: + """ + Resolve store → merchant → subscription. + + Convenience method for backwards compatibility with store-level code. + """ + from app.modules.tenancy.models import Store + + store = db.query(Store).filter(Store.id == store_id).first() + if not store: + return None + + merchant_id = store.merchant_id + if merchant_id is None: + return None + + # Get platform_id from store + platform_id = getattr(store, "platform_id", None) + if platform_id is None: + from app.modules.tenancy.models import StorePlatform + sp = ( + db.query(StorePlatform.platform_id) + .filter(StorePlatform.store_id == store_id) + .first() + ) + platform_id = sp[0] if sp else None + + if platform_id is None: + return None + + return self.get_merchant_subscription(db, merchant_id, platform_id) + def get_subscription_or_raise( - self, db: Session, vendor_id: int - ) -> VendorSubscription: - """Get vendor subscription or raise exception.""" - subscription = self.get_subscription(db, vendor_id) + self, db: Session, merchant_id: int, platform_id: int + ) -> MerchantSubscription: + """Get merchant subscription or raise exception.""" + subscription = self.get_merchant_subscription(db, merchant_id, platform_id) if not subscription: - raise SubscriptionNotFoundException(vendor_id) + raise SubscriptionNotFoundException(merchant_id) return subscription - def get_current_tier( - self, db: Session, vendor_id: int - ) -> TierCode | None: - """Get vendor's current subscription tier code.""" - subscription = self.get_subscription(db, vendor_id) - if subscription: - try: - return TierCode(subscription.tier) - except ValueError: - return None - return None - - def get_or_create_subscription( + def create_merchant_subscription( self, db: Session, - vendor_id: int, - tier: str = TierCode.ESSENTIAL.value, + merchant_id: int, + platform_id: int, + tier_code: str = TierCode.ESSENTIAL.value, trial_days: int = 14, - ) -> VendorSubscription: + is_annual: bool = False, + ) -> MerchantSubscription: """ - Get existing subscription or create a new trial subscription. + Create a new merchant subscription for a platform. - Used when a vendor first accesses the system. + Args: + db: Database session + merchant_id: Merchant ID (the billing entity) + platform_id: Platform ID + tier_code: Tier code (default: essential) + trial_days: Trial period in days (0 = no trial) + is_annual: Annual billing cycle + + Returns: + New MerchantSubscription """ - subscription = self.get_subscription(db, vendor_id) - if subscription: - return subscription + # Check for existing + existing = self.get_merchant_subscription(db, merchant_id, platform_id) + if existing: + raise ValueError( + f"Merchant {merchant_id} already has a subscription " + f"on platform {platform_id}" + ) - # Create new trial subscription now = datetime.now(UTC) - trial_end = now + timedelta(days=trial_days) - # Lookup tier_id from tier code - tier_id = self.get_tier_id(db, tier) + # Calculate period + if trial_days > 0: + period_end = now + timedelta(days=trial_days) + trial_ends_at = period_end + status = SubscriptionStatus.TRIAL.value + elif is_annual: + period_end = now + timedelta(days=365) + trial_ends_at = None + status = SubscriptionStatus.ACTIVE.value + else: + period_end = now + timedelta(days=30) + trial_ends_at = None + status = SubscriptionStatus.ACTIVE.value - subscription = VendorSubscription( - vendor_id=vendor_id, - tier=tier, + tier_id = self.get_tier_id(db, tier_code) + + subscription = MerchantSubscription( + merchant_id=merchant_id, + platform_id=platform_id, tier_id=tier_id, - status=SubscriptionStatus.TRIAL.value, + status=status, + is_annual=is_annual, period_start=now, - period_end=trial_end, - trial_ends_at=trial_end, - is_annual=False, + period_end=period_end, + trial_ends_at=trial_ends_at, ) db.add(subscription) @@ -234,99 +227,44 @@ class SubscriptionService: db.refresh(subscription) logger.info( - f"Created trial subscription for vendor {vendor_id} " - f"(tier={tier}, trial_ends={trial_end})" + f"Created subscription for merchant {merchant_id} on platform {platform_id} " + f"(tier={tier_code}, status={status})" ) return subscription - def create_subscription( + def get_or_create_subscription( self, db: Session, - vendor_id: int, - data: SubscriptionCreate, - ) -> VendorSubscription: - """Create a subscription for a vendor.""" - # Check if subscription exists - existing = self.get_subscription(db, vendor_id) - if existing: - raise ValueError("Vendor already has a subscription") - - now = datetime.now(UTC) - - # Calculate period end based on billing cycle - if data.is_annual: - period_end = now + timedelta(days=365) - else: - period_end = now + timedelta(days=30) - - # Handle trial - trial_ends_at = None - status = SubscriptionStatus.ACTIVE.value - if data.trial_days > 0: - trial_ends_at = now + timedelta(days=data.trial_days) - status = SubscriptionStatus.TRIAL.value - period_end = trial_ends_at - - # Lookup tier_id from tier code - tier_id = self.get_tier_id(db, data.tier) - - subscription = VendorSubscription( - vendor_id=vendor_id, - tier=data.tier, - tier_id=tier_id, - status=status, - period_start=now, - period_end=period_end, - trial_ends_at=trial_ends_at, - is_annual=data.is_annual, + merchant_id: int, + platform_id: int, + tier_code: str = TierCode.ESSENTIAL.value, + trial_days: int = 14, + ) -> MerchantSubscription: + """Get existing subscription or create a new trial subscription.""" + subscription = self.get_merchant_subscription(db, merchant_id, platform_id) + if subscription: + return subscription + return self.create_merchant_subscription( + db, merchant_id, platform_id, tier_code, trial_days ) - db.add(subscription) - db.flush() - db.refresh(subscription) - - logger.info(f"Created subscription for vendor {vendor_id}: {data.tier}") - return subscription - - def update_subscription( - self, - db: Session, - vendor_id: int, - data: SubscriptionUpdate, - ) -> VendorSubscription: - """Update a vendor subscription.""" - subscription = self.get_subscription_or_raise(db, vendor_id) - - update_data = data.model_dump(exclude_unset=True) - - # If tier is being updated, also update tier_id - if "tier" in update_data: - tier_id = self.get_tier_id(db, update_data["tier"]) - update_data["tier_id"] = tier_id - - for key, value in update_data.items(): - setattr(subscription, key, value) - - subscription.updated_at = datetime.now(UTC) - db.flush() - db.refresh(subscription) - - logger.info(f"Updated subscription for vendor {vendor_id}") - return subscription - def upgrade_tier( self, db: Session, - vendor_id: int, - new_tier: str, - ) -> VendorSubscription: - """Upgrade vendor to a new tier.""" - subscription = self.get_subscription_or_raise(db, vendor_id) + merchant_id: int, + platform_id: int, + new_tier_code: str, + ) -> MerchantSubscription: + """Upgrade merchant to a new tier.""" + subscription = self.get_subscription_or_raise(db, merchant_id, platform_id) - old_tier = subscription.tier - subscription.tier = new_tier - subscription.tier_id = self.get_tier_id(db, new_tier) + old_tier_id = subscription.tier_id + new_tier = self.get_tier_by_code(db, new_tier_code) + if not new_tier: + raise ValueError(f"Tier '{new_tier_code}' not found") + + subscription.tier_id = new_tier.id subscription.updated_at = datetime.now(UTC) # If upgrading from trial, mark as active @@ -336,17 +274,21 @@ class SubscriptionService: db.flush() db.refresh(subscription) - logger.info(f"Upgraded vendor {vendor_id} from {old_tier} to {new_tier}") + logger.info( + f"Upgraded merchant {merchant_id} on platform {platform_id} " + f"from tier_id={old_tier_id} to tier_id={new_tier.id} ({new_tier_code})" + ) return subscription def cancel_subscription( self, db: Session, - vendor_id: int, + merchant_id: int, + platform_id: int, reason: str | None = None, - ) -> VendorSubscription: - """Cancel a vendor subscription (access until period end).""" - subscription = self.get_subscription_or_raise(db, vendor_id) + ) -> MerchantSubscription: + """Cancel a merchant subscription (access continues until period end).""" + subscription = self.get_subscription_or_raise(db, merchant_id, platform_id) subscription.status = SubscriptionStatus.CANCELLED.value subscription.cancelled_at = datetime.now(UTC) @@ -356,275 +298,34 @@ class SubscriptionService: db.flush() db.refresh(subscription) - logger.info(f"Cancelled subscription for vendor {vendor_id}") + logger.info( + f"Cancelled subscription for merchant {merchant_id} " + f"on platform {platform_id}" + ) return subscription - # ========================================================================= - # Usage Tracking - # ========================================================================= + def reactivate_subscription( + self, + db: Session, + merchant_id: int, + platform_id: int, + ) -> MerchantSubscription: + """Reactivate a cancelled subscription.""" + subscription = self.get_subscription_or_raise(db, merchant_id, platform_id) - def get_usage(self, db: Session, vendor_id: int) -> SubscriptionUsage: - """Get current subscription usage statistics.""" - subscription = self.get_or_create_subscription(db, vendor_id) + subscription.status = SubscriptionStatus.ACTIVE.value + subscription.cancelled_at = None + subscription.cancellation_reason = None + subscription.updated_at = datetime.now(UTC) - # Get actual counts - products_count = ( - db.query(func.count(Product.id)) - .filter(Product.vendor_id == vendor_id) - .scalar() - or 0 - ) - - team_count = ( - db.query(func.count(VendorUser.id)) - .filter(VendorUser.vendor_id == vendor_id, VendorUser.is_active == True) - .scalar() - or 0 - ) - - # Calculate usage stats - orders_limit = subscription.orders_limit - products_limit = subscription.products_limit - team_limit = subscription.team_members_limit - - def calc_remaining(current: int, limit: int | None) -> int | None: - if limit is None: - return None - return max(0, limit - current) - - def calc_percent(current: int, limit: int | None) -> float | None: - if limit is None or limit == 0: - return None - return min(100.0, (current / limit) * 100) - - return SubscriptionUsage( - orders_used=subscription.orders_this_period, - orders_limit=orders_limit, - orders_remaining=calc_remaining(subscription.orders_this_period, orders_limit), - orders_percent_used=calc_percent(subscription.orders_this_period, orders_limit), - products_used=products_count, - products_limit=products_limit, - products_remaining=calc_remaining(products_count, products_limit), - products_percent_used=calc_percent(products_count, products_limit), - team_members_used=team_count, - team_members_limit=team_limit, - team_members_remaining=calc_remaining(team_count, team_limit), - team_members_percent_used=calc_percent(team_count, team_limit), - ) - - def get_usage_summary(self, db: Session, vendor_id: int) -> UsageSummary: - """Get usage summary for billing page display.""" - subscription = self.get_or_create_subscription(db, vendor_id) - - # Get actual counts - products_count = ( - db.query(func.count(Product.id)) - .filter(Product.vendor_id == vendor_id) - .scalar() - or 0 - ) - - team_count = ( - db.query(func.count(VendorUser.id)) - .filter(VendorUser.vendor_id == vendor_id, VendorUser.is_active == True) - .scalar() - or 0 - ) - - # Get limits - orders_limit = subscription.orders_limit - products_limit = subscription.products_limit - team_limit = subscription.team_members_limit - - def calc_remaining(current: int, limit: int | None) -> int | None: - if limit is None: - return None - return max(0, limit - current) - - return UsageSummary( - orders_this_period=subscription.orders_this_period, - orders_limit=orders_limit, - orders_remaining=calc_remaining(subscription.orders_this_period, orders_limit), - products_count=products_count, - products_limit=products_limit, - products_remaining=calc_remaining(products_count, products_limit), - team_count=team_count, - team_limit=team_limit, - team_remaining=calc_remaining(team_count, team_limit), - ) - - def increment_order_count(self, db: Session, vendor_id: int) -> None: - """ - Increment the order counter for the current period. - - Call this after successfully creating/importing an order. - """ - subscription = self.get_or_create_subscription(db, vendor_id) - subscription.increment_order_count() db.flush() + db.refresh(subscription) - def reset_period_counters(self, db: Session, vendor_id: int) -> None: - """Reset counters for a new billing period.""" - subscription = self.get_subscription_or_raise(db, vendor_id) - subscription.reset_period_counters() - db.flush() - logger.info(f"Reset period counters for vendor {vendor_id}") - - # ========================================================================= - # Limit Checks - # ========================================================================= - - def can_create_order( - self, db: Session, vendor_id: int - ) -> tuple[bool, str | None]: - """ - Check if vendor can create/import another order. - - Returns: (allowed, error_message) - """ - subscription = self.get_or_create_subscription(db, vendor_id) - return subscription.can_create_order() - - def check_order_limit(self, db: Session, vendor_id: int) -> None: - """ - Check order limit and raise exception if exceeded. - - Use this in order creation flows. - """ - can_create, message = self.can_create_order(db, vendor_id) - if not can_create: - subscription = self.get_subscription(db, vendor_id) - raise TierLimitExceededException( - message=message or "Order limit exceeded", - limit_type="orders", - current=subscription.orders_this_period if subscription else 0, - limit=subscription.orders_limit if subscription else 0, - ) - - def can_add_product( - self, db: Session, vendor_id: int - ) -> tuple[bool, str | None]: - """ - Check if vendor can add another product. - - Returns: (allowed, error_message) - """ - subscription = self.get_or_create_subscription(db, vendor_id) - - products_count = ( - db.query(func.count(Product.id)) - .filter(Product.vendor_id == vendor_id) - .scalar() - or 0 + logger.info( + f"Reactivated subscription for merchant {merchant_id} " + f"on platform {platform_id}" ) - - return subscription.can_add_product(products_count) - - def check_product_limit(self, db: Session, vendor_id: int) -> None: - """ - Check product limit and raise exception if exceeded. - - Use this in product creation flows. - """ - can_add, message = self.can_add_product(db, vendor_id) - if not can_add: - subscription = self.get_subscription(db, vendor_id) - products_count = ( - db.query(func.count(Product.id)) - .filter(Product.vendor_id == vendor_id) - .scalar() - or 0 - ) - raise TierLimitExceededException( - message=message or "Product limit exceeded", - limit_type="products", - current=products_count, - limit=subscription.products_limit if subscription else 0, - ) - - def can_add_team_member( - self, db: Session, vendor_id: int - ) -> tuple[bool, str | None]: - """ - Check if vendor can add another team member. - - Returns: (allowed, error_message) - """ - subscription = self.get_or_create_subscription(db, vendor_id) - - team_count = ( - db.query(func.count(VendorUser.id)) - .filter(VendorUser.vendor_id == vendor_id, VendorUser.is_active == True) - .scalar() - or 0 - ) - - return subscription.can_add_team_member(team_count) - - def check_team_limit(self, db: Session, vendor_id: int) -> None: - """ - Check team member limit and raise exception if exceeded. - - Use this in team member invitation flows. - """ - can_add, message = self.can_add_team_member(db, vendor_id) - if not can_add: - subscription = self.get_subscription(db, vendor_id) - team_count = ( - db.query(func.count(VendorUser.id)) - .filter(VendorUser.vendor_id == vendor_id, VendorUser.is_active == True) - .scalar() - or 0 - ) - raise TierLimitExceededException( - message=message or "Team member limit exceeded", - limit_type="team_members", - current=team_count, - limit=subscription.team_members_limit if subscription else 0, - ) - - # ========================================================================= - # Feature Gating - # ========================================================================= - - def has_feature(self, db: Session, vendor_id: int, feature: str) -> bool: - """Check if vendor has access to a feature.""" - subscription = self.get_or_create_subscription(db, vendor_id) - return subscription.has_feature(feature) - - def check_feature(self, db: Session, vendor_id: int, feature: str) -> None: - """ - Check feature access and raise exception if not available. - - Use this to gate premium features. - """ - if not self.has_feature(db, vendor_id, feature): - subscription = self.get_or_create_subscription(db, vendor_id) - - # Find which tier has this feature - required_tier = None - for tier_code, limits in TIER_LIMITS.items(): - if feature in limits.get("features", []): - required_tier = limits["name"] - break - - raise FeatureNotAvailableException( - feature=feature, - current_tier=subscription.tier, - required_tier=required_tier or "higher", - ) - - def get_feature_tier(self, feature: str) -> str | None: - """Get the minimum tier required for a feature.""" - for tier_code in [ - TierCode.ESSENTIAL, - TierCode.PROFESSIONAL, - TierCode.BUSINESS, - TierCode.ENTERPRISE, - ]: - if feature in TIER_LIMITS[tier_code].get("features", []): - return tier_code.value - return None + return subscription # Singleton instance diff --git a/app/modules/billing/static/admin/js/billing-history.js b/app/modules/billing/static/admin/js/billing-history.js index 2043703a..3df890c6 100644 --- a/app/modules/billing/static/admin/js/billing-history.js +++ b/app/modules/billing/static/admin/js/billing-history.js @@ -20,7 +20,7 @@ function adminBillingHistory() { // Data invoices: [], - vendors: [], + stores: [], statusCounts: { paid: 0, open: 0, @@ -31,7 +31,7 @@ function adminBillingHistory() { // Filters filters: { - vendor_id: '', + store_id: '', status: '' }, @@ -107,7 +107,7 @@ function adminBillingHistory() { this.pagination.per_page = await window.PlatformSettings.getRowsPerPage(); } - await this.loadVendors(); + await this.loadStores(); await this.loadInvoices(); }, @@ -117,13 +117,13 @@ function adminBillingHistory() { await this.loadInvoices(); }, - async loadVendors() { + async loadStores() { try { - const data = await apiClient.get('/admin/vendors?limit=1000'); - this.vendors = data.vendors || []; - billingLog.info(`Loaded ${this.vendors.length} vendors for filter`); + const data = await apiClient.get('/admin/stores?limit=1000'); + this.stores = data.stores || []; + billingLog.info(`Loaded ${this.stores.length} stores for filter`); } catch (error) { - billingLog.error('Failed to load vendors:', error); + billingLog.error('Failed to load stores:', error); } }, @@ -135,7 +135,7 @@ function adminBillingHistory() { const params = new URLSearchParams(); params.append('page', this.pagination.page); params.append('per_page', this.pagination.per_page); - if (this.filters.vendor_id) params.append('vendor_id', this.filters.vendor_id); + if (this.filters.store_id) params.append('store_id', this.filters.store_id); if (this.filters.status) params.append('status', this.filters.status); if (this.sortBy) params.append('sort_by', this.sortBy); if (this.sortOrder) params.append('sort_order', this.sortOrder); @@ -188,7 +188,7 @@ function adminBillingHistory() { resetFilters() { this.filters = { - vendor_id: '', + store_id: '', status: '' }; this.pagination.page = 1; diff --git a/app/modules/billing/static/shared/js/feature-store.js b/app/modules/billing/static/shared/js/feature-store.js index 5d26106b..e92f0fcc 100644 --- a/app/modules/billing/static/shared/js/feature-store.js +++ b/app/modules/billing/static/shared/js/feature-store.js @@ -40,7 +40,7 @@ */ const featureStore = { // State - features: [], // Array of feature codes available to vendor + features: [], // Array of feature codes available to store featuresMap: {}, // Full feature info keyed by code tierCode: null, // Current tier code tierName: null, // Current tier name @@ -75,10 +75,10 @@ return; } - // Get vendor code from URL - const vendorCode = this.getVendorCode(); - if (!vendorCode) { - log.warn('[FeatureStore] No vendor code found in URL'); + // Get store code from URL + const storeCode = this.getStoreCode(); + if (!storeCode) { + log.warn('[FeatureStore] No store code found in URL'); this.loading = false; return; } @@ -88,7 +88,7 @@ this.error = null; // Fetch available features (lightweight endpoint) - const response = await apiClient.get('/vendor/features/available'); + const response = await apiClient.get('/store/features/available'); this.features = response.features || []; this.tierCode = response.tier_code; @@ -112,11 +112,11 @@ * Use this when you need upgrade info */ async loadFullFeatures() { - const vendorCode = this.getVendorCode(); - if (!vendorCode) return; + const storeCode = this.getStoreCode(); + if (!storeCode) return; try { - const response = await apiClient.get('/vendor/features'); + const response = await apiClient.get('/store/features'); // Build map for quick lookup this.featuresMap = {}; @@ -132,7 +132,7 @@ }, /** - * Check if vendor has access to a feature + * Check if store has access to a feature * @param {string} featureCode - The feature code to check * @returns {boolean} - Whether the feature is available */ @@ -141,7 +141,7 @@ }, /** - * Check if vendor has access to ANY of the given features + * Check if store has access to ANY of the given features * @param {...string} featureCodes - Feature codes to check * @returns {boolean} - Whether any feature is available */ @@ -150,7 +150,7 @@ }, /** - * Check if vendor has access to ALL of the given features + * Check if store has access to ALL of the given features * @param {...string} featureCodes - Feature codes to check * @returns {boolean} - Whether all features are available */ @@ -178,13 +178,13 @@ }, /** - * Get vendor code from URL + * Get store code from URL * @returns {string|null} */ - getVendorCode() { + getStoreCode() { const path = window.location.pathname; const segments = path.split('/').filter(Boolean); - if (segments[0] === 'vendor' && segments[1]) { + if (segments[0] === 'store' && segments[1]) { return segments[1]; } return null; diff --git a/app/modules/billing/static/shared/js/upgrade-prompts.js b/app/modules/billing/static/shared/js/upgrade-prompts.js index eeff3507..be8284e6 100644 --- a/app/modules/billing/static/shared/js/upgrade-prompts.js +++ b/app/modules/billing/static/shared/js/upgrade-prompts.js @@ -77,7 +77,7 @@ this.loading = true; this.error = null; - const response = await apiClient.get('/vendor/usage'); + const response = await apiClient.get('/store/usage'); this.usage = response; this.loaded = true; @@ -134,12 +134,12 @@ }, /** - * Get vendor code from URL + * Get store code from URL */ - getVendorCode() { + getStoreCode() { const path = window.location.pathname; const segments = path.split('/').filter(Boolean); - if (segments[0] === 'vendor' && segments[1]) { + if (segments[0] === 'store' && segments[1]) { return segments[1]; } return null; @@ -149,8 +149,8 @@ * Get billing URL */ getBillingUrl() { - const vendorCode = this.getVendorCode(); - return vendorCode ? `/vendor/${vendorCode}/billing` : '#'; + const storeCode = this.getStoreCode(); + return storeCode ? `/store/${storeCode}/billing` : '#'; }, /** @@ -158,7 +158,7 @@ */ async checkLimitAndProceed(limitType, onSuccess) { try { - const response = await apiClient.get(`/vendor/usage/check/${limitType}`); + const response = await apiClient.get(`/store/usage/check/${limitType}`); if (response.can_proceed) { if (typeof onSuccess === 'function') { diff --git a/app/modules/billing/static/vendor/js/.gitkeep b/app/modules/billing/static/store/js/.gitkeep similarity index 100% rename from app/modules/billing/static/vendor/js/.gitkeep rename to app/modules/billing/static/store/js/.gitkeep diff --git a/app/modules/billing/static/vendor/js/invoices.js b/app/modules/billing/static/store/js/invoices.js similarity index 79% rename from app/modules/billing/static/vendor/js/invoices.js rename to app/modules/billing/static/store/js/invoices.js index 6dd16674..a49f6562 100644 --- a/app/modules/billing/static/vendor/js/invoices.js +++ b/app/modules/billing/static/store/js/invoices.js @@ -1,14 +1,14 @@ -// app/modules/billing/static/vendor/js/invoices.js +// app/modules/billing/static/store/js/invoices.js /** - * Vendor invoice management page logic + * Store invoice management page logic */ const invoicesLog = window.LogConfig?.createLogger('INVOICES') || console; -invoicesLog.info('[VENDOR INVOICES] Loading...'); +invoicesLog.info('[STORE INVOICES] Loading...'); -function vendorInvoices() { - invoicesLog.info('[VENDOR INVOICES] vendorInvoices() called'); +function storeInvoices() { + invoicesLog.info('[STORE INVOICES] storeInvoices() called'); return { // Inherit base layout state @@ -34,11 +34,11 @@ function vendorInvoices() { hasSettings: false, settings: null, settingsForm: { - company_name: '', - company_address: '', - company_city: '', - company_postal_code: '', - company_country: 'LU', + merchant_name: '', + merchant_address: '', + merchant_city: '', + merchant_postal_code: '', + merchant_country: 'LU', vat_number: '', invoice_prefix: 'INV', default_vat_rate: '17.00', @@ -77,12 +77,12 @@ function vendorInvoices() { async init() { // Guard against multiple initialization - if (window._vendorInvoicesInitialized) { + if (window._storeInvoicesInitialized) { return; } - window._vendorInvoicesInitialized = true; + window._storeInvoicesInitialized = true; - // Call parent init first to set vendorCode from URL + // Call parent init first to set storeCode from URL const parentInit = data().init; if (parentInit) { await parentInit.call(this); @@ -98,17 +98,17 @@ function vendorInvoices() { */ async loadSettings() { try { - const response = await apiClient.get('/vendor/invoices/settings'); + const response = await apiClient.get('/store/invoices/settings'); if (response) { this.settings = response; this.hasSettings = true; // Populate form with existing settings this.settingsForm = { - company_name: response.company_name || '', - company_address: response.company_address || '', - company_city: response.company_city || '', - company_postal_code: response.company_postal_code || '', - company_country: response.company_country || 'LU', + merchant_name: response.merchant_name || '', + merchant_address: response.merchant_address || '', + merchant_city: response.merchant_city || '', + merchant_postal_code: response.merchant_postal_code || '', + merchant_country: response.merchant_country || 'LU', vat_number: response.vat_number || '', invoice_prefix: response.invoice_prefix || 'INV', default_vat_rate: response.default_vat_rate?.toString() || '17.00', @@ -124,7 +124,7 @@ function vendorInvoices() { } catch (error) { // 404 means not configured yet, which is fine if (error.status !== 404) { - invoicesLog.error('[VENDOR INVOICES] Failed to load settings:', error); + invoicesLog.error('[STORE INVOICES] Failed to load settings:', error); } this.hasSettings = false; } @@ -135,7 +135,7 @@ function vendorInvoices() { */ async loadStats() { try { - const response = await apiClient.get('/vendor/invoices/stats'); + const response = await apiClient.get('/store/invoices/stats'); this.stats = { total_invoices: response.total_invoices || 0, total_revenue_cents: response.total_revenue_cents || 0, @@ -145,7 +145,7 @@ function vendorInvoices() { cancelled_count: response.cancelled_count || 0 }; } catch (error) { - invoicesLog.error('[VENDOR INVOICES] Failed to load stats:', error); + invoicesLog.error('[STORE INVOICES] Failed to load stats:', error); } }, @@ -166,11 +166,11 @@ function vendorInvoices() { params.append('status', this.filters.status); } - const response = await apiClient.get(`/vendor/invoices?${params}`); + const response = await apiClient.get(`/store/invoices?${params}`); this.invoices = response.items || []; this.totalInvoices = response.total || 0; } catch (error) { - invoicesLog.error('[VENDOR INVOICES] Failed to load invoices:', error); + invoicesLog.error('[STORE INVOICES] Failed to load invoices:', error); this.error = error.message || 'Failed to load invoices'; } finally { this.loading = false; @@ -192,8 +192,8 @@ function vendorInvoices() { * Save invoice settings */ async saveSettings() { - if (!this.settingsForm.company_name) { - this.error = 'Company name is required'; + if (!this.settingsForm.merchant_name) { + this.error = 'Merchant name is required'; return; } @@ -202,11 +202,11 @@ function vendorInvoices() { try { const payload = { - company_name: this.settingsForm.company_name, - company_address: this.settingsForm.company_address || null, - company_city: this.settingsForm.company_city || null, - company_postal_code: this.settingsForm.company_postal_code || null, - company_country: this.settingsForm.company_country || 'LU', + merchant_name: this.settingsForm.merchant_name, + merchant_address: this.settingsForm.merchant_address || null, + merchant_city: this.settingsForm.merchant_city || null, + merchant_postal_code: this.settingsForm.merchant_postal_code || null, + merchant_country: this.settingsForm.merchant_country || 'LU', vat_number: this.settingsForm.vat_number || null, invoice_prefix: this.settingsForm.invoice_prefix || 'INV', default_vat_rate: parseFloat(this.settingsForm.default_vat_rate) || 17.0, @@ -220,17 +220,17 @@ function vendorInvoices() { let response; if (this.hasSettings) { // Update existing settings - response = await apiClient.put('/vendor/invoices/settings', payload); + response = await apiClient.put('/store/invoices/settings', payload); } else { // Create new settings - response = await apiClient.post('/vendor/invoices/settings', payload); + response = await apiClient.post('/store/invoices/settings', payload); } this.settings = response; this.hasSettings = true; this.successMessage = 'Settings saved successfully'; } catch (error) { - invoicesLog.error('[VENDOR INVOICES] Failed to save settings:', error); + invoicesLog.error('[STORE INVOICES] Failed to save settings:', error); this.error = error.message || 'Failed to save settings'; } finally { this.savingSettings = false; @@ -272,14 +272,14 @@ function vendorInvoices() { notes: this.createForm.notes || null }; - const response = await apiClient.post('/vendor/invoices', payload); + const response = await apiClient.post('/store/invoices', payload); this.showCreateModal = false; this.successMessage = `Invoice ${response.invoice_number} created successfully`; await this.loadStats(); await this.loadInvoices(); } catch (error) { - invoicesLog.error('[VENDOR INVOICES] Failed to create invoice:', error); + invoicesLog.error('[STORE INVOICES] Failed to create invoice:', error); this.error = error.message || 'Failed to create invoice'; } finally { this.creatingInvoice = false; @@ -302,7 +302,7 @@ function vendorInvoices() { } try { - await apiClient.put(`/vendor/invoices/${invoice.id}/status`, { + await apiClient.put(`/store/invoices/${invoice.id}/status`, { status: newStatus }); @@ -310,7 +310,7 @@ function vendorInvoices() { await this.loadStats(); await this.loadInvoices(); } catch (error) { - invoicesLog.error('[VENDOR INVOICES] Failed to update status:', error); + invoicesLog.error('[STORE INVOICES] Failed to update status:', error); this.error = error.message || 'Failed to update invoice status'; } setTimeout(() => this.successMessage = '', 5000); @@ -324,13 +324,13 @@ function vendorInvoices() { try { // Get the token for authentication - const token = localStorage.getItem('wizamart_token') || localStorage.getItem('vendor_token'); + const token = localStorage.getItem('wizamart_token') || localStorage.getItem('store_token'); if (!token) { throw new Error('Not authenticated'); } // noqa: js-008 - File download needs response headers for filename - const response = await fetch(`/api/v1/vendor/invoices/${invoice.id}/pdf`, { + const response = await fetch(`/api/v1/store/invoices/${invoice.id}/pdf`, { method: 'GET', headers: { 'Authorization': `Bearer ${token}` @@ -365,7 +365,7 @@ function vendorInvoices() { this.successMessage = `Downloaded: ${filename}`; } catch (error) { - invoicesLog.error('[VENDOR INVOICES] Failed to download PDF:', error); + invoicesLog.error('[STORE INVOICES] Failed to download PDF:', error); this.error = error.message || 'Failed to download PDF'; } finally { this.downloadingPdf = false; @@ -379,7 +379,7 @@ function vendorInvoices() { formatDate(dateStr) { if (!dateStr) return 'N/A'; const date = new Date(dateStr); - const locale = window.VENDOR_CONFIG?.locale || 'en-GB'; + const locale = window.STORE_CONFIG?.locale || 'en-GB'; return date.toLocaleDateString(locale, { day: '2-digit', month: 'short', @@ -393,8 +393,8 @@ function vendorInvoices() { formatCurrency(cents, currency = 'EUR') { if (cents === null || cents === undefined) return 'N/A'; const amount = cents / 100; - const locale = window.VENDOR_CONFIG?.locale || 'en-GB'; - const currencyCode = window.VENDOR_CONFIG?.currency || currency; + const locale = window.STORE_CONFIG?.locale || 'en-GB'; + const currencyCode = window.STORE_CONFIG?.currency || currency; return new Intl.NumberFormat(locale, { style: 'currency', currency: currencyCode diff --git a/app/modules/billing/static/vendor/js/billing.js b/app/modules/billing/static/vendor/js/billing.js deleted file mode 100644 index c3b53698..00000000 --- a/app/modules/billing/static/vendor/js/billing.js +++ /dev/null @@ -1,214 +0,0 @@ -// app/modules/billing/static/vendor/js/billing.js -// Vendor billing and subscription management - -const billingLog = window.LogConfig?.createLogger('BILLING') || console; - -function vendorBilling() { - return { - // Inherit base data (dark mode, sidebar, vendor info, etc.) - ...data(), - currentPage: 'billing', - - // State - loading: true, - subscription: null, - tiers: [], - addons: [], - myAddons: [], - invoices: [], - - // UI state - showTiersModal: false, - showAddonsModal: false, - showCancelModal: false, - showSuccessMessage: false, - showCancelMessage: false, - showAddonSuccessMessage: false, - cancelReason: '', - purchasingAddon: null, - - // Initialize - async init() { - // Load i18n translations - await I18n.loadModule('billing'); - - // Guard against multiple initialization - if (window._vendorBillingInitialized) return; - window._vendorBillingInitialized = true; - - // IMPORTANT: Call parent init first to set vendorCode from URL - const parentInit = data().init; - if (parentInit) { - await parentInit.call(this); - } - - try { - // Check URL params for success/cancel - const params = new URLSearchParams(window.location.search); - if (params.get('success') === 'true') { - this.showSuccessMessage = true; - window.history.replaceState({}, document.title, window.location.pathname); - } - if (params.get('cancelled') === 'true') { - this.showCancelMessage = true; - window.history.replaceState({}, document.title, window.location.pathname); - } - if (params.get('addon_success') === 'true') { - this.showAddonSuccessMessage = true; - window.history.replaceState({}, document.title, window.location.pathname); - } - - await this.loadData(); - } catch (error) { - billingLog.error('Failed to initialize billing page:', error); - } - }, - - async loadData() { - this.loading = true; - try { - // Load all data in parallel - const [subscriptionRes, tiersRes, addonsRes, myAddonsRes, invoicesRes] = await Promise.all([ - apiClient.get('/vendor/billing/subscription'), - apiClient.get('/vendor/billing/tiers'), - apiClient.get('/vendor/billing/addons'), - apiClient.get('/vendor/billing/my-addons'), - apiClient.get('/vendor/billing/invoices?limit=5'), - ]); - - this.subscription = subscriptionRes; - this.tiers = tiersRes.tiers || []; - this.addons = addonsRes || []; - this.myAddons = myAddonsRes || []; - this.invoices = invoicesRes.invoices || []; - - } catch (error) { - billingLog.error('Error loading billing data:', error); - Utils.showToast(I18n.t('billing.messages.failed_to_load_billing_data'), 'error'); - } finally { - this.loading = false; - } - }, - - async selectTier(tier) { - if (tier.is_current) return; - - try { - const response = await apiClient.post('/vendor/billing/checkout', { - tier_code: tier.code, - is_annual: false - }); - - if (response.checkout_url) { - window.location.href = response.checkout_url; - } - } catch (error) { - billingLog.error('Error creating checkout:', error); - Utils.showToast(I18n.t('billing.messages.failed_to_create_checkout_session'), 'error'); - } - }, - - async openPortal() { - try { - const response = await apiClient.post('/vendor/billing/portal', {}); - if (response.portal_url) { - window.location.href = response.portal_url; - } - } catch (error) { - billingLog.error('Error opening portal:', error); - Utils.showToast(I18n.t('billing.messages.failed_to_open_payment_portal'), 'error'); - } - }, - - async cancelSubscription() { - try { - await apiClient.post('/vendor/billing/cancel', { - reason: this.cancelReason, - immediately: false - }); - - this.showCancelModal = false; - Utils.showToast(I18n.t('billing.messages.subscription_cancelled_you_have_access_u'), 'success'); - await this.loadData(); - - } catch (error) { - billingLog.error('Error cancelling subscription:', error); - Utils.showToast(I18n.t('billing.messages.failed_to_cancel_subscription'), 'error'); - } - }, - - async reactivate() { - try { - await apiClient.post('/vendor/billing/reactivate', {}); - Utils.showToast(I18n.t('billing.messages.subscription_reactivated'), 'success'); - await this.loadData(); - - } catch (error) { - billingLog.error('Error reactivating subscription:', error); - Utils.showToast(I18n.t('billing.messages.failed_to_reactivate_subscription'), 'error'); - } - }, - - async purchaseAddon(addon) { - this.purchasingAddon = addon.code; - try { - const response = await apiClient.post('/vendor/billing/addons/purchase', { - addon_code: addon.code, - quantity: 1 - }); - - if (response.checkout_url) { - window.location.href = response.checkout_url; - } - } catch (error) { - billingLog.error('Error purchasing addon:', error); - Utils.showToast(I18n.t('billing.messages.failed_to_purchase_addon'), 'error'); - } finally { - this.purchasingAddon = null; - } - }, - - async cancelAddon(addon) { - if (!confirm(`Are you sure you want to cancel ${addon.addon_name}?`)) { - return; - } - - try { - await apiClient.delete(`/vendor/billing/addons/${addon.id}`); - Utils.showToast(I18n.t('billing.messages.addon_cancelled_successfully'), 'success'); - await this.loadData(); - } catch (error) { - billingLog.error('Error cancelling addon:', error); - Utils.showToast(I18n.t('billing.messages.failed_to_cancel_addon'), 'error'); - } - }, - - // Check if addon is already purchased - isAddonPurchased(addonCode) { - return this.myAddons.some(a => a.addon_code === addonCode && a.status === 'active'); - }, - - // Formatters - formatDate(dateString) { - if (!dateString) return '-'; - const date = new Date(dateString); - const locale = window.VENDOR_CONFIG?.locale || 'en-GB'; - return date.toLocaleDateString(locale, { - year: 'numeric', - month: 'short', - day: 'numeric' - }); - }, - - formatCurrency(cents, currency = 'EUR') { - if (cents === null || cents === undefined) return '-'; - const amount = cents / 100; - const locale = window.VENDOR_CONFIG?.locale || 'en-GB'; - const currencyCode = window.VENDOR_CONFIG?.currency || currency; - return new Intl.NumberFormat(locale, { - style: 'currency', - currency: currencyCode - }).format(amount); - } - }; -} diff --git a/app/modules/billing/tasks/subscription.py b/app/modules/billing/tasks/subscription.py index 878c2d00..338035ac 100644 --- a/app/modules/billing/tasks/subscription.py +++ b/app/modules/billing/tasks/subscription.py @@ -13,7 +13,7 @@ import logging from datetime import UTC, datetime, timedelta from app.core.celery_config import celery_app -from app.modules.billing.models import SubscriptionStatus, VendorSubscription +from app.modules.billing.models import MerchantSubscription, SubscriptionStatus from app.modules.billing.services import stripe_service from app.modules.task_base import ModuleTask @@ -27,9 +27,9 @@ logger = logging.getLogger(__name__) ) def reset_period_counters(self): """ - Reset order counters for subscriptions whose billing period has ended. + Reset billing period dates for subscriptions whose billing period has ended. - Runs daily at 00:05. Resets orders_this_period to 0 and updates period dates. + Runs daily at 00:05. Updates period_start and period_end for the new cycle. """ now = datetime.now(UTC) reset_count = 0 @@ -37,10 +37,10 @@ def reset_period_counters(self): with self.get_db() as db: # Find subscriptions where period has ended expired_periods = ( - db.query(VendorSubscription) + db.query(MerchantSubscription) .filter( - VendorSubscription.period_end <= now, - VendorSubscription.status.in_(["active", "trial"]), + MerchantSubscription.period_end <= now, + MerchantSubscription.status.in_(["active", "trial"]), ) .all() ) @@ -48,10 +48,6 @@ def reset_period_counters(self): for subscription in expired_periods: old_period_end = subscription.period_end - # Reset counters - subscription.orders_this_period = 0 - subscription.orders_limit_reached_at = None - # Set new period dates if subscription.is_annual: subscription.period_start = now @@ -64,7 +60,7 @@ def reset_period_counters(self): reset_count += 1 logger.info( - f"Reset period counters for vendor {subscription.vendor_id}: " + f"Reset period for merchant {subscription.merchant_id}: " f"old_period_end={old_period_end}, new_period_end={subscription.period_end}" ) @@ -93,10 +89,10 @@ def check_trial_expirations(self): with self.get_db() as db: # Find expired trials expired_trials = ( - db.query(VendorSubscription) + db.query(MerchantSubscription) .filter( - VendorSubscription.status == SubscriptionStatus.TRIAL.value, - VendorSubscription.trial_ends_at <= now, + MerchantSubscription.status == SubscriptionStatus.TRIAL.value, + MerchantSubscription.trial_ends_at <= now, ) .all() ) @@ -107,7 +103,7 @@ def check_trial_expirations(self): subscription.status = SubscriptionStatus.ACTIVE.value activated_count += 1 logger.info( - f"Activated subscription for vendor {subscription.vendor_id} " + f"Activated subscription for merchant {subscription.merchant_id} " f"(trial ended with payment method)" ) else: @@ -115,7 +111,7 @@ def check_trial_expirations(self): subscription.status = SubscriptionStatus.EXPIRED.value expired_count += 1 logger.info( - f"Expired trial for vendor {subscription.vendor_id} " + f"Expired trial for merchant {subscription.merchant_id} " f"(no payment method)" ) @@ -149,8 +145,8 @@ def sync_stripe_status(self): with self.get_db() as db: # Find subscriptions with Stripe IDs subscriptions = ( - db.query(VendorSubscription) - .filter(VendorSubscription.stripe_subscription_id.isnot(None)) + db.query(MerchantSubscription) + .filter(MerchantSubscription.stripe_subscription_id.isnot(None)) .all() ) @@ -162,7 +158,7 @@ def sync_stripe_status(self): if not stripe_sub: logger.warning( f"Stripe subscription {subscription.stripe_subscription_id} " - f"not found for vendor {subscription.vendor_id}" + f"not found for merchant {subscription.merchant_id}" ) continue @@ -183,7 +179,7 @@ def sync_stripe_status(self): subscription.status = new_status subscription.updated_at = datetime.now(UTC) logger.info( - f"Updated vendor {subscription.vendor_id} status: " + f"Updated merchant {subscription.merchant_id} status: " f"{old_status} -> {new_status} (from Stripe)" ) @@ -233,10 +229,10 @@ def cleanup_stale_subscriptions(self): with self.get_db() as db: # Find cancelled subscriptions past their period end stale_cancelled = ( - db.query(VendorSubscription) + db.query(MerchantSubscription) .filter( - VendorSubscription.status == SubscriptionStatus.CANCELLED.value, - VendorSubscription.period_end < now - timedelta(days=30), + MerchantSubscription.status == SubscriptionStatus.CANCELLED.value, + MerchantSubscription.period_end < now - timedelta(days=30), ) .all() ) @@ -247,7 +243,7 @@ def cleanup_stale_subscriptions(self): subscription.updated_at = now cleaned_count += 1 logger.info( - f"Marked stale cancelled subscription as expired: vendor {subscription.vendor_id}" + f"Marked stale cancelled subscription as expired: merchant {subscription.merchant_id}" ) logger.info(f"Cleaned up {cleaned_count} stale subscriptions") diff --git a/app/modules/billing/templates/billing/admin/billing-history.html b/app/modules/billing/templates/billing/admin/billing-history.html index cc355056..f80daded 100644 --- a/app/modules/billing/templates/billing/admin/billing-history.html +++ b/app/modules/billing/templates/billing/admin/billing-history.html @@ -66,16 +66,16 @@
- +
@@ -278,14 +278,14 @@ function signupWizard() { // Step 2: Letzshop letzshopUrl: '', - letzshopVendor: null, + letzshopStore: null, letzshopError: null, // Step 3: Account account: { firstName: '', lastName: '', - companyName: '', + merchantName: '', email: '', password: '' }, @@ -345,14 +345,14 @@ function signupWizard() { } }, - async claimVendor() { + async claimStore() { if (this.letzshopUrl.trim()) { this.loading = true; this.letzshopError = null; try { - // First lookup the vendor - const lookupResponse = await fetch('/api/v1/platform/letzshop-vendors/lookup', { + // First lookup the store + const lookupResponse = await fetch('/api/v1/platform/letzshop-stores/lookup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: this.letzshopUrl }) @@ -360,35 +360,35 @@ function signupWizard() { const lookupData = await lookupResponse.json(); - if (lookupData.found && !lookupData.vendor.is_claimed) { - this.letzshopVendor = lookupData.vendor; + if (lookupData.found && !lookupData.store.is_claimed) { + this.letzshopStore = lookupData.store; - // Claim the vendor - const claimResponse = await fetch('/api/v1/platform/signup/claim-vendor', { + // Claim the store + const claimResponse = await fetch('/api/v1/platform/signup/claim-store', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: this.sessionId, - letzshop_slug: lookupData.vendor.slug + letzshop_slug: lookupData.store.slug }) }); if (claimResponse.ok) { const claimData = await claimResponse.json(); - this.account.companyName = claimData.vendor_name || ''; + this.account.merchantName = claimData.store_name || ''; this.currentStep = 3; } else { const error = await claimResponse.json(); - this.letzshopError = error.detail || 'Failed to claim vendor'; + this.letzshopError = error.detail || 'Failed to claim store'; } - } else if (lookupData.vendor?.is_claimed) { + } else if (lookupData.store?.is_claimed) { this.letzshopError = 'This shop has already been claimed.'; } else { this.letzshopError = lookupData.error || 'Shop not found.'; } } catch (error) { console.error('Error:', error); - this.letzshopError = 'Failed to lookup vendor.'; + this.letzshopError = 'Failed to lookup store.'; } finally { this.loading = false; } @@ -401,7 +401,7 @@ function signupWizard() { isAccountValid() { return this.account.firstName.trim() && this.account.lastName.trim() && - this.account.companyName.trim() && + this.account.merchantName.trim() && this.account.email.trim() && this.account.password.length >= 8; }, @@ -420,7 +420,7 @@ function signupWizard() { password: this.account.password, first_name: this.account.firstName, last_name: this.account.lastName, - company_name: this.account.companyName + merchant_name: this.account.merchantName }) }); @@ -513,11 +513,11 @@ function signupWizard() { if (response.ok) { // Store access token for automatic login if (data.access_token) { - localStorage.setItem('vendor_token', data.access_token); - localStorage.setItem('vendorCode', data.vendor_code); - console.log('Vendor token stored for automatic login'); + localStorage.setItem('store_token', data.access_token); + localStorage.setItem('storeCode', data.store_code); + console.log('Store token stored for automatic login'); } - window.location.href = '/signup/success?vendor_code=' + data.vendor_code; + window.location.href = '/signup/success?store_code=' + data.store_code; } else { alert(data.detail || 'Failed to complete signup'); } diff --git a/app/modules/billing/templates/billing/vendor/billing.html b/app/modules/billing/templates/billing/vendor/billing.html deleted file mode 100644 index 79af1e06..00000000 --- a/app/modules/billing/templates/billing/vendor/billing.html +++ /dev/null @@ -1,428 +0,0 @@ -{# app/templates/vendor/billing.html #} -{% extends "vendor/base.html" %} -{% from 'shared/macros/headers.html' import page_header %} -{% from 'shared/macros/modals.html' import modal_simple %} - -{% block title %}Billing & Subscription{% endblock %} - -{% block alpine_data %}vendorBilling(){% endblock %} - -{% block content %} -{{ page_header('Billing & Subscription') }} - - - - - - - - - - - - - - -{% call modal_simple('tiersModal', 'Choose Your Plan', show_var='showTiersModal', size='xl') %} -
- -
-{% endcall %} - - -
-
-
-

Add-ons

- -
-
- - - - -

Available Add-ons

- -
- -
-
-
-
- - -
-
-
-

Cancel Subscription

- -
-
-

- Are you sure you want to cancel your subscription? You'll continue to have access until the end of your current billing period. -

-
- - -
-
- - -
-
-
-
- -{% endblock %} - -{% block extra_scripts %} - -{% endblock %} diff --git a/app/modules/cart/definition.py b/app/modules/cart/definition.py index f7c8626c..bd9f3ae0 100644 --- a/app/modules/cart/definition.py +++ b/app/modules/cart/definition.py @@ -52,7 +52,7 @@ cart_module = ModuleDefinition( category="cart", ), ], - # Cart is storefront-only - no admin/vendor menus needed + # Cart is storefront-only - no admin/store menus needed menu_items={}, ) diff --git a/app/modules/cart/models/cart.py b/app/modules/cart/models/cart.py index c2e7b2e6..ed900e6d 100644 --- a/app/modules/cart/models/cart.py +++ b/app/modules/cart/models/cart.py @@ -24,7 +24,7 @@ class CartItem(Base, TimestampMixin): """ Shopping cart items. - Stores cart items per session, vendor, and product. + Stores cart items per session, store, and product. Sessions are identified by a session_id string (from browser cookies). Price is stored as integer cents for precision. @@ -33,7 +33,7 @@ class CartItem(Base, TimestampMixin): __tablename__ = "cart_items" id = Column(Integer, primary_key=True, index=True) - vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False) + store_id = Column(Integer, ForeignKey("stores.id"), nullable=False) product_id = Column(Integer, ForeignKey("products.id"), nullable=False) session_id = Column(String(255), nullable=False, index=True) @@ -42,13 +42,13 @@ class CartItem(Base, TimestampMixin): price_at_add_cents = Column(Integer, nullable=False) # Price in cents when added # Relationships - vendor = relationship("Vendor") + store = relationship("Store") product = relationship("Product") # Constraints __table_args__ = ( - UniqueConstraint("vendor_id", "session_id", "product_id", name="uq_cart_item"), - Index("idx_cart_session", "vendor_id", "session_id"), + UniqueConstraint("store_id", "session_id", "product_id", name="uq_cart_item"), + Index("idx_cart_session", "store_id", "session_id"), Index("idx_cart_created", "created_at"), # For cleanup of old carts ) diff --git a/app/modules/cart/routes/api/storefront.py b/app/modules/cart/routes/api/storefront.py index fc9642d0..241c1d42 100644 --- a/app/modules/cart/routes/api/storefront.py +++ b/app/modules/cart/routes/api/storefront.py @@ -3,10 +3,10 @@ Cart Module - Storefront API Routes Public endpoints for managing shopping cart in storefront. -Uses vendor from middleware context (VendorContextMiddleware). +Uses store from middleware context (StoreContextMiddleware). No authentication required - uses session ID for cart tracking. -Vendor Context: require_vendor_context() - detects vendor from URL/subdomain/domain +Store Context: require_store_context() - detects store from URL/subdomain/domain """ import logging @@ -23,8 +23,8 @@ from app.modules.cart.schemas import ( ClearCartResponse, UpdateCartItemRequest, ) -from middleware.vendor_context import require_vendor_context -from app.modules.tenancy.models import Vendor +from middleware.store_context import require_store_context +from app.modules.tenancy.models import Store router = APIRouter() logger = logging.getLogger(__name__) @@ -38,34 +38,34 @@ logger = logging.getLogger(__name__) @router.get("/cart/{session_id}", response_model=CartResponse) # public def get_cart( session_id: str = Path(..., description="Shopping session ID"), - vendor: Vendor = Depends(require_vendor_context()), + store: Store = Depends(require_store_context()), db: Session = Depends(get_db), ) -> CartResponse: """ - Get shopping cart contents for current vendor. + Get shopping cart contents for current store. - Vendor is automatically determined from request context (URL/subdomain/domain). + Store is automatically determined from request context (URL/subdomain/domain). No authentication required - uses session ID for cart tracking. Path Parameters: - session_id: Unique session identifier for the cart """ logger.info( - f"[CART_STOREFRONT] get_cart for session {session_id}, vendor {vendor.id}", + f"[CART_STOREFRONT] get_cart for session {session_id}, store {store.id}", extra={ - "vendor_id": vendor.id, - "vendor_code": vendor.subdomain, + "store_id": store.id, + "store_code": store.subdomain, "session_id": session_id, }, ) - cart = cart_service.get_cart(db=db, vendor_id=vendor.id, session_id=session_id) + cart = cart_service.get_cart(db=db, store_id=store.id, session_id=session_id) logger.info( f"[CART_STOREFRONT] get_cart result: {len(cart.get('items', []))} items in cart", extra={ "session_id": session_id, - "vendor_id": vendor.id, + "store_id": store.id, "item_count": len(cart.get("items", [])), "total": cart.get("total", 0), }, @@ -78,13 +78,13 @@ def get_cart( def add_to_cart( session_id: str = Path(..., description="Shopping session ID"), cart_data: AddToCartRequest = Body(...), - vendor: Vendor = Depends(require_vendor_context()), + store: Store = Depends(require_store_context()), db: Session = Depends(get_db), ) -> CartOperationResponse: """ - Add product to cart for current vendor. + Add product to cart for current store. - Vendor is automatically determined from request context (URL/subdomain/domain). + Store is automatically determined from request context (URL/subdomain/domain). No authentication required - uses session ID. Path Parameters: @@ -97,8 +97,8 @@ def add_to_cart( logger.info( f"[CART_STOREFRONT] add_to_cart: product {cart_data.product_id}, qty {cart_data.quantity}, session {session_id}", extra={ - "vendor_id": vendor.id, - "vendor_code": vendor.subdomain, + "store_id": store.id, + "store_code": store.subdomain, "session_id": session_id, "product_id": cart_data.product_id, "quantity": cart_data.quantity, @@ -107,7 +107,7 @@ def add_to_cart( result = cart_service.add_to_cart( db=db, - vendor_id=vendor.id, + store_id=store.id, session_id=session_id, product_id=cart_data.product_id, quantity=cart_data.quantity, @@ -132,13 +132,13 @@ def update_cart_item( session_id: str = Path(..., description="Shopping session ID"), product_id: int = Path(..., description="Product ID", gt=0), cart_data: UpdateCartItemRequest = Body(...), - vendor: Vendor = Depends(require_vendor_context()), + store: Store = Depends(require_store_context()), db: Session = Depends(get_db), ) -> CartOperationResponse: """ - Update cart item quantity for current vendor. + Update cart item quantity for current store. - Vendor is automatically determined from request context (URL/subdomain/domain). + Store is automatically determined from request context (URL/subdomain/domain). No authentication required - uses session ID. Path Parameters: @@ -151,8 +151,8 @@ def update_cart_item( logger.debug( f"[CART_STOREFRONT] update_cart_item: product {product_id}, qty {cart_data.quantity}", extra={ - "vendor_id": vendor.id, - "vendor_code": vendor.subdomain, + "store_id": store.id, + "store_code": store.subdomain, "session_id": session_id, "product_id": product_id, "quantity": cart_data.quantity, @@ -161,7 +161,7 @@ def update_cart_item( result = cart_service.update_cart_item( db=db, - vendor_id=vendor.id, + store_id=store.id, session_id=session_id, product_id=product_id, quantity=cart_data.quantity, @@ -177,13 +177,13 @@ def update_cart_item( def remove_from_cart( session_id: str = Path(..., description="Shopping session ID"), product_id: int = Path(..., description="Product ID", gt=0), - vendor: Vendor = Depends(require_vendor_context()), + store: Store = Depends(require_store_context()), db: Session = Depends(get_db), ) -> CartOperationResponse: """ - Remove item from cart for current vendor. + Remove item from cart for current store. - Vendor is automatically determined from request context (URL/subdomain/domain). + Store is automatically determined from request context (URL/subdomain/domain). No authentication required - uses session ID. Path Parameters: @@ -193,15 +193,15 @@ def remove_from_cart( logger.debug( f"[CART_STOREFRONT] remove_from_cart: product {product_id}", extra={ - "vendor_id": vendor.id, - "vendor_code": vendor.subdomain, + "store_id": store.id, + "store_code": store.subdomain, "session_id": session_id, "product_id": product_id, }, ) result = cart_service.remove_from_cart( - db=db, vendor_id=vendor.id, session_id=session_id, product_id=product_id + db=db, store_id=store.id, session_id=session_id, product_id=product_id ) db.commit() @@ -211,13 +211,13 @@ def remove_from_cart( @router.delete("/cart/{session_id}", response_model=ClearCartResponse) # public def clear_cart( session_id: str = Path(..., description="Shopping session ID"), - vendor: Vendor = Depends(require_vendor_context()), + store: Store = Depends(require_store_context()), db: Session = Depends(get_db), ) -> ClearCartResponse: """ - Clear all items from cart for current vendor. + Clear all items from cart for current store. - Vendor is automatically determined from request context (URL/subdomain/domain). + Store is automatically determined from request context (URL/subdomain/domain). No authentication required - uses session ID. Path Parameters: @@ -226,13 +226,13 @@ def clear_cart( logger.debug( f"[CART_STOREFRONT] clear_cart for session {session_id}", extra={ - "vendor_id": vendor.id, - "vendor_code": vendor.subdomain, + "store_id": store.id, + "store_code": store.subdomain, "session_id": session_id, }, ) - result = cart_service.clear_cart(db=db, vendor_id=vendor.id, session_id=session_id) + result = cart_service.clear_cart(db=db, store_id=store.id, session_id=session_id) db.commit() return ClearCartResponse(**result) diff --git a/app/modules/cart/routes/pages/storefront.py b/app/modules/cart/routes/pages/storefront.py index b034e035..d67c9e9d 100644 --- a/app/modules/cart/routes/pages/storefront.py +++ b/app/modules/cart/routes/pages/storefront.py @@ -36,7 +36,7 @@ async def shop_cart_page(request: Request, db: Session = Depends(get_db)): "[STOREFRONT] shop_cart_page REACHED", extra={ "path": request.url.path, - "vendor": getattr(request.state, "vendor", "NOT SET"), + "store": getattr(request.state, "store", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) diff --git a/app/modules/cart/schemas/cart.py b/app/modules/cart/schemas/cart.py index e122214a..ea395ef0 100644 --- a/app/modules/cart/schemas/cart.py +++ b/app/modules/cart/schemas/cart.py @@ -46,7 +46,7 @@ class CartItemResponse(BaseModel): class CartResponse(BaseModel): """Response model for shopping cart.""" - vendor_id: int = Field(..., description="Vendor ID") + store_id: int = Field(..., description="Store ID") session_id: str = Field(..., description="Shopping session ID") items: list[CartItemResponse] = Field( default_factory=list, description="Cart items" @@ -65,7 +65,7 @@ class CartResponse(BaseModel): """ items = [CartItemResponse(**item) for item in cart_dict.get("items", [])] return cls( - vendor_id=cart_dict["vendor_id"], + store_id=cart_dict["store_id"], session_id=cart_dict["session_id"], items=items, subtotal=cart_dict["subtotal"], diff --git a/app/modules/cart/services/cart_service.py b/app/modules/cart/services/cart_service.py index b3ac629c..b40a15e2 100644 --- a/app/modules/cart/services/cart_service.py +++ b/app/modules/cart/services/cart_service.py @@ -32,13 +32,13 @@ logger = logging.getLogger(__name__) class CartService: """Service for managing shopping carts.""" - def get_cart(self, db: Session, vendor_id: int, session_id: str) -> dict: + def get_cart(self, db: Session, store_id: int, session_id: str) -> dict: """ Get cart contents for a session. Args: db: Database session - vendor_id: Vendor ID + store_id: Store ID session_id: Session ID Returns: @@ -47,7 +47,7 @@ class CartService: logger.info( "[CART_SERVICE] get_cart called", extra={ - "vendor_id": vendor_id, + "store_id": store_id, "session_id": session_id, }, ) @@ -56,7 +56,7 @@ class CartService: cart_items = ( db.query(CartItem) .filter( - and_(CartItem.vendor_id == vendor_id, CartItem.session_id == session_id) + and_(CartItem.store_id == store_id, CartItem.session_id == session_id) ) .all() ) @@ -96,7 +96,7 @@ class CartService: # Convert to euros for API response subtotal = cents_to_euros(subtotal_cents) cart_data = { - "vendor_id": vendor_id, + "store_id": store_id, "session_id": session_id, "items": items, "subtotal": subtotal, @@ -113,7 +113,7 @@ class CartService: def add_to_cart( self, db: Session, - vendor_id: int, + store_id: int, session_id: str, product_id: int, quantity: int = 1, @@ -123,7 +123,7 @@ class CartService: Args: db: Database session - vendor_id: Vendor ID + store_id: Store ID session_id: Session ID product_id: Product ID quantity: Quantity to add @@ -138,20 +138,20 @@ class CartService: logger.info( "[CART_SERVICE] add_to_cart called", extra={ - "vendor_id": vendor_id, + "store_id": store_id, "session_id": session_id, "product_id": product_id, "quantity": quantity, }, ) - # Verify product exists and belongs to vendor + # Verify product exists and belongs to store product = ( db.query(Product) .filter( and_( Product.id == product_id, - Product.vendor_id == vendor_id, + Product.store_id == store_id, Product.is_active == True, ) ) @@ -161,9 +161,9 @@ class CartService: if not product: logger.error( "[CART_SERVICE] Product not found", - extra={"product_id": product_id, "vendor_id": vendor_id}, + extra={"product_id": product_id, "store_id": store_id}, ) - raise ProductNotFoundException(product_id=product_id, vendor_id=vendor_id) + raise ProductNotFoundException(product_id=product_id, store_id=store_id) logger.info( f"[CART_SERVICE] Product found: {product.marketplace_product.title}", @@ -186,7 +186,7 @@ class CartService: db.query(CartItem) .filter( and_( - CartItem.vendor_id == vendor_id, + CartItem.store_id == store_id, CartItem.session_id == session_id, CartItem.product_id == product_id, ) @@ -250,7 +250,7 @@ class CartService: # Create new cart item (price stored in cents) cart_item = CartItem( - vendor_id=vendor_id, + store_id=store_id, session_id=session_id, product_id=product_id, quantity=quantity, @@ -278,7 +278,7 @@ class CartService: def update_cart_item( self, db: Session, - vendor_id: int, + store_id: int, session_id: str, product_id: int, quantity: int, @@ -288,7 +288,7 @@ class CartService: Args: db: Database session - vendor_id: Vendor ID + store_id: Store ID session_id: Session ID product_id: Product ID quantity: New quantity (must be >= 1) @@ -309,7 +309,7 @@ class CartService: db.query(CartItem) .filter( and_( - CartItem.vendor_id == vendor_id, + CartItem.store_id == store_id, CartItem.session_id == session_id, CartItem.product_id == product_id, ) @@ -328,7 +328,7 @@ class CartService: .filter( and_( Product.id == product_id, - Product.vendor_id == vendor_id, + Product.store_id == store_id, Product.is_active == True, ) ) @@ -368,14 +368,14 @@ class CartService: } def remove_from_cart( - self, db: Session, vendor_id: int, session_id: str, product_id: int + self, db: Session, store_id: int, session_id: str, product_id: int ) -> dict: """ Remove item from cart. Args: db: Database session - vendor_id: Vendor ID + store_id: Store ID session_id: Session ID product_id: Product ID @@ -390,7 +390,7 @@ class CartService: db.query(CartItem) .filter( and_( - CartItem.vendor_id == vendor_id, + CartItem.store_id == store_id, CartItem.session_id == session_id, CartItem.product_id == product_id, ) @@ -416,13 +416,13 @@ class CartService: return {"message": "Item removed from cart", "product_id": product_id} - def clear_cart(self, db: Session, vendor_id: int, session_id: str) -> dict: + def clear_cart(self, db: Session, store_id: int, session_id: str) -> dict: """ Clear all items from cart. Args: db: Database session - vendor_id: Vendor ID + store_id: Store ID session_id: Session ID Returns: @@ -432,7 +432,7 @@ class CartService: deleted_count = ( db.query(CartItem) .filter( - and_(CartItem.vendor_id == vendor_id, CartItem.session_id == session_id) + and_(CartItem.store_id == store_id, CartItem.session_id == session_id) ) .delete() ) @@ -441,7 +441,7 @@ class CartService: "[CART_SERVICE] Cleared cart", extra={ "session_id": session_id, - "vendor_id": vendor_id, + "store_id": store_id, "items_removed": deleted_count, }, ) diff --git a/app/modules/catalog/definition.py b/app/modules/catalog/definition.py index 67fc8a5e..4906b7b7 100644 --- a/app/modules/catalog/definition.py +++ b/app/modules/catalog/definition.py @@ -22,11 +22,11 @@ def _get_admin_router(): return admin_router -def _get_vendor_router(): - """Lazy import of vendor router to avoid circular imports.""" - from app.modules.catalog.routes.api.vendor import vendor_router +def _get_store_router(): + """Lazy import of store router to avoid circular imports.""" + from app.modules.catalog.routes.api.store import store_router - return vendor_router + return store_router def _get_metrics_provider(): @@ -36,6 +36,13 @@ def _get_metrics_provider(): return catalog_metrics_provider +def _get_feature_provider(): + """Lazy import of feature provider to avoid circular imports.""" + from app.modules.catalog.services.catalog_features import catalog_feature_provider + + return catalog_feature_provider + + # Catalog module definition catalog_module = ModuleDefinition( code="catalog", @@ -93,7 +100,7 @@ catalog_module = ModuleDefinition( ], # Module-driven menu definitions menus={ - FrontendType.VENDOR: [ + FrontendType.STORE: [ MenuSectionDefinition( id="products", label_key="catalog.menu.products_inventory", @@ -104,7 +111,7 @@ catalog_module = ModuleDefinition( id="products", label_key="catalog.menu.all_products", icon="shopping-bag", - route="/vendor/{vendor_code}/products", + route="/store/{store_code}/products", order=10, is_mandatory=True, ), @@ -114,6 +121,7 @@ catalog_module = ModuleDefinition( }, # Metrics provider for dashboard statistics metrics_provider=_get_metrics_provider, + feature_provider=_get_feature_provider, ) @@ -125,7 +133,7 @@ def get_catalog_module_with_routers() -> ModuleDefinition: during module initialization. """ catalog_module.admin_router = _get_admin_router() - catalog_module.vendor_router = _get_vendor_router() + catalog_module.store_router = _get_store_router() return catalog_module diff --git a/app/modules/catalog/exceptions.py b/app/modules/catalog/exceptions.py index 730ab6f7..8aaf5c62 100644 --- a/app/modules/catalog/exceptions.py +++ b/app/modules/catalog/exceptions.py @@ -30,11 +30,11 @@ __all__ = [ class ProductNotFoundException(ResourceNotFoundException): - """Raised when a product is not found in vendor catalog.""" + """Raised when a product is not found in store catalog.""" - def __init__(self, product_id: int, vendor_id: int | None = None): - if vendor_id: - message = f"Product with ID '{product_id}' not found in vendor {vendor_id} catalog" + def __init__(self, product_id: int, store_id: int | None = None): + if store_id: + message = f"Product with ID '{product_id}' not found in store {store_id} catalog" else: message = f"Product with ID '{product_id}' not found" @@ -45,48 +45,48 @@ class ProductNotFoundException(ResourceNotFoundException): error_code="PRODUCT_NOT_FOUND", ) self.details["product_id"] = product_id - if vendor_id: - self.details["vendor_id"] = vendor_id + if store_id: + self.details["store_id"] = store_id class ProductAlreadyExistsException(ConflictException): """Raised when trying to add a product that already exists.""" - def __init__(self, vendor_id: int, identifier: str | int): + def __init__(self, store_id: int, identifier: str | int): super().__init__( - message=f"Product '{identifier}' already exists in vendor {vendor_id} catalog", + message=f"Product '{identifier}' already exists in store {store_id} catalog", error_code="PRODUCT_ALREADY_EXISTS", details={ - "vendor_id": vendor_id, + "store_id": store_id, "identifier": identifier, }, ) class ProductNotInCatalogException(ResourceNotFoundException): - """Raised when trying to access a product that's not in vendor's catalog.""" + """Raised when trying to access a product that's not in store's catalog.""" - def __init__(self, product_id: int, vendor_id: int): + def __init__(self, product_id: int, store_id: int): super().__init__( resource_type="Product", identifier=str(product_id), - message=f"Product {product_id} is not in vendor {vendor_id} catalog", + message=f"Product {product_id} is not in store {store_id} catalog", error_code="PRODUCT_NOT_IN_CATALOG", ) self.details["product_id"] = product_id - self.details["vendor_id"] = vendor_id + self.details["store_id"] = store_id class ProductNotActiveException(BusinessLogicException): """Raised when trying to perform operations on inactive product.""" - def __init__(self, product_id: int, vendor_id: int): + def __init__(self, product_id: int, store_id: int): super().__init__( - message=f"Product {product_id} in vendor {vendor_id} catalog is not active", + message=f"Product {product_id} in store {store_id} catalog is not active", error_code="PRODUCT_NOT_ACTIVE", details={ "product_id": product_id, - "vendor_id": vendor_id, + "store_id": store_id, }, ) diff --git a/app/modules/catalog/locales/de.json b/app/modules/catalog/locales/de.json index fc421bd3..0f14d025 100644 --- a/app/modules/catalog/locales/de.json +++ b/app/modules/catalog/locales/de.json @@ -51,14 +51,25 @@ "please_fill_in_all_required_fields": "Please fill in all required fields", "product_updated_successfully": "Product updated successfully", "failed_to_load_media_library": "Failed to load media library", - "no_vendor_associated_with_this_product": "No vendor associated with this product", + "no_store_associated_with_this_product": "No store associated with this product", "please_select_an_image_file": "Please select an image file", "image_must_be_less_than_10mb": "Image must be less than 10MB", "image_uploaded_successfully": "Image uploaded successfully", - "product_removed_from_vendor_catalog": "Product removed from vendor catalog.", - "please_select_a_vendor": "Please select a vendor", + "product_removed_from_store_catalog": "Product removed from store catalog.", + "please_select_a_store": "Please select a store", "please_enter_a_product_title_english": "Please enter a product title (English)", "product_created_successfully": "Product created successfully", - "please_select_a_vendor_first": "Please select a vendor first" + "please_select_a_store_first": "Please select a store first" + }, + "features": { + "products_limit": { + "name": "Produkte", + "description": "Maximale Anzahl an Produkten im Katalog", + "unit": "Produkte" + }, + "product_import_export": { + "name": "Import/Export", + "description": "Massenimport und -export von Produkten" + } } } diff --git a/app/modules/catalog/locales/en.json b/app/modules/catalog/locales/en.json index 2738a620..92e1c043 100644 --- a/app/modules/catalog/locales/en.json +++ b/app/modules/catalog/locales/en.json @@ -67,16 +67,27 @@ "failed_to_activate_products": "Failed to activate products", "failed_to_deactivate_products": "Failed to deactivate products", "failed_to_upload_image": "Failed to upload image", - "product_removed_from_vendor_catalog": "Product removed from vendor catalog.", + "product_removed_from_store_catalog": "Product removed from store catalog.", "please_fill_in_all_required_fields": "Please fill in all required fields", "failed_to_load_media_library": "Failed to load media library", - "no_vendor_associated_with_this_product": "No vendor associated with this product", + "no_store_associated_with_this_product": "No store associated with this product", "please_select_an_image_file": "Please select an image file", "image_must_be_less_than_10mb": "Image must be less than 10MB", "image_uploaded_successfully": "Image uploaded successfully", - "please_select_a_vendor": "Please select a vendor", + "please_select_a_store": "Please select a store", "please_enter_a_product_title_english": "Please enter a product title (English)", - "please_select_a_vendor_first": "Please select a vendor first", + "please_select_a_store_first": "Please select a store first", "title_and_price_required": "Title and price are required" + }, + "features": { + "products_limit": { + "name": "Products", + "description": "Maximum number of products in catalog", + "unit": "products" + }, + "product_import_export": { + "name": "Import/Export", + "description": "Bulk product import and export functionality" + } } } diff --git a/app/modules/catalog/locales/fr.json b/app/modules/catalog/locales/fr.json index 05707fe8..e3746b72 100644 --- a/app/modules/catalog/locales/fr.json +++ b/app/modules/catalog/locales/fr.json @@ -51,14 +51,25 @@ "please_fill_in_all_required_fields": "Please fill in all required fields", "product_updated_successfully": "Product updated successfully", "failed_to_load_media_library": "Failed to load media library", - "no_vendor_associated_with_this_product": "No vendor associated with this product", + "no_store_associated_with_this_product": "No store associated with this product", "please_select_an_image_file": "Please select an image file", "image_must_be_less_than_10mb": "Image must be less than 10MB", "image_uploaded_successfully": "Image uploaded successfully", - "product_removed_from_vendor_catalog": "Product removed from vendor catalog.", - "please_select_a_vendor": "Please select a vendor", + "product_removed_from_store_catalog": "Product removed from store catalog.", + "please_select_a_store": "Please select a store", "please_enter_a_product_title_english": "Please enter a product title (English)", "product_created_successfully": "Product created successfully", - "please_select_a_vendor_first": "Please select a vendor first" + "please_select_a_store_first": "Please select a store first" + }, + "features": { + "products_limit": { + "name": "Produits", + "description": "Nombre maximum de produits dans le catalogue", + "unit": "produits" + }, + "product_import_export": { + "name": "Import/Export", + "description": "Import et export en masse de produits" + } } } diff --git a/app/modules/catalog/locales/lb.json b/app/modules/catalog/locales/lb.json index 9c442288..58e9dd23 100644 --- a/app/modules/catalog/locales/lb.json +++ b/app/modules/catalog/locales/lb.json @@ -51,14 +51,25 @@ "please_fill_in_all_required_fields": "Please fill in all required fields", "product_updated_successfully": "Product updated successfully", "failed_to_load_media_library": "Failed to load media library", - "no_vendor_associated_with_this_product": "No vendor associated with this product", + "no_store_associated_with_this_product": "No store associated with this product", "please_select_an_image_file": "Please select an image file", "image_must_be_less_than_10mb": "Image must be less than 10MB", "image_uploaded_successfully": "Image uploaded successfully", - "product_removed_from_vendor_catalog": "Product removed from vendor catalog.", - "please_select_a_vendor": "Please select a vendor", + "product_removed_from_store_catalog": "Product removed from store catalog.", + "please_select_a_store": "Please select a store", "please_enter_a_product_title_english": "Please enter a product title (English)", "product_created_successfully": "Product created successfully", - "please_select_a_vendor_first": "Please select a vendor first" + "please_select_a_store_first": "Please select a store first" + }, + "features": { + "products_limit": { + "name": "Produkter", + "description": "Maximal Unzuel vu Produkter am Katalog", + "unit": "Produkter" + }, + "product_import_export": { + "name": "Import/Export", + "description": "Mass-Import an -Export vu Produkter" + } } } diff --git a/app/modules/catalog/models/product.py b/app/modules/catalog/models/product.py index 8338ff35..92134846 100644 --- a/app/modules/catalog/models/product.py +++ b/app/modules/catalog/models/product.py @@ -1,9 +1,9 @@ # app/modules/catalog/models/product.py -"""Vendor Product model - independent copy pattern. +"""Store Product model - independent copy pattern. -This model represents a vendor's product. Products can be: +This model represents a store's product. Products can be: 1. Created from a marketplace import (has marketplace_product_id) -2. Created directly by the vendor (no marketplace_product_id) +2. Created directly by the store (no marketplace_product_id) When created from marketplace, the marketplace_product_id FK provides "view original source" comparison feature. @@ -30,9 +30,9 @@ from models.database.base import TimestampMixin class Product(Base, TimestampMixin): - """Vendor-specific product. + """Store-specific product. - Products can be created from marketplace imports or directly by vendors. + Products can be created from marketplace imports or directly by stores. When from marketplace, marketplace_product_id provides source comparison. Price fields use integer cents for precision (19.99 = 1999 cents). @@ -41,13 +41,13 @@ class Product(Base, TimestampMixin): __tablename__ = "products" id = Column(Integer, primary_key=True, index=True) - vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False) + store_id = Column(Integer, ForeignKey("stores.id"), nullable=False) marketplace_product_id = Column( Integer, ForeignKey("marketplace_products.id"), nullable=True ) - # === VENDOR REFERENCE === - vendor_sku = Column(String, index=True) # Vendor's internal SKU + # === STORE REFERENCE === + store_sku = Column(String, index=True) # Store's internal SKU # === PRODUCT IDENTIFIERS === # GTIN (Global Trade Item Number) - barcode for EAN matching with orders @@ -82,14 +82,14 @@ class Product(Base, TimestampMixin): # === SUPPLIER TRACKING & COST === supplier = Column(String(50)) # 'codeswholesale', 'internal', etc. supplier_product_id = Column(String) # Supplier's product reference - cost_cents = Column(Integer) # What vendor pays to acquire (in cents) - for profit calculation + cost_cents = Column(Integer) # What store pays to acquire (in cents) - for profit calculation margin_percent_x100 = Column(Integer) # Markup percentage * 100 (e.g., 25.5% = 2550) # === PRODUCT TYPE === is_digital = Column(Boolean, default=False, index=True) product_type = Column(String(20), default="physical") # physical, digital, service, subscription - # === VENDOR-SPECIFIC === + # === STORE-SPECIFIC === is_featured = Column(Boolean, default=False) is_active = Column(Boolean, default=True) display_order = Column(Integer, default=0) @@ -102,9 +102,9 @@ class Product(Base, TimestampMixin): fulfillment_email_template = Column(String) # Template name for digital delivery # === RELATIONSHIPS === - vendor = relationship("Vendor", back_populates="products") + store = relationship("Store", back_populates="products") marketplace_product = relationship( - "MarketplaceProduct", back_populates="vendor_products" + "MarketplaceProduct", back_populates="store_products" ) translations = relationship( "ProductTranslation", @@ -121,18 +121,18 @@ class Product(Base, TimestampMixin): # === CONSTRAINTS & INDEXES === __table_args__ = ( UniqueConstraint( - "vendor_id", "marketplace_product_id", name="uq_vendor_marketplace_product" + "store_id", "marketplace_product_id", name="uq_vendor_marketplace_product" ), - Index("idx_product_vendor_active", "vendor_id", "is_active"), - Index("idx_product_vendor_featured", "vendor_id", "is_featured"), - Index("idx_product_vendor_sku", "vendor_id", "vendor_sku"), + Index("idx_product_vendor_active", "store_id", "is_active"), + Index("idx_product_vendor_featured", "store_id", "is_featured"), + Index("idx_product_vendor_sku", "store_id", "store_sku"), Index("idx_product_supplier", "supplier", "supplier_product_id"), ) def __repr__(self): return ( - f"" + f"" ) # === PRICE PROPERTIES (Euro convenience accessors) === @@ -163,7 +163,7 @@ class Product(Base, TimestampMixin): @property def cost(self) -> float | None: - """Get cost in euros (what vendor pays to acquire).""" + """Get cost in euros (what store pays to acquire).""" if self.cost_cents is not None: return cents_to_euros(self.cost_cents) return None diff --git a/app/modules/catalog/models/product_translation.py b/app/modules/catalog/models/product_translation.py index a3f2b48f..04cc88c0 100644 --- a/app/modules/catalog/models/product_translation.py +++ b/app/modules/catalog/models/product_translation.py @@ -1,7 +1,7 @@ # app/modules/catalog/models/product_translation.py -"""Product Translation model for vendor-specific localized content. +"""Product Translation model for store-specific localized content. -This model stores vendor-specific translations. Translations are independent +This model stores store-specific translations. Translations are independent entities with all fields populated at creation time from the source marketplace product translation. @@ -25,9 +25,9 @@ from models.database.base import TimestampMixin class ProductTranslation(Base, TimestampMixin): - """Vendor-specific localized content - independent copy. + """Store-specific localized content - independent copy. - Each vendor has their own translations with all fields populated + Each store has their own translations with all fields populated at creation time. The source marketplace translation can be accessed for comparison via the product's marketplace_product relationship. """ diff --git a/app/modules/catalog/routes/api/__init__.py b/app/modules/catalog/routes/api/__init__.py index a94bc14c..b8500a66 100644 --- a/app/modules/catalog/routes/api/__init__.py +++ b/app/modules/catalog/routes/api/__init__.py @@ -10,7 +10,7 @@ __all__ = [ "storefront_router", "STOREFRONT_TAG", "admin_router", - "vendor_router", + "store_router", ] @@ -19,7 +19,7 @@ def __getattr__(name: str): if name == "admin_router": from app.modules.catalog.routes.api.admin import admin_router return admin_router - elif name == "vendor_router": - from app.modules.catalog.routes.api.vendor import vendor_router - return vendor_router + elif name == "store_router": + from app.modules.catalog.routes.api.store import store_router + return store_router raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/app/modules/catalog/routes/api/admin.py b/app/modules/catalog/routes/api/admin.py index fc66c218..bdb1d29c 100644 --- a/app/modules/catalog/routes/api/admin.py +++ b/app/modules/catalog/routes/api/admin.py @@ -1,9 +1,9 @@ # app/modules/catalog/routes/api/admin.py """ -Admin vendor product catalog endpoints. +Admin store product catalog endpoints. -Provides management of vendor-specific product catalogs: -- Browse products in vendor catalogs +Provides management of store-specific product catalogs: +- Browse products in store catalogs - View product details with override info - Create/update/remove products from catalog @@ -18,24 +18,24 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api, require_module_access from app.core.database import get_db from app.modules.billing.services.subscription_service import subscription_service -from app.modules.catalog.services.vendor_product_service import vendor_product_service +from app.modules.catalog.services.store_product_service import store_product_service from app.modules.enums import FrontendType from models.schema.auth import UserContext from app.modules.catalog.schemas import ( - CatalogVendor, - CatalogVendorsResponse, + CatalogStore, + CatalogStoresResponse, RemoveProductResponse, - VendorProductCreate, - VendorProductCreateResponse, - VendorProductDetail, - VendorProductListItem, - VendorProductListResponse, - VendorProductStats, - VendorProductUpdate, + StoreProductCreate, + StoreProductCreateResponse, + StoreProductDetail, + StoreProductListItem, + StoreProductListResponse, + StoreProductStats, + StoreProductUpdate, ) admin_router = APIRouter( - prefix="/vendor-products", + prefix="/store-products", dependencies=[Depends(require_module_access("catalog", FrontendType.ADMIN))], ) logger = logging.getLogger(__name__) @@ -46,12 +46,12 @@ logger = logging.getLogger(__name__) # ============================================================================ -@admin_router.get("", response_model=VendorProductListResponse) -def get_vendor_products( +@admin_router.get("", response_model=StoreProductListResponse) +def get_store_products( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=500), search: str | None = Query(None, description="Search by title or SKU"), - vendor_id: int | None = Query(None, description="Filter by vendor"), + store_id: int | None = Query(None, description="Filter by store"), is_active: bool | None = Query(None, description="Filter by active status"), is_featured: bool | None = Query(None, description="Filter by featured status"), language: str = Query("en", description="Language for title lookup"), @@ -59,103 +59,103 @@ def get_vendor_products( current_admin: UserContext = Depends(get_current_admin_api), ): """ - Get all products in vendor catalogs with filtering. + Get all products in store catalogs with filtering. This endpoint allows admins to browse products that have been - copied to vendor catalogs from the marketplace repository. + copied to store catalogs from the marketplace repository. """ - products, total = vendor_product_service.get_products( + products, total = store_product_service.get_products( db=db, skip=skip, limit=limit, search=search, - vendor_id=vendor_id, + store_id=store_id, is_active=is_active, is_featured=is_featured, language=language, ) - return VendorProductListResponse( - products=[VendorProductListItem(**p) for p in products], + return StoreProductListResponse( + products=[StoreProductListItem(**p) for p in products], total=total, skip=skip, limit=limit, ) -@admin_router.get("/stats", response_model=VendorProductStats) -def get_vendor_product_stats( - vendor_id: int | None = Query(None, description="Filter stats by vendor ID"), +@admin_router.get("/stats", response_model=StoreProductStats) +def get_store_product_stats( + store_id: int | None = Query(None, description="Filter stats by store ID"), db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), ): - """Get vendor product statistics for admin dashboard.""" - stats = vendor_product_service.get_product_stats(db, vendor_id=vendor_id) - return VendorProductStats(**stats) + """Get store product statistics for admin dashboard.""" + stats = store_product_service.get_product_stats(db, store_id=store_id) + return StoreProductStats(**stats) -@admin_router.get("/vendors", response_model=CatalogVendorsResponse) -def get_catalog_vendors( +@admin_router.get("/stores", response_model=CatalogStoresResponse) +def get_catalog_stores( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), ): - """Get list of vendors with products in their catalogs.""" - vendors = vendor_product_service.get_catalog_vendors(db) - return CatalogVendorsResponse(vendors=[CatalogVendor(**v) for v in vendors]) + """Get list of stores with products in their catalogs.""" + stores = store_product_service.get_catalog_stores(db) + return CatalogStoresResponse(stores=[CatalogStore(**v) for v in stores]) -@admin_router.get("/{product_id}", response_model=VendorProductDetail) -def get_vendor_product_detail( +@admin_router.get("/{product_id}", response_model=StoreProductDetail) +def get_store_product_detail( product_id: int, db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), ): - """Get detailed vendor product information including override info.""" - product = vendor_product_service.get_product_detail(db, product_id) - return VendorProductDetail(**product) + """Get detailed store product information including override info.""" + product = store_product_service.get_product_detail(db, product_id) + return StoreProductDetail(**product) -@admin_router.post("", response_model=VendorProductCreateResponse) -def create_vendor_product( - data: VendorProductCreate, +@admin_router.post("", response_model=StoreProductCreateResponse) +def create_store_product( + data: StoreProductCreate, db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), ): - """Create a new vendor product.""" + """Create a new store product.""" # Check product limit before creating - subscription_service.check_product_limit(db, data.vendor_id) + subscription_service.check_product_limit(db, data.store_id) - product = vendor_product_service.create_product(db, data.model_dump()) + product = store_product_service.create_product(db, data.model_dump()) db.commit() - return VendorProductCreateResponse( + return StoreProductCreateResponse( id=product.id, message="Product created successfully" ) -@admin_router.patch("/{product_id}", response_model=VendorProductDetail) -def update_vendor_product( +@admin_router.patch("/{product_id}", response_model=StoreProductDetail) +def update_store_product( product_id: int, - data: VendorProductUpdate, + data: StoreProductUpdate, db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), ): - """Update a vendor product.""" + """Update a store product.""" # Only include fields that were explicitly set update_data = data.model_dump(exclude_unset=True) - vendor_product_service.update_product(db, product_id, update_data) + store_product_service.update_product(db, product_id, update_data) db.commit() # Return the updated product detail - product = vendor_product_service.get_product_detail(db, product_id) - return VendorProductDetail(**product) + product = store_product_service.get_product_detail(db, product_id) + return StoreProductDetail(**product) @admin_router.delete("/{product_id}", response_model=RemoveProductResponse) -def remove_vendor_product( +def remove_store_product( product_id: int, db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), ): - """Remove a product from vendor catalog.""" - result = vendor_product_service.remove_product(db, product_id) + """Remove a product from store catalog.""" + result = store_product_service.remove_product(db, product_id) db.commit() return RemoveProductResponse(**result) diff --git a/app/modules/catalog/routes/api/vendor.py b/app/modules/catalog/routes/api/store.py similarity index 60% rename from app/modules/catalog/routes/api/vendor.py rename to app/modules/catalog/routes/api/store.py index 9961f2d4..1e5a2764 100644 --- a/app/modules/catalog/routes/api/vendor.py +++ b/app/modules/catalog/routes/api/store.py @@ -1,9 +1,9 @@ -# app/modules/catalog/routes/api/vendor.py +# app/modules/catalog/routes/api/store.py """ -Vendor product catalog management endpoints. +Store product catalog management endpoints. -Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern). -The get_current_vendor_api dependency guarantees token_vendor_id is present. +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. All routes require module access control for the 'catalog' module. """ @@ -13,11 +13,11 @@ import logging from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session -from app.api.deps import get_current_vendor_api, require_module_access +from app.api.deps import get_current_store_api, require_module_access from app.core.database import get_db from app.modules.catalog.services.product_service import product_service from app.modules.billing.services.subscription_service import subscription_service -from app.modules.catalog.services.vendor_product_service import vendor_product_service +from app.modules.catalog.services.store_product_service import store_product_service from app.modules.enums import FrontendType from models.schema.auth import UserContext from app.modules.catalog.schemas import ( @@ -28,38 +28,38 @@ from app.modules.catalog.schemas import ( ProductResponse, ProductToggleResponse, ProductUpdate, - VendorDirectProductCreate, - VendorProductCreateResponse, + StoreDirectProductCreate, + StoreProductCreateResponse, ) -vendor_router = APIRouter( +store_router = APIRouter( prefix="/products", - dependencies=[Depends(require_module_access("catalog", FrontendType.VENDOR))], + dependencies=[Depends(require_module_access("catalog", FrontendType.STORE))], ) logger = logging.getLogger(__name__) -@vendor_router.get("", response_model=ProductListResponse) -def get_vendor_products( +@store_router.get("", response_model=ProductListResponse) +def get_store_products( skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=1000), is_active: bool | None = Query(None), is_featured: bool | None = Query(None), - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ - Get all products in vendor catalog. + Get all products in store catalog. Supports filtering by: - is_active: Filter active/inactive products - is_featured: Filter featured products - Vendor is determined from JWT token (vendor_id claim). + Store is determined from JWT token (store_id claim). """ - products, total = product_service.get_vendor_products( + products, total = product_service.get_store_products( db=db, - vendor_id=current_user.token_vendor_id, + store_id=current_user.token_store_id, skip=skip, limit=limit, is_active=is_active, @@ -74,51 +74,51 @@ def get_vendor_products( ) -@vendor_router.get("/{product_id}", response_model=ProductDetailResponse) +@store_router.get("/{product_id}", response_model=ProductDetailResponse) def get_product_details( product_id: int, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """Get detailed product information including inventory.""" product = product_service.get_product( - db=db, vendor_id=current_user.token_vendor_id, product_id=product_id + db=db, store_id=current_user.token_store_id, product_id=product_id ) return ProductDetailResponse.model_validate(product) -@vendor_router.post("", response_model=ProductResponse) +@store_router.post("", response_model=ProductResponse) def add_product_to_catalog( product_data: ProductCreate, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ - Add a product from marketplace to vendor catalog. + Add a product from marketplace to store catalog. - This publishes a MarketplaceProduct to the vendor's public catalog. + This publishes a MarketplaceProduct to the store's public catalog. """ # Check product limit before creating - subscription_service.check_product_limit(db, current_user.token_vendor_id) + subscription_service.check_product_limit(db, current_user.token_store_id) product = product_service.create_product( - db=db, vendor_id=current_user.token_vendor_id, product_data=product_data + db=db, store_id=current_user.token_store_id, product_data=product_data ) db.commit() logger.info( f"Product {product.id} added to catalog by user {current_user.username} " - f"for vendor {current_user.token_vendor_code}" + f"for store {current_user.token_store_code}" ) return ProductResponse.model_validate(product) -@vendor_router.post("/create", response_model=VendorProductCreateResponse) +@store_router.post("/create", response_model=StoreProductCreateResponse) def create_product_direct( - product_data: VendorDirectProductCreate, - current_user: UserContext = Depends(get_current_vendor_api), + product_data: StoreDirectProductCreate, + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ @@ -128,14 +128,14 @@ def create_product_direct( an existing MarketplaceProduct. """ # Check product limit before creating - subscription_service.check_product_limit(db, current_user.token_vendor_id) + subscription_service.check_product_limit(db, current_user.token_store_id) - # Build data dict with vendor_id from token + # Build data dict with store_id from token data = { - "vendor_id": current_user.token_vendor_id, + "store_id": current_user.token_store_id, "title": product_data.title, "brand": product_data.brand, - "vendor_sku": product_data.vendor_sku, + "store_sku": product_data.store_sku, "gtin": product_data.gtin, "price": product_data.price, "currency": product_data.currency, @@ -145,31 +145,31 @@ def create_product_direct( "description": product_data.description, } - product = vendor_product_service.create_product(db=db, data=data) + product = store_product_service.create_product(db=db, data=data) db.commit() logger.info( f"Product {product.id} created by user {current_user.username} " - f"for vendor {current_user.token_vendor_code}" + f"for store {current_user.token_store_code}" ) - return VendorProductCreateResponse( + return StoreProductCreateResponse( id=product.id, message="Product created successfully", ) -@vendor_router.put("/{product_id}", response_model=ProductResponse) +@store_router.put("/{product_id}", response_model=ProductResponse) def update_product( product_id: int, product_data: ProductUpdate, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): - """Update product in vendor catalog.""" + """Update product in store catalog.""" product = product_service.update_product( db=db, - vendor_id=current_user.token_vendor_id, + store_id=current_user.token_store_id, product_id=product_id, product_update=product_data, ) @@ -177,71 +177,71 @@ def update_product( logger.info( f"Product {product_id} updated by user {current_user.username} " - f"for vendor {current_user.token_vendor_code}" + f"for store {current_user.token_store_code}" ) return ProductResponse.model_validate(product) -@vendor_router.delete("/{product_id}", response_model=ProductDeleteResponse) +@store_router.delete("/{product_id}", response_model=ProductDeleteResponse) def remove_product_from_catalog( product_id: int, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): - """Remove product from vendor catalog.""" + """Remove product from store catalog.""" product_service.delete_product( - db=db, vendor_id=current_user.token_vendor_id, product_id=product_id + db=db, store_id=current_user.token_store_id, product_id=product_id ) db.commit() logger.info( f"Product {product_id} removed from catalog by user {current_user.username} " - f"for vendor {current_user.token_vendor_code}" + f"for store {current_user.token_store_code}" ) return ProductDeleteResponse(message=f"Product {product_id} removed from catalog") -@vendor_router.post("/from-import/{marketplace_product_id}", response_model=ProductResponse) +@store_router.post("/from-import/{marketplace_product_id}", response_model=ProductResponse) def publish_from_marketplace( marketplace_product_id: int, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ - Publish a marketplace product to vendor catalog. + Publish a marketplace product to store catalog. Shortcut endpoint for publishing directly from marketplace import. """ # Check product limit before creating - subscription_service.check_product_limit(db, current_user.token_vendor_id) + subscription_service.check_product_limit(db, current_user.token_store_id) product_data = ProductCreate( marketplace_product_id=marketplace_product_id, is_active=True ) product = product_service.create_product( - db=db, vendor_id=current_user.token_vendor_id, product_data=product_data + db=db, store_id=current_user.token_store_id, product_data=product_data ) db.commit() logger.info( f"Marketplace product {marketplace_product_id} published to catalog " - f"by user {current_user.username} for vendor {current_user.token_vendor_code}" + f"by user {current_user.username} for store {current_user.token_store_code}" ) return ProductResponse.model_validate(product) -@vendor_router.put("/{product_id}/toggle-active", response_model=ProductToggleResponse) +@store_router.put("/{product_id}/toggle-active", response_model=ProductToggleResponse) def toggle_product_active( product_id: int, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """Toggle product active status.""" - product = product_service.get_product(db, current_user.token_vendor_id, product_id) + product = product_service.get_product(db, current_user.token_store_id, product_id) product.is_active = not product.is_active db.commit() @@ -249,7 +249,7 @@ def toggle_product_active( status = "activated" if product.is_active else "deactivated" logger.info( - f"Product {product_id} {status} for vendor {current_user.token_vendor_code}" + f"Product {product_id} {status} for store {current_user.token_store_code}" ) return ProductToggleResponse( @@ -257,14 +257,14 @@ def toggle_product_active( ) -@vendor_router.put("/{product_id}/toggle-featured", response_model=ProductToggleResponse) +@store_router.put("/{product_id}/toggle-featured", response_model=ProductToggleResponse) def toggle_product_featured( product_id: int, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """Toggle product featured status.""" - product = product_service.get_product(db, current_user.token_vendor_id, product_id) + product = product_service.get_product(db, current_user.token_store_id, product_id) product.is_featured = not product.is_featured db.commit() @@ -272,7 +272,7 @@ def toggle_product_featured( status = "featured" if product.is_featured else "unfeatured" logger.info( - f"Product {product_id} {status} for vendor {current_user.token_vendor_code}" + f"Product {product_id} {status} for store {current_user.token_store_code}" ) return ProductToggleResponse( diff --git a/app/modules/catalog/routes/api/storefront.py b/app/modules/catalog/routes/api/storefront.py index 19cdbf45..2f142b30 100644 --- a/app/modules/catalog/routes/api/storefront.py +++ b/app/modules/catalog/routes/api/storefront.py @@ -3,10 +3,10 @@ Catalog Module - Storefront API Routes Public endpoints for browsing product catalog in storefront. -Uses vendor from middleware context (VendorContextMiddleware). +Uses store from middleware context (StoreContextMiddleware). No authentication required. -Vendor Context: require_vendor_context() - detects vendor from URL/subdomain/domain +Store Context: require_store_context() - detects store from URL/subdomain/domain """ import logging @@ -21,8 +21,8 @@ from app.modules.catalog.schemas import ( ProductListResponse, ProductResponse, ) -from middleware.vendor_context import require_vendor_context -from app.modules.tenancy.models import Vendor +from middleware.store_context import require_store_context +from app.modules.tenancy.models import Store router = APIRouter() logger = logging.getLogger(__name__) @@ -34,13 +34,13 @@ def get_product_catalog( limit: int = Query(100, ge=1, le=1000), search: str | None = Query(None, description="Search products by name"), is_featured: bool | None = Query(None, description="Filter by featured products"), - vendor: Vendor = Depends(require_vendor_context()), + store: Store = Depends(require_store_context()), db: Session = Depends(get_db), ): """ - Get product catalog for current vendor. + Get product catalog for current store. - Vendor is automatically determined from request context (URL/subdomain/domain). + Store is automatically determined from request context (URL/subdomain/domain). Only returns active products visible to customers. No authentication required. @@ -51,10 +51,10 @@ def get_product_catalog( - is_featured: Filter by featured products only """ logger.debug( - f"[CATALOG_STOREFRONT] get_product_catalog for vendor: {vendor.subdomain}", + f"[CATALOG_STOREFRONT] get_product_catalog for store: {store.subdomain}", extra={ - "vendor_id": vendor.id, - "vendor_code": vendor.subdomain, + "store_id": store.id, + "store_code": store.subdomain, "skip": skip, "limit": limit, "search": search, @@ -65,7 +65,7 @@ def get_product_catalog( # Get only active products for public view products, total = catalog_service.get_catalog_products( db=db, - vendor_id=vendor.id, + store_id=store.id, skip=skip, limit=limit, is_featured=is_featured, @@ -82,13 +82,13 @@ def get_product_catalog( @router.get("/products/{product_id}", response_model=ProductDetailResponse) # public def get_product_details( product_id: int = Path(..., description="Product ID", gt=0), - vendor: Vendor = Depends(require_vendor_context()), + store: Store = Depends(require_store_context()), db: Session = Depends(get_db), ): """ Get detailed product information for customers. - Vendor is automatically determined from request context (URL/subdomain/domain). + Store is automatically determined from request context (URL/subdomain/domain). No authentication required. Path Parameters: @@ -97,14 +97,14 @@ def get_product_details( logger.debug( f"[CATALOG_STOREFRONT] get_product_details for product {product_id}", extra={ - "vendor_id": vendor.id, - "vendor_code": vendor.subdomain, + "store_id": store.id, + "store_code": store.subdomain, "product_id": product_id, }, ) product = catalog_service.get_product( - db=db, vendor_id=vendor.id, product_id=product_id + db=db, store_id=store.id, product_id=product_id ) # Check if product is active @@ -122,14 +122,14 @@ def search_products( q: str = Query(..., min_length=1, description="Search query"), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), - vendor: Vendor = Depends(require_vendor_context()), + store: Store = Depends(require_store_context()), db: Session = Depends(get_db), ): """ - Search products in current vendor's catalog. + Search products in current store's catalog. Searches in product names, descriptions, SKUs, brands, and GTINs. - Vendor is automatically determined from request context (URL/subdomain/domain). + Store is automatically determined from request context (URL/subdomain/domain). No authentication required. Query Parameters: @@ -143,8 +143,8 @@ def search_products( logger.debug( f"[CATALOG_STOREFRONT] search_products: '{q}'", extra={ - "vendor_id": vendor.id, - "vendor_code": vendor.subdomain, + "store_id": store.id, + "store_code": store.subdomain, "query": q, "skip": skip, "limit": limit, @@ -155,7 +155,7 @@ def search_products( # Search products using the service products, total = catalog_service.search_products( db=db, - vendor_id=vendor.id, + store_id=store.id, query=q, skip=skip, limit=limit, diff --git a/app/modules/catalog/routes/pages/admin.py b/app/modules/catalog/routes/pages/admin.py index c032c355..55edbd7b 100644 --- a/app/modules/catalog/routes/pages/admin.py +++ b/app/modules/catalog/routes/pages/admin.py @@ -2,10 +2,10 @@ """ Catalog Admin Page Routes (HTML rendering). -Admin pages for vendor product catalog management: -- Vendor products list -- Vendor product create -- Vendor product detail/edit +Admin pages for store product catalog management: +- Store products list +- Store product create +- Store product detail/edit """ from fastapi import APIRouter, Depends, Path, Request @@ -22,89 +22,89 @@ router = APIRouter() # ============================================================================ -# VENDOR PRODUCT CATALOG ROUTES +# STORE PRODUCT CATALOG ROUTES # ============================================================================ -@router.get("/vendor-products", response_class=HTMLResponse, include_in_schema=False) -async def admin_vendor_products_page( +@router.get("/store-products", response_class=HTMLResponse, include_in_schema=False) +async def admin_store_products_page( request: Request, current_user: User = Depends( - require_menu_access("vendor-products", FrontendType.ADMIN) + require_menu_access("store-products", FrontendType.ADMIN) ), db: Session = Depends(get_db), ): """ - Render vendor products catalog page. - Browse vendor-specific product catalogs with override capability. + Render store products catalog page. + Browse store-specific product catalogs with override capability. """ return templates.TemplateResponse( - "catalog/admin/vendor-products.html", + "catalog/admin/store-products.html", get_admin_context(request, db, current_user), ) @router.get( - "/vendor-products/create", response_class=HTMLResponse, include_in_schema=False + "/store-products/create", response_class=HTMLResponse, include_in_schema=False ) -async def admin_vendor_product_create_page( +async def admin_store_product_create_page( request: Request, current_user: User = Depends( - require_menu_access("vendor-products", FrontendType.ADMIN) + require_menu_access("store-products", FrontendType.ADMIN) ), db: Session = Depends(get_db), ): """ - Render vendor product create page. - Create a new vendor product entry. + Render store product create page. + Create a new store product entry. """ return templates.TemplateResponse( - "catalog/admin/vendor-product-create.html", + "catalog/admin/store-product-create.html", get_admin_context(request, db, current_user), ) @router.get( - "/vendor-products/{product_id}", + "/store-products/{product_id}", response_class=HTMLResponse, include_in_schema=False, ) -async def admin_vendor_product_detail_page( +async def admin_store_product_detail_page( request: Request, - product_id: int = Path(..., description="Vendor Product ID"), + product_id: int = Path(..., description="Store Product ID"), current_user: User = Depends( - require_menu_access("vendor-products", FrontendType.ADMIN) + require_menu_access("store-products", FrontendType.ADMIN) ), db: Session = Depends(get_db), ): """ - Render vendor product detail page. - Shows full product information with vendor-specific overrides. + Render store product detail page. + Shows full product information with store-specific overrides. """ return templates.TemplateResponse( - "catalog/admin/vendor-product-detail.html", + "catalog/admin/store-product-detail.html", get_admin_context(request, db, current_user, product_id=product_id), ) @router.get( - "/vendor-products/{product_id}/edit", + "/store-products/{product_id}/edit", response_class=HTMLResponse, include_in_schema=False, ) -async def admin_vendor_product_edit_page( +async def admin_store_product_edit_page( request: Request, - product_id: int = Path(..., description="Vendor Product ID"), + product_id: int = Path(..., description="Store Product ID"), current_user: User = Depends( - require_menu_access("vendor-products", FrontendType.ADMIN) + require_menu_access("store-products", FrontendType.ADMIN) ), db: Session = Depends(get_db), ): """ - Render vendor product edit page. - Edit vendor product information and overrides. + Render store product edit page. + Edit store product information and overrides. """ return templates.TemplateResponse( - "catalog/admin/vendor-product-edit.html", + "catalog/admin/store-product-edit.html", get_admin_context(request, db, current_user, product_id=product_id), ) diff --git a/app/modules/catalog/routes/pages/vendor.py b/app/modules/catalog/routes/pages/store.py similarity index 50% rename from app/modules/catalog/routes/pages/vendor.py rename to app/modules/catalog/routes/pages/store.py index 48ef6375..c04a07f5 100644 --- a/app/modules/catalog/routes/pages/vendor.py +++ b/app/modules/catalog/routes/pages/store.py @@ -1,8 +1,8 @@ -# app/modules/catalog/routes/pages/vendor.py +# app/modules/catalog/routes/pages/store.py """ -Catalog Vendor Page Routes (HTML rendering). +Catalog Store Page Routes (HTML rendering). -Vendor pages for product management: +Store pages for product management: - Products list - Product create """ @@ -11,8 +11,8 @@ 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.modules.core.utils.page_context import get_vendor_context +from app.api.deps import get_current_store_from_cookie_or_header, get_db +from app.modules.core.utils.page_context import get_store_context from app.templates_config import templates from app.modules.tenancy.models import User @@ -25,12 +25,12 @@ router = APIRouter() @router.get( - "/{vendor_code}/products", response_class=HTMLResponse, include_in_schema=False + "/{store_code}/products", response_class=HTMLResponse, include_in_schema=False ) -async def vendor_products_page( +async def store_products_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), ): """ @@ -38,20 +38,20 @@ async def vendor_products_page( JavaScript loads product list via API. """ return templates.TemplateResponse( - "catalog/vendor/products.html", - get_vendor_context(request, db, current_user, vendor_code), + "catalog/store/products.html", + get_store_context(request, db, current_user, store_code), ) @router.get( - "/{vendor_code}/products/create", + "/{store_code}/products/create", response_class=HTMLResponse, include_in_schema=False, ) -async def vendor_product_create_page( +async def store_product_create_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), ): """ @@ -59,6 +59,6 @@ async def vendor_product_create_page( JavaScript handles form submission via API. """ return templates.TemplateResponse( - "catalog/vendor/product-create.html", - get_vendor_context(request, db, current_user, vendor_code), + "catalog/store/product-create.html", + get_store_context(request, db, current_user, store_code), ) diff --git a/app/modules/catalog/routes/pages/storefront.py b/app/modules/catalog/routes/pages/storefront.py index c26ac56a..f3ca83e4 100644 --- a/app/modules/catalog/routes/pages/storefront.py +++ b/app/modules/catalog/routes/pages/storefront.py @@ -41,7 +41,7 @@ async def shop_products_page(request: Request, db: Session = Depends(get_db)): "[STOREFRONT] shop_products_page REACHED", extra={ "path": request.url.path, - "vendor": getattr(request.state, "vendor", "NOT SET"), + "store": getattr(request.state, "store", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) @@ -68,7 +68,7 @@ async def shop_product_detail_page( extra={ "path": request.url.path, "product_id": product_id, - "vendor": getattr(request.state, "vendor", "NOT SET"), + "store": getattr(request.state, "store", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) @@ -98,7 +98,7 @@ async def shop_category_page( extra={ "path": request.url.path, "category_slug": category_slug, - "vendor": getattr(request.state, "vendor", "NOT SET"), + "store": getattr(request.state, "store", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) @@ -119,7 +119,7 @@ async def shop_search_page(request: Request, db: Session = Depends(get_db)): "[STOREFRONT] shop_search_page REACHED", extra={ "path": request.url.path, - "vendor": getattr(request.state, "vendor", "NOT SET"), + "store": getattr(request.state, "store", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) @@ -160,7 +160,7 @@ async def shop_wishlist_page( "[STOREFRONT] shop_wishlist_page REACHED", extra={ "path": request.url.path, - "vendor": getattr(request.state, "vendor", "NOT SET"), + "store": getattr(request.state, "store", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) diff --git a/app/modules/catalog/schemas/__init__.py b/app/modules/catalog/schemas/__init__.py index a0deaf8b..ab644bf5 100644 --- a/app/modules/catalog/schemas/__init__.py +++ b/app/modules/catalog/schemas/__init__.py @@ -15,21 +15,21 @@ from app.modules.catalog.schemas.product import ( ProductDeleteResponse, ProductToggleResponse, ) -from app.modules.catalog.schemas.vendor_product import ( +from app.modules.catalog.schemas.store_product import ( # List/Detail schemas - VendorProductListItem, - VendorProductListResponse, - VendorProductStats, - VendorProductDetail, - # Catalog vendor schemas - CatalogVendor, - CatalogVendorsResponse, + StoreProductListItem, + StoreProductListResponse, + StoreProductStats, + StoreProductDetail, + # Catalog store schemas + CatalogStore, + CatalogStoresResponse, # CRUD schemas TranslationUpdate, - VendorProductCreate, - VendorDirectProductCreate, - VendorProductUpdate, - VendorProductCreateResponse, + StoreProductCreate, + StoreDirectProductCreate, + StoreProductUpdate, + StoreProductCreateResponse, RemoveProductResponse, ) @@ -38,7 +38,7 @@ __all__ = [ "CatalogProductResponse", "CatalogProductDetailResponse", "CatalogProductListResponse", - # Product CRUD schemas (vendor management) + # Product CRUD schemas (store management) "ProductCreate", "ProductUpdate", "ProductResponse", @@ -46,17 +46,17 @@ __all__ = [ "ProductListResponse", "ProductDeleteResponse", "ProductToggleResponse", - # Vendor Product schemas (admin) - "VendorProductListItem", - "VendorProductListResponse", - "VendorProductStats", - "VendorProductDetail", - "CatalogVendor", - "CatalogVendorsResponse", + # Store Product schemas (admin) + "StoreProductListItem", + "StoreProductListResponse", + "StoreProductStats", + "StoreProductDetail", + "CatalogStore", + "CatalogStoresResponse", "TranslationUpdate", - "VendorProductCreate", - "VendorDirectProductCreate", - "VendorProductUpdate", - "VendorProductCreateResponse", + "StoreProductCreate", + "StoreDirectProductCreate", + "StoreProductUpdate", + "StoreProductCreateResponse", "RemoveProductResponse", ] diff --git a/app/modules/catalog/schemas/catalog.py b/app/modules/catalog/schemas/catalog.py index 8c701c86..70161c04 100644 --- a/app/modules/catalog/schemas/catalog.py +++ b/app/modules/catalog/schemas/catalog.py @@ -3,7 +3,7 @@ Pydantic schemas for catalog browsing operations. These schemas are for the public storefront catalog API. -For vendor product management, see the products module. +For store product management, see the products module. """ from datetime import datetime @@ -20,9 +20,9 @@ class ProductResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: int - vendor_id: int + store_id: int marketplace_product: MarketplaceProductResponse - vendor_sku: str | None + store_sku: str | None price: float | None sale_price: float | None currency: str | None diff --git a/app/modules/catalog/schemas/product.py b/app/modules/catalog/schemas/product.py index a1540586..2df8a782 100644 --- a/app/modules/catalog/schemas/product.py +++ b/app/modules/catalog/schemas/product.py @@ -2,8 +2,8 @@ """ Pydantic schemas for Product CRUD operations. -These schemas are used for vendor product catalog management, -linking vendor products to marketplace products. +These schemas are used for store product catalog management, +linking store products to marketplace products. """ from datetime import datetime @@ -16,9 +16,9 @@ from app.modules.marketplace.schemas import MarketplaceProductResponse class ProductCreate(BaseModel): marketplace_product_id: int = Field( - ..., description="MarketplaceProduct ID to add to vendor catalog" + ..., description="MarketplaceProduct ID to add to store catalog" ) - vendor_sku: str | None = Field(None, description="Vendor's internal SKU") + store_sku: str | None = Field(None, description="Store's internal SKU") price: float | None = Field(None, ge=0) sale_price: float | None = Field(None, ge=0) currency: str | None = None @@ -30,7 +30,7 @@ class ProductCreate(BaseModel): class ProductUpdate(BaseModel): - vendor_sku: str | None = None + store_sku: str | None = None price: float | None = Field(None, ge=0) sale_price: float | None = Field(None, ge=0) currency: str | None = None @@ -46,9 +46,9 @@ class ProductResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: int - vendor_id: int + store_id: int marketplace_product: MarketplaceProductResponse - vendor_sku: str | None + store_sku: str | None price: float | None sale_price: float | None currency: str | None diff --git a/app/modules/catalog/schemas/vendor_product.py b/app/modules/catalog/schemas/store_product.py similarity index 75% rename from app/modules/catalog/schemas/vendor_product.py rename to app/modules/catalog/schemas/store_product.py index ffc05084..3d792b5e 100644 --- a/app/modules/catalog/schemas/vendor_product.py +++ b/app/modules/catalog/schemas/store_product.py @@ -1,28 +1,28 @@ -# app/modules/catalog/schemas/vendor_product.py +# app/modules/catalog/schemas/store_product.py """ -Pydantic schemas for vendor product catalog operations. +Pydantic schemas for store product catalog operations. -Used by admin vendor product endpoints for: +Used by admin store product endpoints for: - Product listing and filtering - Product statistics - Product detail views -- Catalog vendor listings +- Catalog store listings """ from pydantic import BaseModel, ConfigDict -class VendorProductListItem(BaseModel): - """Product item for vendor catalog list view.""" +class StoreProductListItem(BaseModel): + """Product item for store catalog list view.""" model_config = ConfigDict(from_attributes=True) id: int - vendor_id: int - vendor_name: str | None = None - vendor_code: str | None = None + store_id: int + store_name: str | None = None + store_code: str | None = None marketplace_product_id: int | None = None - vendor_sku: str | None = None + store_sku: str | None = None title: str | None = None brand: str | None = None price: float | None = None @@ -34,22 +34,22 @@ class VendorProductListItem(BaseModel): is_digital: bool | None = None image_url: str | None = None source_marketplace: str | None = None - source_vendor: str | None = None + source_store: str | None = None created_at: str | None = None updated_at: str | None = None -class VendorProductListResponse(BaseModel): - """Paginated vendor product list response.""" +class StoreProductListResponse(BaseModel): + """Paginated store product list response.""" - products: list[VendorProductListItem] + products: list[StoreProductListItem] total: int skip: int limit: int -class VendorProductStats(BaseModel): - """Vendor product statistics.""" +class StoreProductStats(BaseModel): + """Store product statistics.""" total: int active: int @@ -57,36 +57,36 @@ class VendorProductStats(BaseModel): featured: int digital: int physical: int - by_vendor: dict[str, int] + by_store: dict[str, int] -class CatalogVendor(BaseModel): - """Vendor with products in catalog.""" +class CatalogStore(BaseModel): + """Store with products in catalog.""" id: int name: str - vendor_code: str + store_code: str -class CatalogVendorsResponse(BaseModel): - """Response for catalog vendors list.""" +class CatalogStoresResponse(BaseModel): + """Response for catalog stores list.""" - vendors: list[CatalogVendor] + stores: list[CatalogStore] -class VendorProductDetail(BaseModel): - """Detailed vendor product information. +class StoreProductDetail(BaseModel): + """Detailed store product information. Products are independent entities - all fields are populated at creation. Source values are kept for "view original source" comparison only. """ id: int - vendor_id: int - vendor_name: str | None = None - vendor_code: str | None = None + store_id: int + store_name: str | None = None + store_code: str | None = None marketplace_product_id: int | None = None # Optional for direct product creation - vendor_sku: str | None = None + store_sku: str | None = None # Product identifiers gtin: str | None = None gtin_type: str | None = None # ean13, ean8, upc, isbn, etc. @@ -110,7 +110,7 @@ class VendorProductDetail(BaseModel): additional_images: list[str] | None = None is_digital: bool | None = None product_type: str | None = None - # Vendor-specific fields + # Store-specific fields is_featured: bool | None = None is_active: bool | None = None display_order: int | None = None @@ -119,7 +119,7 @@ class VendorProductDetail(BaseModel): # Supplier tracking supplier: str | None = None supplier_product_id: str | None = None - cost: float | None = None # What vendor pays to acquire product + cost: float | None = None # What store pays to acquire product margin_percent: float | None = None # Tax/profit info tax_rate_percent: int | None = None @@ -133,12 +133,12 @@ class VendorProductDetail(BaseModel): fulfillment_email_template: str | None = None # Source info source_marketplace: str | None = None - source_vendor: str | None = None + source_store: str | None = None source_gtin: str | None = None source_sku: str | None = None # Translations marketplace_translations: dict | None = None - vendor_translations: dict | None = None + store_translations: dict | None = None # Convenience fields for UI display title: str | None = None description: str | None = None @@ -161,17 +161,17 @@ class TranslationUpdate(BaseModel): description: str | None = None -class VendorProductCreate(BaseModel): - """Schema for creating a vendor product (admin use - includes vendor_id).""" +class StoreProductCreate(BaseModel): + """Schema for creating a store product (admin use - includes store_id).""" - vendor_id: int + store_id: int # Translations by language code (en, fr, de, lu) translations: dict[str, TranslationUpdate] | None = None # Product identifiers brand: str | None = None - vendor_sku: str | None = None + store_sku: str | None = None gtin: str | None = None gtin_type: str | None = None # ean13, ean8, upc, isbn @@ -192,12 +192,12 @@ class VendorProductCreate(BaseModel): is_digital: bool = False -class VendorDirectProductCreate(BaseModel): - """Schema for vendor direct product creation (vendor_id from JWT token).""" +class StoreDirectProductCreate(BaseModel): + """Schema for store direct product creation (store_id from JWT token).""" title: str brand: str | None = None - vendor_sku: str | None = None + store_sku: str | None = None gtin: str | None = None price: float | None = None currency: str = "EUR" @@ -207,15 +207,15 @@ class VendorDirectProductCreate(BaseModel): description: str | None = None -class VendorProductUpdate(BaseModel): - """Schema for updating a vendor product.""" +class StoreProductUpdate(BaseModel): + """Schema for updating a store product.""" # Translations by language code (en, fr, de, lu) translations: dict[str, TranslationUpdate] | None = None # Product identifiers brand: str | None = None - vendor_sku: str | None = None + store_sku: str | None = None gtin: str | None = None gtin_type: str | None = None # ean13, ean8, upc, isbn, etc. @@ -240,7 +240,7 @@ class VendorProductUpdate(BaseModel): cost: float | None = None # Cost in euros -class VendorProductCreateResponse(BaseModel): +class StoreProductCreateResponse(BaseModel): """Response from product creation.""" id: int diff --git a/app/modules/catalog/services/__init__.py b/app/modules/catalog/services/__init__.py index 533c2273..56eee3bc 100644 --- a/app/modules/catalog/services/__init__.py +++ b/app/modules/catalog/services/__init__.py @@ -3,15 +3,15 @@ from app.modules.catalog.services.catalog_service import catalog_service from app.modules.catalog.services.product_service import ProductService, product_service -from app.modules.catalog.services.vendor_product_service import ( - VendorProductService, - vendor_product_service, +from app.modules.catalog.services.store_product_service import ( + StoreProductService, + store_product_service, ) __all__ = [ "catalog_service", "ProductService", "product_service", - "VendorProductService", - "vendor_product_service", + "StoreProductService", + "store_product_service", ] diff --git a/app/modules/catalog/services/catalog_features.py b/app/modules/catalog/services/catalog_features.py new file mode 100644 index 00000000..d366f2fa --- /dev/null +++ b/app/modules/catalog/services/catalog_features.py @@ -0,0 +1,121 @@ +# app/modules/catalog/services/catalog_features.py +""" +Catalog feature provider for the billing feature system. + +Declares catalog-related billable features (product limits, import/export) +and provides usage tracking queries for feature gating. +""" + +from __future__ import annotations + +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 + + +class CatalogFeatureProvider: + """Feature provider for the catalog module. + + Declares: + - products_limit: quantitative per-store limit on product count + - product_import_export: binary merchant-level feature for import/export + """ + + @property + def feature_category(self) -> str: + return "catalog" + + def get_feature_declarations(self) -> list[FeatureDeclaration]: + return [ + FeatureDeclaration( + code="products_limit", + name_key="catalog.features.products_limit.name", + description_key="catalog.features.products_limit.description", + category="catalog", + feature_type=FeatureType.QUANTITATIVE, + scope=FeatureScope.STORE, + default_limit=200, + unit_key="catalog.features.products_limit.unit", + ui_icon="package", + display_order=10, + ), + FeatureDeclaration( + code="product_import_export", + name_key="catalog.features.product_import_export.name", + description_key="catalog.features.product_import_export.description", + category="catalog", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="upload-download", + display_order=20, + ), + ] + + def get_store_usage( + self, + db: Session, + store_id: int, + ) -> list[FeatureUsage]: + from app.modules.catalog.models.product import Product + + count = ( + db.query(func.count(Product.id)) + .filter(Product.store_id == store_id) + .scalar() + or 0 + ) + return [ + FeatureUsage( + feature_code="products_limit", + current_count=count, + label="Products", + ), + ] + + def get_merchant_usage( + self, + db: Session, + merchant_id: int, + platform_id: int, + ) -> list[FeatureUsage]: + from app.modules.catalog.models.product import Product + from app.modules.tenancy.models import Store, StorePlatform + + count = ( + db.query(func.count(Product.id)) + .join(Store, Product.store_id == Store.id) + .join(StorePlatform, Store.id == StorePlatform.store_id) + .filter( + Store.merchant_id == merchant_id, + StorePlatform.platform_id == platform_id, + ) + .scalar() + or 0 + ) + return [ + FeatureUsage( + feature_code="products_limit", + current_count=count, + label="Products", + ), + ] + + +# Singleton instance for module registration +catalog_feature_provider = CatalogFeatureProvider() + +__all__ = [ + "CatalogFeatureProvider", + "catalog_feature_provider", +] diff --git a/app/modules/catalog/services/catalog_metrics.py b/app/modules/catalog/services/catalog_metrics.py index b5cc05b2..ab947d5b 100644 --- a/app/modules/catalog/services/catalog_metrics.py +++ b/app/modules/catalog/services/catalog_metrics.py @@ -31,21 +31,21 @@ class CatalogMetricsProvider: """ Metrics provider for catalog module. - Provides product-related metrics for vendor and platform dashboards. + Provides product-related metrics for store and platform dashboards. """ @property def metrics_category(self) -> str: return "catalog" - def get_vendor_metrics( + def get_store_metrics( self, db: Session, - vendor_id: int, + store_id: int, context: MetricsContext | None = None, ) -> list[MetricValue]: """ - Get product metrics for a specific vendor. + Get product metrics for a specific store. Provides: - Total products @@ -58,13 +58,13 @@ class CatalogMetricsProvider: try: # Total products total_products = ( - db.query(Product).filter(Product.vendor_id == vendor_id).count() + db.query(Product).filter(Product.store_id == store_id).count() ) # Active products active_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() ) @@ -72,7 +72,7 @@ class CatalogMetricsProvider: featured_products = ( db.query(Product) .filter( - Product.vendor_id == vendor_id, + Product.store_id == store_id, Product.is_featured == True, Product.is_active == True, ) @@ -85,7 +85,7 @@ class CatalogMetricsProvider: date_from = datetime.utcnow() - timedelta(days=30) new_products_query = db.query(Product).filter( - Product.vendor_id == vendor_id, + Product.store_id == store_id, Product.created_at >= date_from, ) if context and context.date_to: @@ -97,7 +97,7 @@ class CatalogMetricsProvider: # Products with translations products_with_translations = ( db.query(func.count(func.distinct(Product.id))) - .filter(Product.vendor_id == vendor_id) + .filter(Product.store_id == store_id) .join(Product.translations) .scalar() or 0 @@ -138,7 +138,7 @@ class CatalogMetricsProvider: ), ] except Exception as e: - logger.warning(f"Failed to get catalog vendor metrics: {e}") + logger.warning(f"Failed to get catalog store metrics: {e}") return [] def get_platform_metrics( @@ -150,31 +150,31 @@ class CatalogMetricsProvider: """ Get product metrics aggregated for a platform. - Aggregates catalog data across all vendors. + Aggregates catalog data across all stores. """ from app.modules.catalog.models import Product - from app.modules.tenancy.models import VendorPlatform + from app.modules.tenancy.models import StorePlatform try: - # Get all vendor IDs for this platform using VendorPlatform junction table - vendor_ids = ( - db.query(VendorPlatform.vendor_id) + # Get all store IDs for this platform using StorePlatform junction table + store_ids = ( + db.query(StorePlatform.store_id) .filter( - VendorPlatform.platform_id == platform_id, - VendorPlatform.is_active == True, + StorePlatform.platform_id == platform_id, + StorePlatform.is_active == True, ) .subquery() ) # Total products total_products = ( - db.query(Product).filter(Product.vendor_id.in_(vendor_ids)).count() + db.query(Product).filter(Product.store_id.in_(store_ids)).count() ) # Active products active_products = ( db.query(Product) - .filter(Product.vendor_id.in_(vendor_ids), Product.is_active == True) + .filter(Product.store_id.in_(store_ids), Product.is_active == True) .count() ) @@ -182,31 +182,31 @@ class CatalogMetricsProvider: featured_products = ( db.query(Product) .filter( - Product.vendor_id.in_(vendor_ids), + Product.store_id.in_(store_ids), Product.is_featured == True, Product.is_active == True, ) .count() ) - # Vendors with products - vendors_with_products = ( - db.query(func.count(func.distinct(Product.vendor_id))) - .filter(Product.vendor_id.in_(vendor_ids)) + # Stores with products + stores_with_products = ( + db.query(func.count(func.distinct(Product.store_id))) + .filter(Product.store_id.in_(store_ids)) .scalar() or 0 ) - # Average products per vendor - total_vendors = ( - db.query(VendorPlatform) + # Average products per store + total_stores = ( + db.query(StorePlatform) .filter( - VendorPlatform.platform_id == platform_id, - VendorPlatform.is_active == True, + StorePlatform.platform_id == platform_id, + StorePlatform.is_active == True, ) .count() ) - avg_products = round(total_products / total_vendors, 1) if total_vendors > 0 else 0 + avg_products = round(total_products / total_stores, 1) if total_stores > 0 else 0 return [ MetricValue( @@ -215,7 +215,7 @@ class CatalogMetricsProvider: label="Total Products", category="catalog", icon="box", - description="Total products across all vendors", + description="Total products across all stores", ), MetricValue( key="catalog.active_products", @@ -234,20 +234,20 @@ class CatalogMetricsProvider: description="Products marked as featured", ), MetricValue( - key="catalog.vendors_with_products", - value=vendors_with_products, - label="Vendors with Products", + key="catalog.stores_with_products", + value=stores_with_products, + label="Stores with Products", category="catalog", icon="store", - description="Vendors that have created products", + description="Stores that have created products", ), MetricValue( - key="catalog.avg_products_per_vendor", + key="catalog.avg_products_per_store", value=avg_products, - label="Avg Products/Vendor", + label="Avg Products/Store", category="catalog", icon="calculator", - description="Average products per vendor", + description="Average products per store", ), ] except Exception as e: diff --git a/app/modules/catalog/services/catalog_service.py b/app/modules/catalog/services/catalog_service.py index e43a53ad..553fc681 100644 --- a/app/modules/catalog/services/catalog_service.py +++ b/app/modules/catalog/services/catalog_service.py @@ -8,7 +8,7 @@ This module provides: - Product detail retrieval Note: This is distinct from the product_service which handles -vendor product management. The catalog service is for public +store product management. The catalog service is for public storefront operations only. """ @@ -27,13 +27,13 @@ logger = logging.getLogger(__name__) class CatalogService: """Service for public catalog browsing operations.""" - def get_product(self, db: Session, vendor_id: int, product_id: int) -> Product: + def get_product(self, db: Session, store_id: int, product_id: int) -> Product: """ - Get a product from vendor catalog. + Get a product from store catalog. Args: db: Database session - vendor_id: Vendor ID + store_id: Store ID product_id: Product ID Returns: @@ -44,7 +44,7 @@ class CatalogService: """ product = ( db.query(Product) - .filter(Product.id == product_id, Product.vendor_id == vendor_id) + .filter(Product.id == product_id, Product.store_id == store_id) .first() ) @@ -56,19 +56,19 @@ class CatalogService: def get_catalog_products( self, db: Session, - vendor_id: int, + store_id: int, skip: int = 0, limit: int = 100, is_featured: bool | None = None, ) -> tuple[list[Product], int]: """ - Get products in vendor catalog for public display. + Get products in store catalog for public display. Only returns active products visible to customers. Args: db: Database session - vendor_id: Vendor ID + store_id: Store ID skip: Pagination offset limit: Pagination limit is_featured: Filter by featured status @@ -79,7 +79,7 @@ class CatalogService: try: # Always filter for active products only query = db.query(Product).filter( - Product.vendor_id == vendor_id, + Product.store_id == store_id, Product.is_active == True, ) @@ -98,14 +98,14 @@ class CatalogService: def search_products( self, db: Session, - vendor_id: int, + store_id: int, query: str, skip: int = 0, limit: int = 50, language: str = "en", ) -> tuple[list[Product], int]: """ - Search products in vendor catalog. + Search products in store catalog. Searches across: - Product title and description (from translations) @@ -113,7 +113,7 @@ class CatalogService: Args: db: Database session - vendor_id: Vendor ID + store_id: Store ID query: Search query string skip: Pagination offset limit: Pagination limit @@ -135,7 +135,7 @@ class CatalogService: & (ProductTranslation.language == language), ) .filter( - Product.vendor_id == vendor_id, + Product.store_id == store_id, Product.is_active == True, ) .filter( @@ -145,7 +145,7 @@ class CatalogService: ProductTranslation.description.ilike(search_pattern), ProductTranslation.short_description.ilike(search_pattern), # Search in product fields - Product.vendor_sku.ilike(search_pattern), + Product.store_sku.ilike(search_pattern), Product.brand.ilike(search_pattern), Product.gtin.ilike(search_pattern), ) @@ -170,7 +170,7 @@ class CatalogService: ) logger.debug( - f"Search '{query}' for vendor {vendor_id}: {total} results" + f"Search '{query}' for store {store_id}: {total} results" ) return products, total diff --git a/app/modules/catalog/services/product_media_service.py b/app/modules/catalog/services/product_media_service.py index 561f589e..577760c3 100644 --- a/app/modules/catalog/services/product_media_service.py +++ b/app/modules/catalog/services/product_media_service.py @@ -27,7 +27,7 @@ class ProductMediaService: def attach_media_to_product( self, db: Session, - vendor_id: int, + store_id: int, product_id: int, media_id: int, usage_type: str = "gallery", @@ -38,7 +38,7 @@ class ProductMediaService: Args: db: Database session - vendor_id: Vendor ID (for ownership verification) + store_id: Store ID (for ownership verification) product_id: Product ID media_id: Media file ID usage_type: How the media is used (main_image, gallery, etc.) @@ -48,25 +48,25 @@ class ProductMediaService: Created or updated ProductMedia association Raises: - ValueError: If product or media doesn't belong to vendor + ValueError: If product or media doesn't belong to store """ - # Verify product belongs to vendor + # Verify product belongs to store product = ( db.query(Product) - .filter(Product.id == product_id, Product.vendor_id == vendor_id) + .filter(Product.id == product_id, Product.store_id == store_id) .first() ) if not product: - raise ValueError(f"Product {product_id} not found for vendor {vendor_id}") + raise ValueError(f"Product {product_id} not found for store {store_id}") - # Verify media belongs to vendor + # Verify media belongs to store media = ( db.query(MediaFile) - .filter(MediaFile.id == media_id, MediaFile.vendor_id == vendor_id) + .filter(MediaFile.id == media_id, MediaFile.store_id == store_id) .first() ) if not media: - raise ValueError(f"Media {media_id} not found for vendor {vendor_id}") + raise ValueError(f"Media {media_id} not found for store {store_id}") # Check if already attached with same usage type existing = ( @@ -109,7 +109,7 @@ class ProductMediaService: def detach_media_from_product( self, db: Session, - vendor_id: int, + store_id: int, product_id: int, media_id: int, usage_type: str | None = None, @@ -119,7 +119,7 @@ class ProductMediaService: Args: db: Database session - vendor_id: Vendor ID (for ownership verification) + store_id: Store ID (for ownership verification) product_id: Product ID media_id: Media file ID usage_type: Specific usage type to remove (None = all usages) @@ -128,16 +128,16 @@ class ProductMediaService: Number of associations removed Raises: - ValueError: If product doesn't belong to vendor + ValueError: If product doesn't belong to store """ - # Verify product belongs to vendor + # Verify product belongs to store product = ( db.query(Product) - .filter(Product.id == product_id, Product.vendor_id == vendor_id) + .filter(Product.id == product_id, Product.store_id == store_id) .first() ) if not product: - raise ValueError(f"Product {product_id} not found for vendor {vendor_id}") + raise ValueError(f"Product {product_id} not found for store {store_id}") # Build query query = db.query(ProductMedia).filter( @@ -234,7 +234,7 @@ class ProductMediaService: def set_main_image( self, db: Session, - vendor_id: int, + store_id: int, product_id: int, media_id: int, ) -> ProductMedia | None: @@ -245,7 +245,7 @@ class ProductMediaService: Args: db: Database session - vendor_id: Vendor ID + store_id: Store ID product_id: Product ID media_id: Media file ID to set as main image @@ -254,7 +254,7 @@ class ProductMediaService: """ # Remove existing main image self.detach_media_from_product( - db, vendor_id, product_id, media_id=0, usage_type="main_image" + db, store_id, product_id, media_id=0, usage_type="main_image" ) # Actually, we need to remove ALL main_image associations, not just for media_id=0 @@ -266,7 +266,7 @@ class ProductMediaService: # Attach new main image return self.attach_media_to_product( db, - vendor_id=vendor_id, + store_id=store_id, product_id=product_id, media_id=media_id, usage_type="main_image", diff --git a/app/modules/catalog/services/product_service.py b/app/modules/catalog/services/product_service.py index 99ec233a..e3f7555d 100644 --- a/app/modules/catalog/services/product_service.py +++ b/app/modules/catalog/services/product_service.py @@ -1,6 +1,6 @@ # app/modules/catalog/services/product_service.py """ -Product service for vendor catalog management. +Product service for store catalog management. This module provides: - Product catalog CRUD operations @@ -26,15 +26,15 @@ logger = logging.getLogger(__name__) class ProductService: - """Service for vendor catalog product operations.""" + """Service for store catalog product operations.""" - def get_product(self, db: Session, vendor_id: int, product_id: int) -> Product: + def get_product(self, db: Session, store_id: int, product_id: int) -> Product: """ - Get a product from vendor catalog. + Get a product from store catalog. Args: db: Database session - vendor_id: Vendor ID + store_id: Store ID product_id: Product ID Returns: @@ -46,7 +46,7 @@ class ProductService: try: product = ( db.query(Product) - .filter(Product.id == product_id, Product.vendor_id == vendor_id) + .filter(Product.id == product_id, Product.store_id == store_id) .first() ) @@ -62,14 +62,14 @@ class ProductService: raise ValidationException("Failed to retrieve product") def create_product( - self, db: Session, vendor_id: int, product_data: ProductCreate + self, db: Session, store_id: int, product_data: ProductCreate ) -> Product: """ - Add a product from marketplace to vendor catalog. + Add a product from marketplace to store catalog. Args: db: Database session - vendor_id: Vendor ID + store_id: Store ID product_data: Product creation data Returns: @@ -96,7 +96,7 @@ class ProductService: existing = ( db.query(Product) .filter( - Product.vendor_id == vendor_id, + Product.store_id == store_id, Product.marketplace_product_id == product_data.marketplace_product_id, ) @@ -108,9 +108,9 @@ class ProductService: # Create product product = Product( - vendor_id=vendor_id, + store_id=store_id, marketplace_product_id=product_data.marketplace_product_id, - vendor_sku=product_data.vendor_sku, + store_sku=product_data.store_sku, price=product_data.price, sale_price=product_data.sale_price, currency=product_data.currency, @@ -126,7 +126,7 @@ class ProductService: db.flush() db.refresh(product) - logger.info(f"Added product {product.id} to vendor {vendor_id} catalog") + logger.info(f"Added product {product.id} to store {store_id} catalog") return product except (ProductAlreadyExistsException, ValidationException): @@ -138,16 +138,16 @@ class ProductService: def update_product( self, db: Session, - vendor_id: int, + store_id: int, product_id: int, product_update: ProductUpdate, ) -> Product: """ - Update product in vendor catalog. + Update product in store catalog. Args: db: Database session - vendor_id: Vendor ID + store_id: Store ID product_id: Product ID product_update: Update data @@ -155,7 +155,7 @@ class ProductService: Updated Product object """ try: - product = self.get_product(db, vendor_id, product_id) + product = self.get_product(db, store_id, product_id) # Update fields update_data = product_update.model_dump(exclude_unset=True) @@ -166,7 +166,7 @@ class ProductService: db.flush() db.refresh(product) - logger.info(f"Updated product {product_id} in vendor {vendor_id} catalog") + logger.info(f"Updated product {product_id} in store {store_id} catalog") return product except ProductNotFoundException: @@ -175,24 +175,24 @@ class ProductService: logger.error(f"Error updating product: {str(e)}") raise ValidationException("Failed to update product") - def delete_product(self, db: Session, vendor_id: int, product_id: int) -> bool: + def delete_product(self, db: Session, store_id: int, product_id: int) -> bool: """ - Remove product from vendor catalog. + Remove product from store catalog. Args: db: Database session - vendor_id: Vendor ID + store_id: Store ID product_id: Product ID Returns: True if deleted """ try: - product = self.get_product(db, vendor_id, product_id) + product = self.get_product(db, store_id, product_id) db.delete(product) - logger.info(f"Deleted product {product_id} from vendor {vendor_id} catalog") + logger.info(f"Deleted product {product_id} from store {store_id} catalog") return True except ProductNotFoundException: @@ -201,21 +201,21 @@ class ProductService: logger.error(f"Error deleting product: {str(e)}") raise ValidationException("Failed to delete product") - def get_vendor_products( + def get_store_products( self, db: Session, - vendor_id: int, + store_id: int, skip: int = 0, limit: int = 100, is_active: bool | None = None, is_featured: bool | None = None, ) -> tuple[list[Product], int]: """ - Get products in vendor catalog with filtering. + Get products in store catalog with filtering. Args: db: Database session - vendor_id: Vendor ID + store_id: Store ID skip: Pagination offset limit: Pagination limit is_active: Filter by active status @@ -225,7 +225,7 @@ class ProductService: Tuple of (products, total_count) """ try: - query = db.query(Product).filter(Product.vendor_id == vendor_id) + query = db.query(Product).filter(Product.store_id == store_id) if is_active is not None: query = query.filter(Product.is_active == is_active) @@ -239,20 +239,20 @@ class ProductService: return products, total except Exception as e: - logger.error(f"Error getting vendor products: {str(e)}") + logger.error(f"Error getting store products: {str(e)}") raise ValidationException("Failed to retrieve products") def search_products( self, db: Session, - vendor_id: int, + store_id: int, query: str, skip: int = 0, limit: int = 50, language: str = "en", ) -> tuple[list[Product], int]: """ - Search products in vendor catalog. + Search products in store catalog. Searches across: - Product title and description (from translations) @@ -260,7 +260,7 @@ class ProductService: Args: db: Database session - vendor_id: Vendor ID + store_id: Store ID query: Search query string skip: Pagination offset limit: Pagination limit @@ -287,7 +287,7 @@ class ProductService: & (ProductTranslation.language == language), ) .filter( - Product.vendor_id == vendor_id, + Product.store_id == store_id, Product.is_active == True, ) .filter( @@ -297,7 +297,7 @@ class ProductService: ProductTranslation.description.ilike(search_pattern), ProductTranslation.short_description.ilike(search_pattern), # Search in product fields - Product.vendor_sku.ilike(search_pattern), + Product.store_sku.ilike(search_pattern), Product.brand.ilike(search_pattern), Product.gtin.ilike(search_pattern), ) @@ -322,7 +322,7 @@ class ProductService: ) logger.debug( - f"Search '{query}' for vendor {vendor_id}: {total} results" + f"Search '{query}' for store {store_id}: {total} results" ) return products, total diff --git a/app/modules/catalog/services/vendor_product_service.py b/app/modules/catalog/services/store_product_service.py similarity index 77% rename from app/modules/catalog/services/vendor_product_service.py rename to app/modules/catalog/services/store_product_service.py index 5dfa204b..cf66fc20 100644 --- a/app/modules/catalog/services/vendor_product_service.py +++ b/app/modules/catalog/services/store_product_service.py @@ -1,9 +1,9 @@ -# app/modules/catalog/services/vendor_product_service.py +# app/modules/catalog/services/store_product_service.py """ -Vendor product service for managing vendor-specific product catalogs. +Store product service for managing store-specific product catalogs. This module provides: -- Vendor product catalog browsing +- Store product catalog browsing - Product search and filtering - Product statistics - Product removal from catalogs @@ -16,13 +16,13 @@ from sqlalchemy.orm import Session, joinedload from app.modules.catalog.exceptions import ProductNotFoundException from app.modules.catalog.models import Product -from app.modules.tenancy.models import Vendor +from app.modules.tenancy.models import Store logger = logging.getLogger(__name__) -class VendorProductService: - """Service for vendor product catalog operations.""" +class StoreProductService: + """Service for store product catalog operations.""" def get_products( self, @@ -30,22 +30,22 @@ class VendorProductService: skip: int = 0, limit: int = 50, search: str | None = None, - vendor_id: int | None = None, + store_id: int | None = None, is_active: bool | None = None, is_featured: bool | None = None, language: str = "en", ) -> tuple[list[dict], int]: """ - Get vendor products with search and filtering. + Get store products with search and filtering. Returns: Tuple of (products list as dicts, total count) """ query = ( db.query(Product) - .join(Vendor, Product.vendor_id == Vendor.id) + .join(Store, Product.store_id == Store.id) .options( - joinedload(Product.vendor), + joinedload(Product.store), joinedload(Product.marketplace_product), joinedload(Product.translations), ) @@ -53,10 +53,10 @@ class VendorProductService: if search: search_term = f"%{search}%" - query = query.filter(Product.vendor_sku.ilike(search_term)) + query = query.filter(Product.store_sku.ilike(search_term)) - if vendor_id: - query = query.filter(Product.vendor_id == vendor_id) + if store_id: + query = query.filter(Product.store_id == store_id) if is_active is not None: query = query.filter(Product.is_active == is_active) @@ -76,18 +76,18 @@ class VendorProductService: return result, total - def get_product_stats(self, db: Session, vendor_id: int | None = None) -> dict: - """Get vendor product statistics for admin dashboard. + def get_product_stats(self, db: Session, store_id: int | None = None) -> dict: + """Get store product statistics for admin dashboard. Args: db: Database session - vendor_id: Optional vendor ID to filter stats + store_id: Optional store ID to filter stats Returns: Dict with product counts (total, active, inactive, etc.) """ # Base query filter - base_filter = Product.vendor_id == vendor_id if vendor_id else True + base_filter = Product.store_id == store_id if store_id else True total = db.query(func.count(Product.id)).filter(base_filter).scalar() or 0 @@ -119,19 +119,19 @@ class VendorProductService: ) physical = total - digital - # Count by vendor (only when not filtered by vendor_id) - by_vendor = {} - if not vendor_id: - vendor_counts = ( + # Count by store (only when not filtered by store_id) + by_store = {} + if not store_id: + store_counts = ( db.query( - Vendor.name, + Store.name, func.count(Product.id), ) - .join(Vendor, Product.vendor_id == Vendor.id) - .group_by(Vendor.name) + .join(Store, Product.store_id == Store.id) + .group_by(Store.name) .all() ) - by_vendor = {name or "unknown": count for name, count in vendor_counts} + by_store = {name or "unknown": count for name, count in store_counts} return { "total": total, @@ -140,27 +140,27 @@ class VendorProductService: "featured": featured, "digital": digital, "physical": physical, - "by_vendor": by_vendor, + "by_store": by_store, } - def get_catalog_vendors(self, db: Session) -> list[dict]: - """Get list of vendors with products in their catalogs.""" - vendors = ( - db.query(Vendor.id, Vendor.name, Vendor.vendor_code) - .join(Product, Vendor.id == Product.vendor_id) + def get_catalog_stores(self, db: Session) -> list[dict]: + """Get list of stores with products in their catalogs.""" + stores = ( + db.query(Store.id, Store.name, Store.store_code) + .join(Product, Store.id == Product.store_id) .distinct() .all() ) return [ - {"id": v.id, "name": v.name, "vendor_code": v.vendor_code} for v in vendors + {"id": v.id, "name": v.name, "store_code": v.store_code} for v in stores ] def get_product_detail(self, db: Session, product_id: int) -> dict: - """Get detailed vendor product information including override info.""" + """Get detailed store product information including override info.""" product = ( db.query(Product) .options( - joinedload(Product.vendor), + joinedload(Product.store), joinedload(Product.marketplace_product), joinedload(Product.translations), ) @@ -184,40 +184,40 @@ class VendorProductService: "short_description": t.short_description, } - # Get vendor translations - vendor_translations = {} + # Get store translations + store_translations = {} for t in product.translations: - vendor_translations[t.language] = { + store_translations[t.language] = { "title": t.title, "description": t.description, } - # Convenience fields for UI (prefer vendor translations, fallback to English) + # Convenience fields for UI (prefer store translations, fallback to English) title = None description = None - if vendor_translations: + if store_translations: # Try English first, then first available language - if "en" in vendor_translations: - title = vendor_translations["en"].get("title") - description = vendor_translations["en"].get("description") - elif vendor_translations: - first_lang = next(iter(vendor_translations)) - title = vendor_translations[first_lang].get("title") - description = vendor_translations[first_lang].get("description") + if "en" in store_translations: + title = store_translations["en"].get("title") + description = store_translations["en"].get("description") + elif store_translations: + first_lang = next(iter(store_translations)) + title = store_translations[first_lang].get("title") + description = store_translations[first_lang].get("description") return { "id": product.id, - "vendor_id": product.vendor_id, - "vendor_name": product.vendor.name if product.vendor else None, - "vendor_code": product.vendor.vendor_code if product.vendor else None, + "store_id": product.store_id, + "store_name": product.store.name if product.store else None, + "store_code": product.store.store_code if product.store else None, "marketplace_product_id": product.marketplace_product_id, - "vendor_sku": product.vendor_sku, + "store_sku": product.store_sku, # Product identifiers "gtin": product.gtin, "gtin_type": product.gtin_type or "ean13", # Product fields with source comparison info **source_comparison_info, - # Vendor-specific fields + # Store-specific fields "is_featured": product.is_featured, "is_active": product.is_active, "display_order": product.display_order, @@ -240,12 +240,12 @@ class VendorProductService: "fulfillment_email_template": product.fulfillment_email_template, # Source info from marketplace product "source_marketplace": mp.marketplace if mp else None, - "source_vendor": mp.vendor_name if mp else None, + "source_store": mp.store_name if mp else None, "source_gtin": mp.gtin if mp else None, "source_sku": mp.sku if mp else None, # Translations "marketplace_translations": mp_translations, - "vendor_translations": vendor_translations, + "store_translations": store_translations, # Convenience fields for UI display "title": title, "description": description, @@ -261,7 +261,7 @@ class VendorProductService: } def create_product(self, db: Session, data: dict) -> Product: - """Create a new vendor product. + """Create a new store product. Args: db: Database session @@ -277,8 +277,8 @@ class VendorProductService: product_type = "digital" if is_digital else data.get("product_type", "physical") product = Product( - vendor_id=data["vendor_id"], - vendor_sku=data.get("vendor_sku"), + store_id=data["store_id"], + store_sku=data.get("store_sku"), brand=data.get("brand"), gtin=data.get("gtin"), gtin_type=data.get("gtin_type"), @@ -329,12 +329,12 @@ class VendorProductService: db.flush() - logger.info(f"Created vendor product {product.id} for vendor {data['vendor_id']}") + logger.info(f"Created store product {product.id} for store {data['store_id']}") return product def update_product(self, db: Session, product_id: int, data: dict) -> Product: - """Update a vendor product. + """Update a store product. Args: db: Database session @@ -389,7 +389,7 @@ class VendorProductService: # Update other allowed fields updatable_fields = [ - "vendor_sku", + "store_sku", "brand", "gtin", "gtin_type", @@ -410,32 +410,32 @@ class VendorProductService: db.flush() - logger.info(f"Updated vendor product {product_id}") + logger.info(f"Updated store product {product_id}") return product def remove_product(self, db: Session, product_id: int) -> dict: - """Remove a product from vendor catalog.""" + """Remove a product from store catalog.""" product = db.query(Product).filter(Product.id == product_id).first() if not product: raise ProductNotFoundException(product_id) - vendor_name = product.vendor.name if product.vendor else "Unknown" + store_name = product.store.name if product.store else "Unknown" db.delete(product) db.flush() - logger.info(f"Removed product {product_id} from vendor {vendor_name} catalog") + logger.info(f"Removed product {product_id} from store {store_name} catalog") - return {"message": f"Product removed from {vendor_name}'s catalog"} + return {"message": f"Product removed from {store_name}'s catalog"} def _build_product_list_item(self, product: Product, language: str) -> dict: """Build a product list item dict.""" mp = product.marketplace_product - # Get title: prefer vendor translations, fallback to marketplace translations + # Get title: prefer store translations, fallback to marketplace translations title = None - # First try vendor's own translations + # First try store's own translations if product.translations: for trans in product.translations: if trans.language == language and trans.title: @@ -453,11 +453,11 @@ class VendorProductService: return { "id": product.id, - "vendor_id": product.vendor_id, - "vendor_name": product.vendor.name if product.vendor else None, - "vendor_code": product.vendor.vendor_code if product.vendor else None, + "store_id": product.store_id, + "store_name": product.store.name if product.store else None, + "store_code": product.store.store_code if product.store else None, "marketplace_product_id": product.marketplace_product_id, - "vendor_sku": product.vendor_sku, + "store_sku": product.store_sku, "title": title, "brand": product.brand, "price": product.price, @@ -470,7 +470,7 @@ class VendorProductService: "is_digital": product.is_digital, "image_url": product.primary_image_url, "source_marketplace": mp.marketplace if mp else None, - "source_vendor": mp.vendor_name if mp else None, + "source_store": mp.store_name if mp else None, "created_at": product.created_at.isoformat() if product.created_at else None, @@ -481,4 +481,4 @@ class VendorProductService: # Create service instance -vendor_product_service = VendorProductService() +store_product_service = StoreProductService() diff --git a/app/modules/catalog/static/admin/js/product-create.js b/app/modules/catalog/static/admin/js/product-create.js index 02f8ed57..373acf89 100644 --- a/app/modules/catalog/static/admin/js/product-create.js +++ b/app/modules/catalog/static/admin/js/product-create.js @@ -1,16 +1,16 @@ // app/modules/catalog/static/admin/js/product-create.js /** - * Admin vendor product create page logic - * Create new vendor product entries with translations + * Admin store product create page logic + * Create new store product entries with translations */ -const adminVendorProductCreateLog = window.LogConfig.loggers.adminVendorProductCreate || - window.LogConfig.createLogger('adminVendorProductCreate', false); +const adminStoreProductCreateLog = window.LogConfig.loggers.adminStoreProductCreate || + window.LogConfig.createLogger('adminStoreProductCreate', false); -adminVendorProductCreateLog.info('Loading...'); +adminStoreProductCreateLog.info('Loading...'); -function adminVendorProductCreate() { - adminVendorProductCreateLog.info('adminVendorProductCreate() called'); +function adminStoreProductCreate() { + adminStoreProductCreateLog.info('adminStoreProductCreate() called'); // Default translations structure const defaultTranslations = () => ({ @@ -24,29 +24,29 @@ function adminVendorProductCreate() { // Inherit base layout state ...data(), - // Include media picker functionality (vendor ID getter will be bound via loadMediaLibrary override) + // Include media picker functionality (store ID getter will be bound via loadMediaLibrary override) ...mediaPickerMixin(() => null, false), // Set page identifier - currentPage: 'vendor-products', + currentPage: 'store-products', // Loading states loading: false, saving: false, // Tom Select instance - vendorSelectInstance: null, + storeSelectInstance: null, // Active language tab activeLanguage: 'en', // Form data form: { - vendor_id: null, + store_id: null, // Translations by language translations: defaultTranslations(), // Product identifiers - vendor_sku: '', + store_sku: '', brand: '', gtin: '', gtin_type: '', @@ -70,56 +70,56 @@ function adminVendorProductCreate() { // Load i18n translations await I18n.loadModule('catalog'); - adminVendorProductCreateLog.info('Vendor Product Create init() called'); + adminStoreProductCreateLog.info('Store Product Create init() called'); // Guard against multiple initialization - if (window._adminVendorProductCreateInitialized) { - adminVendorProductCreateLog.warn('Already initialized, skipping'); + if (window._adminStoreProductCreateInitialized) { + adminStoreProductCreateLog.warn('Already initialized, skipping'); return; } - window._adminVendorProductCreateInitialized = true; + window._adminStoreProductCreateInitialized = true; // Initialize Tom Select - this.initVendorSelect(); + this.initStoreSelect(); - adminVendorProductCreateLog.info('Vendor Product Create initialization complete'); + adminStoreProductCreateLog.info('Store Product Create initialization complete'); } catch (error) { - adminVendorProductCreateLog.error('Init failed:', error); + adminStoreProductCreateLog.error('Init failed:', error); this.error = 'Failed to initialize product create page'; } }, /** - * Initialize Tom Select for vendor autocomplete + * Initialize Tom Select for store autocomplete */ - initVendorSelect() { - const selectEl = this.$refs.vendorSelect; + initStoreSelect() { + const selectEl = this.$refs.storeSelect; if (!selectEl) { - adminVendorProductCreateLog.warn('Vendor select element not found'); + adminStoreProductCreateLog.warn('Store select element not found'); return; } // Wait for Tom Select to be available if (typeof TomSelect === 'undefined') { - adminVendorProductCreateLog.warn('TomSelect not loaded, retrying in 100ms'); - setTimeout(() => this.initVendorSelect(), 100); + adminStoreProductCreateLog.warn('TomSelect not loaded, retrying in 100ms'); + setTimeout(() => this.initStoreSelect(), 100); return; } - this.vendorSelectInstance = new TomSelect(selectEl, { + this.storeSelectInstance = new TomSelect(selectEl, { valueField: 'id', labelField: 'name', - searchField: ['name', 'vendor_code'], - placeholder: 'Search vendor...', + searchField: ['name', 'store_code'], + placeholder: 'Search store...', load: async (query, callback) => { try { - const response = await apiClient.get('/admin/vendors', { + const response = await apiClient.get('/admin/stores', { search: query, limit: 50 }); - callback(response.vendors || []); + callback(response.stores || []); } catch (error) { - adminVendorProductCreateLog.error('Failed to search vendors:', error); + adminStoreProductCreateLog.error('Failed to search stores:', error); callback([]); } }, @@ -127,7 +127,7 @@ function adminVendorProductCreate() { option: (data, escape) => { return `
${escape(data.name)} - ${escape(data.vendor_code || '')} + ${escape(data.store_code || '')}
`; }, item: (data, escape) => { @@ -135,19 +135,19 @@ function adminVendorProductCreate() { } }, onChange: (value) => { - this.form.vendor_id = value ? parseInt(value) : null; + this.form.store_id = value ? parseInt(value) : null; } }); - adminVendorProductCreateLog.info('Vendor select initialized'); + adminStoreProductCreateLog.info('Store select initialized'); }, /** - * Generate a unique vendor SKU - * Format: XXXX_XXXX_XXXX (includes vendor_id for uniqueness) + * Generate a unique store SKU + * Format: XXXX_XXXX_XXXX (includes store_id for uniqueness) */ generateSku() { - const vendorId = this.form.vendor_id || 0; + const storeId = this.form.store_id || 0; // Generate random alphanumeric segments const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; @@ -159,22 +159,22 @@ function adminVendorProductCreate() { return result; }; - // First segment includes vendor ID (padded) - const vendorSegment = vendorId.toString().padStart(4, '0').slice(-4); + // First segment includes store ID (padded) + const storeSegment = storeId.toString().padStart(4, '0').slice(-4); // Generate SKU: VID + random + random - const sku = `${vendorSegment}_${generateSegment(4)}_${generateSegment(4)}`; - this.form.vendor_sku = sku; + const sku = `${storeSegment}_${generateSegment(4)}_${generateSegment(4)}`; + this.form.store_sku = sku; - adminVendorProductCreateLog.info('Generated SKU:', sku); + adminStoreProductCreateLog.info('Generated SKU:', sku); }, /** * Create the product */ async createProduct() { - if (!this.form.vendor_id) { - Utils.showToast(I18n.t('catalog.messages.please_select_a_vendor'), 'error'); + if (!this.form.store_id) { + Utils.showToast(I18n.t('catalog.messages.please_select_a_store'), 'error'); return; } @@ -200,11 +200,11 @@ function adminVendorProductCreate() { // Build create payload const payload = { - vendor_id: this.form.vendor_id, + store_id: this.form.store_id, translations: Object.keys(translations).length > 0 ? translations : null, // Product identifiers brand: this.form.brand?.trim() || null, - vendor_sku: this.form.vendor_sku?.trim() || null, + store_sku: this.form.store_sku?.trim() || null, gtin: this.form.gtin?.trim() || null, gtin_type: this.form.gtin_type || null, // Pricing @@ -226,20 +226,20 @@ function adminVendorProductCreate() { is_digital: this.form.is_digital }; - adminVendorProductCreateLog.info('Creating product with payload:', payload); + adminStoreProductCreateLog.info('Creating product with payload:', payload); - const response = await apiClient.post('/admin/vendor-products', payload); + const response = await apiClient.post('/admin/store-products', payload); - adminVendorProductCreateLog.info('Product created:', response.id); + adminStoreProductCreateLog.info('Product created:', response.id); Utils.showToast(I18n.t('catalog.messages.product_created_successfully'), 'success'); // Redirect to the new product's detail page setTimeout(() => { - window.location.href = `/admin/vendor-products/${response.id}`; + window.location.href = `/admin/store-products/${response.id}`; }, 1000); } catch (error) { - adminVendorProductCreateLog.error('Failed to create product:', error); + adminStoreProductCreateLog.error('Failed to create product:', error); Utils.showToast(error.message || I18n.t('catalog.messages.failed_to_create_product'), 'error'); } finally { this.saving = false; @@ -250,13 +250,13 @@ function adminVendorProductCreate() { // These override the mixin methods to use proper form context /** - * Load media library for the selected vendor + * Load media library for the selected store */ async loadMediaLibrary() { - const vendorId = this.form?.vendor_id; + const storeId = this.form?.store_id; - if (!vendorId) { - adminVendorProductCreateLog.warn('Media picker: No vendor ID selected'); + if (!storeId) { + adminStoreProductCreateLog.warn('Media picker: No store ID selected'); return; } @@ -275,13 +275,13 @@ function adminVendorProductCreate() { } const response = await apiClient.get( - `/admin/media/vendors/${vendorId}?${params.toString()}` + `/admin/media/stores/${storeId}?${params.toString()}` ); this.mediaPickerState.media = response.media || []; this.mediaPickerState.total = response.total || 0; } catch (error) { - adminVendorProductCreateLog.error('Failed to load media library:', error); + adminStoreProductCreateLog.error('Failed to load media library:', error); Utils.showToast(I18n.t('catalog.messages.failed_to_load_media_library'), 'error'); } finally { this.mediaPickerState.loading = false; @@ -292,8 +292,8 @@ function adminVendorProductCreate() { * Load more media (pagination) */ async loadMoreMedia() { - const vendorId = this.form?.vendor_id; - if (!vendorId) return; + const storeId = this.form?.store_id; + if (!storeId) return; this.mediaPickerState.loading = true; this.mediaPickerState.skip += this.mediaPickerState.limit; @@ -310,7 +310,7 @@ function adminVendorProductCreate() { } const response = await apiClient.get( - `/admin/media/vendors/${vendorId}?${params.toString()}` + `/admin/media/stores/${storeId}?${params.toString()}` ); this.mediaPickerState.media = [ @@ -318,7 +318,7 @@ function adminVendorProductCreate() { ...(response.media || []) ]; } catch (error) { - adminVendorProductCreateLog.error('Failed to load more media:', error); + adminStoreProductCreateLog.error('Failed to load more media:', error); } finally { this.mediaPickerState.loading = false; } @@ -331,10 +331,10 @@ function adminVendorProductCreate() { const file = event.target.files?.[0]; if (!file) return; - const vendorId = this.form?.vendor_id; + const storeId = this.form?.store_id; - if (!vendorId) { - Utils.showToast(I18n.t('catalog.messages.please_select_a_vendor_first'), 'error'); + if (!storeId) { + Utils.showToast(I18n.t('catalog.messages.please_select_a_store_first'), 'error'); return; } @@ -355,7 +355,7 @@ function adminVendorProductCreate() { formData.append('file', file); const response = await apiClient.postFormData( - `/admin/media/vendors/${vendorId}/upload?folder=products`, + `/admin/media/stores/${storeId}/upload?folder=products`, formData ); @@ -366,7 +366,7 @@ function adminVendorProductCreate() { Utils.showToast(I18n.t('catalog.messages.image_uploaded_successfully'), 'success'); } } catch (error) { - adminVendorProductCreateLog.error('Failed to upload image:', error); + adminStoreProductCreateLog.error('Failed to upload image:', error); Utils.showToast(error.message || I18n.t('catalog.messages.failed_to_upload_image'), 'error'); } finally { this.mediaPickerState.uploading = false; @@ -379,7 +379,7 @@ function adminVendorProductCreate() { */ setMainImage(media) { this.form.primary_image_url = media.url; - adminVendorProductCreateLog.info('Main image set:', media.url); + adminStoreProductCreateLog.info('Main image set:', media.url); }, /** @@ -391,7 +391,7 @@ function adminVendorProductCreate() { ...this.form.additional_images, ...newUrls ]; - adminVendorProductCreateLog.info('Additional images added:', newUrls); + adminStoreProductCreateLog.info('Additional images added:', newUrls); }, /** diff --git a/app/modules/catalog/static/admin/js/product-detail.js b/app/modules/catalog/static/admin/js/product-detail.js index 4c5445ee..5f3ab850 100644 --- a/app/modules/catalog/static/admin/js/product-detail.js +++ b/app/modules/catalog/static/admin/js/product-detail.js @@ -1,16 +1,16 @@ // app/modules/catalog/static/admin/js/product-detail.js /** - * Admin vendor product detail page logic - * View and manage individual vendor catalog products + * Admin store product detail page logic + * View and manage individual store catalog products */ -const adminVendorProductDetailLog = window.LogConfig.loggers.adminVendorProductDetail || - window.LogConfig.createLogger('adminVendorProductDetail', false); +const adminStoreProductDetailLog = window.LogConfig.loggers.adminStoreProductDetail || + window.LogConfig.createLogger('adminStoreProductDetail', false); -adminVendorProductDetailLog.info('Loading...'); +adminStoreProductDetailLog.info('Loading...'); -function adminVendorProductDetail() { - adminVendorProductDetailLog.info('adminVendorProductDetail() called'); +function adminStoreProductDetail() { + adminStoreProductDetailLog.info('adminStoreProductDetail() called'); // Extract product ID from URL const pathParts = window.location.pathname.split('/'); @@ -21,7 +21,7 @@ function adminVendorProductDetail() { ...data(), // Set page identifier - currentPage: 'vendor-products', + currentPage: 'store-products', // Product ID from URL productId: productId, @@ -38,19 +38,19 @@ function adminVendorProductDetail() { removing: false, async init() { - adminVendorProductDetailLog.info('Vendor Product Detail init() called, ID:', this.productId); + adminStoreProductDetailLog.info('Store Product Detail init() called, ID:', this.productId); // Guard against multiple initialization - if (window._adminVendorProductDetailInitialized) { - adminVendorProductDetailLog.warn('Already initialized, skipping'); + if (window._adminStoreProductDetailInitialized) { + adminStoreProductDetailLog.warn('Already initialized, skipping'); return; } - window._adminVendorProductDetailInitialized = true; + window._adminStoreProductDetailInitialized = true; // Load product data await this.loadProduct(); - adminVendorProductDetailLog.info('Vendor Product Detail initialization complete'); + adminStoreProductDetailLog.info('Store Product Detail initialization complete'); }, /** @@ -61,11 +61,11 @@ function adminVendorProductDetail() { this.error = ''; try { - const response = await apiClient.get(`/admin/vendor-products/${this.productId}`); + const response = await apiClient.get(`/admin/store-products/${this.productId}`); this.product = response; - adminVendorProductDetailLog.info('Loaded product:', this.product.id); + adminStoreProductDetailLog.info('Loaded product:', this.product.id); } catch (error) { - adminVendorProductDetailLog.error('Failed to load product:', error); + adminStoreProductDetailLog.error('Failed to load product:', error); this.error = error.message || 'Failed to load product details'; } finally { this.loading = false; @@ -108,9 +108,9 @@ function adminVendorProductDetail() { this.removing = true; try { - await apiClient.delete(`/admin/vendor-products/${this.productId}`); + await apiClient.delete(`/admin/store-products/${this.productId}`); - adminVendorProductDetailLog.info('Product removed:', this.productId); + adminStoreProductDetailLog.info('Product removed:', this.productId); window.dispatchEvent(new CustomEvent('toast', { detail: { @@ -119,12 +119,12 @@ function adminVendorProductDetail() { } })); - // Redirect to vendor products list + // Redirect to store products list setTimeout(() => { - window.location.href = '/admin/vendor-products'; + window.location.href = '/admin/store-products'; }, 1000); } catch (error) { - adminVendorProductDetailLog.error('Failed to remove product:', error); + adminStoreProductDetailLog.error('Failed to remove product:', error); window.dispatchEvent(new CustomEvent('toast', { detail: { message: error.message || 'Failed to remove product', type: 'error' } })); diff --git a/app/modules/catalog/static/admin/js/product-edit.js b/app/modules/catalog/static/admin/js/product-edit.js index 8b54be98..31f5edf9 100644 --- a/app/modules/catalog/static/admin/js/product-edit.js +++ b/app/modules/catalog/static/admin/js/product-edit.js @@ -1,20 +1,20 @@ // app/modules/catalog/static/admin/js/product-edit.js /** - * Admin vendor product edit page logic - * Edit vendor product information with translations + * Admin store product edit page logic + * Edit store product information with translations */ -const adminVendorProductEditLog = window.LogConfig.loggers.adminVendorProductEdit || - window.LogConfig.createLogger('adminVendorProductEdit', false); +const adminStoreProductEditLog = window.LogConfig.loggers.adminStoreProductEdit || + window.LogConfig.createLogger('adminStoreProductEdit', false); -adminVendorProductEditLog.info('Loading...'); +adminStoreProductEditLog.info('Loading...'); -function adminVendorProductEdit() { - adminVendorProductEditLog.info('adminVendorProductEdit() called'); +function adminStoreProductEdit() { + adminStoreProductEditLog.info('adminStoreProductEdit() called'); // Extract product ID from URL const pathParts = window.location.pathname.split('/'); - const productId = parseInt(pathParts[pathParts.length - 2]); // /vendor-products/{id}/edit + const productId = parseInt(pathParts[pathParts.length - 2]); // /store-products/{id}/edit // Default translations structure const defaultTranslations = () => ({ @@ -28,11 +28,11 @@ function adminVendorProductEdit() { // Inherit base layout state ...data(), - // Include media picker functionality (vendor ID comes from loaded product) + // Include media picker functionality (store ID comes from loaded product) ...mediaPickerMixin(() => null, false), // Set page identifier - currentPage: 'vendor-products', + currentPage: 'store-products', // Product ID from URL productId: productId, @@ -53,7 +53,7 @@ function adminVendorProductEdit() { // Translations by language translations: defaultTranslations(), // Product identifiers - vendor_sku: '', + store_sku: '', brand: '', gtin: '', gtin_type: 'ean13', @@ -77,24 +77,24 @@ function adminVendorProductEdit() { async init() { // Guard against multiple initialization - if (window._adminVendorProductEditInitialized) { - adminVendorProductEditLog.warn('Already initialized, skipping'); + if (window._adminStoreProductEditInitialized) { + adminStoreProductEditLog.warn('Already initialized, skipping'); return; } - window._adminVendorProductEditInitialized = true; + window._adminStoreProductEditInitialized = true; try { // Load i18n translations await I18n.loadModule('catalog'); - adminVendorProductEditLog.info('Vendor Product Edit init() called, ID:', this.productId); + adminStoreProductEditLog.info('Store Product Edit init() called, ID:', this.productId); // Load product data await this.loadProduct(); - adminVendorProductEditLog.info('Vendor Product Edit initialization complete'); + adminStoreProductEditLog.info('Store Product Edit initialization complete'); } catch (error) { - adminVendorProductEditLog.error('Init failed:', error); + adminStoreProductEditLog.error('Init failed:', error); this.error = 'Failed to initialize product edit page'; } }, @@ -107,19 +107,19 @@ function adminVendorProductEdit() { this.error = ''; try { - const response = await apiClient.get(`/admin/vendor-products/${this.productId}`); + const response = await apiClient.get(`/admin/store-products/${this.productId}`); this.product = response; - adminVendorProductEditLog.info('Loaded product:', response); + adminStoreProductEditLog.info('Loaded product:', response); - // Populate translations from vendor_translations + // Populate translations from store_translations const translations = defaultTranslations(); - if (response.vendor_translations) { + if (response.store_translations) { for (const lang of ['en', 'fr', 'de', 'lu']) { - if (response.vendor_translations[lang]) { + if (response.store_translations[lang]) { translations[lang] = { - title: response.vendor_translations[lang].title || '', - description: response.vendor_translations[lang].description || '' + title: response.store_translations[lang].title || '', + description: response.store_translations[lang].description || '' }; } } @@ -129,7 +129,7 @@ function adminVendorProductEdit() { this.form = { translations: translations, // Product identifiers - vendor_sku: response.vendor_sku || '', + store_sku: response.store_sku || '', brand: response.brand || '', gtin: response.gtin || '', gtin_type: response.gtin_type || 'ean13', @@ -151,9 +151,9 @@ function adminVendorProductEdit() { cost: response.cost || null }; - adminVendorProductEditLog.info('Form initialized:', this.form); + adminStoreProductEditLog.info('Form initialized:', this.form); } catch (error) { - adminVendorProductEditLog.error('Failed to load product:', error); + adminStoreProductEditLog.error('Failed to load product:', error); this.error = error.message || 'Failed to load product details'; } finally { this.loading = false; @@ -169,7 +169,7 @@ function adminVendorProductEdit() { if (!this.form.translations.en.description?.trim()) return false; // Product identifiers - if (!this.form.vendor_sku?.trim()) return false; + if (!this.form.store_sku?.trim()) return false; if (!this.form.brand?.trim()) return false; if (!this.form.gtin?.trim()) return false; if (!this.form.gtin_type) return false; @@ -186,11 +186,11 @@ function adminVendorProductEdit() { }, /** - * Generate a unique vendor SKU - * Format: XXXX_XXXX_XXXX (includes vendor_id for uniqueness) + * Generate a unique store SKU + * Format: XXXX_XXXX_XXXX (includes store_id for uniqueness) */ generateSku() { - const vendorId = this.product?.vendor_id || 0; + const storeId = this.product?.store_id || 0; // Generate random alphanumeric segments const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; @@ -202,14 +202,14 @@ function adminVendorProductEdit() { return result; }; - // First segment includes vendor ID (padded) - const vendorSegment = vendorId.toString().padStart(4, '0').slice(-4); + // First segment includes store ID (padded) + const storeSegment = storeId.toString().padStart(4, '0').slice(-4); // Generate SKU: VID + random + random - const sku = `${vendorSegment}_${generateSegment(4)}_${generateSegment(4)}`; - this.form.vendor_sku = sku; + const sku = `${storeSegment}_${generateSegment(4)}_${generateSegment(4)}`; + this.form.store_sku = sku; - adminVendorProductEditLog.info('Generated SKU:', sku); + adminStoreProductEditLog.info('Generated SKU:', sku); }, /** @@ -241,7 +241,7 @@ function adminVendorProductEdit() { const payload = { translations: Object.keys(translations).length > 0 ? translations : null, // Product identifiers - vendor_sku: this.form.vendor_sku?.trim() || null, + store_sku: this.form.store_sku?.trim() || null, brand: this.form.brand?.trim() || null, gtin: this.form.gtin?.trim() || null, gtin_type: this.form.gtin_type || null, @@ -268,20 +268,20 @@ function adminVendorProductEdit() { ? parseFloat(this.form.cost) : null }; - adminVendorProductEditLog.info('Saving payload:', payload); + adminStoreProductEditLog.info('Saving payload:', payload); - await apiClient.patch(`/admin/vendor-products/${this.productId}`, payload); + await apiClient.patch(`/admin/store-products/${this.productId}`, payload); - adminVendorProductEditLog.info('Product saved:', this.productId); + adminStoreProductEditLog.info('Product saved:', this.productId); Utils.showToast(I18n.t('catalog.messages.product_updated_successfully'), 'success'); // Redirect to detail page setTimeout(() => { - window.location.href = `/admin/vendor-products/${this.productId}`; + window.location.href = `/admin/store-products/${this.productId}`; }, 1000); } catch (error) { - adminVendorProductEditLog.error('Failed to save product:', error); + adminStoreProductEditLog.error('Failed to save product:', error); Utils.showToast(error.message || I18n.t('catalog.messages.failed_to_save_product'), 'error'); } finally { this.saving = false; @@ -292,13 +292,13 @@ function adminVendorProductEdit() { // These override the mixin methods to use proper form/product context /** - * Load media library for the product's vendor + * Load media library for the product's store */ async loadMediaLibrary() { - const vendorId = this.product?.vendor_id; + const storeId = this.product?.store_id; - if (!vendorId) { - adminVendorProductEditLog.warn('Media picker: No vendor ID available'); + if (!storeId) { + adminStoreProductEditLog.warn('Media picker: No store ID available'); return; } @@ -317,13 +317,13 @@ function adminVendorProductEdit() { } const response = await apiClient.get( - `/admin/media/vendors/${vendorId}?${params.toString()}` + `/admin/media/stores/${storeId}?${params.toString()}` ); this.mediaPickerState.media = response.media || []; this.mediaPickerState.total = response.total || 0; } catch (error) { - adminVendorProductEditLog.error('Failed to load media library:', error); + adminStoreProductEditLog.error('Failed to load media library:', error); Utils.showToast(I18n.t('catalog.messages.failed_to_load_media_library'), 'error'); } finally { this.mediaPickerState.loading = false; @@ -334,8 +334,8 @@ function adminVendorProductEdit() { * Load more media (pagination) */ async loadMoreMedia() { - const vendorId = this.product?.vendor_id; - if (!vendorId) return; + const storeId = this.product?.store_id; + if (!storeId) return; this.mediaPickerState.loading = true; this.mediaPickerState.skip += this.mediaPickerState.limit; @@ -352,7 +352,7 @@ function adminVendorProductEdit() { } const response = await apiClient.get( - `/admin/media/vendors/${vendorId}?${params.toString()}` + `/admin/media/stores/${storeId}?${params.toString()}` ); this.mediaPickerState.media = [ @@ -360,7 +360,7 @@ function adminVendorProductEdit() { ...(response.media || []) ]; } catch (error) { - adminVendorProductEditLog.error('Failed to load more media:', error); + adminStoreProductEditLog.error('Failed to load more media:', error); } finally { this.mediaPickerState.loading = false; } @@ -373,10 +373,10 @@ function adminVendorProductEdit() { const file = event.target.files?.[0]; if (!file) return; - const vendorId = this.product?.vendor_id; + const storeId = this.product?.store_id; - if (!vendorId) { - Utils.showToast(I18n.t('catalog.messages.no_vendor_associated_with_this_product'), 'error'); + if (!storeId) { + Utils.showToast(I18n.t('catalog.messages.no_store_associated_with_this_product'), 'error'); return; } @@ -397,7 +397,7 @@ function adminVendorProductEdit() { formData.append('file', file); const response = await apiClient.postFormData( - `/admin/media/vendors/${vendorId}/upload?folder=products`, + `/admin/media/stores/${storeId}/upload?folder=products`, formData ); @@ -408,7 +408,7 @@ function adminVendorProductEdit() { Utils.showToast(I18n.t('catalog.messages.image_uploaded_successfully'), 'success'); } } catch (error) { - adminVendorProductEditLog.error('Failed to upload image:', error); + adminStoreProductEditLog.error('Failed to upload image:', error); Utils.showToast(error.message || I18n.t('catalog.messages.failed_to_upload_image'), 'error'); } finally { this.mediaPickerState.uploading = false; @@ -421,7 +421,7 @@ function adminVendorProductEdit() { */ setMainImage(media) { this.form.primary_image_url = media.url; - adminVendorProductEditLog.info('Main image set:', media.url); + adminStoreProductEditLog.info('Main image set:', media.url); }, /** @@ -433,7 +433,7 @@ function adminVendorProductEdit() { ...this.form.additional_images, ...newUrls ]; - adminVendorProductEditLog.info('Additional images added:', newUrls); + adminStoreProductEditLog.info('Additional images added:', newUrls); }, /** diff --git a/app/modules/catalog/static/admin/js/products.js b/app/modules/catalog/static/admin/js/products.js index 3858fa4a..7061eaad 100644 --- a/app/modules/catalog/static/admin/js/products.js +++ b/app/modules/catalog/static/admin/js/products.js @@ -1,24 +1,24 @@ // noqa: js-006 - async init pattern is safe, loadData has try/catch -// static/admin/js/vendor-products.js +// static/admin/js/store-products.js /** - * Admin vendor products page logic - * Browse vendor-specific product catalogs with override capability + * Admin store products page logic + * Browse store-specific product catalogs with override capability */ -const adminVendorProductsLog = window.LogConfig.loggers.adminVendorProducts || - window.LogConfig.createLogger('adminVendorProducts', false); +const adminStoreProductsLog = window.LogConfig.loggers.adminStoreProducts || + window.LogConfig.createLogger('adminStoreProducts', false); -adminVendorProductsLog.info('Loading...'); +adminStoreProductsLog.info('Loading...'); -function adminVendorProducts() { - adminVendorProductsLog.info('adminVendorProducts() called'); +function adminStoreProducts() { + adminStoreProductsLog.info('adminStoreProducts() called'); return { // Inherit base layout state ...data(), // Set page identifier - currentPage: 'vendor-products', + currentPage: 'store-products', // Loading states loading: true, @@ -33,22 +33,22 @@ function adminVendorProducts() { featured: 0, digital: 0, physical: 0, - by_vendor: {} + by_store: {} }, // Filters filters: { search: '', - vendor_id: '', + store_id: '', is_active: '', is_featured: '' }, - // Selected vendor (for prominent display and filtering) - selectedVendor: null, + // Selected store (for prominent display and filtering) + selectedStore: null, // Tom Select instance - vendorSelectInstance: null, + storeSelectInstance: null, // Pagination pagination: { @@ -119,108 +119,108 @@ function adminVendorProducts() { // Load i18n translations await I18n.loadModule('catalog'); - adminVendorProductsLog.info('Vendor Products init() called'); + adminStoreProductsLog.info('Store Products init() called'); // Guard against multiple initialization - if (window._adminVendorProductsInitialized) { - adminVendorProductsLog.warn('Already initialized, skipping'); + if (window._adminStoreProductsInitialized) { + adminStoreProductsLog.warn('Already initialized, skipping'); return; } - window._adminVendorProductsInitialized = true; + window._adminStoreProductsInitialized = true; // Load platform settings for rows per page if (window.PlatformSettings) { this.pagination.per_page = await window.PlatformSettings.getRowsPerPage(); } - // Initialize Tom Select for vendor filter - this.initVendorSelect(); + // Initialize Tom Select for store filter + this.initStoreSelect(); - // Check localStorage for saved vendor - const savedVendorId = localStorage.getItem('vendor_products_selected_vendor_id'); - if (savedVendorId) { - adminVendorProductsLog.info('Restoring saved vendor:', savedVendorId); - // Restore vendor after a short delay to ensure TomSelect is ready + // Check localStorage for saved store + const savedStoreId = localStorage.getItem('store_products_selected_store_id'); + if (savedStoreId) { + adminStoreProductsLog.info('Restoring saved store:', savedStoreId); + // Restore store after a short delay to ensure TomSelect is ready setTimeout(async () => { - await this.restoreSavedVendor(parseInt(savedVendorId)); + await this.restoreSavedStore(parseInt(savedStoreId)); }, 200); - // Load stats but not products (restoreSavedVendor will do that) + // Load stats but not products (restoreSavedStore will do that) await this.loadStats(); } else { - // No saved vendor - load all data including unfiltered products + // No saved store - load all data including unfiltered products await Promise.all([ this.loadStats(), this.loadProducts() ]); } - adminVendorProductsLog.info('Vendor Products initialization complete'); + adminStoreProductsLog.info('Store Products initialization complete'); }, /** - * Restore saved vendor from localStorage + * Restore saved store from localStorage */ - async restoreSavedVendor(vendorId) { + async restoreSavedStore(storeId) { try { - const vendor = await apiClient.get(`/admin/vendors/${vendorId}`); - if (this.vendorSelectInstance && vendor) { - // Add the vendor as an option and select it - this.vendorSelectInstance.addOption({ - id: vendor.id, - name: vendor.name, - vendor_code: vendor.vendor_code + const store = await apiClient.get(`/admin/stores/${storeId}`); + if (this.storeSelectInstance && store) { + // Add the store as an option and select it + this.storeSelectInstance.addOption({ + id: store.id, + name: store.name, + store_code: store.store_code }); - this.vendorSelectInstance.setValue(vendor.id, true); + this.storeSelectInstance.setValue(store.id, true); // Set the filter state - this.selectedVendor = vendor; - this.filters.vendor_id = vendor.id; + this.selectedStore = store; + this.filters.store_id = store.id; - adminVendorProductsLog.info('Restored vendor:', vendor.name); + adminStoreProductsLog.info('Restored store:', store.name); - // Load products with the vendor filter applied + // Load products with the store filter applied await this.loadProducts(); } } catch (error) { - adminVendorProductsLog.warn('Failed to restore saved vendor, clearing localStorage:', error); - localStorage.removeItem('vendor_products_selected_vendor_id'); + adminStoreProductsLog.warn('Failed to restore saved store, clearing localStorage:', error); + localStorage.removeItem('store_products_selected_store_id'); // Load unfiltered products as fallback await this.loadProducts(); } }, /** - * Initialize Tom Select for vendor autocomplete + * Initialize Tom Select for store autocomplete */ - initVendorSelect() { - const selectEl = this.$refs.vendorSelect; + initStoreSelect() { + const selectEl = this.$refs.storeSelect; if (!selectEl) { - adminVendorProductsLog.warn('Vendor select element not found'); + adminStoreProductsLog.warn('Store select element not found'); return; } // Wait for Tom Select to be available if (typeof TomSelect === 'undefined') { - adminVendorProductsLog.warn('TomSelect not loaded, retrying in 100ms'); - setTimeout(() => this.initVendorSelect(), 100); + adminStoreProductsLog.warn('TomSelect not loaded, retrying in 100ms'); + setTimeout(() => this.initStoreSelect(), 100); return; } - this.vendorSelectInstance = new TomSelect(selectEl, { + this.storeSelectInstance = new TomSelect(selectEl, { valueField: 'id', labelField: 'name', - searchField: ['name', 'vendor_code'], - placeholder: 'Filter by vendor...', + searchField: ['name', 'store_code'], + placeholder: 'Filter by store...', allowEmptyOption: true, load: async (query, callback) => { try { - const response = await apiClient.get('/admin/vendors', { + const response = await apiClient.get('/admin/stores', { search: query, limit: 50 }); - callback(response.vendors || []); + callback(response.stores || []); } catch (error) { - adminVendorProductsLog.error('Failed to search vendors:', error); + adminStoreProductsLog.error('Failed to search stores:', error); callback([]); } }, @@ -228,7 +228,7 @@ function adminVendorProducts() { option: (data, escape) => { return `
${escape(data.name)} - ${escape(data.vendor_code || '')} + ${escape(data.store_code || '')}
`; }, item: (data, escape) => { @@ -237,16 +237,16 @@ function adminVendorProducts() { }, onChange: (value) => { if (value) { - const vendor = this.vendorSelectInstance.options[value]; - this.selectedVendor = vendor; - this.filters.vendor_id = value; + const store = this.storeSelectInstance.options[value]; + this.selectedStore = store; + this.filters.store_id = value; // Save to localStorage - localStorage.setItem('vendor_products_selected_vendor_id', value.toString()); + localStorage.setItem('store_products_selected_store_id', value.toString()); } else { - this.selectedVendor = null; - this.filters.vendor_id = ''; + this.selectedStore = null; + this.filters.store_id = ''; // Clear from localStorage - localStorage.removeItem('vendor_products_selected_vendor_id'); + localStorage.removeItem('store_products_selected_store_id'); } this.pagination.page = 1; this.loadProducts(); @@ -254,20 +254,20 @@ function adminVendorProducts() { } }); - adminVendorProductsLog.info('Vendor select initialized'); + adminStoreProductsLog.info('Store select initialized'); }, /** - * Clear vendor filter + * Clear store filter */ - clearVendorFilter() { - if (this.vendorSelectInstance) { - this.vendorSelectInstance.clear(); + clearStoreFilter() { + if (this.storeSelectInstance) { + this.storeSelectInstance.clear(); } - this.selectedVendor = null; - this.filters.vendor_id = ''; + this.selectedStore = null; + this.filters.store_id = ''; // Clear from localStorage - localStorage.removeItem('vendor_products_selected_vendor_id'); + localStorage.removeItem('store_products_selected_store_id'); this.pagination.page = 1; this.loadProducts(); this.loadStats(); @@ -279,15 +279,15 @@ function adminVendorProducts() { async loadStats() { try { const params = new URLSearchParams(); - if (this.filters.vendor_id) { - params.append('vendor_id', this.filters.vendor_id); + if (this.filters.store_id) { + params.append('store_id', this.filters.store_id); } - const url = params.toString() ? `/admin/vendor-products/stats?${params}` : '/admin/vendor-products/stats'; + const url = params.toString() ? `/admin/store-products/stats?${params}` : '/admin/store-products/stats'; const response = await apiClient.get(url); this.stats = response; - adminVendorProductsLog.info('Loaded stats:', this.stats); + adminStoreProductsLog.info('Loaded stats:', this.stats); } catch (error) { - adminVendorProductsLog.error('Failed to load stats:', error); + adminStoreProductsLog.error('Failed to load stats:', error); } }, @@ -308,8 +308,8 @@ function adminVendorProducts() { if (this.filters.search) { params.append('search', this.filters.search); } - if (this.filters.vendor_id) { - params.append('vendor_id', this.filters.vendor_id); + if (this.filters.store_id) { + params.append('store_id', this.filters.store_id); } if (this.filters.is_active !== '') { params.append('is_active', this.filters.is_active); @@ -318,15 +318,15 @@ function adminVendorProducts() { params.append('is_featured', this.filters.is_featured); } - const response = await apiClient.get(`/admin/vendor-products?${params.toString()}`); + const response = await apiClient.get(`/admin/store-products?${params.toString()}`); this.products = response.products || []; this.pagination.total = response.total || 0; this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page); - adminVendorProductsLog.info('Loaded products:', this.products.length, 'of', this.pagination.total); + adminStoreProductsLog.info('Loaded products:', this.products.length, 'of', this.pagination.total); } catch (error) { - adminVendorProductsLog.error('Failed to load products:', error); + adminStoreProductsLog.error('Failed to load products:', error); this.error = error.message || 'Failed to load products'; } finally { this.loading = false; @@ -350,7 +350,7 @@ function adminVendorProducts() { async refresh() { await Promise.all([ this.loadStats(), - this.loadVendors(), + this.loadStores(), this.loadProducts() ]); }, @@ -359,8 +359,8 @@ function adminVendorProducts() { * View product details - navigate to detail page */ viewProduct(productId) { - adminVendorProductsLog.info('Navigating to product detail:', productId); - window.location.href = `/admin/vendor-products/${productId}`; + adminStoreProductsLog.info('Navigating to product detail:', productId); + window.location.href = `/admin/store-products/${productId}`; }, /** @@ -379,21 +379,21 @@ function adminVendorProducts() { this.removing = true; try { - await apiClient.delete(`/admin/vendor-products/${this.productToRemove.id}`); + await apiClient.delete(`/admin/store-products/${this.productToRemove.id}`); - adminVendorProductsLog.info('Removed product:', this.productToRemove.id); + adminStoreProductsLog.info('Removed product:', this.productToRemove.id); // Close modal and refresh this.showRemoveModal = false; this.productToRemove = null; // Show success notification - Utils.showToast(I18n.t('catalog.messages.product_removed_from_vendor_catalog'), 'success'); + Utils.showToast(I18n.t('catalog.messages.product_removed_from_store_catalog'), 'success'); // Refresh the list await this.refresh(); } catch (error) { - adminVendorProductsLog.error('Failed to remove product:', error); + adminStoreProductsLog.error('Failed to remove product:', error); this.error = error.message || 'Failed to remove product'; } finally { this.removing = false; diff --git a/app/modules/catalog/static/vendor/js/product-create.js b/app/modules/catalog/static/store/js/product-create.js similarity index 65% rename from app/modules/catalog/static/vendor/js/product-create.js rename to app/modules/catalog/static/store/js/product-create.js index a2d35be4..651713eb 100644 --- a/app/modules/catalog/static/vendor/js/product-create.js +++ b/app/modules/catalog/static/store/js/product-create.js @@ -1,15 +1,15 @@ -// app/modules/catalog/static/vendor/js/product-create.js +// app/modules/catalog/static/store/js/product-create.js /** - * Vendor product creation page logic + * Store product creation page logic */ -const vendorProductCreateLog = window.LogConfig.loggers.vendorProductCreate || - window.LogConfig.createLogger('vendorProductCreate', false); +const storeProductCreateLog = window.LogConfig.loggers.storeProductCreate || + window.LogConfig.createLogger('storeProductCreate', false); -vendorProductCreateLog.info('Loading...'); +storeProductCreateLog.info('Loading...'); -function vendorProductCreate() { - vendorProductCreateLog.info('vendorProductCreate() called'); +function storeProductCreate() { + storeProductCreateLog.info('storeProductCreate() called'); return { // Inherit base layout state @@ -20,7 +20,7 @@ function vendorProductCreate() { // Back URL get backUrl() { - return `/vendor/${this.vendorCode}/products`; + return `/store/${this.storeCode}/products`; }, // Loading states @@ -32,7 +32,7 @@ function vendorProductCreate() { form: { title: '', brand: '', - vendor_sku: '', + store_sku: '', gtin: '', price: '', currency: 'EUR', @@ -48,21 +48,21 @@ function vendorProductCreate() { await I18n.loadModule('catalog'); // Guard against duplicate initialization - if (window._vendorProductCreateInitialized) return; - window._vendorProductCreateInitialized = true; + if (window._storeProductCreateInitialized) return; + window._storeProductCreateInitialized = true; - vendorProductCreateLog.info('Initializing product create page...'); + storeProductCreateLog.info('Initializing product create page...'); try { - // 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); } - vendorProductCreateLog.info('Product create page initialized'); + storeProductCreateLog.info('Product create page initialized'); } catch (err) { - vendorProductCreateLog.error('Failed to initialize:', err); + storeProductCreateLog.error('Failed to initialize:', err); this.error = err.message || 'Failed to initialize'; } }, @@ -77,11 +77,11 @@ function vendorProductCreate() { this.error = ''; try { - // Create product directly (vendor_id from JWT token) - const response = await apiClient.post('/vendor/products/create', { + // Create product directly (store_id from JWT token) + const response = await apiClient.post('/store/products/create', { title: this.form.title, brand: this.form.brand || null, - vendor_sku: this.form.vendor_sku || null, + store_sku: this.form.store_sku || null, gtin: this.form.gtin || null, price: parseFloat(this.form.price), currency: this.form.currency, @@ -95,7 +95,7 @@ function vendorProductCreate() { throw new Error(response.message || 'Failed to create product'); } - vendorProductCreateLog.info('Product created:', response.data); + storeProductCreateLog.info('Product created:', response.data); Utils.showToast(I18n.t('catalog.messages.product_created_successfully'), 'success'); // Navigate back to products list @@ -104,7 +104,7 @@ function vendorProductCreate() { }, 1000); } catch (err) { - vendorProductCreateLog.error('Failed to create product:', err); + storeProductCreateLog.error('Failed to create product:', err); this.error = err.message || I18n.t('catalog.messages.failed_to_create_product'); Utils.showToast(this.error, 'error'); } finally { @@ -114,4 +114,4 @@ function vendorProductCreate() { }; } -vendorProductCreateLog.info('Loaded successfully'); +storeProductCreateLog.info('Loaded successfully'); diff --git a/app/modules/catalog/static/vendor/js/products.js b/app/modules/catalog/static/store/js/products.js similarity index 84% rename from app/modules/catalog/static/vendor/js/products.js rename to app/modules/catalog/static/store/js/products.js index c8d423a2..7ac60bb3 100644 --- a/app/modules/catalog/static/vendor/js/products.js +++ b/app/modules/catalog/static/store/js/products.js @@ -1,16 +1,16 @@ -// app/modules/catalog/static/vendor/js/products.js +// app/modules/catalog/static/store/js/products.js /** - * Vendor products management page logic - * View, edit, and manage vendor's product catalog + * Store products management page logic + * View, edit, and manage store's product catalog */ -const vendorProductsLog = window.LogConfig.loggers.vendorProducts || - window.LogConfig.createLogger('vendorProducts', false); +const storeProductsLog = window.LogConfig.loggers.storeProducts || + window.LogConfig.createLogger('storeProducts', false); -vendorProductsLog.info('Loading...'); +storeProductsLog.info('Loading...'); -function vendorProducts() { - vendorProductsLog.info('vendorProducts() called'); +function storeProducts() { + storeProductsLog.info('storeProducts() called'); return { // Inherit base layout state @@ -116,16 +116,16 @@ function vendorProducts() { // Load i18n translations await I18n.loadModule('catalog'); - vendorProductsLog.info('Products init() called'); + storeProductsLog.info('Products init() called'); // Guard against multiple initialization - if (window._vendorProductsInitialized) { - vendorProductsLog.warn('Already initialized, skipping'); + if (window._storeProductsInitialized) { + storeProductsLog.warn('Already initialized, skipping'); return; } - window._vendorProductsInitialized = true; + window._storeProductsInitialized = 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); @@ -138,9 +138,9 @@ function vendorProducts() { await this.loadProducts(); - vendorProductsLog.info('Products initialization complete'); + storeProductsLog.info('Products initialization complete'); } catch (error) { - vendorProductsLog.error('Init failed:', error); + storeProductsLog.error('Init failed:', error); this.error = 'Failed to initialize products page'; } }, @@ -169,7 +169,7 @@ function vendorProducts() { params.append('is_featured', this.filters.featured === 'true'); } - const response = await apiClient.get(`/vendor/products?${params.toString()}`); + const response = await apiClient.get(`/store/products?${params.toString()}`); this.products = response.products || []; this.pagination.total = response.total || 0; @@ -183,9 +183,9 @@ function vendorProducts() { featured: this.products.filter(p => p.is_featured).length }; - vendorProductsLog.info('Loaded products:', this.products.length, 'of', this.pagination.total); + storeProductsLog.info('Loaded products:', this.products.length, 'of', this.pagination.total); } catch (error) { - vendorProductsLog.error('Failed to load products:', error); + storeProductsLog.error('Failed to load products:', error); this.error = error.message || 'Failed to load products'; } finally { this.loading = false; @@ -230,15 +230,15 @@ function vendorProducts() { async toggleActive(product) { this.saving = true; try { - await apiClient.put(`/vendor/products/${product.id}/toggle-active`); + await apiClient.put(`/store/products/${product.id}/toggle-active`); product.is_active = !product.is_active; Utils.showToast( product.is_active ? I18n.t('catalog.messages.product_activated') : I18n.t('catalog.messages.product_deactivated'), 'success' ); - vendorProductsLog.info('Toggled product active:', product.id, product.is_active); + storeProductsLog.info('Toggled product active:', product.id, product.is_active); } catch (error) { - vendorProductsLog.error('Failed to toggle active:', error); + storeProductsLog.error('Failed to toggle active:', error); Utils.showToast(error.message || I18n.t('catalog.messages.failed_to_update_product'), 'error'); } finally { this.saving = false; @@ -251,15 +251,15 @@ function vendorProducts() { async toggleFeatured(product) { this.saving = true; try { - await apiClient.put(`/vendor/products/${product.id}/toggle-featured`); + await apiClient.put(`/store/products/${product.id}/toggle-featured`); product.is_featured = !product.is_featured; Utils.showToast( product.is_featured ? I18n.t('catalog.messages.product_marked_as_featured') : I18n.t('catalog.messages.product_unmarked_as_featured'), 'success' ); - vendorProductsLog.info('Toggled product featured:', product.id, product.is_featured); + storeProductsLog.info('Toggled product featured:', product.id, product.is_featured); } catch (error) { - vendorProductsLog.error('Failed to toggle featured:', error); + storeProductsLog.error('Failed to toggle featured:', error); Utils.showToast(error.message || I18n.t('catalog.messages.failed_to_update_product'), 'error'); } finally { this.saving = false; @@ -290,15 +290,15 @@ function vendorProducts() { this.saving = true; try { - await apiClient.delete(`/vendor/products/${this.selectedProduct.id}`); + await apiClient.delete(`/store/products/${this.selectedProduct.id}`); Utils.showToast(I18n.t('catalog.messages.product_deleted_successfully'), 'success'); - vendorProductsLog.info('Deleted product:', this.selectedProduct.id); + storeProductsLog.info('Deleted product:', this.selectedProduct.id); this.showDeleteModal = false; this.selectedProduct = null; await this.loadProducts(); } catch (error) { - vendorProductsLog.error('Failed to delete product:', error); + storeProductsLog.error('Failed to delete product:', error); Utils.showToast(error.message || I18n.t('catalog.messages.failed_to_delete_product'), 'error'); } finally { this.saving = false; @@ -309,14 +309,14 @@ function vendorProducts() { * Navigate to edit product page */ editProduct(product) { - window.location.href = `/vendor/${this.vendorCode}/products/${product.id}/edit`; + window.location.href = `/store/${this.storeCode}/products/${product.id}/edit`; }, /** * Navigate to create product page */ createProduct() { - window.location.href = `/vendor/${this.vendorCode}/products/create`; + window.location.href = `/store/${this.storeCode}/products/create`; }, /** @@ -324,8 +324,8 @@ function vendorProducts() { */ formatPrice(cents) { if (!cents && cents !== 0) return '-'; - const locale = window.VENDOR_CONFIG?.locale || 'en-GB'; - const currency = window.VENDOR_CONFIG?.currency || 'EUR'; + const locale = window.STORE_CONFIG?.locale || 'en-GB'; + const currency = window.STORE_CONFIG?.currency || 'EUR'; return new Intl.NumberFormat(locale, { style: 'currency', currency: currency @@ -415,7 +415,7 @@ function vendorProducts() { for (const productId of this.selectedProducts) { const product = this.products.find(p => p.id === productId); if (product && !product.is_active) { - await apiClient.put(`/vendor/products/${productId}/toggle-active`); + await apiClient.put(`/store/products/${productId}/toggle-active`); product.is_active = true; successCount++; } @@ -424,7 +424,7 @@ function vendorProducts() { this.clearSelection(); await this.loadProducts(); } catch (error) { - vendorProductsLog.error('Bulk activate failed:', error); + storeProductsLog.error('Bulk activate failed:', error); Utils.showToast(error.message || I18n.t('catalog.messages.failed_to_activate_products'), 'error'); } finally { this.saving = false; @@ -443,7 +443,7 @@ function vendorProducts() { for (const productId of this.selectedProducts) { const product = this.products.find(p => p.id === productId); if (product && product.is_active) { - await apiClient.put(`/vendor/products/${productId}/toggle-active`); + await apiClient.put(`/store/products/${productId}/toggle-active`); product.is_active = false; successCount++; } @@ -452,7 +452,7 @@ function vendorProducts() { this.clearSelection(); await this.loadProducts(); } catch (error) { - vendorProductsLog.error('Bulk deactivate failed:', error); + storeProductsLog.error('Bulk deactivate failed:', error); Utils.showToast(error.message || I18n.t('catalog.messages.failed_to_deactivate_products'), 'error'); } finally { this.saving = false; @@ -471,7 +471,7 @@ function vendorProducts() { for (const productId of this.selectedProducts) { const product = this.products.find(p => p.id === productId); if (product && !product.is_featured) { - await apiClient.put(`/vendor/products/${productId}/toggle-featured`); + await apiClient.put(`/store/products/${productId}/toggle-featured`); product.is_featured = true; successCount++; } @@ -480,7 +480,7 @@ function vendorProducts() { this.clearSelection(); await this.loadProducts(); } catch (error) { - vendorProductsLog.error('Bulk set featured failed:', error); + storeProductsLog.error('Bulk set featured failed:', error); Utils.showToast(error.message || I18n.t('catalog.messages.failed_to_update_product'), 'error'); } finally { this.saving = false; @@ -499,7 +499,7 @@ function vendorProducts() { for (const productId of this.selectedProducts) { const product = this.products.find(p => p.id === productId); if (product && product.is_featured) { - await apiClient.put(`/vendor/products/${productId}/toggle-featured`); + await apiClient.put(`/store/products/${productId}/toggle-featured`); product.is_featured = false; successCount++; } @@ -508,7 +508,7 @@ function vendorProducts() { this.clearSelection(); await this.loadProducts(); } catch (error) { - vendorProductsLog.error('Bulk remove featured failed:', error); + storeProductsLog.error('Bulk remove featured failed:', error); Utils.showToast(error.message || I18n.t('catalog.messages.failed_to_update_product'), 'error'); } finally { this.saving = false; @@ -533,7 +533,7 @@ function vendorProducts() { try { let successCount = 0; for (const productId of this.selectedProducts) { - await apiClient.delete(`/vendor/products/${productId}`); + await apiClient.delete(`/store/products/${productId}`); successCount++; } Utils.showToast(I18n.t('catalog.messages.products_deleted', { count: successCount }), 'success'); @@ -541,7 +541,7 @@ function vendorProducts() { this.clearSelection(); await this.loadProducts(); } catch (error) { - vendorProductsLog.error('Bulk delete failed:', error); + storeProductsLog.error('Bulk delete failed:', error); Utils.showToast(error.message || I18n.t('catalog.messages.failed_to_delete_product'), 'error'); } finally { this.saving = false; diff --git a/app/modules/catalog/templates/catalog/admin/vendor-product-create.html b/app/modules/catalog/templates/catalog/admin/store-product-create.html similarity index 95% rename from app/modules/catalog/templates/catalog/admin/vendor-product-create.html rename to app/modules/catalog/templates/catalog/admin/store-product-create.html index 138eb189..bddef428 100644 --- a/app/modules/catalog/templates/catalog/admin/vendor-product-create.html +++ b/app/modules/catalog/templates/catalog/admin/store-product-create.html @@ -1,12 +1,12 @@ -{# app/templates/admin/vendor-product-create.html #} +{# app/templates/admin/store-product-create.html #} {% extends "admin/base.html" %} {% from 'shared/macros/headers.html' import detail_page_header %} {% from 'shared/macros/modals.html' import media_picker_modal %} {% from 'shared/macros/richtext.html' import quill_css, quill_js, quill_editor %} -{% block title %}Create Vendor Product{% endblock %} +{% block title %}Create Store Product{% endblock %} -{% block alpine_data %}adminVendorProductCreate(){% endblock %} +{% block alpine_data %}adminStoreProductCreate(){% endblock %} {% block quill_css %} {{ quill_css() }} @@ -21,7 +21,7 @@ @@ -108,8 +108,8 @@
- -
+ +

@@ -117,8 +117,8 @@
- - +
@@ -205,7 +205,7 @@
- letzshop.lu/.../vendors/ + letzshop.lu/.../stores/ @@ -378,6 +378,6 @@ - + diff --git a/app/modules/messaging/__init__.py b/app/modules/messaging/__init__.py index c5042c18..c616b8ec 100644 --- a/app/modules/messaging/__init__.py +++ b/app/modules/messaging/__init__.py @@ -5,7 +5,7 @@ Messaging Module - Internal messaging and notifications. This is a self-contained module providing: - Internal messages between users - Customer communication -- Admin-vendor-customer conversations +- Admin-store-customer conversations - Notification center - Message attachments diff --git a/app/modules/messaging/definition.py b/app/modules/messaging/definition.py index 39f49d56..e7666697 100644 --- a/app/modules/messaging/definition.py +++ b/app/modules/messaging/definition.py @@ -17,11 +17,18 @@ def _get_admin_router(): return admin_router -def _get_vendor_router(): - """Lazy import of vendor router to avoid circular imports.""" - from app.modules.messaging.routes.vendor import vendor_router +def _get_store_router(): + """Lazy import of store router to avoid circular imports.""" + from app.modules.messaging.routes.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.messaging.services.messaging_features import messaging_feature_provider + + return messaging_feature_provider # Messaging module definition @@ -66,9 +73,9 @@ messaging_module = ModuleDefinition( "messages", # Admin messages "notifications", # Admin notifications ], - FrontendType.VENDOR: [ - "messages", # Vendor messages - "notifications", # Vendor notifications + FrontendType.STORE: [ + "messages", # Store messages + "notifications", # Store notifications ], }, # New module-driven menu definitions @@ -120,7 +127,7 @@ messaging_module = ModuleDefinition( ], ), ], - FrontendType.VENDOR: [ + FrontendType.STORE: [ MenuSectionDefinition( id="customers", label_key="messaging.menu.customers", @@ -131,14 +138,14 @@ messaging_module = ModuleDefinition( id="messages", label_key="messaging.menu.messages", icon="chat-bubble-left-right", - route="/vendor/{vendor_code}/messages", + route="/store/{store_code}/messages", order=20, ), MenuItemDefinition( id="notifications", label_key="messaging.menu.notifications", icon="bell", - route="/vendor/{vendor_code}/notifications", + route="/store/{store_code}/notifications", order=30, ), ], @@ -153,7 +160,7 @@ messaging_module = ModuleDefinition( id="email-templates", label_key="messaging.menu.email_templates", icon="mail", - route="/vendor/{vendor_code}/email-templates", + route="/store/{store_code}/email-templates", order=40, ), ], @@ -169,6 +176,8 @@ messaging_module = ModuleDefinition( models_path="app.modules.messaging.models", schemas_path="app.modules.messaging.schemas", exceptions_path="app.modules.messaging.exceptions", + # Feature provider for feature flags + feature_provider=_get_feature_provider, ) @@ -180,7 +189,7 @@ def get_messaging_module_with_routers() -> ModuleDefinition: during module initialization. """ messaging_module.admin_router = _get_admin_router() - messaging_module.vendor_router = _get_vendor_router() + messaging_module.store_router = _get_store_router() return messaging_module diff --git a/app/modules/messaging/locales/de.json b/app/modules/messaging/locales/de.json index fe8a1f20..7be6c338 100644 --- a/app/modules/messaging/locales/de.json +++ b/app/modules/messaging/locales/de.json @@ -36,5 +36,19 @@ "failed_to_load_alerts": "Failed to load alerts", "alert_resolved_successfully": "Alert resolved successfully", "failed_to_resolve_alert": "Failed to resolve alert" + }, + "features": { + "messaging_basic": { + "name": "Basis-Nachrichten", + "description": "Grundlegende Nachrichtenfunktionalität" + }, + "email_templates": { + "name": "E-Mail-Vorlagen", + "description": "Anpassbare E-Mail-Vorlagen" + }, + "bulk_messaging": { + "name": "Massennachrichten", + "description": "Massennachrichten an Kunden senden" + } } } diff --git a/app/modules/messaging/locales/en.json b/app/modules/messaging/locales/en.json index a9f8ee6d..ac3e837d 100644 --- a/app/modules/messaging/locales/en.json +++ b/app/modules/messaging/locales/en.json @@ -45,5 +45,19 @@ "close_conversation": "Close this conversation?", "close_conversation_admin": "Are you sure you want to close this conversation?", "delete_customization": "Are you sure you want to delete your customization and revert to the platform default?" + }, + "features": { + "messaging_basic": { + "name": "Basic Messaging", + "description": "Basic messaging functionality" + }, + "email_templates": { + "name": "Email Templates", + "description": "Customizable email templates" + }, + "bulk_messaging": { + "name": "Bulk Messaging", + "description": "Send bulk messages to customers" + } } } diff --git a/app/modules/messaging/locales/fr.json b/app/modules/messaging/locales/fr.json index 80091f0a..c11e0cd3 100644 --- a/app/modules/messaging/locales/fr.json +++ b/app/modules/messaging/locales/fr.json @@ -36,5 +36,19 @@ "failed_to_load_alerts": "Failed to load alerts", "alert_resolved_successfully": "Alert resolved successfully", "failed_to_resolve_alert": "Failed to resolve alert" + }, + "features": { + "messaging_basic": { + "name": "Messagerie de base", + "description": "Fonctionnalité de messagerie de base" + }, + "email_templates": { + "name": "Modèles d'e-mail", + "description": "Modèles d'e-mail personnalisables" + }, + "bulk_messaging": { + "name": "Messagerie en masse", + "description": "Envoyer des messages en masse aux clients" + } } } diff --git a/app/modules/messaging/locales/lb.json b/app/modules/messaging/locales/lb.json index 2e880420..f055f574 100644 --- a/app/modules/messaging/locales/lb.json +++ b/app/modules/messaging/locales/lb.json @@ -36,5 +36,19 @@ "failed_to_load_alerts": "Failed to load alerts", "alert_resolved_successfully": "Alert resolved successfully", "failed_to_resolve_alert": "Failed to resolve alert" + }, + "features": { + "messaging_basic": { + "name": "Basis-Noriichten", + "description": "Grondleeënd Noriichtenfunktiounalitéit" + }, + "email_templates": { + "name": "E-Mail-Virlagen", + "description": "Upassbar E-Mail-Virlagen" + }, + "bulk_messaging": { + "name": "Massennoriichten", + "description": "Massennoriichten u Clienten schécken" + } } } diff --git a/app/modules/messaging/models/__init__.py b/app/modules/messaging/models/__init__.py index 18e5c66e..8b73f8b9 100644 --- a/app/modules/messaging/models/__init__.py +++ b/app/modules/messaging/models/__init__.py @@ -23,12 +23,12 @@ from app.modules.messaging.models.email import ( EmailStatus, EmailTemplate, ) -from app.modules.messaging.models.vendor_email_settings import ( +from app.modules.messaging.models.store_email_settings import ( EmailProvider, PREMIUM_EMAIL_PROVIDERS, - VendorEmailSettings, + StoreEmailSettings, ) -from app.modules.messaging.models.vendor_email_template import VendorEmailTemplate +from app.modules.messaging.models.store_email_template import StoreEmailTemplate __all__ = [ # Conversations and messages @@ -45,9 +45,9 @@ __all__ = [ "EmailLog", "EmailStatus", "EmailTemplate", - # Vendor email settings + # Store email settings "EmailProvider", "PREMIUM_EMAIL_PROVIDERS", - "VendorEmailSettings", - "VendorEmailTemplate", + "StoreEmailSettings", + "StoreEmailTemplate", ] diff --git a/app/modules/messaging/models/admin_notification.py b/app/modules/messaging/models/admin_notification.py index 77092f8f..72532a54 100644 --- a/app/modules/messaging/models/admin_notification.py +++ b/app/modules/messaging/models/admin_notification.py @@ -25,7 +25,7 @@ class AdminNotification(Base, TimestampMixin): """ Admin-specific notifications for system alerts and warnings. - Different from vendor/customer notifications - these are for platform + Different from store/customer notifications - these are for platform administrators to track system health and issues requiring attention. """ @@ -34,7 +34,7 @@ class AdminNotification(Base, TimestampMixin): id = Column(Integer, primary_key=True, index=True) type = Column( String(50), nullable=False, index=True - ) # system_alert, vendor_issue, import_failure + ) # system_alert, store_issue, import_failure priority = Column( String(20), default="normal", index=True ) # low, normal, high, critical diff --git a/app/modules/messaging/models/email.py b/app/modules/messaging/models/email.py index e5bce20b..74e424c7 100644 --- a/app/modules/messaging/models/email.py +++ b/app/modules/messaging/models/email.py @@ -6,9 +6,9 @@ Provides: - EmailTemplate: Multi-language email templates stored in database - EmailLog: Email sending history and tracking -Platform vs Vendor Templates: +Platform vs Store Templates: - Platform templates (EmailTemplate) are the defaults -- Vendors can override templates via VendorEmailTemplate +- Stores can override templates via StoreEmailTemplate - Platform-only templates (is_platform_only=True) cannot be overridden """ @@ -83,7 +83,7 @@ class EmailTemplate(Base, TimestampMixin): body_text = Column(Text, nullable=True) # Plain text fallback # Template variables (JSON list of expected variables) - # e.g., ["first_name", "company_name", "login_url"] + # e.g., ["first_name", "merchant_name", "login_url"] variables = Column(Text, nullable=True) # Required variables (JSON list of variables that MUST be provided) @@ -93,7 +93,7 @@ class EmailTemplate(Base, TimestampMixin): # Status is_active = Column(Boolean, default=True, nullable=False) - # Platform-only flag: if True, vendors cannot override this template + # Platform-only flag: if True, stores cannot override this template # Used for billing, subscription, and other platform-level emails is_platform_only = Column(Boolean, default=False, nullable=False) @@ -201,7 +201,7 @@ class EmailTemplate(Base, TimestampMixin): @classmethod def get_overridable_templates(cls, db: Session) -> list["EmailTemplate"]: """ - Get all templates that vendors can override. + Get all templates that stores can override. Returns: List of EmailTemplate objects where is_platform_only=False @@ -264,7 +264,7 @@ class EmailLog(Base, TimestampMixin): provider_message_id = Column(String(255), nullable=True, index=True) # Context linking (optional - link to related entities) - vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=True, index=True) + store_id = Column(Integer, ForeignKey("stores.id"), nullable=True, index=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) related_type = Column(String(50), nullable=True) # e.g., "order", "subscription" related_id = Column(Integer, nullable=True) @@ -274,7 +274,7 @@ class EmailLog(Base, TimestampMixin): # Relationships template = relationship("EmailTemplate", foreign_keys=[template_id]) - vendor = relationship("Vendor", foreign_keys=[vendor_id]) + store = relationship("Store", foreign_keys=[store_id]) user = relationship("User", foreign_keys=[user_id]) def __repr__(self): diff --git a/app/modules/messaging/models/message.py b/app/modules/messaging/models/message.py index f3d16516..59754a8b 100644 --- a/app/modules/messaging/models/message.py +++ b/app/modules/messaging/models/message.py @@ -3,11 +3,11 @@ Messaging system database models. Supports three communication channels: -- Admin <-> Vendor -- Vendor <-> Customer +- Admin <-> Store +- Store <-> Customer - Admin <-> Customer -Multi-tenant isolation is enforced via vendor_id for conversations +Multi-tenant isolation is enforced via store_id for conversations involving customers. """ @@ -35,8 +35,8 @@ from models.database.base import TimestampMixin class ConversationType(str, enum.Enum): """Defines the three supported conversation channels.""" - ADMIN_VENDOR = "admin_vendor" - VENDOR_CUSTOMER = "vendor_customer" + ADMIN_STORE = "admin_store" + STORE_CUSTOMER = "store_customer" ADMIN_CUSTOMER = "admin_customer" @@ -44,7 +44,7 @@ class ParticipantType(str, enum.Enum): """Type of participant in a conversation.""" ADMIN = "admin" # User with role="admin" - VENDOR = "vendor" # User with role="vendor" (via VendorUser) + STORE = "store" # User with role="store" (via StoreUser) CUSTOMER = "customer" # Customer model @@ -57,7 +57,7 @@ class Conversation(Base, TimestampMixin): """ Represents a threaded conversation between participants. - Multi-tenancy: vendor_id is required for vendor_customer and admin_customer + Multi-tenancy: store_id is required for store_customer and admin_customer conversations to ensure customer data isolation. """ @@ -75,11 +75,11 @@ class Conversation(Base, TimestampMixin): # Subject line for the conversation thread subject = Column(String(500), nullable=False) - # For vendor_customer and admin_customer conversations + # For store_customer and admin_customer conversations # Required for multi-tenant data isolation - vendor_id = Column( + store_id = Column( Integer, - ForeignKey("vendors.id"), + ForeignKey("stores.id"), nullable=True, index=True, ) @@ -95,7 +95,7 @@ class Conversation(Base, TimestampMixin): message_count = Column(Integer, default=0, nullable=False) # Relationships - vendor = relationship("Vendor", foreign_keys=[vendor_id]) + store = relationship("Store", foreign_keys=[store_id]) participants = relationship( "ConversationParticipant", back_populates="conversation", @@ -110,7 +110,7 @@ class Conversation(Base, TimestampMixin): # Indexes for common queries __table_args__ = ( - Index("ix_conversations_type_vendor", "conversation_type", "vendor_id"), + Index("ix_conversations_type_store", "conversation_type", "store_id"), ) def __repr__(self) -> str: @@ -125,7 +125,7 @@ class ConversationParticipant(Base, TimestampMixin): Links participants (users or customers) to conversations. Polymorphic relationship: - - participant_type="admin" or "vendor": references users.id + - participant_type="admin" or "store": references users.id - participant_type="customer": references customers.id """ @@ -143,10 +143,10 @@ class ConversationParticipant(Base, TimestampMixin): participant_type = Column(Enum(ParticipantType, values_callable=_enum_values), nullable=False) participant_id = Column(Integer, nullable=False, index=True) - # For vendor participants, track which vendor they represent - vendor_id = Column( + # For store participants, track which store they represent + store_id = Column( Integer, - ForeignKey("vendors.id"), + ForeignKey("stores.id"), nullable=True, ) @@ -160,7 +160,7 @@ class ConversationParticipant(Base, TimestampMixin): # Relationships conversation = relationship("Conversation", back_populates="participants") - vendor = relationship("Vendor", foreign_keys=[vendor_id]) + store = relationship("Store", foreign_keys=[store_id]) __table_args__ = ( UniqueConstraint( diff --git a/app/modules/messaging/models/vendor_email_settings.py b/app/modules/messaging/models/store_email_settings.py similarity index 89% rename from app/modules/messaging/models/vendor_email_settings.py rename to app/modules/messaging/models/store_email_settings.py index d0ecd840..20407b82 100644 --- a/app/modules/messaging/models/vendor_email_settings.py +++ b/app/modules/messaging/models/store_email_settings.py @@ -1,14 +1,14 @@ -# app/modules/messaging/models/vendor_email_settings.py +# app/modules/messaging/models/store_email_settings.py """ -Vendor Email Settings model for vendor-specific email configuration. +Store Email Settings model for store-specific email configuration. -This model stores vendor SMTP/email provider settings, enabling vendors to: +This model stores store SMTP/email provider settings, enabling stores to: - Send emails from their own domain/email address - Use their own SMTP server or email provider (tier-gated) - Customize sender name, reply-to address, and signature Architecture: -- Vendors MUST configure email settings to send transactional emails +- Stores MUST configure email settings to send transactional emails - Platform emails (billing, subscription) still use platform settings - Advanced providers (SendGrid, Mailgun, SES) are tier-gated (Business+) - "Powered by Wizamart" footer is added for Essential/Professional tiers @@ -50,20 +50,20 @@ PREMIUM_EMAIL_PROVIDERS = { } -class VendorEmailSettings(Base, TimestampMixin): +class StoreEmailSettings(Base, TimestampMixin): """ - Vendor email configuration for sending transactional emails. + Store email configuration for sending transactional emails. - This is a one-to-one relationship with Vendor. - Vendors must configure this to send emails to their customers. + This is a one-to-one relationship with Store. + Stores must configure this to send emails to their customers. """ - __tablename__ = "vendor_email_settings" + __tablename__ = "store_email_settings" id = Column(Integer, primary_key=True, index=True) - vendor_id = Column( + store_id = Column( Integer, - ForeignKey("vendors.id", ondelete="CASCADE"), + ForeignKey("stores.id", ondelete="CASCADE"), unique=True, nullable=False, index=True, @@ -72,8 +72,8 @@ class VendorEmailSettings(Base, TimestampMixin): # ========================================================================= # Sender Identity (Required) # ========================================================================= - from_email = Column(String(255), nullable=False) # e.g., orders@vendorshop.lu - from_name = Column(String(100), nullable=False) # e.g., "VendorShop" + from_email = Column(String(255), nullable=False) # e.g., orders@storeshop.lu + from_name = Column(String(100), nullable=False) # e.g., "StoreShop" reply_to_email = Column(String(255), nullable=True) # Optional reply-to address # ========================================================================= @@ -130,17 +130,17 @@ class VendorEmailSettings(Base, TimestampMixin): # ========================================================================= # Relationship # ========================================================================= - vendor = relationship("Vendor", back_populates="email_settings") + store = relationship("Store", back_populates="email_settings") # ========================================================================= # Indexes # ========================================================================= __table_args__ = ( - Index("idx_vendor_email_settings_configured", "vendor_id", "is_configured"), + Index("idx_vendor_email_settings_configured", "store_id", "is_configured"), ) def __repr__(self) -> str: - return f"" + return f"" # ========================================================================= # Helper Methods @@ -221,7 +221,7 @@ class VendorEmailSettings(Base, TimestampMixin): """Convert to dictionary for API responses (excludes sensitive data).""" return { "id": self.id, - "vendor_id": self.vendor_id, + "store_id": self.store_id, "from_email": self.from_email, "from_name": self.from_name, "reply_to_email": self.reply_to_email, @@ -255,4 +255,4 @@ class VendorEmailSettings(Base, TimestampMixin): } -__all__ = ["EmailProvider", "PREMIUM_EMAIL_PROVIDERS", "VendorEmailSettings"] +__all__ = ["EmailProvider", "PREMIUM_EMAIL_PROVIDERS", "StoreEmailSettings"] diff --git a/app/modules/messaging/models/vendor_email_template.py b/app/modules/messaging/models/store_email_template.py similarity index 69% rename from app/modules/messaging/models/vendor_email_template.py rename to app/modules/messaging/models/store_email_template.py index 18458c17..f367cb61 100644 --- a/app/modules/messaging/models/vendor_email_template.py +++ b/app/modules/messaging/models/store_email_template.py @@ -1,8 +1,8 @@ -# app/modules/messaging/models/vendor_email_template.py +# app/modules/messaging/models/store_email_template.py """ -Vendor email template override model. +Store email template override model. -Allows vendors to customize platform email templates with their own content. +Allows stores to customize platform email templates with their own content. Platform-only templates cannot be overridden (e.g., billing, subscription emails). """ @@ -23,30 +23,30 @@ from app.core.database import Base from models.database.base import TimestampMixin -class VendorEmailTemplate(Base, TimestampMixin): +class StoreEmailTemplate(Base, TimestampMixin): """ - Vendor-specific email template override. + Store-specific email template override. - Each vendor can customize email templates for their shop. + Each store can customize email templates for their shop. Overrides are per-template-code and per-language. When sending emails: - 1. Check if vendor has an override for the template+language - 2. If yes, use vendor's version + 1. Check if store has an override for the template+language + 2. If yes, use store's version 3. If no, fall back to platform template Platform-only templates (is_platform_only=True on EmailTemplate) cannot be overridden. """ - __tablename__ = "vendor_email_templates" + __tablename__ = "store_email_templates" id = Column(Integer, primary_key=True, index=True, autoincrement=True) - # Vendor relationship - vendor_id = Column( + # Store relationship + store_id = Column( Integer, - ForeignKey("vendors.id", ondelete="CASCADE"), + ForeignKey("stores.id", ondelete="CASCADE"), nullable=False, index=True, ) @@ -67,12 +67,12 @@ class VendorEmailTemplate(Base, TimestampMixin): is_active = Column(Boolean, default=True, nullable=False) # Relationships - vendor = relationship("Vendor", back_populates="email_templates") + store = relationship("Store", back_populates="email_templates") - # Unique constraint: one override per vendor+template+language + # Unique constraint: one override per store+template+language __table_args__ = ( UniqueConstraint( - "vendor_id", + "store_id", "template_code", "language", name="uq_vendor_email_template_code_language", @@ -82,8 +82,8 @@ class VendorEmailTemplate(Base, TimestampMixin): def __repr__(self): return ( - f"" ) @@ -92,26 +92,26 @@ class VendorEmailTemplate(Base, TimestampMixin): def get_override( cls, db: Session, - vendor_id: int, + store_id: int, template_code: str, language: str, - ) -> "VendorEmailTemplate | None": + ) -> "StoreEmailTemplate | None": """ - Get vendor's template override if it exists. + Get store's template override if it exists. Args: db: Database session - vendor_id: Vendor ID + store_id: Store ID template_code: Template code to look up language: Language code (en, fr, de, lb) Returns: - VendorEmailTemplate if override exists, None otherwise + StoreEmailTemplate if override exists, None otherwise """ return ( db.query(cls) .filter( - cls.vendor_id == vendor_id, + cls.store_id == store_id, cls.template_code == template_code, cls.language == language, cls.is_active == True, # noqa: E712 @@ -120,25 +120,25 @@ class VendorEmailTemplate(Base, TimestampMixin): ) @classmethod - def get_all_overrides_for_vendor( + def get_all_overrides_for_store( cls, db: Session, - vendor_id: int, - ) -> list["VendorEmailTemplate"]: + store_id: int, + ) -> list["StoreEmailTemplate"]: """ - Get all template overrides for a vendor. + Get all template overrides for a store. Args: db: Database session - vendor_id: Vendor ID + store_id: Store ID Returns: - List of VendorEmailTemplate objects + List of StoreEmailTemplate objects """ return ( db.query(cls) .filter( - cls.vendor_id == vendor_id, + cls.store_id == store_id, cls.is_active == True, # noqa: E712 ) .order_by(cls.template_code, cls.language) @@ -149,20 +149,20 @@ class VendorEmailTemplate(Base, TimestampMixin): def create_or_update( cls, db: Session, - vendor_id: int, + store_id: int, template_code: str, language: str, subject: str, body_html: str, body_text: str | None = None, name: str | None = None, - ) -> "VendorEmailTemplate": + ) -> "StoreEmailTemplate": """ - Create or update a vendor email template override. + Create or update a store email template override. Args: db: Database session - vendor_id: Vendor ID + store_id: Store ID template_code: Template code language: Language code subject: Email subject @@ -171,9 +171,9 @@ class VendorEmailTemplate(Base, TimestampMixin): name: Optional custom name Returns: - Created or updated VendorEmailTemplate + Created or updated StoreEmailTemplate """ - existing = cls.get_override(db, vendor_id, template_code, language) + existing = cls.get_override(db, store_id, template_code, language) if existing: existing.subject = subject @@ -184,7 +184,7 @@ class VendorEmailTemplate(Base, TimestampMixin): return existing new_template = cls( - vendor_id=vendor_id, + store_id=store_id, template_code=template_code, language=language, subject=subject, @@ -199,16 +199,16 @@ class VendorEmailTemplate(Base, TimestampMixin): def delete_override( cls, db: Session, - vendor_id: int, + store_id: int, template_code: str, language: str, ) -> bool: """ - Delete a vendor's template override (revert to platform default). + Delete a store's template override (revert to platform default). Args: db: Database session - vendor_id: Vendor ID + store_id: Store ID template_code: Template code language: Language code @@ -218,7 +218,7 @@ class VendorEmailTemplate(Base, TimestampMixin): deleted = ( db.query(cls) .filter( - cls.vendor_id == vendor_id, + cls.store_id == store_id, cls.template_code == template_code, cls.language == language, ) @@ -227,4 +227,4 @@ class VendorEmailTemplate(Base, TimestampMixin): return deleted > 0 -__all__ = ["VendorEmailTemplate"] +__all__ = ["StoreEmailTemplate"] diff --git a/app/modules/messaging/routes/__init__.py b/app/modules/messaging/routes/__init__.py index 5d6e2521..17df68ee 100644 --- a/app/modules/messaging/routes/__init__.py +++ b/app/modules/messaging/routes/__init__.py @@ -6,15 +6,15 @@ This module provides functions to register messaging routes with module-based access control. NOTE: Routers are NOT auto-imported to avoid circular dependencies. -Import directly from admin.py or vendor.py as needed: +Import directly from admin.py or store.py as needed: from app.modules.messaging.routes.admin import admin_router, admin_notifications_router - from app.modules.messaging.routes.vendor import vendor_router, vendor_notifications_router + from app.modules.messaging.routes.store import store_router, store_notifications_router """ # Routers are imported on-demand to avoid circular dependencies # Do NOT add auto-imports here -__all__ = ["admin_router", "admin_notifications_router", "vendor_router", "vendor_notifications_router"] +__all__ = ["admin_router", "admin_notifications_router", "store_router", "store_notifications_router"] def __getattr__(name: str): @@ -25,10 +25,10 @@ def __getattr__(name: str): elif name == "admin_notifications_router": from app.modules.messaging.routes.admin import admin_notifications_router return admin_notifications_router - elif name == "vendor_router": - from app.modules.messaging.routes.vendor import vendor_router - return vendor_router - elif name == "vendor_notifications_router": - from app.modules.messaging.routes.vendor import vendor_notifications_router - return vendor_notifications_router + elif name == "store_router": + from app.modules.messaging.routes.store import store_router + return store_router + elif name == "store_notifications_router": + from app.modules.messaging.routes.store import store_notifications_router + return store_notifications_router raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/app/modules/messaging/routes/api/__init__.py b/app/modules/messaging/routes/api/__init__.py index 943fef8c..7bf93b39 100644 --- a/app/modules/messaging/routes/api/__init__.py +++ b/app/modules/messaging/routes/api/__init__.py @@ -6,9 +6,9 @@ Admin routes: - /messages/* - Conversation and message management - /notifications/* - Admin notifications and platform alerts -Vendor routes: +Store routes: - /messages/* - Conversation and message management -- /notifications/* - Vendor notifications +- /notifications/* - Store notifications - /email-settings/* - SMTP and provider configuration - /email-templates/* - Email template customization @@ -18,9 +18,9 @@ Storefront routes: from app.modules.messaging.routes.api.admin import admin_router from app.modules.messaging.routes.api.storefront import router as storefront_router -from app.modules.messaging.routes.api.vendor import vendor_router +from app.modules.messaging.routes.api.store import store_router # Tag for OpenAPI documentation STOREFRONT_TAG = "Messages (Storefront)" -__all__ = ["admin_router", "storefront_router", "vendor_router", "STOREFRONT_TAG"] +__all__ = ["admin_router", "storefront_router", "store_router", "STOREFRONT_TAG"] diff --git a/app/modules/messaging/routes/api/admin_email_templates.py b/app/modules/messaging/routes/api/admin_email_templates.py index 547185e3..56cd9df3 100644 --- a/app/modules/messaging/routes/api/admin_email_templates.py +++ b/app/modules/messaging/routes/api/admin_email_templates.py @@ -286,9 +286,9 @@ def _get_sample_variables(template_code: str) -> dict[str, Any]: samples = { "signup_welcome": { "first_name": "John", - "company_name": "Acme Corp", + "merchant_name": "Acme Corp", "email": "john@example.com", - "vendor_code": "acme", + "store_code": "acme", "login_url": "https://example.com/login", "trial_days": "14", "tier_name": "Business", @@ -312,14 +312,14 @@ def _get_sample_variables(template_code: str) -> dict[str, Any]: "team_invite": { "invitee_name": "Jane", "inviter_name": "John", - "vendor_name": "Acme Corp", + "store_name": "Acme Corp", "role": "Admin", "accept_url": "https://example.com/accept", "expires_in_days": "7", "platform_name": "Wizamart", }, "subscription_welcome": { - "vendor_name": "Acme Corp", + "store_name": "Acme Corp", "tier_name": "Business", "billing_cycle": "Monthly", "amount": "€49.99", @@ -328,7 +328,7 @@ def _get_sample_variables(template_code: str) -> dict[str, Any]: "platform_name": "Wizamart", }, "payment_failed": { - "vendor_name": "Acme Corp", + "store_name": "Acme Corp", "tier_name": "Business", "amount": "€49.99", "retry_date": "2024-01-18", @@ -337,14 +337,14 @@ def _get_sample_variables(template_code: str) -> dict[str, Any]: "platform_name": "Wizamart", }, "subscription_cancelled": { - "vendor_name": "Acme Corp", + "store_name": "Acme Corp", "tier_name": "Business", "end_date": "2024-02-15", "reactivate_url": "https://example.com/billing", "platform_name": "Wizamart", }, "trial_ending": { - "vendor_name": "Acme Corp", + "store_name": "Acme Corp", "tier_name": "Business", "days_remaining": "3", "trial_end_date": "2024-01-18", diff --git a/app/modules/messaging/routes/api/admin_messages.py b/app/modules/messaging/routes/api/admin_messages.py index 9e227cea..7716182f 100644 --- a/app/modules/messaging/routes/api/admin_messages.py +++ b/app/modules/messaging/routes/api/admin_messages.py @@ -3,7 +3,7 @@ Admin messaging endpoints. Provides endpoints for: -- Viewing conversations (admin_vendor and admin_customer channels) +- Viewing conversations (admin_store and admin_customer channels) - Sending and receiving messages - Managing conversation status - File attachments @@ -147,18 +147,18 @@ def _enrich_conversation_summary( preview += "..." last_message_preview = preview - # Get vendor info if applicable - vendor_name = None - vendor_code = None - if conversation.vendor: - vendor_name = conversation.vendor.name - vendor_code = conversation.vendor.vendor_code + # Get store info if applicable + store_name = None + store_code = None + if conversation.store: + store_name = conversation.store.name + store_code = conversation.store.store_code return AdminConversationSummary( id=conversation.id, conversation_type=conversation.conversation_type, subject=conversation.subject, - vendor_id=conversation.vendor_id, + store_id=conversation.store_id, is_closed=conversation.is_closed, closed_at=conversation.closed_at, last_message_at=conversation.last_message_at, @@ -167,8 +167,8 @@ def _enrich_conversation_summary( unread_count=unread_count, other_participant=other_info, last_message_preview=last_message_preview, - vendor_name=vendor_name, - vendor_code=vendor_code, + store_name=store_name, + store_code=store_code, ) @@ -186,7 +186,7 @@ def list_conversations( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), ) -> AdminConversationListResponse: - """List conversations for admin (admin_vendor and admin_customer channels).""" + """List conversations for admin (admin_store and admin_customer channels).""" conversations, total, total_unread = messaging_service.list_conversations( db=db, participant_type=ParticipantType.ADMIN, @@ -231,17 +231,17 @@ def get_unread_count( def get_recipients( recipient_type: ParticipantType = Query(..., description="Type of recipients to list"), search: str | None = Query(None, description="Search by name/email"), - vendor_id: int | None = Query(None, description="Filter by vendor"), + store_id: int | None = Query(None, description="Filter by store"), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), ) -> RecipientListResponse: """Get list of available recipients for compose modal.""" - if recipient_type == ParticipantType.VENDOR: - recipient_data, total = messaging_service.get_vendor_recipients( + if recipient_type == ParticipantType.STORE: + recipient_data, total = messaging_service.get_store_recipients( db=db, - vendor_id=vendor_id, + store_id=store_id, search=search, skip=skip, limit=limit, @@ -252,15 +252,15 @@ def get_recipients( type=r["type"], name=r["name"], email=r["email"], - vendor_id=r["vendor_id"], - vendor_name=r.get("vendor_name"), + store_id=r["store_id"], + store_name=r.get("store_name"), ) for r in recipient_data ] elif recipient_type == ParticipantType.CUSTOMER: recipient_data, total = messaging_service.get_customer_recipients( db=db, - vendor_id=vendor_id, + store_id=store_id, search=search, skip=skip, limit=limit, @@ -271,7 +271,7 @@ def get_recipients( type=r["type"], name=r["name"], email=r["email"], - vendor_id=r["vendor_id"], + store_id=r["store_id"], ) for r in recipient_data ] @@ -296,22 +296,22 @@ def create_conversation( """Create a new conversation.""" # Validate conversation type for admin if data.conversation_type not in [ - ConversationType.ADMIN_VENDOR, + ConversationType.ADMIN_STORE, ConversationType.ADMIN_CUSTOMER, ]: raise InvalidConversationTypeException( - message="Admin can only create admin_vendor or admin_customer conversations", - allowed_types=["admin_vendor", "admin_customer"], + message="Admin can only create admin_store or admin_customer conversations", + allowed_types=["admin_store", "admin_customer"], ) # Validate recipient type matches conversation type if ( - data.conversation_type == ConversationType.ADMIN_VENDOR - and data.recipient_type != ParticipantType.VENDOR + data.conversation_type == ConversationType.ADMIN_STORE + and data.recipient_type != ParticipantType.STORE ): raise InvalidRecipientTypeException( - conversation_type="admin_vendor", - expected_recipient_type="vendor", + conversation_type="admin_store", + expected_recipient_type="store", ) if ( data.conversation_type == ConversationType.ADMIN_CUSTOMER @@ -331,7 +331,7 @@ def create_conversation( initiator_id=current_admin.id, recipient_type=data.recipient_type, recipient_id=data.recipient_id, - vendor_id=data.vendor_id, + store_id=data.store_id, initial_message=data.initial_message, ) db.commit() @@ -398,16 +398,16 @@ def _build_conversation_detail( # Build message responses messages = [_enrich_message(db, m) for m in conversation.messages] - # Get vendor name if applicable - vendor_name = None - if conversation.vendor: - vendor_name = conversation.vendor.name + # Get store name if applicable + store_name = None + if conversation.store: + store_name = conversation.store.name return ConversationDetailResponse( id=conversation.id, conversation_type=conversation.conversation_type, subject=conversation.subject, - vendor_id=conversation.vendor_id, + store_id=conversation.store_id, is_closed=conversation.is_closed, closed_at=conversation.closed_at, closed_by_type=conversation.closed_by_type, @@ -419,7 +419,7 @@ def _build_conversation_detail( participants=participants, messages=messages, unread_count=unread_count, - vendor_name=vendor_name, + store_name=store_name, ) diff --git a/app/modules/messaging/routes/api/admin_notifications.py b/app/modules/messaging/routes/api/admin_notifications.py index 42ff571e..c257cd93 100644 --- a/app/modules/messaging/routes/api/admin_notifications.py +++ b/app/modules/messaging/routes/api/admin_notifications.py @@ -240,7 +240,7 @@ def get_platform_alerts( severity=a.severity, title=a.title, description=a.description, - affected_vendors=a.affected_vendors, + affected_stores=a.affected_stores, affected_systems=a.affected_systems, is_resolved=a.is_resolved, resolved_at=a.resolved_at, @@ -280,7 +280,7 @@ def create_platform_alert( severity=alert.severity, title=alert.title, description=alert.description, - affected_vendors=alert.affected_vendors, + affected_stores=alert.affected_stores, affected_systems=alert.affected_systems, is_resolved=alert.is_resolved, resolved_at=alert.resolved_at, diff --git a/app/modules/messaging/routes/api/store.py b/app/modules/messaging/routes/api/store.py new file mode 100644 index 00000000..85f5eca6 --- /dev/null +++ b/app/modules/messaging/routes/api/store.py @@ -0,0 +1,30 @@ +# app/modules/messaging/routes/api/store.py +""" +Messaging module store API routes. + +Aggregates all store messaging routes: +- /messages/* - Conversation and message management +- /notifications/* - Store notifications +- /email-settings/* - SMTP and provider configuration +- /email-templates/* - Email template customization +""" + +from fastapi import APIRouter, Depends + +from app.api.deps import require_module_access +from app.modules.enums import FrontendType + +from .store_messages import store_messages_router +from .store_notifications import store_notifications_router +from .store_email_settings import store_email_settings_router +from .store_email_templates import store_email_templates_router + +store_router = APIRouter( + dependencies=[Depends(require_module_access("messaging", FrontendType.STORE))], +) + +# Aggregate all messaging store routes +store_router.include_router(store_messages_router, tags=["store-messages"]) +store_router.include_router(store_notifications_router, tags=["store-notifications"]) +store_router.include_router(store_email_settings_router, tags=["store-email-settings"]) +store_router.include_router(store_email_templates_router, tags=["store-email-templates"]) diff --git a/app/modules/messaging/routes/api/vendor_email_settings.py b/app/modules/messaging/routes/api/store_email_settings.py similarity index 73% rename from app/modules/messaging/routes/api/vendor_email_settings.py rename to app/modules/messaging/routes/api/store_email_settings.py index 03b96fa4..6da695b8 100644 --- a/app/modules/messaging/routes/api/vendor_email_settings.py +++ b/app/modules/messaging/routes/api/store_email_settings.py @@ -1,15 +1,15 @@ -# app/modules/messaging/routes/api/vendor_email_settings.py +# app/modules/messaging/routes/api/store_email_settings.py """ -Vendor email settings API endpoints. +Store email settings API endpoints. -Allows vendors to configure their email sending settings: +Allows stores to configure their email sending settings: - SMTP configuration (all tiers) - Advanced providers: SendGrid, Mailgun, SES (Business+ tier) - Sender identity (from_email, from_name, reply_to) - Signature/footer customization - Configuration verification via test email -Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern). +Store Context: Uses token_store_id from JWT token (authenticated store API pattern). """ import logging @@ -18,13 +18,13 @@ from fastapi import APIRouter, Depends from pydantic import BaseModel, EmailStr, Field 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.cms.services.vendor_email_settings_service import vendor_email_settings_service +from app.modules.cms.services.store_email_settings_service import store_email_settings_service from app.modules.billing.services.subscription_service import subscription_service from models.schema.auth import UserContext -vendor_email_settings_router = APIRouter(prefix="/email-settings") +store_email_settings_router = APIRouter(prefix="/email-settings") logger = logging.getLogger(__name__) @@ -126,19 +126,19 @@ class EmailDeleteResponse(BaseModel): # ============================================================================= -@vendor_email_settings_router.get("", response_model=EmailSettingsResponse) +@store_email_settings_router.get("", response_model=EmailSettingsResponse) def get_email_settings( - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ) -> EmailSettingsResponse: """ - Get current email settings for the vendor. + Get current email settings for the store. Returns settings with sensitive fields masked. """ - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id - settings = vendor_email_settings_service.get_settings(db, vendor_id) + settings = store_email_settings_service.get_settings(db, store_id) if not settings: return EmailSettingsResponse( configured=False, @@ -153,9 +153,9 @@ def get_email_settings( ) -@vendor_email_settings_router.get("/status", response_model=EmailStatusResponse) +@store_email_settings_router.get("/status", response_model=EmailStatusResponse) def get_email_status( - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ) -> EmailStatusResponse: """ @@ -163,14 +163,14 @@ def get_email_status( Used by frontend to show warning banner if not configured. """ - vendor_id = current_user.token_vendor_id - status = vendor_email_settings_service.get_status(db, vendor_id) + store_id = current_user.token_store_id + status = store_email_settings_service.get_status(db, store_id) return EmailStatusResponse(**status) -@vendor_email_settings_router.get("/providers", response_model=ProvidersResponse) +@store_email_settings_router.get("/providers", response_model=ProvidersResponse) def get_available_providers( - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ) -> ProvidersResponse: """ @@ -178,21 +178,21 @@ def get_available_providers( Returns list of providers with availability status. """ - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id - # Get vendor's current tier - tier = subscription_service.get_current_tier(db, vendor_id) + # Get store's current tier + tier = subscription_service.get_current_tier(db, store_id) return ProvidersResponse( - providers=vendor_email_settings_service.get_available_providers(tier), + providers=store_email_settings_service.get_available_providers(tier), current_tier=tier.value if tier else None, ) -@vendor_email_settings_router.put("", response_model=EmailUpdateResponse) +@store_email_settings_router.put("", response_model=EmailUpdateResponse) def update_email_settings( data: EmailSettingsUpdate, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ) -> EmailUpdateResponse: """ @@ -202,15 +202,15 @@ def update_email_settings( Raises AuthorizationException if tier is insufficient. Raises ValidationException if data is invalid. """ - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id - # Get vendor's current tier for validation - tier = subscription_service.get_current_tier(db, vendor_id) + # Get store's current tier for validation + tier = subscription_service.get_current_tier(db, store_id) # Service raises appropriate exceptions (API-003 compliance) - settings = vendor_email_settings_service.create_or_update( + settings = store_email_settings_service.create_or_update( db=db, - vendor_id=vendor_id, + store_id=store_id, data=data.model_dump(exclude_unset=True), current_tier=tier, ) @@ -223,10 +223,10 @@ def update_email_settings( ) -@vendor_email_settings_router.post("/verify", response_model=EmailVerifyResponse) +@store_email_settings_router.post("/verify", response_model=EmailVerifyResponse) def verify_email_settings( data: VerifyEmailRequest, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ) -> EmailVerifyResponse: """ @@ -236,10 +236,10 @@ def verify_email_settings( Raises ResourceNotFoundException if settings not configured. Raises ValidationException if verification fails. """ - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id # Service raises appropriate exceptions (API-003 compliance) - result = vendor_email_settings_service.verify_settings(db, vendor_id, data.test_email) + result = store_email_settings_service.verify_settings(db, store_id, data.test_email) db.commit() return EmailVerifyResponse( @@ -248,21 +248,21 @@ def verify_email_settings( ) -@vendor_email_settings_router.delete("", response_model=EmailDeleteResponse) +@store_email_settings_router.delete("", response_model=EmailDeleteResponse) def delete_email_settings( - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ) -> EmailDeleteResponse: """ Delete email settings. - Warning: This will disable email sending for the vendor. + Warning: This will disable email sending for the store. Raises ResourceNotFoundException if settings not found. """ - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id # Service raises ResourceNotFoundException if not found (API-003 compliance) - vendor_email_settings_service.delete(db, vendor_id) + store_email_settings_service.delete(db, store_id) db.commit() return EmailDeleteResponse( diff --git a/app/modules/messaging/routes/api/vendor_email_templates.py b/app/modules/messaging/routes/api/store_email_templates.py similarity index 63% rename from app/modules/messaging/routes/api/vendor_email_templates.py rename to app/modules/messaging/routes/api/store_email_templates.py index 4acb3d28..c32390a8 100644 --- a/app/modules/messaging/routes/api/vendor_email_templates.py +++ b/app/modules/messaging/routes/api/store_email_templates.py @@ -1,11 +1,11 @@ -# app/modules/messaging/routes/api/vendor_email_templates.py +# app/modules/messaging/routes/api/store_email_templates.py """ -Vendor email template override endpoints. +Store email template override endpoints. -Allows vendors to customize platform email templates with their own content. +Allows stores to customize platform email templates with their own content. Platform-only templates (billing, subscription) cannot be overridden. -Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern). +Store Context: Uses token_store_id from JWT token (authenticated store API pattern). """ import logging @@ -15,14 +15,14 @@ from fastapi import APIRouter, Depends from pydantic import BaseModel, EmailStr, Field 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.messaging.services.email_service import EmailService from app.modules.messaging.services.email_template_service import EmailTemplateService -from app.modules.tenancy.services.vendor_service import vendor_service +from app.modules.tenancy.services.store_service import store_service from models.schema.auth import UserContext -vendor_email_templates_router = APIRouter(prefix="/email-templates") +store_email_templates_router = APIRouter(prefix="/email-templates") logger = logging.getLogger(__name__) @@ -31,8 +31,8 @@ logger = logging.getLogger(__name__) # ============================================================================= -class VendorTemplateUpdate(BaseModel): - """Schema for creating/updating a vendor template override.""" +class StoreTemplateUpdate(BaseModel): + """Schema for creating/updating a store template override.""" subject: str = Field(..., min_length=1, max_length=500) body_html: str = Field(..., min_length=1) @@ -60,74 +60,74 @@ class TemplateTestRequest(BaseModel): # ============================================================================= -@vendor_email_templates_router.get("") +@store_email_templates_router.get("") def list_overridable_templates( - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ - List all email templates that the vendor can customize. + List all email templates that the store can customize. - Returns platform templates with vendor override status. + Returns platform templates with store override status. Platform-only templates (billing, subscription) are excluded. """ - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id service = EmailTemplateService(db) - return service.list_overridable_templates(vendor_id) + return service.list_overridable_templates(store_id) -@vendor_email_templates_router.get("/{code}") +@store_email_templates_router.get("/{code}") def get_template( code: str, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ Get a specific template with all language versions. - Returns platform template details and vendor overrides for each language. + Returns platform template details and store overrides for each language. """ - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id service = EmailTemplateService(db) - return service.get_vendor_template(vendor_id, code) + return service.get_store_template(store_id, code) -@vendor_email_templates_router.get("/{code}/{language}") +@store_email_templates_router.get("/{code}/{language}") def get_template_language( code: str, language: str, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ Get a specific template for a specific language. - Returns vendor override if exists, otherwise platform template. + Returns store override if exists, otherwise platform template. """ - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id service = EmailTemplateService(db) - return service.get_vendor_template_language(vendor_id, code, language) + return service.get_store_template_language(store_id, code, language) -@vendor_email_templates_router.put("/{code}/{language}") +@store_email_templates_router.put("/{code}/{language}") def update_template_override( code: str, language: str, - template_data: VendorTemplateUpdate, - current_user: UserContext = Depends(get_current_vendor_api), + template_data: StoreTemplateUpdate, + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ - Create or update a vendor template override. + Create or update a store template override. - Creates a vendor-specific version of the email template. + Creates a store-specific version of the email template. The platform template remains unchanged. """ - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id service = EmailTemplateService(db) - result = service.create_or_update_vendor_override( - vendor_id=vendor_id, + result = service.create_or_update_store_override( + store_id=store_id, code=code, language=language, subject=template_data.subject, @@ -140,21 +140,21 @@ def update_template_override( return result -@vendor_email_templates_router.delete("/{code}/{language}") +@store_email_templates_router.delete("/{code}/{language}") def delete_template_override( code: str, language: str, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ - Delete a vendor template override. + Delete a store template override. Reverts to using the platform default template for this language. """ - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id service = EmailTemplateService(db) - service.delete_vendor_override(vendor_id, code, language) + service.delete_store_override(store_id, code, language) db.commit() return { @@ -164,20 +164,20 @@ def delete_template_override( } -@vendor_email_templates_router.post("/{code}/preview") +@store_email_templates_router.post("/{code}/preview") def preview_template( code: str, preview_data: TemplatePreviewRequest, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ Preview a template with sample variables. - Uses vendor override if exists, otherwise platform template. + Uses store override if exists, otherwise platform template. """ - vendor_id = current_user.token_vendor_id - vendor = vendor_service.get_vendor_by_id(db, vendor_id) + store_id = current_user.token_store_id + store = store_service.get_store_by_id(db, store_id) service = EmailTemplateService(db) # Add branding variables @@ -185,40 +185,40 @@ def preview_template( **_get_sample_variables(code), **preview_data.variables, "platform_name": "Wizamart", - "vendor_name": vendor.name if vendor else "Your Store", - "support_email": vendor.contact_email if vendor else "support@wizamart.com", + "store_name": store.name if store else "Your Store", + "support_email": store.contact_email if store else "support@wizamart.com", } - return service.preview_vendor_template( - vendor_id=vendor_id, + return service.preview_store_template( + store_id=store_id, code=code, language=preview_data.language, variables=variables, ) -@vendor_email_templates_router.post("/{code}/test") +@store_email_templates_router.post("/{code}/test") def send_test_email( code: str, test_data: TemplateTestRequest, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ Send a test email using the template. - Uses vendor override if exists, otherwise platform template. + Uses store override if exists, otherwise platform template. """ - vendor_id = current_user.token_vendor_id - vendor = vendor_service.get_vendor_by_id(db, vendor_id) + store_id = current_user.token_store_id + store = store_service.get_store_by_id(db, store_id) # Build test variables variables = { **_get_sample_variables(code), **test_data.variables, "platform_name": "Wizamart", - "vendor_name": vendor.name if vendor else "Your Store", - "support_email": vendor.contact_email if vendor else "support@wizamart.com", + "store_name": store.name if store else "Your Store", + "support_email": store.contact_email if store else "support@wizamart.com", } try: @@ -227,7 +227,7 @@ def send_test_email( template_code=code, to_email=test_data.to_email, variables=variables, - vendor_id=vendor_id, + store_id=store_id, language=test_data.language, ) @@ -259,9 +259,9 @@ def _get_sample_variables(template_code: str) -> dict[str, Any]: samples = { "signup_welcome": { "first_name": "John", - "company_name": "Acme Corp", + "merchant_name": "Acme Corp", "email": "john@example.com", - "vendor_code": "acme", + "store_code": "acme", "login_url": "https://example.com/login", "trial_days": "14", "tier_name": "Business", @@ -282,7 +282,7 @@ def _get_sample_variables(template_code: str) -> dict[str, Any]: "team_invite": { "invitee_name": "Jane", "inviter_name": "John", - "vendor_name": "Acme Corp", + "store_name": "Acme Corp", "role": "Admin", "accept_url": "https://example.com/accept", "expires_in_days": "7", diff --git a/app/modules/messaging/routes/api/vendor_messages.py b/app/modules/messaging/routes/api/store_messages.py similarity index 76% rename from app/modules/messaging/routes/api/vendor_messages.py rename to app/modules/messaging/routes/api/store_messages.py index c741bcbd..923f9c54 100644 --- a/app/modules/messaging/routes/api/vendor_messages.py +++ b/app/modules/messaging/routes/api/store_messages.py @@ -1,14 +1,14 @@ -# app/modules/messaging/routes/api/vendor_messages.py +# app/modules/messaging/routes/api/store_messages.py """ -Vendor messaging endpoints. +Store messaging endpoints. Provides endpoints for: -- Viewing conversations (vendor_customer and admin_vendor channels) +- Viewing conversations (store_customer and admin_store channels) - Sending and receiving messages - Managing conversation status - File attachments -Uses get_current_vendor_api dependency which guarantees token_vendor_id is present. +Uses get_current_store_api dependency which guarantees token_store_id is present. """ import logging @@ -18,7 +18,7 @@ from fastapi import APIRouter, Depends, File, Form, Query, UploadFile from pydantic import BaseModel 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.messaging.exceptions import ( ConversationClosedException, @@ -49,7 +49,7 @@ from app.modules.messaging.schemas import ( ) from models.schema.auth import UserContext -vendor_messages_router = APIRouter(prefix="/messages") +store_messages_router = APIRouter(prefix="/messages") logger = logging.getLogger(__name__) @@ -106,7 +106,7 @@ def _enrich_message( def _enrich_conversation_summary( - db: Session, conversation: Any, current_user_id: int, vendor_id: int + db: Session, conversation: Any, current_user_id: int, store_id: int ) -> ConversationSummary: """Enrich conversation with other participant info and unread count.""" # Get current user's participant record @@ -114,9 +114,9 @@ def _enrich_conversation_summary( ( p for p in conversation.participants - if p.participant_type == ParticipantType.VENDOR + if p.participant_type == ParticipantType.STORE and p.participant_id == current_user_id - and p.vendor_id == vendor_id + and p.store_id == store_id ), None, ) @@ -124,7 +124,7 @@ def _enrich_conversation_summary( # Get other participant info other = messaging_service.get_other_participant( - conversation, ParticipantType.VENDOR, current_user_id + conversation, ParticipantType.STORE, current_user_id ) other_info = None if other: @@ -153,7 +153,7 @@ def _enrich_conversation_summary( id=conversation.id, conversation_type=conversation.conversation_type, subject=conversation.subject, - vendor_id=conversation.vendor_id, + store_id=conversation.store_id, is_closed=conversation.is_closed, closed_at=conversation.closed_at, last_message_at=conversation.last_message_at, @@ -170,23 +170,23 @@ def _enrich_conversation_summary( # ============================================================================ -@vendor_messages_router.get("", response_model=ConversationListResponse) +@store_messages_router.get("", response_model=ConversationListResponse) def list_conversations( conversation_type: ConversationType | None = Query(None, description="Filter by type"), is_closed: bool | None = Query(None, description="Filter by status"), skip: int = Query(0, ge=0), limit: int = Query(20, ge=1, le=100), db: Session = Depends(get_db), - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), ) -> ConversationListResponse: - """List conversations for vendor (vendor_customer and admin_vendor channels).""" - vendor_id = current_user.token_vendor_id + """List conversations for store (store_customer and admin_store channels).""" + store_id = current_user.token_store_id conversations, total, total_unread = messaging_service.list_conversations( db=db, - participant_type=ParticipantType.VENDOR, + participant_type=ParticipantType.STORE, participant_id=current_user.id, - vendor_id=vendor_id, + store_id=store_id, conversation_type=conversation_type, is_closed=is_closed, skip=skip, @@ -195,7 +195,7 @@ def list_conversations( return ConversationListResponse( conversations=[ - _enrich_conversation_summary(db, c, current_user.id, vendor_id) + _enrich_conversation_summary(db, c, current_user.id, store_id) for c in conversations ], total=total, @@ -205,19 +205,19 @@ def list_conversations( ) -@vendor_messages_router.get("/unread-count", response_model=UnreadCountResponse) +@store_messages_router.get("/unread-count", response_model=UnreadCountResponse) def get_unread_count( db: Session = Depends(get_db), - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), ) -> UnreadCountResponse: """Get total unread message count for header badge.""" - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id count = messaging_service.get_unread_count( db=db, - participant_type=ParticipantType.VENDOR, + participant_type=ParticipantType.STORE, participant_id=current_user.id, - vendor_id=vendor_id, + store_id=store_id, ) return UnreadCountResponse(total_unread=count) @@ -227,23 +227,23 @@ def get_unread_count( # ============================================================================ -@vendor_messages_router.get("/recipients", response_model=RecipientListResponse) +@store_messages_router.get("/recipients", response_model=RecipientListResponse) def get_recipients( recipient_type: ParticipantType = Query(..., description="Type of recipients to list"), search: str | None = Query(None, description="Search by name/email"), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), db: Session = Depends(get_db), - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), ) -> RecipientListResponse: """Get list of available recipients for compose modal.""" - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id if recipient_type == ParticipantType.CUSTOMER: - # List customers for this vendor (for vendor_customer conversations) + # List customers for this store (for store_customer conversations) recipient_data, total = messaging_service.get_customer_recipients( db=db, - vendor_id=vendor_id, + store_id=store_id, search=search, skip=skip, limit=limit, @@ -254,12 +254,12 @@ def get_recipients( type=r["type"], name=r["name"], email=r["email"], - vendor_id=r["vendor_id"], + store_id=r["store_id"], ) for r in recipient_data ] else: - # Vendors can't start conversations with admins - admins initiate those + # Stores can't start conversations with admins - admins initiate those recipients = [] total = 0 @@ -271,50 +271,50 @@ def get_recipients( # ============================================================================ -@vendor_messages_router.post("", response_model=ConversationDetailResponse) +@store_messages_router.post("", response_model=ConversationDetailResponse) def create_conversation( data: ConversationCreate, db: Session = Depends(get_db), - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), ) -> ConversationDetailResponse: """Create a new conversation with a customer.""" - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id - # Vendors can only create vendor_customer conversations - if data.conversation_type != ConversationType.VENDOR_CUSTOMER: + # Stores can only create store_customer conversations + if data.conversation_type != ConversationType.STORE_CUSTOMER: raise InvalidConversationTypeException( - message="Vendors can only create vendor_customer conversations", - allowed_types=["vendor_customer"], + message="Stores can only create store_customer conversations", + allowed_types=["store_customer"], ) if data.recipient_type != ParticipantType.CUSTOMER: raise InvalidRecipientTypeException( - conversation_type="vendor_customer", + conversation_type="store_customer", expected_recipient_type="customer", ) # Create conversation conversation = messaging_service.create_conversation( db=db, - conversation_type=ConversationType.VENDOR_CUSTOMER, + conversation_type=ConversationType.STORE_CUSTOMER, subject=data.subject, - initiator_type=ParticipantType.VENDOR, + initiator_type=ParticipantType.STORE, initiator_id=current_user.id, recipient_type=ParticipantType.CUSTOMER, recipient_id=data.recipient_id, - vendor_id=vendor_id, + store_id=store_id, initial_message=data.initial_message, ) db.commit() db.refresh(conversation) logger.info( - f"Vendor {current_user.username} created conversation {conversation.id} " + f"Store {current_user.username} created conversation {conversation.id} " f"with customer:{data.recipient_id}" ) # Return full detail response - return _build_conversation_detail(db, conversation, current_user.id, vendor_id) + return _build_conversation_detail(db, conversation, current_user.id, store_id) # ============================================================================ @@ -323,7 +323,7 @@ def create_conversation( def _build_conversation_detail( - db: Session, conversation: Any, current_user_id: int, vendor_id: int + db: Session, conversation: Any, current_user_id: int, store_id: int ) -> ConversationDetailResponse: """Build full conversation detail response.""" # Get my participant for unread count @@ -331,7 +331,7 @@ def _build_conversation_detail( ( p for p in conversation.participants - if p.participant_type == ParticipantType.VENDOR + if p.participant_type == ParticipantType.STORE and p.participant_id == current_user_id ), None, @@ -369,16 +369,16 @@ def _build_conversation_detail( # Build message responses messages = [_enrich_message(db, m) for m in conversation.messages] - # Get vendor name if applicable - vendor_name = None - if conversation.vendor: - vendor_name = conversation.vendor.name + # Get store name if applicable + store_name = None + if conversation.store: + store_name = conversation.store.name return ConversationDetailResponse( id=conversation.id, conversation_type=conversation.conversation_type, subject=conversation.subject, - vendor_id=conversation.vendor_id, + store_id=conversation.store_id, is_closed=conversation.is_closed, closed_at=conversation.closed_at, closed_by_type=conversation.closed_by_type, @@ -390,32 +390,32 @@ def _build_conversation_detail( participants=participants, messages=messages, unread_count=unread_count, - vendor_name=vendor_name, + store_name=store_name, ) -@vendor_messages_router.get("/{conversation_id}", response_model=ConversationDetailResponse) +@store_messages_router.get("/{conversation_id}", response_model=ConversationDetailResponse) def get_conversation( conversation_id: int, mark_read: bool = Query(True, description="Automatically mark as read"), db: Session = Depends(get_db), - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), ) -> ConversationDetailResponse: """Get conversation detail with messages.""" - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id conversation = messaging_service.get_conversation( db=db, conversation_id=conversation_id, - participant_type=ParticipantType.VENDOR, + participant_type=ParticipantType.STORE, participant_id=current_user.id, ) if not conversation: raise ConversationNotFoundException(str(conversation_id)) - # Verify vendor context - if conversation.vendor_id and conversation.vendor_id != vendor_id: + # Verify store context + if conversation.store_id and conversation.store_id != store_id: raise ConversationNotFoundException(str(conversation_id)) # Mark as read if requested @@ -423,12 +423,12 @@ def get_conversation( messaging_service.mark_conversation_read( db=db, conversation_id=conversation_id, - reader_type=ParticipantType.VENDOR, + reader_type=ParticipantType.STORE, reader_id=current_user.id, ) db.commit() - return _build_conversation_detail(db, conversation, current_user.id, vendor_id) + return _build_conversation_detail(db, conversation, current_user.id, store_id) # ============================================================================ @@ -436,30 +436,30 @@ def get_conversation( # ============================================================================ -@vendor_messages_router.post("/{conversation_id}/messages", response_model=MessageResponse) +@store_messages_router.post("/{conversation_id}/messages", response_model=MessageResponse) async def send_message( conversation_id: int, content: str = Form(...), files: list[UploadFile] = File(default=[]), db: Session = Depends(get_db), - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), ) -> MessageResponse: """Send a message in a conversation, optionally with attachments.""" - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id # Verify access conversation = messaging_service.get_conversation( db=db, conversation_id=conversation_id, - participant_type=ParticipantType.VENDOR, + participant_type=ParticipantType.STORE, participant_id=current_user.id, ) if not conversation: raise ConversationNotFoundException(str(conversation_id)) - # Verify vendor context - if conversation.vendor_id and conversation.vendor_id != vendor_id: + # Verify store context + if conversation.store_id and conversation.store_id != store_id: raise ConversationNotFoundException(str(conversation_id)) if conversation.is_closed: @@ -480,7 +480,7 @@ async def send_message( message = messaging_service.send_message( db=db, conversation_id=conversation_id, - sender_type=ParticipantType.VENDOR, + sender_type=ParticipantType.STORE, sender_id=current_user.id, content=content, attachments=attachments if attachments else None, @@ -489,7 +489,7 @@ async def send_message( db.refresh(message) logger.info( - f"Vendor {current_user.username} sent message {message.id} " + f"Store {current_user.username} sent message {message.id} " f"in conversation {conversation_id}" ) @@ -501,39 +501,39 @@ async def send_message( # ============================================================================ -@vendor_messages_router.post("/{conversation_id}/close", response_model=CloseConversationResponse) +@store_messages_router.post("/{conversation_id}/close", response_model=CloseConversationResponse) def close_conversation( conversation_id: int, db: Session = Depends(get_db), - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), ) -> CloseConversationResponse: """Close a conversation.""" - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id # Verify access first conversation = messaging_service.get_conversation( db=db, conversation_id=conversation_id, - participant_type=ParticipantType.VENDOR, + participant_type=ParticipantType.STORE, participant_id=current_user.id, ) if not conversation: raise ConversationNotFoundException(str(conversation_id)) - if conversation.vendor_id and conversation.vendor_id != vendor_id: + if conversation.store_id and conversation.store_id != store_id: raise ConversationNotFoundException(str(conversation_id)) conversation = messaging_service.close_conversation( db=db, conversation_id=conversation_id, - closer_type=ParticipantType.VENDOR, + closer_type=ParticipantType.STORE, closer_id=current_user.id, ) db.commit() logger.info( - f"Vendor {current_user.username} closed conversation {conversation_id}" + f"Store {current_user.username} closed conversation {conversation_id}" ) return CloseConversationResponse( @@ -543,39 +543,39 @@ def close_conversation( ) -@vendor_messages_router.post("/{conversation_id}/reopen", response_model=ReopenConversationResponse) +@store_messages_router.post("/{conversation_id}/reopen", response_model=ReopenConversationResponse) def reopen_conversation( conversation_id: int, db: Session = Depends(get_db), - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), ) -> ReopenConversationResponse: """Reopen a closed conversation.""" - vendor_id = current_user.token_vendor_id + store_id = current_user.token_store_id # Verify access first conversation = messaging_service.get_conversation( db=db, conversation_id=conversation_id, - participant_type=ParticipantType.VENDOR, + participant_type=ParticipantType.STORE, participant_id=current_user.id, ) if not conversation: raise ConversationNotFoundException(str(conversation_id)) - if conversation.vendor_id and conversation.vendor_id != vendor_id: + if conversation.store_id and conversation.store_id != store_id: raise ConversationNotFoundException(str(conversation_id)) conversation = messaging_service.reopen_conversation( db=db, conversation_id=conversation_id, - opener_type=ParticipantType.VENDOR, + opener_type=ParticipantType.STORE, opener_id=current_user.id, ) db.commit() logger.info( - f"Vendor {current_user.username} reopened conversation {conversation_id}" + f"Store {current_user.username} reopened conversation {conversation_id}" ) return ReopenConversationResponse( @@ -585,17 +585,17 @@ def reopen_conversation( ) -@vendor_messages_router.put("/{conversation_id}/read", response_model=MarkReadResponse) +@store_messages_router.put("/{conversation_id}/read", response_model=MarkReadResponse) def mark_read( conversation_id: int, db: Session = Depends(get_db), - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), ) -> MarkReadResponse: """Mark conversation as read.""" success = messaging_service.mark_conversation_read( db=db, conversation_id=conversation_id, - reader_type=ParticipantType.VENDOR, + reader_type=ParticipantType.STORE, reader_id=current_user.id, ) db.commit() @@ -612,18 +612,18 @@ class PreferencesUpdateResponse(BaseModel): success: bool -@vendor_messages_router.put("/{conversation_id}/preferences", response_model=PreferencesUpdateResponse) +@store_messages_router.put("/{conversation_id}/preferences", response_model=PreferencesUpdateResponse) def update_preferences( conversation_id: int, preferences: NotificationPreferencesUpdate, db: Session = Depends(get_db), - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), ) -> PreferencesUpdateResponse: """Update notification preferences for a conversation.""" success = messaging_service.update_notification_preferences( db=db, conversation_id=conversation_id, - participant_type=ParticipantType.VENDOR, + participant_type=ParticipantType.STORE, participant_id=current_user.id, email_notifications=preferences.email_notifications, muted=preferences.muted, diff --git a/app/modules/messaging/routes/api/vendor_notifications.py b/app/modules/messaging/routes/api/store_notifications.py similarity index 57% rename from app/modules/messaging/routes/api/vendor_notifications.py rename to app/modules/messaging/routes/api/store_notifications.py index 83b656a7..ed725e6a 100644 --- a/app/modules/messaging/routes/api/vendor_notifications.py +++ b/app/modules/messaging/routes/api/store_notifications.py @@ -1,9 +1,9 @@ -# app/modules/messaging/routes/api/vendor_notifications.py +# app/modules/messaging/routes/api/store_notifications.py """ -Vendor notification management endpoints. +Store notification management endpoints. -Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern). -The get_current_vendor_api dependency guarantees token_vendor_id is present. +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. """ import logging @@ -11,9 +11,9 @@ import logging from fastapi import APIRouter, Depends, Query 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.tenancy.services.vendor_service import vendor_service +from app.modules.tenancy.services.store_service import store_service from models.schema.auth import UserContext from app.modules.messaging.schemas import ( MessageResponse, @@ -26,28 +26,28 @@ from app.modules.messaging.schemas import ( UnreadCountResponse, ) -vendor_notifications_router = APIRouter(prefix="/notifications") +store_notifications_router = APIRouter(prefix="/notifications") logger = logging.getLogger(__name__) -@vendor_notifications_router.get("", response_model=NotificationListResponse) +@store_notifications_router.get("", response_model=NotificationListResponse) def get_notifications( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), unread_only: bool | None = Query(False), - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ - Get vendor notifications. + Get store notifications. TODO: Implement in Slice 5 - - Get all notifications for vendor + - Get all notifications for store - Filter by read/unread status - Support pagination - Return notification details """ - vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841 + store = store_service.get_store_by_id(db, current_user.token_store_id) # noqa: F841 return NotificationListResponse( notifications=[], total=0, @@ -56,26 +56,26 @@ def get_notifications( ) -@vendor_notifications_router.get("/unread-count", response_model=UnreadCountResponse) +@store_notifications_router.get("/unread-count", response_model=UnreadCountResponse) def get_unread_count( - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ Get count of unread notifications. TODO: Implement in Slice 5 - - Count unread notifications for vendor + - Count unread notifications for store - Used for notification badge """ - vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841 + store = store_service.get_store_by_id(db, current_user.token_store_id) # noqa: F841 return UnreadCountResponse(unread_count=0, message="Unread count coming in Slice 5") -@vendor_notifications_router.put("/{notification_id}/read", response_model=MessageResponse) +@store_notifications_router.put("/{notification_id}/read", response_model=MessageResponse) def mark_as_read( notification_id: int, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ @@ -85,30 +85,30 @@ def mark_as_read( - Mark single notification as read - Update read timestamp """ - vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841 + store = store_service.get_store_by_id(db, current_user.token_store_id) # noqa: F841 return MessageResponse(message="Mark as read coming in Slice 5") -@vendor_notifications_router.put("/mark-all-read", response_model=MessageResponse) +@store_notifications_router.put("/mark-all-read", response_model=MessageResponse) def mark_all_as_read( - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ Mark all notifications as read. TODO: Implement in Slice 5 - - Mark all vendor notifications as read + - Mark all store notifications as read - Update timestamps """ - vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841 + store = store_service.get_store_by_id(db, current_user.token_store_id) # noqa: F841 return MessageResponse(message="Mark all as read coming in Slice 5") -@vendor_notifications_router.delete("/{notification_id}", response_model=MessageResponse) +@store_notifications_router.delete("/{notification_id}", response_model=MessageResponse) def delete_notification( notification_id: int, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ @@ -116,15 +116,15 @@ def delete_notification( TODO: Implement in Slice 5 - Delete single notification - - Verify notification belongs to vendor + - Verify notification belongs to store """ - vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841 + store = store_service.get_store_by_id(db, current_user.token_store_id) # noqa: F841 return MessageResponse(message="Notification deletion coming in Slice 5") -@vendor_notifications_router.get("/settings", response_model=NotificationSettingsResponse) +@store_notifications_router.get("/settings", response_model=NotificationSettingsResponse) def get_notification_settings( - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ @@ -135,7 +135,7 @@ def get_notification_settings( - Get in-app notification settings - Get notification types enabled/disabled """ - vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841 + store = store_service.get_store_by_id(db, current_user.token_store_id) # noqa: F841 return NotificationSettingsResponse( email_notifications=True, in_app_notifications=True, @@ -144,10 +144,10 @@ def get_notification_settings( ) -@vendor_notifications_router.put("/settings", response_model=MessageResponse) +@store_notifications_router.put("/settings", response_model=MessageResponse) def update_notification_settings( settings: NotificationSettingsUpdate, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ @@ -158,13 +158,13 @@ def update_notification_settings( - Update in-app notification settings - Enable/disable specific notification types """ - vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841 + store = store_service.get_store_by_id(db, current_user.token_store_id) # noqa: F841 return MessageResponse(message="Notification settings update coming in Slice 5") -@vendor_notifications_router.get("/templates", response_model=NotificationTemplateListResponse) +@store_notifications_router.get("/templates", response_model=NotificationTemplateListResponse) def get_notification_templates( - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ @@ -175,17 +175,17 @@ def get_notification_templates( - Include: order confirmation, shipping notification, etc. - Return template details """ - vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841 + store = store_service.get_store_by_id(db, current_user.token_store_id) # noqa: F841 return NotificationTemplateListResponse( templates=[], message="Notification templates coming in Slice 5" ) -@vendor_notifications_router.put("/templates/{template_id}", response_model=MessageResponse) +@store_notifications_router.put("/templates/{template_id}", response_model=MessageResponse) def update_notification_template( template_id: int, template_data: NotificationTemplateUpdate, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ @@ -197,14 +197,14 @@ def update_notification_template( - Validate template variables - Preview template """ - vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841 + store = store_service.get_store_by_id(db, current_user.token_store_id) # noqa: F841 return MessageResponse(message="Template update coming in Slice 5") -@vendor_notifications_router.post("/test", response_model=MessageResponse) +@store_notifications_router.post("/test", response_model=MessageResponse) def send_test_notification( notification_data: TestNotificationRequest, - current_user: UserContext = Depends(get_current_vendor_api), + current_user: UserContext = Depends(get_current_store_api), db: Session = Depends(get_db), ): """ @@ -215,5 +215,5 @@ def send_test_notification( - Use specified template - Send to current user's email """ - vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841 + store = store_service.get_store_by_id(db, current_user.token_store_id) # noqa: F841 return MessageResponse(message="Test notification coming in Slice 5") diff --git a/app/modules/messaging/routes/api/storefront.py b/app/modules/messaging/routes/api/storefront.py index 63729d88..f1aac7b1 100644 --- a/app/modules/messaging/routes/api/storefront.py +++ b/app/modules/messaging/routes/api/storefront.py @@ -8,11 +8,11 @@ Authenticated endpoints for customer messaging: - Download attachments - Mark as read -Uses vendor from middleware context (VendorContextMiddleware). +Uses store from middleware context (StoreContextMiddleware). Requires customer authentication. Customers can only: -- View their own vendor_customer conversations +- View their own store_customer conversations - Reply to existing conversations - Mark conversations as read """ @@ -32,7 +32,7 @@ from app.modules.messaging.exceptions import ( ConversationClosedException, ConversationNotFoundException, ) -from app.modules.tenancy.exceptions import VendorNotFoundException +from app.modules.tenancy.exceptions import StoreNotFoundException from app.modules.customers.schemas import CustomerContext from app.modules.messaging.models.message import ConversationType, ParticipantType from app.modules.messaging.schemas import ( @@ -80,22 +80,22 @@ def list_conversations( """ List conversations for authenticated customer. - Customers only see their vendor_customer conversations. + Customers only see their store_customer conversations. Query Parameters: - skip: Pagination offset - limit: Max items to return - status: Filter by open/closed """ - vendor = getattr(request.state, "vendor", None) + store = getattr(request.state, "store", None) - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") + if not store: + raise StoreNotFoundException("context", identifier_type="subdomain") logger.debug( f"[MESSAGING_STOREFRONT] list_conversations for customer {customer.id}", extra={ - "vendor_id": vendor.id, + "store_id": store.id, "customer_id": customer.id, "skip": skip, "limit": limit, @@ -113,8 +113,8 @@ def list_conversations( db=db, participant_type=ParticipantType.CUSTOMER, participant_id=customer.id, - vendor_id=vendor.id, - conversation_type=ConversationType.VENDOR_CUSTOMER, + store_id=store.id, + conversation_type=ConversationType.STORE_CUSTOMER, is_closed=is_closed, skip=skip, limit=limit, @@ -152,16 +152,16 @@ def get_unread_count( """ Get total unread message count for header badge. """ - vendor = getattr(request.state, "vendor", None) + store = getattr(request.state, "store", None) - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") + if not store: + raise StoreNotFoundException("context", identifier_type="subdomain") count = messaging_service.get_unread_count( db=db, participant_type=ParticipantType.CUSTOMER, participant_id=customer.id, - vendor_id=vendor.id, + store_id=store.id, ) return UnreadCountResponse(unread_count=count) @@ -180,15 +180,15 @@ def get_conversation( Validates that customer is a participant. Automatically marks conversation as read. """ - vendor = getattr(request.state, "vendor", None) + store = getattr(request.state, "store", None) - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") + if not store: + raise StoreNotFoundException("context", identifier_type="subdomain") logger.debug( f"[MESSAGING_STOREFRONT] get_conversation {conversation_id} for customer {customer.id}", extra={ - "vendor_id": vendor.id, + "store_id": store.id, "customer_id": customer.id, "conversation_id": conversation_id, }, @@ -199,7 +199,7 @@ def get_conversation( conversation_id=conversation_id, participant_type=ParticipantType.CUSTOMER, participant_id=customer.id, - vendor_id=vendor.id, + store_id=store.id, ) if not conversation: @@ -270,15 +270,15 @@ async def send_message( Validates that customer is a participant. Supports file attachments. """ - vendor = getattr(request.state, "vendor", None) + store = getattr(request.state, "store", None) - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") + if not store: + raise StoreNotFoundException("context", identifier_type="subdomain") logger.debug( f"[MESSAGING_STOREFRONT] send_message in {conversation_id} from customer {customer.id}", extra={ - "vendor_id": vendor.id, + "store_id": store.id, "customer_id": customer.id, "conversation_id": conversation_id, "attachment_count": len(attachments), @@ -290,7 +290,7 @@ async def send_message( conversation_id=conversation_id, participant_type=ParticipantType.CUSTOMER, participant_id=customer.id, - vendor_id=vendor.id, + store_id=store.id, ) if not conversation: @@ -323,7 +323,7 @@ async def send_message( extra={ "message_id": message.id, "customer_id": customer.id, - "vendor_id": vendor.id, + "store_id": store.id, }, ) @@ -363,17 +363,17 @@ def mark_as_read( db: Session = Depends(get_db), ): """Mark conversation as read.""" - vendor = getattr(request.state, "vendor", None) + store = getattr(request.state, "store", None) - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") + if not store: + raise StoreNotFoundException("context", identifier_type="subdomain") conversation = messaging_service.get_conversation( db=db, conversation_id=conversation_id, participant_type=ParticipantType.CUSTOMER, participant_id=customer.id, - vendor_id=vendor.id, + store_id=store.id, ) if not conversation: @@ -402,17 +402,17 @@ async def download_attachment( Validates that customer has access to the conversation. """ - vendor = getattr(request.state, "vendor", None) + store = getattr(request.state, "store", None) - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") + if not store: + raise StoreNotFoundException("context", identifier_type="subdomain") conversation = messaging_service.get_conversation( db=db, conversation_id=conversation_id, participant_type=ParticipantType.CUSTOMER, participant_id=customer.id, - vendor_id=vendor.id, + store_id=store.id, ) if not conversation: @@ -447,17 +447,17 @@ async def get_attachment_thumbnail( Validates that customer has access to the conversation. """ - vendor = getattr(request.state, "vendor", None) + store = getattr(request.state, "store", None) - if not vendor: - raise VendorNotFoundException("context", identifier_type="subdomain") + if not store: + raise StoreNotFoundException("context", identifier_type="subdomain") conversation = messaging_service.get_conversation( db=db, conversation_id=conversation_id, participant_type=ParticipantType.CUSTOMER, participant_id=customer.id, - vendor_id=vendor.id, + store_id=store.id, ) if not conversation: @@ -484,9 +484,9 @@ async def get_attachment_thumbnail( def _get_other_participant_name(conversation, customer_id: int) -> str: - """Get the name of the other participant (the vendor user).""" + """Get the name of the other participant (the store user).""" for participant in conversation.participants: - if participant.participant_type == ParticipantType.VENDOR: + if participant.participant_type == ParticipantType.STORE: from app.modules.tenancy.models import User user = ( @@ -513,7 +513,7 @@ def _get_sender_name(message) -> str: if customer: return f"{customer.first_name} {customer.last_name}" return "Customer" - elif message.sender_type == ParticipantType.VENDOR: + elif message.sender_type == ParticipantType.STORE: from app.modules.tenancy.models import User user = ( diff --git a/app/modules/messaging/routes/api/vendor.py b/app/modules/messaging/routes/api/vendor.py deleted file mode 100644 index a278f5c5..00000000 --- a/app/modules/messaging/routes/api/vendor.py +++ /dev/null @@ -1,30 +0,0 @@ -# app/modules/messaging/routes/api/vendor.py -""" -Messaging module vendor API routes. - -Aggregates all vendor messaging routes: -- /messages/* - Conversation and message management -- /notifications/* - Vendor notifications -- /email-settings/* - SMTP and provider configuration -- /email-templates/* - Email template customization -""" - -from fastapi import APIRouter, Depends - -from app.api.deps import require_module_access -from app.modules.enums import FrontendType - -from .vendor_messages import vendor_messages_router -from .vendor_notifications import vendor_notifications_router -from .vendor_email_settings import vendor_email_settings_router -from .vendor_email_templates import vendor_email_templates_router - -vendor_router = APIRouter( - dependencies=[Depends(require_module_access("messaging", FrontendType.VENDOR))], -) - -# Aggregate all messaging vendor routes -vendor_router.include_router(vendor_messages_router, tags=["vendor-messages"]) -vendor_router.include_router(vendor_notifications_router, tags=["vendor-notifications"]) -vendor_router.include_router(vendor_email_settings_router, tags=["vendor-email-settings"]) -vendor_router.include_router(vendor_email_templates_router, tags=["vendor-email-templates"]) diff --git a/app/modules/messaging/routes/pages/admin.py b/app/modules/messaging/routes/pages/admin.py index 7d197477..ef8253d9 100644 --- a/app/modules/messaging/routes/pages/admin.py +++ b/app/modules/messaging/routes/pages/admin.py @@ -58,7 +58,7 @@ async def admin_messages_page( ): """ Render messaging page. - Shows all conversations (admin_vendor and admin_customer channels). + Shows all conversations (admin_store and admin_customer channels). """ return templates.TemplateResponse( "messaging/admin/messages.html", diff --git a/app/modules/messaging/routes/pages/store.py b/app/modules/messaging/routes/pages/store.py new file mode 100644 index 00000000..cd8f3e4d --- /dev/null +++ b/app/modules/messaging/routes/pages/store.py @@ -0,0 +1,94 @@ +# app/modules/messaging/routes/pages/store.py +""" +Messaging Store Page Routes (HTML rendering). + +Store pages for messaging management: +- Messages list +- Conversation detail +- Email templates +""" + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_current_store_from_cookie_or_header, get_db +from app.modules.core.utils.page_context import get_store_context +from app.templates_config import templates +from app.modules.tenancy.models import User + +router = APIRouter() + + +# ============================================================================ +# MESSAGING +# ============================================================================ + + +@router.get( + "/{store_code}/messages", response_class=HTMLResponse, include_in_schema=False +) +async def store_messages_page( + request: Request, + store_code: str = Path(..., description="Store code"), + current_user: User = Depends(get_current_store_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render messages page. + JavaScript loads conversations and messages via API. + """ + return templates.TemplateResponse( + "messaging/store/messages.html", + get_store_context(request, db, current_user, store_code), + ) + + +@router.get( + "/{store_code}/messages/{conversation_id}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def store_message_detail_page( + request: Request, + store_code: str = Path(..., description="Store code"), + conversation_id: int = Path(..., description="Conversation ID"), + current_user: User = Depends(get_current_store_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render message detail page. + Shows the full conversation thread. + """ + return templates.TemplateResponse( + "messaging/store/messages.html", + get_store_context( + request, db, current_user, store_code, conversation_id=conversation_id + ), + ) + + +# ============================================================================ +# EMAIL TEMPLATES +# ============================================================================ + + +@router.get( + "/{store_code}/email-templates", + response_class=HTMLResponse, + include_in_schema=False, +) +async def store_email_templates_page( + request: Request, + store_code: str = Path(..., description="Store code"), + current_user: User = Depends(get_current_store_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render store email templates customization page. + Allows stores to override platform email templates. + """ + return templates.TemplateResponse( + "messaging/store/email-templates.html", + get_store_context(request, db, current_user, store_code), + ) diff --git a/app/modules/messaging/routes/pages/storefront.py b/app/modules/messaging/routes/pages/storefront.py index 860497f8..e523c1c0 100644 --- a/app/modules/messaging/routes/pages/storefront.py +++ b/app/modules/messaging/routes/pages/storefront.py @@ -38,14 +38,14 @@ async def shop_messages_page( ): """ Render customer messages page. - View and reply to conversations with the vendor. + View and reply to conversations with the store. Requires customer authentication. """ logger.debug( "[STOREFRONT] shop_messages_page REACHED", extra={ "path": request.url.path, - "vendor": getattr(request.state, "vendor", "NOT SET"), + "store": getattr(request.state, "store", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) @@ -77,7 +77,7 @@ async def shop_message_detail_page( extra={ "path": request.url.path, "conversation_id": conversation_id, - "vendor": getattr(request.state, "vendor", "NOT SET"), + "store": getattr(request.state, "store", "NOT SET"), "context": getattr(request.state, "context_type", "NOT SET"), }, ) diff --git a/app/modules/messaging/routes/pages/vendor.py b/app/modules/messaging/routes/pages/vendor.py deleted file mode 100644 index 685d60b5..00000000 --- a/app/modules/messaging/routes/pages/vendor.py +++ /dev/null @@ -1,94 +0,0 @@ -# app/modules/messaging/routes/pages/vendor.py -""" -Messaging Vendor Page Routes (HTML rendering). - -Vendor pages for messaging management: -- Messages list -- Conversation detail -- Email templates -""" - -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.modules.core.utils.page_context import get_vendor_context -from app.templates_config import templates -from app.modules.tenancy.models import User - -router = APIRouter() - - -# ============================================================================ -# MESSAGING -# ============================================================================ - - -@router.get( - "/{vendor_code}/messages", response_class=HTMLResponse, include_in_schema=False -) -async def vendor_messages_page( - request: Request, - vendor_code: str = Path(..., description="Vendor code"), - current_user: User = Depends(get_current_vendor_from_cookie_or_header), - db: Session = Depends(get_db), -): - """ - Render messages page. - JavaScript loads conversations and messages via API. - """ - return templates.TemplateResponse( - "messaging/vendor/messages.html", - get_vendor_context(request, db, current_user, vendor_code), - ) - - -@router.get( - "/{vendor_code}/messages/{conversation_id}", - response_class=HTMLResponse, - include_in_schema=False, -) -async def vendor_message_detail_page( - request: Request, - vendor_code: str = Path(..., description="Vendor code"), - conversation_id: int = Path(..., description="Conversation ID"), - current_user: User = Depends(get_current_vendor_from_cookie_or_header), - db: Session = Depends(get_db), -): - """ - Render message detail page. - Shows the full conversation thread. - """ - return templates.TemplateResponse( - "messaging/vendor/messages.html", - get_vendor_context( - request, db, current_user, vendor_code, conversation_id=conversation_id - ), - ) - - -# ============================================================================ -# EMAIL TEMPLATES -# ============================================================================ - - -@router.get( - "/{vendor_code}/email-templates", - response_class=HTMLResponse, - include_in_schema=False, -) -async def vendor_email_templates_page( - request: Request, - vendor_code: str = Path(..., description="Vendor code"), - current_user: User = Depends(get_current_vendor_from_cookie_or_header), - db: Session = Depends(get_db), -): - """ - Render vendor email templates customization page. - Allows vendors to override platform email templates. - """ - return templates.TemplateResponse( - "messaging/vendor/email-templates.html", - get_vendor_context(request, db, current_user, vendor_code), - ) diff --git a/app/modules/messaging/schemas/__init__.py b/app/modules/messaging/schemas/__init__.py index baf5bb2d..51042843 100644 --- a/app/modules/messaging/schemas/__init__.py +++ b/app/modules/messaging/schemas/__init__.py @@ -69,9 +69,9 @@ from app.modules.messaging.schemas.email import ( EmailTemplateWithOverrideStatus, EmailTestRequest, EmailTestResponse, - VendorEmailTemplateCreate, - VendorEmailTemplateResponse, - VendorEmailTemplateUpdate, + StoreEmailTemplateCreate, + StoreEmailTemplateResponse, + StoreEmailTemplateUpdate, ) __all__ = [ @@ -132,7 +132,7 @@ __all__ = [ "EmailTemplateWithOverrideStatus", "EmailTestRequest", "EmailTestResponse", - "VendorEmailTemplateCreate", - "VendorEmailTemplateResponse", - "VendorEmailTemplateUpdate", + "StoreEmailTemplateCreate", + "StoreEmailTemplateResponse", + "StoreEmailTemplateUpdate", ] diff --git a/app/modules/messaging/schemas/email.py b/app/modules/messaging/schemas/email.py index 82752633..2244fc8c 100644 --- a/app/modules/messaging/schemas/email.py +++ b/app/modules/messaging/schemas/email.py @@ -4,7 +4,7 @@ Email template Pydantic schemas for API responses and requests. Provides schemas for: - EmailTemplate: Platform email templates -- VendorEmailTemplate: Vendor-specific template overrides +- StoreEmailTemplate: Store-specific template overrides """ from datetime import datetime @@ -33,7 +33,7 @@ class EmailTemplateCreate(EmailTemplateBase): default_factory=list, description="Required variables" ) is_platform_only: bool = Field( - default=False, description="Cannot be overridden by vendors" + default=False, description="Cannot be overridden by stores" ) @@ -142,11 +142,11 @@ class EmailTemplateSummary(BaseModel): return summaries -# Vendor Email Template Schemas +# Store Email Template Schemas -class VendorEmailTemplateCreate(BaseModel): - """Schema for creating a vendor email template override.""" +class StoreEmailTemplateCreate(BaseModel): + """Schema for creating a store email template override.""" template_code: str = Field(..., description="Template code to override") language: str = Field(default="en", description="Language code") @@ -156,8 +156,8 @@ class VendorEmailTemplateCreate(BaseModel): body_text: str | None = Field(None, description="Custom plain text body") -class VendorEmailTemplateUpdate(BaseModel): - """Schema for updating a vendor email template override.""" +class StoreEmailTemplateUpdate(BaseModel): + """Schema for updating a store email template override.""" name: str | None = Field(None, description="Custom name") subject: str | None = Field(None, description="Custom email subject") @@ -166,13 +166,13 @@ class VendorEmailTemplateUpdate(BaseModel): is_active: bool | None = Field(None, description="Override active status") -class VendorEmailTemplateResponse(BaseModel): - """Schema for vendor email template override API response.""" +class StoreEmailTemplateResponse(BaseModel): + """Schema for store email template override API response.""" model_config = ConfigDict(from_attributes=True) id: int - vendor_id: int + store_id: int template_code: str language: str name: str | None @@ -186,9 +186,9 @@ class VendorEmailTemplateResponse(BaseModel): class EmailTemplateWithOverrideStatus(BaseModel): """ - Schema showing template with vendor override status. + Schema showing template with store override status. - Used in vendor UI to show which templates have been customized. + Used in store UI to show which templates have been customized. """ model_config = ConfigDict(from_attributes=True) @@ -199,11 +199,11 @@ class EmailTemplateWithOverrideStatus(BaseModel): languages: list[str] is_platform_only: bool has_override: bool = Field( - default=False, description="Whether vendor has customized this template" + default=False, description="Whether store has customized this template" ) override_languages: list[str] = Field( default_factory=list, - description="Languages with vendor overrides", + description="Languages with store overrides", ) diff --git a/app/modules/messaging/schemas/message.py b/app/modules/messaging/schemas/message.py index de4269a6..1f34fe8e 100644 --- a/app/modules/messaging/schemas/message.py +++ b/app/modules/messaging/schemas/message.py @@ -3,8 +3,8 @@ Pydantic schemas for the messaging system. Supports three communication channels: -- Admin <-> Vendor -- Vendor <-> Customer +- Admin <-> Store +- Store <-> Customer - Admin <-> Customer """ @@ -124,7 +124,7 @@ class ConversationCreate(BaseModel): subject: str = Field(..., min_length=1, max_length=500) recipient_type: ParticipantType recipient_id: int - vendor_id: int | None = None + store_id: int | None = None initial_message: str | None = Field(None, min_length=1, max_length=10000) @@ -136,7 +136,7 @@ class ConversationSummary(BaseModel): id: int conversation_type: ConversationType subject: str - vendor_id: int | None = None + store_id: int | None = None is_closed: bool closed_at: datetime | None last_message_at: datetime | None @@ -161,7 +161,7 @@ class ConversationDetailResponse(BaseModel): id: int conversation_type: ConversationType subject: str - vendor_id: int | None = None + store_id: int | None = None is_closed: bool closed_at: datetime | None closed_by_type: ParticipantType | None = None @@ -180,8 +180,8 @@ class ConversationDetailResponse(BaseModel): # Current user's unread count unread_count: int = 0 - # Vendor info if applicable - vendor_name: str | None = None + # Store info if applicable + store_name: str | None = None class ConversationListResponse(BaseModel): @@ -262,8 +262,8 @@ class RecipientOption(BaseModel): type: ParticipantType name: str email: str | None = None - vendor_id: int | None = None # For vendor users - vendor_name: str | None = None + store_id: int | None = None # For store users + store_name: str | None = None class RecipientListResponse(BaseModel): @@ -279,14 +279,14 @@ class RecipientListResponse(BaseModel): class AdminConversationSummary(ConversationSummary): - """Extended conversation summary with vendor info for admin views.""" + """Extended conversation summary with store info for admin views.""" - vendor_name: str | None = None - vendor_code: str | None = None + store_name: str | None = None + store_code: str | None = None class AdminConversationListResponse(BaseModel): - """Schema for admin conversation list with vendor info.""" + """Schema for admin conversation list with store info.""" conversations: list[AdminConversationSummary] total: int @@ -304,8 +304,8 @@ class AdminMessageStats(BaseModel): total_messages: int = 0 # By type - admin_vendor_conversations: int = 0 - vendor_customer_conversations: int = 0 + admin_store_conversations: int = 0 + store_customer_conversations: int = 0 admin_customer_conversations: int = 0 # Unread diff --git a/app/modules/messaging/schemas/notification.py b/app/modules/messaging/schemas/notification.py index 6a56d5fa..9ba1b7a7 100644 --- a/app/modules/messaging/schemas/notification.py +++ b/app/modules/messaging/schemas/notification.py @@ -3,7 +3,7 @@ Notification Pydantic schemas for API validation and responses. This module provides schemas for: -- Vendor notifications (list, read, delete) +- Store notifications (list, read, delete) - Notification settings management - Notification email templates - Unread counts and statistics diff --git a/app/modules/messaging/services/__init__.py b/app/modules/messaging/services/__init__.py index e6583231..595f6fd7 100644 --- a/app/modules/messaging/services/__init__.py +++ b/app/modules/messaging/services/__init__.py @@ -32,7 +32,7 @@ from app.modules.messaging.services.email_service import ( send_email, get_provider, get_platform_provider, - get_vendor_provider, + get_store_provider, get_platform_email_config, # Provider classes SMTPProvider, @@ -45,11 +45,11 @@ from app.modules.messaging.services.email_service import ( ConfigurableSendGridProvider, ConfigurableMailgunProvider, ConfigurableSESProvider, - # Vendor provider classes - VendorSMTPProvider, - VendorSendGridProvider, - VendorMailgunProvider, - VendorSESProvider, + # Store provider classes + StoreSMTPProvider, + StoreSendGridProvider, + StoreMailgunProvider, + StoreSESProvider, # Constants PLATFORM_NAME, PLATFORM_SUPPORT_EMAIL, @@ -62,7 +62,7 @@ from app.modules.messaging.services.email_service import ( from app.modules.messaging.services.email_template_service import ( EmailTemplateService, TemplateData, - VendorOverrideData, + StoreOverrideData, ) __all__ = [ @@ -87,7 +87,7 @@ __all__ = [ "send_email", "get_provider", "get_platform_provider", - "get_vendor_provider", + "get_store_provider", "get_platform_email_config", # Provider classes "SMTPProvider", @@ -100,11 +100,11 @@ __all__ = [ "ConfigurableSendGridProvider", "ConfigurableMailgunProvider", "ConfigurableSESProvider", - # Vendor provider classes - "VendorSMTPProvider", - "VendorSendGridProvider", - "VendorMailgunProvider", - "VendorSESProvider", + # Store provider classes + "StoreSMTPProvider", + "StoreSendGridProvider", + "StoreMailgunProvider", + "StoreSESProvider", # Email constants "PLATFORM_NAME", "PLATFORM_SUPPORT_EMAIL", @@ -116,5 +116,5 @@ __all__ = [ # Email template service "EmailTemplateService", "TemplateData", - "VendorOverrideData", + "StoreOverrideData", ] diff --git a/app/modules/messaging/services/admin_notification_service.py b/app/modules/messaging/services/admin_notification_service.py index c611b0b7..0e0c1e3b 100644 --- a/app/modules/messaging/services/admin_notification_service.py +++ b/app/modules/messaging/services/admin_notification_service.py @@ -33,9 +33,9 @@ class NotificationType: IMPORT_FAILURE = "import_failure" EXPORT_FAILURE = "export_failure" ORDER_SYNC_FAILURE = "order_sync_failure" - VENDOR_ISSUE = "vendor_issue" + STORE_ISSUE = "store_issue" CUSTOMER_MESSAGE = "customer_message" - VENDOR_MESSAGE = "vendor_message" + STORE_MESSAGE = "store_message" SECURITY_ALERT = "security_alert" PERFORMANCE_ALERT = "performance_alert" ORDER_EXCEPTION = "order_exception" @@ -322,70 +322,70 @@ class AdminNotificationService: def notify_import_failure( self, db: Session, - vendor_name: str, + store_name: str, job_id: int, error_message: str, - vendor_id: int | None = None, + store_id: int | None = None, ) -> AdminNotification: """Create notification for import job failure.""" return self.create_notification( db=db, notification_type=NotificationType.IMPORT_FAILURE, - title=f"Import Failed: {vendor_name}", + title=f"Import Failed: {store_name}", message=error_message, priority=Priority.HIGH, action_required=True, - action_url=f"/admin/marketplace/letzshop?vendor_id={vendor_id}&tab=jobs" - if vendor_id + action_url=f"/admin/marketplace/letzshop?store_id={store_id}&tab=jobs" + if store_id else "/admin/marketplace", - metadata={"vendor_name": vendor_name, "job_id": job_id, "vendor_id": vendor_id}, + metadata={"store_name": store_name, "job_id": job_id, "store_id": store_id}, ) def notify_order_sync_failure( self, db: Session, - vendor_name: str, + store_name: str, error_message: str, - vendor_id: int | None = None, + store_id: int | None = None, ) -> AdminNotification: """Create notification for order sync failure.""" return self.create_notification( db=db, notification_type=NotificationType.ORDER_SYNC_FAILURE, - title=f"Order Sync Failed: {vendor_name}", + title=f"Order Sync Failed: {store_name}", message=error_message, priority=Priority.HIGH, action_required=True, - action_url=f"/admin/marketplace/letzshop?vendor_id={vendor_id}&tab=jobs" - if vendor_id + action_url=f"/admin/marketplace/letzshop?store_id={store_id}&tab=jobs" + if store_id else "/admin/marketplace/letzshop", - metadata={"vendor_name": vendor_name, "vendor_id": vendor_id}, + metadata={"store_name": store_name, "store_id": store_id}, ) def notify_order_exception( self, db: Session, - vendor_name: str, + store_name: str, order_number: str, exception_count: int, - vendor_id: int | None = None, + store_id: int | None = None, ) -> AdminNotification: """Create notification for order item exceptions.""" return self.create_notification( db=db, notification_type=NotificationType.ORDER_EXCEPTION, title=f"Order Exception: {order_number}", - message=f"{exception_count} item(s) need attention for order {order_number} ({vendor_name})", + message=f"{exception_count} item(s) need attention for order {order_number} ({store_name})", priority=Priority.NORMAL, action_required=True, - action_url=f"/admin/marketplace/letzshop?vendor_id={vendor_id}&tab=exceptions" - if vendor_id + action_url=f"/admin/marketplace/letzshop?store_id={store_id}&tab=exceptions" + if store_id else "/admin/marketplace/letzshop", metadata={ - "vendor_name": vendor_name, + "store_name": store_name, "order_number": order_number, "exception_count": exception_count, - "vendor_id": vendor_id, + "store_id": store_id, }, ) @@ -408,27 +408,27 @@ class AdminNotificationService: metadata=details, ) - def notify_vendor_issue( + def notify_store_issue( self, db: Session, - vendor_name: str, + store_name: str, issue_type: str, message: str, - vendor_id: int | None = None, + store_id: int | None = None, ) -> AdminNotification: - """Create notification for vendor-related issues.""" + """Create notification for store-related issues.""" return self.create_notification( db=db, - notification_type=NotificationType.VENDOR_ISSUE, - title=f"Vendor Issue: {vendor_name}", + notification_type=NotificationType.STORE_ISSUE, + title=f"Store Issue: {store_name}", message=message, priority=Priority.HIGH, action_required=True, - action_url=f"/admin/vendors/{vendor_id}" if vendor_id else "/admin/vendors", + action_url=f"/admin/stores/{store_id}" if store_id else "/admin/stores", metadata={ - "vendor_name": vendor_name, + "store_name": store_name, "issue_type": issue_type, - "vendor_id": vendor_id, + "store_id": store_id, }, ) @@ -467,7 +467,7 @@ class PlatformAlertService: severity: str, title: str, description: str | None = None, - affected_vendors: list[int] | None = None, + affected_stores: list[int] | None = None, affected_systems: list[str] | None = None, auto_generated: bool = True, ) -> PlatformAlert: @@ -479,7 +479,7 @@ class PlatformAlertService: severity=severity, title=title, description=description, - affected_vendors=affected_vendors, + affected_stores=affected_stores, affected_systems=affected_systems, auto_generated=auto_generated, first_occurred_at=now, @@ -504,7 +504,7 @@ class PlatformAlertService: severity=data.severity, title=data.title, description=data.description, - affected_vendors=data.affected_vendors, + affected_stores=data.affected_stores, affected_systems=data.affected_systems, auto_generated=data.auto_generated, ) @@ -676,7 +676,7 @@ class PlatformAlertService: severity: str, title: str, description: str | None = None, - affected_vendors: list[int] | None = None, + affected_stores: list[int] | None = None, affected_systems: list[str] | None = None, ) -> PlatformAlert: """Create alert or increment occurrence if similar exists.""" @@ -692,7 +692,7 @@ class PlatformAlertService: severity=severity, title=title, description=description, - affected_vendors=affected_vendors, + affected_stores=affected_stores, affected_systems=affected_systems, ) diff --git a/app/modules/messaging/services/email_service.py b/app/modules/messaging/services/email_service.py index 25b27a0d..509ca1c0 100644 --- a/app/modules/messaging/services/email_service.py +++ b/app/modules/messaging/services/email_service.py @@ -10,20 +10,20 @@ Supports: Features: - Multi-language templates from database -- Vendor template overrides +- Store template overrides - Jinja2 template rendering - Email logging and tracking - Queue support via background tasks -- Branding based on vendor tier (whitelabel) +- Branding based on store tier (whitelabel) Language Resolution (priority order): 1. Explicit language parameter 2. Customer's preferred language (if customer context) -3. Vendor's storefront language +3. Store's storefront language 4. Platform default (en) Template Resolution (priority order): -1. Vendor override (if vendor_id and template is not platform-only) +1. Store override (if store_id and template is not platform-only) 2. Platform template 3. English fallback (if requested language not found) """ @@ -42,7 +42,7 @@ from sqlalchemy.orm import Session from app.core.config import settings from app.modules.messaging.models import EmailLog, EmailStatus, EmailTemplate -from app.modules.messaging.models import VendorEmailTemplate +from app.modules.messaging.models import StoreEmailTemplate logger = logging.getLogger(__name__) @@ -69,13 +69,13 @@ POWERED_BY_FOOTER_TEXT = "\n\n---\nPowered by Wizamart - https://wizamart.com" @dataclass class ResolvedTemplate: - """Resolved template content after checking vendor overrides.""" + """Resolved template content after checking store overrides.""" subject: str body_html: str body_text: str | None - is_vendor_override: bool - template_id: int | None # Platform template ID (None if vendor override) + is_store_override: bool + template_id: int | None # Platform template ID (None if store override) template_code: str language: str @@ -87,8 +87,8 @@ class BrandingContext: platform_name: str platform_logo_url: str | None support_email: str - vendor_name: str | None - vendor_logo_url: str | None + store_name: str | None + store_logo_url: str | None is_whitelabel: bool @@ -687,15 +687,15 @@ def get_platform_provider(db: Session) -> EmailProvider: # ============================================================================= -# VENDOR EMAIL PROVIDERS +# STORE EMAIL PROVIDERS # ============================================================================= -class VendorSMTPProvider(EmailProvider): - """SMTP provider using vendor-specific settings.""" +class StoreSMTPProvider(EmailProvider): + """SMTP provider using store-specific settings.""" - def __init__(self, vendor_settings): - self.settings = vendor_settings + def __init__(self, store_settings): + self.settings = store_settings def send( self, @@ -721,7 +721,7 @@ class VendorSMTPProvider(EmailProvider): msg.attach(MIMEText(body_text, "plain", "utf-8")) msg.attach(MIMEText(body_html, "html", "utf-8")) - # Use vendor's SMTP settings (10-second timeout to fail fast) + # Use store's SMTP settings (10-second timeout to fail fast) timeout = 10 if self.settings.smtp_use_ssl: server = smtplib.SMTP_SSL(self.settings.smtp_host, self.settings.smtp_port, timeout=timeout) @@ -742,15 +742,15 @@ class VendorSMTPProvider(EmailProvider): server.quit() except Exception as e: - logger.error(f"Vendor SMTP send error: {e}") + logger.error(f"Store SMTP send error: {e}") return False, None, str(e) -class VendorSendGridProvider(EmailProvider): - """SendGrid provider using vendor-specific API key.""" +class StoreSendGridProvider(EmailProvider): + """SendGrid provider using store-specific API key.""" - def __init__(self, vendor_settings): - self.settings = vendor_settings + def __init__(self, store_settings): + self.settings = store_settings def send( self, @@ -792,15 +792,15 @@ class VendorSendGridProvider(EmailProvider): except ImportError: return False, None, "SendGrid library not installed" except Exception as e: - logger.error(f"Vendor SendGrid send error: {e}") + logger.error(f"Store SendGrid send error: {e}") return False, None, str(e) -class VendorMailgunProvider(EmailProvider): - """Mailgun provider using vendor-specific settings.""" +class StoreMailgunProvider(EmailProvider): + """Mailgun provider using store-specific settings.""" - def __init__(self, vendor_settings): - self.settings = vendor_settings + def __init__(self, store_settings): + self.settings = store_settings def send( self, @@ -845,15 +845,15 @@ class VendorMailgunProvider(EmailProvider): return False, None, f"Mailgun error: {response.status_code} - {response.text}" except Exception as e: - logger.error(f"Vendor Mailgun send error: {e}") + logger.error(f"Store Mailgun send error: {e}") return False, None, str(e) -class VendorSESProvider(EmailProvider): - """Amazon SES provider using vendor-specific credentials.""" +class StoreSESProvider(EmailProvider): + """Amazon SES provider using store-specific credentials.""" - def __init__(self, vendor_settings): - self.settings = vendor_settings + def __init__(self, store_settings): + self.settings = store_settings def send( self, @@ -900,36 +900,36 @@ class VendorSESProvider(EmailProvider): except ImportError: return False, None, "boto3 library not installed" except Exception as e: - logger.error(f"Vendor SES send error: {e}") + logger.error(f"Store SES send error: {e}") return False, None, str(e) -def get_vendor_provider(vendor_settings) -> EmailProvider | None: +def get_store_provider(store_settings) -> EmailProvider | None: """ - Create an email provider instance using vendor's settings. + Create an email provider instance using store's settings. Args: - vendor_settings: VendorEmailSettings model instance + store_settings: StoreEmailSettings model instance Returns: EmailProvider instance or None if not configured """ - if not vendor_settings or not vendor_settings.is_configured: + if not store_settings or not store_settings.is_configured: return None provider_map = { - "smtp": VendorSMTPProvider, - "sendgrid": VendorSendGridProvider, - "mailgun": VendorMailgunProvider, - "ses": VendorSESProvider, + "smtp": StoreSMTPProvider, + "sendgrid": StoreSendGridProvider, + "mailgun": StoreMailgunProvider, + "ses": StoreSESProvider, } - provider_class = provider_map.get(vendor_settings.provider) + provider_class = provider_map.get(store_settings.provider) if not provider_class: - logger.warning(f"Unknown vendor email provider: {vendor_settings.provider}") + logger.warning(f"Unknown store email provider: {store_settings.provider}") return None - return provider_class(vendor_settings) + return provider_class(store_settings) # ============================================================================= @@ -964,14 +964,14 @@ class EmailService: Usage: email_service = EmailService(db) - # Send using database template with vendor override support + # Send using database template with store override support email_service.send_template( template_code="signup_welcome", to_email="user@example.com", to_name="John Doe", variables={"first_name": "John", "login_url": "https://..."}, - vendor_id=1, - # Language is resolved automatically from vendor/customer settings + store_id=1, + # Language is resolved automatically from store/customer settings ) # Send raw email @@ -993,68 +993,68 @@ class EmailService: # Cache the platform config for use in send_raw self._platform_config = get_platform_email_config(db) self.jinja_env = Environment(loader=BaseLoader()) - # Cache vendor and feature data to avoid repeated queries - self._vendor_cache: dict[int, Any] = {} + # Cache store and feature data to avoid repeated queries + self._store_cache: dict[int, Any] = {} self._feature_cache: dict[int, set[str]] = {} - self._vendor_email_settings_cache: dict[int, Any] = {} - self._vendor_tier_cache: dict[int, str | None] = {} + self._store_email_settings_cache: dict[int, Any] = {} + self._store_tier_cache: dict[int, str | None] = {} - def _get_vendor(self, vendor_id: int): - """Get vendor with caching.""" - if vendor_id not in self._vendor_cache: - from app.modules.tenancy.models import Vendor + def _get_store(self, store_id: int): + """Get store with caching.""" + if store_id not in self._store_cache: + from app.modules.tenancy.models import Store - self._vendor_cache[vendor_id] = ( - self.db.query(Vendor).filter(Vendor.id == vendor_id).first() + self._store_cache[store_id] = ( + self.db.query(Store).filter(Store.id == store_id).first() ) - return self._vendor_cache[vendor_id] + return self._store_cache[store_id] - def _has_feature(self, vendor_id: int, feature_code: str) -> bool: - """Check if vendor has a specific feature enabled.""" - if vendor_id not in self._feature_cache: + def _has_feature(self, store_id: int, feature_code: str) -> bool: + """Check if store has a specific feature enabled.""" + if store_id not in self._feature_cache: from app.modules.billing.services.feature_service import feature_service try: - features = feature_service.get_vendor_features(self.db, vendor_id) + features = feature_service.get_store_features(self.db, store_id) # Convert to set of feature codes - self._feature_cache[vendor_id] = {f.code for f in features.features} + self._feature_cache[store_id] = {f.code for f in features.features} except Exception: - self._feature_cache[vendor_id] = set() + self._feature_cache[store_id] = set() - return feature_code in self._feature_cache[vendor_id] + return feature_code in self._feature_cache[store_id] - def _get_vendor_email_settings(self, vendor_id: int): - """Get vendor email settings with caching.""" - if vendor_id not in self._vendor_email_settings_cache: - from app.modules.messaging.models import VendorEmailSettings + def _get_store_email_settings(self, store_id: int): + """Get store email settings with caching.""" + if store_id not in self._store_email_settings_cache: + from app.modules.messaging.models import StoreEmailSettings - self._vendor_email_settings_cache[vendor_id] = ( - self.db.query(VendorEmailSettings) - .filter(VendorEmailSettings.vendor_id == vendor_id) + self._store_email_settings_cache[store_id] = ( + self.db.query(StoreEmailSettings) + .filter(StoreEmailSettings.store_id == store_id) .first() ) - return self._vendor_email_settings_cache[vendor_id] + return self._store_email_settings_cache[store_id] - def _get_vendor_tier(self, vendor_id: int) -> str | None: - """Get vendor's subscription tier with caching.""" - if vendor_id not in self._vendor_tier_cache: + def _get_store_tier(self, store_id: int) -> str | None: + """Get store's subscription tier with caching.""" + if store_id not in self._store_tier_cache: from app.modules.billing.services.subscription_service import subscription_service - tier = subscription_service.get_current_tier(self.db, vendor_id) - self._vendor_tier_cache[vendor_id] = tier.value if tier else None - return self._vendor_tier_cache[vendor_id] + tier = subscription_service.get_current_tier(self.db, store_id) + self._store_tier_cache[store_id] = tier.value if tier else None + return self._store_tier_cache[store_id] - def _should_add_powered_by_footer(self, vendor_id: int | None) -> bool: + def _should_add_powered_by_footer(self, store_id: int | None) -> bool: """ Check if "Powered by Wizamart" footer should be added. Footer is added for Essential and Professional tiers. Business and Enterprise tiers get white-label (no footer). """ - if not vendor_id: + if not store_id: return False # Platform emails don't get the footer - tier = self._get_vendor_tier(vendor_id) + tier = self._get_store_tier(store_id) if not tier: return True # No tier = show footer (shouldn't happen normally) @@ -1064,7 +1064,7 @@ class EmailService: self, body_html: str, body_text: str | None, - vendor_id: int | None, + store_id: int | None, ) -> tuple[str, str | None]: """ Inject "Powered by Wizamart" footer if needed based on tier. @@ -1072,7 +1072,7 @@ class EmailService: Returns: Tuple of (modified_html, modified_text) """ - if not self._should_add_powered_by_footer(vendor_id): + if not self._should_add_powered_by_footer(store_id): return body_html, body_text # Inject footer before closing tag if present, otherwise append @@ -1096,7 +1096,7 @@ class EmailService: def resolve_language( self, explicit_language: str | None = None, - vendor_id: int | None = None, + store_id: int | None = None, customer_id: int | None = None, ) -> str: """ @@ -1105,12 +1105,12 @@ class EmailService: Priority order: 1. Explicit language parameter 2. Customer's preferred language (if customer_id provided) - 3. Vendor's storefront language (if vendor_id provided) + 3. Store's storefront language (if store_id provided) 4. Platform default (en) Args: explicit_language: Explicitly requested language - vendor_id: Vendor ID for storefront language lookup + store_id: Store ID for storefront language lookup customer_id: Customer ID for preferred language lookup Returns: @@ -1130,53 +1130,53 @@ class EmailService: if customer and customer.preferred_language in SUPPORTED_LANGUAGES: return customer.preferred_language - # 3. Vendor's storefront language - if vendor_id: - vendor = self._get_vendor(vendor_id) - if vendor and vendor.storefront_language in SUPPORTED_LANGUAGES: - return vendor.storefront_language + # 3. Store's storefront language + if store_id: + store = self._get_store(store_id) + if store and store.storefront_language in SUPPORTED_LANGUAGES: + return store.storefront_language # 4. Platform default return PLATFORM_DEFAULT_LANGUAGE - def get_branding(self, vendor_id: int | None = None) -> BrandingContext: + def get_branding(self, store_id: int | None = None) -> BrandingContext: """ Get branding context for email templates. - If vendor has white_label feature enabled (Enterprise tier), - platform branding is replaced with vendor branding. + If store has white_label feature enabled (Enterprise tier), + platform branding is replaced with store branding. Args: - vendor_id: Optional vendor ID + store_id: Optional store ID Returns: BrandingContext with appropriate branding variables """ - vendor = None + store = None is_whitelabel = False - if vendor_id: - vendor = self._get_vendor(vendor_id) - is_whitelabel = self._has_feature(vendor_id, "white_label") + if store_id: + store = self._get_store(store_id) + is_whitelabel = self._has_feature(store_id, "white_label") - if is_whitelabel and vendor: - # Whitelabel: use vendor branding throughout + if is_whitelabel and store: + # Whitelabel: use store branding throughout return BrandingContext( - platform_name=vendor.name, - platform_logo_url=vendor.get_logo_url(), - support_email=vendor.support_email or PLATFORM_SUPPORT_EMAIL, - vendor_name=vendor.name, - vendor_logo_url=vendor.get_logo_url(), + platform_name=store.name, + platform_logo_url=store.get_logo_url(), + support_email=store.support_email or PLATFORM_SUPPORT_EMAIL, + store_name=store.name, + store_logo_url=store.get_logo_url(), is_whitelabel=True, ) else: - # Standard: Wizamart branding with vendor details + # Standard: Wizamart branding with store details return BrandingContext( platform_name=PLATFORM_NAME, platform_logo_url=None, # Use default platform logo support_email=PLATFORM_SUPPORT_EMAIL, - vendor_name=vendor.name if vendor else None, - vendor_logo_url=vendor.get_logo_url() if vendor else None, + store_name=store.name if store else None, + store_logo_url=store.get_logo_url() if store else None, is_whitelabel=False, ) @@ -1184,20 +1184,20 @@ class EmailService: self, template_code: str, language: str, - vendor_id: int | None = None, + store_id: int | None = None, ) -> ResolvedTemplate | None: """ - Resolve template content with vendor override support. + Resolve template content with store override support. Resolution order: - 1. Check for vendor override (if vendor_id and template is not platform-only) + 1. Check for store override (if store_id and template is not platform-only) 2. Fall back to platform template 3. Fall back to English if language not found Args: template_code: Template code (e.g., "password_reset") language: Language code - vendor_id: Optional vendor ID for override lookup + store_id: Optional store ID for override lookup Returns: ResolvedTemplate with content, or None if not found @@ -1209,18 +1209,18 @@ class EmailService: logger.warning(f"Template not found: {template_code} ({language})") return None - # Check for vendor override (if not platform-only) - if vendor_id and not platform_template.is_platform_only: - vendor_override = VendorEmailTemplate.get_override( - self.db, vendor_id, template_code, language + # Check for store override (if not platform-only) + if store_id and not platform_template.is_platform_only: + store_override = StoreEmailTemplate.get_override( + self.db, store_id, template_code, language ) - if vendor_override: + if store_override: return ResolvedTemplate( - subject=vendor_override.subject, - body_html=vendor_override.body_html, - body_text=vendor_override.body_text, - is_vendor_override=True, + subject=store_override.subject, + body_html=store_override.body_html, + body_text=store_override.body_text, + is_store_override=True, template_id=None, template_code=template_code, language=language, @@ -1231,7 +1231,7 @@ class EmailService: subject=platform_template.subject, body_html=platform_template.body_html, body_text=platform_template.body_text, - is_vendor_override=False, + is_store_override=False, template_id=platform_template.id, template_code=template_code, language=language, @@ -1281,7 +1281,7 @@ class EmailService: to_name: str | None = None, language: str | None = None, variables: dict[str, Any] | None = None, - vendor_id: int | None = None, + store_id: int | None = None, customer_id: int | None = None, user_id: int | None = None, related_type: str | None = None, @@ -1289,7 +1289,7 @@ class EmailService: include_branding: bool = True, ) -> EmailLog: """ - Send an email using a database template with vendor override support. + Send an email using a database template with store override support. Args: template_code: Template code (e.g., "signup_welcome") @@ -1297,7 +1297,7 @@ class EmailService: to_name: Recipient name (optional) language: Language code (auto-resolved if None) variables: Template variables dict - vendor_id: Vendor ID for override lookup and logging + store_id: Store ID for override lookup and logging customer_id: Customer ID for language resolution user_id: Related user ID for logging related_type: Related entity type (e.g., "order") @@ -1309,15 +1309,15 @@ class EmailService: """ variables = variables or {} - # Resolve language (uses customer -> vendor -> platform default order) + # Resolve language (uses customer -> store -> platform default order) resolved_language = self.resolve_language( explicit_language=language, - vendor_id=vendor_id, + store_id=store_id, customer_id=customer_id, ) - # Resolve template (checks vendor override, falls back to platform) - resolved = self.resolve_template(template_code, resolved_language, vendor_id) + # Resolve template (checks store override, falls back to platform) + resolved = self.resolve_template(template_code, resolved_language, store_id) if not resolved: logger.error(f"Email template not found: {template_code} ({resolved_language})") @@ -1332,7 +1332,7 @@ class EmailService: status=EmailStatus.FAILED.value, error_message=f"Template not found: {template_code} ({resolved_language})", provider=settings.email_provider, - vendor_id=vendor_id, + store_id=store_id, user_id=user_id, related_type=related_type, related_id=related_id, @@ -1343,14 +1343,14 @@ class EmailService: # Inject branding variables if requested if include_branding: - branding = self.get_branding(vendor_id) + branding = self.get_branding(store_id) variables = { **variables, "platform_name": branding.platform_name, "platform_logo_url": branding.platform_logo_url, "support_email": branding.support_email, - "vendor_name": branding.vendor_name, - "vendor_logo_url": branding.vendor_logo_url, + "store_name": branding.store_name, + "store_logo_url": branding.store_logo_url, "is_whitelabel": branding.is_whitelabel, } @@ -1371,7 +1371,7 @@ class EmailService: body_text=body_text, template_code=template_code, template_id=resolved.template_id, - vendor_id=vendor_id, + store_id=store_id, user_id=user_id, related_type=related_type, related_id=related_id, @@ -1390,7 +1390,7 @@ class EmailService: reply_to: str | None = None, template_code: str | None = None, template_id: int | None = None, - vendor_id: int | None = None, + store_id: int | None = None, user_id: int | None = None, related_type: str | None = None, related_id: int | None = None, @@ -1400,12 +1400,12 @@ class EmailService: """ Send a raw email without using a template. - For vendor emails (when vendor_id is provided and is_platform_email=False): - - Uses vendor's SMTP/provider settings if configured - - Uses vendor's from_email, from_name, reply_to + For store emails (when store_id is provided and is_platform_email=False): + - Uses store's SMTP/provider settings if configured + - Uses store's from_email, from_name, reply_to - Adds "Powered by Wizamart" footer for Essential/Professional tiers - For platform emails (is_platform_email=True or no vendor_id): + For platform emails (is_platform_email=True or no store_id): - Uses platform's email settings from config - No "Powered by Wizamart" footer @@ -1416,33 +1416,33 @@ class EmailService: EmailLog record """ # Determine which provider and settings to use - vendor_settings = None - vendor_provider = None + store_settings = None + store_provider = None provider_name = self._platform_config.get("provider", settings.email_provider) - if vendor_id and not is_platform_email: - vendor_settings = self._get_vendor_email_settings(vendor_id) - if vendor_settings and vendor_settings.is_configured: - vendor_provider = get_vendor_provider(vendor_settings) - if vendor_provider: - # Use vendor's email identity - from_email = from_email or vendor_settings.from_email - from_name = from_name or vendor_settings.from_name - reply_to = reply_to or vendor_settings.reply_to_email - provider_name = f"vendor_{vendor_settings.provider}" - logger.debug(f"Using vendor email provider: {vendor_settings.provider}") + if store_id and not is_platform_email: + store_settings = self._get_store_email_settings(store_id) + if store_settings and store_settings.is_configured: + store_provider = get_store_provider(store_settings) + if store_provider: + # Use store's email identity + from_email = from_email or store_settings.from_email + from_name = from_name or store_settings.from_name + reply_to = reply_to or store_settings.reply_to_email + provider_name = f"store_{store_settings.provider}" + logger.debug(f"Using store email provider: {store_settings.provider}") - # Fall back to platform settings if no vendor provider + # Fall back to platform settings if no store provider # Uses DB config if available, otherwise .env - if not vendor_provider: + if not store_provider: from_email = from_email or self._platform_config.get("from_email", settings.email_from_address) from_name = from_name or self._platform_config.get("from_name", settings.email_from_name) reply_to = reply_to or self._platform_config.get("reply_to") or settings.email_reply_to or None # Inject "Powered by Wizamart" footer for non-whitelabel tiers - if vendor_id and not is_platform_email: + if store_id and not is_platform_email: body_html, body_text = self._inject_powered_by_footer( - body_html, body_text, vendor_id + body_html, body_text, store_id ) # Create log entry @@ -1459,7 +1459,7 @@ class EmailService: reply_to=reply_to, status=EmailStatus.PENDING.value, provider=provider_name, - vendor_id=vendor_id, + store_id=store_id, user_id=user_id, related_type=related_type, related_id=related_id, @@ -1477,8 +1477,8 @@ class EmailService: logger.info(f"Email sending disabled, skipping: {to_email}") return log - # Use vendor provider if available, otherwise platform provider - provider_to_use = vendor_provider or self.provider + # Use store provider if available, otherwise platform provider + provider_to_use = store_provider or self.provider # Send email success, message_id, error = provider_to_use.send( @@ -1515,7 +1515,7 @@ def send_email( to_name: str | None = None, language: str | None = None, variables: dict[str, Any] | None = None, - vendor_id: int | None = None, + store_id: int | None = None, customer_id: int | None = None, **kwargs, ) -> EmailLog: @@ -1527,9 +1527,9 @@ def send_email( template_code: Template code (e.g., "password_reset") to_email: Recipient email address to_name: Recipient name (optional) - language: Language code (auto-resolved from customer/vendor if None) + language: Language code (auto-resolved from customer/store if None) variables: Template variables dict - vendor_id: Vendor ID for override lookup and branding + store_id: Store ID for override lookup and branding customer_id: Customer ID for language resolution **kwargs: Additional arguments passed to send_template @@ -1543,7 +1543,7 @@ def send_email( to_name=to_name, language=language, variables=variables, - vendor_id=vendor_id, + store_id=store_id, customer_id=customer_id, **kwargs, ) diff --git a/app/modules/messaging/services/email_template_service.py b/app/modules/messaging/services/email_template_service.py index e1dabccd..19fecde3 100644 --- a/app/modules/messaging/services/email_template_service.py +++ b/app/modules/messaging/services/email_template_service.py @@ -4,7 +4,7 @@ Email Template Service Handles business logic for email template management: - Platform template CRUD operations -- Vendor template override management +- Store template override management - Template preview and testing - Email log queries @@ -25,7 +25,7 @@ from app.exceptions.base import ( ValidationException, ) from app.modules.messaging.models import EmailCategory, EmailLog, EmailTemplate -from app.modules.messaging.models import VendorEmailTemplate +from app.modules.messaging.models import StoreEmailTemplate logger = logging.getLogger(__name__) @@ -50,8 +50,8 @@ class TemplateData: @dataclass -class VendorOverrideData: - """Vendor override data container.""" +class StoreOverrideData: + """Store override data container.""" code: str language: str subject: str @@ -303,15 +303,15 @@ class EmailTemplateService: ) # ========================================================================= - # VENDOR OPERATIONS + # STORE OPERATIONS # ========================================================================= - def list_overridable_templates(self, vendor_id: int) -> dict[str, Any]: + def list_overridable_templates(self, store_id: int) -> dict[str, Any]: """ - List all templates that a vendor can customize. + List all templates that a store can customize. Args: - vendor_id: Vendor ID + store_id: Store ID Returns: Dict with templates list and supported languages @@ -319,14 +319,14 @@ class EmailTemplateService: # Get all overridable platform templates platform_templates = EmailTemplate.get_overridable_templates(self.db) - # Get all vendor overrides - vendor_overrides = VendorEmailTemplate.get_all_overrides_for_vendor( - self.db, vendor_id + # Get all store overrides + store_overrides = StoreEmailTemplate.get_all_overrides_for_store( + self.db, store_id ) # Build override lookup override_lookup = {} - for override in vendor_overrides: + for override in store_overrides: key = (override.template_code, override.language) override_lookup[key] = override @@ -355,16 +355,16 @@ class EmailTemplateService: "supported_languages": SUPPORTED_LANGUAGES, } - def get_vendor_template(self, vendor_id: int, code: str) -> dict[str, Any]: + def get_store_template(self, store_id: int, code: str) -> dict[str, Any]: """ - Get a template with all language versions for a vendor. + Get a template with all language versions for a store. Args: - vendor_id: Vendor ID + store_id: Store ID code: Template code Returns: - Template details with vendor overrides status + Template details with store overrides status Raises: NotFoundError: If template not found @@ -390,17 +390,17 @@ class EmailTemplateService: .all() ) - # Get vendor overrides - vendor_overrides = ( - self.db.query(VendorEmailTemplate) + # Get store overrides + store_overrides = ( + self.db.query(StoreEmailTemplate) .filter( - VendorEmailTemplate.vendor_id == vendor_id, - VendorEmailTemplate.template_code == code, + StoreEmailTemplate.store_id == store_id, + StoreEmailTemplate.template_code == code, ) .all() ) - override_lookup = {v.language: v for v in vendor_overrides} + override_lookup = {v.language: v for v in store_overrides} platform_lookup = {t.language: t for t in platform_versions} # Build language versions @@ -411,13 +411,13 @@ class EmailTemplateService: languages[lang] = { "has_platform_template": platform_ver is not None, - "has_vendor_override": override_ver is not None, + "has_store_override": override_ver is not None, "platform": { "subject": platform_ver.subject, "body_html": platform_ver.body_html, "body_text": platform_ver.body_text, } if platform_ver else None, - "vendor_override": { + "store_override": { "subject": override_ver.subject, "body_html": override_ver.body_html, "body_text": override_ver.body_text, @@ -435,17 +435,17 @@ class EmailTemplateService: "languages": languages, } - def get_vendor_template_language( + def get_store_template_language( self, - vendor_id: int, + store_id: int, code: str, language: str, ) -> dict[str, Any]: """ - Get a specific language version for a vendor (override or platform). + Get a specific language version for a store (override or platform). Args: - vendor_id: Vendor ID + store_id: Store ID code: Template code language: Language code @@ -473,9 +473,9 @@ class EmailTemplateService: if platform_template.is_platform_only: raise AuthorizationException("This is a platform-only template and cannot be customized") - # Check for vendor override - vendor_override = VendorEmailTemplate.get_override( - self.db, vendor_id, code, language + # Check for store override + store_override = StoreEmailTemplate.get_override( + self.db, store_id, code, language ) # Get platform version @@ -483,15 +483,15 @@ class EmailTemplateService: self.db, code, language ) - if vendor_override: + if store_override: return { "code": code, "language": language, - "source": "vendor_override", - "subject": vendor_override.subject, - "body_html": vendor_override.body_html, - "body_text": vendor_override.body_text, - "name": vendor_override.name, + "source": "store_override", + "subject": store_override.subject, + "body_html": store_override.body_html, + "body_text": store_override.body_text, + "name": store_override.name, "variables": self._parse_required_variables(platform_template.required_variables), "platform_template": { "subject": platform_version.subject, @@ -513,9 +513,9 @@ class EmailTemplateService: else: raise ResourceNotFoundException(f"No template found for language: {language}") - def create_or_update_vendor_override( + def create_or_update_store_override( self, - vendor_id: int, + store_id: int, code: str, language: str, subject: str, @@ -524,10 +524,10 @@ class EmailTemplateService: name: str | None = None, ) -> dict[str, Any]: """ - Create or update a vendor template override. + Create or update a store template override. Args: - vendor_id: Vendor ID + store_id: Store ID code: Template code language: Language code subject: Custom subject @@ -563,9 +563,9 @@ class EmailTemplateService: self._validate_template_syntax(subject, body_html, body_text) # Create or update - override = VendorEmailTemplate.create_or_update( + override = StoreEmailTemplate.create_or_update( db=self.db, - vendor_id=vendor_id, + store_id=store_id, template_code=code, language=language, subject=subject, @@ -574,7 +574,7 @@ class EmailTemplateService: name=name, ) - logger.info(f"Vendor {vendor_id} updated template override: {code}/{language}") + logger.info(f"Store {store_id} updated template override: {code}/{language}") return { "message": "Template override saved", @@ -583,17 +583,17 @@ class EmailTemplateService: "is_new": override.created_at == override.updated_at, } - def delete_vendor_override( + def delete_store_override( self, - vendor_id: int, + store_id: int, code: str, language: str, ) -> None: """ - Delete a vendor template override. + Delete a store template override. Args: - vendor_id: Vendor ID + store_id: Store ID code: Template code language: Language code @@ -604,27 +604,27 @@ class EmailTemplateService: if language not in SUPPORTED_LANGUAGES: raise ValidationException(f"Unsupported language: {language}") - deleted = VendorEmailTemplate.delete_override( - self.db, vendor_id, code, language + deleted = StoreEmailTemplate.delete_override( + self.db, store_id, code, language ) if not deleted: raise ResourceNotFoundException("No override found for this template and language") - logger.info(f"Vendor {vendor_id} deleted template override: {code}/{language}") + logger.info(f"Store {store_id} deleted template override: {code}/{language}") - def preview_vendor_template( + def preview_store_template( self, - vendor_id: int, + store_id: int, code: str, language: str, variables: dict[str, Any], ) -> dict[str, Any]: """ - Preview a vendor template (override or platform). + Preview a store template (override or platform). Args: - vendor_id: Vendor ID + store_id: Store ID code: Template code language: Language code variables: Variables to render @@ -637,18 +637,18 @@ class EmailTemplateService: ValidationError: If rendering fails """ # Get template content - vendor_override = VendorEmailTemplate.get_override( - self.db, vendor_id, code, language + store_override = StoreEmailTemplate.get_override( + self.db, store_id, code, language ) platform_version = EmailTemplate.get_by_code_and_language( self.db, code, language ) - if vendor_override: - subject = vendor_override.subject - body_html = vendor_override.body_html - body_text = vendor_override.body_text - source = "vendor_override" + if store_override: + subject = store_override.subject + body_html = store_override.body_html + body_text = store_override.body_text + source = "store_override" elif platform_version: subject = platform_version.subject body_html = platform_version.body_html diff --git a/app/modules/messaging/services/messaging_features.py b/app/modules/messaging/services/messaging_features.py new file mode 100644 index 00000000..f4f999e1 --- /dev/null +++ b/app/modules/messaging/services/messaging_features.py @@ -0,0 +1,97 @@ +# app/modules/messaging/services/messaging_features.py +""" +Messaging feature provider for the billing feature system. + +Declares messaging-related billable features (basic messaging, email templates, +bulk messaging) for feature gating. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from app.modules.contracts.features import ( + FeatureDeclaration, + FeatureProviderProtocol, + FeatureScope, + FeatureType, + FeatureUsage, +) + +if TYPE_CHECKING: + from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + + +class MessagingFeatureProvider: + """Feature provider for the messaging module. + + Declares: + - messaging_basic: binary merchant-level feature for basic messaging + - email_templates: binary merchant-level feature for custom email templates + - bulk_messaging: binary merchant-level feature for bulk messaging + """ + + @property + def feature_category(self) -> str: + return "messaging" + + def get_feature_declarations(self) -> list[FeatureDeclaration]: + return [ + FeatureDeclaration( + code="messaging_basic", + name_key="messaging.features.messaging_basic.name", + description_key="messaging.features.messaging_basic.description", + category="messaging", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="mail", + display_order=10, + ), + FeatureDeclaration( + code="email_templates", + name_key="messaging.features.email_templates.name", + description_key="messaging.features.email_templates.description", + category="messaging", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="file-text", + display_order=20, + ), + FeatureDeclaration( + code="bulk_messaging", + name_key="messaging.features.bulk_messaging.name", + description_key="messaging.features.bulk_messaging.description", + category="messaging", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="send", + display_order=30, + ), + ] + + def get_store_usage( + self, + db: Session, + store_id: int, + ) -> list[FeatureUsage]: + return [] + + def get_merchant_usage( + self, + db: Session, + merchant_id: int, + platform_id: int, + ) -> list[FeatureUsage]: + return [] + + +# Singleton instance for module registration +messaging_feature_provider = MessagingFeatureProvider() + +__all__ = [ + "MessagingFeatureProvider", + "messaging_feature_provider", +] diff --git a/app/modules/messaging/services/messaging_service.py b/app/modules/messaging/services/messaging_service.py index 074effe3..2b9a336b 100644 --- a/app/modules/messaging/services/messaging_service.py +++ b/app/modules/messaging/services/messaging_service.py @@ -47,7 +47,7 @@ class MessagingService: initiator_id: int, recipient_type: ParticipantType, recipient_id: int, - vendor_id: int | None = None, + store_id: int | None = None, initial_message: str | None = None, ) -> Conversation: """ @@ -61,51 +61,51 @@ class MessagingService: initiator_id: ID of initiating participant recipient_type: Type of receiving participant recipient_id: ID of receiving participant - vendor_id: Required for vendor_customer/admin_customer types + store_id: Required for store_customer/admin_customer types initial_message: Optional first message content Returns: Created Conversation object """ - # Validate vendor_id requirement + # Validate store_id requirement if conversation_type in [ - ConversationType.VENDOR_CUSTOMER, + ConversationType.STORE_CUSTOMER, ConversationType.ADMIN_CUSTOMER, ]: - if not vendor_id: + if not store_id: raise ValueError( - f"vendor_id required for {conversation_type.value} conversations" + f"store_id required for {conversation_type.value} conversations" ) # Create conversation conversation = Conversation( conversation_type=conversation_type, subject=subject, - vendor_id=vendor_id, + store_id=store_id, ) db.add(conversation) db.flush() # Add participants - initiator_vendor_id = ( - vendor_id if initiator_type == ParticipantType.VENDOR else None + initiator_store_id = ( + store_id if initiator_type == ParticipantType.STORE else None ) - recipient_vendor_id = ( - vendor_id if recipient_type == ParticipantType.VENDOR else None + recipient_store_id = ( + store_id if recipient_type == ParticipantType.STORE else None ) initiator = ConversationParticipant( conversation_id=conversation.id, participant_type=initiator_type, participant_id=initiator_id, - vendor_id=initiator_vendor_id, + store_id=initiator_store_id, unread_count=0, # Initiator has read their own message ) recipient = ConversationParticipant( conversation_id=conversation.id, participant_type=recipient_type, participant_id=recipient_id, - vendor_id=recipient_vendor_id, + store_id=recipient_store_id, unread_count=1 if initial_message else 0, ) @@ -177,7 +177,7 @@ class MessagingService: db: Session, participant_type: ParticipantType, participant_id: int, - vendor_id: int | None = None, + store_id: int | None = None, conversation_type: ConversationType | None = None, is_closed: bool | None = None, skip: int = 0, @@ -201,13 +201,13 @@ class MessagingService: ) ) - # Multi-tenant filter for vendor users - if participant_type == ParticipantType.VENDOR and vendor_id: - query = query.filter(ConversationParticipant.vendor_id == vendor_id) + # Multi-tenant filter for store users + if participant_type == ParticipantType.STORE and store_id: + query = query.filter(ConversationParticipant.store_id == store_id) - # Customer vendor isolation - if participant_type == ParticipantType.CUSTOMER and vendor_id: - query = query.filter(Conversation.vendor_id == vendor_id) + # Customer store isolation + if participant_type == ParticipantType.CUSTOMER and store_id: + query = query.filter(Conversation.store_id == store_id) # Type filter if conversation_type: @@ -230,9 +230,9 @@ class MessagingService: ) ) - if participant_type == ParticipantType.VENDOR and vendor_id: + if participant_type == ParticipantType.STORE and store_id: unread_query = unread_query.filter( - ConversationParticipant.vendor_id == vendor_id + ConversationParticipant.store_id == store_id ) total_unread = unread_query.scalar() or 0 @@ -468,7 +468,7 @@ class MessagingService: db: Session, participant_type: ParticipantType, participant_id: int, - vendor_id: int | None = None, + store_id: int | None = None, ) -> int: """Get total unread message count for a participant.""" query = db.query(func.sum(ConversationParticipant.unread_count)).filter( @@ -478,8 +478,8 @@ class MessagingService: ) ) - if vendor_id: - query = query.filter(ConversationParticipant.vendor_id == vendor_id) + if store_id: + query = query.filter(ConversationParticipant.store_id == store_id) return query.scalar() or 0 @@ -494,7 +494,7 @@ class MessagingService: participant_id: int, ) -> dict[str, Any] | None: """Get display info for a participant (name, email, avatar).""" - if participant_type in [ParticipantType.ADMIN, ParticipantType.VENDOR]: + if participant_type in [ParticipantType.ADMIN, ParticipantType.STORE]: user = db.query(User).filter(User.id == participant_id).first() if user: return { @@ -571,20 +571,20 @@ class MessagingService: # RECIPIENT QUERIES # ========================================================================= - def get_vendor_recipients( + def get_store_recipients( self, db: Session, - vendor_id: int | None = None, + store_id: int | None = None, search: str | None = None, skip: int = 0, limit: int = 50, ) -> tuple[list[dict], int]: """ - Get list of vendor users as potential recipients. + Get list of store users as potential recipients. Args: db: Database session - vendor_id: Optional vendor ID filter + store_id: Optional store ID filter search: Search term for name/email skip: Pagination offset limit: Max results @@ -592,16 +592,16 @@ class MessagingService: Returns: Tuple of (recipients list, total count) """ - from app.modules.tenancy.models import VendorUser + from app.modules.tenancy.models import StoreUser query = ( - db.query(User, VendorUser) - .join(VendorUser, User.id == VendorUser.user_id) + db.query(User, StoreUser) + .join(StoreUser, User.id == StoreUser.user_id) .filter(User.is_active == True) # noqa: E712 ) - if vendor_id: - query = query.filter(VendorUser.vendor_id == vendor_id) + if store_id: + query = query.filter(StoreUser.store_id == store_id) if search: search_pattern = f"%{search}%" @@ -616,15 +616,15 @@ class MessagingService: results = query.offset(skip).limit(limit).all() recipients = [] - for user, vendor_user in results: + for user, store_user in results: name = f"{user.first_name or ''} {user.last_name or ''}".strip() or user.username recipients.append({ "id": user.id, - "type": ParticipantType.VENDOR, + "type": ParticipantType.STORE, "name": name, "email": user.email, - "vendor_id": vendor_user.vendor_id, - "vendor_name": vendor_user.vendor.name if vendor_user.vendor else None, + "store_id": store_user.store_id, + "store_name": store_user.store.name if store_user.store else None, }) return recipients, total @@ -632,7 +632,7 @@ class MessagingService: def get_customer_recipients( self, db: Session, - vendor_id: int | None = None, + store_id: int | None = None, search: str | None = None, skip: int = 0, limit: int = 50, @@ -642,7 +642,7 @@ class MessagingService: Args: db: Database session - vendor_id: Optional vendor ID filter (required for vendor users) + store_id: Optional store ID filter (required for store users) search: Search term for name/email skip: Pagination offset limit: Max results @@ -652,8 +652,8 @@ class MessagingService: """ query = db.query(Customer).filter(Customer.is_active == True) # noqa: E712 - if vendor_id: - query = query.filter(Customer.vendor_id == vendor_id) + if store_id: + query = query.filter(Customer.store_id == store_id) if search: search_pattern = f"%{search}%" @@ -674,7 +674,7 @@ class MessagingService: "type": ParticipantType.CUSTOMER, "name": name or customer.email, "email": customer.email, - "vendor_id": customer.vendor_id, + "store_id": customer.store_id, }) return recipients, total diff --git a/app/modules/messaging/static/admin/js/email-templates.js b/app/modules/messaging/static/admin/js/email-templates.js index 6c7d7c3d..eba1e5cc 100644 --- a/app/modules/messaging/static/admin/js/email-templates.js +++ b/app/modules/messaging/static/admin/js/email-templates.js @@ -19,7 +19,7 @@ function emailTemplatesPage() { currentPage: 'email-templates', // Data - loading: true, + loading: false, templates: [], categories: [], selectedCategory: null, @@ -207,9 +207,9 @@ function emailTemplatesPage() { const samples = { 'signup_welcome': { first_name: 'John', - company_name: 'Acme Corp', + merchant_name: 'Acme Corp', email: 'john@example.com', - vendor_code: 'acme', + store_code: 'acme', login_url: 'https://example.com/login', trial_days: '14', tier_name: 'Business' @@ -230,7 +230,7 @@ function emailTemplatesPage() { 'team_invite': { invitee_name: 'Jane', inviter_name: 'John', - vendor_name: 'Acme Corp', + store_name: 'Acme Corp', role: 'Admin', accept_url: 'https://example.com/accept', expires_in_days: '7' diff --git a/app/modules/messaging/static/admin/js/messages.js b/app/modules/messaging/static/admin/js/messages.js index f3980e9e..8e2d0c7a 100644 --- a/app/modules/messaging/static/admin/js/messages.js +++ b/app/modules/messaging/static/admin/js/messages.js @@ -385,15 +385,15 @@ function adminMessages(initialConversationId = null) { this.creatingConversation = true; try { // Determine conversation type - const conversationType = this.compose.recipientType === 'vendor' - ? 'admin_vendor' + const conversationType = this.compose.recipientType === 'store' + ? 'admin_store' : 'admin_customer'; - // Get vendor_id if customer - let vendorId = null; + // Get store_id if customer + let storeId = null; if (this.compose.recipientType === 'customer') { const recipient = this.recipients.find(r => r.id === parseInt(this.compose.recipientId)); - vendorId = recipient?.vendor_id; + storeId = recipient?.store_id; } const response = await apiClient.post('/admin/messages', { @@ -401,7 +401,7 @@ function adminMessages(initialConversationId = null) { subject: this.compose.subject, recipient_type: this.compose.recipientType, recipient_id: parseInt(this.compose.recipientId), - vendor_id: vendorId, + store_id: storeId, initial_message: this.compose.message || null }); diff --git a/app/modules/messaging/static/admin/js/notifications.js b/app/modules/messaging/static/admin/js/notifications.js index 9e60101d..d5825d69 100644 --- a/app/modules/messaging/static/admin/js/notifications.js +++ b/app/modules/messaging/static/admin/js/notifications.js @@ -192,7 +192,7 @@ function adminNotifications() { const icons = { 'import_failure': window.$icon?.('x-circle', 'w-5 h-5') || '❌', 'sync_issue': window.$icon?.('refresh', 'w-5 h-5') || '🔄', - 'vendor_alert': window.$icon?.('exclamation-triangle', 'w-5 h-5') || '⚠️', + 'store_alert': window.$icon?.('exclamation-triangle', 'w-5 h-5') || '⚠️', 'system_health': window.$icon?.('heart', 'w-5 h-5') || '💓', 'security': window.$icon?.('shield-exclamation', 'w-5 h-5') || '🛡️', 'performance': window.$icon?.('chart-bar', 'w-5 h-5') || '📊', diff --git a/app/modules/messaging/static/vendor/js/.gitkeep b/app/modules/messaging/static/store/js/.gitkeep similarity index 100% rename from app/modules/messaging/static/vendor/js/.gitkeep rename to app/modules/messaging/static/store/js/.gitkeep diff --git a/app/modules/messaging/static/vendor/js/email-templates.js b/app/modules/messaging/static/store/js/email-templates.js similarity index 81% rename from app/modules/messaging/static/vendor/js/email-templates.js rename to app/modules/messaging/static/store/js/email-templates.js index 26b4d8c9..44a21146 100644 --- a/app/modules/messaging/static/vendor/js/email-templates.js +++ b/app/modules/messaging/static/store/js/email-templates.js @@ -1,18 +1,18 @@ /** - * Vendor Email Templates Management Page + * Store Email Templates Management Page * - * Allows vendors to customize email templates sent to their customers. + * Allows stores to customize email templates sent to their customers. * Platform-only templates (billing, subscription) cannot be overridden. */ -const vendorEmailTemplatesLog = window.LogConfig?.loggers?.vendorEmailTemplates || - window.LogConfig?.createLogger?.('vendorEmailTemplates', false) || +const storeEmailTemplatesLog = window.LogConfig?.loggers?.storeEmailTemplates || + window.LogConfig?.createLogger?.('storeEmailTemplates', false) || { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} }; -vendorEmailTemplatesLog.info('Loading...'); +storeEmailTemplatesLog.info('Loading...'); -function vendorEmailTemplates() { - vendorEmailTemplatesLog.info('vendorEmailTemplates() called'); +function storeEmailTemplates() { + storeEmailTemplatesLog.info('storeEmailTemplates() called'); return { // Inherit base layout state @@ -57,12 +57,12 @@ function vendorEmailTemplates() { // Load i18n translations await I18n.loadModule('messaging'); - if (window._vendorEmailTemplatesInitialized) return; - window._vendorEmailTemplatesInitialized = true; + if (window._storeEmailTemplatesInitialized) return; + window._storeEmailTemplatesInitialized = true; - vendorEmailTemplatesLog.info('Email templates init() called'); + storeEmailTemplatesLog.info('Email templates init() called'); - // Call parent init to set vendorCode and other base state + // Call parent init to set storeCode and other base state const parentInit = data().init; if (parentInit) { await parentInit.call(this); @@ -77,11 +77,11 @@ function vendorEmailTemplates() { this.error = ''; try { - const response = await apiClient.get('/vendor/email-templates'); + const response = await apiClient.get('/store/email-templates'); this.templates = response.templates || []; this.supportedLanguages = response.supported_languages || ['en', 'fr', 'de', 'lb']; } catch (error) { - vendorEmailTemplatesLog.error('Failed to load templates:', error); + storeEmailTemplatesLog.error('Failed to load templates:', error); this.error = error.detail || 'Failed to load templates'; } finally { this.loading = false; @@ -116,7 +116,7 @@ function vendorEmailTemplates() { try { const data = await apiClient.get( - `/vendor/email-templates/${this.editingTemplate.code}/${this.editLanguage}` + `/store/email-templates/${this.editingTemplate.code}/${this.editLanguage}` ); this.templateSource = data.source; @@ -136,7 +136,7 @@ function vendorEmailTemplates() { }; Utils.showToast(`No template available for ${this.editLanguage.toUpperCase()}`, 'info'); } else { - vendorEmailTemplatesLog.error('Failed to load template:', error); + storeEmailTemplatesLog.error('Failed to load template:', error); Utils.showToast(I18n.t('messaging.messages.failed_to_load_template'), 'error'); } } finally { @@ -161,7 +161,7 @@ function vendorEmailTemplates() { try { await apiClient.put( - `/vendor/email-templates/${this.editingTemplate.code}/${this.editLanguage}`, + `/store/email-templates/${this.editingTemplate.code}/${this.editLanguage}`, { subject: this.editForm.subject, body_html: this.editForm.body_html, @@ -170,11 +170,11 @@ function vendorEmailTemplates() { ); Utils.showToast(I18n.t('messaging.messages.template_saved_successfully'), 'success'); - this.templateSource = 'vendor_override'; + this.templateSource = 'store_override'; // Refresh list to show updated status await this.loadData(); } catch (error) { - vendorEmailTemplatesLog.error('Failed to save template:', error); + storeEmailTemplatesLog.error('Failed to save template:', error); Utils.showToast(error.detail || 'Failed to save template', 'error'); } finally { this.saving = false; @@ -192,7 +192,7 @@ function vendorEmailTemplates() { try { await apiClient.delete( - `/vendor/email-templates/${this.editingTemplate.code}/${this.editLanguage}` + `/store/email-templates/${this.editingTemplate.code}/${this.editLanguage}` ); Utils.showToast(I18n.t('messaging.messages.reverted_to_platform_default'), 'success'); @@ -201,7 +201,7 @@ function vendorEmailTemplates() { // Refresh list await this.loadData(); } catch (error) { - vendorEmailTemplatesLog.error('Failed to revert template:', error); + storeEmailTemplatesLog.error('Failed to revert template:', error); Utils.showToast(error.detail || 'Failed to revert', 'error'); } finally { this.reverting = false; @@ -214,7 +214,7 @@ function vendorEmailTemplates() { try { const data = await apiClient.post( - `/vendor/email-templates/${this.editingTemplate.code}/preview`, + `/store/email-templates/${this.editingTemplate.code}/preview`, { language: this.editLanguage, variables: {} @@ -224,7 +224,7 @@ function vendorEmailTemplates() { this.previewData = data; this.showPreviewModal = true; } catch (error) { - vendorEmailTemplatesLog.error('Failed to preview template:', error); + storeEmailTemplatesLog.error('Failed to preview template:', error); Utils.showToast(I18n.t('messaging.messages.failed_to_load_preview'), 'error'); } }, @@ -241,7 +241,7 @@ function vendorEmailTemplates() { try { const result = await apiClient.post( - `/vendor/email-templates/${this.editingTemplate.code}/test`, + `/store/email-templates/${this.editingTemplate.code}/test`, { to_email: this.testEmailAddress, language: this.editLanguage, @@ -257,7 +257,7 @@ function vendorEmailTemplates() { Utils.showToast(result.message || 'Failed to send test email', 'error'); } } catch (error) { - vendorEmailTemplatesLog.error('Failed to send test email:', error); + storeEmailTemplatesLog.error('Failed to send test email:', error); Utils.showToast(I18n.t('messaging.messages.failed_to_send_test_email'), 'error'); } finally { this.sendingTest = false; diff --git a/app/modules/messaging/static/vendor/js/messages.js b/app/modules/messaging/static/store/js/messages.js similarity index 88% rename from app/modules/messaging/static/vendor/js/messages.js rename to app/modules/messaging/static/store/js/messages.js index a8cf1321..f9332f86 100644 --- a/app/modules/messaging/static/vendor/js/messages.js +++ b/app/modules/messaging/static/store/js/messages.js @@ -1,19 +1,19 @@ /** - * Vendor Messages Page + * Store Messages Page * - * Handles the messaging interface for vendors including: + * Handles the messaging interface for stores including: * - Conversation list with filtering * - Message thread display * - Sending messages * - Creating new conversations with customers */ -const messagesLog = window.LogConfig?.createLogger('VENDOR-MESSAGES') || console; +const messagesLog = window.LogConfig?.createLogger('STORE-MESSAGES') || console; /** - * Vendor Messages Component + * Store Messages Component */ -function vendorMessages(initialConversationId = null) { +function storeMessages(initialConversationId = null) { return { ...data(), @@ -66,17 +66,17 @@ function vendorMessages(initialConversationId = null) { await I18n.loadModule('messaging'); // Guard against multiple initialization - if (window._vendorMessagesInitialized) return; - window._vendorMessagesInitialized = true; + if (window._storeMessagesInitialized) return; + window._storeMessagesInitialized = 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); } try { - messagesLog.debug('Initializing vendor messages page'); + messagesLog.debug('Initializing store messages page'); await Promise.all([ this.loadConversations(), this.loadRecipients() @@ -138,7 +138,7 @@ function vendorMessages(initialConversationId = null) { params.append('is_closed', this.filters.is_closed); } - const response = await apiClient.get(`/vendor/messages?${params}`); + const response = await apiClient.get(`/store/messages?${params}`); this.conversations = response.conversations || []; this.totalConversations = response.total || 0; this.totalUnread = response.total_unread || 0; @@ -157,7 +157,7 @@ function vendorMessages(initialConversationId = null) { */ async updateUnreadCount() { try { - const response = await apiClient.get('/vendor/messages/unread-count'); + const response = await apiClient.get('/store/messages/unread-count'); this.totalUnread = response.total_unread || 0; } catch (error) { messagesLog.error('Failed to update unread count:', error); @@ -180,7 +180,7 @@ function vendorMessages(initialConversationId = null) { async loadConversation(conversationId) { this.loadingMessages = true; try { - const response = await apiClient.get(`/vendor/messages/${conversationId}?mark_read=true`); + const response = await apiClient.get(`/store/messages/${conversationId}?mark_read=true`); this.selectedConversation = response; // Update unread count in list @@ -208,7 +208,7 @@ function vendorMessages(initialConversationId = null) { if (!this.selectedConversationId) return; try { - const response = await apiClient.get(`/vendor/messages/${this.selectedConversationId}?mark_read=true`); + const response = await apiClient.get(`/store/messages/${this.selectedConversationId}?mark_read=true`); const oldCount = this.selectedConversation?.messages?.length || 0; const newCount = response.messages?.length || 0; @@ -249,7 +249,7 @@ function vendorMessages(initialConversationId = null) { const formData = new FormData(); formData.append('content', this.replyContent); - const message = await apiClient.postFormData(`/vendor/messages/${this.selectedConversationId}/messages`, formData); + const message = await apiClient.postFormData(`/store/messages/${this.selectedConversationId}/messages`, formData); // Add to messages if (this.selectedConversation) { @@ -282,7 +282,7 @@ function vendorMessages(initialConversationId = null) { if (!confirm(I18n.t('messaging.confirmations.close_conversation'))) return; try { - await apiClient.post(`/vendor/messages/${this.selectedConversationId}/close`); + await apiClient.post(`/store/messages/${this.selectedConversationId}/close`); if (this.selectedConversation) { this.selectedConversation.is_closed = true; @@ -303,7 +303,7 @@ function vendorMessages(initialConversationId = null) { */ async reopenConversation() { try { - await apiClient.post(`/vendor/messages/${this.selectedConversationId}/reopen`); + await apiClient.post(`/store/messages/${this.selectedConversationId}/reopen`); if (this.selectedConversation) { this.selectedConversation.is_closed = false; @@ -328,7 +328,7 @@ function vendorMessages(initialConversationId = null) { */ async loadRecipients() { try { - const response = await apiClient.get('/vendor/messages/recipients?recipient_type=customer&limit=100'); + const response = await apiClient.get('/store/messages/recipients?recipient_type=customer&limit=100'); this.recipients = response.recipients || []; } catch (error) { messagesLog.error('Failed to load recipients:', error); @@ -343,8 +343,8 @@ function vendorMessages(initialConversationId = null) { this.creatingConversation = true; try { - const response = await apiClient.post('/vendor/messages', { - conversation_type: 'vendor_customer', + const response = await apiClient.post('/store/messages', { + conversation_type: 'store_customer', subject: this.compose.subject, recipient_type: 'customer', recipient_id: parseInt(this.compose.recipientId), @@ -374,7 +374,7 @@ function vendorMessages(initialConversationId = null) { getOtherParticipantName() { if (!this.selectedConversation?.participants) return 'Unknown'; - const other = this.selectedConversation.participants.find(p => p.participant_type !== 'vendor'); + const other = this.selectedConversation.participants.find(p => p.participant_type !== 'store'); return other?.participant_info?.name || 'Unknown'; }, @@ -388,7 +388,7 @@ function vendorMessages(initialConversationId = null) { if (diff < 3600) return `${Math.floor(diff / 60)}m`; if (diff < 86400) return `${Math.floor(diff / 3600)}h`; if (diff < 172800) return 'Yesterday'; - const locale = window.VENDOR_CONFIG?.locale || 'en-GB'; + const locale = window.STORE_CONFIG?.locale || 'en-GB'; return date.toLocaleDateString(locale); }, @@ -397,7 +397,7 @@ function vendorMessages(initialConversationId = null) { const date = new Date(dateString); const now = new Date(); const isToday = date.toDateString() === now.toDateString(); - const locale = window.VENDOR_CONFIG?.locale || 'en-GB'; + const locale = window.STORE_CONFIG?.locale || 'en-GB'; if (isToday) { return date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }); @@ -408,4 +408,4 @@ function vendorMessages(initialConversationId = null) { } // Make available globally -window.vendorMessages = vendorMessages; +window.storeMessages = storeMessages; diff --git a/app/modules/messaging/static/vendor/js/notifications.js b/app/modules/messaging/static/store/js/notifications.js similarity index 78% rename from app/modules/messaging/static/vendor/js/notifications.js rename to app/modules/messaging/static/store/js/notifications.js index 22e7c927..025f323d 100644 --- a/app/modules/messaging/static/vendor/js/notifications.js +++ b/app/modules/messaging/static/store/js/notifications.js @@ -1,16 +1,16 @@ -// app/modules/messaging/static/vendor/js/notifications.js +// app/modules/messaging/static/store/js/notifications.js /** - * Vendor notifications center page logic + * Store notifications center page logic * View and manage notifications */ -const vendorNotificationsLog = window.LogConfig.loggers.vendorNotifications || - window.LogConfig.createLogger('vendorNotifications', false); +const storeNotificationsLog = window.LogConfig.loggers.storeNotifications || + window.LogConfig.createLogger('storeNotifications', false); -vendorNotificationsLog.info('Loading...'); +storeNotificationsLog.info('Loading...'); -function vendorNotifications() { - vendorNotificationsLog.info('vendorNotifications() called'); +function storeNotifications() { + storeNotificationsLog.info('storeNotifications() called'); return { // Inherit base layout state @@ -54,16 +54,16 @@ function vendorNotifications() { // Load i18n translations await I18n.loadModule('messaging'); - vendorNotificationsLog.info('Notifications init() called'); + storeNotificationsLog.info('Notifications init() called'); // Guard against multiple initialization - if (window._vendorNotificationsInitialized) { - vendorNotificationsLog.warn('Already initialized, skipping'); + if (window._storeNotificationsInitialized) { + storeNotificationsLog.warn('Already initialized, skipping'); return; } - window._vendorNotificationsInitialized = true; + window._storeNotificationsInitialized = 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); @@ -72,13 +72,13 @@ function vendorNotifications() { try { await this.loadNotifications(); } catch (error) { - vendorNotificationsLog.error('Init failed:', error); + storeNotificationsLog.error('Init failed:', error); this.error = 'Failed to initialize notifications page'; } finally { this.loading = false; } - vendorNotificationsLog.info('Notifications initialization complete'); + storeNotificationsLog.info('Notifications initialization complete'); }, /** @@ -98,15 +98,15 @@ function vendorNotifications() { params.append('unread_only', 'true'); } - const response = await apiClient.get(`/vendor/notifications?${params}`); + const response = await apiClient.get(`/store/notifications?${params}`); this.notifications = response.notifications || []; this.stats.total = response.total || 0; this.stats.unread_count = response.unread_count || 0; - vendorNotificationsLog.info(`Loaded ${this.notifications.length} notifications`); + storeNotificationsLog.info(`Loaded ${this.notifications.length} notifications`); } catch (error) { - vendorNotificationsLog.error('Failed to load notifications:', error); + storeNotificationsLog.error('Failed to load notifications:', error); this.error = error.message || 'Failed to load notifications'; } finally { this.loadingNotifications = false; @@ -118,7 +118,7 @@ function vendorNotifications() { */ async markAsRead(notification) { try { - await apiClient.put(`/vendor/notifications/${notification.id}/read`); + await apiClient.put(`/store/notifications/${notification.id}/read`); // Update local state notification.is_read = true; @@ -126,7 +126,7 @@ function vendorNotifications() { Utils.showToast(I18n.t('messaging.messages.notification_marked_as_read'), 'success'); } catch (error) { - vendorNotificationsLog.error('Failed to mark as read:', error); + storeNotificationsLog.error('Failed to mark as read:', error); Utils.showToast(error.message || 'Failed to mark notification as read', 'error'); } }, @@ -136,7 +136,7 @@ function vendorNotifications() { */ async markAllAsRead() { try { - await apiClient.put(`/vendor/notifications/mark-all-read`); + await apiClient.put(`/store/notifications/mark-all-read`); // Update local state this.notifications.forEach(n => n.is_read = true); @@ -144,7 +144,7 @@ function vendorNotifications() { Utils.showToast(I18n.t('messaging.messages.all_notifications_marked_as_read'), 'success'); } catch (error) { - vendorNotificationsLog.error('Failed to mark all as read:', error); + storeNotificationsLog.error('Failed to mark all as read:', error); Utils.showToast(error.message || 'Failed to mark all as read', 'error'); } }, @@ -158,7 +158,7 @@ function vendorNotifications() { } try { - await apiClient.delete(`/vendor/notifications/${notificationId}`); + await apiClient.delete(`/store/notifications/${notificationId}`); // Remove from local state const wasUnread = this.notifications.find(n => n.id === notificationId && !n.is_read); @@ -170,7 +170,7 @@ function vendorNotifications() { Utils.showToast(I18n.t('messaging.messages.notification_deleted'), 'success'); } catch (error) { - vendorNotificationsLog.error('Failed to delete notification:', error); + storeNotificationsLog.error('Failed to delete notification:', error); Utils.showToast(error.message || 'Failed to delete notification', 'error'); } }, @@ -180,14 +180,14 @@ function vendorNotifications() { */ async openSettingsModal() { try { - const response = await apiClient.get(`/vendor/notifications/settings`); + const response = await apiClient.get(`/store/notifications/settings`); this.settingsForm = { email_notifications: response.email_notifications !== false, in_app_notifications: response.in_app_notifications !== false }; this.showSettingsModal = true; } catch (error) { - vendorNotificationsLog.error('Failed to load settings:', error); + storeNotificationsLog.error('Failed to load settings:', error); Utils.showToast(error.message || 'Failed to load notification settings', 'error'); } }, @@ -197,11 +197,11 @@ function vendorNotifications() { */ async saveSettings() { try { - await apiClient.put(`/vendor/notifications/settings`, this.settingsForm); + await apiClient.put(`/store/notifications/settings`, this.settingsForm); Utils.showToast(I18n.t('messaging.messages.notification_settings_saved'), 'success'); this.showSettingsModal = false; } catch (error) { - vendorNotificationsLog.error('Failed to save settings:', error); + storeNotificationsLog.error('Failed to save settings:', error); Utils.showToast(error.message || 'Failed to save settings', 'error'); } }, @@ -253,7 +253,7 @@ function vendorNotifications() { if (diff < 172800) return 'Yesterday'; // Show full date for older dates - const locale = window.VENDOR_CONFIG?.locale || 'en-GB'; + const locale = window.STORE_CONFIG?.locale || 'en-GB'; return date.toLocaleDateString(locale); }, diff --git a/app/modules/messaging/templates/messaging/admin/email-templates.html b/app/modules/messaging/templates/messaging/admin/email-templates.html index 5934d565..e6cd7be9 100644 --- a/app/modules/messaging/templates/messaging/admin/email-templates.html +++ b/app/modules/messaging/templates/messaging/admin/email-templates.html @@ -12,7 +12,7 @@ Email Templates

- Manage platform email templates. Vendors can override non-platform-only templates. + Manage platform email templates. Stores can override non-platform-only templates.

@@ -118,11 +118,13 @@ - - - No templates found - - +
diff --git a/app/modules/messaging/templates/messaging/admin/messages.html b/app/modules/messaging/templates/messaging/admin/messages.html index 88ac3c39..75c54066 100644 --- a/app/modules/messaging/templates/messaging/admin/messages.html +++ b/app/modules/messaging/templates/messaging/admin/messages.html @@ -34,7 +34,7 @@ class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300" > - +
@@ -301,7 +301,7 @@ >
diff --git a/app/modules/messaging/templates/messaging/vendor/.gitkeep b/app/modules/messaging/templates/messaging/store/.gitkeep similarity index 100% rename from app/modules/messaging/templates/messaging/vendor/.gitkeep rename to app/modules/messaging/templates/messaging/store/.gitkeep diff --git a/app/modules/messaging/templates/messaging/vendor/email-templates.html b/app/modules/messaging/templates/messaging/store/email-templates.html similarity index 97% rename from app/modules/messaging/templates/messaging/vendor/email-templates.html rename to app/modules/messaging/templates/messaging/store/email-templates.html index e14ac273..3032c817 100644 --- a/app/modules/messaging/templates/messaging/vendor/email-templates.html +++ b/app/modules/messaging/templates/messaging/store/email-templates.html @@ -1,12 +1,12 @@ -{# app/templates/vendor/email-templates.html #} -{% extends "vendor/base.html" %} +{# app/templates/store/email-templates.html #} +{% extends "store/base.html" %} {% from 'shared/macros/headers.html' import page_header_flex %} {% from 'shared/macros/alerts.html' import loading_state, error_state %} {% from 'shared/macros/modals.html' import modal_dialog %} {% block title %}Email Templates{% endblock %} -{% block alpine_data %}vendorEmailTemplates(){% endblock %} +{% block alpine_data %}storeEmailTemplates(){% endblock %} {% block content %} @@ -148,7 +148,7 @@
-