Compare commits
269 Commits
f47c680cb8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 56c94ac2f4 | |||
| 255ac6525e | |||
| 10e37e749b | |||
| f23990a4d9 | |||
| 62b83b46a4 | |||
| f8b2429533 | |||
| 3883927be0 | |||
| 39e02f0d9b | |||
| 29593f4c61 | |||
| 220f7e3a08 | |||
| 258aa6a34b | |||
| 51bcc9f874 | |||
| eafa086c73 | |||
| ab2daf99bd | |||
| 1cf9fea40a | |||
| cd4f83f2cb | |||
| 457350908a | |||
| e759282116 | |||
| 1df1b2bfca | |||
| 51a2114e02 | |||
| 21e4ac5124 | |||
| 3ade1b9354 | |||
| b5bb9415f6 | |||
| bb3d6f0012 | |||
| c92fe1261b | |||
| ca152cd544 | |||
| 914967edcc | |||
| 64fe58c171 | |||
| 3044490a3e | |||
| adc36246b8 | |||
| dd9dc04328 | |||
| 4a60d75a13 | |||
| e98eddc168 | |||
| 8cd09f3f89 | |||
| 4c1608f78a | |||
| 24219e4d9a | |||
| fde58bea06 | |||
| 52b78ce346 | |||
| f804ff8442 | |||
| d9abb275a5 | |||
| 4b56eb7ab1 | |||
| 27ac7f3e28 | |||
| dfd42c1b10 | |||
| 297b8a8d5a | |||
| 91fb4b0757 | |||
| f4386e97ee | |||
| e8c9fc7e7d | |||
| d591200df8 | |||
| 83af32eb88 | |||
| 2a49e3d30f | |||
| 6e40e16017 | |||
| dd09bcaeec | |||
| 013eafd775 | |||
| 07cd66a0e3 | |||
| 73d453d78a | |||
| d4e9fed719 | |||
| 3e93f64c6b | |||
| 377d2d3ae8 | |||
| b51f9e8e30 | |||
| d380437594 | |||
| cff0af31be | |||
| e492e5f71c | |||
| 9a5b7dd061 | |||
| b3051b423a | |||
| bc951a36d9 | |||
| 2e043260eb | |||
| 1828ac85eb | |||
| 50a4fc38a7 | |||
| 30f3dae5a3 | |||
| 4c750f0268 | |||
| 59b0d8977a | |||
| 2bc03ed97c | |||
| 91963f3b87 | |||
| 3ae0b579d3 | |||
| 972ee1e5d0 | |||
| 70f2803dd3 | |||
| a247622d23 | |||
| 50d50fcbd0 | |||
| b306a5e8f4 | |||
| 28b08580c8 | |||
| 754bfca87d | |||
| 1decb4572c | |||
| d685341b04 | |||
| 0c6d8409c7 | |||
| f81851445e | |||
| 4748368809 | |||
| f310363f7c | |||
| 95f0eac079 | |||
| 11dcfdad73 | |||
| 01f7add8dd | |||
| 0d1007282a | |||
| 2a15c14ee8 | |||
| bc5e227d81 | |||
| 8a70259445 | |||
| 823935c016 | |||
| dab5560de8 | |||
| 157b4c6ec3 | |||
| 211c46ebbc | |||
| d81e9a3fa4 | |||
| fd0de714a4 | |||
| c6b155520c | |||
| 66b77e747d | |||
| 71b5eb1758 | |||
| b4f01210d9 | |||
| 9bceeaac9c | |||
| 332960de30 | |||
| 0455e63a2e | |||
| aaed1b2d01 | |||
| 9dee534b2f | |||
| beef3ce76b | |||
| 884a694718 | |||
| 4cafbe9610 | |||
| 19923ed26b | |||
| 46f8d227b8 | |||
| 95e4956216 | |||
| 77e520bbce | |||
| 518bace534 | |||
| fcde2d68fc | |||
| 5a33f68743 | |||
| 040cbd1962 | |||
| b679c9687d | |||
| 314360a394 | |||
| 44a0c38016 | |||
| da9e1ab293 | |||
| 5de297a804 | |||
| 4429674100 | |||
| 316ec42566 | |||
| 894832c62b | |||
| 1d90bfe044 | |||
| ce0caa5685 | |||
| 33f823aba0 | |||
| edd55cd2fd | |||
| f3344b2859 | |||
| 1107de989b | |||
| a423bcf03e | |||
| 661547f6cf | |||
| 3015a490f9 | |||
| 5b4ed79f87 | |||
| 52a5f941fe | |||
| 6161d69ba2 | |||
| f41f72b86f | |||
| 644bf158cd | |||
| f89c0382f0 | |||
| 11b8e31a29 | |||
| 0ddef13124 | |||
| 60bed05d3f | |||
| 40da2d6b11 | |||
| d96e0ea1b4 | |||
| 7d652716bb | |||
| b6047f5b7d | |||
| 366d4b9765 | |||
| 540205402f | |||
| 07fab01f6a | |||
| 6c07f6cbb2 | |||
| bc7431943a | |||
| adec17cd02 | |||
| a28d5d1de5 | |||
| 502473eee4 | |||
| 183f55c7b3 | |||
| 169a774b9c | |||
| ebbe6d62b8 | |||
| c2c0e3c740 | |||
| 4a1f71a312 | |||
| 5dd5e01dc6 | |||
| 694a1cd1a5 | |||
| 826ef2ddd2 | |||
| a1cc05cd3d | |||
| 19d267587b | |||
| 9a13aee8ed | |||
| 9c39a9703f | |||
| 395707951e | |||
| 34bf961309 | |||
| 44acf5e442 | |||
| b3224ba13d | |||
| 93b7279c3a | |||
| 29d942322d | |||
| 8c8975239a | |||
| f766a72480 | |||
| 618376aa39 | |||
| efca9734d2 | |||
| 6acd783754 | |||
| 8cf5da6914 | |||
| eee33d6a1b | |||
| aefca3115e | |||
| 319900623a | |||
| a77a8a3a98 | |||
| f141cc4e6a | |||
| 2287f4597d | |||
| 8136739233 | |||
| 2ca313c3c7 | |||
| 27802e47c2 | |||
| 14d5ff97f3 | |||
| b9b8ffadcb | |||
| 31ced5f759 | |||
| 802cc6b137 | |||
| 45260b6b82 | |||
| fa758b7e31 | |||
| a099bfdc48 | |||
| cb9a829684 | |||
| c4e9e4e646 | |||
| 8c449d7baa | |||
| 820ab1aaa4 | |||
| 2268f32f51 | |||
| b68d542258 | |||
| a7392de9f6 | |||
| 3c7e4458af | |||
| 8b147f53c6 | |||
| 784bcb9d23 | |||
| b8aa484653 | |||
| 05c53e1865 | |||
| 6dec1e3ca6 | |||
| f631283286 | |||
| f631322b4e | |||
| e61e02fb39 | |||
| b5b73559b5 | |||
| 28dca65a06 | |||
| adbecd360b | |||
| ef9ea29643 | |||
| f8a2394da5 | |||
| 4d07418f44 | |||
| bf64f82613 | |||
| 9684747d08 | |||
| 2078ce35b2 | |||
| 22ae63b414 | |||
| 78ee05f50e | |||
| 6d6eba75bf | |||
| a709adaee8 | |||
| 8d5c8a52e6 | |||
| d8f0cf16c7 | |||
| 93a2d9baff | |||
| 35d1559162 | |||
| ce822af883 | |||
| 4ebd419987 | |||
| 2b29867093 | |||
| 30c4593e0f | |||
| 8c0967e215 | |||
| 86e85a98b8 | |||
| e3a52f6536 | |||
| 4aa6f76e46 | |||
| f95db7c0b1 | |||
| 2b55e7458b | |||
| c82210795f | |||
| cb3bc3c118 | |||
| 962862ccc1 | |||
| 3053bc5d92 | |||
| 79a88b0a36 | |||
| e7f8e61717 | |||
| d480b59df4 | |||
| ce5b54f27b | |||
| 6a82d7c12d | |||
| f1e7baaa6c | |||
| 6b46a78e72 | |||
| d648c921b7 | |||
| 3df75e2e78 | |||
| 92a434530f | |||
| 01146d5c97 | |||
| d0d5aadaf7 | |||
| 56afb9192b | |||
| a4519035df | |||
| c9b2ecbdff | |||
| 1194731f33 | |||
| 12c1c3c511 | |||
| 81cf84ed28 | |||
| a6e6d9be8e | |||
| ec888f2e94 | |||
| 53dfe018c2 | |||
| 3de69e55a1 | |||
| cfce6c0ca4 | |||
| 2833ff1476 |
@@ -111,11 +111,9 @@ language_rules:
|
|||||||
function languageSelector(currentLang, enabledLanguages) { ... }
|
function languageSelector(currentLang, enabledLanguages) { ... }
|
||||||
window.languageSelector = languageSelector;
|
window.languageSelector = languageSelector;
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "static/shop/js/shop-layout.js"
|
file_patterns:
|
||||||
required_patterns:
|
- "static/shop/js/shop-layout.js"
|
||||||
- "function languageSelector"
|
- "static/vendor/js/init-alpine.js"
|
||||||
- "window.languageSelector"
|
|
||||||
file_pattern: "static/vendor/js/init-alpine.js"
|
|
||||||
required_patterns:
|
required_patterns:
|
||||||
- "function languageSelector"
|
- "function languageSelector"
|
||||||
- "window.languageSelector"
|
- "window.languageSelector"
|
||||||
@@ -247,3 +245,26 @@ language_rules:
|
|||||||
pattern:
|
pattern:
|
||||||
file_pattern: "static/locales/*.json"
|
file_pattern: "static/locales/*.json"
|
||||||
check: "valid_json"
|
check: "valid_json"
|
||||||
|
|
||||||
|
- id: "LANG-011"
|
||||||
|
name: "Use $t() not I18n.t() in HTML templates"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
In HTML templates, never use I18n.t() directly. It evaluates once
|
||||||
|
and does NOT re-evaluate when translations finish loading async.
|
||||||
|
|
||||||
|
WRONG (non-reactive, shows raw key then updates):
|
||||||
|
<span x-text="I18n.t('module.key')"></span>
|
||||||
|
|
||||||
|
RIGHT (reactive, updates when translations load):
|
||||||
|
<span x-text="$t('module.key')"></span>
|
||||||
|
|
||||||
|
BEST (server-side, zero flash):
|
||||||
|
<span>{{ _('module.key') }}</span>
|
||||||
|
|
||||||
|
Note: I18n.t() is fine in .js files where it's called inside
|
||||||
|
async callbacks after I18n.init() has completed.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "**/*.html"
|
||||||
|
anti_patterns:
|
||||||
|
- "I18n.t("
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ module_rules:
|
|||||||
en.json
|
en.json
|
||||||
de.json
|
de.json
|
||||||
fr.json
|
fr.json
|
||||||
lu.json
|
lb.json
|
||||||
|
|
||||||
Translation keys are namespaced as {module}.key_name
|
Translation keys are namespaced as {module}.key_name
|
||||||
pattern:
|
pattern:
|
||||||
@@ -269,14 +269,14 @@ module_rules:
|
|||||||
Module locales/ directory should have translation files for
|
Module locales/ directory should have translation files for
|
||||||
all supported languages to ensure consistent i18n.
|
all supported languages to ensure consistent i18n.
|
||||||
|
|
||||||
Supported languages: en, de, fr, lu
|
Supported languages: en, de, fr, lb
|
||||||
|
|
||||||
Structure:
|
Structure:
|
||||||
app/modules/<code>/locales/
|
app/modules/<code>/locales/
|
||||||
├── en.json
|
├── en.json
|
||||||
├── de.json
|
├── de.json
|
||||||
├── fr.json
|
├── fr.json
|
||||||
└── lu.json
|
└── lb.json
|
||||||
|
|
||||||
Missing translations will fall back to English, but it's
|
Missing translations will fall back to English, but it's
|
||||||
better to have all languages covered.
|
better to have all languages covered.
|
||||||
@@ -286,7 +286,7 @@ module_rules:
|
|||||||
- "en.json"
|
- "en.json"
|
||||||
- "de.json"
|
- "de.json"
|
||||||
- "fr.json"
|
- "fr.json"
|
||||||
- "lu.json"
|
- "lb.json"
|
||||||
|
|
||||||
- id: "MOD-007"
|
- id: "MOD-007"
|
||||||
name: "Module definition must match directory structure"
|
name: "Module definition must match directory structure"
|
||||||
@@ -692,8 +692,9 @@ module_rules:
|
|||||||
name: "Modules with routers should use get_*_with_routers pattern"
|
name: "Modules with routers should use get_*_with_routers pattern"
|
||||||
severity: "info"
|
severity: "info"
|
||||||
description: |
|
description: |
|
||||||
Modules that define routers (admin_router, vendor_router, etc.)
|
Modules that define routers should follow the lazy import pattern
|
||||||
should follow the lazy import pattern with a dedicated function:
|
with a dedicated function. Route files use `router` as the variable
|
||||||
|
name; consumer code distinguishes via `admin_router`/`store_router`.
|
||||||
|
|
||||||
def get_{module}_module_with_routers() -> ModuleDefinition:
|
def get_{module}_module_with_routers() -> ModuleDefinition:
|
||||||
|
|
||||||
@@ -704,12 +705,12 @@ module_rules:
|
|||||||
|
|
||||||
WRONG:
|
WRONG:
|
||||||
# Direct router assignment at module level
|
# Direct router assignment at module level
|
||||||
module.admin_router = admin_router
|
module.admin_router = router
|
||||||
|
|
||||||
RIGHT:
|
RIGHT:
|
||||||
def _get_admin_router():
|
def _get_admin_router():
|
||||||
from app.modules.orders.routes.admin import admin_router
|
from app.modules.orders.routes.api.admin import router
|
||||||
return admin_router
|
return router
|
||||||
|
|
||||||
def get_orders_module_with_routers() -> ModuleDefinition:
|
def get_orders_module_with_routers() -> ModuleDefinition:
|
||||||
orders_module.admin_router = _get_admin_router()
|
orders_module.admin_router = _get_admin_router()
|
||||||
@@ -761,3 +762,96 @@ module_rules:
|
|||||||
file_pattern: "main.py"
|
file_pattern: "main.py"
|
||||||
validates:
|
validates:
|
||||||
- "module_locales mount BEFORE module_static mount"
|
- "module_locales mount BEFORE module_static mount"
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Cross-Module Boundary Rules
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
- id: "MOD-025"
|
||||||
|
name: "Modules must NOT import models from other modules"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Modules must access data from other modules through their SERVICE layer,
|
||||||
|
never by importing and querying their models directly.
|
||||||
|
|
||||||
|
This is the "services over models" principle: if module A needs data
|
||||||
|
from module B, it MUST call module B's service methods.
|
||||||
|
|
||||||
|
WRONG (direct model import):
|
||||||
|
# app/modules/orders/services/order_service.py
|
||||||
|
from app.modules.catalog.models import Product # FORBIDDEN
|
||||||
|
|
||||||
|
class OrderService:
|
||||||
|
def get_order_details(self, db, order_id):
|
||||||
|
product = db.query(Product).filter_by(id=pid).first()
|
||||||
|
|
||||||
|
RIGHT (service call):
|
||||||
|
# app/modules/orders/services/order_service.py
|
||||||
|
from app.modules.catalog.services import product_service
|
||||||
|
|
||||||
|
class OrderService:
|
||||||
|
def get_order_details(self, db, order_id):
|
||||||
|
product = product_service.get_product_by_id(db, pid)
|
||||||
|
|
||||||
|
ALSO RIGHT (provider protocol for core→optional):
|
||||||
|
# app/modules/core/services/stats_aggregator.py
|
||||||
|
from app.modules.contracts.metrics import MetricsProviderProtocol
|
||||||
|
# Discover providers through registry, no direct imports
|
||||||
|
|
||||||
|
EXCEPTIONS:
|
||||||
|
- Test fixtures may create models from other modules for setup
|
||||||
|
- TYPE_CHECKING imports for type hints are allowed
|
||||||
|
- Tenancy models (User, Store, Merchant, Platform) may be imported
|
||||||
|
as type hints in route signatures where FastAPI requires it,
|
||||||
|
but queries must go through tenancy services
|
||||||
|
|
||||||
|
WHY THIS MATTERS:
|
||||||
|
- Encapsulation: Modules own their data access patterns
|
||||||
|
- Refactoring: Module B can change its schema without breaking A
|
||||||
|
- Testability: Mock services, not database queries
|
||||||
|
- Consistency: Clear API boundaries between modules
|
||||||
|
- Decoupling: Modules can evolve independently
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/modules/*/services/**/*.py"
|
||||||
|
anti_patterns:
|
||||||
|
- "from app\\.modules\\.(?!<own_module>)\\.models import"
|
||||||
|
exceptions:
|
||||||
|
- "TYPE_CHECKING"
|
||||||
|
- "tests/"
|
||||||
|
|
||||||
|
- id: "MOD-026"
|
||||||
|
name: "Cross-module data access must use service methods"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
When a module needs data from another module, it must use that
|
||||||
|
module's public service API. Each module should expose service
|
||||||
|
methods for common data access patterns.
|
||||||
|
|
||||||
|
Service methods a module should expose:
|
||||||
|
- get_{entity}_by_id(db, id) -> Entity or None
|
||||||
|
- list_{entities}(db, filters) -> list[Entity]
|
||||||
|
- get_{entity}_count(db, filters) -> int
|
||||||
|
- search_{entities}(db, query, filters) -> list[Entity]
|
||||||
|
|
||||||
|
WRONG (direct query across module boundary):
|
||||||
|
# In orders module
|
||||||
|
count = db.query(func.count(Product.id)).scalar()
|
||||||
|
|
||||||
|
RIGHT (call catalog service):
|
||||||
|
# In orders module
|
||||||
|
count = product_service.get_product_count(db, store_id=store_id)
|
||||||
|
|
||||||
|
This applies to:
|
||||||
|
- Simple lookups (get by ID)
|
||||||
|
- List/search queries
|
||||||
|
- Aggregation queries (count, sum)
|
||||||
|
- Join queries (should be decomposed into service calls)
|
||||||
|
|
||||||
|
WHY THIS MATTERS:
|
||||||
|
- Single source of truth for data access logic
|
||||||
|
- Easier to add caching, validation, or access control
|
||||||
|
- Clear contract between modules
|
||||||
|
- Simpler testing with service mocks
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/modules/*/services/**/*.py"
|
||||||
|
check: "cross_module_service_usage"
|
||||||
|
|||||||
20
.env.example
20
.env.example
@@ -67,10 +67,15 @@ LOG_LEVEL=INFO
|
|||||||
LOG_FILE=logs/app.log
|
LOG_FILE=logs/app.log
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# PLATFORM DOMAIN CONFIGURATION
|
# MAIN DOMAIN CONFIGURATION
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Your main platform domain
|
# Your main platform domain
|
||||||
PLATFORM_DOMAIN=wizard.lu
|
MAIN_DOMAIN=wizard.lu
|
||||||
|
|
||||||
|
# Full base URL for outbound links (emails, billing redirects, etc.)
|
||||||
|
# Must include protocol and port if non-standard
|
||||||
|
# Examples: http://localhost:8000, http://acme.localhost:9999, https://wizard.lu
|
||||||
|
APP_BASE_URL=http://localhost:8000
|
||||||
|
|
||||||
# Custom domain features
|
# Custom domain features
|
||||||
# Enable/disable custom domains
|
# Enable/disable custom domains
|
||||||
@@ -149,6 +154,10 @@ SEED_ORDERS_PER_STORE=10
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# CELERY / REDIS TASK QUEUE
|
# CELERY / REDIS TASK QUEUE
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
# Redis password (must match docker-compose.yml --requirepass flag)
|
||||||
|
# ⚠️ CHANGE THIS IN PRODUCTION! Generate with: openssl rand -hex 16
|
||||||
|
REDIS_PASSWORD=changeme
|
||||||
|
|
||||||
# Redis connection URL (used for Celery broker and backend)
|
# Redis connection URL (used for Celery broker and backend)
|
||||||
# Default works with: docker-compose up -d redis
|
# Default works with: docker-compose up -d redis
|
||||||
REDIS_URL=redis://localhost:6379/0
|
REDIS_URL=redis://localhost:6379/0
|
||||||
@@ -219,7 +228,12 @@ R2_BACKUP_BUCKET=orion-backups
|
|||||||
# See docs/deployment/hetzner-server-setup.md Step 25 for setup guide
|
# See docs/deployment/hetzner-server-setup.md Step 25 for setup guide
|
||||||
# Get Issuer ID from https://pay.google.com/business/console
|
# Get Issuer ID from https://pay.google.com/business/console
|
||||||
# LOYALTY_GOOGLE_ISSUER_ID=3388000000012345678
|
# LOYALTY_GOOGLE_ISSUER_ID=3388000000012345678
|
||||||
# LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/path/to/service-account.json
|
# Production convention: ~/apps/orion/google-wallet-sa.json (app user, mode 600).
|
||||||
|
# Path is validated at startup — file must exist and be readable, otherwise
|
||||||
|
# the app fails fast at import time.
|
||||||
|
# LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=~/apps/orion/google-wallet-sa.json
|
||||||
|
# LOYALTY_GOOGLE_WALLET_ORIGINS=["https://yourdomain.com"]
|
||||||
|
# LOYALTY_DEFAULT_LOGO_URL=https://yourdomain.com/path/to/default-logo.png
|
||||||
|
|
||||||
# Apple Wallet integration (requires Apple Developer account)
|
# Apple Wallet integration (requires Apple Developer account)
|
||||||
# LOYALTY_APPLE_PASS_TYPE_ID=pass.com.example.loyalty
|
# LOYALTY_APPLE_PASS_TYPE_ID=pass.com.example.loyalty
|
||||||
|
|||||||
@@ -37,10 +37,11 @@ jobs:
|
|||||||
run: ruff check .
|
run: ruff check .
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Tests
|
# Tests — unit only (integration tests run locally via make test)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
pytest:
|
pytest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 150
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
@@ -55,10 +56,9 @@ jobs:
|
|||||||
--health-retries 5
|
--health-retries 5
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# act_runner executes jobs in Docker containers on the same network as services,
|
|
||||||
# so use the service name (postgres) as hostname with the internal port (5432)
|
|
||||||
TEST_DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/orion_test"
|
TEST_DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/orion_test"
|
||||||
DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/orion_test"
|
DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/orion_test"
|
||||||
|
LOG_LEVEL: "WARNING"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -73,8 +73,8 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv pip install --system -r requirements.txt -r requirements-test.txt
|
run: uv pip install --system -r requirements.txt -r requirements-test.txt
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run unit tests
|
||||||
run: python -m pytest tests/ -v --tb=short
|
run: python -m pytest -m "unit" -q --tb=short --timeout=120 --no-cov --override-ini="addopts=" -p no:cacheprovider -p no:logging --durations=20
|
||||||
|
|
||||||
validate:
|
validate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
19
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
19
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- Brief description of what this PR does -->
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
|
||||||
|
- [ ] Unit tests pass (`python -m pytest tests/unit/`)
|
||||||
|
- [ ] Integration tests pass (`python -m pytest tests/integration/`)
|
||||||
|
- [ ] Architecture validation passes (`python scripts/validate/validate_all.py`)
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] Code follows project conventions
|
||||||
|
- [ ] No new warnings introduced
|
||||||
|
- [ ] Database migrations included (if applicable)
|
||||||
9
.github/dependabot.yml
vendored
Normal file
9
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "pip"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
labels:
|
||||||
|
- "dependencies"
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -156,11 +156,10 @@ uploads/
|
|||||||
__pypackages__/
|
__pypackages__/
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker-compose.override.yml
|
|
||||||
.dockerignore.local
|
.dockerignore.local
|
||||||
*.override.yml
|
|
||||||
|
|
||||||
# Deployment & Security
|
# Deployment & Security
|
||||||
|
.build-info
|
||||||
deployment-local/
|
deployment-local/
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
@@ -190,3 +189,6 @@ static/shared/css/tailwind.css
|
|||||||
# Export files
|
# Export files
|
||||||
orion_letzshop_export_*.csv
|
orion_letzshop_export_*.csv
|
||||||
exports/
|
exports/
|
||||||
|
|
||||||
|
# Security audit (needs revamping)
|
||||||
|
scripts/security-audit/
|
||||||
|
|||||||
32
Makefile
32
Makefile
@@ -1,7 +1,7 @@
|
|||||||
# Orion Multi-Tenant E-Commerce Platform Makefile
|
# Orion Multi-Tenant E-Commerce Platform Makefile
|
||||||
# Cross-platform compatible (Windows & Linux)
|
# Cross-platform compatible (Windows & Linux)
|
||||||
|
|
||||||
.PHONY: install install-dev install-docs install-all dev test test-coverage lint format check docker-build docker-up docker-down clean help tailwind-install tailwind-dev tailwind-build tailwind-watch arch-check arch-check-file arch-check-object test-db-up test-db-down test-db-reset test-db-status celery-worker celery-beat celery-dev flower celery-status celery-purge urls infra-check
|
.PHONY: install install-dev install-docs install-all dev test test-coverage lint format check docker-build docker-up docker-down clean help tailwind-install tailwind-dev tailwind-build tailwind-watch arch-check arch-check-file arch-check-object test-db-up test-db-down test-db-reset test-db-status celery-worker celery-beat celery-dev flower celery-status celery-purge urls infra-check test-affected test-affected-dry
|
||||||
|
|
||||||
# Detect OS
|
# Detect OS
|
||||||
ifeq ($(OS),Windows_NT)
|
ifeq ($(OS),Windows_NT)
|
||||||
@@ -249,24 +249,21 @@ ifdef frontend
|
|||||||
endif
|
endif
|
||||||
endif
|
endif
|
||||||
|
|
||||||
# All testpaths (central + module tests)
|
|
||||||
TEST_PATHS := tests/ app/modules/tenancy/tests/ app/modules/catalog/tests/ app/modules/billing/tests/ app/modules/messaging/tests/ app/modules/orders/tests/ app/modules/customers/tests/ app/modules/marketplace/tests/ app/modules/inventory/tests/ app/modules/loyalty/tests/
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||||
@sleep 2
|
@sleep 2
|
||||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
$(PYTHON) -m pytest $(TEST_PATHS) -v $(MARKER_EXPR)
|
$(PYTHON) -m pytest -v $(MARKER_EXPR)
|
||||||
|
|
||||||
test-unit:
|
test-unit:
|
||||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||||
@sleep 2
|
@sleep 2
|
||||||
ifdef module
|
ifdef module
|
||||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
$(PYTHON) -m pytest $(TEST_PATHS) -v -m "unit and $(module)"
|
$(PYTHON) -m pytest -v -m "unit and $(module)"
|
||||||
else
|
else
|
||||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
$(PYTHON) -m pytest $(TEST_PATHS) -v -m unit
|
$(PYTHON) -m pytest -v -m unit
|
||||||
endif
|
endif
|
||||||
|
|
||||||
test-integration:
|
test-integration:
|
||||||
@@ -274,29 +271,38 @@ test-integration:
|
|||||||
@sleep 2
|
@sleep 2
|
||||||
ifdef module
|
ifdef module
|
||||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
$(PYTHON) -m pytest $(TEST_PATHS) -v -m "integration and $(module)"
|
$(PYTHON) -m pytest -v -m "integration and $(module)"
|
||||||
else
|
else
|
||||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
$(PYTHON) -m pytest $(TEST_PATHS) -v -m integration
|
$(PYTHON) -m pytest -v -m integration
|
||||||
endif
|
endif
|
||||||
|
|
||||||
test-coverage:
|
test-coverage:
|
||||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||||
@sleep 2
|
@sleep 2
|
||||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
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)
|
$(PYTHON) -m pytest --cov=app --cov=models --cov=utils --cov=middleware --cov-report=html --cov-report=term-missing $(MARKER_EXPR)
|
||||||
|
|
||||||
|
test-affected:
|
||||||
|
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||||
|
@sleep 2
|
||||||
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
|
$(PYTHON) scripts/tests/run_affected_tests.py $(AFFECTED_ARGS)
|
||||||
|
|
||||||
|
test-affected-dry:
|
||||||
|
@$(PYTHON) scripts/tests/run_affected_tests.py --dry-run $(AFFECTED_ARGS)
|
||||||
|
|
||||||
test-fast:
|
test-fast:
|
||||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||||
@sleep 2
|
@sleep 2
|
||||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
$(PYTHON) -m pytest $(TEST_PATHS) -v -m "not slow" $(MARKER_EXPR)
|
$(PYTHON) -m pytest -v -m "not slow" $(MARKER_EXPR)
|
||||||
|
|
||||||
test-slow:
|
test-slow:
|
||||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||||
@sleep 2
|
@sleep 2
|
||||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
$(PYTHON) -m pytest $(TEST_PATHS) -v -m slow
|
$(PYTHON) -m pytest -v -m slow
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# CODE QUALITY
|
# CODE QUALITY
|
||||||
@@ -569,6 +575,8 @@ help:
|
|||||||
@echo " test-unit module=X - Run unit tests for module X"
|
@echo " test-unit module=X - Run unit tests for module X"
|
||||||
@echo " test-integration - Run integration tests only"
|
@echo " test-integration - Run integration tests only"
|
||||||
@echo " test-coverage - Run tests with coverage"
|
@echo " test-coverage - Run tests with coverage"
|
||||||
|
@echo " test-affected - Run tests for modules affected by changes"
|
||||||
|
@echo " test-affected-dry - Show affected modules without running tests"
|
||||||
@echo " test-fast - Run fast tests only"
|
@echo " test-fast - Run fast tests only"
|
||||||
@echo " test frontend=storefront - Run storefront tests"
|
@echo " test frontend=storefront - Run storefront tests"
|
||||||
@echo ""
|
@echo ""
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
script_location = alembic
|
script_location = alembic
|
||||||
prepend_sys_path = .
|
prepend_sys_path = .
|
||||||
version_path_separator = space
|
version_path_separator = space
|
||||||
version_locations = alembic/versions app/modules/billing/migrations/versions app/modules/cart/migrations/versions app/modules/catalog/migrations/versions app/modules/cms/migrations/versions app/modules/customers/migrations/versions app/modules/dev_tools/migrations/versions app/modules/inventory/migrations/versions app/modules/loyalty/migrations/versions app/modules/marketplace/migrations/versions app/modules/messaging/migrations/versions app/modules/orders/migrations/versions app/modules/tenancy/migrations/versions
|
version_locations = alembic/versions app/modules/billing/migrations/versions app/modules/cart/migrations/versions app/modules/catalog/migrations/versions app/modules/cms/migrations/versions app/modules/customers/migrations/versions app/modules/dev_tools/migrations/versions app/modules/hosting/migrations/versions app/modules/inventory/migrations/versions app/modules/loyalty/migrations/versions app/modules/marketplace/migrations/versions app/modules/messaging/migrations/versions app/modules/orders/migrations/versions app/modules/prospecting/migrations/versions app/modules/tenancy/migrations/versions
|
||||||
# This will be overridden by alembic\env.py using settings.database_url
|
# This will be overridden by alembic\env.py using settings.database_url
|
||||||
sqlalchemy.url =
|
sqlalchemy.url =
|
||||||
# for PROD: sqlalchemy.url = postgresql://username:password@localhost:5432/ecommerce_db
|
# for PROD: sqlalchemy.url = postgresql://username:password@localhost:5432/ecommerce_db
|
||||||
|
|||||||
35
alembic/versions/remove_store_platform_is_primary.py
Normal file
35
alembic/versions/remove_store_platform_is_primary.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""Remove is_primary from store_platforms
|
||||||
|
|
||||||
|
The platform is always deterministic from the URL context (path in dev,
|
||||||
|
subdomain/domain in prod) and the JWT carries token_platform_id.
|
||||||
|
The is_primary column was a fallback picker that silently returned the
|
||||||
|
wrong platform for multi-platform stores.
|
||||||
|
|
||||||
|
Revision ID: remove_is_primary_001
|
||||||
|
Revises: billing_001
|
||||||
|
Create Date: 2026-03-09
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "remove_is_primary_001"
|
||||||
|
down_revision = "billing_001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_index("idx_store_platform_primary", table_name="store_platforms")
|
||||||
|
op.drop_column("store_platforms", "is_primary")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"store_platforms",
|
||||||
|
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_store_platform_primary", "store_platforms", ["store_id", "is_primary"]
|
||||||
|
)
|
||||||
118
alembic/versions/softdelete_001_add_soft_delete.py
Normal file
118
alembic/versions/softdelete_001_add_soft_delete.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""Add soft delete columns (deleted_at, deleted_by_id) to business-critical tables.
|
||||||
|
|
||||||
|
Also converts unique constraints on users.email, users.username,
|
||||||
|
stores.store_code, stores.subdomain to partial unique indexes
|
||||||
|
that only apply to non-deleted rows.
|
||||||
|
|
||||||
|
Revision ID: softdelete_001
|
||||||
|
Revises: remove_is_primary_001, customers_002, dev_tools_002, orders_002, tenancy_004
|
||||||
|
Create Date: 2026-03-28
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "softdelete_001"
|
||||||
|
down_revision = (
|
||||||
|
"remove_is_primary_001",
|
||||||
|
"customers_002",
|
||||||
|
"dev_tools_002",
|
||||||
|
"orders_002",
|
||||||
|
"tenancy_004",
|
||||||
|
)
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# Tables receiving soft-delete columns
|
||||||
|
SOFT_DELETE_TABLES = [
|
||||||
|
"users",
|
||||||
|
"merchants",
|
||||||
|
"stores",
|
||||||
|
"customers",
|
||||||
|
"store_users",
|
||||||
|
"orders",
|
||||||
|
"products",
|
||||||
|
"loyalty_programs",
|
||||||
|
"loyalty_cards",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ======================================================================
|
||||||
|
# Step 1: Add deleted_at and deleted_by_id to all soft-delete tables
|
||||||
|
# ======================================================================
|
||||||
|
for table in SOFT_DELETE_TABLES:
|
||||||
|
op.add_column(table, sa.Column("deleted_at", sa.DateTime(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
table,
|
||||||
|
sa.Column(
|
||||||
|
"deleted_by_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(f"ix_{table}_deleted_at", table, ["deleted_at"])
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# Step 2: Replace simple unique constraints with partial unique indexes
|
||||||
|
# (only enforce uniqueness among non-deleted rows)
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
# users.email: drop old unique index, create partial
|
||||||
|
op.drop_index("ix_users_email", table_name="users")
|
||||||
|
op.execute(
|
||||||
|
'CREATE UNIQUE INDEX uq_users_email_active ON users (email) '
|
||||||
|
'WHERE deleted_at IS NULL'
|
||||||
|
)
|
||||||
|
# Keep a non-unique index for lookups on all rows (including deleted)
|
||||||
|
op.create_index("ix_users_email", "users", ["email"])
|
||||||
|
|
||||||
|
# users.username: drop old unique index, create partial
|
||||||
|
op.drop_index("ix_users_username", table_name="users")
|
||||||
|
op.execute(
|
||||||
|
'CREATE UNIQUE INDEX uq_users_username_active ON users (username) '
|
||||||
|
'WHERE deleted_at IS NULL'
|
||||||
|
)
|
||||||
|
op.create_index("ix_users_username", "users", ["username"])
|
||||||
|
|
||||||
|
# stores.store_code: drop old unique index, create partial
|
||||||
|
op.drop_index("ix_stores_store_code", table_name="stores")
|
||||||
|
op.execute(
|
||||||
|
'CREATE UNIQUE INDEX uq_stores_store_code_active ON stores (store_code) '
|
||||||
|
'WHERE deleted_at IS NULL'
|
||||||
|
)
|
||||||
|
op.create_index("ix_stores_store_code", "stores", ["store_code"])
|
||||||
|
|
||||||
|
# stores.subdomain: drop old unique index, create partial
|
||||||
|
op.drop_index("ix_stores_subdomain", table_name="stores")
|
||||||
|
op.execute(
|
||||||
|
'CREATE UNIQUE INDEX uq_stores_subdomain_active ON stores (subdomain) '
|
||||||
|
'WHERE deleted_at IS NULL'
|
||||||
|
)
|
||||||
|
op.create_index("ix_stores_subdomain", "stores", ["subdomain"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Reverse partial unique indexes back to simple unique indexes
|
||||||
|
op.drop_index("ix_stores_subdomain", table_name="stores")
|
||||||
|
op.execute("DROP INDEX IF EXISTS uq_stores_subdomain_active")
|
||||||
|
op.create_index("ix_stores_subdomain", "stores", ["subdomain"], unique=True)
|
||||||
|
|
||||||
|
op.drop_index("ix_stores_store_code", table_name="stores")
|
||||||
|
op.execute("DROP INDEX IF EXISTS uq_stores_store_code_active")
|
||||||
|
op.create_index("ix_stores_store_code", "stores", ["store_code"], unique=True)
|
||||||
|
|
||||||
|
op.drop_index("ix_users_username", table_name="users")
|
||||||
|
op.execute("DROP INDEX IF EXISTS uq_users_username_active")
|
||||||
|
op.create_index("ix_users_username", "users", ["username"], unique=True)
|
||||||
|
|
||||||
|
op.drop_index("ix_users_email", table_name="users")
|
||||||
|
op.execute("DROP INDEX IF EXISTS uq_users_email_active")
|
||||||
|
op.create_index("ix_users_email", "users", ["email"], unique=True)
|
||||||
|
|
||||||
|
# Remove soft-delete columns from all tables
|
||||||
|
for table in reversed(SOFT_DELETE_TABLES):
|
||||||
|
op.drop_index(f"ix_{table}_deleted_at", table_name=table)
|
||||||
|
op.drop_column(table, "deleted_by_id")
|
||||||
|
op.drop_column(table, "deleted_at")
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""add unique constraints for custom_subdomain and store domain per platform
|
||||||
|
|
||||||
|
Revision ID: z_unique_subdomain_domain
|
||||||
|
Revises: a44f4956cfb1
|
||||||
|
Create Date: 2026-02-26
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "z_unique_subdomain_domain"
|
||||||
|
down_revision = ("a44f4956cfb1", "tenancy_003")
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# StorePlatform: same custom_subdomain cannot be claimed twice on the same platform
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_custom_subdomain_platform",
|
||||||
|
"store_platforms",
|
||||||
|
["custom_subdomain", "platform_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# StoreDomain: a store can have at most one custom domain per platform
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_store_domain_platform",
|
||||||
|
"store_domains",
|
||||||
|
["store_id", "platform_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("uq_store_domain_platform", "store_domains", type_="unique")
|
||||||
|
op.drop_constraint("uq_custom_subdomain_platform", "store_platforms", type_="unique")
|
||||||
112
app/api/deps.py
112
app/api/deps.py
@@ -39,7 +39,7 @@ The cookie path restrictions prevent cross-context cookie leakage:
|
|||||||
import logging
|
import logging
|
||||||
from datetime import UTC
|
from datetime import UTC
|
||||||
|
|
||||||
from fastapi import Cookie, Depends, Request
|
from fastapi import Cookie, Depends, HTTPException, Request
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -56,10 +56,10 @@ from app.modules.tenancy.exceptions import (
|
|||||||
)
|
)
|
||||||
from app.modules.tenancy.models import Store
|
from app.modules.tenancy.models import Store
|
||||||
from app.modules.tenancy.models import User as UserModel
|
from app.modules.tenancy.models import User as UserModel
|
||||||
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
from app.modules.tenancy.services.store_service import store_service
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
from middleware.auth import AuthManager
|
from middleware.auth import AuthManager
|
||||||
from middleware.rate_limiter import RateLimiter
|
from middleware.rate_limiter import RateLimiter
|
||||||
from models.schema.auth import UserContext
|
|
||||||
|
|
||||||
# Initialize dependencies
|
# Initialize dependencies
|
||||||
security = HTTPBearer(auto_error=False) # auto_error=False prevents automatic 403
|
security = HTTPBearer(auto_error=False) # auto_error=False prevents automatic 403
|
||||||
@@ -73,6 +73,19 @@ logger = logging.getLogger(__name__)
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def get_resolved_store_code(request: Request) -> str:
|
||||||
|
"""Get store code from path parameter (path-based) or middleware (subdomain/custom domain)."""
|
||||||
|
# Path parameter from double-mount prefix (/store/{store_code}/...)
|
||||||
|
store_code = request.path_params.get("store_code")
|
||||||
|
if store_code:
|
||||||
|
return store_code
|
||||||
|
# Middleware-resolved store (subdomain or custom domain)
|
||||||
|
store = getattr(request.state, "store", None)
|
||||||
|
if store:
|
||||||
|
return store.store_code
|
||||||
|
raise HTTPException(status_code=404, detail="Store not found")
|
||||||
|
|
||||||
|
|
||||||
def _get_token_from_request(
|
def _get_token_from_request(
|
||||||
credentials: HTTPAuthorizationCredentials | None,
|
credentials: HTTPAuthorizationCredentials | None,
|
||||||
cookie_value: str | None,
|
cookie_value: str | None,
|
||||||
@@ -461,11 +474,11 @@ def require_module_access(module_code: str, frontend_type: FrontendType):
|
|||||||
tied to a specific menu item.
|
tied to a specific menu item.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
admin_router = APIRouter(
|
router = APIRouter(
|
||||||
dependencies=[Depends(require_module_access("messaging", FrontendType.ADMIN))]
|
dependencies=[Depends(require_module_access("messaging", FrontendType.ADMIN))]
|
||||||
)
|
)
|
||||||
|
|
||||||
store_router = APIRouter(
|
router = APIRouter(
|
||||||
dependencies=[Depends(require_module_access("billing", FrontendType.STORE))]
|
dependencies=[Depends(require_module_access("billing", FrontendType.STORE))]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -607,9 +620,9 @@ def require_menu_access(menu_item_id: str, frontend_type: "FrontendType"):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if user_context.is_super_admin:
|
if user_context.is_super_admin:
|
||||||
# Super admin: check user-level config
|
# Super admin: use platform from token if selected, else global (no filtering)
|
||||||
platform_id = None
|
platform_id = user_context.token_platform_id
|
||||||
user_id = user_context.id
|
user_id = None
|
||||||
else:
|
else:
|
||||||
# Platform admin: need platform context
|
# Platform admin: need platform context
|
||||||
# Try to get from request state
|
# Try to get from request state
|
||||||
@@ -1544,6 +1557,55 @@ def get_user_permissions(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# PAGE-LEVEL PERMISSION GUARDS (For Store Page Routes)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def require_store_page_permission(permission: str):
|
||||||
|
"""
|
||||||
|
Dependency factory to require a specific store permission for page routes.
|
||||||
|
|
||||||
|
Same as require_store_permission but raises InsufficientStorePermissionsException
|
||||||
|
which the exception handler intercepts for HTML requests (redirecting to login).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@router.get("/products", response_class=HTMLResponse)
|
||||||
|
def store_products_page(
|
||||||
|
request: Request,
|
||||||
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
|
current_user: User = Depends(require_store_page_permission("products.view")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
|
||||||
|
def permission_checker(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: UserContext = Depends(get_current_store_from_cookie_or_header),
|
||||||
|
) -> UserContext:
|
||||||
|
if not current_user.token_store_id:
|
||||||
|
raise InvalidTokenException(
|
||||||
|
"Token missing store information. Please login again."
|
||||||
|
)
|
||||||
|
|
||||||
|
store_id = current_user.token_store_id
|
||||||
|
store = store_service.get_store_by_id(db, store_id)
|
||||||
|
request.state.store = store
|
||||||
|
|
||||||
|
user_model = _get_user_model(current_user, db)
|
||||||
|
if not user_model.has_store_permission(store.id, permission):
|
||||||
|
raise InsufficientStorePermissionsException(
|
||||||
|
required_permission=permission,
|
||||||
|
store_code=store.store_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
return permission_checker
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# OPTIONAL AUTHENTICATION (For Login Page Redirects)
|
# OPTIONAL AUTHENTICATION (For Login Page Redirects)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -1682,3 +1744,39 @@ def get_current_customer_optional(
|
|||||||
except Exception:
|
except Exception:
|
||||||
# Invalid token, store mismatch, or other error
|
# Invalid token, store mismatch, or other error
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# STOREFRONT MODULE GATING
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def make_storefront_module_gate(module_code: str):
|
||||||
|
"""
|
||||||
|
Create a FastAPI dependency that gates storefront routes by module enablement.
|
||||||
|
|
||||||
|
Used by main.py at route registration time: each non-core module's storefront
|
||||||
|
router gets this dependency injected automatically. The framework already knows
|
||||||
|
which module owns each route via RouteInfo.module_code — no hardcoded path map.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
module_code: The module code to check (e.g. "catalog", "orders", "loyalty")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A FastAPI dependency function
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _check_module_enabled(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
from app.modules.service import module_service
|
||||||
|
|
||||||
|
platform = getattr(request.state, "platform", None)
|
||||||
|
if not platform:
|
||||||
|
return # No platform context — let other middleware handle it
|
||||||
|
|
||||||
|
if not module_service.is_module_enabled(db, platform.id, module_code):
|
||||||
|
raise HTTPException(status_code=404, detail="Page not found")
|
||||||
|
|
||||||
|
return _check_module_enabled
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
Platform signup API endpoints.
|
Platform signup API endpoints.
|
||||||
|
|
||||||
Handles the multi-step signup flow:
|
Handles the multi-step signup flow:
|
||||||
1. Start signup (select tier)
|
1. Start signup (select tier + platform)
|
||||||
2. Claim Letzshop store (optional)
|
2. Create account (user + merchant)
|
||||||
3. Create account
|
3. Create store
|
||||||
4. Setup payment (collect card via SetupIntent)
|
4. Setup payment (collect card via SetupIntent)
|
||||||
5. Complete signup (create subscription with trial)
|
5. Complete signup (create Stripe subscription with trial)
|
||||||
|
|
||||||
|
Platform-specific steps (e.g., OMS Letzshop claiming) are handled
|
||||||
|
by their respective modules and call into this core flow.
|
||||||
|
|
||||||
All endpoints are public (no authentication required).
|
All endpoints are public (no authentication required).
|
||||||
"""
|
"""
|
||||||
@@ -20,9 +23,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.environment import should_use_secure_cookies
|
from app.core.environment import should_use_secure_cookies
|
||||||
from app.modules.marketplace.services.platform_signup_service import (
|
from app.modules.billing.services.signup_service import signup_service
|
||||||
platform_signup_service,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -34,10 +35,12 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class SignupStartRequest(BaseModel):
|
class SignupStartRequest(BaseModel):
|
||||||
"""Start signup - select tier."""
|
"""Start signup - select tier and platform."""
|
||||||
|
|
||||||
tier_code: str
|
tier_code: str
|
||||||
is_annual: bool = False
|
is_annual: bool = False
|
||||||
|
platform_code: str
|
||||||
|
language: str = "fr"
|
||||||
|
|
||||||
|
|
||||||
class SignupStartResponse(BaseModel):
|
class SignupStartResponse(BaseModel):
|
||||||
@@ -46,26 +49,11 @@ class SignupStartResponse(BaseModel):
|
|||||||
session_id: str
|
session_id: str
|
||||||
tier_code: str
|
tier_code: str
|
||||||
is_annual: bool
|
is_annual: bool
|
||||||
|
platform_code: str
|
||||||
|
|
||||||
class ClaimStoreRequest(BaseModel):
|
|
||||||
"""Claim Letzshop store."""
|
|
||||||
|
|
||||||
session_id: str
|
|
||||||
letzshop_slug: str
|
|
||||||
letzshop_store_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ClaimStoreResponse(BaseModel):
|
|
||||||
"""Response from store claim."""
|
|
||||||
|
|
||||||
session_id: str
|
|
||||||
letzshop_slug: str
|
|
||||||
store_name: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class CreateAccountRequest(BaseModel):
|
class CreateAccountRequest(BaseModel):
|
||||||
"""Create account."""
|
"""Create account (user + merchant)."""
|
||||||
|
|
||||||
session_id: str
|
session_id: str
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
@@ -77,12 +65,30 @@ class CreateAccountRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CreateAccountResponse(BaseModel):
|
class CreateAccountResponse(BaseModel):
|
||||||
"""Response from account creation."""
|
"""Response from account creation (includes auto-created store)."""
|
||||||
|
|
||||||
session_id: str
|
session_id: str
|
||||||
user_id: int
|
user_id: int
|
||||||
store_id: int
|
merchant_id: int
|
||||||
stripe_customer_id: str
|
stripe_customer_id: str
|
||||||
|
store_id: int
|
||||||
|
store_code: str
|
||||||
|
|
||||||
|
|
||||||
|
class CreateStoreRequest(BaseModel):
|
||||||
|
"""Create store for the merchant."""
|
||||||
|
|
||||||
|
session_id: str
|
||||||
|
store_name: str | None = None
|
||||||
|
language: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreateStoreResponse(BaseModel):
|
||||||
|
"""Response from store creation."""
|
||||||
|
|
||||||
|
session_id: str
|
||||||
|
store_id: int
|
||||||
|
store_code: str
|
||||||
|
|
||||||
|
|
||||||
class SetupPaymentRequest(BaseModel):
|
class SetupPaymentRequest(BaseModel):
|
||||||
@@ -127,43 +133,21 @@ async def start_signup(request: SignupStartRequest) -> SignupStartResponse:
|
|||||||
"""
|
"""
|
||||||
Start the signup process.
|
Start the signup process.
|
||||||
|
|
||||||
Step 1: User selects a tier and billing period.
|
Step 1: User selects a tier, billing period, and platform.
|
||||||
Creates a signup session to track the flow.
|
Creates a signup session to track the flow.
|
||||||
"""
|
"""
|
||||||
session_id = platform_signup_service.create_session(
|
session_id = signup_service.create_session(
|
||||||
tier_code=request.tier_code,
|
tier_code=request.tier_code,
|
||||||
is_annual=request.is_annual,
|
is_annual=request.is_annual,
|
||||||
|
platform_code=request.platform_code,
|
||||||
|
language=request.language,
|
||||||
)
|
)
|
||||||
|
|
||||||
return SignupStartResponse(
|
return SignupStartResponse(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
tier_code=request.tier_code,
|
tier_code=request.tier_code,
|
||||||
is_annual=request.is_annual,
|
is_annual=request.is_annual,
|
||||||
)
|
platform_code=request.platform_code,
|
||||||
|
|
||||||
|
|
||||||
@router.post("/signup/claim-store", response_model=ClaimStoreResponse) # public
|
|
||||||
async def claim_letzshop_store(
|
|
||||||
request: ClaimStoreRequest,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> ClaimStoreResponse:
|
|
||||||
"""
|
|
||||||
Claim a Letzshop store.
|
|
||||||
|
|
||||||
Step 2 (optional): User claims their Letzshop shop.
|
|
||||||
This pre-fills store info during account creation.
|
|
||||||
"""
|
|
||||||
store_name = platform_signup_service.claim_store(
|
|
||||||
db=db,
|
|
||||||
session_id=request.session_id,
|
|
||||||
letzshop_slug=request.letzshop_slug,
|
|
||||||
letzshop_store_id=request.letzshop_store_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return ClaimStoreResponse(
|
|
||||||
session_id=request.session_id,
|
|
||||||
letzshop_slug=request.letzshop_slug,
|
|
||||||
store_name=store_name,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -173,12 +157,13 @@ async def create_account(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> CreateAccountResponse:
|
) -> CreateAccountResponse:
|
||||||
"""
|
"""
|
||||||
Create user and store accounts.
|
Create user and merchant accounts.
|
||||||
|
|
||||||
Step 3: User provides account details.
|
Step 2: User provides account details.
|
||||||
Creates User, Merchant, Store, and Stripe Customer.
|
Creates User, Merchant, and Stripe Customer.
|
||||||
|
Store creation is a separate step.
|
||||||
"""
|
"""
|
||||||
result = platform_signup_service.create_account(
|
result = signup_service.create_account(
|
||||||
db=db,
|
db=db,
|
||||||
session_id=request.session_id,
|
session_id=request.session_id,
|
||||||
email=request.email,
|
email=request.email,
|
||||||
@@ -192,8 +177,35 @@ async def create_account(
|
|||||||
return CreateAccountResponse(
|
return CreateAccountResponse(
|
||||||
session_id=request.session_id,
|
session_id=request.session_id,
|
||||||
user_id=result.user_id,
|
user_id=result.user_id,
|
||||||
store_id=result.store_id,
|
merchant_id=result.merchant_id,
|
||||||
stripe_customer_id=result.stripe_customer_id,
|
stripe_customer_id=result.stripe_customer_id,
|
||||||
|
store_id=result.store_id,
|
||||||
|
store_code=result.store_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/signup/create-store", response_model=CreateStoreResponse) # public
|
||||||
|
async def create_store(
|
||||||
|
request: CreateStoreRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> CreateStoreResponse:
|
||||||
|
"""
|
||||||
|
Create the first store for the merchant.
|
||||||
|
|
||||||
|
Step 3: User names their store (defaults to merchant name).
|
||||||
|
Creates Store, StorePlatform, and MerchantSubscription.
|
||||||
|
"""
|
||||||
|
result = signup_service.create_store(
|
||||||
|
db=db,
|
||||||
|
session_id=request.session_id,
|
||||||
|
store_name=request.store_name,
|
||||||
|
language=request.language,
|
||||||
|
)
|
||||||
|
|
||||||
|
return CreateStoreResponse(
|
||||||
|
session_id=request.session_id,
|
||||||
|
store_id=result.store_id,
|
||||||
|
store_code=result.store_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -205,7 +217,7 @@ async def setup_payment(request: SetupPaymentRequest) -> SetupPaymentResponse:
|
|||||||
Step 4: Collect card details without charging.
|
Step 4: Collect card details without charging.
|
||||||
The card will be charged after the trial period ends.
|
The card will be charged after the trial period ends.
|
||||||
"""
|
"""
|
||||||
client_secret, stripe_customer_id = platform_signup_service.setup_payment(
|
client_secret, stripe_customer_id = signup_service.setup_payment(
|
||||||
session_id=request.session_id,
|
session_id=request.session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -228,7 +240,7 @@ async def complete_signup(
|
|||||||
Step 5: Verify SetupIntent, attach payment method, create subscription.
|
Step 5: Verify SetupIntent, attach payment method, create subscription.
|
||||||
Also sets HTTP-only cookie for page navigation and returns token for localStorage.
|
Also sets HTTP-only cookie for page navigation and returns token for localStorage.
|
||||||
"""
|
"""
|
||||||
result = platform_signup_service.complete_signup(
|
result = signup_service.complete_signup(
|
||||||
db=db,
|
db=db,
|
||||||
session_id=request.session_id,
|
session_id=request.session_id,
|
||||||
setup_intent_id=request.setup_intent_id,
|
setup_intent_id=request.setup_intent_id,
|
||||||
@@ -265,7 +277,7 @@ async def get_signup_session(session_id: str) -> dict:
|
|||||||
|
|
||||||
Useful for resuming an incomplete signup.
|
Useful for resuming an incomplete signup.
|
||||||
"""
|
"""
|
||||||
session = platform_signup_service.get_session_or_raise(session_id)
|
session = signup_service.get_session_or_raise(session_id)
|
||||||
|
|
||||||
# Return safe subset of session data
|
# Return safe subset of session data
|
||||||
return {
|
return {
|
||||||
|
|||||||
60
app/core/build_info.py
Normal file
60
app/core/build_info.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# app/core/build_info.py
|
||||||
|
"""
|
||||||
|
Build information utilities.
|
||||||
|
|
||||||
|
Reads commit SHA and deploy timestamp from .build-info file
|
||||||
|
(written by scripts/deploy.sh at deploy time), or falls back
|
||||||
|
to git for local development.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BUILD_INFO_FILE = Path(__file__).resolve().parent.parent.parent / ".build-info"
|
||||||
|
_cached_info: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_build_info() -> dict:
|
||||||
|
"""Return build info: commit, deployed_at, environment."""
|
||||||
|
global _cached_info
|
||||||
|
if _cached_info is not None:
|
||||||
|
return _cached_info
|
||||||
|
|
||||||
|
info = {
|
||||||
|
"commit": None,
|
||||||
|
"deployed_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try .build-info file first (written by deploy.sh)
|
||||||
|
if _BUILD_INFO_FILE.is_file():
|
||||||
|
try:
|
||||||
|
data = json.loads(_BUILD_INFO_FILE.read_text())
|
||||||
|
info["commit"] = data.get("commit")
|
||||||
|
info["deployed_at"] = data.get("deployed_at")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to read .build-info: {e}")
|
||||||
|
|
||||||
|
# Fall back to git for local development
|
||||||
|
if not info["commit"]:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "rev-parse", "--short=8", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
info["commit"] = result.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not info["deployed_at"]:
|
||||||
|
info["deployed_at"] = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
_cached_info = info
|
||||||
|
return info
|
||||||
@@ -91,7 +91,7 @@ celery_app.conf.update(
|
|||||||
task_soft_time_limit=25 * 60, # 25 minutes soft limit
|
task_soft_time_limit=25 * 60, # 25 minutes soft limit
|
||||||
# Worker settings
|
# Worker settings
|
||||||
worker_prefetch_multiplier=1, # Disable prefetching for long tasks
|
worker_prefetch_multiplier=1, # Disable prefetching for long tasks
|
||||||
worker_concurrency=4, # Number of concurrent workers
|
worker_concurrency=2, # Keep low on 4GB servers to avoid OOM
|
||||||
# Result backend
|
# Result backend
|
||||||
result_expires=86400, # Results expire after 24 hours
|
result_expires=86400, # Results expire after 24 hours
|
||||||
# Retry policy
|
# Retry policy
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ This module provides classes and functions for:
|
|||||||
- Configuration management via environment variables
|
- Configuration management via environment variables
|
||||||
- Database settings
|
- Database settings
|
||||||
- JWT and authentication configuration
|
- JWT and authentication configuration
|
||||||
- Platform domain and multi-tenancy settings
|
- Main domain and multi-tenancy settings
|
||||||
- Admin initialization settings
|
- Admin initialization settings
|
||||||
|
|
||||||
Note: Environment detection is handled by app.core.environment module.
|
Note: Environment detection is handled by app.core.environment module.
|
||||||
@@ -94,9 +94,14 @@ class Settings(BaseSettings):
|
|||||||
log_file: str | None = None
|
log_file: str | None = None
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# PLATFORM DOMAIN CONFIGURATION
|
# MAIN DOMAIN CONFIGURATION
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
platform_domain: str = "wizard.lu"
|
main_domain: str = "wizard.lu"
|
||||||
|
|
||||||
|
# Full base URL for outbound links (emails, redirects, etc.)
|
||||||
|
# Must include protocol and port if non-standard.
|
||||||
|
# Examples: http://localhost:8000, http://acme.localhost:9999, https://wizard.lu
|
||||||
|
app_base_url: str = "http://localhost:8000"
|
||||||
|
|
||||||
# Custom domain features
|
# Custom domain features
|
||||||
allow_custom_domains: bool = True
|
allow_custom_domains: bool = True
|
||||||
@@ -218,12 +223,15 @@ class Settings(BaseSettings):
|
|||||||
cloudflare_enabled: bool = False # Set to True when using CloudFlare proxy
|
cloudflare_enabled: bool = False # Set to True when using CloudFlare proxy
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# GOOGLE WALLET (LOYALTY MODULE)
|
# APPLE WALLET (LOYALTY MODULE)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
loyalty_google_issuer_id: str | None = None
|
loyalty_apple_pass_type_id: str | None = None
|
||||||
loyalty_google_service_account_json: str | None = None # Path to service account JSON
|
loyalty_apple_team_id: str | None = None
|
||||||
|
loyalty_apple_wwdr_cert_path: str | None = None
|
||||||
|
loyalty_apple_signer_cert_path: str | None = None
|
||||||
|
loyalty_apple_signer_key_path: str | None = None
|
||||||
|
|
||||||
model_config = {"env_file": ".env"}
|
model_config = {"env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
# Singleton settings instance
|
# Singleton settings instance
|
||||||
@@ -342,7 +350,7 @@ def print_environment_info():
|
|||||||
print(f" Database: {settings.database_url}")
|
print(f" Database: {settings.database_url}")
|
||||||
print(f" Debug mode: {settings.debug}")
|
print(f" Debug mode: {settings.debug}")
|
||||||
print(f" API port: {settings.api_port}")
|
print(f" API port: {settings.api_port}")
|
||||||
print(f" Platform: {settings.platform_domain}")
|
print(f" Platform: {settings.main_domain}")
|
||||||
print(f" Secure cookies: {should_use_secure_cookies()}")
|
print(f" Secure cookies: {should_use_secure_cookies()}")
|
||||||
print("=" * 70 + "\n")
|
print("=" * 70 + "\n")
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ Note: This project uses PostgreSQL only. SQLite is not supported.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, event
|
||||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
from sqlalchemy.orm import declarative_base, sessionmaker, with_loader_criteria
|
||||||
from sqlalchemy.pool import QueuePool
|
from sqlalchemy.pool import QueuePool
|
||||||
|
|
||||||
from .config import settings, validate_database_url
|
from .config import settings, validate_database_url
|
||||||
@@ -38,6 +38,45 @@ Base = declarative_base()
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Soft-delete automatic query filter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Any model that inherits SoftDeleteMixin will automatically have
|
||||||
|
# `WHERE deleted_at IS NULL` appended to SELECT queries.
|
||||||
|
# Bypass with: db.execute(stmt, execution_options={"include_deleted": True})
|
||||||
|
# or db.query(Model).execution_options(include_deleted=True).all()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def register_soft_delete_filter(session_factory):
|
||||||
|
"""Register the soft-delete query filter on a session factory.
|
||||||
|
|
||||||
|
Call this for any sessionmaker that should auto-exclude soft-deleted records.
|
||||||
|
Used for both the production SessionLocal and test session factories.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@event.listens_for(session_factory, "do_orm_execute")
|
||||||
|
def _soft_delete_filter(orm_execute_state):
|
||||||
|
if (
|
||||||
|
orm_execute_state.is_select
|
||||||
|
and not orm_execute_state.execution_options.get("include_deleted", False)
|
||||||
|
):
|
||||||
|
from models.database.base import SoftDeleteMixin
|
||||||
|
|
||||||
|
orm_execute_state.statement = orm_execute_state.statement.options(
|
||||||
|
with_loader_criteria(
|
||||||
|
SoftDeleteMixin,
|
||||||
|
lambda cls: cls.deleted_at.is_(None),
|
||||||
|
include_aliases=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return _soft_delete_filter
|
||||||
|
|
||||||
|
|
||||||
|
# Register on the production session factory
|
||||||
|
register_soft_delete_filter(SessionLocal)
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
"""
|
"""
|
||||||
Database session dependency for FastAPI routes.
|
Database session dependency for FastAPI routes.
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ async def lifespan(app: FastAPI):
|
|||||||
grafana_url=settings.grafana_url,
|
grafana_url=settings.grafana_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Validate wallet configurations
|
||||||
|
_validate_wallet_config()
|
||||||
|
|
||||||
logger.info("[OK] Application startup completed")
|
logger.info("[OK] Application startup completed")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
@@ -53,6 +56,72 @@ async def lifespan(app: FastAPI):
|
|||||||
shutdown_observability()
|
shutdown_observability()
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_wallet_config():
|
||||||
|
"""Validate Google/Apple Wallet configuration at startup."""
|
||||||
|
try:
|
||||||
|
from app.modules.loyalty.services.google_wallet_service import (
|
||||||
|
google_wallet_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = google_wallet_service.validate_config()
|
||||||
|
if result["configured"]:
|
||||||
|
if result["credentials_valid"]:
|
||||||
|
logger.info(
|
||||||
|
"[OK] Google Wallet configured (issuer: %s, email: %s)",
|
||||||
|
result["issuer_id"],
|
||||||
|
result.get("service_account_email", "unknown"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for err in result["errors"]:
|
||||||
|
logger.error("[FAIL] Google Wallet config error: %s", err)
|
||||||
|
else:
|
||||||
|
logger.info("[--] Google Wallet not configured (optional)")
|
||||||
|
|
||||||
|
# Apple Wallet config check
|
||||||
|
if settings.loyalty_apple_pass_type_id:
|
||||||
|
import os
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
for field in [
|
||||||
|
"loyalty_apple_team_id",
|
||||||
|
"loyalty_apple_wwdr_cert_path",
|
||||||
|
"loyalty_apple_signer_cert_path",
|
||||||
|
"loyalty_apple_signer_key_path",
|
||||||
|
]:
|
||||||
|
val = getattr(settings, field, None)
|
||||||
|
if not val:
|
||||||
|
missing.append(field)
|
||||||
|
elif field.endswith("_path") and not os.path.isfile(val):
|
||||||
|
logger.error(
|
||||||
|
"[FAIL] Apple Wallet file not found: %s = %s",
|
||||||
|
field,
|
||||||
|
val,
|
||||||
|
)
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
logger.error(
|
||||||
|
"[FAIL] Apple Wallet missing config: %s",
|
||||||
|
", ".join(missing),
|
||||||
|
)
|
||||||
|
elif not any(
|
||||||
|
not os.path.isfile(getattr(settings, f, "") or "")
|
||||||
|
for f in [
|
||||||
|
"loyalty_apple_wwdr_cert_path",
|
||||||
|
"loyalty_apple_signer_cert_path",
|
||||||
|
"loyalty_apple_signer_key_path",
|
||||||
|
]
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"[OK] Apple Wallet configured (pass type: %s)",
|
||||||
|
settings.loyalty_apple_pass_type_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("[--] Apple Wallet not configured (optional)")
|
||||||
|
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("Wallet config validation skipped: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
# === NEW HELPER FUNCTION ===
|
# === NEW HELPER FUNCTION ===
|
||||||
def check_database_ready():
|
def check_database_ready():
|
||||||
"""Check if database is ready (migrations have been run)."""
|
"""Check if database is ready (migrations have been run)."""
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ from datetime import UTC, datetime
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Response
|
from fastapi import APIRouter, Request, Response
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -538,12 +539,20 @@ async def readiness_check() -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
@health_router.get("/metrics")
|
@health_router.get("/metrics")
|
||||||
async def metrics_endpoint() -> Response:
|
async def metrics_endpoint(request: Request) -> Response:
|
||||||
"""
|
"""
|
||||||
Prometheus metrics endpoint.
|
Prometheus metrics endpoint.
|
||||||
|
|
||||||
Returns metrics in Prometheus text format for scraping.
|
Returns metrics in Prometheus text format for scraping.
|
||||||
|
Restricted to localhost and Docker internal networks only.
|
||||||
"""
|
"""
|
||||||
|
client_ip = request.client.host if request.client else None
|
||||||
|
allowed_prefixes = ("127.", "10.", "172.16.", "172.17.", "172.18.", "172.19.",
|
||||||
|
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.",
|
||||||
|
"172.25.", "172.26.", "172.27.", "172.28.", "172.29.",
|
||||||
|
"172.30.", "172.31.", "192.168.", "::1")
|
||||||
|
if not client_ip or not client_ip.startswith(allowed_prefixes):
|
||||||
|
return JSONResponse(status_code=403, content={"detail": "Forbidden"})
|
||||||
content = metrics_registry.generate_latest()
|
content = metrics_registry.generate_latest()
|
||||||
return Response(
|
return Response(
|
||||||
content=content,
|
content=content,
|
||||||
|
|||||||
54
app/core/preview_token.py
Normal file
54
app/core/preview_token.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# app/core/preview_token.py
|
||||||
|
"""
|
||||||
|
Signed preview tokens for POC site previews.
|
||||||
|
|
||||||
|
Generates time-limited JWT tokens that allow viewing storefront pages
|
||||||
|
for stores without active subscriptions (POC sites). The token is
|
||||||
|
validated by StorefrontAccessMiddleware to bypass the subscription gate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PREVIEW_TOKEN_HOURS = 24
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
|
||||||
|
|
||||||
|
def create_preview_token(store_id: int, store_code: str, site_id: int) -> str:
|
||||||
|
"""Create a signed preview token for a POC site.
|
||||||
|
|
||||||
|
Token is valid for PREVIEW_TOKEN_HOURS (default 24h) and is tied
|
||||||
|
to a specific store_id. Shareable with clients for preview access.
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"sub": f"preview:{store_id}",
|
||||||
|
"store_id": store_id,
|
||||||
|
"store_code": store_code,
|
||||||
|
"site_id": site_id,
|
||||||
|
"preview": True,
|
||||||
|
"exp": datetime.now(UTC) + timedelta(hours=PREVIEW_TOKEN_HOURS),
|
||||||
|
"iat": datetime.now(UTC),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.jwt_secret_key, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_preview_token(token: str, store_id: int) -> bool:
|
||||||
|
"""Verify a preview token is valid and matches the store.
|
||||||
|
|
||||||
|
Returns True if:
|
||||||
|
- Token signature is valid
|
||||||
|
- Token has not expired
|
||||||
|
- Token has preview=True claim
|
||||||
|
- Token store_id matches the requested store
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[ALGORITHM])
|
||||||
|
return payload.get("preview") is True and payload.get("store_id") == store_id
|
||||||
|
except JWTError:
|
||||||
|
return False
|
||||||
143
app/core/soft_delete.py
Normal file
143
app/core/soft_delete.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# app/core/soft_delete.py
|
||||||
|
"""
|
||||||
|
Soft-delete utility functions.
|
||||||
|
|
||||||
|
Provides helpers for soft-deleting, restoring, and cascade soft-deleting
|
||||||
|
records that use the SoftDeleteMixin.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from app.core.soft_delete import soft_delete, restore, soft_delete_cascade
|
||||||
|
|
||||||
|
# Simple soft delete
|
||||||
|
soft_delete(db, user, deleted_by_id=admin.id)
|
||||||
|
|
||||||
|
# Cascade soft delete (merchant + all stores + their children)
|
||||||
|
soft_delete_cascade(db, merchant, deleted_by_id=admin.id, cascade_rels=[
|
||||||
|
("stores", [("products", []), ("customers", []), ("orders", []), ("store_users", [])]),
|
||||||
|
])
|
||||||
|
|
||||||
|
# Restore a soft-deleted record
|
||||||
|
from app.modules.tenancy.models import User
|
||||||
|
restore(db, User, entity_id=42, restored_by_id=admin.id)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def soft_delete(db: Session, entity, deleted_by_id: int | None = None) -> None:
|
||||||
|
"""
|
||||||
|
Mark an entity as soft-deleted.
|
||||||
|
|
||||||
|
Sets deleted_at to now and deleted_by_id to the actor.
|
||||||
|
Does NOT call db.commit() — caller is responsible.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session.
|
||||||
|
entity: SQLAlchemy model instance with SoftDeleteMixin.
|
||||||
|
deleted_by_id: ID of the user performing the deletion.
|
||||||
|
"""
|
||||||
|
entity.deleted_at = datetime.now(UTC)
|
||||||
|
entity.deleted_by_id = deleted_by_id
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Soft-deleted {entity.__class__.__name__} id={entity.id} "
|
||||||
|
f"by user_id={deleted_by_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def restore(
|
||||||
|
db: Session,
|
||||||
|
model_class,
|
||||||
|
entity_id: int,
|
||||||
|
restored_by_id: int | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Restore a soft-deleted entity.
|
||||||
|
|
||||||
|
Queries with include_deleted=True to find the record, then clears
|
||||||
|
deleted_at and deleted_by_id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session.
|
||||||
|
model_class: SQLAlchemy model class.
|
||||||
|
entity_id: ID of the entity to restore.
|
||||||
|
restored_by_id: ID of the user performing the restore (for logging).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The restored entity.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If entity not found.
|
||||||
|
"""
|
||||||
|
entity = db.execute(
|
||||||
|
select(model_class).filter(model_class.id == entity_id),
|
||||||
|
execution_options={"include_deleted": True},
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if entity is None:
|
||||||
|
raise ValueError(f"{model_class.__name__} with id={entity_id} not found")
|
||||||
|
|
||||||
|
if entity.deleted_at is None:
|
||||||
|
raise ValueError(f"{model_class.__name__} with id={entity_id} is not deleted")
|
||||||
|
|
||||||
|
entity.deleted_at = None
|
||||||
|
entity.deleted_by_id = None
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Restored {model_class.__name__} id={entity_id} "
|
||||||
|
f"by user_id={restored_by_id}"
|
||||||
|
)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
def soft_delete_cascade(
|
||||||
|
db: Session,
|
||||||
|
entity,
|
||||||
|
deleted_by_id: int | None = None,
|
||||||
|
cascade_rels: list[tuple[str, list]] | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Soft-delete an entity and recursively soft-delete its children.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session.
|
||||||
|
entity: SQLAlchemy model instance with SoftDeleteMixin.
|
||||||
|
deleted_by_id: ID of the user performing the deletion.
|
||||||
|
cascade_rels: List of (relationship_name, child_cascade_rels) tuples.
|
||||||
|
Example: [("stores", [("products", []), ("customers", [])])]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Total number of records soft-deleted (including the root entity).
|
||||||
|
"""
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
# Soft-delete the entity itself
|
||||||
|
soft_delete(db, entity, deleted_by_id)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
# Recursively soft-delete children
|
||||||
|
if cascade_rels:
|
||||||
|
for rel_name, child_cascade in cascade_rels:
|
||||||
|
children = getattr(entity, rel_name, None)
|
||||||
|
if children is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle both collections and single items (uselist=False)
|
||||||
|
if not isinstance(children, list):
|
||||||
|
children = [children]
|
||||||
|
|
||||||
|
for child in children:
|
||||||
|
if hasattr(child, "deleted_at") and child.deleted_at is None:
|
||||||
|
count += soft_delete_cascade(
|
||||||
|
db, child, deleted_by_id, child_cascade
|
||||||
|
)
|
||||||
|
|
||||||
|
return count
|
||||||
@@ -85,8 +85,9 @@ class ErrorPageRenderer:
|
|||||||
Returns:
|
Returns:
|
||||||
HTMLResponse with rendered error page
|
HTMLResponse with rendered error page
|
||||||
"""
|
"""
|
||||||
# Get frontend type
|
# Get frontend type — default to PLATFORM in error rendering context
|
||||||
frontend_type = get_frontend_type(request)
|
# (errors can occur before FrontendTypeMiddleware runs)
|
||||||
|
frontend_type = get_frontend_type(request) or FrontendType.PLATFORM
|
||||||
|
|
||||||
# Prepare template data
|
# Prepare template data
|
||||||
template_data = ErrorPageRenderer._prepare_template_data(
|
template_data = ErrorPageRenderer._prepare_template_data(
|
||||||
@@ -291,7 +292,7 @@ class ErrorPageRenderer:
|
|||||||
# TODO: Implement actual admin check based on JWT/session
|
# TODO: Implement actual admin check based on JWT/session
|
||||||
# For now, check if we're in admin frontend
|
# For now, check if we're in admin frontend
|
||||||
frontend_type = get_frontend_type(request)
|
frontend_type = get_frontend_type(request)
|
||||||
return frontend_type == FrontendType.ADMIN
|
return frontend_type is not None and frontend_type == FrontendType.ADMIN
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _render_basic_html_fallback(
|
def _render_basic_html_fallback(
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ def _redirect_to_login(request: Request) -> RedirectResponse:
|
|||||||
Uses FrontendType detection to determine admin vs store vs storefront login.
|
Uses FrontendType detection to determine admin vs store vs storefront login.
|
||||||
Properly handles multi-access routing (domain, subdomain, path-based).
|
Properly handles multi-access routing (domain, subdomain, path-based).
|
||||||
"""
|
"""
|
||||||
frontend_type = get_frontend_type(request)
|
frontend_type = get_frontend_type(request) or FrontendType.PLATFORM
|
||||||
|
|
||||||
if frontend_type == FrontendType.ADMIN:
|
if frontend_type == FrontendType.ADMIN:
|
||||||
logger.debug("Redirecting to /admin/login")
|
logger.debug("Redirecting to /admin/login")
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
|
|||||||
# api_timeout: int = 30
|
# api_timeout: int = 30
|
||||||
# batch_size: int = 100
|
# batch_size: int = 100
|
||||||
|
|
||||||
model_config = {"env_prefix": "ANALYTICS_"}
|
model_config = {"env_prefix": "ANALYTICS_", "env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
# Export for auto-discovery
|
# Export for auto-discovery
|
||||||
|
|||||||
@@ -96,11 +96,13 @@ analytics_module = ModuleDefinition(
|
|||||||
icon="chart-bar",
|
icon="chart-bar",
|
||||||
route="/store/{store_code}/analytics",
|
route="/store/{store_code}/analytics",
|
||||||
order=20,
|
order=20,
|
||||||
|
requires_permission="analytics.view",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
requires=["catalog", "inventory", "marketplace", "orders"], # Imports from these modules
|
||||||
is_core=False,
|
is_core=False,
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Self-Contained Module Configuration
|
# Self-Contained Module Configuration
|
||||||
|
|||||||
42
app/modules/analytics/docs/index.md
Normal file
42
app/modules/analytics/docs/index.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Analytics & Reporting
|
||||||
|
|
||||||
|
Dashboard analytics, custom reports, and data exports.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| Code | `analytics` |
|
||||||
|
| Classification | Optional |
|
||||||
|
| Dependencies | `catalog`, `inventory`, `marketplace`, `orders` |
|
||||||
|
| Status | Active |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- `basic_reports` — Standard built-in reports
|
||||||
|
- `analytics_dashboard` — Analytics dashboard widgets
|
||||||
|
- `custom_reports` — Custom report builder
|
||||||
|
- `export_reports` — Report data export
|
||||||
|
- `usage_metrics` — Platform usage metrics
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
| Permission | Description |
|
||||||
|
|------------|-------------|
|
||||||
|
| `analytics.view` | View analytics and reports |
|
||||||
|
| `analytics.export` | Export report data |
|
||||||
|
| `analytics.manage_dashboards` | Create/edit custom dashboards |
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
Analytics primarily queries data from other modules (orders, inventory, catalog).
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `GET` | `/api/v1/store/analytics/*` | Store analytics data |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
No module-specific configuration.
|
||||||
@@ -16,5 +16,13 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"analytics": "Analytik"
|
"analytics": "Analytik"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"view": "Analytik anzeigen",
|
||||||
|
"view_desc": "Zugriff auf Analytik-Dashboards und Berichte",
|
||||||
|
"export": "Analytik exportieren",
|
||||||
|
"export_desc": "Analytikdaten und Berichte exportieren",
|
||||||
|
"manage_dashboards": "Dashboards verwalten",
|
||||||
|
"manage_dashboards_desc": "Analytik-Dashboards erstellen und konfigurieren"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,14 @@
|
|||||||
"loading": "Loading analytics...",
|
"loading": "Loading analytics...",
|
||||||
"error_loading": "Failed to load analytics data"
|
"error_loading": "Failed to load analytics data"
|
||||||
},
|
},
|
||||||
|
"permissions": {
|
||||||
|
"view": "View Analytics",
|
||||||
|
"view_desc": "Access analytics dashboards and reports",
|
||||||
|
"export": "Export Analytics",
|
||||||
|
"export_desc": "Export analytics data and reports",
|
||||||
|
"manage_dashboards": "Manage Dashboards",
|
||||||
|
"manage_dashboards_desc": "Create and configure analytics dashboards"
|
||||||
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"analytics": "Analytics"
|
"analytics": "Analytics"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,5 +16,13 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"analytics": "Analytique"
|
"analytics": "Analytique"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"view": "Voir l'analytique",
|
||||||
|
"view_desc": "Accéder aux tableaux de bord et rapports analytiques",
|
||||||
|
"export": "Exporter l'analytique",
|
||||||
|
"export_desc": "Exporter les données et rapports analytiques",
|
||||||
|
"manage_dashboards": "Gérer les tableaux de bord",
|
||||||
|
"manage_dashboards_desc": "Créer et configurer les tableaux de bord analytiques"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,5 +16,13 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"analytics": "Analytik"
|
"analytics": "Analytik"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"view": "Analytik kucken",
|
||||||
|
"view_desc": "Zougang zu Analytik-Dashboards a Berichter",
|
||||||
|
"export": "Analytik exportéieren",
|
||||||
|
"export_desc": "Analytikdaten a Berichter exportéieren",
|
||||||
|
"manage_dashboards": "Dashboards verwalten",
|
||||||
|
"manage_dashboards_desc": "Analytik-Dashboards erstellen a konfiguréieren"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"analytics": {
|
|
||||||
"page_title": "Analysen",
|
|
||||||
"dashboard_title": "Analyse-Dashboard",
|
|
||||||
"dashboard_subtitle": "Kuckt Är Buttek Leeschtungsmetriken an Abléck",
|
|
||||||
"period_7d": "Lescht 7 Deeg",
|
|
||||||
"period_30d": "Lescht 30 Deeg",
|
|
||||||
"period_90d": "Lescht 90 Deeg",
|
|
||||||
"period_1y": "Lescht Joer",
|
|
||||||
"imports_count": "Importer",
|
|
||||||
"products_added": "Produkter bäigesat",
|
|
||||||
"inventory_locations": "Lagerplazen",
|
|
||||||
"data_since": "Donnéeë vun",
|
|
||||||
"loading": "Analysen ginn gelueden...",
|
|
||||||
"error_loading": "Analysedonnéeën konnten net geluede ginn"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,8 +7,8 @@ with module-based access control.
|
|||||||
|
|
||||||
NOTE: Routers are NOT auto-imported to avoid circular dependencies.
|
NOTE: Routers are NOT auto-imported to avoid circular dependencies.
|
||||||
Import directly from api/ or pages/ as needed:
|
Import directly from api/ or pages/ as needed:
|
||||||
from app.modules.analytics.routes.api import store_router as store_api_router
|
from app.modules.analytics.routes.api import store_router
|
||||||
from app.modules.analytics.routes.pages import store_router as store_page_router
|
from app.modules.analytics.routes.pages import store_page_router
|
||||||
|
|
||||||
Note: Analytics module has no admin routes - admin uses dashboard.
|
Note: Analytics module has no admin routes - admin uses dashboard.
|
||||||
"""
|
"""
|
||||||
@@ -25,6 +25,6 @@ def __getattr__(name: str):
|
|||||||
from app.modules.analytics.routes.api import store_router
|
from app.modules.analytics.routes.api import store_router
|
||||||
return store_router
|
return store_router
|
||||||
if name == "store_page_router":
|
if name == "store_page_router":
|
||||||
from app.modules.analytics.routes.pages import store_router
|
from app.modules.analytics.routes.pages import router
|
||||||
return store_router
|
return router
|
||||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ router = APIRouter(
|
|||||||
prefix="/analytics",
|
prefix="/analytics",
|
||||||
dependencies=[Depends(require_module_access("analytics", FrontendType.STORE))],
|
dependencies=[Depends(require_module_access("analytics", FrontendType.STORE))],
|
||||||
)
|
)
|
||||||
store_router = router # Alias for discovery
|
router = router # Alias for discovery
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ Store pages for analytics dashboard.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Path, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_store_from_cookie_or_header, get_db
|
from app.api.deps import (
|
||||||
|
get_db,
|
||||||
|
get_resolved_store_code,
|
||||||
|
require_store_page_permission,
|
||||||
|
)
|
||||||
from app.modules.core.services.platform_settings_service import (
|
from app.modules.core.services.platform_settings_service import (
|
||||||
platform_settings_service, # MOD-004 - shared platform service
|
platform_settings_service, # MOD-004 - shared platform service
|
||||||
)
|
)
|
||||||
@@ -73,12 +77,12 @@ def get_store_context(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/analytics", response_class=HTMLResponse, include_in_schema=False
|
"/analytics", response_class=HTMLResponse, include_in_schema=False
|
||||||
)
|
)
|
||||||
async def store_analytics_page(
|
async def store_analytics_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(require_store_page_permission("analytics.view")),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -15,23 +15,13 @@ import logging
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import func
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.modules.catalog.models import Product # IMPORT-002
|
|
||||||
from app.modules.customers.models.customer import Customer # IMPORT-002
|
|
||||||
from app.modules.inventory.models import Inventory # IMPORT-002
|
|
||||||
from app.modules.marketplace.models import ( # IMPORT-002
|
|
||||||
MarketplaceImportJob,
|
|
||||||
MarketplaceProduct,
|
|
||||||
)
|
|
||||||
from app.modules.orders.models import Order # IMPORT-002
|
|
||||||
from app.modules.tenancy.exceptions import (
|
from app.modules.tenancy.exceptions import (
|
||||||
AdminOperationException,
|
AdminOperationException,
|
||||||
StoreNotFoundException,
|
StoreNotFoundException,
|
||||||
)
|
)
|
||||||
from app.modules.tenancy.models import Store, User
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -58,84 +48,56 @@ class StatsService:
|
|||||||
StoreNotFoundException: If store doesn't exist
|
StoreNotFoundException: If store doesn't exist
|
||||||
AdminOperationException: If database query fails
|
AdminOperationException: If database query fails
|
||||||
"""
|
"""
|
||||||
|
from app.modules.catalog.services.product_service import product_service
|
||||||
|
from app.modules.customers.services.customer_service import customer_service
|
||||||
|
from app.modules.inventory.services.inventory_service import inventory_service
|
||||||
|
from app.modules.marketplace.services.marketplace_import_job_service import (
|
||||||
|
marketplace_import_job_service,
|
||||||
|
)
|
||||||
|
from app.modules.marketplace.services.marketplace_product_service import (
|
||||||
|
marketplace_product_service,
|
||||||
|
)
|
||||||
|
from app.modules.orders.services.order_service import order_service
|
||||||
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
# Verify store exists
|
# Verify store exists
|
||||||
store = db.query(Store).filter(Store.id == store_id).first()
|
store = store_service.get_store_by_id_optional(db, store_id)
|
||||||
if not store:
|
if not store:
|
||||||
raise StoreNotFoundException(str(store_id), identifier_type="id")
|
raise StoreNotFoundException(str(store_id), identifier_type="id")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Catalog statistics
|
# Catalog statistics
|
||||||
total_catalog_products = (
|
total_catalog_products = product_service.get_store_product_count(
|
||||||
db.query(Product)
|
db, store_id, active_only=True,
|
||||||
.filter(Product.store_id == store_id, Product.is_active == True)
|
|
||||||
.count()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
featured_products = (
|
featured_products = product_service.get_store_product_count(
|
||||||
db.query(Product)
|
db, store_id, active_only=True, featured_only=True,
|
||||||
.filter(
|
|
||||||
Product.store_id == store_id,
|
|
||||||
Product.is_featured == True,
|
|
||||||
Product.is_active == True,
|
|
||||||
)
|
|
||||||
.count()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Staging statistics
|
# Staging statistics
|
||||||
# TODO: This is fragile - MarketplaceProduct uses store_name (string) not store_id
|
staging_products = marketplace_product_service.get_staging_product_count(
|
||||||
# Should add store_id foreign key to MarketplaceProduct for robust querying
|
db, store_name=store.name,
|
||||||
# For now, matching by store name which could fail if names don't match exactly
|
|
||||||
staging_products = (
|
|
||||||
db.query(MarketplaceProduct)
|
|
||||||
.filter(MarketplaceProduct.store_name == store.name)
|
|
||||||
.count()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Inventory statistics
|
# Inventory statistics
|
||||||
total_inventory = (
|
inv_stats = inventory_service.get_store_inventory_stats(db, store_id)
|
||||||
db.query(func.sum(Inventory.quantity))
|
total_inventory = inv_stats["total"]
|
||||||
.filter(Inventory.store_id == store_id)
|
reserved_inventory = inv_stats["reserved"]
|
||||||
.scalar()
|
inventory_locations = inv_stats["locations"]
|
||||||
or 0
|
|
||||||
)
|
|
||||||
|
|
||||||
reserved_inventory = (
|
|
||||||
db.query(func.sum(Inventory.reserved_quantity))
|
|
||||||
.filter(Inventory.store_id == store_id)
|
|
||||||
.scalar()
|
|
||||||
or 0
|
|
||||||
)
|
|
||||||
|
|
||||||
inventory_locations = (
|
|
||||||
db.query(func.count(func.distinct(Inventory.bin_location)))
|
|
||||||
.filter(Inventory.store_id == store_id)
|
|
||||||
.scalar()
|
|
||||||
or 0
|
|
||||||
)
|
|
||||||
|
|
||||||
# Import statistics
|
# Import statistics
|
||||||
total_imports = (
|
import_stats = marketplace_import_job_service.get_import_job_stats(
|
||||||
db.query(MarketplaceImportJob)
|
db, store_id=store_id,
|
||||||
.filter(MarketplaceImportJob.store_id == store_id)
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
successful_imports = (
|
|
||||||
db.query(MarketplaceImportJob)
|
|
||||||
.filter(
|
|
||||||
MarketplaceImportJob.store_id == store_id,
|
|
||||||
MarketplaceImportJob.status == "completed",
|
|
||||||
)
|
|
||||||
.count()
|
|
||||||
)
|
)
|
||||||
|
total_imports = import_stats["total"]
|
||||||
|
successful_imports = import_stats["completed"]
|
||||||
|
|
||||||
# Orders
|
# Orders
|
||||||
total_orders = db.query(Order).filter(Order.store_id == store_id).count()
|
total_orders = order_service.get_store_order_count(db, store_id)
|
||||||
|
|
||||||
# Customers
|
# Customers
|
||||||
total_customers = (
|
total_customers = customer_service.get_store_customer_count(db, store_id)
|
||||||
db.query(Customer).filter(Customer.store_id == store_id).count()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Return flat structure compatible with StoreDashboardStatsResponse schema
|
# Return flat structure compatible with StoreDashboardStatsResponse schema
|
||||||
# The endpoint will restructure this into nested format
|
# The endpoint will restructure this into nested format
|
||||||
@@ -204,8 +166,15 @@ class StatsService:
|
|||||||
StoreNotFoundException: If store doesn't exist
|
StoreNotFoundException: If store doesn't exist
|
||||||
AdminOperationException: If database query fails
|
AdminOperationException: If database query fails
|
||||||
"""
|
"""
|
||||||
|
from app.modules.catalog.services.product_service import product_service
|
||||||
|
from app.modules.inventory.services.inventory_service import inventory_service
|
||||||
|
from app.modules.marketplace.services.marketplace_import_job_service import (
|
||||||
|
marketplace_import_job_service,
|
||||||
|
)
|
||||||
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
# Verify store exists
|
# Verify store exists
|
||||||
store = db.query(Store).filter(Store.id == store_id).first()
|
store = store_service.get_store_by_id_optional(db, store_id)
|
||||||
if not store:
|
if not store:
|
||||||
raise StoreNotFoundException(str(store_id), identifier_type="id")
|
raise StoreNotFoundException(str(store_id), identifier_type="id")
|
||||||
|
|
||||||
@@ -215,28 +184,17 @@ class StatsService:
|
|||||||
start_date = datetime.utcnow() - timedelta(days=days)
|
start_date = datetime.utcnow() - timedelta(days=days)
|
||||||
|
|
||||||
# Import activity
|
# Import activity
|
||||||
recent_imports = (
|
import_stats = marketplace_import_job_service.get_import_job_stats(
|
||||||
db.query(MarketplaceImportJob)
|
db, store_id=store_id,
|
||||||
.filter(
|
|
||||||
MarketplaceImportJob.store_id == store_id,
|
|
||||||
MarketplaceImportJob.created_at >= start_date,
|
|
||||||
)
|
|
||||||
.count()
|
|
||||||
)
|
)
|
||||||
|
recent_imports = import_stats["total"]
|
||||||
|
|
||||||
# Products added to catalog
|
# Products added to catalog
|
||||||
products_added = (
|
products_added = product_service.get_store_product_count(db, store_id)
|
||||||
db.query(Product)
|
|
||||||
.filter(
|
|
||||||
Product.store_id == store_id, Product.created_at >= start_date
|
|
||||||
)
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Inventory changes
|
# Inventory changes
|
||||||
inventory_entries = (
|
inv_stats = inventory_service.get_store_inventory_stats(db, store_id)
|
||||||
db.query(Inventory).filter(Inventory.store_id == store_id).count()
|
inventory_entries = inv_stats.get("locations", 0)
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"period": period,
|
"period": period,
|
||||||
@@ -271,19 +229,15 @@ class StatsService:
|
|||||||
Returns dict compatible with StoreStatsResponse schema.
|
Returns dict compatible with StoreStatsResponse schema.
|
||||||
Keys: total, verified, pending, inactive (mapped from internal names)
|
Keys: total, verified, pending, inactive (mapped from internal names)
|
||||||
"""
|
"""
|
||||||
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
try:
|
try:
|
||||||
total_stores = db.query(Store).count()
|
total_stores = store_service.get_total_store_count(db)
|
||||||
active_stores = db.query(Store).filter(Store.is_active == True).count()
|
active_stores = store_service.get_total_store_count(db, active_only=True)
|
||||||
verified_stores = (
|
|
||||||
db.query(Store).filter(Store.is_verified == True).count()
|
|
||||||
)
|
|
||||||
inactive_stores = total_stores - active_stores
|
inactive_stores = total_stores - active_stores
|
||||||
# Pending = active but not yet verified
|
# Use store_service for verified/pending counts
|
||||||
pending_stores = (
|
verified_stores = store_service.get_store_count_by_status(db, verified=True)
|
||||||
db.query(Store)
|
pending_stores = store_service.get_store_count_by_status(db, active=True, verified=False)
|
||||||
.filter(Store.is_active == True, Store.is_verified == False)
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total_stores,
|
"total": total_stores,
|
||||||
@@ -318,21 +272,22 @@ class StatsService:
|
|||||||
AdminOperationException: If database query fails
|
AdminOperationException: If database query fails
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
from app.modules.catalog.services.product_service import product_service
|
||||||
|
from app.modules.marketplace.services.marketplace_product_service import (
|
||||||
|
marketplace_product_service,
|
||||||
|
)
|
||||||
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
# Stores
|
# Stores
|
||||||
total_stores = db.query(Store).filter(Store.is_active == True).count()
|
total_stores = store_service.get_total_store_count(db, active_only=True)
|
||||||
|
|
||||||
# Products
|
# Products
|
||||||
total_catalog_products = db.query(Product).count()
|
total_catalog_products = product_service.get_total_product_count(db)
|
||||||
unique_brands = self._get_unique_brands_count(db)
|
unique_brands = marketplace_product_service.get_distinct_brand_count(db)
|
||||||
unique_categories = self._get_unique_categories_count(db)
|
unique_categories = marketplace_product_service.get_distinct_category_count(db)
|
||||||
|
|
||||||
# Marketplaces
|
# Marketplaces
|
||||||
unique_marketplaces = (
|
unique_marketplaces = marketplace_product_service.get_distinct_marketplace_count(db)
|
||||||
db.query(MarketplaceProduct.marketplace)
|
|
||||||
.filter(MarketplaceProduct.marketplace.isnot(None))
|
|
||||||
.distinct()
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Inventory
|
# Inventory
|
||||||
inventory_stats = self._get_inventory_statistics(db)
|
inventory_stats = self._get_inventory_statistics(db)
|
||||||
@@ -368,31 +323,11 @@ class StatsService:
|
|||||||
AdminOperationException: If database query fails
|
AdminOperationException: If database query fails
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
marketplace_stats = (
|
from app.modules.marketplace.services.marketplace_product_service import (
|
||||||
db.query(
|
marketplace_product_service,
|
||||||
MarketplaceProduct.marketplace,
|
|
||||||
func.count(MarketplaceProduct.id).label("total_products"),
|
|
||||||
func.count(func.distinct(MarketplaceProduct.store_name)).label(
|
|
||||||
"unique_stores"
|
|
||||||
),
|
|
||||||
func.count(func.distinct(MarketplaceProduct.brand)).label(
|
|
||||||
"unique_brands"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.filter(MarketplaceProduct.marketplace.isnot(None))
|
|
||||||
.group_by(MarketplaceProduct.marketplace)
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return [
|
return marketplace_product_service.get_marketplace_breakdown(db)
|
||||||
{
|
|
||||||
"marketplace": stat.marketplace,
|
|
||||||
"total_products": stat.total_products,
|
|
||||||
"unique_stores": stat.unique_stores,
|
|
||||||
"unique_brands": stat.unique_brands,
|
|
||||||
}
|
|
||||||
for stat in marketplace_stats
|
|
||||||
]
|
|
||||||
|
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -417,20 +352,10 @@ class StatsService:
|
|||||||
AdminOperationException: If database query fails
|
AdminOperationException: If database query fails
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
total_users = db.query(User).count()
|
from app.modules.tenancy.services.admin_service import admin_service
|
||||||
active_users = db.query(User).filter(User.is_active == True).count()
|
|
||||||
inactive_users = total_users - active_users
|
|
||||||
admin_users = db.query(User).filter(User.role.in_(["super_admin", "platform_admin"])).count()
|
|
||||||
|
|
||||||
return {
|
user_stats = admin_service.get_user_statistics(db)
|
||||||
"total_users": total_users,
|
return user_stats
|
||||||
"active_users": active_users,
|
|
||||||
"inactive_users": inactive_users,
|
|
||||||
"admin_users": admin_users,
|
|
||||||
"activation_rate": (
|
|
||||||
(active_users / total_users * 100) if total_users > 0 else 0
|
|
||||||
),
|
|
||||||
}
|
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
logger.error(f"Failed to get user statistics: {str(e)}")
|
logger.error(f"Failed to get user statistics: {str(e)}")
|
||||||
raise AdminOperationException(
|
raise AdminOperationException(
|
||||||
@@ -451,38 +376,19 @@ class StatsService:
|
|||||||
AdminOperationException: If database query fails
|
AdminOperationException: If database query fails
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
total = db.query(MarketplaceImportJob).count()
|
from app.modules.marketplace.services.marketplace_import_job_service import (
|
||||||
pending = (
|
marketplace_import_job_service,
|
||||||
db.query(MarketplaceImportJob)
|
|
||||||
.filter(MarketplaceImportJob.status == "pending")
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
processing = (
|
|
||||||
db.query(MarketplaceImportJob)
|
|
||||||
.filter(MarketplaceImportJob.status == "processing")
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
completed = (
|
|
||||||
db.query(MarketplaceImportJob)
|
|
||||||
.filter(
|
|
||||||
MarketplaceImportJob.status.in_(
|
|
||||||
["completed", "completed_with_errors"]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
failed = (
|
|
||||||
db.query(MarketplaceImportJob)
|
|
||||||
.filter(MarketplaceImportJob.status == "failed")
|
|
||||||
.count()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
stats = marketplace_import_job_service.get_import_job_stats(db)
|
||||||
|
total = stats["total"]
|
||||||
|
completed = stats["completed"]
|
||||||
return {
|
return {
|
||||||
"total": total,
|
"total": total,
|
||||||
"pending": pending,
|
"pending": stats["pending"],
|
||||||
"processing": processing,
|
"processing": stats.get("processing", 0),
|
||||||
"completed": completed,
|
"completed": completed,
|
||||||
"failed": failed,
|
"failed": stats["failed"],
|
||||||
"success_rate": (completed / total * 100) if total > 0 else 0,
|
"success_rate": (completed / total * 100) if total > 0 else 0,
|
||||||
}
|
}
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
@@ -548,58 +454,13 @@ class StatsService:
|
|||||||
}
|
}
|
||||||
return period_map.get(period, 30)
|
return period_map.get(period, 30)
|
||||||
|
|
||||||
def _get_unique_brands_count(self, db: Session) -> int:
|
|
||||||
"""
|
|
||||||
Get count of unique brands.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Count of unique brands
|
|
||||||
"""
|
|
||||||
return (
|
|
||||||
db.query(MarketplaceProduct.brand)
|
|
||||||
.filter(
|
|
||||||
MarketplaceProduct.brand.isnot(None), MarketplaceProduct.brand != ""
|
|
||||||
)
|
|
||||||
.distinct()
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get_unique_categories_count(self, db: Session) -> int:
|
|
||||||
"""
|
|
||||||
Get count of unique categories.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Count of unique categories
|
|
||||||
"""
|
|
||||||
return (
|
|
||||||
db.query(MarketplaceProduct.google_product_category)
|
|
||||||
.filter(
|
|
||||||
MarketplaceProduct.google_product_category.isnot(None),
|
|
||||||
MarketplaceProduct.google_product_category != "",
|
|
||||||
)
|
|
||||||
.distinct()
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get_inventory_statistics(self, db: Session) -> dict[str, int]:
|
def _get_inventory_statistics(self, db: Session) -> dict[str, int]:
|
||||||
"""
|
"""Get inventory-related statistics via inventory service."""
|
||||||
Get inventory-related statistics.
|
from app.modules.inventory.services.inventory_service import inventory_service
|
||||||
|
|
||||||
Args:
|
total_entries = inventory_service.get_total_inventory_count(db)
|
||||||
db: Database session
|
total_quantity = inventory_service.get_total_inventory_quantity(db)
|
||||||
|
total_reserved = inventory_service.get_total_reserved_quantity(db)
|
||||||
Returns:
|
|
||||||
Dictionary with inventory statistics
|
|
||||||
"""
|
|
||||||
total_entries = db.query(Inventory).count()
|
|
||||||
total_quantity = db.query(func.sum(Inventory.quantity)).scalar() or 0
|
|
||||||
total_reserved = db.query(func.sum(Inventory.reserved_quantity)).scalar() or 0
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_entries": total_entries,
|
"total_entries": total_entries,
|
||||||
|
|||||||
@@ -143,7 +143,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
||||||
<span x-html="$icon('location-marker', 'w-6 h-6')"></span>
|
<span x-html="$icon('map-pin', 'w-6 h-6')"></span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="formatNumber(analytics.inventory?.total_locations || 0)"></p>
|
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="formatNumber(analytics.inventory?.total_locations || 0)"></p>
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ if TYPE_CHECKING:
|
|||||||
from app.modules.contracts.cms import MediaUsageProviderProtocol
|
from app.modules.contracts.cms import MediaUsageProviderProtocol
|
||||||
from app.modules.contracts.features import FeatureProviderProtocol
|
from app.modules.contracts.features import FeatureProviderProtocol
|
||||||
from app.modules.contracts.metrics import MetricsProviderProtocol
|
from app.modules.contracts.metrics import MetricsProviderProtocol
|
||||||
|
from app.modules.contracts.onboarding import OnboardingProviderProtocol
|
||||||
from app.modules.contracts.widgets import DashboardWidgetProviderProtocol
|
from app.modules.contracts.widgets import DashboardWidgetProviderProtocol
|
||||||
|
|
||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
@@ -94,6 +95,7 @@ class MenuItemDefinition:
|
|||||||
requires_permission: str | None = None
|
requires_permission: str | None = None
|
||||||
badge_source: str | None = None
|
badge_source: str | None = None
|
||||||
is_super_admin_only: bool = False
|
is_super_admin_only: bool = False
|
||||||
|
header_template: str | None = None # Optional partial for custom header rendering
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -486,6 +488,29 @@ class ModuleDefinition:
|
|||||||
# to report where media is being used.
|
# to report where media is being used.
|
||||||
media_usage_provider: "Callable[[], MediaUsageProviderProtocol] | None" = None
|
media_usage_provider: "Callable[[], MediaUsageProviderProtocol] | None" = None
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Onboarding Provider (Module-Driven Post-Signup Onboarding)
|
||||||
|
# =========================================================================
|
||||||
|
# Callable that returns an OnboardingProviderProtocol implementation.
|
||||||
|
# Modules declare onboarding steps (what needs to be configured after signup)
|
||||||
|
# and provide completion checks. The core module's OnboardingAggregator
|
||||||
|
# discovers and aggregates all providers into a dashboard checklist banner.
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# def _get_onboarding_provider():
|
||||||
|
# from app.modules.marketplace.services.marketplace_onboarding_service import (
|
||||||
|
# marketplace_onboarding_provider,
|
||||||
|
# )
|
||||||
|
# return marketplace_onboarding_provider
|
||||||
|
#
|
||||||
|
# marketplace_module = ModuleDefinition(
|
||||||
|
# code="marketplace",
|
||||||
|
# onboarding_provider=_get_onboarding_provider,
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# The provider will be discovered by core's OnboardingAggregator service.
|
||||||
|
onboarding_provider: "Callable[[], OnboardingProviderProtocol] | None" = None
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Menu Item Methods (Legacy - uses menu_items dict of IDs)
|
# Menu Item Methods (Legacy - uses menu_items dict of IDs)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -955,6 +980,24 @@ class ModuleDefinition:
|
|||||||
return None
|
return None
|
||||||
return self.media_usage_provider()
|
return self.media_usage_provider()
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Onboarding Provider Methods
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def has_onboarding_provider(self) -> bool:
|
||||||
|
"""Check if this module has an onboarding provider."""
|
||||||
|
return self.onboarding_provider is not None
|
||||||
|
|
||||||
|
def get_onboarding_provider_instance(self) -> "OnboardingProviderProtocol | None":
|
||||||
|
"""Get the onboarding provider instance for this module.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
OnboardingProviderProtocol instance, or None
|
||||||
|
"""
|
||||||
|
if self.onboarding_provider is None:
|
||||||
|
return None
|
||||||
|
return self.onboarding_provider()
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Magic Methods
|
# Magic Methods
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
|
|||||||
# api_timeout: int = 30
|
# api_timeout: int = 30
|
||||||
# batch_size: int = 100
|
# batch_size: int = 100
|
||||||
|
|
||||||
model_config = {"env_prefix": "BILLING_"}
|
model_config = {"env_prefix": "BILLING_", "env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
# Export for auto-discovery
|
# Export for auto-discovery
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any
|
|||||||
"""
|
"""
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.modules.billing.models import SubscriptionTier, TierCode
|
from app.modules.billing.models import SubscriptionTier, TierCode
|
||||||
|
from app.modules.billing.services.feature_aggregator import feature_aggregator
|
||||||
|
|
||||||
|
language = getattr(request.state, "language", "fr") or "fr"
|
||||||
|
|
||||||
tiers_db = (
|
tiers_db = (
|
||||||
db.query(SubscriptionTier)
|
db.query(SubscriptionTier)
|
||||||
@@ -48,14 +51,28 @@ def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any
|
|||||||
tiers = []
|
tiers = []
|
||||||
for tier in tiers_db:
|
for tier in tiers_db:
|
||||||
feature_codes = sorted(tier.get_feature_codes())
|
feature_codes = sorted(tier.get_feature_codes())
|
||||||
|
|
||||||
|
# Build features list from declarations for template rendering
|
||||||
|
features = []
|
||||||
|
for code in feature_codes:
|
||||||
|
decl = feature_aggregator.get_declaration(code)
|
||||||
|
if decl:
|
||||||
|
features.append({
|
||||||
|
"code": code,
|
||||||
|
"name_key": decl.name_key,
|
||||||
|
"limit": tier.get_limit_for_feature(code),
|
||||||
|
"is_quantitative": decl.feature_type.value == "quantitative",
|
||||||
|
})
|
||||||
|
|
||||||
tiers.append({
|
tiers.append({
|
||||||
"code": tier.code,
|
"code": tier.code,
|
||||||
"name": tier.name,
|
"name": tier.get_translated_name(language),
|
||||||
"price_monthly": tier.price_monthly_cents / 100,
|
"price_monthly": tier.price_monthly_cents / 100,
|
||||||
"price_annual": (tier.price_annual_cents / 100)
|
"price_annual": (tier.price_annual_cents / 100)
|
||||||
if tier.price_annual_cents
|
if tier.price_annual_cents
|
||||||
else None,
|
else None,
|
||||||
"feature_codes": feature_codes,
|
"feature_codes": feature_codes,
|
||||||
|
"features": features,
|
||||||
"products_limit": tier.get_limit_for_feature("products_limit"),
|
"products_limit": tier.get_limit_for_feature("products_limit"),
|
||||||
"orders_per_month": tier.get_limit_for_feature("orders_per_month"),
|
"orders_per_month": tier.get_limit_for_feature("orders_per_month"),
|
||||||
"team_members": tier.get_limit_for_feature("team_members"),
|
"team_members": tier.get_limit_for_feature("team_members"),
|
||||||
@@ -77,16 +94,16 @@ def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any
|
|||||||
|
|
||||||
def _get_admin_router():
|
def _get_admin_router():
|
||||||
"""Lazy import of admin router to avoid circular imports."""
|
"""Lazy import of admin router to avoid circular imports."""
|
||||||
from app.modules.billing.routes.api.admin import admin_router
|
from app.modules.billing.routes.api.admin import router
|
||||||
|
|
||||||
return admin_router
|
return router
|
||||||
|
|
||||||
|
|
||||||
def _get_store_router():
|
def _get_store_router():
|
||||||
"""Lazy import of store router to avoid circular imports."""
|
"""Lazy import of store router to avoid circular imports."""
|
||||||
from app.modules.billing.routes.api.store import store_router
|
from app.modules.billing.routes.api.store import router
|
||||||
|
|
||||||
return store_router
|
return router
|
||||||
|
|
||||||
|
|
||||||
def _get_metrics_provider():
|
def _get_metrics_provider():
|
||||||
@@ -241,6 +258,7 @@ billing_module = ModuleDefinition(
|
|||||||
icon="currency-euro",
|
icon="currency-euro",
|
||||||
route="/store/{store_code}/invoices",
|
route="/store/{store_code}/invoices",
|
||||||
order=30,
|
order=30,
|
||||||
|
requires_permission="billing.view_invoices",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -256,6 +274,7 @@ billing_module = ModuleDefinition(
|
|||||||
icon="credit-card",
|
icon="credit-card",
|
||||||
route="/store/{store_code}/billing",
|
route="/store/{store_code}/billing",
|
||||||
order=30,
|
order=30,
|
||||||
|
requires_permission="billing.view_subscriptions",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -103,9 +103,12 @@ class RequireFeature:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Check if store's merchant has access to any of the required features."""
|
"""Check if store's merchant has access to any of the required features."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
|
platform_id = current_user.token_platform_id
|
||||||
|
|
||||||
for feature_code in self.feature_codes:
|
for feature_code in self.feature_codes:
|
||||||
if feature_service.has_feature_for_store(db, store_id, feature_code):
|
if feature_service.has_feature_for_store(
|
||||||
|
db, store_id, feature_code, platform_id=platform_id
|
||||||
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
# None of the features are available
|
# None of the features are available
|
||||||
@@ -136,7 +139,8 @@ class RequireWithinLimit:
|
|||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
|
|
||||||
allowed, message = feature_service.check_resource_limit(
|
allowed, message = feature_service.check_resource_limit(
|
||||||
db, self.feature_code, store_id=store_id
|
db, self.feature_code, store_id=store_id,
|
||||||
|
platform_id=current_user.token_platform_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not allowed:
|
if not allowed:
|
||||||
@@ -176,9 +180,12 @@ def require_feature(*feature_codes: str) -> Callable:
|
|||||||
)
|
)
|
||||||
|
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
|
platform_id = current_user.token_platform_id
|
||||||
|
|
||||||
for feature_code in feature_codes:
|
for feature_code in feature_codes:
|
||||||
if feature_service.has_feature_for_store(db, store_id, feature_code):
|
if feature_service.has_feature_for_store(
|
||||||
|
db, store_id, feature_code, platform_id=platform_id
|
||||||
|
):
|
||||||
return await func(*args, **kwargs)
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
raise FeatureNotAvailableError(feature_code=feature_codes[0])
|
raise FeatureNotAvailableError(feature_code=feature_codes[0])
|
||||||
@@ -195,9 +202,12 @@ def require_feature(*feature_codes: str) -> Callable:
|
|||||||
)
|
)
|
||||||
|
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
|
platform_id = current_user.token_platform_id
|
||||||
|
|
||||||
for feature_code in feature_codes:
|
for feature_code in feature_codes:
|
||||||
if feature_service.has_feature_for_store(db, store_id, feature_code):
|
if feature_service.has_feature_for_store(
|
||||||
|
db, store_id, feature_code, platform_id=platform_id
|
||||||
|
):
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
raise FeatureNotAvailableError(feature_code=feature_codes[0])
|
raise FeatureNotAvailableError(feature_code=feature_codes[0])
|
||||||
|
|||||||
138
app/modules/billing/docs/data-model.md
Normal file
138
app/modules/billing/docs/data-model.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# Billing Data Model
|
||||||
|
|
||||||
|
## Entity Relationship Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────┐
|
||||||
|
│ SubscriptionTier │
|
||||||
|
└────────┬──────────┘
|
||||||
|
│ 1:N
|
||||||
|
▼
|
||||||
|
┌───────────────────┐ ┌──────────────────────┐
|
||||||
|
│ TierFeatureLimit │ │ MerchantSubscription │
|
||||||
|
│ (feature limits) │ │ (per merchant+plat) │
|
||||||
|
└───────────────────┘ └──────────┬───────────┘
|
||||||
|
│
|
||||||
|
┌──────────┼──────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌────────────┐ ┌──────────┐ ┌─────────────┐
|
||||||
|
│ BillingHist│ │StoreAddOn│ │FeatureOverride│
|
||||||
|
└────────────┘ └──────────┘ └─────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌────────────┐
|
||||||
|
│AddOnProduct│
|
||||||
|
└────────────┘
|
||||||
|
|
||||||
|
┌──────────────────────┐
|
||||||
|
│StripeWebhookEvent │ (idempotency tracking)
|
||||||
|
└──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Entities
|
||||||
|
|
||||||
|
### SubscriptionTier
|
||||||
|
|
||||||
|
Defines available subscription plans with pricing and Stripe integration.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | Integer | Primary key |
|
||||||
|
| `code` | String | Unique tier code (`essential`, `professional`, `business`, `enterprise`) |
|
||||||
|
| `name` | String | Display name |
|
||||||
|
| `price_monthly_cents` | Integer | Monthly price in cents |
|
||||||
|
| `price_annual_cents` | Integer | Annual price in cents (optional) |
|
||||||
|
| `stripe_product_id` | String | Stripe product ID |
|
||||||
|
| `stripe_price_monthly_id` | String | Stripe monthly price ID |
|
||||||
|
| `stripe_price_annual_id` | String | Stripe annual price ID |
|
||||||
|
| `display_order` | Integer | Sort order on pricing pages |
|
||||||
|
| `is_active` | Boolean | Available for subscription |
|
||||||
|
| `is_public` | Boolean | Visible to stores |
|
||||||
|
|
||||||
|
### TierFeatureLimit
|
||||||
|
|
||||||
|
Per-tier feature limits — each row links a tier to a feature code with a limit value.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | Integer | Primary key |
|
||||||
|
| `tier_id` | Integer | FK to SubscriptionTier |
|
||||||
|
| `feature_code` | String | Feature identifier (e.g., `max_products`) |
|
||||||
|
| `limit_value` | Integer | Numeric limit (NULL = unlimited) |
|
||||||
|
| `enabled` | Boolean | Whether feature is enabled for this tier |
|
||||||
|
|
||||||
|
### MerchantSubscription
|
||||||
|
|
||||||
|
Per-merchant+platform subscription state. Subscriptions are merchant-level, not store-level.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | Integer | Primary key |
|
||||||
|
| `merchant_id` | Integer | FK to Merchant |
|
||||||
|
| `platform_id` | Integer | FK to Platform |
|
||||||
|
| `tier_id` | Integer | FK to SubscriptionTier |
|
||||||
|
| `tier_code` | String | Tier code (denormalized for convenience) |
|
||||||
|
| `status` | SubscriptionStatus | `trial`, `active`, `past_due`, `cancelled`, `expired` |
|
||||||
|
| `stripe_customer_id` | String | Stripe customer ID |
|
||||||
|
| `stripe_subscription_id` | String | Stripe subscription ID |
|
||||||
|
| `trial_ends_at` | DateTime | Trial expiry |
|
||||||
|
| `period_start` | DateTime | Current billing period start |
|
||||||
|
| `period_end` | DateTime | Current billing period end |
|
||||||
|
|
||||||
|
### MerchantFeatureOverride
|
||||||
|
|
||||||
|
Per-merchant exceptions to tier defaults (e.g., enterprise custom limits).
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | Integer | Primary key |
|
||||||
|
| `merchant_id` | Integer | FK to Merchant |
|
||||||
|
| `feature_code` | String | Feature identifier |
|
||||||
|
| `limit_value` | Integer | Override limit (NULL = unlimited) |
|
||||||
|
|
||||||
|
## Add-on Entities
|
||||||
|
|
||||||
|
### AddOnProduct
|
||||||
|
|
||||||
|
Purchasable add-on items (domains, SSL, email packages).
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | Integer | Primary key |
|
||||||
|
| `code` | String | Unique add-on code |
|
||||||
|
| `name` | String | Display name |
|
||||||
|
| `category` | AddOnCategory | `domain`, `ssl`, `email` |
|
||||||
|
| `price_cents` | Integer | Price in cents |
|
||||||
|
| `billing_period` | BillingPeriod | `monthly` or `yearly` |
|
||||||
|
|
||||||
|
### StoreAddOn
|
||||||
|
|
||||||
|
Add-ons purchased by individual stores.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | Integer | Primary key |
|
||||||
|
| `store_id` | Integer | FK to Store |
|
||||||
|
| `addon_product_id` | Integer | FK to AddOnProduct |
|
||||||
|
| `config` | JSON | Configuration (e.g., domain name) |
|
||||||
|
| `stripe_subscription_item_id` | String | Stripe subscription item ID |
|
||||||
|
| `status` | String | `active`, `cancelled`, `pending_setup` |
|
||||||
|
|
||||||
|
## Supporting Entities
|
||||||
|
|
||||||
|
### BillingHistory
|
||||||
|
|
||||||
|
Invoice and payment history records.
|
||||||
|
|
||||||
|
### StripeWebhookEvent
|
||||||
|
|
||||||
|
Idempotency tracking for Stripe webhook events. Prevents duplicate event processing.
|
||||||
|
|
||||||
|
## Key Relationships
|
||||||
|
|
||||||
|
- A **SubscriptionTier** has many **TierFeatureLimits** (one per feature)
|
||||||
|
- A **Merchant** has one **MerchantSubscription** per Platform
|
||||||
|
- A **MerchantSubscription** references one **SubscriptionTier**
|
||||||
|
- A **Merchant** can have many **MerchantFeatureOverrides** (per-feature)
|
||||||
|
- A **Store** can purchase many **StoreAddOns**
|
||||||
|
- Feature limits are resolved: MerchantFeatureOverride > TierFeatureLimit > default
|
||||||
434
app/modules/billing/docs/feature-gating.md
Normal file
434
app/modules/billing/docs/feature-gating.md
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
# Feature Gating System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The feature gating system provides tier-based access control for platform features. It allows restricting functionality based on store subscription tiers (Essential, Professional, Business, Enterprise) with contextual upgrade prompts when features are locked.
|
||||||
|
|
||||||
|
**Implemented:** December 31, 2025
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Database Models
|
||||||
|
|
||||||
|
Located in `models/database/feature.py`:
|
||||||
|
|
||||||
|
| Model | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `Feature` | Feature definitions with tier requirements |
|
||||||
|
| `StoreFeatureOverride` | Per-store feature overrides (enable/disable) |
|
||||||
|
|
||||||
|
### Feature Model Structure
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Feature(Base):
|
||||||
|
__tablename__ = "features"
|
||||||
|
|
||||||
|
id: int # Primary key
|
||||||
|
code: str # Unique feature code (e.g., "analytics_dashboard")
|
||||||
|
name: str # Display name
|
||||||
|
description: str # User-facing description
|
||||||
|
category: str # Feature category
|
||||||
|
minimum_tier_code: str # Minimum tier required (essential/professional/business/enterprise)
|
||||||
|
minimum_tier_order: int # Tier order for comparison (1-4)
|
||||||
|
is_active: bool # Whether feature is available
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tier Ordering
|
||||||
|
|
||||||
|
| Tier | Order | Code |
|
||||||
|
|------|-------|------|
|
||||||
|
| Essential | 1 | `essential` |
|
||||||
|
| Professional | 2 | `professional` |
|
||||||
|
| Business | 3 | `business` |
|
||||||
|
| Enterprise | 4 | `enterprise` |
|
||||||
|
|
||||||
|
## Feature Categories
|
||||||
|
|
||||||
|
30 features organized into 8 categories:
|
||||||
|
|
||||||
|
### 1. Analytics
|
||||||
|
| Feature Code | Name | Min Tier |
|
||||||
|
|-------------|------|----------|
|
||||||
|
| `basic_analytics` | Basic Analytics | Essential |
|
||||||
|
| `analytics_dashboard` | Analytics Dashboard | Professional |
|
||||||
|
| `advanced_analytics` | Advanced Analytics | Business |
|
||||||
|
| `custom_reports` | Custom Reports | Enterprise |
|
||||||
|
|
||||||
|
### 2. Product Management
|
||||||
|
| Feature Code | Name | Min Tier |
|
||||||
|
|-------------|------|----------|
|
||||||
|
| `basic_products` | Product Management | Essential |
|
||||||
|
| `bulk_product_edit` | Bulk Product Edit | Professional |
|
||||||
|
| `product_variants` | Product Variants | Professional |
|
||||||
|
| `product_bundles` | Product Bundles | Business |
|
||||||
|
| `inventory_alerts` | Inventory Alerts | Professional |
|
||||||
|
|
||||||
|
### 3. Order Management
|
||||||
|
| Feature Code | Name | Min Tier |
|
||||||
|
|-------------|------|----------|
|
||||||
|
| `basic_orders` | Order Management | Essential |
|
||||||
|
| `order_automation` | Order Automation | Professional |
|
||||||
|
| `advanced_fulfillment` | Advanced Fulfillment | Business |
|
||||||
|
| `multi_warehouse` | Multi-Warehouse | Enterprise |
|
||||||
|
|
||||||
|
### 4. Marketing
|
||||||
|
| Feature Code | Name | Min Tier |
|
||||||
|
|-------------|------|----------|
|
||||||
|
| `discount_codes` | Discount Codes | Professional |
|
||||||
|
| `abandoned_cart` | Abandoned Cart Recovery | Business |
|
||||||
|
| `email_marketing` | Email Marketing | Business |
|
||||||
|
| `loyalty_program` | Loyalty Program | Enterprise |
|
||||||
|
|
||||||
|
### 5. Support
|
||||||
|
| Feature Code | Name | Min Tier |
|
||||||
|
|-------------|------|----------|
|
||||||
|
| `basic_support` | Email Support | Essential |
|
||||||
|
| `priority_support` | Priority Support | Professional |
|
||||||
|
| `phone_support` | Phone Support | Business |
|
||||||
|
| `dedicated_manager` | Dedicated Account Manager | Enterprise |
|
||||||
|
|
||||||
|
### 6. Integration
|
||||||
|
| Feature Code | Name | Min Tier |
|
||||||
|
|-------------|------|----------|
|
||||||
|
| `basic_api` | Basic API Access | Professional |
|
||||||
|
| `advanced_api` | Advanced API Access | Business |
|
||||||
|
| `webhooks` | Webhooks | Business |
|
||||||
|
| `custom_integrations` | Custom Integrations | Enterprise |
|
||||||
|
|
||||||
|
### 7. Branding
|
||||||
|
| Feature Code | Name | Min Tier |
|
||||||
|
|-------------|------|----------|
|
||||||
|
| `basic_theme` | Theme Customization | Essential |
|
||||||
|
| `custom_domain` | Custom Domain | Professional |
|
||||||
|
| `white_label` | White Label | Enterprise |
|
||||||
|
| `custom_checkout` | Custom Checkout | Enterprise |
|
||||||
|
|
||||||
|
### 8. Team
|
||||||
|
| Feature Code | Name | Min Tier |
|
||||||
|
|-------------|------|----------|
|
||||||
|
| `team_management` | Team Management | Professional |
|
||||||
|
| `role_permissions` | Role Permissions | Business |
|
||||||
|
| `audit_logs` | Audit Logs | Business |
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
### FeatureService
|
||||||
|
|
||||||
|
Located in `app/services/feature_service.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class FeatureService:
|
||||||
|
"""Service for managing tier-based feature access."""
|
||||||
|
|
||||||
|
# In-memory caching (refreshed every 5 minutes)
|
||||||
|
_feature_cache: dict[str, Feature] = {}
|
||||||
|
_cache_timestamp: datetime | None = None
|
||||||
|
CACHE_TTL_SECONDS = 300
|
||||||
|
|
||||||
|
def has_feature(self, db: Session, store_id: int, feature_code: str) -> bool:
|
||||||
|
"""Check if store has access to a feature."""
|
||||||
|
|
||||||
|
def get_available_features(self, db: Session, store_id: int) -> list[str]:
|
||||||
|
"""Get list of feature codes available to store."""
|
||||||
|
|
||||||
|
def get_all_features_with_status(self, db: Session, store_id: int) -> list[dict]:
|
||||||
|
"""Get all features with availability status for store."""
|
||||||
|
|
||||||
|
def get_feature_info(self, db: Session, feature_code: str) -> dict | None:
|
||||||
|
"""Get full feature information including tier requirements."""
|
||||||
|
```
|
||||||
|
|
||||||
|
### UsageService
|
||||||
|
|
||||||
|
Located in `app/services/usage_service.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class UsageService:
|
||||||
|
"""Service for tracking and managing store usage against tier limits."""
|
||||||
|
|
||||||
|
def get_usage_summary(self, db: Session, store_id: int) -> dict:
|
||||||
|
"""Get comprehensive usage summary with limits and upgrade info."""
|
||||||
|
|
||||||
|
def check_limit(self, db: Session, store_id: int, limit_type: str) -> dict:
|
||||||
|
"""Check specific limit with detailed info."""
|
||||||
|
|
||||||
|
def get_upgrade_info(self, db: Session, store_id: int) -> dict:
|
||||||
|
"""Get upgrade recommendations based on current usage."""
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Enforcement
|
||||||
|
|
||||||
|
### Decorator Pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.core.feature_gate import require_feature
|
||||||
|
|
||||||
|
@router.get("/analytics/advanced")
|
||||||
|
@require_feature("advanced_analytics")
|
||||||
|
async def get_advanced_analytics(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
store_id: int = Depends(get_current_store_id)
|
||||||
|
):
|
||||||
|
# Only accessible if store has advanced_analytics feature
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependency Pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.core.feature_gate import RequireFeature
|
||||||
|
|
||||||
|
@router.get("/marketing/loyalty")
|
||||||
|
async def get_loyalty_program(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: None = Depends(RequireFeature("loyalty_program"))
|
||||||
|
):
|
||||||
|
# Only accessible if store has loyalty_program feature
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exception Handling
|
||||||
|
|
||||||
|
When a feature is not available, `FeatureNotAvailableException` is raised:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class FeatureNotAvailableException(Exception):
|
||||||
|
def __init__(self, feature_code: str, required_tier: str):
|
||||||
|
self.feature_code = feature_code
|
||||||
|
self.required_tier = required_tier
|
||||||
|
super().__init__(f"Feature '{feature_code}' requires {required_tier} tier")
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP Response (403):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Feature 'advanced_analytics' requires Professional tier or higher",
|
||||||
|
"feature_code": "advanced_analytics",
|
||||||
|
"required_tier": "Professional",
|
||||||
|
"upgrade_url": "/store/orion/billing"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Store Features API
|
||||||
|
|
||||||
|
Base: `/api/v1/store/features`
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/features/available` | GET | List available feature codes |
|
||||||
|
| `/features` | GET | All features with availability status |
|
||||||
|
| `/features/{code}` | GET | Single feature info |
|
||||||
|
| `/features/{code}/check` | GET | Quick availability check |
|
||||||
|
|
||||||
|
### Store Usage API
|
||||||
|
|
||||||
|
Base: `/api/v1/store/usage`
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/usage` | GET | Full usage summary with limits |
|
||||||
|
| `/usage/check/{limit_type}` | GET | Check specific limit (orders/products/team_members) |
|
||||||
|
| `/usage/upgrade-info` | GET | Upgrade recommendations |
|
||||||
|
|
||||||
|
### Admin Features API
|
||||||
|
|
||||||
|
Base: `/api/v1/admin/features`
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/features` | GET | List all features |
|
||||||
|
| `/features/{id}` | GET | Get feature details |
|
||||||
|
| `/features/{id}` | PUT | Update feature |
|
||||||
|
| `/features/{id}/toggle` | POST | Toggle feature active status |
|
||||||
|
| `/features/stores/{store_id}/overrides` | GET | Get store overrides |
|
||||||
|
| `/features/stores/{store_id}/overrides` | POST | Create override |
|
||||||
|
|
||||||
|
## Frontend Integration
|
||||||
|
|
||||||
|
### Alpine.js Feature Store
|
||||||
|
|
||||||
|
Located in `static/shared/js/feature-store.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Usage in templates
|
||||||
|
$store.features.has('analytics_dashboard') // Check feature
|
||||||
|
$store.features.loaded // Loading state
|
||||||
|
$store.features.getFeature('advanced_api') // Get feature details
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alpine.js Upgrade Store
|
||||||
|
|
||||||
|
Located in `static/shared/js/upgrade-prompts.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Usage in templates
|
||||||
|
$store.upgrade.shouldShowLimitWarning('orders')
|
||||||
|
$store.upgrade.getUsageString('products')
|
||||||
|
$store.upgrade.hasUpgradeRecommendation
|
||||||
|
```
|
||||||
|
|
||||||
|
### Jinja2 Macros
|
||||||
|
|
||||||
|
Located in `app/templates/shared/macros/feature_gate.html`:
|
||||||
|
|
||||||
|
#### Feature Gate Container
|
||||||
|
```jinja2
|
||||||
|
{% from "shared/macros/feature_gate.html" import feature_gate %}
|
||||||
|
|
||||||
|
{% call feature_gate("analytics_dashboard") %}
|
||||||
|
<div>Analytics content here - only visible if feature available</div>
|
||||||
|
{% endcall %}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Feature Locked Card
|
||||||
|
```jinja2
|
||||||
|
{% from "shared/macros/feature_gate.html" import feature_locked %}
|
||||||
|
|
||||||
|
{{ feature_locked("advanced_analytics", "Advanced Analytics", "Get deeper insights") }}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Upgrade Banner
|
||||||
|
```jinja2
|
||||||
|
{% from "shared/macros/feature_gate.html" import upgrade_banner %}
|
||||||
|
|
||||||
|
{{ upgrade_banner("custom_domain") }}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Usage Limit Warning
|
||||||
|
```jinja2
|
||||||
|
{% from "shared/macros/feature_gate.html" import limit_warning %}
|
||||||
|
|
||||||
|
{{ limit_warning("orders") }} {# Shows warning when approaching limit #}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Usage Progress Bar
|
||||||
|
```jinja2
|
||||||
|
{% from "shared/macros/feature_gate.html" import usage_bar %}
|
||||||
|
|
||||||
|
{{ usage_bar("products", "Products") }}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Tier Badge
|
||||||
|
```jinja2
|
||||||
|
{% from "shared/macros/feature_gate.html" import tier_badge %}
|
||||||
|
|
||||||
|
{{ tier_badge() }} {# Shows current tier as colored badge #}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Store Dashboard Integration
|
||||||
|
|
||||||
|
The store dashboard (`/store/{code}/dashboard`) now includes:
|
||||||
|
|
||||||
|
1. **Tier Badge**: Shows current subscription tier in header
|
||||||
|
2. **Usage Bars**: Visual progress bars for orders, products, team members
|
||||||
|
3. **Upgrade Prompts**: Contextual upgrade recommendations when approaching limits
|
||||||
|
4. **Feature Gates**: Locked sections for premium features
|
||||||
|
|
||||||
|
## Admin Features Page
|
||||||
|
|
||||||
|
Located at `/admin/features`:
|
||||||
|
|
||||||
|
- View all 30 features in categorized table
|
||||||
|
- Toggle features on/off globally
|
||||||
|
- Filter by category
|
||||||
|
- Search by name/code
|
||||||
|
- View tier requirements
|
||||||
|
|
||||||
|
## Admin Tier Management UI
|
||||||
|
|
||||||
|
Located at `/admin/subscription-tiers`:
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
The subscription tiers admin page provides full CRUD functionality for managing subscription tiers and their feature assignments.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
1. **Stats Cards**: Display total tiers, active tiers, public tiers, and estimated MRR
|
||||||
|
2. **Tier Table**: Sortable list of all tiers with:
|
||||||
|
- Display order
|
||||||
|
- Code (colored badge by tier)
|
||||||
|
- Name
|
||||||
|
- Monthly/Annual pricing
|
||||||
|
- Feature count
|
||||||
|
- Status (Active/Private/Inactive)
|
||||||
|
- Actions (Edit Features, Edit, Activate/Deactivate)
|
||||||
|
|
||||||
|
3. **Create/Edit Modal**: Form with all tier fields:
|
||||||
|
- Code and Name
|
||||||
|
- Monthly and Annual pricing (in cents)
|
||||||
|
- Display order
|
||||||
|
- Stripe IDs (optional)
|
||||||
|
- Description
|
||||||
|
- Active/Public toggles
|
||||||
|
|
||||||
|
4. **Feature Assignment Slide-over Panel**:
|
||||||
|
- Opens when clicking the puzzle-piece icon
|
||||||
|
- Shows all features grouped by category
|
||||||
|
- Binary features: checkbox selection with Select all/Deselect all per category
|
||||||
|
- Quantitative features: checkbox + numeric limit input for `limit_value`
|
||||||
|
- Feature count in footer
|
||||||
|
- Save to update tier's feature assignments via `TierFeatureLimitEntry[]`
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `app/templates/admin/subscription-tiers.html` | Page template |
|
||||||
|
| `static/admin/js/subscription-tiers.js` | Alpine.js component |
|
||||||
|
| `app/routes/admin_pages.py` | Route registration |
|
||||||
|
|
||||||
|
### API Endpoints Used
|
||||||
|
|
||||||
|
| Action | Method | Endpoint |
|
||||||
|
|--------|--------|----------|
|
||||||
|
| Load tiers | GET | `/api/v1/admin/subscriptions/tiers` |
|
||||||
|
| Load stats | GET | `/api/v1/admin/subscriptions/stats` |
|
||||||
|
| Create tier | POST | `/api/v1/admin/subscriptions/tiers` |
|
||||||
|
| Update tier | PATCH | `/api/v1/admin/subscriptions/tiers/{code}` |
|
||||||
|
| Delete tier | DELETE | `/api/v1/admin/subscriptions/tiers/{code}` |
|
||||||
|
| Load feature catalog | GET | `/api/v1/admin/subscriptions/features/catalog` |
|
||||||
|
| Get tier feature limits | GET | `/api/v1/admin/subscriptions/features/tiers/{code}/limits` |
|
||||||
|
| Update tier feature limits | PUT | `/api/v1/admin/subscriptions/features/tiers/{code}/limits` |
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
The features are seeded via Alembic migration:
|
||||||
|
|
||||||
|
```
|
||||||
|
alembic/versions/n2c3d4e5f6a7_add_features_table.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates:
|
||||||
|
- `features` table with 30 default features
|
||||||
|
- `store_feature_overrides` table for per-store exceptions
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Unit tests located in:
|
||||||
|
- `tests/unit/services/test_feature_service.py`
|
||||||
|
- `tests/unit/services/test_usage_service.py`
|
||||||
|
|
||||||
|
Run tests:
|
||||||
|
```bash
|
||||||
|
pytest tests/unit/services/test_feature_service.py -v
|
||||||
|
pytest tests/unit/services/test_usage_service.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Compliance
|
||||||
|
|
||||||
|
All JavaScript files follow architecture rules:
|
||||||
|
- JS-003: Alpine components use `store*` naming convention
|
||||||
|
- JS-005: Init guards prevent duplicate initialization
|
||||||
|
- JS-006: Async operations have try/catch error handling
|
||||||
|
- JS-008: API calls use `apiClient` (not raw `fetch()`)
|
||||||
|
- JS-009: Notifications use `Utils.showToast()`
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Subscription Billing](subscription-system.md) - Core subscription system
|
||||||
|
- [Subscription Workflow Plan](subscription-workflow.md) - Implementation roadmap
|
||||||
74
app/modules/billing/docs/index.md
Normal file
74
app/modules/billing/docs/index.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Billing & Subscriptions
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| Code | `billing` |
|
||||||
|
| Classification | Core |
|
||||||
|
| Dependencies | `payments` |
|
||||||
|
| Status | Active |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- `subscription_management` — Subscription lifecycle management
|
||||||
|
- `billing_history` — Billing and payment history
|
||||||
|
- `invoice_generation` — Automatic invoice generation
|
||||||
|
- `subscription_analytics` — Subscription metrics and analytics
|
||||||
|
- `trial_management` — Free trial period management
|
||||||
|
- `limit_overrides` — Per-store tier limit overrides
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
| Permission | Description |
|
||||||
|
|------------|-------------|
|
||||||
|
| `billing.view_tiers` | View subscription tiers |
|
||||||
|
| `billing.manage_tiers` | Manage subscription tiers |
|
||||||
|
| `billing.view_subscriptions` | View subscriptions |
|
||||||
|
| `billing.manage_subscriptions` | Manage subscriptions |
|
||||||
|
| `billing.view_invoices` | View invoices |
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
See [Data Model](data-model.md) for full entity relationships.
|
||||||
|
|
||||||
|
- **SubscriptionTier** — Tier definitions with Stripe price IDs
|
||||||
|
- **TierFeatureLimit** — Per-tier feature limits (feature_code + limit_value)
|
||||||
|
- **MerchantSubscription** — Per-merchant+platform subscription state
|
||||||
|
- **MerchantFeatureOverride** — Per-merchant feature limit overrides
|
||||||
|
- **AddOnProduct / StoreAddOn** — Purchasable add-ons
|
||||||
|
- **BillingHistory** — Invoice and payment records
|
||||||
|
- **StripeWebhookEvent** — Webhook idempotency tracking
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `*` | `/api/v1/admin/billing/*` | Admin billing management |
|
||||||
|
| `*` | `/api/v1/admin/features/*` | Feature/tier management |
|
||||||
|
| `*` | `/api/v1/merchant/billing/*` | Merchant billing endpoints |
|
||||||
|
| `*` | `/api/v1/platform/billing/*` | Platform-wide billing stats |
|
||||||
|
|
||||||
|
## Scheduled Tasks
|
||||||
|
|
||||||
|
| Task | Schedule | Description |
|
||||||
|
|------|----------|-------------|
|
||||||
|
| `billing.reset_period_counters` | Daily 00:05 | Reset period-based usage counters |
|
||||||
|
| `billing.check_trial_expirations` | Daily 01:00 | Check and handle expired trials |
|
||||||
|
| `billing.sync_stripe_status` | Hourly :30 | Sync subscription status with Stripe |
|
||||||
|
| `billing.cleanup_stale_subscriptions` | Weekly Sunday 03:00 | Clean up stale subscription records |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configured via Stripe environment variables and tier definitions in the admin panel.
|
||||||
|
|
||||||
|
## Additional Documentation
|
||||||
|
|
||||||
|
- [Data Model](data-model.md) — Entity relationships and database schema
|
||||||
|
- [Subscription System](subscription-system.md) — Architecture, feature providers, API reference
|
||||||
|
- [Feature Gating](feature-gating.md) — Tier-based feature access control and UI integration
|
||||||
|
- [Tier Management](tier-management.md) — Admin guide for managing subscription tiers
|
||||||
|
- [Subscription Workflow](subscription-workflow.md) — Subscription lifecycle and implementation phases
|
||||||
|
- [Stripe Integration](stripe-integration.md) — Stripe Connect setup, webhooks, payment flow
|
||||||
617
app/modules/billing/docs/stripe-integration.md
Normal file
617
app/modules/billing/docs/stripe-integration.md
Normal file
@@ -0,0 +1,617 @@
|
|||||||
|
# Stripe Payment Integration - Multi-Tenant Ecommerce Platform
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
The payment integration uses **Stripe Connect** to handle multi-store payments, enabling:
|
||||||
|
- Each store to receive payments directly
|
||||||
|
- Platform to collect fees/commissions
|
||||||
|
- Proper financial isolation between stores
|
||||||
|
- Compliance with financial regulations
|
||||||
|
|
||||||
|
## Payment Models
|
||||||
|
|
||||||
|
### Database Models
|
||||||
|
|
||||||
|
```python
|
||||||
|
# models/database/payment.py
|
||||||
|
from decimal import Decimal
|
||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, Numeric
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.core.database import Base
|
||||||
|
from .base import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class StorePaymentConfig(Base, TimestampMixin):
|
||||||
|
"""Store-specific payment configuration."""
|
||||||
|
__tablename__ = "store_payment_configs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
store_id = Column(Integer, ForeignKey("stores.id"), nullable=False, unique=True)
|
||||||
|
|
||||||
|
# Stripe Connect configuration
|
||||||
|
stripe_account_id = Column(String(255)) # Stripe Connect account ID
|
||||||
|
stripe_account_status = Column(String(50)) # pending, active, restricted, inactive
|
||||||
|
stripe_onboarding_url = Column(Text) # Onboarding link for store
|
||||||
|
stripe_dashboard_url = Column(Text) # Store's Stripe dashboard
|
||||||
|
|
||||||
|
# Payment settings
|
||||||
|
accepts_payments = Column(Boolean, default=False)
|
||||||
|
currency = Column(String(3), default="EUR")
|
||||||
|
platform_fee_percentage = Column(Numeric(5, 2), default=2.5) # Platform commission
|
||||||
|
|
||||||
|
# Payout settings
|
||||||
|
payout_schedule = Column(String(20), default="weekly") # daily, weekly, monthly
|
||||||
|
minimum_payout = Column(Numeric(10, 2), default=20.00)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
store = relationship("Store", back_populates="payment_config")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<StorePaymentConfig(store_id={self.store_id}, stripe_account_id='{self.stripe_account_id}')>"
|
||||||
|
|
||||||
|
|
||||||
|
class Payment(Base, TimestampMixin):
|
||||||
|
"""Payment records for orders."""
|
||||||
|
__tablename__ = "payments"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
store_id = Column(Integer, ForeignKey("stores.id"), nullable=False)
|
||||||
|
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||||
|
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False)
|
||||||
|
|
||||||
|
# Stripe payment details
|
||||||
|
stripe_payment_intent_id = Column(String(255), unique=True, index=True)
|
||||||
|
stripe_charge_id = Column(String(255), index=True)
|
||||||
|
stripe_transfer_id = Column(String(255)) # Transfer to store account
|
||||||
|
|
||||||
|
# Payment amounts (in cents to avoid floating point issues)
|
||||||
|
amount_total = Column(Integer, nullable=False) # Total customer payment
|
||||||
|
amount_store = Column(Integer, nullable=False) # Amount to store
|
||||||
|
amount_platform_fee = Column(Integer, nullable=False) # Platform commission
|
||||||
|
currency = Column(String(3), default="EUR")
|
||||||
|
|
||||||
|
# Payment status
|
||||||
|
status = Column(String(50), nullable=False) # pending, succeeded, failed, refunded
|
||||||
|
payment_method = Column(String(50)) # card, bank_transfer, etc.
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
stripe_metadata = Column(Text) # JSON string of Stripe metadata
|
||||||
|
failure_reason = Column(Text)
|
||||||
|
refund_reason = Column(Text)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
paid_at = Column(DateTime)
|
||||||
|
refunded_at = Column(DateTime)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
store = relationship("Store")
|
||||||
|
order = relationship("Order", back_populates="payment")
|
||||||
|
customer = relationship("Customer")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Payment(id={self.id}, order_id={self.order_id}, status='{self.status}')>"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def amount_total_euros(self):
|
||||||
|
"""Convert cents to euros for display."""
|
||||||
|
return self.amount_total / 100
|
||||||
|
|
||||||
|
@property
|
||||||
|
def amount_store_euros(self):
|
||||||
|
"""Convert cents to euros for display."""
|
||||||
|
return self.amount_store / 100
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentMethod(Base, TimestampMixin):
|
||||||
|
"""Saved customer payment methods."""
|
||||||
|
__tablename__ = "payment_methods"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
store_id = Column(Integer, ForeignKey("stores.id"), nullable=False)
|
||||||
|
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False)
|
||||||
|
|
||||||
|
# Stripe payment method details
|
||||||
|
stripe_payment_method_id = Column(String(255), nullable=False, index=True)
|
||||||
|
payment_method_type = Column(String(50), nullable=False) # card, sepa_debit, etc.
|
||||||
|
|
||||||
|
# Card details (if applicable)
|
||||||
|
card_brand = Column(String(50)) # visa, mastercard, etc.
|
||||||
|
card_last4 = Column(String(4))
|
||||||
|
card_exp_month = Column(Integer)
|
||||||
|
card_exp_year = Column(Integer)
|
||||||
|
|
||||||
|
# Settings
|
||||||
|
is_default = Column(Boolean, default=False)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
store = relationship("Store")
|
||||||
|
customer = relationship("Customer")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<PaymentMethod(id={self.id}, customer_id={self.customer_id}, type='{self.payment_method_type}')>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updated Order Model
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Update models/database/order.py
|
||||||
|
class Order(Base, TimestampMixin):
|
||||||
|
# ... existing fields ...
|
||||||
|
|
||||||
|
# Payment integration
|
||||||
|
payment_status = Column(String(50), default="pending") # pending, paid, failed, refunded
|
||||||
|
payment_intent_id = Column(String(255)) # Stripe PaymentIntent ID
|
||||||
|
total_amount_cents = Column(Integer, nullable=False) # Amount in cents
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
payment = relationship("Payment", back_populates="order", uselist=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_amount_euros(self):
|
||||||
|
"""Convert cents to euros for display."""
|
||||||
|
return self.total_amount_cents / 100 if self.total_amount_cents else 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Payment Service Integration
|
||||||
|
|
||||||
|
### Stripe Service
|
||||||
|
|
||||||
|
```python
|
||||||
|
# services/payment_service.py
|
||||||
|
import stripe
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Dict, Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from models.database.payment import Payment, StorePaymentConfig
|
||||||
|
from models.database.order import Order
|
||||||
|
from models.database.store import Store
|
||||||
|
from app.exceptions.payment import *
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Configure Stripe
|
||||||
|
stripe.api_key = settings.stripe_secret_key
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentService:
|
||||||
|
"""Service for handling Stripe payments in multi-tenant environment."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def create_payment_intent(
|
||||||
|
self,
|
||||||
|
store_id: int,
|
||||||
|
order_id: int,
|
||||||
|
amount_euros: Decimal,
|
||||||
|
customer_email: str,
|
||||||
|
metadata: Optional[Dict] = None
|
||||||
|
) -> Dict:
|
||||||
|
"""Create Stripe PaymentIntent for store order."""
|
||||||
|
|
||||||
|
# Get store payment configuration
|
||||||
|
payment_config = self.get_store_payment_config(store_id)
|
||||||
|
if not payment_config.accepts_payments:
|
||||||
|
raise PaymentNotConfiguredException(f"Store {store_id} not configured for payments")
|
||||||
|
|
||||||
|
# Calculate amounts
|
||||||
|
amount_cents = int(amount_euros * 100)
|
||||||
|
platform_fee_cents = int(amount_cents * (payment_config.platform_fee_percentage / 100))
|
||||||
|
store_amount_cents = amount_cents - platform_fee_cents
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create PaymentIntent with Stripe Connect
|
||||||
|
payment_intent = stripe.PaymentIntent.create(
|
||||||
|
amount=amount_cents,
|
||||||
|
currency=payment_config.currency.lower(),
|
||||||
|
application_fee_amount=platform_fee_cents,
|
||||||
|
transfer_data={
|
||||||
|
'destination': payment_config.stripe_account_id,
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
'store_id': str(store_id),
|
||||||
|
'order_id': str(order_id),
|
||||||
|
'platform': 'multi_tenant_ecommerce',
|
||||||
|
**(metadata or {})
|
||||||
|
},
|
||||||
|
receipt_email=customer_email,
|
||||||
|
description=f"Order payment for store {store_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create payment record
|
||||||
|
payment = Payment(
|
||||||
|
store_id=store_id,
|
||||||
|
order_id=order_id,
|
||||||
|
customer_id=self.get_order_customer_id(order_id),
|
||||||
|
stripe_payment_intent_id=payment_intent.id,
|
||||||
|
amount_total=amount_cents,
|
||||||
|
amount_store=store_amount_cents,
|
||||||
|
amount_platform_fee=platform_fee_cents,
|
||||||
|
currency=payment_config.currency,
|
||||||
|
status='pending',
|
||||||
|
stripe_metadata=json.dumps(payment_intent.metadata)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(payment)
|
||||||
|
|
||||||
|
# Update order
|
||||||
|
order = self.db.query(Order).filter(Order.id == order_id).first()
|
||||||
|
if order:
|
||||||
|
order.payment_intent_id = payment_intent.id
|
||||||
|
order.payment_status = 'pending'
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'payment_intent_id': payment_intent.id,
|
||||||
|
'client_secret': payment_intent.client_secret,
|
||||||
|
'amount_total': amount_euros,
|
||||||
|
'amount_store': store_amount_cents / 100,
|
||||||
|
'platform_fee': platform_fee_cents / 100,
|
||||||
|
'currency': payment_config.currency
|
||||||
|
}
|
||||||
|
|
||||||
|
except stripe.error.StripeError as e:
|
||||||
|
logger.error(f"Stripe error creating PaymentIntent: {e}")
|
||||||
|
raise PaymentProcessingException(f"Payment processing failed: {str(e)}")
|
||||||
|
|
||||||
|
def confirm_payment(self, payment_intent_id: str) -> Payment:
|
||||||
|
"""Confirm payment and update records."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Retrieve PaymentIntent from Stripe
|
||||||
|
payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id)
|
||||||
|
|
||||||
|
# Find payment record
|
||||||
|
payment = self.db.query(Payment).filter(
|
||||||
|
Payment.stripe_payment_intent_id == payment_intent_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not payment:
|
||||||
|
raise PaymentNotFoundException(f"Payment not found for intent {payment_intent_id}")
|
||||||
|
|
||||||
|
# Update payment status based on Stripe status
|
||||||
|
if payment_intent.status == 'succeeded':
|
||||||
|
payment.status = 'succeeded'
|
||||||
|
payment.stripe_charge_id = payment_intent.charges.data[0].id if payment_intent.charges.data else None
|
||||||
|
payment.paid_at = datetime.utcnow()
|
||||||
|
|
||||||
|
# Update order status
|
||||||
|
order = self.db.query(Order).filter(Order.id == payment.order_id).first()
|
||||||
|
if order:
|
||||||
|
order.payment_status = 'paid'
|
||||||
|
order.status = 'processing' # Move order to processing
|
||||||
|
|
||||||
|
elif payment_intent.status == 'payment_failed':
|
||||||
|
payment.status = 'failed'
|
||||||
|
payment.failure_reason = payment_intent.last_payment_error.message if payment_intent.last_payment_error else "Unknown error"
|
||||||
|
|
||||||
|
# Update order status
|
||||||
|
order = self.db.query(Order).filter(Order.id == payment.order_id).first()
|
||||||
|
if order:
|
||||||
|
order.payment_status = 'failed'
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
return payment
|
||||||
|
|
||||||
|
except stripe.error.StripeError as e:
|
||||||
|
logger.error(f"Stripe error confirming payment: {e}")
|
||||||
|
raise PaymentProcessingException(f"Payment confirmation failed: {str(e)}")
|
||||||
|
|
||||||
|
def create_store_stripe_account(self, store_id: int, store_data: Dict) -> str:
|
||||||
|
"""Create Stripe Connect account for store."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create Stripe Connect Express account
|
||||||
|
account = stripe.Account.create(
|
||||||
|
type='express',
|
||||||
|
country='LU', # Luxembourg
|
||||||
|
email=store_data.get('business_email'),
|
||||||
|
capabilities={
|
||||||
|
'card_payments': {'requested': True},
|
||||||
|
'transfers': {'requested': True},
|
||||||
|
},
|
||||||
|
business_type='merchant',
|
||||||
|
merchant={
|
||||||
|
'name': store_data.get('business_name'),
|
||||||
|
'phone': store_data.get('business_phone'),
|
||||||
|
'address': {
|
||||||
|
'line1': store_data.get('address_line1'),
|
||||||
|
'city': store_data.get('city'),
|
||||||
|
'postal_code': store_data.get('postal_code'),
|
||||||
|
'country': 'LU'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
'store_id': str(store_id),
|
||||||
|
'platform': 'multi_tenant_ecommerce'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update or create payment configuration
|
||||||
|
payment_config = self.get_or_create_store_payment_config(store_id)
|
||||||
|
payment_config.stripe_account_id = account.id
|
||||||
|
payment_config.stripe_account_status = account.charges_enabled and account.payouts_enabled and 'active' or 'pending'
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
return account.id
|
||||||
|
|
||||||
|
except stripe.error.StripeError as e:
|
||||||
|
logger.error(f"Stripe error creating account: {e}")
|
||||||
|
raise PaymentConfigurationException(f"Failed to create payment account: {str(e)}")
|
||||||
|
|
||||||
|
def create_onboarding_link(self, store_id: int) -> str:
|
||||||
|
"""Create Stripe onboarding link for store."""
|
||||||
|
|
||||||
|
payment_config = self.get_store_payment_config(store_id)
|
||||||
|
if not payment_config.stripe_account_id:
|
||||||
|
raise PaymentNotConfiguredException("Store does not have Stripe account")
|
||||||
|
|
||||||
|
try:
|
||||||
|
account_link = stripe.AccountLink.create(
|
||||||
|
account=payment_config.stripe_account_id,
|
||||||
|
refresh_url=f"{settings.frontend_url}/store/admin/payments/refresh",
|
||||||
|
return_url=f"{settings.frontend_url}/store/admin/payments/success",
|
||||||
|
type='account_onboarding',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update onboarding URL
|
||||||
|
payment_config.stripe_onboarding_url = account_link.url
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
return account_link.url
|
||||||
|
|
||||||
|
except stripe.error.StripeError as e:
|
||||||
|
logger.error(f"Stripe error creating onboarding link: {e}")
|
||||||
|
raise PaymentConfigurationException(f"Failed to create onboarding link: {str(e)}")
|
||||||
|
|
||||||
|
def get_store_payment_config(self, store_id: int) -> StorePaymentConfig:
|
||||||
|
"""Get store payment configuration."""
|
||||||
|
config = self.db.query(StorePaymentConfig).filter(
|
||||||
|
StorePaymentConfig.store_id == store_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not config:
|
||||||
|
raise PaymentNotConfiguredException(f"No payment configuration for store {store_id}")
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
def webhook_handler(self, event_type: str, event_data: Dict) -> None:
|
||||||
|
"""Handle Stripe webhook events."""
|
||||||
|
|
||||||
|
if event_type == 'payment_intent.succeeded':
|
||||||
|
payment_intent_id = event_data['object']['id']
|
||||||
|
self.confirm_payment(payment_intent_id)
|
||||||
|
|
||||||
|
elif event_type == 'payment_intent.payment_failed':
|
||||||
|
payment_intent_id = event_data['object']['id']
|
||||||
|
self.confirm_payment(payment_intent_id)
|
||||||
|
|
||||||
|
elif event_type == 'account.updated':
|
||||||
|
# Update store account status
|
||||||
|
account_id = event_data['object']['id']
|
||||||
|
self.update_store_account_status(account_id, event_data['object'])
|
||||||
|
|
||||||
|
# Add more webhook handlers as needed
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Payment APIs
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app/api/v1/store/payments.py
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from middleware.store_context import require_store_context
|
||||||
|
from models.database.store import Store
|
||||||
|
from services.payment_service import PaymentService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/payments", tags=["store-payments"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
async def get_payment_config(
|
||||||
|
store: Store = Depends(require_store_context()),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get store payment configuration."""
|
||||||
|
payment_service = PaymentService(db)
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = payment_service.get_store_payment_config(store.id)
|
||||||
|
return {
|
||||||
|
"stripe_account_id": config.stripe_account_id,
|
||||||
|
"account_status": config.stripe_account_status,
|
||||||
|
"accepts_payments": config.accepts_payments,
|
||||||
|
"currency": config.currency,
|
||||||
|
"platform_fee_percentage": float(config.platform_fee_percentage),
|
||||||
|
"needs_onboarding": config.stripe_account_status != 'active'
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return {
|
||||||
|
"stripe_account_id": None,
|
||||||
|
"account_status": "not_configured",
|
||||||
|
"accepts_payments": False,
|
||||||
|
"needs_setup": True
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/setup")
|
||||||
|
async def setup_payments(
|
||||||
|
setup_data: dict,
|
||||||
|
store: Store = Depends(require_store_context()),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Set up Stripe payments for store."""
|
||||||
|
payment_service = PaymentService(db)
|
||||||
|
|
||||||
|
store_data = {
|
||||||
|
"business_name": store.name,
|
||||||
|
"business_email": store.business_email,
|
||||||
|
"business_phone": store.business_phone,
|
||||||
|
**setup_data
|
||||||
|
}
|
||||||
|
|
||||||
|
account_id = payment_service.create_store_stripe_account(store.id, store_data)
|
||||||
|
onboarding_url = payment_service.create_onboarding_link(store.id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"stripe_account_id": account_id,
|
||||||
|
"onboarding_url": onboarding_url,
|
||||||
|
"message": "Payment setup initiated. Complete onboarding to accept payments."
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# app/api/v1/platform/stores/payments.py
|
||||||
|
@router.post("/{store_id}/payments/create-intent")
|
||||||
|
async def create_payment_intent(
|
||||||
|
store_id: int,
|
||||||
|
payment_data: dict,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Create payment intent for customer order."""
|
||||||
|
payment_service = PaymentService(db)
|
||||||
|
|
||||||
|
payment_intent = payment_service.create_payment_intent(
|
||||||
|
store_id=store_id,
|
||||||
|
order_id=payment_data['order_id'],
|
||||||
|
amount_euros=Decimal(str(payment_data['amount'])),
|
||||||
|
customer_email=payment_data['customer_email'],
|
||||||
|
metadata=payment_data.get('metadata', {})
|
||||||
|
)
|
||||||
|
|
||||||
|
return payment_intent
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/webhooks/stripe")
|
||||||
|
async def stripe_webhook(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Handle Stripe webhook events."""
|
||||||
|
import stripe
|
||||||
|
|
||||||
|
payload = await request.body()
|
||||||
|
sig_header = request.headers.get('stripe-signature')
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = stripe.Webhook.construct_event(
|
||||||
|
payload, sig_header, settings.stripe_webhook_secret
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid payload")
|
||||||
|
except stripe.error.SignatureVerificationError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid signature")
|
||||||
|
|
||||||
|
payment_service = PaymentService(db)
|
||||||
|
payment_service.webhook_handler(event['type'], event['data'])
|
||||||
|
|
||||||
|
return {"status": "success"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Integration
|
||||||
|
|
||||||
|
### Checkout Process
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// frontend/js/storefront/checkout.js
|
||||||
|
class CheckoutManager {
|
||||||
|
constructor(storeId) {
|
||||||
|
this.storeId = storeId;
|
||||||
|
this.stripe = Stripe(STRIPE_PUBLISHABLE_KEY);
|
||||||
|
this.elements = this.stripe.elements();
|
||||||
|
this.paymentElement = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializePayment(orderData) {
|
||||||
|
// Create payment intent
|
||||||
|
const response = await fetch(`/api/v1/platform/stores/${this.storeId}/payments/create-intent`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
order_id: orderData.orderId,
|
||||||
|
amount: orderData.total,
|
||||||
|
customer_email: orderData.customerEmail
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const { client_secret, amount_total, platform_fee } = await response.json();
|
||||||
|
|
||||||
|
// Display payment breakdown
|
||||||
|
this.displayPaymentBreakdown(amount_total, platform_fee);
|
||||||
|
|
||||||
|
// Create payment element
|
||||||
|
this.paymentElement = this.elements.create('payment', {
|
||||||
|
clientSecret: client_secret
|
||||||
|
});
|
||||||
|
|
||||||
|
this.paymentElement.mount('#payment-element');
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmPayment(orderData) {
|
||||||
|
const { error } = await this.stripe.confirmPayment({
|
||||||
|
elements: this.elements,
|
||||||
|
confirmParams: {
|
||||||
|
return_url: `${window.location.origin}/storefront/order-confirmation`,
|
||||||
|
receipt_email: orderData.customerEmail
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
this.showPaymentError(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updated Workflow Integration
|
||||||
|
|
||||||
|
### Enhanced Customer Purchase Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
Customer adds products to cart
|
||||||
|
↓
|
||||||
|
Customer proceeds to checkout
|
||||||
|
↓
|
||||||
|
System creates Order (payment_status: pending)
|
||||||
|
↓
|
||||||
|
Frontend calls POST /api/v1/platform/stores/{store_id}/payments/create-intent
|
||||||
|
↓
|
||||||
|
PaymentService creates Stripe PaymentIntent with store destination
|
||||||
|
↓
|
||||||
|
Customer completes payment with Stripe Elements
|
||||||
|
↓
|
||||||
|
Stripe webhook confirms payment
|
||||||
|
↓
|
||||||
|
PaymentService updates Order (payment_status: paid, status: processing)
|
||||||
|
↓
|
||||||
|
Store receives order for fulfillment
|
||||||
|
```
|
||||||
|
|
||||||
|
### Payment Configuration Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
Store accesses payment settings
|
||||||
|
↓
|
||||||
|
POST /api/v1/store/payments/setup
|
||||||
|
↓
|
||||||
|
System creates Stripe Connect account
|
||||||
|
↓
|
||||||
|
Store completes Stripe onboarding
|
||||||
|
↓
|
||||||
|
Webhook updates account status to 'active'
|
||||||
|
↓
|
||||||
|
Store can now accept payments
|
||||||
|
```
|
||||||
|
|
||||||
|
This integration provides secure, compliant payment processing while maintaining store isolation and enabling proper revenue distribution between stores and the platform.
|
||||||
182
app/modules/billing/docs/subscription-system.md
Normal file
182
app/modules/billing/docs/subscription-system.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# Subscription & Billing System
|
||||||
|
|
||||||
|
The platform provides a comprehensive subscription and billing system for managing merchant subscriptions, feature-based usage limits, and payments through Stripe.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The billing system enables:
|
||||||
|
|
||||||
|
- **Subscription Tiers**: Database-driven tier definitions with configurable feature limits
|
||||||
|
- **Feature Provider Pattern**: Modules declare features and usage via `FeatureProviderProtocol`, aggregated by `FeatureAggregatorService`
|
||||||
|
- **Dynamic Usage Tracking**: Quantitative features (orders, products, team members) tracked per merchant with dynamic limits from `TierFeatureLimit`
|
||||||
|
- **Binary Feature Gating**: Toggle-based features (analytics, API access, white-label) controlled per tier
|
||||||
|
- **Merchant-Level Billing**: Subscriptions are per merchant+platform, not per store
|
||||||
|
- **Stripe Integration**: Checkout sessions, customer portal, and webhook handling
|
||||||
|
- **Add-ons**: Optional purchasable items (domains, SSL, email packages)
|
||||||
|
- **Capacity Forecasting**: Growth trends and scaling recommendations
|
||||||
|
- **Background Jobs**: Automated subscription lifecycle management
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Key Concepts
|
||||||
|
|
||||||
|
The billing system uses a **feature provider pattern** where:
|
||||||
|
|
||||||
|
1. **`TierFeatureLimit`** replaces hardcoded tier columns (`orders_per_month`, `products_limit`, `team_members`). Each feature limit is a row linking a tier to a feature code with a `limit_value`.
|
||||||
|
2. **`MerchantFeatureOverride`** provides per-merchant exceptions to tier defaults.
|
||||||
|
3. **Module feature providers** implement `FeatureProviderProtocol` to supply current usage data.
|
||||||
|
4. **`FeatureAggregatorService`** collects usage from all providers and combines it with tier limits to produce `FeatureSummary` records.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Frontend Page Request │
|
||||||
|
│ (Store Billing, Admin Subscriptions, Admin Store Detail) │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ FeatureAggregatorService │
|
||||||
|
│ (app/modules/billing/services/feature_service.py) │
|
||||||
|
│ │
|
||||||
|
│ • Collects feature providers from all enabled modules │
|
||||||
|
│ • Queries TierFeatureLimit for limit values │
|
||||||
|
│ • Queries MerchantFeatureOverride for per-merchant limits │
|
||||||
|
│ • Calls provider.get_current_usage() for live counts │
|
||||||
|
│ • Returns FeatureSummary[] with current/limit/percentage │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
|
||||||
|
│ catalog module │ │ orders module │ │ tenancy module │
|
||||||
|
│ products count │ │ orders count │ │ team members │
|
||||||
|
└────────────────┘ └────────────────┘ └────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Feature Types
|
||||||
|
|
||||||
|
| Type | Description | Example |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| **Quantitative** | Has a numeric limit with usage tracking | `max_products` (limit: 200, current: 150) |
|
||||||
|
| **Binary** | Toggle-based, either enabled or disabled | `analytics_dashboard` (enabled/disabled) |
|
||||||
|
|
||||||
|
### FeatureSummary Dataclass
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class FeatureSummary:
|
||||||
|
code: str # e.g., "max_products"
|
||||||
|
name_key: str # i18n key for display name
|
||||||
|
limit: int | None # None = unlimited
|
||||||
|
current: int # Current usage count
|
||||||
|
remaining: int # Remaining before limit
|
||||||
|
percent_used: float # 0.0 to 100.0
|
||||||
|
feature_type: str # "quantitative" or "binary"
|
||||||
|
scope: str # "tier" or "merchant_override"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Services
|
||||||
|
|
||||||
|
| Service | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `FeatureAggregatorService` | Aggregates usage from module providers, resolves tier limits + overrides |
|
||||||
|
| `BillingService` | Subscription operations, checkout, portal |
|
||||||
|
| `SubscriptionService` | Subscription CRUD, tier lookups |
|
||||||
|
| `AdminSubscriptionService` | Admin subscription management |
|
||||||
|
| `StripeService` | Core Stripe API operations |
|
||||||
|
| `CapacityForecastService` | Growth trends, projections |
|
||||||
|
|
||||||
|
### Background Tasks
|
||||||
|
|
||||||
|
| Task | Schedule | Purpose |
|
||||||
|
|------|----------|---------|
|
||||||
|
| `reset_period_counters` | Daily | Reset order counters at period end |
|
||||||
|
| `check_trial_expirations` | Daily | Expire trials without payment method |
|
||||||
|
| `sync_stripe_status` | Hourly | Sync status with Stripe |
|
||||||
|
| `cleanup_stale_subscriptions` | Weekly | Clean up old cancelled subscriptions |
|
||||||
|
| `capture_capacity_snapshot` | Daily | Capture capacity metrics snapshot |
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Store Billing API (`/api/v1/store/billing`)
|
||||||
|
|
||||||
|
| Endpoint | Method | Purpose |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `/subscription` | GET | Current subscription status |
|
||||||
|
| `/tiers` | GET | Available tiers for upgrade |
|
||||||
|
| `/usage` | GET | Dynamic usage metrics (from feature providers) |
|
||||||
|
| `/checkout` | POST | Create Stripe checkout session |
|
||||||
|
| `/portal` | POST | Create Stripe customer portal session |
|
||||||
|
| `/invoices` | GET | Invoice history |
|
||||||
|
| `/change-tier` | POST | Upgrade/downgrade tier |
|
||||||
|
| `/addons` | GET | Available add-on products |
|
||||||
|
| `/my-addons` | GET | Store's purchased add-ons |
|
||||||
|
| `/addons/purchase` | POST | Purchase an add-on |
|
||||||
|
| `/cancel` | POST | Cancel subscription |
|
||||||
|
| `/reactivate` | POST | Reactivate cancelled subscription |
|
||||||
|
|
||||||
|
### Admin Subscription API (`/api/v1/admin/subscriptions`)
|
||||||
|
|
||||||
|
| Endpoint | Method | Purpose |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `/tiers` | GET/POST | List/create tiers |
|
||||||
|
| `/tiers/{code}` | PATCH/DELETE | Update/delete tier |
|
||||||
|
| `/stats` | GET | Subscription statistics |
|
||||||
|
| `/merchants/{id}/platforms/{pid}` | GET/PUT | Get/update merchant subscription |
|
||||||
|
| `/store/{store_id}` | GET | Convenience: subscription + usage for a store |
|
||||||
|
|
||||||
|
### Admin Feature Management API (`/api/v1/admin/subscriptions/features`)
|
||||||
|
|
||||||
|
| Endpoint | Method | Purpose |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `/catalog` | GET | Feature catalog grouped by category |
|
||||||
|
| `/tiers/{code}/limits` | GET/PUT | Get/upsert feature limits for a tier |
|
||||||
|
| `/merchants/{id}/overrides` | GET/PUT | Get/upsert merchant feature overrides |
|
||||||
|
|
||||||
|
## Subscription Tiers
|
||||||
|
|
||||||
|
Tiers are stored in `subscription_tiers` with feature limits in `tier_feature_limits`:
|
||||||
|
|
||||||
|
```
|
||||||
|
SubscriptionTier (essential)
|
||||||
|
├── TierFeatureLimit: max_products = 200
|
||||||
|
├── TierFeatureLimit: max_orders_per_month = 100
|
||||||
|
├── TierFeatureLimit: max_team_members = 1
|
||||||
|
└── TierFeatureLimit: basic_analytics (binary, enabled)
|
||||||
|
|
||||||
|
SubscriptionTier (professional)
|
||||||
|
├── TierFeatureLimit: max_products = NULL (unlimited)
|
||||||
|
├── TierFeatureLimit: max_orders_per_month = 500
|
||||||
|
├── TierFeatureLimit: max_team_members = 3
|
||||||
|
└── TierFeatureLimit: analytics_dashboard (binary, enabled)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Add-ons
|
||||||
|
|
||||||
|
| Code | Name | Category | Price |
|
||||||
|
|------|------|----------|-------|
|
||||||
|
| `domain` | Custom Domain | domain | €15/year |
|
||||||
|
| `ssl_premium` | Premium SSL | ssl | €49/year |
|
||||||
|
| `email_5` | 5 Email Addresses | email | €5/month |
|
||||||
|
| `email_10` | 10 Email Addresses | email | €9/month |
|
||||||
|
| `email_25` | 25 Email Addresses | email | €19/month |
|
||||||
|
|
||||||
|
## Exception Handling
|
||||||
|
|
||||||
|
| Exception | HTTP | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `PaymentSystemNotConfiguredException` | 503 | Stripe not configured |
|
||||||
|
| `TierNotFoundException` | 404 | Invalid tier code |
|
||||||
|
| `StripePriceNotConfiguredException` | 400 | No Stripe price for tier |
|
||||||
|
| `NoActiveSubscriptionException` | 400 | Operation requires subscription |
|
||||||
|
| `SubscriptionNotCancelledException` | 400 | Cannot reactivate active subscription |
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Data Model](data-model.md) — Entity relationships
|
||||||
|
- [Feature Gating](feature-gating.md) — Feature access control and UI integration
|
||||||
|
- [Stripe Integration](stripe-integration.md) — Payment setup
|
||||||
|
- [Tier Management](tier-management.md) — Admin guide for tier management
|
||||||
|
- [Subscription Workflow](subscription-workflow.md) — Subscription lifecycle
|
||||||
|
- [Metrics Provider Pattern](../../architecture/metrics-provider-pattern.md) — Protocol-based metrics
|
||||||
|
- [Capacity Monitoring](../../operations/capacity-monitoring.md) — Monitoring guide
|
||||||
|
- [Capacity Planning](../../architecture/capacity-planning.md) — Infrastructure sizing
|
||||||
454
app/modules/billing/docs/subscription-workflow.md
Normal file
454
app/modules/billing/docs/subscription-workflow.md
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
# Subscription Workflow Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
End-to-end subscription management workflow for stores on the platform.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Store Subscribes to a Tier
|
||||||
|
|
||||||
|
### 1.1 New Store Registration Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Store Registration → Select Tier → Trial Period → Payment Setup → Active Subscription
|
||||||
|
```
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Store creates account (existing flow)
|
||||||
|
2. During onboarding, store selects a tier:
|
||||||
|
- Show tier comparison cards (Essential, Professional, Business, Enterprise)
|
||||||
|
- Highlight features and limits for each tier
|
||||||
|
- Default to 14-day trial on selected tier
|
||||||
|
3. Create `StoreSubscription` record with:
|
||||||
|
- `tier` = selected tier code
|
||||||
|
- `status` = "trial"
|
||||||
|
- `trial_ends_at` = now + 14 days
|
||||||
|
- `period_start` / `period_end` set for trial period
|
||||||
|
4. Before trial ends, prompt store to add payment method
|
||||||
|
5. On payment method added → Create Stripe subscription → Status becomes "active"
|
||||||
|
|
||||||
|
### 1.2 Database Changes Required
|
||||||
|
|
||||||
|
**Add FK relationship to `subscription_tiers`:**
|
||||||
|
```python
|
||||||
|
# StoreSubscription - Add proper FK
|
||||||
|
tier_id = Column(Integer, ForeignKey("subscription_tiers.id"), nullable=True)
|
||||||
|
tier_code = Column(String(20), nullable=False) # Keep for backwards compat
|
||||||
|
|
||||||
|
# Relationship
|
||||||
|
tier_obj = relationship("SubscriptionTier", backref="subscriptions")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Migration:**
|
||||||
|
1. Add `tier_id` column (nullable initially)
|
||||||
|
2. Populate `tier_id` from existing `tier` code values
|
||||||
|
3. Add FK constraint
|
||||||
|
|
||||||
|
### 1.3 API Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/api/v1/store/subscription/tiers` | GET | List available tiers for selection |
|
||||||
|
| `/api/v1/store/subscription/select-tier` | POST | Select tier during onboarding |
|
||||||
|
| `/api/v1/store/subscription/setup-payment` | POST | Create Stripe checkout for payment |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Admin Views Subscription on Store Page
|
||||||
|
|
||||||
|
### 2.1 Store Detail Page Enhancement
|
||||||
|
|
||||||
|
**Location:** `/admin/stores/{store_id}`
|
||||||
|
|
||||||
|
**New Subscription Card:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Subscription [Edit] │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Tier: Professional Status: Active │
|
||||||
|
│ Price: €99/month Since: Jan 15, 2025 │
|
||||||
|
│ Next Billing: Feb 15, 2025 │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Usage This Period │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Orders │ │ Products │ │ Team Members │ │
|
||||||
|
│ │ 234 / 500 │ │ 156 / ∞ │ │ 2 / 3 │ │
|
||||||
|
│ │ ████████░░ │ │ ████████████ │ │ ██████░░░░ │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Add-ons: Custom Domain (mydomain.com), 5 Email Addresses │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Files to Modify
|
||||||
|
|
||||||
|
- `app/templates/admin/store-detail.html` - Add subscription card
|
||||||
|
- `static/admin/js/store-detail.js` - Load subscription data
|
||||||
|
- `app/api/v1/admin/stores.py` - Include subscription in store response
|
||||||
|
|
||||||
|
### 2.3 Admin Quick Actions
|
||||||
|
|
||||||
|
From the store page, admin can:
|
||||||
|
- **Change Tier** - Upgrade/downgrade store
|
||||||
|
- **Override Limits** - Set custom limits (enterprise deals)
|
||||||
|
- **Extend Trial** - Give more trial days
|
||||||
|
- **Cancel Subscription** - With reason
|
||||||
|
- **Manage Add-ons** - Add/remove add-ons
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Tier Upgrade/Downgrade
|
||||||
|
|
||||||
|
### 3.1 Admin-Initiated Change
|
||||||
|
|
||||||
|
**Location:** Admin store page → Subscription card → [Edit] button
|
||||||
|
|
||||||
|
**Modal: Change Subscription Tier**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Change Subscription Tier [X] │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Current: Professional (€99/month) │
|
||||||
|
│ │
|
||||||
|
│ New Tier: │
|
||||||
|
│ ○ Essential (€49/month) - Downgrade │
|
||||||
|
│ ● Business (€199/month) - Upgrade │
|
||||||
|
│ ○ Enterprise (Custom) - Contact required │
|
||||||
|
│ │
|
||||||
|
│ When to apply: │
|
||||||
|
│ ○ Immediately (prorate current period) │
|
||||||
|
│ ● At next billing cycle (Feb 15, 2025) │
|
||||||
|
│ │
|
||||||
|
│ [ ] Notify store by email │
|
||||||
|
│ │
|
||||||
|
│ [Cancel] [Apply Change] │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Store-Initiated Change
|
||||||
|
|
||||||
|
**Location:** Store dashboard → Billing page → [Change Plan]
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. Store clicks "Change Plan" on billing page
|
||||||
|
2. Shows tier comparison with current tier highlighted
|
||||||
|
3. Store selects new tier
|
||||||
|
4. For upgrades:
|
||||||
|
- Show prorated amount for immediate change
|
||||||
|
- Or option to change at next billing
|
||||||
|
- Redirect to Stripe checkout if needed
|
||||||
|
5. For downgrades:
|
||||||
|
- Always schedule for next billing cycle
|
||||||
|
- Show what features they'll lose
|
||||||
|
- Confirmation required
|
||||||
|
|
||||||
|
### 3.3 API Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Actor | Description |
|
||||||
|
|----------|--------|-------|-------------|
|
||||||
|
| `/api/v1/admin/subscriptions/{store_id}/change-tier` | POST | Admin | Change store's tier |
|
||||||
|
| `/api/v1/store/billing/change-tier` | POST | Store | Request tier change |
|
||||||
|
| `/api/v1/store/billing/preview-change` | POST | Store | Preview proration |
|
||||||
|
|
||||||
|
### 3.4 Stripe Integration
|
||||||
|
|
||||||
|
**Upgrade (Immediate):**
|
||||||
|
```python
|
||||||
|
stripe.Subscription.modify(
|
||||||
|
subscription_id,
|
||||||
|
items=[{"price": new_price_id}],
|
||||||
|
proration_behavior="create_prorations"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Downgrade (Scheduled):**
|
||||||
|
```python
|
||||||
|
stripe.Subscription.modify(
|
||||||
|
subscription_id,
|
||||||
|
items=[{"price": new_price_id}],
|
||||||
|
proration_behavior="none",
|
||||||
|
billing_cycle_anchor="unchanged"
|
||||||
|
)
|
||||||
|
# Store scheduled change in our DB
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Add-ons Upselling
|
||||||
|
|
||||||
|
### 4.1 Where Add-ons Are Displayed
|
||||||
|
|
||||||
|
#### A. Store Billing Page
|
||||||
|
```
|
||||||
|
/store/{code}/billing
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Available Add-ons │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ ┌─────────────────────┐ ┌─────────────────────┐ │
|
||||||
|
│ │ 🌐 Custom Domain │ │ 📧 Email Package │ │
|
||||||
|
│ │ €15/year │ │ From €5/month │ │
|
||||||
|
│ │ Use your own domain │ │ 5, 10, or 25 emails │ │
|
||||||
|
│ │ [Add to Plan] │ │ [Add to Plan] │ │
|
||||||
|
│ └─────────────────────┘ └─────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────┐ ┌─────────────────────┐ │
|
||||||
|
│ │ 🔒 Premium SSL │ │ 💾 Extra Storage │ │
|
||||||
|
│ │ €49/year │ │ €5/month per 10GB │ │
|
||||||
|
│ │ EV certificate │ │ More product images │ │
|
||||||
|
│ │ [Add to Plan] │ │ [Add to Plan] │ │
|
||||||
|
│ └─────────────────────┘ └─────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B. Contextual Upsells
|
||||||
|
|
||||||
|
**When store hits a limit:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ ⚠️ You've reached your order limit for this month │
|
||||||
|
│ │
|
||||||
|
│ Upgrade to Professional to get 500 orders/month │
|
||||||
|
│ [Upgrade Now] [Dismiss] │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**In settings when configuring domain:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 🌐 Custom Domain │
|
||||||
|
│ │
|
||||||
|
│ Your shop is available at: myshop.platform.com │
|
||||||
|
│ │
|
||||||
|
│ Want to use your own domain like www.myshop.com? │
|
||||||
|
│ Add the Custom Domain add-on for just €15/year │
|
||||||
|
│ │
|
||||||
|
│ [Add Custom Domain] │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### C. Upgrade Prompts in Tier Comparison
|
||||||
|
|
||||||
|
When showing tier comparison, highlight what add-ons come included:
|
||||||
|
- Professional: Includes 1 custom domain
|
||||||
|
- Business: Includes custom domain + 5 email addresses
|
||||||
|
- Enterprise: All add-ons included
|
||||||
|
|
||||||
|
### 4.2 Add-on Purchase Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Store clicks [Add to Plan]
|
||||||
|
↓
|
||||||
|
Modal: Configure Add-on
|
||||||
|
- Domain: Enter domain name, check availability
|
||||||
|
- Email: Select package (5/10/25)
|
||||||
|
↓
|
||||||
|
Create Stripe checkout session for add-on price
|
||||||
|
↓
|
||||||
|
On success: Create StoreAddOn record
|
||||||
|
↓
|
||||||
|
Provision add-on (domain registration, email setup)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Add-on Management
|
||||||
|
|
||||||
|
**Store can view/manage in Billing page:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Your Add-ons │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Custom Domain myshop.com €15/year [Manage] │
|
||||||
|
│ Email Package 5 addresses €5/month [Manage] │
|
||||||
|
│ │
|
||||||
|
│ Next billing: Feb 15, 2025 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Database: `store_addons` Table
|
||||||
|
|
||||||
|
```python
|
||||||
|
class StoreAddOn(Base):
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
store_id = Column(Integer, ForeignKey("stores.id"))
|
||||||
|
addon_product_id = Column(Integer, ForeignKey("addon_products.id"))
|
||||||
|
|
||||||
|
# Config (e.g., domain name, email count)
|
||||||
|
config = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
# Stripe
|
||||||
|
stripe_subscription_item_id = Column(String(100))
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status = Column(String(20)) # active, cancelled, pending_setup
|
||||||
|
provisioned_at = Column(DateTime)
|
||||||
|
|
||||||
|
# Billing
|
||||||
|
quantity = Column(Integer, default=1)
|
||||||
|
|
||||||
|
created_at = Column(DateTime)
|
||||||
|
cancelled_at = Column(DateTime, nullable=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Implementation Phases
|
||||||
|
|
||||||
|
**Last Updated:** December 31, 2025
|
||||||
|
|
||||||
|
### Phase 1: Database & Core (COMPLETED)
|
||||||
|
- [x] Add `tier_id` FK to StoreSubscription
|
||||||
|
- [x] Create migration with data backfill
|
||||||
|
- [x] Update subscription service to use tier relationship
|
||||||
|
- [x] Update admin subscription endpoints
|
||||||
|
- [x] **NEW:** Add Feature model with 30 features across 8 categories
|
||||||
|
- [x] **NEW:** Create FeatureService with caching for tier-based feature checking
|
||||||
|
- [x] **NEW:** Add UsageService for limit tracking and upgrade recommendations
|
||||||
|
|
||||||
|
### Phase 2: Admin Store Page (PARTIALLY COMPLETE)
|
||||||
|
- [x] Add subscription card to store detail page
|
||||||
|
- [x] Show usage meters (orders, products, team)
|
||||||
|
- [ ] Add "Edit Subscription" modal
|
||||||
|
- [ ] Implement tier change API (admin)
|
||||||
|
- [x] **NEW:** Add Admin Features page (`/admin/features`)
|
||||||
|
- [x] **NEW:** Admin features API (list, update, toggle)
|
||||||
|
|
||||||
|
### Phase 3: Store Billing Page (COMPLETED)
|
||||||
|
- [x] Create `/store/{code}/billing` page
|
||||||
|
- [x] Show current plan and usage
|
||||||
|
- [x] Add tier comparison/change UI
|
||||||
|
- [x] Implement tier change API (store)
|
||||||
|
- [x] Add Stripe checkout integration for upgrades
|
||||||
|
- [x] **NEW:** Add feature gate macros for templates
|
||||||
|
- [x] **NEW:** Add Alpine.js feature store
|
||||||
|
- [x] **NEW:** Add Alpine.js upgrade prompts store
|
||||||
|
- [x] **FIX:** Resolved 89 JS architecture violations (JS-005 through JS-009)
|
||||||
|
|
||||||
|
### Phase 4: Add-ons (COMPLETED)
|
||||||
|
- [x] Seed add-on products in database
|
||||||
|
- [x] Add "Available Add-ons" section to billing page
|
||||||
|
- [x] Implement add-on purchase flow
|
||||||
|
- [x] Create StoreAddOn management (via billing page)
|
||||||
|
- [x] Add contextual upsell prompts
|
||||||
|
- [x] **FIX:** Fix Stripe webhook to create StoreAddOn records
|
||||||
|
|
||||||
|
### Phase 5: Polish & Testing (IN PROGRESS)
|
||||||
|
- [ ] Email notifications for tier changes
|
||||||
|
- [x] Webhook handling for Stripe events
|
||||||
|
- [x] Usage limit enforcement updates
|
||||||
|
- [ ] End-to-end testing (manual testing required)
|
||||||
|
- [x] Documentation (feature-gating-system.md created)
|
||||||
|
|
||||||
|
### Phase 6: Remaining Work (NEW)
|
||||||
|
- [ ] Admin tier change modal (upgrade/downgrade stores)
|
||||||
|
- [ ] Admin subscription override UI (custom limits for enterprise)
|
||||||
|
- [ ] Trial extension from admin panel
|
||||||
|
- [ ] Email notifications for tier changes
|
||||||
|
- [ ] Email notifications for approaching limits
|
||||||
|
- [ ] Grace period handling for failed payments
|
||||||
|
- [ ] Integration tests for full billing workflow
|
||||||
|
- [ ] Stripe test mode checkout verification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Files Created/Modified
|
||||||
|
|
||||||
|
**Last Updated:** December 31, 2025
|
||||||
|
|
||||||
|
### New Files (Created)
|
||||||
|
| File | Purpose | Status |
|
||||||
|
|------|---------|--------|
|
||||||
|
| `app/templates/store/billing.html` | Store billing page | DONE |
|
||||||
|
| `static/store/js/billing.js` | Billing page JS | DONE |
|
||||||
|
| `app/api/v1/store/billing.py` | Store billing endpoints | DONE |
|
||||||
|
| `models/database/feature.py` | Feature & StoreFeatureOverride models | DONE |
|
||||||
|
| `app/services/feature_service.py` | Feature access control service | DONE |
|
||||||
|
| `app/services/usage_service.py` | Usage tracking & limits service | DONE |
|
||||||
|
| `app/core/feature_gate.py` | @require_feature decorator & dependency | DONE |
|
||||||
|
| `app/api/v1/store/features.py` | Store features API | DONE |
|
||||||
|
| `app/api/v1/store/usage.py` | Store usage API | DONE |
|
||||||
|
| `app/api/v1/admin/features.py` | Admin features API | DONE |
|
||||||
|
| `app/templates/admin/features.html` | Admin features management page | DONE |
|
||||||
|
| `app/templates/shared/macros/feature_gate.html` | Jinja2 feature gate macros | DONE |
|
||||||
|
| `static/shared/js/feature-store.js` | Alpine.js feature store | DONE |
|
||||||
|
| `static/shared/js/upgrade-prompts.js` | Alpine.js upgrade prompts | DONE |
|
||||||
|
| `alembic/versions/n2c3d4e5f6a7_add_features_table.py` | Features migration | DONE |
|
||||||
|
| `docs/implementation/feature-gating-system.md` | Feature gating documentation | DONE |
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
| File | Changes | Status |
|
||||||
|
|------|---------|--------|
|
||||||
|
| `models/database/subscription.py` | Add tier_id FK | DONE |
|
||||||
|
| `models/database/__init__.py` | Export Feature models | DONE |
|
||||||
|
| `app/templates/admin/store-detail.html` | Add subscription card | DONE |
|
||||||
|
| `static/admin/js/store-detail.js` | Load subscription data | DONE |
|
||||||
|
| `app/api/v1/admin/stores.py` | Include subscription in response | DONE |
|
||||||
|
| `app/api/v1/admin/__init__.py` | Register features router | DONE |
|
||||||
|
| `app/api/v1/store/__init__.py` | Register features/usage routers | DONE |
|
||||||
|
| `app/services/subscription_service.py` | Tier change logic | DONE |
|
||||||
|
| `app/templates/store/partials/sidebar.html` | Add Billing link | DONE |
|
||||||
|
| `app/templates/store/base.html` | Load feature/upgrade stores | DONE |
|
||||||
|
| `app/templates/store/dashboard.html` | Add tier badge & usage bars | DONE |
|
||||||
|
| `app/handlers/stripe_webhook.py` | Create StoreAddOn on purchase | DONE |
|
||||||
|
| `app/routes/admin_pages.py` | Add features page route | DONE |
|
||||||
|
| `static/shared/js/api-client.js` | Add postFormData() & getBlob() | DONE |
|
||||||
|
|
||||||
|
### Architecture Fixes (48 files)
|
||||||
|
| Rule | Files Fixed | Description |
|
||||||
|
|------|-------------|-------------|
|
||||||
|
| JS-003 | billing.js | Rename billingData→storeBilling |
|
||||||
|
| JS-005 | 15 files | Add init guards |
|
||||||
|
| JS-006 | 39 files | Add try/catch to async init |
|
||||||
|
| JS-008 | 5 files | Use apiClient not fetch |
|
||||||
|
| JS-009 | 30 files | Use Utils.showToast |
|
||||||
|
| TPL-009 | validate_architecture.py | Check store templates too |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. API Summary
|
||||||
|
|
||||||
|
### Admin APIs
|
||||||
|
```
|
||||||
|
GET /admin/stores/{id} # Includes subscription
|
||||||
|
POST /admin/subscriptions/{store_id}/change-tier
|
||||||
|
POST /admin/subscriptions/{store_id}/override-limits
|
||||||
|
POST /admin/subscriptions/{store_id}/extend-trial
|
||||||
|
POST /admin/subscriptions/{store_id}/cancel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Store APIs
|
||||||
|
```
|
||||||
|
GET /store/billing/subscription # Current subscription
|
||||||
|
GET /store/billing/tiers # Available tiers
|
||||||
|
POST /store/billing/preview-change # Preview tier change
|
||||||
|
POST /store/billing/change-tier # Request tier change
|
||||||
|
POST /store/billing/checkout # Stripe checkout session
|
||||||
|
|
||||||
|
GET /store/billing/addons # Available add-ons
|
||||||
|
GET /store/billing/my-addons # Store's add-ons
|
||||||
|
POST /store/billing/addons/purchase # Purchase add-on
|
||||||
|
DELETE /store/billing/addons/{id} # Cancel add-on
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Questions to Resolve
|
||||||
|
|
||||||
|
1. **Trial without payment method?**
|
||||||
|
- Allow full trial without card, or require card upfront?
|
||||||
|
|
||||||
|
2. **Downgrade handling:**
|
||||||
|
- What happens if store has more products than new tier allows?
|
||||||
|
- Block downgrade, or just prevent new products?
|
||||||
|
|
||||||
|
3. **Enterprise tier:**
|
||||||
|
- Self-service or contact sales only?
|
||||||
|
- Custom pricing in UI or hidden?
|
||||||
|
|
||||||
|
4. **Add-on provisioning:**
|
||||||
|
- Domain: Use reseller API or manual process?
|
||||||
|
- Email: Integrate with email provider or manual?
|
||||||
|
|
||||||
|
5. **Grace period:**
|
||||||
|
- How long after payment failure before suspension?
|
||||||
|
- What gets disabled first?
|
||||||
135
app/modules/billing/docs/tier-management.md
Normal file
135
app/modules/billing/docs/tier-management.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Subscription Tier Management
|
||||||
|
|
||||||
|
This guide explains how to manage subscription tiers and assign features to them in the admin panel.
|
||||||
|
|
||||||
|
## Accessing Tier Management
|
||||||
|
|
||||||
|
Navigate to **Admin → Billing & Subscriptions → Subscription Tiers** or go directly to `/admin/subscription-tiers`.
|
||||||
|
|
||||||
|
## Dashboard Overview
|
||||||
|
|
||||||
|
The tier management page displays:
|
||||||
|
|
||||||
|
### Stats Cards
|
||||||
|
- **Total Tiers**: Number of configured subscription tiers
|
||||||
|
- **Active Tiers**: Tiers currently available for subscription
|
||||||
|
- **Public Tiers**: Tiers visible to stores (excludes enterprise/custom)
|
||||||
|
- **Est. MRR**: Estimated Monthly Recurring Revenue
|
||||||
|
|
||||||
|
### Tier Table
|
||||||
|
|
||||||
|
Each tier shows:
|
||||||
|
|
||||||
|
| Column | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| # | Display order (affects pricing page order) |
|
||||||
|
| Code | Unique identifier (e.g., `essential`, `professional`) |
|
||||||
|
| Name | Display name shown to stores |
|
||||||
|
| Monthly | Monthly price in EUR |
|
||||||
|
| Annual | Annual price in EUR (or `-` if not set) |
|
||||||
|
| Orders/Mo | Monthly order limit (or `Unlimited`) |
|
||||||
|
| Products | Product limit (or `Unlimited`) |
|
||||||
|
| Team | Team member limit (or `Unlimited`) |
|
||||||
|
| Features | Number of features assigned |
|
||||||
|
| Status | Active, Private, or Inactive |
|
||||||
|
| Actions | Edit Features, Edit, Activate/Deactivate |
|
||||||
|
|
||||||
|
## Managing Tiers
|
||||||
|
|
||||||
|
### Creating a New Tier
|
||||||
|
|
||||||
|
1. Click **Create Tier** button
|
||||||
|
2. Fill in the tier details:
|
||||||
|
- **Code**: Unique lowercase identifier (cannot be changed after creation)
|
||||||
|
- **Name**: Display name for the tier
|
||||||
|
- **Monthly Price**: Price in cents (e.g., 4900 for €49.00)
|
||||||
|
- **Annual Price**: Optional annual price in cents
|
||||||
|
- **Order Limit**: Leave empty for unlimited
|
||||||
|
- **Product Limit**: Leave empty for unlimited
|
||||||
|
- **Team Members**: Leave empty for unlimited
|
||||||
|
- **Display Order**: Controls sort order on pricing pages
|
||||||
|
- **Active**: Whether tier is available
|
||||||
|
- **Public**: Whether tier is visible to stores
|
||||||
|
3. Click **Create**
|
||||||
|
|
||||||
|
### Editing a Tier
|
||||||
|
|
||||||
|
1. Click the **pencil icon** on the tier row
|
||||||
|
2. Modify the tier properties
|
||||||
|
3. Click **Update**
|
||||||
|
|
||||||
|
Note: The tier code cannot be changed after creation.
|
||||||
|
|
||||||
|
### Activating/Deactivating Tiers
|
||||||
|
|
||||||
|
- Click the **check-circle icon** to activate an inactive tier
|
||||||
|
- Click the **x-circle icon** to deactivate an active tier
|
||||||
|
|
||||||
|
Deactivating a tier:
|
||||||
|
- Does not affect existing subscriptions
|
||||||
|
- Hides the tier from new subscription selection
|
||||||
|
- Can be reactivated at any time
|
||||||
|
|
||||||
|
## Managing Features
|
||||||
|
|
||||||
|
### Assigning Features to a Tier
|
||||||
|
|
||||||
|
1. Click the **puzzle-piece icon** on the tier row
|
||||||
|
2. A slide-over panel opens showing all available features
|
||||||
|
3. Features are grouped by category:
|
||||||
|
- Analytics
|
||||||
|
- Product Management
|
||||||
|
- Order Management
|
||||||
|
- Marketing
|
||||||
|
- Support
|
||||||
|
- Integration
|
||||||
|
- Branding
|
||||||
|
- Team
|
||||||
|
|
||||||
|
4. Check/uncheck features to include in the tier
|
||||||
|
5. Use **Select all** or **Deselect all** per category for bulk actions
|
||||||
|
6. The footer shows the total number of selected features
|
||||||
|
7. Click **Save Features** to apply changes
|
||||||
|
|
||||||
|
### Feature Categories
|
||||||
|
|
||||||
|
| Category | Example Features |
|
||||||
|
|----------|------------------|
|
||||||
|
| Analytics | Basic Analytics, Analytics Dashboard, Custom Reports |
|
||||||
|
| Product Management | Bulk Edit, Variants, Bundles, Inventory Alerts |
|
||||||
|
| Order Management | Order Automation, Advanced Fulfillment, Multi-Warehouse |
|
||||||
|
| Marketing | Discount Codes, Abandoned Cart, Email Marketing, Loyalty |
|
||||||
|
| Support | Email Support, Priority Support, Phone Support, Dedicated Manager |
|
||||||
|
| Integration | Basic API, Advanced API, Webhooks, Custom Integrations |
|
||||||
|
| Branding | Theme Customization, Custom Domain, White Label |
|
||||||
|
| Team | Team Management, Role Permissions, Audit Logs |
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Tier Pricing Strategy
|
||||||
|
|
||||||
|
1. **Essential**: Entry-level with basic features and limits
|
||||||
|
2. **Professional**: Mid-tier with increased limits and key integrations
|
||||||
|
3. **Business**: Full-featured for growing businesses
|
||||||
|
4. **Enterprise**: Custom pricing with unlimited everything
|
||||||
|
|
||||||
|
### Feature Assignment Tips
|
||||||
|
|
||||||
|
- Start with fewer features in lower tiers
|
||||||
|
- Ensure each upgrade tier adds meaningful value
|
||||||
|
- Keep support features as upgrade incentives
|
||||||
|
- API access typically belongs in Business+ tiers
|
||||||
|
|
||||||
|
### Stripe Integration
|
||||||
|
|
||||||
|
For each tier, you can optionally configure:
|
||||||
|
- **Stripe Product ID**: Link to Stripe product
|
||||||
|
- **Stripe Monthly Price ID**: Link to monthly price
|
||||||
|
- **Stripe Annual Price ID**: Link to annual price
|
||||||
|
|
||||||
|
These are required for automated billing via Stripe Checkout.
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Subscription & Billing System](subscription-system.md) - Complete billing documentation
|
||||||
|
- [Feature Gating System](feature-gating.md) - Technical feature gating details
|
||||||
@@ -134,5 +134,17 @@
|
|||||||
"invoices": "Rechnungen",
|
"invoices": "Rechnungen",
|
||||||
"account_settings": "Kontoeinstellungen",
|
"account_settings": "Kontoeinstellungen",
|
||||||
"billing": "Abrechnung"
|
"billing": "Abrechnung"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"view_tiers": "Tarife anzeigen",
|
||||||
|
"view_tiers_desc": "Details der Abonnement-Tarife anzeigen",
|
||||||
|
"manage_tiers": "Tarife verwalten",
|
||||||
|
"manage_tiers_desc": "Abonnement-Tarife erstellen und konfigurieren",
|
||||||
|
"view_subscriptions": "Abonnements anzeigen",
|
||||||
|
"view_subscriptions_desc": "Abonnementdetails anzeigen",
|
||||||
|
"manage_subscriptions": "Abonnements verwalten",
|
||||||
|
"manage_subscriptions_desc": "Abonnements und Abrechnung verwalten",
|
||||||
|
"view_invoices": "Rechnungen anzeigen",
|
||||||
|
"view_invoices_desc": "Rechnungen und Abrechnungsverlauf anzeigen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,18 @@
|
|||||||
"current": "Current Plan",
|
"current": "Current Plan",
|
||||||
"recommended": "Recommended"
|
"recommended": "Recommended"
|
||||||
},
|
},
|
||||||
|
"permissions": {
|
||||||
|
"view_tiers": "View Tiers",
|
||||||
|
"view_tiers_desc": "View subscription tier details",
|
||||||
|
"manage_tiers": "Manage Tiers",
|
||||||
|
"manage_tiers_desc": "Create and configure subscription tiers",
|
||||||
|
"view_subscriptions": "View Subscriptions",
|
||||||
|
"view_subscriptions_desc": "View store subscription details",
|
||||||
|
"manage_subscriptions": "Manage Subscriptions",
|
||||||
|
"manage_subscriptions_desc": "Manage store subscriptions and billing",
|
||||||
|
"view_invoices": "View Invoices",
|
||||||
|
"view_invoices_desc": "View billing invoices and history"
|
||||||
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"subscription_updated": "Subscription updated successfully",
|
"subscription_updated": "Subscription updated successfully",
|
||||||
"tier_created": "Tier created successfully",
|
"tier_created": "Tier created successfully",
|
||||||
|
|||||||
@@ -134,5 +134,17 @@
|
|||||||
"invoices": "Factures",
|
"invoices": "Factures",
|
||||||
"account_settings": "Paramètres du compte",
|
"account_settings": "Paramètres du compte",
|
||||||
"billing": "Facturation"
|
"billing": "Facturation"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"view_tiers": "Voir les niveaux",
|
||||||
|
"view_tiers_desc": "Voir les détails des niveaux d'abonnement",
|
||||||
|
"manage_tiers": "Gérer les niveaux",
|
||||||
|
"manage_tiers_desc": "Créer et configurer les niveaux d'abonnement",
|
||||||
|
"view_subscriptions": "Voir les abonnements",
|
||||||
|
"view_subscriptions_desc": "Voir les détails des abonnements",
|
||||||
|
"manage_subscriptions": "Gérer les abonnements",
|
||||||
|
"manage_subscriptions_desc": "Gérer les abonnements et la facturation",
|
||||||
|
"view_invoices": "Voir les factures",
|
||||||
|
"view_invoices_desc": "Voir les factures et l'historique de facturation"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,5 +134,17 @@
|
|||||||
"invoices": "Rechnungen",
|
"invoices": "Rechnungen",
|
||||||
"account_settings": "Kont-Astellungen",
|
"account_settings": "Kont-Astellungen",
|
||||||
"billing": "Ofrechnung"
|
"billing": "Ofrechnung"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"view_tiers": "Tariffer kucken",
|
||||||
|
"view_tiers_desc": "Detailer vun den Abonnement-Tariffer kucken",
|
||||||
|
"manage_tiers": "Tariffer verwalten",
|
||||||
|
"manage_tiers_desc": "Abonnement-Tariffer erstellen a konfiguréieren",
|
||||||
|
"view_subscriptions": "Abonnementer kucken",
|
||||||
|
"view_subscriptions_desc": "Abonnementdetailer kucken",
|
||||||
|
"manage_subscriptions": "Abonnementer verwalten",
|
||||||
|
"manage_subscriptions_desc": "Abonnementer an Ofrechnung verwalten",
|
||||||
|
"view_invoices": "Rechnunge kucken",
|
||||||
|
"view_invoices_desc": "Rechnungen an Ofrechnungsverlaf kucken"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ def upgrade() -> None:
|
|||||||
sa.Column("platform_id", sa.Integer(), sa.ForeignKey("platforms.id", ondelete="CASCADE"), nullable=False, index=True, comment="Reference to the platform"),
|
sa.Column("platform_id", sa.Integer(), sa.ForeignKey("platforms.id", ondelete="CASCADE"), nullable=False, index=True, comment="Reference to the platform"),
|
||||||
sa.Column("tier_id", sa.Integer(), sa.ForeignKey("subscription_tiers.id", ondelete="SET NULL"), nullable=True, index=True, comment="Platform-specific subscription tier"),
|
sa.Column("tier_id", sa.Integer(), sa.ForeignKey("subscription_tiers.id", ondelete="SET NULL"), nullable=True, index=True, comment="Platform-specific subscription tier"),
|
||||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true", comment="Whether the store is active on this platform"),
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true", comment="Whether the store is active on this platform"),
|
||||||
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="false", comment="Whether this is the store's primary platform"),
|
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="false", comment="Whether this is the store's primary platform"), # Removed in migration remove_is_primary_001
|
||||||
sa.Column("custom_subdomain", sa.String(100), nullable=True, comment="Platform-specific subdomain (if different from main subdomain)"),
|
sa.Column("custom_subdomain", sa.String(100), nullable=True, comment="Platform-specific subdomain (if different from main subdomain)"),
|
||||||
sa.Column("settings", sa.JSON(), nullable=True, server_default="{}", comment="Platform-specific store settings"),
|
sa.Column("settings", sa.JSON(), nullable=True, server_default="{}", comment="Platform-specific store settings"),
|
||||||
sa.Column("joined_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, comment="When the store joined this platform"),
|
sa.Column("joined_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, comment="When the store joined this platform"),
|
||||||
@@ -53,7 +53,7 @@ def upgrade() -> None:
|
|||||||
sa.UniqueConstraint("store_id", "platform_id", name="uq_store_platform"),
|
sa.UniqueConstraint("store_id", "platform_id", name="uq_store_platform"),
|
||||||
)
|
)
|
||||||
op.create_index("idx_store_platform_active", "store_platforms", ["store_id", "platform_id", "is_active"])
|
op.create_index("idx_store_platform_active", "store_platforms", ["store_id", "platform_id", "is_active"])
|
||||||
op.create_index("idx_store_platform_primary", "store_platforms", ["store_id", "is_primary"])
|
op.create_index("idx_store_platform_primary", "store_platforms", ["store_id", "is_primary"]) # Removed in migration remove_is_primary_001
|
||||||
|
|
||||||
# --- tier_feature_limits ---
|
# --- tier_feature_limits ---
|
||||||
op.create_table(
|
op.create_table(
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""add name_translations to subscription_tiers
|
||||||
|
|
||||||
|
Revision ID: billing_002
|
||||||
|
Revises: hosting_001
|
||||||
|
Create Date: 2026-03-03
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "billing_002"
|
||||||
|
down_revision = "hosting_001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"subscription_tiers",
|
||||||
|
sa.Column(
|
||||||
|
"name_translations",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=True,
|
||||||
|
comment="Language-keyed name dict for multi-language support",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("subscription_tiers", "name_translations")
|
||||||
@@ -100,6 +100,12 @@ class SubscriptionTier(Base, TimestampMixin):
|
|||||||
|
|
||||||
code = Column(String(30), nullable=False, index=True)
|
code = Column(String(30), nullable=False, index=True)
|
||||||
name = Column(String(100), nullable=False)
|
name = Column(String(100), nullable=False)
|
||||||
|
name_translations = Column(
|
||||||
|
JSON,
|
||||||
|
nullable=True,
|
||||||
|
default=None,
|
||||||
|
comment="Language-keyed name dict for multi-language support",
|
||||||
|
)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
|
|
||||||
# Pricing (in cents for precision)
|
# Pricing (in cents for precision)
|
||||||
@@ -154,6 +160,16 @@ class SubscriptionTier(Base, TimestampMixin):
|
|||||||
"""Check if this tier includes a specific feature."""
|
"""Check if this tier includes a specific feature."""
|
||||||
return feature_code in self.get_feature_codes()
|
return feature_code in self.get_feature_codes()
|
||||||
|
|
||||||
|
def get_translated_name(self, lang: str, default_lang: str = "fr") -> str:
|
||||||
|
"""Get name in the given language, falling back to default_lang then self.name."""
|
||||||
|
if self.name_translations:
|
||||||
|
return (
|
||||||
|
self.name_translations.get(lang)
|
||||||
|
or self.name_translations.get(default_lang)
|
||||||
|
or self.name
|
||||||
|
)
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# AddOnProduct - Purchasable add-ons
|
# AddOnProduct - Purchasable add-ons
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Each main router (admin.py, store.py) aggregates its related sub-routers interna
|
|||||||
Merchant routes are auto-discovered from merchant.py.
|
Merchant routes are auto-discovered from merchant.py.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from app.modules.billing.routes.api.admin import admin_router
|
from app.modules.billing.routes.api.admin import router as admin_router
|
||||||
from app.modules.billing.routes.api.store import store_router
|
from app.modules.billing.routes.api.store import router as store_router
|
||||||
|
|
||||||
__all__ = ["admin_router", "store_router"]
|
__all__ = ["admin_router", "store_router"]
|
||||||
|
|||||||
@@ -35,12 +35,12 @@ from app.modules.billing.services import (
|
|||||||
subscription_service,
|
subscription_service,
|
||||||
)
|
)
|
||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
from models.schema.auth import UserContext
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Admin router with module access control
|
# Admin router with module access control
|
||||||
admin_router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/subscriptions",
|
prefix="/subscriptions",
|
||||||
dependencies=[Depends(require_module_access("billing", FrontendType.ADMIN))],
|
dependencies=[Depends(require_module_access("billing", FrontendType.ADMIN))],
|
||||||
)
|
)
|
||||||
@@ -51,7 +51,7 @@ admin_router = APIRouter(
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("/tiers", response_model=SubscriptionTierListResponse)
|
@router.get("/tiers", response_model=SubscriptionTierListResponse)
|
||||||
def list_subscription_tiers(
|
def list_subscription_tiers(
|
||||||
include_inactive: bool = Query(False, description="Include inactive tiers"),
|
include_inactive: bool = Query(False, description="Include inactive tiers"),
|
||||||
platform_id: int | None = Query(None, description="Filter tiers by platform"),
|
platform_id: int | None = Query(None, description="Filter tiers by platform"),
|
||||||
@@ -75,7 +75,7 @@ def list_subscription_tiers(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("/tiers/{tier_code}", response_model=SubscriptionTierResponse)
|
@router.get("/tiers/{tier_code}", response_model=SubscriptionTierResponse)
|
||||||
def get_subscription_tier(
|
def get_subscription_tier(
|
||||||
tier_code: str = Path(..., description="Tier code"),
|
tier_code: str = Path(..., description="Tier code"),
|
||||||
current_user: UserContext = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
@@ -88,7 +88,7 @@ def get_subscription_tier(
|
|||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@admin_router.post("/tiers", response_model=SubscriptionTierResponse, status_code=201)
|
@router.post("/tiers", response_model=SubscriptionTierResponse, status_code=201)
|
||||||
def create_subscription_tier(
|
def create_subscription_tier(
|
||||||
tier_data: SubscriptionTierCreate,
|
tier_data: SubscriptionTierCreate,
|
||||||
current_user: UserContext = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
@@ -103,7 +103,7 @@ def create_subscription_tier(
|
|||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@admin_router.patch("/tiers/{tier_code}", response_model=SubscriptionTierResponse)
|
@router.patch("/tiers/{tier_code}", response_model=SubscriptionTierResponse)
|
||||||
def update_subscription_tier(
|
def update_subscription_tier(
|
||||||
tier_data: SubscriptionTierUpdate,
|
tier_data: SubscriptionTierUpdate,
|
||||||
tier_code: str = Path(..., description="Tier code"),
|
tier_code: str = Path(..., description="Tier code"),
|
||||||
@@ -120,7 +120,7 @@ def update_subscription_tier(
|
|||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@admin_router.delete("/tiers/{tier_code}", status_code=204)
|
@router.delete("/tiers/{tier_code}", status_code=204)
|
||||||
def delete_subscription_tier(
|
def delete_subscription_tier(
|
||||||
tier_code: str = Path(..., description="Tier code"),
|
tier_code: str = Path(..., description="Tier code"),
|
||||||
current_user: UserContext = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
@@ -136,7 +136,7 @@ def delete_subscription_tier(
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("", response_model=MerchantSubscriptionListResponse)
|
@router.get("", response_model=MerchantSubscriptionListResponse)
|
||||||
def list_merchant_subscriptions(
|
def list_merchant_subscriptions(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
per_page: int = Query(20, ge=1, le=100),
|
per_page: int = Query(20, ge=1, le=100),
|
||||||
@@ -175,7 +175,7 @@ def list_merchant_subscriptions(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("/merchants/{merchant_id}")
|
@router.get("/merchants/{merchant_id}")
|
||||||
def get_merchant_subscriptions(
|
def get_merchant_subscriptions(
|
||||||
merchant_id: int = Path(..., description="Merchant ID"),
|
merchant_id: int = Path(..., description="Merchant ID"),
|
||||||
current_user: UserContext = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
@@ -185,10 +185,10 @@ def get_merchant_subscriptions(
|
|||||||
results = admin_subscription_service.get_merchant_subscriptions_with_usage(
|
results = admin_subscription_service.get_merchant_subscriptions_with_usage(
|
||||||
db, merchant_id
|
db, merchant_id
|
||||||
)
|
)
|
||||||
return {"subscriptions": results}
|
return {"subscriptions": results} # noqa: API001
|
||||||
|
|
||||||
|
|
||||||
@admin_router.post(
|
@router.post(
|
||||||
"/merchants/{merchant_id}/platforms/{platform_id}",
|
"/merchants/{merchant_id}/platforms/{platform_id}",
|
||||||
response_model=MerchantSubscriptionAdminResponse,
|
response_model=MerchantSubscriptionAdminResponse,
|
||||||
status_code=201,
|
status_code=201,
|
||||||
@@ -226,7 +226,7 @@ def create_merchant_subscription(
|
|||||||
return MerchantSubscriptionAdminResponse.model_validate(sub)
|
return MerchantSubscriptionAdminResponse.model_validate(sub)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get(
|
@router.get(
|
||||||
"/merchants/{merchant_id}/platforms/{platform_id}",
|
"/merchants/{merchant_id}/platforms/{platform_id}",
|
||||||
response_model=MerchantSubscriptionAdminResponse,
|
response_model=MerchantSubscriptionAdminResponse,
|
||||||
)
|
)
|
||||||
@@ -243,7 +243,7 @@ def get_merchant_subscription(
|
|||||||
return MerchantSubscriptionAdminResponse.model_validate(sub)
|
return MerchantSubscriptionAdminResponse.model_validate(sub)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.patch(
|
@router.patch(
|
||||||
"/merchants/{merchant_id}/platforms/{platform_id}",
|
"/merchants/{merchant_id}/platforms/{platform_id}",
|
||||||
response_model=MerchantSubscriptionAdminResponse,
|
response_model=MerchantSubscriptionAdminResponse,
|
||||||
)
|
)
|
||||||
@@ -270,7 +270,7 @@ def update_merchant_subscription(
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("/store/{store_id}")
|
@router.get("/store/{store_id}")
|
||||||
def get_subscription_for_store(
|
def get_subscription_for_store(
|
||||||
store_id: int = Path(..., description="Store ID"),
|
store_id: int = Path(..., description="Store ID"),
|
||||||
current_user: UserContext = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
@@ -284,7 +284,7 @@ def get_subscription_for_store(
|
|||||||
of subscription entries with feature usage metrics.
|
of subscription entries with feature usage metrics.
|
||||||
"""
|
"""
|
||||||
results = admin_subscription_service.get_subscriptions_for_store(db, store_id)
|
results = admin_subscription_service.get_subscriptions_for_store(db, store_id)
|
||||||
return {"subscriptions": results}
|
return {"subscriptions": results} # noqa: API001
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -292,7 +292,7 @@ def get_subscription_for_store(
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("/stats", response_model=SubscriptionStatsResponse)
|
@router.get("/stats", response_model=SubscriptionStatsResponse)
|
||||||
def get_subscription_stats(
|
def get_subscription_stats(
|
||||||
current_user: UserContext = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -307,7 +307,7 @@ def get_subscription_stats(
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("/billing/history", response_model=BillingHistoryListResponse)
|
@router.get("/billing/history", response_model=BillingHistoryListResponse)
|
||||||
def list_billing_history(
|
def list_billing_history(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
per_page: int = Query(20, ge=1, le=100),
|
per_page: int = Query(20, ge=1, le=100),
|
||||||
@@ -360,4 +360,4 @@ def list_billing_history(
|
|||||||
# Include the features router to aggregate all billing-related admin routes
|
# Include the features router to aggregate all billing-related admin routes
|
||||||
from app.modules.billing.routes.api.admin_features import admin_features_router
|
from app.modules.billing.routes.api.admin_features import admin_features_router
|
||||||
|
|
||||||
admin_router.include_router(admin_features_router, tags=["admin-features"])
|
router.include_router(admin_features_router, tags=["admin-features"])
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from app.modules.billing.schemas import (
|
|||||||
from app.modules.billing.services.feature_aggregator import feature_aggregator
|
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.feature_service import feature_service
|
||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
from models.schema.auth import UserContext
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
|
|
||||||
admin_features_router = APIRouter(
|
admin_features_router = APIRouter(
|
||||||
prefix="/features",
|
prefix="/features",
|
||||||
@@ -86,11 +86,11 @@ def get_feature_catalog(
|
|||||||
|
|
||||||
|
|
||||||
@admin_features_router.get(
|
@admin_features_router.get(
|
||||||
"/tiers/{tier_code}/limits",
|
"/tiers/{tier_id}/limits",
|
||||||
response_model=list[TierFeatureLimitEntry],
|
response_model=list[TierFeatureLimitEntry],
|
||||||
)
|
)
|
||||||
def get_tier_feature_limits(
|
def get_tier_feature_limits(
|
||||||
tier_code: str = Path(..., description="Tier code"),
|
tier_id: int = Path(..., description="Tier ID"),
|
||||||
current_user: UserContext = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -100,7 +100,7 @@ def get_tier_feature_limits(
|
|||||||
Returns all TierFeatureLimit rows associated with the tier,
|
Returns all TierFeatureLimit rows associated with the tier,
|
||||||
each containing a feature_code and its optional limit_value.
|
each containing a feature_code and its optional limit_value.
|
||||||
"""
|
"""
|
||||||
rows = feature_service.get_tier_feature_limits(db, tier_code)
|
rows = feature_service.get_tier_feature_limits(db, tier_id)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
TierFeatureLimitEntry(
|
TierFeatureLimitEntry(
|
||||||
@@ -113,12 +113,12 @@ def get_tier_feature_limits(
|
|||||||
|
|
||||||
|
|
||||||
@admin_features_router.put(
|
@admin_features_router.put(
|
||||||
"/tiers/{tier_code}/limits",
|
"/tiers/{tier_id}/limits",
|
||||||
response_model=list[TierFeatureLimitEntry],
|
response_model=list[TierFeatureLimitEntry],
|
||||||
)
|
)
|
||||||
def upsert_tier_feature_limits(
|
def upsert_tier_feature_limits(
|
||||||
entries: list[TierFeatureLimitEntry],
|
entries: list[TierFeatureLimitEntry],
|
||||||
tier_code: str = Path(..., description="Tier code"),
|
tier_id: int = Path(..., description="Tier ID"),
|
||||||
current_user: UserContext = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -136,15 +136,15 @@ def upsert_tier_feature_limits(
|
|||||||
raise InvalidFeatureCodesError(invalid_codes)
|
raise InvalidFeatureCodesError(invalid_codes)
|
||||||
|
|
||||||
new_rows = feature_service.upsert_tier_feature_limits(
|
new_rows = feature_service.upsert_tier_feature_limits(
|
||||||
db, tier_code, [e.model_dump() for e in entries]
|
db, tier_id, [e.model_dump() for e in entries]
|
||||||
)
|
)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Admin %s replaced tier '%s' feature limits (%d entries)",
|
"Admin %s replaced tier %d feature limits (%d entries)",
|
||||||
current_user.id,
|
current_user.id,
|
||||||
tier_code,
|
tier_id,
|
||||||
len(new_rows),
|
len(new_rows),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ from app.modules.billing.schemas.billing import (
|
|||||||
)
|
)
|
||||||
from app.modules.billing.services import billing_service, subscription_service
|
from app.modules.billing.services import billing_service, subscription_service
|
||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
from models.schema.auth import UserContext
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Store router with module access control
|
# Store router with module access control
|
||||||
store_router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/billing",
|
prefix="/billing",
|
||||||
dependencies=[Depends(require_module_access("billing", FrontendType.STORE))],
|
dependencies=[Depends(require_module_access("billing", FrontendType.STORE))],
|
||||||
)
|
)
|
||||||
@@ -39,14 +39,14 @@ store_router = APIRouter(
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@store_router.get("/subscription", response_model=SubscriptionStatusResponse)
|
@router.get("/subscription", response_model=SubscriptionStatusResponse)
|
||||||
def get_subscription_status(
|
def get_subscription_status(
|
||||||
current_user: UserContext = Depends(get_current_store_api),
|
current_user: UserContext = Depends(get_current_store_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get current subscription status."""
|
"""Get current subscription status."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
subscription, tier = billing_service.get_subscription_with_tier(db, merchant_id, platform_id)
|
subscription, tier = billing_service.get_subscription_with_tier(db, merchant_id, platform_id)
|
||||||
|
|
||||||
@@ -76,14 +76,14 @@ def get_subscription_status(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@store_router.get("/tiers", response_model=TierListResponse)
|
@router.get("/tiers", response_model=TierListResponse)
|
||||||
def get_available_tiers(
|
def get_available_tiers(
|
||||||
current_user: UserContext = Depends(get_current_store_api),
|
current_user: UserContext = Depends(get_current_store_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get available subscription tiers for upgrade/downgrade."""
|
"""Get available subscription tiers for upgrade/downgrade."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
subscription = subscription_service.get_or_create_subscription(db, merchant_id, platform_id)
|
subscription = subscription_service.get_or_create_subscription(db, merchant_id, platform_id)
|
||||||
current_tier_id = subscription.tier_id
|
current_tier_id = subscription.tier_id
|
||||||
@@ -96,7 +96,7 @@ def get_available_tiers(
|
|||||||
return TierListResponse(tiers=tier_responses, current_tier=current_tier_code)
|
return TierListResponse(tiers=tier_responses, current_tier=current_tier_code)
|
||||||
|
|
||||||
|
|
||||||
@store_router.get("/invoices", response_model=InvoiceListResponse)
|
@router.get("/invoices", response_model=InvoiceListResponse)
|
||||||
def get_invoices(
|
def get_invoices(
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(20, ge=1, le=100),
|
limit: int = Query(20, ge=1, le=100),
|
||||||
@@ -105,7 +105,7 @@ def get_invoices(
|
|||||||
):
|
):
|
||||||
"""Get invoice history."""
|
"""Get invoice history."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
invoices, total = billing_service.get_invoices(db, merchant_id, skip=skip, limit=limit)
|
invoices, total = billing_service.get_invoices(db, merchant_id, skip=skip, limit=limit)
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ from app.modules.billing.routes.api.store_checkout import store_checkout_router
|
|||||||
from app.modules.billing.routes.api.store_features import store_features_router
|
from app.modules.billing.routes.api.store_features import store_features_router
|
||||||
from app.modules.billing.routes.api.store_usage import store_usage_router
|
from app.modules.billing.routes.api.store_usage import store_usage_router
|
||||||
|
|
||||||
store_router.include_router(store_features_router, tags=["store-features"])
|
router.include_router(store_features_router, tags=["store-features"])
|
||||||
store_router.include_router(store_checkout_router, tags=["store-billing"])
|
router.include_router(store_checkout_router, tags=["store-billing"])
|
||||||
store_router.include_router(store_addons_router, tags=["store-billing-addons"])
|
router.include_router(store_addons_router, tags=["store-billing-addons"])
|
||||||
store_router.include_router(store_usage_router, tags=["store-usage"])
|
router.include_router(store_usage_router, tags=["store-usage"])
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from app.core.config import settings
|
|||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.modules.billing.services import billing_service
|
from app.modules.billing.services import billing_service
|
||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
from models.schema.auth import UserContext
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
|
|
||||||
store_addons_router = APIRouter(
|
store_addons_router = APIRouter(
|
||||||
prefix="/addons",
|
prefix="/addons",
|
||||||
@@ -144,7 +144,7 @@ def purchase_addon(
|
|||||||
store = billing_service.get_store(db, store_id)
|
store = billing_service.get_store(db, store_id)
|
||||||
|
|
||||||
# Build URLs
|
# Build URLs
|
||||||
base_url = f"https://{settings.platform_domain}"
|
base_url = settings.app_base_url.rstrip("/")
|
||||||
success_url = f"{base_url}/store/{store.store_code}/billing?addon_success=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"
|
cancel_url = f"{base_url}/store/{store.store_code}/billing?addon_cancelled=true"
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ from app.modules.billing.schemas.billing import (
|
|||||||
from app.modules.billing.services import billing_service
|
from app.modules.billing.services import billing_service
|
||||||
from app.modules.billing.services.subscription_service import subscription_service
|
from app.modules.billing.services.subscription_service import subscription_service
|
||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
from models.schema.auth import UserContext
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
|
|
||||||
store_checkout_router = APIRouter(
|
store_checkout_router = APIRouter(
|
||||||
dependencies=[Depends(require_module_access("billing", FrontendType.STORE))],
|
dependencies=[Depends(require_module_access("billing", FrontendType.STORE))],
|
||||||
@@ -55,11 +55,11 @@ def create_checkout_session(
|
|||||||
):
|
):
|
||||||
"""Create a Stripe checkout session for subscription."""
|
"""Create a Stripe checkout session for subscription."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
store_code = subscription_service.get_store_code(db, store_id)
|
store_code = subscription_service.get_store_code(db, store_id)
|
||||||
|
|
||||||
base_url = f"https://{settings.platform_domain}"
|
base_url = settings.app_base_url.rstrip("/")
|
||||||
success_url = f"{base_url}/store/{store_code}/billing?success=true"
|
success_url = f"{base_url}/store/{store_code}/billing?success=true"
|
||||||
cancel_url = f"{base_url}/store/{store_code}/billing?cancelled=true"
|
cancel_url = f"{base_url}/store/{store_code}/billing?cancelled=true"
|
||||||
|
|
||||||
@@ -84,10 +84,10 @@ def create_portal_session(
|
|||||||
):
|
):
|
||||||
"""Create a Stripe customer portal session."""
|
"""Create a Stripe customer portal session."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
store_code = subscription_service.get_store_code(db, store_id)
|
store_code = subscription_service.get_store_code(db, store_id)
|
||||||
return_url = f"https://{settings.platform_domain}/store/{store_code}/billing"
|
return_url = f"{settings.app_base_url.rstrip('/')}/store/{store_code}/billing"
|
||||||
|
|
||||||
result = billing_service.create_portal_session(db, merchant_id, platform_id, return_url)
|
result = billing_service.create_portal_session(db, merchant_id, platform_id, return_url)
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ def cancel_subscription(
|
|||||||
):
|
):
|
||||||
"""Cancel subscription."""
|
"""Cancel subscription."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
result = billing_service.cancel_subscription(
|
result = billing_service.cancel_subscription(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -126,7 +126,7 @@ def reactivate_subscription(
|
|||||||
):
|
):
|
||||||
"""Reactivate a cancelled subscription."""
|
"""Reactivate a cancelled subscription."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
result = billing_service.reactivate_subscription(db, merchant_id, platform_id)
|
result = billing_service.reactivate_subscription(db, merchant_id, platform_id)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -141,7 +141,7 @@ def get_upcoming_invoice(
|
|||||||
):
|
):
|
||||||
"""Preview the upcoming invoice."""
|
"""Preview the upcoming invoice."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
result = billing_service.get_upcoming_invoice(db, merchant_id, platform_id)
|
result = billing_service.get_upcoming_invoice(db, merchant_id, platform_id)
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ def change_tier(
|
|||||||
):
|
):
|
||||||
"""Change subscription tier (upgrade/downgrade)."""
|
"""Change subscription tier (upgrade/downgrade)."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
result = billing_service.change_tier(
|
result = billing_service.change_tier(
|
||||||
db=db,
|
db=db,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ 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.feature_service import feature_service
|
||||||
from app.modules.billing.services.subscription_service import subscription_service
|
from app.modules.billing.services.subscription_service import subscription_service
|
||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
from models.schema.auth import UserContext
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
|
|
||||||
store_features_router = APIRouter(
|
store_features_router = APIRouter(
|
||||||
prefix="/features",
|
prefix="/features",
|
||||||
@@ -95,7 +95,7 @@ def get_available_features(
|
|||||||
List of feature codes the store has access to
|
List of feature codes the store has access to
|
||||||
"""
|
"""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
# Get available feature codes
|
# Get available feature codes
|
||||||
feature_codes = feature_service.get_merchant_feature_codes(db, merchant_id, platform_id)
|
feature_codes = feature_service.get_merchant_feature_codes(db, merchant_id, platform_id)
|
||||||
@@ -134,7 +134,7 @@ def get_features(
|
|||||||
List of features with metadata and availability
|
List of features with metadata and availability
|
||||||
"""
|
"""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
# Get all declarations and available codes
|
# Get all declarations and available codes
|
||||||
all_declarations = feature_aggregator.get_all_declarations()
|
all_declarations = feature_aggregator.get_all_declarations()
|
||||||
@@ -197,7 +197,7 @@ def get_features_grouped(
|
|||||||
Useful for rendering feature comparison tables or settings pages.
|
Useful for rendering feature comparison tables or settings pages.
|
||||||
"""
|
"""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
# Get declarations grouped by category and available codes
|
# Get declarations grouped by category and available codes
|
||||||
by_category = feature_aggregator.get_declarations_by_category()
|
by_category = feature_aggregator.get_declarations_by_category()
|
||||||
@@ -246,7 +246,9 @@ def check_feature(
|
|||||||
has_feature and feature_code
|
has_feature and feature_code
|
||||||
"""
|
"""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
has = feature_service.has_feature_for_store(db, store_id, feature_code)
|
has = feature_service.has_feature_for_store(
|
||||||
|
db, store_id, feature_code, platform_id=current_user.token_platform_id
|
||||||
|
)
|
||||||
|
|
||||||
return StoreFeatureCheckResponse(has_feature=has, feature_code=feature_code)
|
return StoreFeatureCheckResponse(has_feature=has, feature_code=feature_code)
|
||||||
|
|
||||||
@@ -270,7 +272,7 @@ def get_feature_detail(
|
|||||||
Feature details with upgrade info if locked
|
Feature details with upgrade info if locked
|
||||||
"""
|
"""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
# Get feature declaration
|
# Get feature declaration
|
||||||
decl = feature_aggregator.get_declaration(feature_code)
|
decl = feature_aggregator.get_declaration(feature_code)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from app.api.deps import get_current_store_api, require_module_access
|
|||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.modules.billing.services.usage_service import usage_service
|
from app.modules.billing.services.usage_service import usage_service
|
||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
from models.schema.auth import UserContext
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
|
|
||||||
store_usage_router = APIRouter(
|
store_usage_router = APIRouter(
|
||||||
prefix="/usage",
|
prefix="/usage",
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ from app.api.deps import get_current_merchant_from_cookie_or_header
|
|||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.modules.core.utils.page_context import get_context_for_frontend
|
from app.modules.core.utils.page_context import get_context_for_frontend
|
||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
from app.templates_config import templates
|
from app.templates_config import templates
|
||||||
from models.schema.auth import UserContext
|
|
||||||
|
|
||||||
ROUTE_CONFIG = {
|
ROUTE_CONFIG = {
|
||||||
"prefix": "/billing",
|
"prefix": "/billing",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Platform (unauthenticated) pages for pricing and signup:
|
|||||||
- Signup success
|
- Signup success
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -16,22 +16,30 @@ from app.core.database import get_db
|
|||||||
from app.modules.core.utils.page_context import get_platform_context
|
from app.modules.core.utils.page_context import get_platform_context
|
||||||
from app.templates_config import templates
|
from app.templates_config import templates
|
||||||
|
|
||||||
|
|
||||||
|
def _require_platform(request: Request):
|
||||||
|
"""Get the current platform or raise 404. Platform must always be known."""
|
||||||
|
platform = getattr(request.state, "platform", None)
|
||||||
|
if not platform:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Platform not detected. Pricing and signup require a known platform.",
|
||||||
|
)
|
||||||
|
return platform
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _get_tiers_data(db: Session) -> list[dict]:
|
def _get_tiers_data(db: Session, platform_id: int) -> list[dict]:
|
||||||
"""Build tier data for display in templates from database."""
|
"""Build tier data for display in templates from database."""
|
||||||
from app.modules.billing.models import SubscriptionTier, TierCode
|
from app.modules.billing.models import SubscriptionTier, TierCode
|
||||||
|
|
||||||
tiers_db = (
|
query = db.query(SubscriptionTier).filter(
|
||||||
db.query(SubscriptionTier)
|
SubscriptionTier.is_active == True,
|
||||||
.filter(
|
SubscriptionTier.is_public == True,
|
||||||
SubscriptionTier.is_active == True,
|
SubscriptionTier.platform_id == platform_id,
|
||||||
SubscriptionTier.is_public == True,
|
|
||||||
)
|
|
||||||
.order_by(SubscriptionTier.display_order)
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
|
tiers_db = query.order_by(SubscriptionTier.display_order).all()
|
||||||
|
|
||||||
tiers = []
|
tiers = []
|
||||||
for tier in tiers_db:
|
for tier in tiers_db:
|
||||||
@@ -63,9 +71,12 @@ async def pricing_page(
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Standalone pricing page with detailed tier comparison.
|
Standalone pricing page with detailed tier comparison.
|
||||||
|
|
||||||
|
Tiers are filtered by the current platform (detected from domain/path).
|
||||||
"""
|
"""
|
||||||
|
platform = _require_platform(request)
|
||||||
context = get_platform_context(request, db)
|
context = get_platform_context(request, db)
|
||||||
context["tiers"] = _get_tiers_data(db)
|
context["tiers"] = _get_tiers_data(db, platform_id=platform.id)
|
||||||
context["page_title"] = "Pricing"
|
context["page_title"] = "Pricing"
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
@@ -89,15 +100,19 @@ async def signup_page(
|
|||||||
"""
|
"""
|
||||||
Multi-step signup wizard.
|
Multi-step signup wizard.
|
||||||
|
|
||||||
|
Routes to platform-specific signup templates. Each platform defines
|
||||||
|
its own signup flow (different steps, different UI).
|
||||||
|
|
||||||
Query params:
|
Query params:
|
||||||
- tier: Pre-selected tier code
|
- tier: Pre-selected tier code
|
||||||
- annual: Pre-select annual billing
|
- annual: Pre-select annual billing
|
||||||
"""
|
"""
|
||||||
|
platform = _require_platform(request)
|
||||||
context = get_platform_context(request, db)
|
context = get_platform_context(request, db)
|
||||||
context["page_title"] = "Start Your Free Trial"
|
context["page_title"] = "Start Your Free Trial"
|
||||||
context["selected_tier"] = tier
|
context["selected_tier"] = tier
|
||||||
context["is_annual"] = annual
|
context["is_annual"] = annual
|
||||||
context["tiers"] = _get_tiers_data(db)
|
context["tiers"] = _get_tiers_data(db, platform_id=platform.id)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"billing/platform/signup.html",
|
"billing/platform/signup.html",
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ Store pages for billing management:
|
|||||||
- Invoices
|
- Invoices
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Path, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_store_from_cookie_or_header, get_db
|
from app.api.deps import (
|
||||||
|
get_current_store_from_cookie_or_header,
|
||||||
|
get_db,
|
||||||
|
get_resolved_store_code,
|
||||||
|
)
|
||||||
from app.modules.core.utils.page_context import get_store_context
|
from app.modules.core.utils.page_context import get_store_context
|
||||||
from app.modules.tenancy.models import User
|
from app.modules.tenancy.models import User
|
||||||
from app.templates_config import templates
|
from app.templates_config import templates
|
||||||
@@ -25,11 +29,11 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/billing", response_class=HTMLResponse, include_in_schema=False
|
"/billing", response_class=HTMLResponse, include_in_schema=False
|
||||||
)
|
)
|
||||||
async def store_billing_page(
|
async def store_billing_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -44,11 +48,11 @@ async def store_billing_page(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/invoices", response_class=HTMLResponse, include_in_schema=False
|
"/invoices", response_class=HTMLResponse, include_in_schema=False
|
||||||
)
|
)
|
||||||
async def store_invoices_page(
|
async def store_invoices_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -217,5 +217,3 @@ class MerchantPortalInvoiceListResponse(BaseModel):
|
|||||||
total: int
|
total: int
|
||||||
skip: int
|
skip: int
|
||||||
limit: int
|
limit: int
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ from app.modules.billing.services.platform_pricing_service import (
|
|||||||
PlatformPricingService,
|
PlatformPricingService,
|
||||||
platform_pricing_service,
|
platform_pricing_service,
|
||||||
)
|
)
|
||||||
|
from app.modules.billing.services.signup_service import (
|
||||||
|
SignupService,
|
||||||
|
signup_service,
|
||||||
|
)
|
||||||
from app.modules.billing.services.store_platform_sync_service import (
|
from app.modules.billing.services.store_platform_sync_service import (
|
||||||
StorePlatformSync,
|
StorePlatformSync,
|
||||||
store_platform_sync,
|
store_platform_sync,
|
||||||
@@ -65,4 +69,6 @@ __all__ = [
|
|||||||
"TierInfoData",
|
"TierInfoData",
|
||||||
"UpgradeTierData",
|
"UpgradeTierData",
|
||||||
"LimitCheckData",
|
"LimitCheckData",
|
||||||
|
"SignupService",
|
||||||
|
"signup_service",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import logging
|
|||||||
from math import ceil
|
from math import ceil
|
||||||
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from app.exceptions import (
|
from app.exceptions import (
|
||||||
BusinessLogicException,
|
BusinessLogicException,
|
||||||
@@ -27,7 +27,7 @@ from app.modules.billing.models import (
|
|||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
SubscriptionTier,
|
SubscriptionTier,
|
||||||
)
|
)
|
||||||
from app.modules.tenancy.models import Merchant
|
from app.modules.tenancy.exceptions import PlatformNotFoundException
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -35,6 +35,141 @@ logger = logging.getLogger(__name__)
|
|||||||
class AdminSubscriptionService:
|
class AdminSubscriptionService:
|
||||||
"""Service for admin subscription management operations."""
|
"""Service for admin subscription management operations."""
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Stripe Tier Sync
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sync_tier_to_stripe(db: Session, tier: SubscriptionTier) -> None:
|
||||||
|
"""
|
||||||
|
Sync a tier's Stripe product and prices.
|
||||||
|
|
||||||
|
Creates or verifies the Stripe Product and Price objects, and
|
||||||
|
populates the stripe_product_id, stripe_price_monthly_id, and
|
||||||
|
stripe_price_annual_id fields on the tier.
|
||||||
|
|
||||||
|
Skips gracefully if Stripe is not configured (dev mode).
|
||||||
|
Stripe Prices are immutable — on price changes, new Prices are
|
||||||
|
created and old ones archived.
|
||||||
|
"""
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
if not settings.stripe_secret_key:
|
||||||
|
logger.debug(
|
||||||
|
f"Stripe not configured, skipping sync for tier {tier.code}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
import stripe
|
||||||
|
|
||||||
|
stripe.api_key = settings.stripe_secret_key
|
||||||
|
|
||||||
|
# Resolve platform name for product naming
|
||||||
|
platform_name = "Platform"
|
||||||
|
if tier.platform_id:
|
||||||
|
from app.modules.tenancy.services.platform_service import (
|
||||||
|
platform_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
platform = platform_service.get_platform_by_id(db, tier.platform_id)
|
||||||
|
platform_name = platform.name
|
||||||
|
except Exception: # noqa: EXC-003
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --- Product ---
|
||||||
|
if tier.stripe_product_id:
|
||||||
|
# Verify it still exists in Stripe
|
||||||
|
try:
|
||||||
|
stripe.Product.retrieve(tier.stripe_product_id)
|
||||||
|
except stripe.InvalidRequestError:
|
||||||
|
logger.warning(
|
||||||
|
f"Stripe product {tier.stripe_product_id} not found, "
|
||||||
|
f"recreating for tier {tier.code}"
|
||||||
|
)
|
||||||
|
tier.stripe_product_id = None
|
||||||
|
|
||||||
|
if not tier.stripe_product_id:
|
||||||
|
product = stripe.Product.create(
|
||||||
|
name=f"{platform_name} - {tier.name}",
|
||||||
|
metadata={
|
||||||
|
"tier_code": tier.code,
|
||||||
|
"platform_id": str(tier.platform_id or ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tier.stripe_product_id = product.id
|
||||||
|
logger.info(
|
||||||
|
f"Created Stripe product {product.id} for tier {tier.code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Monthly Price ---
|
||||||
|
if tier.price_monthly_cents:
|
||||||
|
if tier.stripe_price_monthly_id:
|
||||||
|
# Verify price matches; if not, create new one
|
||||||
|
try:
|
||||||
|
existing = stripe.Price.retrieve(tier.stripe_price_monthly_id)
|
||||||
|
if existing.unit_amount != tier.price_monthly_cents:
|
||||||
|
# Price changed — archive old, create new
|
||||||
|
stripe.Price.modify(
|
||||||
|
tier.stripe_price_monthly_id, active=False
|
||||||
|
)
|
||||||
|
tier.stripe_price_monthly_id = None
|
||||||
|
logger.info(
|
||||||
|
f"Archived old monthly price for tier {tier.code}"
|
||||||
|
)
|
||||||
|
except stripe.InvalidRequestError:
|
||||||
|
tier.stripe_price_monthly_id = None
|
||||||
|
|
||||||
|
if not tier.stripe_price_monthly_id:
|
||||||
|
price = stripe.Price.create(
|
||||||
|
product=tier.stripe_product_id,
|
||||||
|
unit_amount=tier.price_monthly_cents,
|
||||||
|
currency="eur",
|
||||||
|
recurring={"interval": "month"},
|
||||||
|
metadata={
|
||||||
|
"tier_code": tier.code,
|
||||||
|
"billing_period": "monthly",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tier.stripe_price_monthly_id = price.id
|
||||||
|
logger.info(
|
||||||
|
f"Created Stripe monthly price {price.id} "
|
||||||
|
f"for tier {tier.code} ({tier.price_monthly_cents} cents)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Annual Price ---
|
||||||
|
if tier.price_annual_cents:
|
||||||
|
if tier.stripe_price_annual_id:
|
||||||
|
try:
|
||||||
|
existing = stripe.Price.retrieve(tier.stripe_price_annual_id)
|
||||||
|
if existing.unit_amount != tier.price_annual_cents:
|
||||||
|
stripe.Price.modify(
|
||||||
|
tier.stripe_price_annual_id, active=False
|
||||||
|
)
|
||||||
|
tier.stripe_price_annual_id = None
|
||||||
|
logger.info(
|
||||||
|
f"Archived old annual price for tier {tier.code}"
|
||||||
|
)
|
||||||
|
except stripe.InvalidRequestError:
|
||||||
|
tier.stripe_price_annual_id = None
|
||||||
|
|
||||||
|
if not tier.stripe_price_annual_id:
|
||||||
|
price = stripe.Price.create(
|
||||||
|
product=tier.stripe_product_id,
|
||||||
|
unit_amount=tier.price_annual_cents,
|
||||||
|
currency="eur",
|
||||||
|
recurring={"interval": "year"},
|
||||||
|
metadata={
|
||||||
|
"tier_code": tier.code,
|
||||||
|
"billing_period": "annual",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tier.stripe_price_annual_id = price.id
|
||||||
|
logger.info(
|
||||||
|
f"Created Stripe annual price {price.id} "
|
||||||
|
f"for tier {tier.code} ({tier.price_annual_cents} cents)"
|
||||||
|
)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Subscription Tiers
|
# Subscription Tiers
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -85,6 +220,9 @@ class AdminSubscriptionService:
|
|||||||
|
|
||||||
tier = SubscriptionTier(**tier_data)
|
tier = SubscriptionTier(**tier_data)
|
||||||
db.add(tier)
|
db.add(tier)
|
||||||
|
db.flush() # Get tier.id before Stripe sync
|
||||||
|
|
||||||
|
self._sync_tier_to_stripe(db, tier)
|
||||||
|
|
||||||
logger.info(f"Created subscription tier: {tier.code}")
|
logger.info(f"Created subscription tier: {tier.code}")
|
||||||
return tier
|
return tier
|
||||||
@@ -95,9 +233,21 @@ class AdminSubscriptionService:
|
|||||||
"""Update a subscription tier."""
|
"""Update a subscription tier."""
|
||||||
tier = self.get_tier_by_code(db, tier_code)
|
tier = self.get_tier_by_code(db, tier_code)
|
||||||
|
|
||||||
|
# Track price changes to know if Stripe sync is needed
|
||||||
|
price_changed = (
|
||||||
|
"price_monthly_cents" in update_data
|
||||||
|
and update_data["price_monthly_cents"] != tier.price_monthly_cents
|
||||||
|
) or (
|
||||||
|
"price_annual_cents" in update_data
|
||||||
|
and update_data["price_annual_cents"] != tier.price_annual_cents
|
||||||
|
)
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(tier, field, value)
|
setattr(tier, field, value)
|
||||||
|
|
||||||
|
if price_changed or not tier.stripe_product_id:
|
||||||
|
self._sync_tier_to_stripe(db, tier)
|
||||||
|
|
||||||
logger.info(f"Updated subscription tier: {tier.code}")
|
logger.info(f"Updated subscription tier: {tier.code}")
|
||||||
return tier
|
return tier
|
||||||
|
|
||||||
@@ -143,8 +293,9 @@ class AdminSubscriptionService:
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""List merchant subscriptions with filtering and pagination."""
|
"""List merchant subscriptions with filtering and pagination."""
|
||||||
query = (
|
query = (
|
||||||
db.query(MerchantSubscription, Merchant)
|
db.query(MerchantSubscription)
|
||||||
.join(Merchant, MerchantSubscription.merchant_id == Merchant.id)
|
.join(MerchantSubscription.merchant)
|
||||||
|
.options(joinedload(MerchantSubscription.merchant))
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
@@ -155,20 +306,35 @@ class AdminSubscriptionService:
|
|||||||
SubscriptionTier, MerchantSubscription.tier_id == SubscriptionTier.id
|
SubscriptionTier, MerchantSubscription.tier_id == SubscriptionTier.id
|
||||||
).filter(SubscriptionTier.code == tier)
|
).filter(SubscriptionTier.code == tier)
|
||||||
if search:
|
if search:
|
||||||
query = query.filter(Merchant.name.ilike(f"%{search}%"))
|
from app.modules.tenancy.services.merchant_service import merchant_service
|
||||||
|
|
||||||
|
merchants, _ = merchant_service.get_merchants(db, search=search, limit=10000)
|
||||||
|
merchant_ids = [m.id for m in merchants]
|
||||||
|
if not merchant_ids:
|
||||||
|
return {
|
||||||
|
"results": [],
|
||||||
|
"total": 0,
|
||||||
|
"page": page,
|
||||||
|
"per_page": per_page,
|
||||||
|
"pages": 0,
|
||||||
|
}
|
||||||
|
query = query.filter(MerchantSubscription.merchant_id.in_(merchant_ids))
|
||||||
|
|
||||||
# Count total
|
# Count total
|
||||||
total = query.count()
|
total = query.count()
|
||||||
|
|
||||||
# Paginate
|
# Paginate
|
||||||
offset = (page - 1) * per_page
|
offset = (page - 1) * per_page
|
||||||
results = (
|
subs = (
|
||||||
query.order_by(MerchantSubscription.created_at.desc())
|
query.order_by(MerchantSubscription.created_at.desc())
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(per_page)
|
.limit(per_page)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Return (sub, merchant) tuples for backward compatibility with callers
|
||||||
|
results = [(sub, sub.merchant) for sub in subs]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"results": results,
|
"results": results,
|
||||||
"total": total,
|
"total": total,
|
||||||
@@ -181,9 +347,9 @@ class AdminSubscriptionService:
|
|||||||
self, db: Session, merchant_id: int, platform_id: int
|
self, db: Session, merchant_id: int, platform_id: int
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
"""Get subscription for a specific merchant on a platform."""
|
"""Get subscription for a specific merchant on a platform."""
|
||||||
result = (
|
sub = (
|
||||||
db.query(MerchantSubscription, Merchant)
|
db.query(MerchantSubscription)
|
||||||
.join(Merchant, MerchantSubscription.merchant_id == Merchant.id)
|
.options(joinedload(MerchantSubscription.merchant))
|
||||||
.filter(
|
.filter(
|
||||||
MerchantSubscription.merchant_id == merchant_id,
|
MerchantSubscription.merchant_id == merchant_id,
|
||||||
MerchantSubscription.platform_id == platform_id,
|
MerchantSubscription.platform_id == platform_id,
|
||||||
@@ -191,13 +357,13 @@ class AdminSubscriptionService:
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
if not result:
|
if not sub:
|
||||||
raise ResourceNotFoundException(
|
raise ResourceNotFoundException(
|
||||||
"Subscription",
|
"Subscription",
|
||||||
f"merchant_id={merchant_id}, platform_id={platform_id}",
|
f"merchant_id={merchant_id}, platform_id={platform_id}",
|
||||||
)
|
)
|
||||||
|
|
||||||
return result
|
return sub, sub.merchant
|
||||||
|
|
||||||
def update_subscription(
|
def update_subscription(
|
||||||
self, db: Session, merchant_id: int, platform_id: int, update_data: dict
|
self, db: Session, merchant_id: int, platform_id: int, update_data: dict
|
||||||
@@ -242,10 +408,7 @@ class AdminSubscriptionService:
|
|||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""List billing history across all merchants."""
|
"""List billing history across all merchants."""
|
||||||
query = (
|
query = db.query(BillingHistory)
|
||||||
db.query(BillingHistory, Merchant)
|
|
||||||
.join(Merchant, BillingHistory.merchant_id == Merchant.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
if merchant_id:
|
if merchant_id:
|
||||||
query = query.filter(BillingHistory.merchant_id == merchant_id)
|
query = query.filter(BillingHistory.merchant_id == merchant_id)
|
||||||
@@ -255,13 +418,29 @@ class AdminSubscriptionService:
|
|||||||
total = query.count()
|
total = query.count()
|
||||||
|
|
||||||
offset = (page - 1) * per_page
|
offset = (page - 1) * per_page
|
||||||
results = (
|
invoices = (
|
||||||
query.order_by(BillingHistory.invoice_date.desc())
|
query.order_by(BillingHistory.invoice_date.desc())
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(per_page)
|
.limit(per_page)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Batch-fetch merchant names for display
|
||||||
|
from app.modules.tenancy.services.merchant_service import merchant_service
|
||||||
|
|
||||||
|
merchant_ids = {inv.merchant_id for inv in invoices if inv.merchant_id}
|
||||||
|
merchants_map = {}
|
||||||
|
for mid in merchant_ids:
|
||||||
|
m = merchant_service.get_merchant_by_id_optional(db, mid)
|
||||||
|
if m:
|
||||||
|
merchants_map[mid] = m
|
||||||
|
|
||||||
|
# Return (invoice, merchant) tuples for backward compatibility
|
||||||
|
results = [
|
||||||
|
(inv, merchants_map.get(inv.merchant_id))
|
||||||
|
for inv in invoices
|
||||||
|
]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"results": results,
|
"results": results,
|
||||||
"total": total,
|
"total": total,
|
||||||
@@ -276,16 +455,20 @@ class AdminSubscriptionService:
|
|||||||
|
|
||||||
def get_platform_names_map(self, db: Session) -> dict[int, str]:
|
def get_platform_names_map(self, db: Session) -> dict[int, str]:
|
||||||
"""Get mapping of platform_id -> platform_name."""
|
"""Get mapping of platform_id -> platform_name."""
|
||||||
from app.modules.tenancy.models import Platform
|
from app.modules.tenancy.services.platform_service import platform_service
|
||||||
|
|
||||||
return {p.id: p.name for p in db.query(Platform).all()}
|
platforms = platform_service.list_platforms(db, include_inactive=True)
|
||||||
|
return {p.id: p.name for p in platforms}
|
||||||
|
|
||||||
def get_platform_name(self, db: Session, platform_id: int) -> str | None:
|
def get_platform_name(self, db: Session, platform_id: int) -> str | None:
|
||||||
"""Get platform name by ID."""
|
"""Get platform name by ID."""
|
||||||
from app.modules.tenancy.models import Platform
|
from app.modules.tenancy.services.platform_service import platform_service
|
||||||
|
|
||||||
p = db.query(Platform).filter(Platform.id == platform_id).first()
|
try:
|
||||||
return p.name if p else None
|
p = platform_service.get_platform_by_id(db, platform_id)
|
||||||
|
return p.name
|
||||||
|
except PlatformNotFoundException:
|
||||||
|
return None
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Merchant Subscriptions with Usage
|
# Merchant Subscriptions with Usage
|
||||||
@@ -359,9 +542,9 @@ class AdminSubscriptionService:
|
|||||||
Convenience method for admin store detail page. Resolves
|
Convenience method for admin store detail page. Resolves
|
||||||
store -> merchant -> all platform subscriptions.
|
store -> merchant -> all platform subscriptions.
|
||||||
"""
|
"""
|
||||||
from app.modules.tenancy.models import Store
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
store = db.query(Store).filter(Store.id == store_id).first()
|
store = store_service.get_store_by_id_optional(db, store_id)
|
||||||
if not store or not store.merchant_id:
|
if not store or not store.merchant_id:
|
||||||
raise ResourceNotFoundException("Store", str(store_id))
|
raise ResourceNotFoundException("Store", str(store_id))
|
||||||
|
|
||||||
|
|||||||
@@ -155,8 +155,8 @@ class BillingService:
|
|||||||
trial_days = settings.stripe_trial_days
|
trial_days = settings.stripe_trial_days
|
||||||
|
|
||||||
# Get merchant for Stripe customer creation
|
# Get merchant for Stripe customer creation
|
||||||
from app.modules.tenancy.models import Merchant
|
from app.modules.tenancy.services.merchant_service import merchant_service
|
||||||
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
|
merchant = merchant_service.get_merchant_by_id_optional(db, merchant_id)
|
||||||
|
|
||||||
session = stripe_service.create_checkout_session(
|
session = stripe_service.create_checkout_session(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -494,8 +494,8 @@ class BillingService:
|
|||||||
if not addon.stripe_price_id:
|
if not addon.stripe_price_id:
|
||||||
raise BillingException(f"Stripe price not configured for add-on '{addon_code}'")
|
raise BillingException(f"Stripe price not configured for add-on '{addon_code}'")
|
||||||
|
|
||||||
from app.modules.tenancy.models import Store
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
store = db.query(Store).filter(Store.id == store_id).first()
|
store = store_service.get_store_by_id_optional(db, store_id)
|
||||||
|
|
||||||
session = stripe_service.create_checkout_session(
|
session = stripe_service.create_checkout_session(
|
||||||
db=db,
|
db=db,
|
||||||
|
|||||||
@@ -108,28 +108,30 @@ class FeatureService:
|
|||||||
# Store -> Merchant Resolution
|
# Store -> Merchant Resolution
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def _get_merchant_for_store(self, db: Session, store_id: int) -> tuple[int | None, int | None]:
|
def _get_merchant_for_store(
|
||||||
|
self, db: Session, store_id: int, platform_id: int | None = None
|
||||||
|
) -> tuple[int | None, int | None]:
|
||||||
"""
|
"""
|
||||||
Resolve store_id to (merchant_id, platform_id).
|
Resolve store_id to (merchant_id, platform_id).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
store_id: Store ID
|
||||||
|
platform_id: Platform ID from JWT. When provided, skips DB lookup.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (merchant_id, platform_id), either may be None
|
Tuple of (merchant_id, platform_id), either may be None
|
||||||
"""
|
"""
|
||||||
from app.modules.tenancy.models import Store, StorePlatform
|
from app.modules.tenancy.services.platform_service import platform_service
|
||||||
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
store = db.query(Store).filter(Store.id == store_id).first()
|
store = store_service.get_store_by_id_optional(db, store_id)
|
||||||
if not store:
|
if not store:
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
merchant_id = store.merchant_id
|
merchant_id = store.merchant_id
|
||||||
# Get primary platform_id from StorePlatform junction
|
if platform_id is None:
|
||||||
sp = (
|
platform_id = platform_service.get_first_active_platform_id_for_store(db, store_id)
|
||||||
db.query(StorePlatform.platform_id)
|
|
||||||
.filter(StorePlatform.store_id == store_id, StorePlatform.is_active == True) # noqa: E712
|
|
||||||
.order_by(StorePlatform.is_primary.desc())
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
platform_id = sp[0] if sp else None
|
|
||||||
|
|
||||||
return merchant_id, platform_id
|
return merchant_id, platform_id
|
||||||
|
|
||||||
@@ -142,19 +144,14 @@ class FeatureService:
|
|||||||
Returns all active platform IDs for the store's merchant,
|
Returns all active platform IDs for the store's merchant,
|
||||||
ordered with the primary platform first.
|
ordered with the primary platform first.
|
||||||
"""
|
"""
|
||||||
from app.modules.tenancy.models import Store, StorePlatform
|
from app.modules.tenancy.services.platform_service import platform_service
|
||||||
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
store = db.query(Store).filter(Store.id == store_id).first()
|
store = store_service.get_store_by_id_optional(db, store_id)
|
||||||
if not store:
|
if not store:
|
||||||
return None, []
|
return None, []
|
||||||
|
|
||||||
platform_ids = [
|
platform_ids = platform_service.get_active_platform_ids_for_store(db, store_id)
|
||||||
sp[0]
|
|
||||||
for sp in db.query(StorePlatform.platform_id)
|
|
||||||
.filter(StorePlatform.store_id == store_id, StorePlatform.is_active == True) # noqa: E712
|
|
||||||
.order_by(StorePlatform.is_primary.desc())
|
|
||||||
.all()
|
|
||||||
]
|
|
||||||
return store.merchant_id, platform_ids
|
return store.merchant_id, platform_ids
|
||||||
|
|
||||||
def _get_subscription(
|
def _get_subscription(
|
||||||
@@ -215,28 +212,29 @@ class FeatureService:
|
|||||||
return subscription.tier.has_feature(feature_code)
|
return subscription.tier.has_feature(feature_code)
|
||||||
|
|
||||||
def has_feature_for_store(
|
def has_feature_for_store(
|
||||||
self, db: Session, store_id: int, feature_code: str
|
self, db: Session, store_id: int, feature_code: str,
|
||||||
|
platform_id: int | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Convenience method that resolves the store -> merchant -> platform
|
Convenience method that resolves the store -> merchant -> platform
|
||||||
hierarchy and checks whether the merchant has access to a feature.
|
hierarchy and checks whether the merchant has access to a feature.
|
||||||
|
|
||||||
Looks up the store's merchant_id and platform_id, then delegates
|
|
||||||
to has_feature().
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session.
|
db: Database session.
|
||||||
store_id: The store ID to resolve.
|
store_id: The store ID to resolve.
|
||||||
feature_code: The feature code to check.
|
feature_code: The feature code to check.
|
||||||
|
platform_id: Platform ID from JWT. When provided, skips DB lookup.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if the resolved merchant has access to the feature,
|
True if the resolved merchant has access to the feature,
|
||||||
False if the store/merchant cannot be resolved or lacks access.
|
False if the store/merchant cannot be resolved or lacks access.
|
||||||
"""
|
"""
|
||||||
merchant_id, platform_id = self._get_merchant_for_store(db, store_id)
|
merchant_id, resolved_platform_id = self._get_merchant_for_store(
|
||||||
if merchant_id is None or platform_id is None:
|
db, store_id, platform_id=platform_id
|
||||||
|
)
|
||||||
|
if merchant_id is None or resolved_platform_id is None:
|
||||||
return False
|
return False
|
||||||
return self.has_feature(db, merchant_id, platform_id, feature_code)
|
return self.has_feature(db, merchant_id, resolved_platform_id, feature_code)
|
||||||
|
|
||||||
def get_merchant_feature_codes(
|
def get_merchant_feature_codes(
|
||||||
self, db: Session, merchant_id: int, platform_id: int
|
self, db: Session, merchant_id: int, platform_id: int
|
||||||
@@ -328,7 +326,7 @@ class FeatureService:
|
|||||||
feature_code: Feature code (e.g., "products_limit")
|
feature_code: Feature code (e.g., "products_limit")
|
||||||
store_id: Store ID (if checking per-store)
|
store_id: Store ID (if checking per-store)
|
||||||
merchant_id: Merchant ID (if already known)
|
merchant_id: Merchant ID (if already known)
|
||||||
platform_id: Platform ID (if already known)
|
platform_id: Platform ID (if already known, e.g. from JWT)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(allowed, error_message) tuple
|
(allowed, error_message) tuple
|
||||||
@@ -337,7 +335,9 @@ class FeatureService:
|
|||||||
|
|
||||||
# Resolve store -> merchant if needed
|
# Resolve store -> merchant if needed
|
||||||
if merchant_id is None and store_id is not None:
|
if merchant_id is None and store_id is not None:
|
||||||
merchant_id, platform_id = self._get_merchant_for_store(db, store_id)
|
merchant_id, platform_id = self._get_merchant_for_store(
|
||||||
|
db, store_id, platform_id=platform_id
|
||||||
|
)
|
||||||
|
|
||||||
if merchant_id is None or platform_id is None:
|
if merchant_id is None or platform_id is None:
|
||||||
return False, "No subscription found"
|
return False, "No subscription found"
|
||||||
@@ -450,30 +450,24 @@ class FeatureService:
|
|||||||
# Tier Feature Limit Management
|
# Tier Feature Limit Management
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def get_tier_feature_limits(self, db: Session, tier_code: str) -> list:
|
def get_tier_feature_limits(self, db: Session, tier_id: int) -> list:
|
||||||
"""Get feature limits for a tier."""
|
"""Get feature limits for a tier."""
|
||||||
from app.modules.billing.services import admin_subscription_service
|
|
||||||
|
|
||||||
tier = admin_subscription_service.get_tier_by_code(db, tier_code)
|
|
||||||
return (
|
return (
|
||||||
db.query(TierFeatureLimit)
|
db.query(TierFeatureLimit)
|
||||||
.filter(TierFeatureLimit.tier_id == tier.id)
|
.filter(TierFeatureLimit.tier_id == tier_id)
|
||||||
.order_by(TierFeatureLimit.feature_code)
|
.order_by(TierFeatureLimit.feature_code)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
def upsert_tier_feature_limits(self, db: Session, tier_code: str, entries: list[dict]) -> list:
|
def upsert_tier_feature_limits(self, db: Session, tier_id: int, entries: list[dict]) -> list:
|
||||||
"""Replace feature limits for a tier. Returns list of new TierFeatureLimit objects."""
|
"""Replace feature limits for a tier. Returns list of new TierFeatureLimit objects."""
|
||||||
from app.modules.billing.services import admin_subscription_service
|
db.query(TierFeatureLimit).filter(TierFeatureLimit.tier_id == tier_id).delete()
|
||||||
|
|
||||||
tier = admin_subscription_service.get_tier_by_code(db, tier_code)
|
|
||||||
db.query(TierFeatureLimit).filter(TierFeatureLimit.tier_id == tier.id).delete()
|
|
||||||
new_rows = []
|
new_rows = []
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
if not entry.get("enabled", True):
|
if not entry.get("enabled", True):
|
||||||
continue
|
continue
|
||||||
row = TierFeatureLimit(
|
row = TierFeatureLimit(
|
||||||
tier_id=tier.id,
|
tier_id=tier_id,
|
||||||
feature_code=entry["feature_code"],
|
feature_code=entry["feature_code"],
|
||||||
limit_value=entry.get("limit_value"),
|
limit_value=entry.get("limit_value"),
|
||||||
)
|
)
|
||||||
|
|||||||
822
app/modules/billing/services/signup_service.py
Normal file
822
app/modules/billing/services/signup_service.py
Normal file
@@ -0,0 +1,822 @@
|
|||||||
|
# app/modules/billing/services/signup_service.py
|
||||||
|
"""
|
||||||
|
Core platform signup service.
|
||||||
|
|
||||||
|
Handles all database operations for the platform signup flow:
|
||||||
|
- Session management
|
||||||
|
- Account creation (User + Merchant)
|
||||||
|
- Store creation (separate step)
|
||||||
|
- Stripe customer & subscription setup
|
||||||
|
- Payment method collection
|
||||||
|
|
||||||
|
Platform-specific signup extensions (e.g., OMS Letzshop claiming)
|
||||||
|
live in their respective modules and call into this core service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.exceptions import (
|
||||||
|
ConflictException,
|
||||||
|
ResourceNotFoundException,
|
||||||
|
ValidationException,
|
||||||
|
)
|
||||||
|
from app.modules.billing.services.stripe_service import stripe_service
|
||||||
|
from app.modules.billing.services.subscription_service import (
|
||||||
|
subscription_service as sub_service,
|
||||||
|
)
|
||||||
|
from app.modules.messaging.services.email_service import EmailService
|
||||||
|
from middleware.auth import AuthManager
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.modules.tenancy.models import Store, User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# In-memory signup session storage
|
||||||
|
# In production, use Redis or database table
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
_signup_sessions: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _create_session_id() -> str:
|
||||||
|
"""Generate a secure session ID."""
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Data Classes
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SignupSessionData:
|
||||||
|
"""Data stored in a signup session."""
|
||||||
|
|
||||||
|
session_id: str
|
||||||
|
step: str
|
||||||
|
tier_code: str
|
||||||
|
is_annual: bool
|
||||||
|
platform_code: str = ""
|
||||||
|
created_at: str = ""
|
||||||
|
updated_at: str | None = None
|
||||||
|
store_name: str | None = None
|
||||||
|
user_id: int | None = None
|
||||||
|
merchant_id: int | None = None
|
||||||
|
store_id: int | None = None
|
||||||
|
store_code: str | None = None
|
||||||
|
platform_id: int | None = None
|
||||||
|
stripe_customer_id: str | None = None
|
||||||
|
setup_intent_id: str | None = None
|
||||||
|
extra: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AccountCreationResult:
|
||||||
|
"""Result of account creation (includes auto-created store)."""
|
||||||
|
|
||||||
|
user_id: int
|
||||||
|
merchant_id: int
|
||||||
|
stripe_customer_id: str
|
||||||
|
store_id: int
|
||||||
|
store_code: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StoreCreationResult:
|
||||||
|
"""Result of store creation."""
|
||||||
|
|
||||||
|
store_id: int
|
||||||
|
store_code: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SignupCompletionResult:
|
||||||
|
"""Result of signup completion."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
store_code: str
|
||||||
|
store_id: int
|
||||||
|
redirect_url: str
|
||||||
|
trial_ends_at: str
|
||||||
|
access_token: str | None = None # JWT token for automatic login
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Platform Signup Service
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class SignupService:
|
||||||
|
"""Core service for handling platform signup operations."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.auth_manager = AuthManager()
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Session Management
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def create_session(
|
||||||
|
self,
|
||||||
|
tier_code: str,
|
||||||
|
is_annual: bool,
|
||||||
|
platform_code: str,
|
||||||
|
language: str = "fr",
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Create a new signup session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tier_code: The subscription tier code
|
||||||
|
is_annual: Whether annual billing is selected
|
||||||
|
platform_code: Platform code (e.g., 'loyalty', 'oms')
|
||||||
|
language: User's browsing language (from lang cookie)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The session ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationException: If tier code or platform code is invalid
|
||||||
|
"""
|
||||||
|
if not platform_code:
|
||||||
|
raise ValidationException(
|
||||||
|
message="Platform code is required for signup.",
|
||||||
|
field="platform_code",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate tier code
|
||||||
|
from app.modules.billing.models import TierCode
|
||||||
|
|
||||||
|
try:
|
||||||
|
tier = TierCode(tier_code)
|
||||||
|
except ValueError:
|
||||||
|
raise ValidationException(
|
||||||
|
message=f"Invalid tier code: {tier_code}",
|
||||||
|
field="tier_code",
|
||||||
|
)
|
||||||
|
|
||||||
|
session_id = _create_session_id()
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
_signup_sessions[session_id] = {
|
||||||
|
"step": "tier_selected",
|
||||||
|
"tier_code": tier.value,
|
||||||
|
"is_annual": is_annual,
|
||||||
|
"platform_code": platform_code,
|
||||||
|
"language": language,
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Created signup session {session_id} for tier {tier.value}"
|
||||||
|
f" on platform {platform_code}"
|
||||||
|
)
|
||||||
|
return session_id
|
||||||
|
|
||||||
|
def get_session(self, session_id: str) -> dict | None:
|
||||||
|
"""Get a signup session by ID."""
|
||||||
|
return _signup_sessions.get(session_id)
|
||||||
|
|
||||||
|
def get_session_or_raise(self, session_id: str) -> dict:
|
||||||
|
"""
|
||||||
|
Get a signup session or raise an exception.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ResourceNotFoundException: If session not found
|
||||||
|
"""
|
||||||
|
session = self.get_session(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ResourceNotFoundException(
|
||||||
|
resource_type="SignupSession",
|
||||||
|
identifier=session_id,
|
||||||
|
)
|
||||||
|
return session
|
||||||
|
|
||||||
|
def update_session(self, session_id: str, data: dict) -> None:
|
||||||
|
"""Update signup session data."""
|
||||||
|
session = self.get_session_or_raise(session_id)
|
||||||
|
session.update(data)
|
||||||
|
session["updated_at"] = datetime.now(UTC).isoformat()
|
||||||
|
_signup_sessions[session_id] = session
|
||||||
|
|
||||||
|
def delete_session(self, session_id: str) -> None:
|
||||||
|
"""Delete a signup session."""
|
||||||
|
_signup_sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Platform Resolution
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def _resolve_platform_id(self, db: Session, session: dict) -> int:
|
||||||
|
"""
|
||||||
|
Resolve platform_id from session data.
|
||||||
|
|
||||||
|
The platform_code is always required in the session (set during
|
||||||
|
create_session). Raises if the platform cannot be resolved.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationException: If platform_code is missing or unknown
|
||||||
|
"""
|
||||||
|
from app.modules.tenancy.services.platform_service import platform_service
|
||||||
|
|
||||||
|
platform_code = session.get("platform_code")
|
||||||
|
if not platform_code:
|
||||||
|
raise ValidationException(
|
||||||
|
message="Platform code is missing from signup session.",
|
||||||
|
field="platform_code",
|
||||||
|
)
|
||||||
|
|
||||||
|
platform = platform_service.get_platform_by_code_optional(
|
||||||
|
db, platform_code
|
||||||
|
)
|
||||||
|
if not platform:
|
||||||
|
raise ValidationException(
|
||||||
|
message=f"Unknown platform: {platform_code}",
|
||||||
|
field="platform_code",
|
||||||
|
)
|
||||||
|
|
||||||
|
return platform.id
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Account Creation (User + Merchant only)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def check_email_exists(self, db: Session, email: str) -> bool:
|
||||||
|
"""Check if an email already exists."""
|
||||||
|
from app.modules.tenancy.services.admin_service import admin_service
|
||||||
|
|
||||||
|
return admin_service.get_user_by_email(db, email) is not None
|
||||||
|
|
||||||
|
def generate_unique_username(self, db: Session, email: str) -> str:
|
||||||
|
"""Generate a unique username from email."""
|
||||||
|
from app.modules.tenancy.services.admin_service import admin_service
|
||||||
|
|
||||||
|
username = email.split("@")[0]
|
||||||
|
base_username = username
|
||||||
|
counter = 1
|
||||||
|
while admin_service.get_user_by_username(db, username):
|
||||||
|
username = f"{base_username}_{counter}"
|
||||||
|
counter += 1
|
||||||
|
return username
|
||||||
|
|
||||||
|
def generate_unique_store_code(self, db: Session, name: str) -> str:
|
||||||
|
"""Generate a unique store code from a name."""
|
||||||
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
|
store_code = name.upper().replace(" ", "_")[:20]
|
||||||
|
base_code = store_code
|
||||||
|
counter = 1
|
||||||
|
while store_service.is_store_code_taken(db, store_code):
|
||||||
|
store_code = f"{base_code}_{counter}"
|
||||||
|
counter += 1
|
||||||
|
return store_code
|
||||||
|
|
||||||
|
def generate_unique_subdomain(self, db: Session, name: str) -> str:
|
||||||
|
"""Generate a unique subdomain from a name."""
|
||||||
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
|
subdomain = name.lower().replace(" ", "-")
|
||||||
|
subdomain = "".join(c for c in subdomain if c.isalnum() or c == "-")[:50]
|
||||||
|
base_subdomain = subdomain
|
||||||
|
counter = 1
|
||||||
|
while store_service.is_subdomain_taken(db, subdomain):
|
||||||
|
subdomain = f"{base_subdomain}-{counter}"
|
||||||
|
counter += 1
|
||||||
|
return subdomain
|
||||||
|
|
||||||
|
def create_account(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
session_id: str,
|
||||||
|
email: str,
|
||||||
|
password: str,
|
||||||
|
first_name: str,
|
||||||
|
last_name: str,
|
||||||
|
merchant_name: str,
|
||||||
|
phone: str | None = None,
|
||||||
|
) -> AccountCreationResult:
|
||||||
|
"""
|
||||||
|
Create user, merchant, store, and subscription in a single atomic step.
|
||||||
|
|
||||||
|
Creates User + Merchant + Store + Stripe Customer + MerchantSubscription.
|
||||||
|
Store name defaults to merchant_name, language from signup session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
session_id: Signup session ID
|
||||||
|
email: User email
|
||||||
|
password: User password
|
||||||
|
first_name: User first name
|
||||||
|
last_name: User last name
|
||||||
|
merchant_name: Merchant/business name
|
||||||
|
phone: Optional phone number
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AccountCreationResult with user, merchant, and store IDs
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ResourceNotFoundException: If session not found
|
||||||
|
ConflictException: If email already exists
|
||||||
|
"""
|
||||||
|
session = self.get_session_or_raise(session_id)
|
||||||
|
|
||||||
|
# Check if email already exists
|
||||||
|
if self.check_email_exists(db, email):
|
||||||
|
raise ConflictException(
|
||||||
|
message="An account with this email already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate unique username
|
||||||
|
username = self.generate_unique_username(db, email)
|
||||||
|
|
||||||
|
# Create User
|
||||||
|
from app.modules.tenancy.models import Merchant, Store, User
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
email=email,
|
||||||
|
username=username,
|
||||||
|
hashed_password=self.auth_manager.hash_password(password),
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
role="merchant_owner",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Create Merchant
|
||||||
|
merchant = Merchant(
|
||||||
|
name=merchant_name,
|
||||||
|
owner_user_id=user.id,
|
||||||
|
contact_email=email,
|
||||||
|
contact_phone=phone,
|
||||||
|
)
|
||||||
|
db.add(merchant)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Create Stripe Customer
|
||||||
|
stripe_customer_id = stripe_service.create_customer_for_merchant(
|
||||||
|
merchant=merchant,
|
||||||
|
email=email,
|
||||||
|
name=f"{first_name} {last_name}",
|
||||||
|
metadata={
|
||||||
|
"merchant_name": merchant_name,
|
||||||
|
"tier": session.get("tier_code"),
|
||||||
|
"platform": session.get("platform_code", ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create Store (name = merchant_name, language from browsing session)
|
||||||
|
store_code = self.generate_unique_store_code(db, merchant_name)
|
||||||
|
subdomain = self.generate_unique_subdomain(db, merchant_name)
|
||||||
|
language = session.get("language", "fr")
|
||||||
|
|
||||||
|
store = Store(
|
||||||
|
merchant_id=merchant.id,
|
||||||
|
store_code=store_code,
|
||||||
|
subdomain=subdomain,
|
||||||
|
name=merchant_name,
|
||||||
|
contact_email=email,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
if language:
|
||||||
|
store.default_language = language
|
||||||
|
db.add(store)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Resolve platform and create subscription
|
||||||
|
platform_id = self._resolve_platform_id(db, session)
|
||||||
|
|
||||||
|
subscription = sub_service.create_merchant_subscription(
|
||||||
|
db=db,
|
||||||
|
merchant_id=merchant.id,
|
||||||
|
platform_id=platform_id,
|
||||||
|
tier_code=session.get("tier_code", "essential"),
|
||||||
|
trial_days=settings.stripe_trial_days,
|
||||||
|
is_annual=session.get("is_annual", False),
|
||||||
|
)
|
||||||
|
subscription.stripe_customer_id = stripe_customer_id
|
||||||
|
|
||||||
|
db.commit() # SVC-006 - Atomic account + store creation
|
||||||
|
|
||||||
|
# Update session
|
||||||
|
self.update_session(session_id, {
|
||||||
|
"user_id": user.id,
|
||||||
|
"merchant_id": merchant.id,
|
||||||
|
"merchant_name": merchant_name,
|
||||||
|
"email": email,
|
||||||
|
"stripe_customer_id": stripe_customer_id,
|
||||||
|
"store_id": store.id,
|
||||||
|
"store_code": store_code,
|
||||||
|
"platform_id": platform_id,
|
||||||
|
"step": "account_created",
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Created account + store for {email}: "
|
||||||
|
f"user_id={user.id}, merchant_id={merchant.id}, "
|
||||||
|
f"store_code={store_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return AccountCreationResult(
|
||||||
|
user_id=user.id,
|
||||||
|
merchant_id=merchant.id,
|
||||||
|
stripe_customer_id=stripe_customer_id,
|
||||||
|
store_id=store.id,
|
||||||
|
store_code=store_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Store Creation (separate step)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def create_store(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
session_id: str,
|
||||||
|
store_name: str | None = None,
|
||||||
|
language: str | None = None,
|
||||||
|
) -> StoreCreationResult:
|
||||||
|
"""
|
||||||
|
Create the first store for the merchant.
|
||||||
|
|
||||||
|
Store name defaults to the merchant name if not provided.
|
||||||
|
The merchant can modify store details later in the merchant panel.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
session_id: Signup session ID
|
||||||
|
store_name: Store name (defaults to merchant name)
|
||||||
|
language: Store language code (e.g., 'fr', 'en', 'de')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StoreCreationResult with store ID and code
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ResourceNotFoundException: If session not found
|
||||||
|
ValidationException: If account not created yet
|
||||||
|
"""
|
||||||
|
session = self.get_session_or_raise(session_id)
|
||||||
|
|
||||||
|
merchant_id = session.get("merchant_id")
|
||||||
|
if not merchant_id:
|
||||||
|
raise ValidationException(
|
||||||
|
message="Account not created. Please complete the account step first.",
|
||||||
|
field="session_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.modules.tenancy.models import Store
|
||||||
|
|
||||||
|
# Use merchant name as default store name
|
||||||
|
effective_name = store_name or session.get("merchant_name", "My Store")
|
||||||
|
email = session.get("email")
|
||||||
|
|
||||||
|
# Generate unique store code and subdomain
|
||||||
|
store_code = self.generate_unique_store_code(db, effective_name)
|
||||||
|
subdomain = self.generate_unique_subdomain(db, effective_name)
|
||||||
|
|
||||||
|
# Create Store
|
||||||
|
store = Store(
|
||||||
|
merchant_id=merchant_id,
|
||||||
|
store_code=store_code,
|
||||||
|
subdomain=subdomain,
|
||||||
|
name=effective_name,
|
||||||
|
contact_email=email,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
if language:
|
||||||
|
store.default_language = language
|
||||||
|
db.add(store)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Resolve platform and create subscription
|
||||||
|
platform_id = self._resolve_platform_id(db, session)
|
||||||
|
|
||||||
|
# Create MerchantSubscription (trial status)
|
||||||
|
stripe_customer_id = session.get("stripe_customer_id")
|
||||||
|
subscription = sub_service.create_merchant_subscription(
|
||||||
|
db=db,
|
||||||
|
merchant_id=merchant_id,
|
||||||
|
platform_id=platform_id,
|
||||||
|
tier_code=session.get("tier_code", "essential"),
|
||||||
|
trial_days=settings.stripe_trial_days,
|
||||||
|
is_annual=session.get("is_annual", False),
|
||||||
|
)
|
||||||
|
subscription.stripe_customer_id = stripe_customer_id
|
||||||
|
|
||||||
|
db.commit() # SVC-006 - Atomic store creation needs commit
|
||||||
|
|
||||||
|
# Update session
|
||||||
|
self.update_session(session_id, {
|
||||||
|
"store_id": store.id,
|
||||||
|
"store_code": store_code,
|
||||||
|
"platform_id": platform_id,
|
||||||
|
"step": "store_created",
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Created store {store_code} for merchant {merchant_id} "
|
||||||
|
f"on platform {session.get('platform_code')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return StoreCreationResult(
|
||||||
|
store_id=store.id,
|
||||||
|
store_code=store_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Payment Setup
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def setup_payment(self, session_id: str) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Create Stripe SetupIntent for card collection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Signup session ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (client_secret, stripe_customer_id)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ResourceNotFoundException: If session not found
|
||||||
|
ValidationException: If account not created yet
|
||||||
|
"""
|
||||||
|
session = self.get_session_or_raise(session_id)
|
||||||
|
|
||||||
|
stripe_customer_id = session.get("stripe_customer_id")
|
||||||
|
if not stripe_customer_id:
|
||||||
|
raise ValidationException(
|
||||||
|
message="Account not created. Please complete earlier steps first.",
|
||||||
|
field="session_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create SetupIntent
|
||||||
|
setup_intent = stripe_service.create_setup_intent(
|
||||||
|
customer_id=stripe_customer_id,
|
||||||
|
metadata={
|
||||||
|
"session_id": session_id,
|
||||||
|
"store_id": str(session.get("store_id")),
|
||||||
|
"tier": session.get("tier_code"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update session
|
||||||
|
self.update_session(session_id, {
|
||||||
|
"setup_intent_id": setup_intent.id,
|
||||||
|
"step": "payment_pending",
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Created SetupIntent {setup_intent.id} for session {session_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return setup_intent.client_secret, stripe_customer_id
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Welcome Email
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def send_welcome_email(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
user: User,
|
||||||
|
store: Store,
|
||||||
|
tier_code: str,
|
||||||
|
language: str = "fr",
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Send welcome email to new store.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
user: User who signed up
|
||||||
|
store: Store that was created
|
||||||
|
tier_code: Selected tier code
|
||||||
|
language: Language for email (default: French)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get tier name
|
||||||
|
from app.modules.billing.services.billing_service import billing_service
|
||||||
|
|
||||||
|
tier = billing_service.get_tier_by_code(db, tier_code)
|
||||||
|
tier_name = tier.name if tier else tier_code.title()
|
||||||
|
|
||||||
|
# Build login URL
|
||||||
|
login_url = (
|
||||||
|
f"{settings.app_base_url.rstrip('/')}"
|
||||||
|
f"/store/{store.store_code}/dashboard"
|
||||||
|
)
|
||||||
|
|
||||||
|
email_service = EmailService(db)
|
||||||
|
email_service.send_template(
|
||||||
|
template_code="signup_welcome",
|
||||||
|
language=language,
|
||||||
|
to_email=user.email,
|
||||||
|
to_name=f"{user.first_name} {user.last_name}",
|
||||||
|
variables={
|
||||||
|
"first_name": user.first_name,
|
||||||
|
"merchant_name": store.name,
|
||||||
|
"email": user.email,
|
||||||
|
"store_code": store.store_code,
|
||||||
|
"login_url": login_url,
|
||||||
|
"trial_days": settings.stripe_trial_days,
|
||||||
|
"tier_name": tier_name,
|
||||||
|
},
|
||||||
|
store_id=store.id,
|
||||||
|
user_id=user.id,
|
||||||
|
related_type="signup",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Welcome email sent to {user.email}")
|
||||||
|
|
||||||
|
except Exception as e: # noqa: EXC003
|
||||||
|
# Log error but don't fail signup
|
||||||
|
logger.error(f"Failed to send welcome email to {user.email}: {e}")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Signup Completion
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def complete_signup(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
session_id: str,
|
||||||
|
setup_intent_id: str,
|
||||||
|
) -> SignupCompletionResult:
|
||||||
|
"""
|
||||||
|
Complete signup after card collection.
|
||||||
|
|
||||||
|
Verifies the SetupIntent, attaches the payment method to the Stripe
|
||||||
|
customer, creates the Stripe Subscription with trial, and generates
|
||||||
|
a JWT token for automatic login.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
session_id: Signup session ID
|
||||||
|
setup_intent_id: Stripe SetupIntent ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SignupCompletionResult
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ResourceNotFoundException: If session not found
|
||||||
|
ValidationException: If signup incomplete or payment failed
|
||||||
|
"""
|
||||||
|
from app.modules.billing.models import SubscriptionTier, TierCode
|
||||||
|
|
||||||
|
session = self.get_session_or_raise(session_id)
|
||||||
|
|
||||||
|
# Guard against completing signup more than once
|
||||||
|
if session.get("step") == "completed":
|
||||||
|
raise ValidationException(
|
||||||
|
message="Signup already completed.",
|
||||||
|
field="session_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
store_id = session.get("store_id")
|
||||||
|
stripe_customer_id = session.get("stripe_customer_id")
|
||||||
|
|
||||||
|
if not store_id or not stripe_customer_id:
|
||||||
|
raise ValidationException(
|
||||||
|
message="Incomplete signup. Please start again.",
|
||||||
|
field="session_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retrieve SetupIntent to get payment method
|
||||||
|
setup_intent = stripe_service.get_setup_intent(setup_intent_id)
|
||||||
|
|
||||||
|
if setup_intent.status != "succeeded":
|
||||||
|
raise ValidationException(
|
||||||
|
message="Card setup not completed. Please try again.",
|
||||||
|
field="setup_intent_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
payment_method_id = setup_intent.payment_method
|
||||||
|
|
||||||
|
# Attach payment method to customer
|
||||||
|
stripe_service.attach_payment_method_to_customer(
|
||||||
|
customer_id=stripe_customer_id,
|
||||||
|
payment_method_id=payment_method_id,
|
||||||
|
set_as_default=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update subscription record
|
||||||
|
subscription = sub_service.get_subscription_for_store(db, store_id)
|
||||||
|
|
||||||
|
if subscription:
|
||||||
|
subscription.stripe_payment_method_id = payment_method_id
|
||||||
|
|
||||||
|
# Create the actual Stripe Subscription with trial period
|
||||||
|
# This is what enables automatic charging after trial ends
|
||||||
|
if subscription.tier_id:
|
||||||
|
tier = (
|
||||||
|
db.query(SubscriptionTier)
|
||||||
|
.filter(SubscriptionTier.id == subscription.tier_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if tier:
|
||||||
|
price_id = (
|
||||||
|
tier.stripe_price_annual_id
|
||||||
|
if subscription.is_annual and tier.stripe_price_annual_id
|
||||||
|
else tier.stripe_price_monthly_id
|
||||||
|
)
|
||||||
|
if price_id:
|
||||||
|
stripe_sub = stripe_service.create_subscription_with_trial(
|
||||||
|
customer_id=stripe_customer_id,
|
||||||
|
price_id=price_id,
|
||||||
|
trial_days=settings.stripe_trial_days,
|
||||||
|
metadata={
|
||||||
|
"merchant_id": str(subscription.merchant_id),
|
||||||
|
"platform_id": str(subscription.platform_id),
|
||||||
|
"tier_code": tier.code,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
subscription.stripe_subscription_id = stripe_sub.id
|
||||||
|
logger.info(
|
||||||
|
f"Created Stripe subscription {stripe_sub.id} "
|
||||||
|
f"for merchant {subscription.merchant_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit() # SVC-006 - Finalize signup needs commit
|
||||||
|
|
||||||
|
# Get store info
|
||||||
|
from app.modules.tenancy.models import Store, User
|
||||||
|
|
||||||
|
store = db.query(Store).filter(Store.id == store_id).first()
|
||||||
|
store_code = store.store_code if store else session.get("store_code")
|
||||||
|
|
||||||
|
trial_ends_at = (
|
||||||
|
subscription.trial_ends_at
|
||||||
|
if subscription
|
||||||
|
else datetime.now(UTC) + timedelta(days=30)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get user for welcome email and token generation
|
||||||
|
user_id = session.get("user_id")
|
||||||
|
user = (
|
||||||
|
db.query(User).filter(User.id == user_id).first() if user_id else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate access token for automatic login after signup
|
||||||
|
access_token = None
|
||||||
|
if user and store:
|
||||||
|
# Create store-scoped JWT token (user is owner since they just signed up)
|
||||||
|
token_data = self.auth_manager.create_access_token(
|
||||||
|
user=user,
|
||||||
|
store_id=store.id,
|
||||||
|
store_code=store.store_code,
|
||||||
|
store_role="Owner", # New signup is always the owner
|
||||||
|
)
|
||||||
|
access_token = token_data["access_token"]
|
||||||
|
logger.info(f"Generated access token for new store user {user.email}")
|
||||||
|
|
||||||
|
# Send welcome email
|
||||||
|
if user and store:
|
||||||
|
tier_code = session.get("tier_code", TierCode.ESSENTIAL.value)
|
||||||
|
self.send_welcome_email(db, user, store, tier_code)
|
||||||
|
|
||||||
|
# Determine redirect based on platform
|
||||||
|
redirect_url = self._get_post_signup_redirect(db, session, store_code)
|
||||||
|
|
||||||
|
# Clean up session
|
||||||
|
self.delete_session(session_id)
|
||||||
|
|
||||||
|
logger.info(f"Completed signup for store {store_id}")
|
||||||
|
|
||||||
|
return SignupCompletionResult(
|
||||||
|
success=True,
|
||||||
|
store_code=store_code,
|
||||||
|
store_id=store_id,
|
||||||
|
redirect_url=redirect_url,
|
||||||
|
trial_ends_at=trial_ends_at.isoformat(),
|
||||||
|
access_token=access_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_post_signup_redirect(
|
||||||
|
self, db: Session, session: dict, store_code: str
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Determine redirect URL after signup.
|
||||||
|
|
||||||
|
Always redirects to the store dashboard. Platform-specific onboarding
|
||||||
|
is handled by the dashboard's onboarding banner (module-driven).
|
||||||
|
"""
|
||||||
|
return f"/store/{store_code}/dashboard"
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
signup_service = SignupService()
|
||||||
@@ -11,7 +11,8 @@ import logging
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.modules.tenancy.models import Store, StorePlatform
|
from app.modules.tenancy.services.platform_service import platform_service
|
||||||
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -31,59 +32,23 @@ class StorePlatformSync:
|
|||||||
Upsert StorePlatform for every store belonging to a merchant.
|
Upsert StorePlatform for every store belonging to a merchant.
|
||||||
|
|
||||||
- Existing entry → update is_active (and tier_id if provided)
|
- Existing entry → update is_active (and tier_id if provided)
|
||||||
- Missing + is_active=True → create (set is_primary if store has none)
|
- Missing + is_active=True → create
|
||||||
- Missing + is_active=False → no-op
|
- Missing + is_active=False → no-op
|
||||||
"""
|
"""
|
||||||
stores = (
|
stores = store_service.get_stores_by_merchant_id(db, merchant_id)
|
||||||
db.query(Store)
|
|
||||||
.filter(Store.merchant_id == merchant_id)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not stores:
|
if not stores:
|
||||||
return
|
return
|
||||||
|
|
||||||
for store in stores:
|
for store in stores:
|
||||||
existing = (
|
result = platform_service.ensure_store_platform(
|
||||||
db.query(StorePlatform)
|
db, store.id, platform_id, is_active, tier_id
|
||||||
.filter(
|
|
||||||
StorePlatform.store_id == store.id,
|
|
||||||
StorePlatform.platform_id == platform_id,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
)
|
||||||
|
if result:
|
||||||
if existing:
|
|
||||||
existing.is_active = is_active
|
|
||||||
if tier_id is not None:
|
|
||||||
existing.tier_id = tier_id
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Updated StorePlatform store_id={store.id} "
|
f"Synced StorePlatform store_id={store.id} "
|
||||||
f"platform_id={platform_id} is_active={is_active}"
|
f"platform_id={platform_id} is_active={is_active}"
|
||||||
)
|
)
|
||||||
elif is_active:
|
|
||||||
# Check if store already has a primary platform
|
|
||||||
has_primary = (
|
|
||||||
db.query(StorePlatform)
|
|
||||||
.filter(
|
|
||||||
StorePlatform.store_id == store.id,
|
|
||||||
StorePlatform.is_primary.is_(True),
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
) is not None
|
|
||||||
|
|
||||||
sp = StorePlatform(
|
|
||||||
store_id=store.id,
|
|
||||||
platform_id=platform_id,
|
|
||||||
is_active=True,
|
|
||||||
is_primary=not has_primary,
|
|
||||||
tier_id=tier_id,
|
|
||||||
)
|
|
||||||
db.add(sp)
|
|
||||||
logger.info(
|
|
||||||
f"Created StorePlatform store_id={store.id} "
|
|
||||||
f"platform_id={platform_id} is_primary={not has_primary}"
|
|
||||||
)
|
|
||||||
|
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ Provides:
|
|||||||
- Webhook event construction
|
- Webhook event construction
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import stripe
|
import stripe
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -23,7 +26,9 @@ from app.modules.billing.exceptions import (
|
|||||||
from app.modules.billing.models import (
|
from app.modules.billing.models import (
|
||||||
MerchantSubscription,
|
MerchantSubscription,
|
||||||
)
|
)
|
||||||
from app.modules.tenancy.models import Store
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.modules.tenancy.models import Store
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -41,7 +46,7 @@ class StripeService:
|
|||||||
stripe.api_key = settings.stripe_secret_key
|
stripe.api_key = settings.stripe_secret_key
|
||||||
self._configured = True
|
self._configured = True
|
||||||
else:
|
else:
|
||||||
logger.warning("Stripe API key not configured")
|
logger.debug("Stripe API key not configured")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
@@ -88,6 +93,38 @@ class StripeService:
|
|||||||
)
|
)
|
||||||
return customer.id
|
return customer.id
|
||||||
|
|
||||||
|
def create_customer_for_merchant(
|
||||||
|
self,
|
||||||
|
merchant,
|
||||||
|
email: str,
|
||||||
|
name: str | None = None,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Create a Stripe customer for a merchant (before store exists).
|
||||||
|
|
||||||
|
Used during signup when the store hasn't been created yet.
|
||||||
|
Returns the Stripe customer ID.
|
||||||
|
"""
|
||||||
|
self._check_configured()
|
||||||
|
|
||||||
|
customer_metadata = {
|
||||||
|
"merchant_id": str(merchant.id),
|
||||||
|
"merchant_name": merchant.name,
|
||||||
|
**(metadata or {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
customer = stripe.Customer.create(
|
||||||
|
email=email,
|
||||||
|
name=name or merchant.name,
|
||||||
|
metadata=customer_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Created Stripe customer {customer.id} for merchant {merchant.name}"
|
||||||
|
)
|
||||||
|
return customer.id
|
||||||
|
|
||||||
def get_customer(self, customer_id: str) -> stripe.Customer:
|
def get_customer(self, customer_id: str) -> stripe.Customer:
|
||||||
"""Get a Stripe customer by ID."""
|
"""Get a Stripe customer by ID."""
|
||||||
self._check_configured()
|
self._check_configured()
|
||||||
@@ -274,6 +311,7 @@ class StripeService:
|
|||||||
trial_days: int | None = None,
|
trial_days: int | None = None,
|
||||||
quantity: int = 1,
|
quantity: int = 1,
|
||||||
metadata: dict | None = None,
|
metadata: dict | None = None,
|
||||||
|
platform_id: int | None = None,
|
||||||
) -> stripe.checkout.Session:
|
) -> stripe.checkout.Session:
|
||||||
"""
|
"""
|
||||||
Create a Stripe Checkout session for subscription signup.
|
Create a Stripe Checkout session for subscription signup.
|
||||||
@@ -287,6 +325,7 @@ class StripeService:
|
|||||||
trial_days: Optional trial period
|
trial_days: Optional trial period
|
||||||
quantity: Number of items (default 1)
|
quantity: Number of items (default 1)
|
||||||
metadata: Additional metadata to store
|
metadata: Additional metadata to store
|
||||||
|
platform_id: Platform ID (from JWT or caller). Falls back to DB lookup.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Stripe Checkout Session object
|
Stripe Checkout Session object
|
||||||
@@ -294,10 +333,11 @@ class StripeService:
|
|||||||
self._check_configured()
|
self._check_configured()
|
||||||
|
|
||||||
# Get or create Stripe customer
|
# Get or create Stripe customer
|
||||||
from app.modules.tenancy.models import StorePlatform
|
from app.modules.tenancy.services.platform_service import platform_service
|
||||||
|
from app.modules.tenancy.services.team_service import team_service
|
||||||
|
|
||||||
sp = db.query(StorePlatform.platform_id).filter(StorePlatform.store_id == store.id).first()
|
if platform_id is None:
|
||||||
platform_id = sp[0] if sp else None
|
platform_id = platform_service.get_first_active_platform_id_for_store(db, store.id)
|
||||||
subscription = None
|
subscription = None
|
||||||
if store.merchant_id and platform_id:
|
if store.merchant_id and platform_id:
|
||||||
subscription = (
|
subscription = (
|
||||||
@@ -313,16 +353,7 @@ class StripeService:
|
|||||||
customer_id = subscription.stripe_customer_id
|
customer_id = subscription.stripe_customer_id
|
||||||
else:
|
else:
|
||||||
# Get store owner email
|
# Get store owner email
|
||||||
from app.modules.tenancy.models import StoreUser
|
owner = team_service.get_store_owner(db, store.id)
|
||||||
|
|
||||||
owner = (
|
|
||||||
db.query(StoreUser)
|
|
||||||
.filter(
|
|
||||||
StoreUser.store_id == store.id,
|
|
||||||
StoreUser.is_owner == True,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
email = owner.user.email if owner and owner.user else None
|
email = owner.user.email if owner and owner.user else None
|
||||||
|
|
||||||
customer_id = self.create_customer(store, email or f"{store.store_code}@placeholder.com")
|
customer_id = self.create_customer(store, email or f"{store.store_code}@placeholder.com")
|
||||||
|
|||||||
@@ -47,23 +47,30 @@ class SubscriptionService:
|
|||||||
# Store Resolution
|
# Store Resolution
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def resolve_store_to_merchant(self, db: Session, store_id: int) -> tuple[int, int]:
|
def resolve_store_to_merchant(
|
||||||
|
self, db: Session, store_id: int, platform_id: int | None = None
|
||||||
|
) -> tuple[int, int]:
|
||||||
"""Resolve store_id to (merchant_id, platform_id).
|
"""Resolve store_id to (merchant_id, platform_id).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
store_id: Store ID
|
||||||
|
platform_id: Platform ID from JWT token. When provided, skips DB lookup.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ResourceNotFoundException: If store not found or has no platform
|
ResourceNotFoundException: If store not found or has no platform
|
||||||
"""
|
"""
|
||||||
from app.modules.tenancy.models import Store, StorePlatform
|
from app.modules.tenancy.services.platform_service import platform_service
|
||||||
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
store = db.query(Store).filter(Store.id == store_id).first()
|
store = store_service.get_store_by_id_optional(db, store_id)
|
||||||
if not store or not store.merchant_id:
|
if not store or not store.merchant_id:
|
||||||
raise ResourceNotFoundException("Store", str(store_id))
|
raise ResourceNotFoundException("Store", str(store_id))
|
||||||
sp = db.query(StorePlatform.platform_id).filter(
|
if platform_id is None:
|
||||||
StorePlatform.store_id == store_id
|
platform_id = platform_service.get_first_active_platform_id_for_store(db, store_id)
|
||||||
).first()
|
if not platform_id:
|
||||||
if not sp:
|
|
||||||
raise ResourceNotFoundException("StorePlatform", f"store_id={store_id}")
|
raise ResourceNotFoundException("StorePlatform", f"store_id={store_id}")
|
||||||
return store.merchant_id, sp[0]
|
return store.merchant_id, platform_id
|
||||||
|
|
||||||
def get_store_code(self, db: Session, store_id: int) -> str:
|
def get_store_code(self, db: Session, store_id: int) -> str:
|
||||||
"""Get the store_code for a given store_id.
|
"""Get the store_code for a given store_id.
|
||||||
@@ -71,9 +78,9 @@ class SubscriptionService:
|
|||||||
Raises:
|
Raises:
|
||||||
ResourceNotFoundException: If store not found
|
ResourceNotFoundException: If store not found
|
||||||
"""
|
"""
|
||||||
from app.modules.tenancy.models import Store
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
store = db.query(Store).filter(Store.id == store_id).first()
|
store = store_service.get_store_by_id_optional(db, store_id)
|
||||||
if not store:
|
if not store:
|
||||||
raise ResourceNotFoundException("Store", str(store_id))
|
raise ResourceNotFoundException("Store", str(store_id))
|
||||||
return store.store_code
|
return store.store_code
|
||||||
@@ -175,9 +182,10 @@ class SubscriptionService:
|
|||||||
The merchant subscription, or None if the store, merchant,
|
The merchant subscription, or None if the store, merchant,
|
||||||
or platform cannot be resolved.
|
or platform cannot be resolved.
|
||||||
"""
|
"""
|
||||||
from app.modules.tenancy.models import Store
|
from app.modules.tenancy.services.platform_service import platform_service
|
||||||
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
store = db.query(Store).filter(Store.id == store_id).first()
|
store = store_service.get_store_by_id_optional(db, store_id)
|
||||||
if not store:
|
if not store:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -185,17 +193,7 @@ class SubscriptionService:
|
|||||||
if merchant_id is None:
|
if merchant_id is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Get platform_id from store
|
platform_id = platform_service.get_first_active_platform_id_for_store(db, store_id)
|
||||||
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:
|
if platform_id is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -394,5 +392,60 @@ class SubscriptionService:
|
|||||||
return subscription
|
return subscription
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Cross-module public API methods
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def get_active_subscription_platform_ids(
|
||||||
|
self, db: Session, merchant_id: int
|
||||||
|
) -> list[int]:
|
||||||
|
"""
|
||||||
|
Get platform IDs where merchant has active subscriptions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
merchant_id: Merchant ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of platform IDs with active subscriptions
|
||||||
|
"""
|
||||||
|
active_statuses = [
|
||||||
|
SubscriptionStatus.ACTIVE,
|
||||||
|
SubscriptionStatus.TRIAL,
|
||||||
|
]
|
||||||
|
results = (
|
||||||
|
db.query(MerchantSubscription.platform_id)
|
||||||
|
.filter(
|
||||||
|
MerchantSubscription.merchant_id == merchant_id,
|
||||||
|
MerchantSubscription.status.in_(active_statuses),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [r[0] for r in results]
|
||||||
|
|
||||||
|
def get_all_active_subscriptions(
|
||||||
|
self, db: Session
|
||||||
|
) -> list[MerchantSubscription]:
|
||||||
|
"""
|
||||||
|
Get all active/trial subscriptions with tier and feature limits.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of MerchantSubscription objects with eager-loaded tier data
|
||||||
|
"""
|
||||||
|
active_statuses = [
|
||||||
|
SubscriptionStatus.ACTIVE,
|
||||||
|
SubscriptionStatus.TRIAL,
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
db.query(MerchantSubscription)
|
||||||
|
.options(
|
||||||
|
joinedload(MerchantSubscription.tier)
|
||||||
|
.joinedload(SubscriptionTier.feature_limits),
|
||||||
|
)
|
||||||
|
.filter(MerchantSubscription.status.in_(active_statuses))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
# Singleton instance
|
||||||
subscription_service = SubscriptionService()
|
subscription_service = SubscriptionService()
|
||||||
|
|||||||
@@ -14,12 +14,10 @@ and feature_service for limit resolution.
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from sqlalchemy import func
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.modules.billing.models import MerchantSubscription, SubscriptionTier
|
from app.modules.billing.models import MerchantSubscription, SubscriptionTier
|
||||||
from app.modules.billing.services.feature_aggregator import feature_aggregator
|
from app.modules.billing.services.feature_aggregator import feature_aggregator
|
||||||
from app.modules.tenancy.models import StoreUser
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -222,12 +220,9 @@ class UsageService:
|
|||||||
|
|
||||||
def _get_team_member_count(self, db: Session, store_id: int) -> int:
|
def _get_team_member_count(self, db: Session, store_id: int) -> int:
|
||||||
"""Get active team member count for store."""
|
"""Get active team member count for store."""
|
||||||
return (
|
from app.modules.tenancy.services.team_service import team_service
|
||||||
db.query(func.count(StoreUser.id))
|
|
||||||
.filter(StoreUser.store_id == store_id, StoreUser.is_active == True) # noqa: E712
|
return team_service.get_active_team_member_count(db, store_id)
|
||||||
.scalar()
|
|
||||||
or 0
|
|
||||||
)
|
|
||||||
|
|
||||||
def _calculate_usage_metrics(
|
def _calculate_usage_metrics(
|
||||||
self, db: Session, store_id: int, subscription: MerchantSubscription | None
|
self, db: Session, store_id: int, subscription: MerchantSubscription | None
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ function adminSubscriptionTiers() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Load tier's current feature limits
|
// Load tier's current feature limits
|
||||||
const data = await apiClient.get(`/admin/subscriptions/features/tiers/${tier.code}/limits`);
|
const data = await apiClient.get(`/admin/subscriptions/features/tiers/${tier.id}/limits`);
|
||||||
// data is TierFeatureLimitEntry[]: [{feature_code, limit_value, enabled}]
|
// data is TierFeatureLimitEntry[]: [{feature_code, limit_value, enabled}]
|
||||||
this.selectedFeatures = [];
|
this.selectedFeatures = [];
|
||||||
for (const entry of (data || [])) {
|
for (const entry of (data || [])) {
|
||||||
@@ -327,7 +327,7 @@ function adminSubscriptionTiers() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
await apiClient.put(
|
await apiClient.put(
|
||||||
`/admin/subscriptions/features/tiers/${this.selectedTierForFeatures.code}/limits`,
|
`/admin/subscriptions/features/tiers/${this.selectedTierForFeatures.id}/limits`,
|
||||||
entries
|
entries
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@
|
|||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
// Fetch available features (lightweight endpoint)
|
// Fetch available features (lightweight endpoint)
|
||||||
const response = await apiClient.get('/store/features/available');
|
const response = await apiClient.get('/store/billing/features/available');
|
||||||
|
|
||||||
this.features = response.features || [];
|
this.features = response.features || [];
|
||||||
this.tierCode = response.tier_code;
|
this.tierCode = response.tier_code;
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
if (!storeCode) return;
|
if (!storeCode) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/store/features');
|
const response = await apiClient.get('/store/billing/features');
|
||||||
|
|
||||||
// Build map for quick lookup
|
// Build map for quick lookup
|
||||||
this.featuresMap = {};
|
this.featuresMap = {};
|
||||||
@@ -178,15 +178,22 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get store code from URL
|
* Get store code from server-rendered value or URL fallback
|
||||||
* @returns {string|null}
|
* @returns {string|null}
|
||||||
*/
|
*/
|
||||||
getStoreCode() {
|
getStoreCode() {
|
||||||
|
if (window.STORE_CODE) return window.STORE_CODE;
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
const segments = path.split('/').filter(Boolean);
|
const segments = path.split('/').filter(Boolean);
|
||||||
|
// Direct: /store/{code}/...
|
||||||
if (segments[0] === 'store' && segments[1]) {
|
if (segments[0] === 'store' && segments[1]) {
|
||||||
return segments[1];
|
return segments[1];
|
||||||
}
|
}
|
||||||
|
// Platform-prefixed: /platforms/{platform}/store/{code}/...
|
||||||
|
const storeIdx = segments.indexOf('store');
|
||||||
|
if (storeIdx !== -1 && segments[storeIdx + 1]) {
|
||||||
|
return segments[storeIdx + 1];
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
const response = await apiClient.get('/store/usage');
|
const response = await apiClient.get('/store/billing/usage');
|
||||||
this.usage = response;
|
this.usage = response;
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
|
|
||||||
@@ -139,9 +139,10 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get store code from URL
|
* Get store code from server-rendered value or URL fallback
|
||||||
*/
|
*/
|
||||||
getStoreCode() {
|
getStoreCode() {
|
||||||
|
if (window.STORE_CODE) return window.STORE_CODE;
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
const segments = path.split('/').filter(Boolean);
|
const segments = path.split('/').filter(Boolean);
|
||||||
if (segments[0] === 'store' && segments[1]) {
|
if (segments[0] === 'store' && segments[1]) {
|
||||||
|
|||||||
@@ -66,32 +66,23 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Features list (dynamic from module providers) #}
|
||||||
|
{% if tier.features %}
|
||||||
<ul class="space-y-3 mb-8 text-sm">
|
<ul class="space-y-3 mb-8 text-sm">
|
||||||
|
{% for feat in tier.features %}
|
||||||
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
||||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
</svg>
|
</svg>
|
||||||
{% if tier.orders_per_month %}{{ _("cms.platform.pricing.orders_per_month", count=tier.orders_per_month) }}{% else %}{{ _("cms.platform.pricing.unlimited_orders") }}{% endif %}
|
{% if feat.is_quantitative and feat.limit %}
|
||||||
</li>
|
{{ feat.limit }} {{ _(feat.name_key) }}
|
||||||
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
{% else %}
|
||||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
{{ _(feat.name_key) }}
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
{% endif %}
|
||||||
</svg>
|
|
||||||
{% if tier.products_limit %}{{ _("cms.platform.pricing.products_limit", count=tier.products_limit) }}{% else %}{{ _("cms.platform.pricing.unlimited_products") }}{% endif %}
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
|
||||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
{% if tier.team_members %}{{ _("cms.platform.pricing.team_members", count=tier.team_members) }}{% else %}{{ _("cms.platform.pricing.unlimited_team") }}{% endif %}
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
|
||||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
{{ _("cms.platform.pricing.letzshop_sync") }}
|
|
||||||
</li>
|
</li>
|
||||||
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if tier.is_enterprise %}
|
{% if tier.is_enterprise %}
|
||||||
<a href="/contact" class="block w-full py-3 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white font-semibold rounded-xl text-center hover:bg-gray-300 transition-colors">
|
<a href="/contact" class="block w-full py-3 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white font-semibold rounded-xl text-center hover:bg-gray-300 transition-colors">
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{# app/templates/platform/signup.html #}
|
{# app/templates/platform/signup.html #}
|
||||||
{# Multi-step Signup Wizard #}
|
{# 3-Step Signup Wizard: Plan → Account → Payment #}
|
||||||
{% extends "platform/base.html" %}
|
{% extends "platform/base.html" %}
|
||||||
|
|
||||||
{% block title %}Start Your Free Trial - Orion{% endblock %}
|
{% block title %}{{ _("cms.platform.signup.page_title") }} - {{ platform.name if platform else 'Orion' }}{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
{# Stripe.js for payment #}
|
{# Stripe.js for payment #}
|
||||||
@@ -16,8 +16,8 @@
|
|||||||
{# Progress Steps #}
|
{# Progress Steps #}
|
||||||
<div class="mb-12">
|
<div class="mb-12">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<template x-for="(stepName, index) in ['Select Plan', 'Claim Shop', 'Account', 'Payment']" :key="index">
|
<template x-for="(stepName, index) in ['{{ _("cms.platform.signup.step_plan") }}', '{{ _("cms.platform.signup.step_account") }}', '{{ _("cms.platform.signup.step_payment") }}']" :key="index">
|
||||||
<div class="flex items-center" :class="index < 3 ? 'flex-1' : ''">
|
<div class="flex items-center" :class="index < 2 ? 'flex-1' : ''">
|
||||||
<div class="flex items-center justify-center w-10 h-10 rounded-full font-semibold transition-colors"
|
<div class="flex items-center justify-center w-10 h-10 rounded-full font-semibold transition-colors"
|
||||||
:class="currentStep > index + 1 ? 'bg-green-500 text-white' : currentStep === index + 1 ? 'bg-indigo-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'">
|
:class="currentStep > index + 1 ? 'bg-green-500 text-white' : currentStep === index + 1 ? 'bg-indigo-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'">
|
||||||
<template x-if="currentStep > index + 1">
|
<template x-if="currentStep > index + 1">
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<span class="ml-2 text-sm font-medium hidden sm:inline"
|
<span class="ml-2 text-sm font-medium hidden sm:inline"
|
||||||
:class="currentStep >= index + 1 ? 'text-gray-900 dark:text-white' : 'text-gray-500'"
|
:class="currentStep >= index + 1 ? 'text-gray-900 dark:text-white' : 'text-gray-500'"
|
||||||
x-text="stepName"></span>
|
x-text="stepName"></span>
|
||||||
<template x-if="index < 3">
|
<template x-if="index < 2">
|
||||||
<div class="flex-1 h-1 mx-4 bg-gray-200 dark:bg-gray-700 rounded">
|
<div class="flex-1 h-1 mx-4 bg-gray-200 dark:bg-gray-700 rounded">
|
||||||
<div class="h-full bg-indigo-600 rounded transition-all"
|
<div class="h-full bg-indigo-600 rounded transition-all"
|
||||||
:style="'width: ' + (currentStep > index + 1 ? '100%' : '0%')"></div>
|
:style="'width: ' + (currentStep > index + 1 ? '100%' : '0%')"></div>
|
||||||
@@ -50,11 +50,11 @@
|
|||||||
STEP 1: SELECT PLAN
|
STEP 1: SELECT PLAN
|
||||||
=============================================================== #}
|
=============================================================== #}
|
||||||
<div x-show="currentStep === 1" class="p-8">
|
<div x-show="currentStep === 1" class="p-8">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Choose Your Plan</h2>
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">{{ _("cms.platform.signup.choose_plan") }}</h2>
|
||||||
|
|
||||||
{# Billing Toggle #}
|
{# Billing Toggle #}
|
||||||
<div class="flex items-center justify-center mb-8 space-x-4">
|
<div class="flex items-center justify-center mb-8 space-x-4">
|
||||||
<span :class="!isAnnual ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-500'">Monthly</span>
|
<span :class="!isAnnual ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-500'">{{ _("cms.platform.pricing.monthly") }}</span>
|
||||||
<button @click="isAnnual = !isAnnual"
|
<button @click="isAnnual = !isAnnual"
|
||||||
class="relative w-12 h-6 rounded-full transition-colors"
|
class="relative w-12 h-6 rounded-full transition-colors"
|
||||||
:class="isAnnual ? 'bg-indigo-600' : 'bg-gray-300'">
|
:class="isAnnual ? 'bg-indigo-600' : 'bg-gray-300'">
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
:class="isAnnual ? 'translate-x-6' : ''"></span>
|
:class="isAnnual ? 'translate-x-6' : ''"></span>
|
||||||
</button>
|
</button>
|
||||||
<span :class="isAnnual ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-500'">
|
<span :class="isAnnual ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-500'">
|
||||||
Annual <span class="text-green-600 text-xs">Save 17%</span>
|
{{ _("cms.platform.pricing.annual") }} <span class="text-green-600 text-xs">{{ _("cms.platform.signup.save_percent", percent=17) }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -80,17 +80,17 @@
|
|||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-white">{{ tier.name }}</h3>
|
<h3 class="font-semibold text-gray-900 dark:text-white">{{ tier.name }}</h3>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500">
|
||||||
{% if tier.orders_per_month %}{{ tier.orders_per_month }} orders/mo{% else %}Unlimited{% endif %}
|
{% if tier.orders_per_month %}{{ tier.orders_per_month }} {{ _("cms.platform.signup.orders_per_month") }}{% else %}{{ _("cms.platform.signup.unlimited") }}{% endif %}
|
||||||
•
|
•
|
||||||
{% if tier.team_members %}{{ tier.team_members }} user{% if tier.team_members > 1 %}s{% endif %}{% else %}Unlimited{% endif %}
|
{% if tier.team_members %}{{ tier.team_members }} {{ _("cms.platform.signup.team_members", count=tier.team_members) }}{% else %}{{ _("cms.platform.signup.unlimited") }}{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<template x-if="!isAnnual">
|
<template x-if="!isAnnual">
|
||||||
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ tier.price_monthly|int }}€/mo</span>
|
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ tier.price_monthly|int }}€{{ _("cms.platform.signup.per_month_short") }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="isAnnual">
|
<template x-if="isAnnual">
|
||||||
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ ((tier.price_annual or tier.price_monthly * 12) / 12)|round(0)|int }}€/mo</span>
|
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ ((tier.price_annual or tier.price_monthly * 12) / 12)|round(0)|int }}€{{ _("cms.platform.signup.per_month_short") }}</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,82 +103,39 @@
|
|||||||
{# Free Trial Note #}
|
{# Free Trial Note #}
|
||||||
<div class="mt-6 p-4 bg-green-50 dark:bg-green-900/20 rounded-xl">
|
<div class="mt-6 p-4 bg-green-50 dark:bg-green-900/20 rounded-xl">
|
||||||
<p class="text-sm text-green-800 dark:text-green-300">
|
<p class="text-sm text-green-800 dark:text-green-300">
|
||||||
<strong>{{ trial_days }}-day free trial.</strong>
|
<strong>{{ trial_days }}-{{ _("cms.platform.signup.trial_info_days") }}</strong>
|
||||||
We'll collect your payment info, but you won't be charged until the trial ends.
|
{{ _("cms.platform.signup.trial_info") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button @click="startSignup()"
|
<button @click="startSignup()"
|
||||||
:disabled="!selectedTier || loading"
|
:disabled="!selectedTier || loading"
|
||||||
class="mt-8 w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition-colors disabled:opacity-50">
|
class="mt-8 w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition-colors disabled:opacity-50">
|
||||||
Continue
|
{{ _("cms.platform.signup.continue") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ===============================================================
|
{# ===============================================================
|
||||||
STEP 2: CLAIM LETZSHOP SHOP (Optional)
|
STEP 2: CREATE ACCOUNT
|
||||||
=============================================================== #}
|
=============================================================== #}
|
||||||
<div x-show="currentStep === 2" class="p-8">
|
<div x-show="currentStep === 2" class="p-8">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Connect Your Letzshop Shop</h2>
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">{{ _("cms.platform.signup.create_account") }}</h2>
|
||||||
<p class="text-gray-600 dark:text-gray-400 mb-6">Optional: Link your Letzshop account to sync orders automatically.</p>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
x-model="letzshopUrl"
|
|
||||||
placeholder="letzshop.lu/vendors/your-shop"
|
|
||||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<template x-if="letzshopStore">
|
|
||||||
<div class="p-4 bg-green-50 dark:bg-green-900/20 rounded-xl">
|
|
||||||
<p class="text-green-800 dark:text-green-300">
|
|
||||||
Found: <strong x-text="letzshopStore.name"></strong>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="letzshopError">
|
|
||||||
<div class="p-4 bg-red-50 dark:bg-red-900/20 rounded-xl">
|
|
||||||
<p class="text-red-800 dark:text-red-300" x-text="letzshopError"></p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8 flex gap-4">
|
|
||||||
<button @click="currentStep = 1"
|
|
||||||
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
<button @click="claimStore()"
|
|
||||||
:disabled="loading"
|
|
||||||
class="flex-1 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl disabled:opacity-50">
|
|
||||||
<span x-text="letzshopUrl.trim() ? 'Connect & Continue' : 'Skip This Step'"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# ===============================================================
|
|
||||||
STEP 3: CREATE ACCOUNT
|
|
||||||
=============================================================== #}
|
|
||||||
<div x-show="currentStep === 3" class="p-8">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Create Your Account</h2>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||||
<span class="text-red-500">*</span> Required fields
|
<span class="text-red-500">*</span> {{ _("cms.platform.signup.required_fields") }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
First Name <span class="text-red-500">*</span>
|
{{ _("cms.platform.signup.first_name") }} <span class="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text" x-model="account.firstName" required
|
<input type="text" x-model="account.firstName" required
|
||||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Last Name <span class="text-red-500">*</span>
|
{{ _("cms.platform.signup.last_name") }} <span class="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text" x-model="account.lastName" required
|
<input type="text" x-model="account.lastName" required
|
||||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
||||||
@@ -187,7 +144,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Merchant Name <span class="text-red-500">*</span>
|
{{ _("cms.platform.signup.merchant_name") }} <span class="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text" x-model="account.merchantName" required
|
<input type="text" x-model="account.merchantName" required
|
||||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
||||||
@@ -195,7 +152,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Email <span class="text-red-500">*</span>
|
{{ _("cms.platform.signup.email") }} <span class="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="email" x-model="account.email" required
|
<input type="email" x-model="account.email" required
|
||||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
||||||
@@ -203,11 +160,11 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Password <span class="text-red-500">*</span>
|
{{ _("cms.platform.signup.password") }} <span class="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="password" x-model="account.password" required minlength="8"
|
<input type="password" x-model="account.password" required minlength="8"
|
||||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
||||||
<p class="text-xs text-gray-500 mt-1">Minimum 8 characters</p>
|
<p class="text-xs text-gray-500 mt-1">{{ _("cms.platform.signup.password_hint") }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template x-if="accountError">
|
<template x-if="accountError">
|
||||||
@@ -218,42 +175,42 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 flex gap-4">
|
<div class="mt-8 flex gap-4">
|
||||||
<button @click="currentStep = 2"
|
<button @click="currentStep = 1"
|
||||||
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
|
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
|
||||||
Back
|
{{ _("cms.platform.signup.back") }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="createAccount()"
|
<button @click="createAccount()"
|
||||||
:disabled="loading || !isAccountValid()"
|
:disabled="loading || !isAccountValid()"
|
||||||
class="flex-1 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl disabled:opacity-50">
|
class="flex-1 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl disabled:opacity-50">
|
||||||
Continue to Payment
|
{{ _("cms.platform.signup.continue_payment") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ===============================================================
|
{# ===============================================================
|
||||||
STEP 4: PAYMENT
|
STEP 3: PAYMENT
|
||||||
=============================================================== #}
|
=============================================================== #}
|
||||||
<div x-show="currentStep === 4" class="p-8">
|
<div x-show="currentStep === 3" class="p-8">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Add Payment Method</h2>
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">{{ _("cms.platform.signup.add_payment") }}</h2>
|
||||||
<p class="text-gray-600 dark:text-gray-400 mb-6">You won't be charged until your {{ trial_days }}-day trial ends.</p>
|
<p class="text-gray-600 dark:text-gray-400 mb-6">{{ _("cms.platform.signup.no_charge_note", trial_days=trial_days) }}</p>
|
||||||
|
|
||||||
{# Stripe Card Element #}
|
{# Stripe Card Element #}
|
||||||
<div id="card-element" class="p-4 border border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-900"></div>
|
<div id="card-element" class="p-4 border border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-900"></div>
|
||||||
<div id="card-errors" class="text-red-600 text-sm mt-2"></div>
|
<div id="card-errors" class="text-red-600 text-sm mt-2"></div>
|
||||||
|
|
||||||
<div class="mt-8 flex gap-4">
|
<div class="mt-8 flex gap-4">
|
||||||
<button @click="currentStep = 3"
|
<button @click="currentStep = 2"
|
||||||
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
|
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
|
||||||
Back
|
{{ _("cms.platform.signup.back") }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="submitPayment()"
|
<button @click="submitPayment()"
|
||||||
:disabled="loading || paymentProcessing"
|
:disabled="loading || paymentProcessing"
|
||||||
class="flex-1 py-3 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-xl disabled:opacity-50">
|
class="flex-1 py-3 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-xl disabled:opacity-50">
|
||||||
<template x-if="paymentProcessing">
|
<template x-if="paymentProcessing">
|
||||||
<span>Processing...</span>
|
<span>{{ _("cms.platform.signup.processing") }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!paymentProcessing">
|
<template x-if="!paymentProcessing">
|
||||||
<span>Start Free Trial</span>
|
<span>{{ _("cms.platform.signup.start_trial") }}</span>
|
||||||
</template>
|
</template>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -267,6 +224,13 @@
|
|||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
<script>
|
<script>
|
||||||
function signupWizard() {
|
function signupWizard() {
|
||||||
|
const MSGS = {
|
||||||
|
failedStart: '{{ _("cms.platform.signup.error_start") }}',
|
||||||
|
failedAccount: '{{ _("cms.platform.signup.error_account") }}',
|
||||||
|
paymentNotConfigured: '{{ _("cms.platform.signup.error_payment_config") }}',
|
||||||
|
paymentFailed: '{{ _("cms.platform.signup.error_payment") }}',
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentStep: 1,
|
currentStep: 1,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -276,12 +240,7 @@ function signupWizard() {
|
|||||||
selectedTier: '{{ selected_tier or "professional" }}',
|
selectedTier: '{{ selected_tier or "professional" }}',
|
||||||
isAnnual: {{ 'true' if is_annual else 'false' }},
|
isAnnual: {{ 'true' if is_annual else 'false' }},
|
||||||
|
|
||||||
// Step 2: Letzshop
|
// Step 2: Account
|
||||||
letzshopUrl: '',
|
|
||||||
letzshopStore: null,
|
|
||||||
letzshopError: null,
|
|
||||||
|
|
||||||
// Step 3: Account
|
|
||||||
account: {
|
account: {
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
@@ -291,7 +250,7 @@ function signupWizard() {
|
|||||||
},
|
},
|
||||||
accountError: null,
|
accountError: null,
|
||||||
|
|
||||||
// Step 4: Payment
|
// Step 3: Payment
|
||||||
stripe: null,
|
stripe: null,
|
||||||
cardElement: null,
|
cardElement: null,
|
||||||
paymentProcessing: false,
|
paymentProcessing: false,
|
||||||
@@ -306,13 +265,10 @@ function signupWizard() {
|
|||||||
if (params.get('annual') === 'true') {
|
if (params.get('annual') === 'true') {
|
||||||
this.isAnnual = true;
|
this.isAnnual = true;
|
||||||
}
|
}
|
||||||
if (params.get('letzshop')) {
|
|
||||||
this.letzshopUrl = params.get('letzshop');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize Stripe when we get to step 4
|
// Initialize Stripe when we get to step 3
|
||||||
this.$watch('currentStep', (step) => {
|
this.$watch('currentStep', (step) => {
|
||||||
if (step === 4) {
|
if (step === 3) {
|
||||||
this.initStripe();
|
this.initStripe();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -326,7 +282,9 @@ function signupWizard() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
tier_code: this.selectedTier,
|
tier_code: this.selectedTier,
|
||||||
is_annual: this.isAnnual
|
is_annual: this.isAnnual,
|
||||||
|
platform_code: '{{ platform.code }}',
|
||||||
|
language: '{{ current_language|default("fr") }}'
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -335,69 +293,16 @@ function signupWizard() {
|
|||||||
this.sessionId = data.session_id;
|
this.sessionId = data.session_id;
|
||||||
this.currentStep = 2;
|
this.currentStep = 2;
|
||||||
} else {
|
} else {
|
||||||
alert(data.detail || 'Failed to start signup');
|
alert(data.detail || MSGS.failedStart);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
alert('Failed to start signup. Please try again.');
|
alert(MSGS.failedStart);
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async claimStore() {
|
|
||||||
if (this.letzshopUrl.trim()) {
|
|
||||||
this.loading = true;
|
|
||||||
this.letzshopError = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 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 })
|
|
||||||
});
|
|
||||||
|
|
||||||
const lookupData = await lookupResponse.json();
|
|
||||||
|
|
||||||
if (lookupData.found && !lookupData.store.is_claimed) {
|
|
||||||
this.letzshopStore = lookupData.store;
|
|
||||||
|
|
||||||
// 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.store.slug
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (claimResponse.ok) {
|
|
||||||
const claimData = await claimResponse.json();
|
|
||||||
this.account.merchantName = claimData.store_name || '';
|
|
||||||
this.currentStep = 3;
|
|
||||||
} else {
|
|
||||||
const error = await claimResponse.json();
|
|
||||||
this.letzshopError = error.detail || 'Failed to claim store';
|
|
||||||
}
|
|
||||||
} 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 store.';
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Skip this step
|
|
||||||
this.currentStep = 3;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
isAccountValid() {
|
isAccountValid() {
|
||||||
return this.account.firstName.trim() &&
|
return this.account.firstName.trim() &&
|
||||||
this.account.lastName.trim() &&
|
this.account.lastName.trim() &&
|
||||||
@@ -426,13 +331,13 @@ function signupWizard() {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
this.currentStep = 4;
|
this.currentStep = 3;
|
||||||
} else {
|
} else {
|
||||||
this.accountError = data.detail || 'Failed to create account';
|
this.accountError = data.detail || MSGS.failedAccount;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
this.accountError = 'Failed to create account. Please try again.';
|
this.accountError = MSGS.failedAccount;
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
@@ -481,7 +386,7 @@ function signupWizard() {
|
|||||||
|
|
||||||
async submitPayment() {
|
async submitPayment() {
|
||||||
if (!this.stripe || !this.clientSecret) {
|
if (!this.stripe || !this.clientSecret) {
|
||||||
alert('Payment not configured. Please contact support.');
|
alert(MSGS.paymentNotConfigured);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,15 +420,14 @@ function signupWizard() {
|
|||||||
if (data.access_token) {
|
if (data.access_token) {
|
||||||
localStorage.setItem('store_token', data.access_token);
|
localStorage.setItem('store_token', data.access_token);
|
||||||
localStorage.setItem('storeCode', data.store_code);
|
localStorage.setItem('storeCode', data.store_code);
|
||||||
console.log('Store token stored for automatic login');
|
|
||||||
}
|
}
|
||||||
window.location.href = '/signup/success?store_code=' + data.store_code;
|
window.location.href = '/signup/success?store_code=' + data.store_code;
|
||||||
} else {
|
} else {
|
||||||
alert(data.detail || 'Failed to complete signup');
|
alert(data.detail || MSGS.paymentFailed);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Payment error:', error);
|
console.error('Payment error:', error);
|
||||||
alert('Payment failed. Please try again.');
|
alert(MSGS.paymentFailed);
|
||||||
} finally {
|
} finally {
|
||||||
this.paymentProcessing = false;
|
this.paymentProcessing = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
# app/modules/billing/tests/integration/test_admin_features_routes.py
|
||||||
|
"""
|
||||||
|
Integration tests for admin feature management API routes.
|
||||||
|
|
||||||
|
Tests the feature limit endpoints at:
|
||||||
|
/api/v1/admin/subscriptions/features/*
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- GET /features/catalog
|
||||||
|
- GET /features/tiers/{tier_id}/limits
|
||||||
|
- PUT /features/tiers/{tier_id}/limits
|
||||||
|
- Regression: tiers with duplicate codes across platforms are isolated by tier_id
|
||||||
|
|
||||||
|
Uses super_admin_headers fixture which bypasses module access checks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.modules.billing.models import SubscriptionTier
|
||||||
|
from app.modules.billing.models.tier_feature_limit import TierFeatureLimit
|
||||||
|
from app.modules.tenancy.models import Platform
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Fixtures
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
BASE = "/api/v1/admin/subscriptions/features"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ft_platform(db):
|
||||||
|
"""Create a platform for feature route tests."""
|
||||||
|
platform = Platform(
|
||||||
|
code=f"feat_{uuid.uuid4().hex[:8]}",
|
||||||
|
name="Feature Test Platform",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(platform)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(platform)
|
||||||
|
return platform
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ft_second_platform(db):
|
||||||
|
"""Second platform for cross-platform isolation tests."""
|
||||||
|
platform = Platform(
|
||||||
|
code=f"feat2_{uuid.uuid4().hex[:8]}",
|
||||||
|
name="Feature Test Platform 2",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(platform)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(platform)
|
||||||
|
return platform
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ft_tier(db, ft_platform):
|
||||||
|
"""Create a tier for feature route tests."""
|
||||||
|
tier = SubscriptionTier(
|
||||||
|
code=f"essential_{uuid.uuid4().hex[:6]}",
|
||||||
|
name="Essential",
|
||||||
|
price_monthly_cents=1000,
|
||||||
|
display_order=0,
|
||||||
|
is_active=True,
|
||||||
|
is_public=True,
|
||||||
|
platform_id=ft_platform.id,
|
||||||
|
)
|
||||||
|
db.add(tier)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(tier)
|
||||||
|
return tier
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ft_duplicate_code_tiers(db, ft_platform, ft_second_platform):
|
||||||
|
"""Create two tiers with the SAME code but different platforms.
|
||||||
|
|
||||||
|
This is the exact scenario that caused the tier_code ambiguity bug.
|
||||||
|
"""
|
||||||
|
tier_a = SubscriptionTier(
|
||||||
|
code="essential",
|
||||||
|
name="Essential (Platform A)",
|
||||||
|
price_monthly_cents=1000,
|
||||||
|
display_order=0,
|
||||||
|
is_active=True,
|
||||||
|
is_public=True,
|
||||||
|
platform_id=ft_platform.id,
|
||||||
|
)
|
||||||
|
tier_b = SubscriptionTier(
|
||||||
|
code="essential",
|
||||||
|
name="Essential (Platform B)",
|
||||||
|
price_monthly_cents=2000,
|
||||||
|
display_order=0,
|
||||||
|
is_active=True,
|
||||||
|
is_public=True,
|
||||||
|
platform_id=ft_second_platform.id,
|
||||||
|
)
|
||||||
|
db.add(tier_a)
|
||||||
|
db.add(tier_b)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(tier_a)
|
||||||
|
db.refresh(tier_b)
|
||||||
|
return tier_a, tier_b
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ft_tier_with_features(db, ft_tier):
|
||||||
|
"""Pre-populate a tier with feature limits."""
|
||||||
|
features = [
|
||||||
|
TierFeatureLimit(tier_id=ft_tier.id, feature_code="basic_shop", limit_value=None),
|
||||||
|
TierFeatureLimit(tier_id=ft_tier.id, feature_code="team_members", limit_value=5),
|
||||||
|
]
|
||||||
|
db.add_all(features)
|
||||||
|
db.commit()
|
||||||
|
# Refresh so the tier's selectin-loaded feature_limits relationship is up to date
|
||||||
|
db.refresh(ft_tier)
|
||||||
|
return features
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Feature Catalog
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.billing
|
||||||
|
class TestFeatureCatalog:
|
||||||
|
"""Tests for GET /features/catalog."""
|
||||||
|
|
||||||
|
def test_get_catalog(self, client, super_admin_headers):
|
||||||
|
"""Returns the feature catalog grouped by category."""
|
||||||
|
response = client.get(f"{BASE}/catalog", headers=super_admin_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "features" in data
|
||||||
|
assert isinstance(data["features"], dict)
|
||||||
|
|
||||||
|
def test_catalog_requires_auth(self, client):
|
||||||
|
"""Catalog endpoint requires authentication."""
|
||||||
|
response = client.get(f"{BASE}/catalog")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# GET Tier Feature Limits
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.billing
|
||||||
|
class TestGetTierFeatureLimits:
|
||||||
|
"""Tests for GET /features/tiers/{tier_id}/limits."""
|
||||||
|
|
||||||
|
def test_get_limits_empty(self, client, super_admin_headers, ft_tier):
|
||||||
|
"""Returns empty list for tier with no features."""
|
||||||
|
response = client.get(
|
||||||
|
f"{BASE}/tiers/{ft_tier.id}/limits",
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == []
|
||||||
|
|
||||||
|
def test_get_limits_with_features(
|
||||||
|
self, client, super_admin_headers, ft_tier, ft_tier_with_features
|
||||||
|
):
|
||||||
|
"""Returns feature limit entries for a tier."""
|
||||||
|
response = client.get(
|
||||||
|
f"{BASE}/tiers/{ft_tier.id}/limits",
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data) == 2
|
||||||
|
codes = {e["feature_code"] for e in data}
|
||||||
|
assert codes == {"basic_shop", "team_members"}
|
||||||
|
# Check limit values
|
||||||
|
for entry in data:
|
||||||
|
assert entry["enabled"] is True
|
||||||
|
if entry["feature_code"] == "team_members":
|
||||||
|
assert entry["limit_value"] == 5
|
||||||
|
else:
|
||||||
|
assert entry["limit_value"] is None
|
||||||
|
|
||||||
|
def test_get_limits_requires_auth(self, client, ft_tier):
|
||||||
|
"""Endpoint requires authentication."""
|
||||||
|
response = client.get(f"{BASE}/tiers/{ft_tier.id}/limits")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# PUT Tier Feature Limits
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.billing
|
||||||
|
class TestUpsertTierFeatureLimits:
|
||||||
|
"""Tests for PUT /features/tiers/{tier_id}/limits."""
|
||||||
|
|
||||||
|
def test_save_features(self, client, super_admin_headers, ft_tier):
|
||||||
|
"""Saves feature limits and returns the saved entries."""
|
||||||
|
# Get valid feature codes from catalog
|
||||||
|
catalog = client.get(f"{BASE}/catalog", headers=super_admin_headers).json()
|
||||||
|
all_codes = []
|
||||||
|
for features in catalog["features"].values():
|
||||||
|
for f in features:
|
||||||
|
all_codes.append(f["code"])
|
||||||
|
|
||||||
|
# Use the first two valid codes
|
||||||
|
entries = [
|
||||||
|
{"feature_code": all_codes[0], "limit_value": None, "enabled": True},
|
||||||
|
{"feature_code": all_codes[1], "limit_value": 10, "enabled": True},
|
||||||
|
]
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"{BASE}/tiers/{ft_tier.id}/limits",
|
||||||
|
json=entries,
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data) == 2
|
||||||
|
|
||||||
|
def test_save_replaces_existing(
|
||||||
|
self, client, super_admin_headers, ft_tier, ft_tier_with_features
|
||||||
|
):
|
||||||
|
"""Saving new features replaces the old ones entirely."""
|
||||||
|
# Get a valid feature code
|
||||||
|
catalog = client.get(f"{BASE}/catalog", headers=super_admin_headers).json()
|
||||||
|
valid_code = next(
|
||||||
|
f["code"]
|
||||||
|
for features in catalog["features"].values()
|
||||||
|
for f in features
|
||||||
|
)
|
||||||
|
|
||||||
|
entries = [
|
||||||
|
{"feature_code": valid_code, "limit_value": None, "enabled": True},
|
||||||
|
]
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"{BASE}/tiers/{ft_tier.id}/limits",
|
||||||
|
json=entries,
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data) == 1
|
||||||
|
|
||||||
|
# Verify old features are gone
|
||||||
|
get_response = client.get(
|
||||||
|
f"{BASE}/tiers/{ft_tier.id}/limits",
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert len(get_response.json()) == 1
|
||||||
|
|
||||||
|
def test_save_empty_clears_features(
|
||||||
|
self, client, super_admin_headers, ft_tier, ft_tier_with_features
|
||||||
|
):
|
||||||
|
"""Saving an empty list removes all features."""
|
||||||
|
response = client.put(
|
||||||
|
f"{BASE}/tiers/{ft_tier.id}/limits",
|
||||||
|
json=[],
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == []
|
||||||
|
|
||||||
|
# Verify cleared
|
||||||
|
get_response = client.get(
|
||||||
|
f"{BASE}/tiers/{ft_tier.id}/limits",
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert get_response.json() == []
|
||||||
|
|
||||||
|
def test_save_rejects_invalid_feature_codes(self, client, super_admin_headers, ft_tier):
|
||||||
|
"""Returns error for unknown feature codes."""
|
||||||
|
entries = [
|
||||||
|
{"feature_code": "totally_fake_feature_xyz", "limit_value": None, "enabled": True},
|
||||||
|
]
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"{BASE}/tiers/{ft_tier.id}/limits",
|
||||||
|
json=entries,
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
# Should fail validation
|
||||||
|
assert response.status_code in (400, 422)
|
||||||
|
|
||||||
|
def test_save_requires_auth(self, client, ft_tier):
|
||||||
|
"""Endpoint requires authentication."""
|
||||||
|
response = client.put(
|
||||||
|
f"{BASE}/tiers/{ft_tier.id}/limits",
|
||||||
|
json=[],
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Cross-Platform Isolation (Regression Test)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.billing
|
||||||
|
class TestCrossPlatformIsolation:
|
||||||
|
"""
|
||||||
|
Regression tests for the tier_code ambiguity bug.
|
||||||
|
|
||||||
|
When multiple tiers share the same code (e.g., "essential" across platforms),
|
||||||
|
feature operations must use tier_id to avoid saving to the wrong tier.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_features_saved_to_correct_tier(
|
||||||
|
self, client, super_admin_headers, ft_duplicate_code_tiers
|
||||||
|
):
|
||||||
|
"""Features saved by tier_id go to the correct tier, not the first match."""
|
||||||
|
tier_a, tier_b = ft_duplicate_code_tiers
|
||||||
|
|
||||||
|
# Get valid feature codes
|
||||||
|
catalog = client.get(f"{BASE}/catalog", headers=super_admin_headers).json()
|
||||||
|
codes = [
|
||||||
|
f["code"]
|
||||||
|
for features in catalog["features"].values()
|
||||||
|
for f in features
|
||||||
|
]
|
||||||
|
|
||||||
|
# Save features to tier B (second platform)
|
||||||
|
entries_b = [
|
||||||
|
{"feature_code": codes[0], "limit_value": None, "enabled": True},
|
||||||
|
{"feature_code": codes[1], "limit_value": 50, "enabled": True},
|
||||||
|
]
|
||||||
|
response = client.put(
|
||||||
|
f"{BASE}/tiers/{tier_b.id}/limits",
|
||||||
|
json=entries_b,
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(response.json()) == 2
|
||||||
|
|
||||||
|
# Tier A should still have 0 features
|
||||||
|
response_a = client.get(
|
||||||
|
f"{BASE}/tiers/{tier_a.id}/limits",
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response_a.status_code == 200
|
||||||
|
assert len(response_a.json()) == 0
|
||||||
|
|
||||||
|
# Tier B should have 2 features
|
||||||
|
response_b = client.get(
|
||||||
|
f"{BASE}/tiers/{tier_b.id}/limits",
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response_b.status_code == 200
|
||||||
|
assert len(response_b.json()) == 2
|
||||||
|
|
||||||
|
def test_features_do_not_leak_between_same_code_tiers(
|
||||||
|
self, client, super_admin_headers, ft_duplicate_code_tiers
|
||||||
|
):
|
||||||
|
"""Saving features to one tier doesn't affect another with the same code."""
|
||||||
|
tier_a, tier_b = ft_duplicate_code_tiers
|
||||||
|
|
||||||
|
# Get valid feature codes
|
||||||
|
catalog = client.get(f"{BASE}/catalog", headers=super_admin_headers).json()
|
||||||
|
codes = [
|
||||||
|
f["code"]
|
||||||
|
for features in catalog["features"].values()
|
||||||
|
for f in features
|
||||||
|
]
|
||||||
|
|
||||||
|
# Save different features to each tier
|
||||||
|
client.put(
|
||||||
|
f"{BASE}/tiers/{tier_a.id}/limits",
|
||||||
|
json=[{"feature_code": codes[0], "limit_value": None, "enabled": True}],
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
client.put(
|
||||||
|
f"{BASE}/tiers/{tier_b.id}/limits",
|
||||||
|
json=[{"feature_code": codes[1], "limit_value": None, "enabled": True}],
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Each tier should have exactly its own feature
|
||||||
|
resp_a = client.get(f"{BASE}/tiers/{tier_a.id}/limits", headers=super_admin_headers)
|
||||||
|
resp_b = client.get(f"{BASE}/tiers/{tier_b.id}/limits", headers=super_admin_headers)
|
||||||
|
|
||||||
|
assert len(resp_a.json()) == 1
|
||||||
|
assert resp_a.json()[0]["feature_code"] == codes[0]
|
||||||
|
|
||||||
|
assert len(resp_b.json()) == 1
|
||||||
|
assert resp_b.json()[0]["feature_code"] == codes[1]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Feature Count in Tier List (End-to-End)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.billing
|
||||||
|
class TestTierListFeatureCount:
|
||||||
|
"""Tests that the tier list endpoint includes correct feature counts."""
|
||||||
|
|
||||||
|
def test_tier_list_includes_feature_codes(
|
||||||
|
self, client, super_admin_headers, ft_tier, ft_tier_with_features
|
||||||
|
):
|
||||||
|
"""GET /tiers returns feature_codes for each tier."""
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/admin/subscriptions/tiers",
|
||||||
|
headers=super_admin_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Find our test tier in the response
|
||||||
|
tiers = response.json()["tiers"]
|
||||||
|
our_tier = next((t for t in tiers if t["id"] == ft_tier.id), None)
|
||||||
|
assert our_tier is not None
|
||||||
|
assert len(our_tier["feature_codes"]) == 2
|
||||||
|
assert set(our_tier["feature_codes"]) == {"basic_shop", "team_members"}
|
||||||
@@ -23,8 +23,8 @@ from app.modules.billing.models import (
|
|||||||
SubscriptionTier,
|
SubscriptionTier,
|
||||||
)
|
)
|
||||||
from app.modules.tenancy.models import Merchant, Platform, User
|
from app.modules.tenancy.models import Merchant, Platform, User
|
||||||
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
from main import app
|
from main import app
|
||||||
from models.schema.auth import UserContext
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Fixtures
|
# Fixtures
|
||||||
|
|||||||
@@ -83,13 +83,12 @@ def billing_extra_platforms(db):
|
|||||||
"""Create additional platforms for multiple subscriptions (unique constraint: merchant+platform)."""
|
"""Create additional platforms for multiple subscriptions (unique constraint: merchant+platform)."""
|
||||||
platforms = []
|
platforms = []
|
||||||
for i in range(2):
|
for i in range(2):
|
||||||
p = Platform(
|
platforms.append(Platform(
|
||||||
code=f"bm_extra_{uuid.uuid4().hex[:8]}",
|
code=f"bm_extra_{uuid.uuid4().hex[:8]}",
|
||||||
name=f"Extra Platform {i}",
|
name=f"Extra Platform {i}",
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
))
|
||||||
db.add(p)
|
db.add_all(platforms)
|
||||||
platforms.append(p)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
for p in platforms:
|
for p in platforms:
|
||||||
db.refresh(p)
|
db.refresh(p)
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from app.modules.billing.models import SubscriptionTier
|
||||||
|
from app.modules.billing.models.tier_feature_limit import TierFeatureLimit
|
||||||
from app.modules.billing.services.feature_service import FeatureService
|
from app.modules.billing.services.feature_service import FeatureService
|
||||||
|
from app.modules.tenancy.models import Platform
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@@ -16,3 +19,222 @@ class TestFeatureService:
|
|||||||
def test_service_instantiation(self):
|
def test_service_instantiation(self):
|
||||||
"""Service can be instantiated."""
|
"""Service can be instantiated."""
|
||||||
assert self.service is not None
|
assert self.service is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Fixtures
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fs_platform(db):
|
||||||
|
"""Create a platform for feature service tests."""
|
||||||
|
platform = Platform(code="fs_test", name="FS Test Platform", is_active=True)
|
||||||
|
db.add(platform)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(platform)
|
||||||
|
return platform
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fs_second_platform(db):
|
||||||
|
"""Create a second platform to test cross-platform isolation."""
|
||||||
|
platform = Platform(code="fs_test2", name="FS Test Platform 2", is_active=True)
|
||||||
|
db.add(platform)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(platform)
|
||||||
|
return platform
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fs_tier(db, fs_platform):
|
||||||
|
"""Create a tier for feature service tests."""
|
||||||
|
tier = SubscriptionTier(
|
||||||
|
code="essential",
|
||||||
|
name="Essential",
|
||||||
|
price_monthly_cents=1000,
|
||||||
|
display_order=0,
|
||||||
|
is_active=True,
|
||||||
|
is_public=True,
|
||||||
|
platform_id=fs_platform.id,
|
||||||
|
)
|
||||||
|
db.add(tier)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(tier)
|
||||||
|
return tier
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fs_same_code_tier(db, fs_second_platform):
|
||||||
|
"""Create a tier with the SAME code but different platform."""
|
||||||
|
tier = SubscriptionTier(
|
||||||
|
code="essential",
|
||||||
|
name="Essential",
|
||||||
|
price_monthly_cents=2000,
|
||||||
|
display_order=0,
|
||||||
|
is_active=True,
|
||||||
|
is_public=True,
|
||||||
|
platform_id=fs_second_platform.id,
|
||||||
|
)
|
||||||
|
db.add(tier)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(tier)
|
||||||
|
return tier
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fs_tier_with_features(db, fs_tier):
|
||||||
|
"""Create a tier with pre-existing feature limits."""
|
||||||
|
features = [
|
||||||
|
TierFeatureLimit(tier_id=fs_tier.id, feature_code="feature_a", limit_value=None),
|
||||||
|
TierFeatureLimit(tier_id=fs_tier.id, feature_code="feature_b", limit_value=100),
|
||||||
|
TierFeatureLimit(tier_id=fs_tier.id, feature_code="feature_c", limit_value=50),
|
||||||
|
]
|
||||||
|
db.add_all(features)
|
||||||
|
db.commit()
|
||||||
|
return features
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# get_tier_feature_limits
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.billing
|
||||||
|
class TestGetTierFeatureLimits:
|
||||||
|
"""Tests for FeatureService.get_tier_feature_limits."""
|
||||||
|
|
||||||
|
def test_returns_limits_for_tier(self, db, fs_tier_with_features, fs_tier):
|
||||||
|
"""Returns all feature limit rows for the given tier ID."""
|
||||||
|
service = FeatureService()
|
||||||
|
rows = service.get_tier_feature_limits(db, fs_tier.id)
|
||||||
|
assert len(rows) == 3
|
||||||
|
codes = {r.feature_code for r in rows}
|
||||||
|
assert codes == {"feature_a", "feature_b", "feature_c"}
|
||||||
|
|
||||||
|
def test_returns_empty_for_tier_without_features(self, db, fs_tier):
|
||||||
|
"""Returns empty list for a tier with no feature limits."""
|
||||||
|
service = FeatureService()
|
||||||
|
rows = service.get_tier_feature_limits(db, fs_tier.id)
|
||||||
|
assert rows == []
|
||||||
|
|
||||||
|
def test_returns_empty_for_nonexistent_tier(self, db):
|
||||||
|
"""Returns empty list for a tier ID that doesn't exist."""
|
||||||
|
service = FeatureService()
|
||||||
|
rows = service.get_tier_feature_limits(db, 999999)
|
||||||
|
assert rows == []
|
||||||
|
|
||||||
|
def test_isolates_by_tier_id(self, db, fs_tier, fs_same_code_tier, fs_tier_with_features):
|
||||||
|
"""Features for one tier don't leak to another with the same code."""
|
||||||
|
service = FeatureService()
|
||||||
|
|
||||||
|
# fs_tier has 3 features
|
||||||
|
rows_tier1 = service.get_tier_feature_limits(db, fs_tier.id)
|
||||||
|
assert len(rows_tier1) == 3
|
||||||
|
|
||||||
|
# fs_same_code_tier (same code, different platform) has 0
|
||||||
|
rows_tier2 = service.get_tier_feature_limits(db, fs_same_code_tier.id)
|
||||||
|
assert len(rows_tier2) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# upsert_tier_feature_limits
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.billing
|
||||||
|
class TestUpsertTierFeatureLimits:
|
||||||
|
"""Tests for FeatureService.upsert_tier_feature_limits."""
|
||||||
|
|
||||||
|
def test_inserts_new_features(self, db, fs_tier):
|
||||||
|
"""Creates feature limit rows for a tier."""
|
||||||
|
service = FeatureService()
|
||||||
|
entries = [
|
||||||
|
{"feature_code": "feat_x", "limit_value": None, "enabled": True},
|
||||||
|
{"feature_code": "feat_y", "limit_value": 200, "enabled": True},
|
||||||
|
]
|
||||||
|
rows = service.upsert_tier_feature_limits(db, fs_tier.id, entries)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert len(rows) == 2
|
||||||
|
assert {r.feature_code for r in rows} == {"feat_x", "feat_y"}
|
||||||
|
|
||||||
|
def test_replaces_existing_features(self, db, fs_tier, fs_tier_with_features):
|
||||||
|
"""Upsert deletes old features and inserts new ones."""
|
||||||
|
service = FeatureService()
|
||||||
|
entries = [
|
||||||
|
{"feature_code": "new_feature", "limit_value": None, "enabled": True},
|
||||||
|
]
|
||||||
|
rows = service.upsert_tier_feature_limits(db, fs_tier.id, entries)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].feature_code == "new_feature"
|
||||||
|
|
||||||
|
# Old features should be gone
|
||||||
|
remaining = service.get_tier_feature_limits(db, fs_tier.id)
|
||||||
|
assert len(remaining) == 1
|
||||||
|
assert remaining[0].feature_code == "new_feature"
|
||||||
|
|
||||||
|
def test_skips_disabled_entries(self, db, fs_tier):
|
||||||
|
"""Entries with enabled=False are not persisted."""
|
||||||
|
service = FeatureService()
|
||||||
|
entries = [
|
||||||
|
{"feature_code": "enabled_feat", "limit_value": None, "enabled": True},
|
||||||
|
{"feature_code": "disabled_feat", "limit_value": None, "enabled": False},
|
||||||
|
]
|
||||||
|
rows = service.upsert_tier_feature_limits(db, fs_tier.id, entries)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].feature_code == "enabled_feat"
|
||||||
|
|
||||||
|
def test_saves_to_correct_tier_by_id(self, db, fs_tier, fs_same_code_tier):
|
||||||
|
"""
|
||||||
|
Regression test: saving by tier_id targets the exact tier,
|
||||||
|
not another tier that happens to share the same code.
|
||||||
|
"""
|
||||||
|
service = FeatureService()
|
||||||
|
entries = [
|
||||||
|
{"feature_code": "platform_specific", "limit_value": None, "enabled": True},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Save to the second tier (same code "essential", different platform)
|
||||||
|
service.upsert_tier_feature_limits(db, fs_same_code_tier.id, entries)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# First tier should have 0 features
|
||||||
|
rows_tier1 = service.get_tier_feature_limits(db, fs_tier.id)
|
||||||
|
assert len(rows_tier1) == 0
|
||||||
|
|
||||||
|
# Second tier should have 1 feature
|
||||||
|
rows_tier2 = service.get_tier_feature_limits(db, fs_same_code_tier.id)
|
||||||
|
assert len(rows_tier2) == 1
|
||||||
|
assert rows_tier2[0].feature_code == "platform_specific"
|
||||||
|
|
||||||
|
def test_clears_all_features_with_empty_list(self, db, fs_tier, fs_tier_with_features):
|
||||||
|
"""Passing an empty list removes all features."""
|
||||||
|
service = FeatureService()
|
||||||
|
rows = service.upsert_tier_feature_limits(db, fs_tier.id, [])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert len(rows) == 0
|
||||||
|
remaining = service.get_tier_feature_limits(db, fs_tier.id)
|
||||||
|
assert len(remaining) == 0
|
||||||
|
|
||||||
|
def test_preserves_limit_values(self, db, fs_tier):
|
||||||
|
"""Limit values (including None for unlimited) are stored correctly."""
|
||||||
|
service = FeatureService()
|
||||||
|
entries = [
|
||||||
|
{"feature_code": "unlimited", "limit_value": None, "enabled": True},
|
||||||
|
{"feature_code": "limited", "limit_value": 42, "enabled": True},
|
||||||
|
]
|
||||||
|
service.upsert_tier_feature_limits(db, fs_tier.id, entries)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
rows = service.get_tier_feature_limits(db, fs_tier.id)
|
||||||
|
limits = {r.feature_code: r.limit_value for r in rows}
|
||||||
|
assert limits["unlimited"] is None
|
||||||
|
assert limits["limited"] == 42
|
||||||
|
|||||||
364
app/modules/billing/tests/unit/test_signup_service.py
Normal file
364
app/modules/billing/tests/unit/test_signup_service.py
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
# app/modules/billing/tests/unit/test_signup_service.py
|
||||||
|
"""Unit tests for SignupService (simplified 3-step signup)."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.exceptions import (
|
||||||
|
ConflictException,
|
||||||
|
ResourceNotFoundException,
|
||||||
|
ValidationException,
|
||||||
|
)
|
||||||
|
from app.modules.billing.models import TierCode
|
||||||
|
from app.modules.billing.services.signup_service import SignupService, _signup_sessions
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_sessions():
|
||||||
|
"""Clear in-memory signup sessions before each test."""
|
||||||
|
_signup_sessions.clear()
|
||||||
|
yield
|
||||||
|
_signup_sessions.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.billing
|
||||||
|
class TestSignupServiceSession:
|
||||||
|
"""Tests for SignupService session management."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
self.service = SignupService()
|
||||||
|
|
||||||
|
def test_create_session_stores_language(self):
|
||||||
|
"""create_session stores the user's browsing language."""
|
||||||
|
session_id = self.service.create_session(
|
||||||
|
tier_code=TierCode.ESSENTIAL.value,
|
||||||
|
is_annual=False,
|
||||||
|
platform_code="loyalty",
|
||||||
|
language="de",
|
||||||
|
)
|
||||||
|
|
||||||
|
session = self.service.get_session(session_id)
|
||||||
|
assert session is not None
|
||||||
|
assert session["language"] == "de"
|
||||||
|
|
||||||
|
def test_create_session_default_language_fr(self):
|
||||||
|
"""create_session defaults to French when no language provided."""
|
||||||
|
session_id = self.service.create_session(
|
||||||
|
tier_code=TierCode.ESSENTIAL.value,
|
||||||
|
is_annual=False,
|
||||||
|
platform_code="oms",
|
||||||
|
)
|
||||||
|
|
||||||
|
session = self.service.get_session(session_id)
|
||||||
|
assert session["language"] == "fr"
|
||||||
|
|
||||||
|
def test_create_session_stores_platform_code(self):
|
||||||
|
"""create_session stores the platform code."""
|
||||||
|
session_id = self.service.create_session(
|
||||||
|
tier_code=TierCode.PROFESSIONAL.value,
|
||||||
|
is_annual=True,
|
||||||
|
platform_code="loyalty",
|
||||||
|
language="en",
|
||||||
|
)
|
||||||
|
|
||||||
|
session = self.service.get_session(session_id)
|
||||||
|
assert session["platform_code"] == "loyalty"
|
||||||
|
assert session["is_annual"] is True
|
||||||
|
assert session["tier_code"] == TierCode.PROFESSIONAL.value
|
||||||
|
|
||||||
|
def test_create_session_raises_without_platform_code(self):
|
||||||
|
"""create_session raises ValidationException when platform_code is empty."""
|
||||||
|
with pytest.raises(ValidationException):
|
||||||
|
self.service.create_session(
|
||||||
|
tier_code=TierCode.ESSENTIAL.value,
|
||||||
|
is_annual=False,
|
||||||
|
platform_code="",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_session_or_raise_missing(self):
|
||||||
|
"""get_session_or_raise raises ResourceNotFoundException for invalid session."""
|
||||||
|
with pytest.raises(ResourceNotFoundException):
|
||||||
|
self.service.get_session_or_raise("nonexistent_session_id")
|
||||||
|
|
||||||
|
def test_delete_session(self):
|
||||||
|
"""delete_session removes the session from storage."""
|
||||||
|
session_id = self.service.create_session(
|
||||||
|
tier_code=TierCode.ESSENTIAL.value,
|
||||||
|
is_annual=False,
|
||||||
|
platform_code="oms",
|
||||||
|
)
|
||||||
|
assert self.service.get_session(session_id) is not None
|
||||||
|
|
||||||
|
self.service.delete_session(session_id)
|
||||||
|
assert self.service.get_session(session_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.billing
|
||||||
|
class TestSignupServiceAccountCreation:
|
||||||
|
"""Tests for SignupService.create_account (merged store creation)."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
self.service = SignupService()
|
||||||
|
|
||||||
|
def test_create_account_creates_store(self, db):
|
||||||
|
"""create_account creates User + Merchant + Store atomically."""
|
||||||
|
from app.modules.billing.models import SubscriptionTier
|
||||||
|
from app.modules.tenancy.models import Platform
|
||||||
|
|
||||||
|
# Create platform
|
||||||
|
platform = Platform(
|
||||||
|
code=f"test_{uuid.uuid4().hex[:8]}",
|
||||||
|
name="Test Platform",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(platform)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Create a tier for the platform
|
||||||
|
tier = SubscriptionTier(
|
||||||
|
code=TierCode.ESSENTIAL.value,
|
||||||
|
name="Essential",
|
||||||
|
platform_id=platform.id,
|
||||||
|
price_monthly_cents=0,
|
||||||
|
display_order=1,
|
||||||
|
is_active=True,
|
||||||
|
is_public=True,
|
||||||
|
)
|
||||||
|
db.add(tier)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
session_id = self.service.create_session(
|
||||||
|
tier_code=TierCode.ESSENTIAL.value,
|
||||||
|
is_annual=False,
|
||||||
|
platform_code=platform.code,
|
||||||
|
language="de",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock Stripe
|
||||||
|
with patch(
|
||||||
|
"app.modules.billing.services.signup_service.stripe_service"
|
||||||
|
) as mock_stripe:
|
||||||
|
mock_stripe.create_customer_for_merchant.return_value = "cus_test123"
|
||||||
|
|
||||||
|
result = self.service.create_account(
|
||||||
|
db=db,
|
||||||
|
session_id=session_id,
|
||||||
|
email=f"test_{uuid.uuid4().hex[:8]}@example.com",
|
||||||
|
password="securepass123", # noqa: SEC-001 # noqa: SEC-001
|
||||||
|
first_name="John",
|
||||||
|
last_name="Doe",
|
||||||
|
merchant_name="John's Shop",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify result includes store info
|
||||||
|
assert result.user_id is not None
|
||||||
|
assert result.merchant_id is not None
|
||||||
|
assert result.store_id is not None
|
||||||
|
assert result.store_code is not None
|
||||||
|
assert result.stripe_customer_id == "cus_test123"
|
||||||
|
|
||||||
|
# Verify store was created with correct language
|
||||||
|
from app.modules.tenancy.models import Store
|
||||||
|
|
||||||
|
store = db.query(Store).filter(Store.id == result.store_id).first()
|
||||||
|
assert store is not None
|
||||||
|
assert store.name == "John's Shop"
|
||||||
|
assert store.default_language == "de"
|
||||||
|
|
||||||
|
def test_create_account_uses_session_language(self, db):
|
||||||
|
"""create_account sets store default_language from the signup session."""
|
||||||
|
from app.modules.billing.models import SubscriptionTier
|
||||||
|
from app.modules.tenancy.models import Platform
|
||||||
|
|
||||||
|
platform = Platform(
|
||||||
|
code=f"test_{uuid.uuid4().hex[:8]}",
|
||||||
|
name="Test Platform",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(platform)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
tier = SubscriptionTier(
|
||||||
|
code=TierCode.ESSENTIAL.value,
|
||||||
|
name="Essential",
|
||||||
|
platform_id=platform.id,
|
||||||
|
price_monthly_cents=0,
|
||||||
|
display_order=1,
|
||||||
|
is_active=True,
|
||||||
|
is_public=True,
|
||||||
|
)
|
||||||
|
db.add(tier)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
session_id = self.service.create_session(
|
||||||
|
tier_code=TierCode.ESSENTIAL.value,
|
||||||
|
is_annual=False,
|
||||||
|
platform_code=platform.code,
|
||||||
|
language="en",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.modules.billing.services.signup_service.stripe_service"
|
||||||
|
) as mock_stripe:
|
||||||
|
mock_stripe.create_customer_for_merchant.return_value = "cus_test456"
|
||||||
|
|
||||||
|
result = self.service.create_account(
|
||||||
|
db=db,
|
||||||
|
session_id=session_id,
|
||||||
|
email=f"test_{uuid.uuid4().hex[:8]}@example.com",
|
||||||
|
password="securepass123", # noqa: SEC-001
|
||||||
|
first_name="Jane",
|
||||||
|
last_name="Smith",
|
||||||
|
merchant_name="Jane's Bakery",
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.modules.tenancy.models import Store
|
||||||
|
|
||||||
|
store = db.query(Store).filter(Store.id == result.store_id).first()
|
||||||
|
assert store.default_language == "en"
|
||||||
|
|
||||||
|
def test_create_account_rejects_duplicate_email(self, db):
|
||||||
|
"""create_account raises ConflictException for existing email."""
|
||||||
|
from app.modules.billing.models import SubscriptionTier
|
||||||
|
from app.modules.tenancy.models import Platform
|
||||||
|
|
||||||
|
platform = Platform(
|
||||||
|
code=f"test_{uuid.uuid4().hex[:8]}",
|
||||||
|
name="Test Platform",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(platform)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
tier = SubscriptionTier(
|
||||||
|
code=TierCode.ESSENTIAL.value,
|
||||||
|
name="Essential",
|
||||||
|
platform_id=platform.id,
|
||||||
|
price_monthly_cents=0,
|
||||||
|
display_order=1,
|
||||||
|
is_active=True,
|
||||||
|
is_public=True,
|
||||||
|
)
|
||||||
|
db.add(tier)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
email = f"dup_{uuid.uuid4().hex[:8]}@example.com"
|
||||||
|
|
||||||
|
# Create first account
|
||||||
|
session_id1 = self.service.create_session(
|
||||||
|
tier_code=TierCode.ESSENTIAL.value,
|
||||||
|
is_annual=False,
|
||||||
|
platform_code=platform.code,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.modules.billing.services.signup_service.stripe_service"
|
||||||
|
) as mock_stripe:
|
||||||
|
mock_stripe.create_customer_for_merchant.return_value = "cus_first"
|
||||||
|
self.service.create_account(
|
||||||
|
db=db,
|
||||||
|
session_id=session_id1,
|
||||||
|
email=email,
|
||||||
|
password="securepass123", # noqa: SEC-001
|
||||||
|
first_name="First",
|
||||||
|
last_name="User",
|
||||||
|
merchant_name="First Shop",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to create second account with same email
|
||||||
|
session_id2 = self.service.create_session(
|
||||||
|
tier_code=TierCode.ESSENTIAL.value,
|
||||||
|
is_annual=False,
|
||||||
|
platform_code=platform.code,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ConflictException):
|
||||||
|
self.service.create_account(
|
||||||
|
db=db,
|
||||||
|
session_id=session_id2,
|
||||||
|
email=email,
|
||||||
|
password="securepass123", # noqa: SEC-001
|
||||||
|
first_name="Second",
|
||||||
|
last_name="User",
|
||||||
|
merchant_name="Second Shop",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_account_updates_session(self, db):
|
||||||
|
"""create_account updates session with user/merchant/store IDs."""
|
||||||
|
from app.modules.billing.models import SubscriptionTier
|
||||||
|
from app.modules.tenancy.models import Platform
|
||||||
|
|
||||||
|
platform = Platform(
|
||||||
|
code=f"test_{uuid.uuid4().hex[:8]}",
|
||||||
|
name="Test Platform",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(platform)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
tier = SubscriptionTier(
|
||||||
|
code=TierCode.ESSENTIAL.value,
|
||||||
|
name="Essential",
|
||||||
|
platform_id=platform.id,
|
||||||
|
price_monthly_cents=0,
|
||||||
|
display_order=1,
|
||||||
|
is_active=True,
|
||||||
|
is_public=True,
|
||||||
|
)
|
||||||
|
db.add(tier)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
session_id = self.service.create_session(
|
||||||
|
tier_code=TierCode.ESSENTIAL.value,
|
||||||
|
is_annual=False,
|
||||||
|
platform_code=platform.code,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.modules.billing.services.signup_service.stripe_service"
|
||||||
|
) as mock_stripe:
|
||||||
|
mock_stripe.create_customer_for_merchant.return_value = "cus_test789"
|
||||||
|
|
||||||
|
result = self.service.create_account(
|
||||||
|
db=db,
|
||||||
|
session_id=session_id,
|
||||||
|
email=f"test_{uuid.uuid4().hex[:8]}@example.com",
|
||||||
|
password="securepass123", # noqa: SEC-001
|
||||||
|
first_name="Test",
|
||||||
|
last_name="User",
|
||||||
|
merchant_name="Test Shop",
|
||||||
|
)
|
||||||
|
|
||||||
|
session = self.service.get_session(session_id)
|
||||||
|
assert session["user_id"] == result.user_id
|
||||||
|
assert session["merchant_id"] == result.merchant_id
|
||||||
|
assert session["store_id"] == result.store_id
|
||||||
|
assert session["store_code"] == result.store_code
|
||||||
|
assert session["stripe_customer_id"] == "cus_test789"
|
||||||
|
assert session["step"] == "account_created"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.billing
|
||||||
|
class TestSignupServicePostRedirect:
|
||||||
|
"""Tests for SignupService._get_post_signup_redirect."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
self.service = SignupService()
|
||||||
|
|
||||||
|
def test_always_redirects_to_dashboard(self, db):
|
||||||
|
"""Post-signup redirect always goes to store dashboard."""
|
||||||
|
session = {"platform_code": "loyalty"}
|
||||||
|
url = self.service._get_post_signup_redirect(db, session, "MY_STORE")
|
||||||
|
assert url == "/store/MY_STORE/dashboard"
|
||||||
|
|
||||||
|
def test_redirect_for_oms_platform(self, db):
|
||||||
|
"""OMS platform also redirects to dashboard (not onboarding wizard)."""
|
||||||
|
session = {"platform_code": "oms"}
|
||||||
|
url = self.service._get_post_signup_redirect(db, session, "OMS_STORE")
|
||||||
|
assert url == "/store/OMS_STORE/dashboard"
|
||||||
@@ -39,52 +39,6 @@ class TestStorePlatformSyncCreate:
|
|||||||
assert sp is not None
|
assert sp is not None
|
||||||
assert sp.is_active is True
|
assert sp.is_active is True
|
||||||
|
|
||||||
def test_sync_sets_primary_when_none(self, db, test_store, test_platform):
|
|
||||||
"""First platform synced for a store gets is_primary=True."""
|
|
||||||
self.service.sync_store_platforms_for_merchant(
|
|
||||||
db, test_store.merchant_id, test_platform.id, is_active=True
|
|
||||||
)
|
|
||||||
|
|
||||||
sp = (
|
|
||||||
db.query(StorePlatform)
|
|
||||||
.filter(
|
|
||||||
StorePlatform.store_id == test_store.id,
|
|
||||||
StorePlatform.platform_id == test_platform.id,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
assert sp.is_primary is True
|
|
||||||
|
|
||||||
def test_sync_no_primary_override(self, db, test_store, test_platform, another_platform):
|
|
||||||
"""Second platform synced does not override existing primary."""
|
|
||||||
# First platform becomes primary
|
|
||||||
self.service.sync_store_platforms_for_merchant(
|
|
||||||
db, test_store.merchant_id, test_platform.id, is_active=True
|
|
||||||
)
|
|
||||||
# Second platform should not be primary
|
|
||||||
self.service.sync_store_platforms_for_merchant(
|
|
||||||
db, test_store.merchant_id, another_platform.id, is_active=True
|
|
||||||
)
|
|
||||||
|
|
||||||
sp1 = (
|
|
||||||
db.query(StorePlatform)
|
|
||||||
.filter(
|
|
||||||
StorePlatform.store_id == test_store.id,
|
|
||||||
StorePlatform.platform_id == test_platform.id,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
sp2 = (
|
|
||||||
db.query(StorePlatform)
|
|
||||||
.filter(
|
|
||||||
StorePlatform.store_id == test_store.id,
|
|
||||||
StorePlatform.platform_id == another_platform.id,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
assert sp1.is_primary is True
|
|
||||||
assert sp2.is_primary is False
|
|
||||||
|
|
||||||
def test_sync_sets_tier_id(self, db, test_store, test_platform, sync_tier):
|
def test_sync_sets_tier_id(self, db, test_store, test_platform, sync_tier):
|
||||||
"""Sync passes tier_id to newly created StorePlatform."""
|
"""Sync passes tier_id to newly created StorePlatform."""
|
||||||
self.service.sync_store_platforms_for_merchant(
|
self.service.sync_store_platforms_for_merchant(
|
||||||
@@ -118,7 +72,6 @@ class TestStorePlatformSyncUpdate:
|
|||||||
store_id=test_store.id,
|
store_id=test_store.id,
|
||||||
platform_id=test_platform.id,
|
platform_id=test_platform.id,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
is_primary=True,
|
|
||||||
)
|
)
|
||||||
db.add(sp)
|
db.add(sp)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -137,7 +90,6 @@ class TestStorePlatformSyncUpdate:
|
|||||||
store_id=test_store.id,
|
store_id=test_store.id,
|
||||||
platform_id=test_platform.id,
|
platform_id=test_platform.id,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
is_primary=True,
|
|
||||||
)
|
)
|
||||||
db.add(sp)
|
db.add(sp)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|||||||
@@ -68,10 +68,11 @@ cart_module = ModuleDefinition(
|
|||||||
items=[
|
items=[
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="cart",
|
id="cart",
|
||||||
label_key="storefront.actions.cart",
|
label_key="cart.storefront.actions.cart",
|
||||||
icon="shopping-cart",
|
icon="shopping-cart",
|
||||||
route="cart",
|
route="cart",
|
||||||
order=20,
|
order=20,
|
||||||
|
header_template="cart/storefront/partials/header-cart.html",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
41
app/modules/cart/docs/index.md
Normal file
41
app/modules/cart/docs/index.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Shopping Cart
|
||||||
|
|
||||||
|
Session-based shopping cart for storefronts.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| Code | `cart` |
|
||||||
|
| Classification | Optional |
|
||||||
|
| Dependencies | `inventory`, `catalog` |
|
||||||
|
| Status | Active |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- `cart_management` — Cart creation and management
|
||||||
|
- `cart_persistence` — Session-based cart persistence
|
||||||
|
- `cart_item_operations` — Add/update/remove cart items
|
||||||
|
- `shipping_calculation` — Shipping cost calculation
|
||||||
|
- `promotion_application` — Apply promotions and discounts
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
| Permission | Description |
|
||||||
|
|------------|-------------|
|
||||||
|
| `cart.view` | View cart data |
|
||||||
|
| `cart.manage` | Manage cart settings |
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
- **Cart** — Shopping cart with session tracking and store scoping
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `*` | `/api/v1/storefront/cart/*` | Storefront cart operations |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
No module-specific configuration.
|
||||||
@@ -1,42 +1,53 @@
|
|||||||
{
|
{
|
||||||
"title": "Warenkorb",
|
"title": "Warenkorb",
|
||||||
"description": "Warenkorbverwaltung für Kunden",
|
"description": "Warenkorbverwaltung für Kunden",
|
||||||
"cart": {
|
"cart": {
|
||||||
"title": "Ihr Warenkorb",
|
"title": "Ihr Warenkorb",
|
||||||
"empty": "Ihr Warenkorb ist leer",
|
"empty": "Ihr Warenkorb ist leer",
|
||||||
"empty_subtitle": "Fügen Sie Artikel hinzu, um einzukaufen",
|
"empty_subtitle": "Fügen Sie Artikel hinzu, um einzukaufen",
|
||||||
"continue_shopping": "Weiter einkaufen",
|
"continue_shopping": "Weiter einkaufen",
|
||||||
"proceed_to_checkout": "Zur Kasse"
|
"proceed_to_checkout": "Zur Kasse"
|
||||||
},
|
},
|
||||||
"item": {
|
"item": {
|
||||||
"product": "Produkt",
|
"product": "Produkt",
|
||||||
"quantity": "Menge",
|
"quantity": "Menge",
|
||||||
"price": "Preis",
|
"price": "Preis",
|
||||||
"total": "Gesamt",
|
"total": "Gesamt",
|
||||||
"remove": "Entfernen",
|
"remove": "Entfernen",
|
||||||
"update": "Aktualisieren"
|
"update": "Aktualisieren"
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"title": "Bestellübersicht",
|
"title": "Bestellübersicht",
|
||||||
"subtotal": "Zwischensumme",
|
"subtotal": "Zwischensumme",
|
||||||
"shipping": "Versand",
|
"shipping": "Versand",
|
||||||
"estimated_shipping": "Wird an der Kasse berechnet",
|
"estimated_shipping": "Wird an der Kasse berechnet",
|
||||||
"tax": "MwSt.",
|
"tax": "MwSt.",
|
||||||
"total": "Gesamtsumme"
|
"total": "Gesamtsumme"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"invalid_quantity": "Ungültige Menge",
|
"invalid_quantity": "Ungültige Menge",
|
||||||
"min_quantity": "Mindestmenge ist {min}",
|
"min_quantity": "Mindestmenge ist {min}",
|
||||||
"max_quantity": "Höchstmenge ist {max}",
|
"max_quantity": "Höchstmenge ist {max}",
|
||||||
"insufficient_inventory": "Nur {available} verfügbar"
|
"insufficient_inventory": "Nur {available} verfügbar"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"item_added": "Artikel zum Warenkorb hinzugefügt",
|
"item_added": "Artikel zum Warenkorb hinzugefügt",
|
||||||
"item_updated": "Warenkorb aktualisiert",
|
"item_updated": "Warenkorb aktualisiert",
|
||||||
"item_removed": "Artikel aus dem Warenkorb entfernt",
|
"item_removed": "Artikel aus dem Warenkorb entfernt",
|
||||||
"cart_cleared": "Warenkorb geleert",
|
"cart_cleared": "Warenkorb geleert",
|
||||||
"product_not_available": "Produkt nicht verfügbar",
|
"product_not_available": "Produkt nicht verfügbar",
|
||||||
"error_adding": "Fehler beim Hinzufügen zum Warenkorb",
|
"error_adding": "Fehler beim Hinzufügen zum Warenkorb",
|
||||||
"error_updating": "Fehler beim Aktualisieren des Warenkorbs"
|
"error_updating": "Fehler beim Aktualisieren des Warenkorbs"
|
||||||
}
|
},
|
||||||
|
"permissions": {
|
||||||
|
"view": "Warenkörbe anzeigen",
|
||||||
|
"view_desc": "Warenkörbe der Kunden anzeigen",
|
||||||
|
"manage": "Warenkörbe verwalten",
|
||||||
|
"manage_desc": "Warenkörbe der Kunden bearbeiten und verwalten"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"actions": {
|
||||||
|
"cart": "Warenkorb"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,53 @@
|
|||||||
{
|
{
|
||||||
"title": "Shopping Cart",
|
"title": "Shopping Cart",
|
||||||
"description": "Shopping cart management for customers",
|
"description": "Shopping cart management for customers",
|
||||||
"cart": {
|
"cart": {
|
||||||
"title": "Your Cart",
|
"title": "Your Cart",
|
||||||
"empty": "Your cart is empty",
|
"empty": "Your cart is empty",
|
||||||
"empty_subtitle": "Add items to start shopping",
|
"empty_subtitle": "Add items to start shopping",
|
||||||
"continue_shopping": "Continue Shopping",
|
"continue_shopping": "Continue Shopping",
|
||||||
"proceed_to_checkout": "Proceed to Checkout"
|
"proceed_to_checkout": "Proceed to Checkout"
|
||||||
},
|
},
|
||||||
"item": {
|
"item": {
|
||||||
"product": "Product",
|
"product": "Product",
|
||||||
"quantity": "Quantity",
|
"quantity": "Quantity",
|
||||||
"price": "Price",
|
"price": "Price",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"update": "Update"
|
"update": "Update"
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"title": "Order Summary",
|
"title": "Order Summary",
|
||||||
"subtotal": "Subtotal",
|
"subtotal": "Subtotal",
|
||||||
"shipping": "Shipping",
|
"shipping": "Shipping",
|
||||||
"estimated_shipping": "Calculated at checkout",
|
"estimated_shipping": "Calculated at checkout",
|
||||||
"tax": "Tax",
|
"tax": "Tax",
|
||||||
"total": "Total"
|
"total": "Total"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"invalid_quantity": "Invalid quantity",
|
"invalid_quantity": "Invalid quantity",
|
||||||
"min_quantity": "Minimum quantity is {min}",
|
"min_quantity": "Minimum quantity is {min}",
|
||||||
"max_quantity": "Maximum quantity is {max}",
|
"max_quantity": "Maximum quantity is {max}",
|
||||||
"insufficient_inventory": "Only {available} available"
|
"insufficient_inventory": "Only {available} available"
|
||||||
},
|
},
|
||||||
"messages": {
|
"permissions": {
|
||||||
"item_added": "Item added to cart",
|
"view": "View Carts",
|
||||||
"item_updated": "Cart updated",
|
"view_desc": "View customer shopping carts",
|
||||||
"item_removed": "Item removed from cart",
|
"manage": "Manage Carts",
|
||||||
"cart_cleared": "Cart cleared",
|
"manage_desc": "Modify and manage customer carts"
|
||||||
"product_not_available": "Product not available",
|
},
|
||||||
"error_adding": "Error adding item to cart",
|
"messages": {
|
||||||
"error_updating": "Error updating cart"
|
"item_added": "Item added to cart",
|
||||||
}
|
"item_updated": "Cart updated",
|
||||||
|
"item_removed": "Item removed from cart",
|
||||||
|
"cart_cleared": "Cart cleared",
|
||||||
|
"product_not_available": "Product not available",
|
||||||
|
"error_adding": "Error adding item to cart",
|
||||||
|
"error_updating": "Error updating cart"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"actions": {
|
||||||
|
"cart": "Cart"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,53 @@
|
|||||||
{
|
{
|
||||||
"title": "Panier",
|
"title": "Panier",
|
||||||
"description": "Gestion du panier pour les clients",
|
"description": "Gestion du panier pour les clients",
|
||||||
"cart": {
|
"cart": {
|
||||||
"title": "Votre panier",
|
"title": "Votre panier",
|
||||||
"empty": "Votre panier est vide",
|
"empty": "Votre panier est vide",
|
||||||
"empty_subtitle": "Ajoutez des articles pour commencer vos achats",
|
"empty_subtitle": "Ajoutez des articles pour commencer vos achats",
|
||||||
"continue_shopping": "Continuer mes achats",
|
"continue_shopping": "Continuer mes achats",
|
||||||
"proceed_to_checkout": "Passer à la caisse"
|
"proceed_to_checkout": "Passer à la caisse"
|
||||||
},
|
},
|
||||||
"item": {
|
"item": {
|
||||||
"product": "Produit",
|
"product": "Produit",
|
||||||
"quantity": "Quantité",
|
"quantity": "Quantité",
|
||||||
"price": "Prix",
|
"price": "Prix",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"remove": "Supprimer",
|
"remove": "Supprimer",
|
||||||
"update": "Mettre à jour"
|
"update": "Mettre à jour"
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"title": "Récapitulatif de commande",
|
"title": "Récapitulatif de commande",
|
||||||
"subtotal": "Sous-total",
|
"subtotal": "Sous-total",
|
||||||
"shipping": "Livraison",
|
"shipping": "Livraison",
|
||||||
"estimated_shipping": "Calculé à la caisse",
|
"estimated_shipping": "Calculé à la caisse",
|
||||||
"tax": "TVA",
|
"tax": "TVA",
|
||||||
"total": "Total"
|
"total": "Total"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"invalid_quantity": "Quantité invalide",
|
"invalid_quantity": "Quantité invalide",
|
||||||
"min_quantity": "Quantité minimum: {min}",
|
"min_quantity": "Quantité minimum: {min}",
|
||||||
"max_quantity": "Quantité maximum: {max}",
|
"max_quantity": "Quantité maximum: {max}",
|
||||||
"insufficient_inventory": "Seulement {available} disponible(s)"
|
"insufficient_inventory": "Seulement {available} disponible(s)"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"item_added": "Article ajouté au panier",
|
"item_added": "Article ajouté au panier",
|
||||||
"item_updated": "Panier mis à jour",
|
"item_updated": "Panier mis à jour",
|
||||||
"item_removed": "Article supprimé du panier",
|
"item_removed": "Article supprimé du panier",
|
||||||
"cart_cleared": "Panier vidé",
|
"cart_cleared": "Panier vidé",
|
||||||
"product_not_available": "Produit non disponible",
|
"product_not_available": "Produit non disponible",
|
||||||
"error_adding": "Erreur lors de l'ajout au panier",
|
"error_adding": "Erreur lors de l'ajout au panier",
|
||||||
"error_updating": "Erreur lors de la mise à jour du panier"
|
"error_updating": "Erreur lors de la mise à jour du panier"
|
||||||
}
|
},
|
||||||
|
"permissions": {
|
||||||
|
"view": "Voir les paniers",
|
||||||
|
"view_desc": "Voir les paniers des clients",
|
||||||
|
"manage": "Gérer les paniers",
|
||||||
|
"manage_desc": "Modifier et gérer les paniers des clients"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"actions": {
|
||||||
|
"cart": "Panier"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,53 @@
|
|||||||
{
|
{
|
||||||
"title": "Akafskuerf",
|
"title": "Akafskuerf",
|
||||||
"description": "Kuerfverwaltung fir Clienten",
|
"description": "Kuerfverwaltung fir Clienten",
|
||||||
"cart": {
|
"cart": {
|
||||||
"title": "Äre Kuerf",
|
"title": "Äre Kuerf",
|
||||||
"empty": "Äre Kuerf ass eidel",
|
"empty": "Äre Kuerf ass eidel",
|
||||||
"empty_subtitle": "Setzt Artikelen derbäi fir anzekafen",
|
"empty_subtitle": "Setzt Artikelen derbäi fir anzekafen",
|
||||||
"continue_shopping": "Weider akafen",
|
"continue_shopping": "Weider akafen",
|
||||||
"proceed_to_checkout": "Zur Keess"
|
"proceed_to_checkout": "Zur Keess"
|
||||||
},
|
},
|
||||||
"item": {
|
"item": {
|
||||||
"product": "Produkt",
|
"product": "Produkt",
|
||||||
"quantity": "Unzuel",
|
"quantity": "Unzuel",
|
||||||
"price": "Präis",
|
"price": "Präis",
|
||||||
"total": "Gesamt",
|
"total": "Gesamt",
|
||||||
"remove": "Ewechhuelen",
|
"remove": "Ewechhuelen",
|
||||||
"update": "Aktualiséieren"
|
"update": "Aktualiséieren"
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"title": "Bestelliwwersiicht",
|
"title": "Bestelliwwersiicht",
|
||||||
"subtotal": "Zwëschesumm",
|
"subtotal": "Zwëschesumm",
|
||||||
"shipping": "Liwwerung",
|
"shipping": "Liwwerung",
|
||||||
"estimated_shipping": "Gëtt bei der Keess berechent",
|
"estimated_shipping": "Gëtt bei der Keess berechent",
|
||||||
"tax": "MwSt.",
|
"tax": "MwSt.",
|
||||||
"total": "Gesamtsumm"
|
"total": "Gesamtsumm"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"invalid_quantity": "Ongëlteg Unzuel",
|
"invalid_quantity": "Ongëlteg Unzuel",
|
||||||
"min_quantity": "Mindestunzuel ass {min}",
|
"min_quantity": "Mindestunzuel ass {min}",
|
||||||
"max_quantity": "Héichstunzuel ass {max}",
|
"max_quantity": "Héichstunzuel ass {max}",
|
||||||
"insufficient_inventory": "Nëmmen {available} verfügbar"
|
"insufficient_inventory": "Nëmmen {available} verfügbar"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"item_added": "Artikel an de Kuerf gesat",
|
"item_added": "Artikel an de Kuerf gesat",
|
||||||
"item_updated": "Kuerf aktualiséiert",
|
"item_updated": "Kuerf aktualiséiert",
|
||||||
"item_removed": "Artikel aus dem Kuerf ewechgeholl",
|
"item_removed": "Artikel aus dem Kuerf ewechgeholl",
|
||||||
"cart_cleared": "Kuerf eidel gemaach",
|
"cart_cleared": "Kuerf eidel gemaach",
|
||||||
"product_not_available": "Produkt net verfügbar",
|
"product_not_available": "Produkt net verfügbar",
|
||||||
"error_adding": "Feeler beim Derbäisetzen an de Kuerf",
|
"error_adding": "Feeler beim Derbäisetzen an de Kuerf",
|
||||||
"error_updating": "Feeler beim Aktualiséiere vum Kuerf"
|
"error_updating": "Feeler beim Aktualiséiere vum Kuerf"
|
||||||
}
|
},
|
||||||
|
"permissions": {
|
||||||
|
"view": "Kuerf kucken",
|
||||||
|
"view_desc": "Clientekuerf kucken",
|
||||||
|
"manage": "Kuerf verwalten",
|
||||||
|
"manage_desc": "Clientekuerf änneren a verwalten"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"actions": {
|
||||||
|
"cart": "Kuerf"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ from app.modules.cart.exceptions import (
|
|||||||
)
|
)
|
||||||
from app.modules.cart.models.cart import CartItem
|
from app.modules.cart.models.cart import CartItem
|
||||||
from app.modules.catalog.exceptions import ProductNotFoundException
|
from app.modules.catalog.exceptions import ProductNotFoundException
|
||||||
from app.modules.catalog.models import Product
|
|
||||||
from app.utils.money import cents_to_euros
|
from app.utils.money import cents_to_euros
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -146,19 +145,18 @@ class CartService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Verify product exists and belongs to store
|
# Verify product exists and belongs to store
|
||||||
product = (
|
from app.modules.catalog.services.product_service import product_service
|
||||||
db.query(Product)
|
|
||||||
.filter(
|
|
||||||
and_(
|
|
||||||
Product.id == product_id,
|
|
||||||
Product.store_id == store_id,
|
|
||||||
Product.is_active == True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not product:
|
try:
|
||||||
|
product = product_service.get_product(db, store_id, product_id)
|
||||||
|
except ProductNotFoundException:
|
||||||
|
logger.error(
|
||||||
|
"[CART_SERVICE] Product not found",
|
||||||
|
extra={"product_id": product_id, "store_id": store_id},
|
||||||
|
)
|
||||||
|
raise ProductNotFoundException(product_id=product_id, store_id=store_id)
|
||||||
|
|
||||||
|
if not product.is_active:
|
||||||
logger.error(
|
logger.error(
|
||||||
"[CART_SERVICE] Product not found",
|
"[CART_SERVICE] Product not found",
|
||||||
extra={"product_id": product_id, "store_id": store_id},
|
extra={"product_id": product_id, "store_id": store_id},
|
||||||
@@ -323,19 +321,14 @@ class CartService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Verify product still exists and is active
|
# Verify product still exists and is active
|
||||||
product = (
|
from app.modules.catalog.services.product_service import product_service
|
||||||
db.query(Product)
|
|
||||||
.filter(
|
|
||||||
and_(
|
|
||||||
Product.id == product_id,
|
|
||||||
Product.store_id == store_id,
|
|
||||||
Product.is_active == True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not product:
|
try:
|
||||||
|
product = product_service.get_product(db, store_id, product_id)
|
||||||
|
except ProductNotFoundException:
|
||||||
|
raise ProductNotFoundException(str(product_id))
|
||||||
|
|
||||||
|
if not product.is_active:
|
||||||
raise ProductNotFoundException(str(product_id))
|
raise ProductNotFoundException(str(product_id))
|
||||||
|
|
||||||
# Check inventory
|
# Check inventory
|
||||||
|
|||||||
@@ -175,7 +175,7 @@
|
|||||||
<script>
|
<script>
|
||||||
document.addEventListener('alpine:init', () => {
|
document.addEventListener('alpine:init', () => {
|
||||||
Alpine.data('shoppingCart', () => {
|
Alpine.data('shoppingCart', () => {
|
||||||
const baseData = shopLayoutData();
|
const baseData = storefrontLayoutData();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...baseData,
|
...baseData,
|
||||||
@@ -208,7 +208,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
async init() {
|
async init() {
|
||||||
console.log('[SHOP] Cart page initializing...');
|
console.log('[STOREFRONT] Cart page initializing...');
|
||||||
|
|
||||||
// Call parent init to set up sessionId
|
// Call parent init to set up sessionId
|
||||||
if (baseData.init) {
|
if (baseData.init) {
|
||||||
@@ -223,17 +223,17 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`[SHOP] Loading cart for session ${this.sessionId}...`);
|
console.log(`[STOREFRONT] Loading cart for session ${this.sessionId}...`);
|
||||||
const response = await fetch(`/api/v1/storefront/cart/${this.sessionId}`);
|
const response = await fetch(`/api/v1/storefront/cart/${this.sessionId}`);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
this.items = data.items || [];
|
this.items = data.items || [];
|
||||||
this.cartCount = this.totalItems;
|
this.cartCount = this.totalItems;
|
||||||
console.log('[SHOP] Cart loaded:', this.items.length, 'items');
|
console.log('[STOREFRONT] Cart loaded:', this.items.length, 'items');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Failed to load cart:', error);
|
console.error('[STOREFRONT] Failed to load cart:', error);
|
||||||
this.showToast('Failed to load cart', 'error');
|
this.showToast('Failed to load cart', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
@@ -249,7 +249,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.updating = true;
|
this.updating = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[SHOP] Updating quantity:', productId, newQuantity);
|
console.log('[STOREFRONT] Updating quantity:', productId, newQuantity);
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
|
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
|
||||||
{
|
{
|
||||||
@@ -268,7 +268,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
throw new Error('Failed to update quantity');
|
throw new Error('Failed to update quantity');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Update quantity error:', error);
|
console.error('[STOREFRONT] Update quantity error:', error);
|
||||||
this.showToast('Failed to update quantity', 'error');
|
this.showToast('Failed to update quantity', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.updating = false;
|
this.updating = false;
|
||||||
@@ -280,7 +280,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.updating = true;
|
this.updating = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[SHOP] Removing item:', productId);
|
console.log('[STOREFRONT] Removing item:', productId);
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
|
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
|
||||||
{
|
{
|
||||||
@@ -295,7 +295,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
throw new Error('Failed to remove item');
|
throw new Error('Failed to remove item');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Remove item error:', error);
|
console.error('[STOREFRONT] Remove item error:', error);
|
||||||
this.showToast('Failed to remove item', 'error');
|
this.showToast('Failed to remove item', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.updating = false;
|
this.updating = false;
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{# cart/storefront/partials/header-cart.html #}
|
||||||
|
{# Cart icon with badge for storefront header — provided by cart module #}
|
||||||
|
<a href="{{ base_url }}cart" class="relative p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||||
|
<span class="w-5 h-5" x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
|
||||||
|
<span x-show="cartCount > 0"
|
||||||
|
x-text="cartCount"
|
||||||
|
class="absolute -top-1 -right-1 bg-accent text-white text-xs rounded-full h-5 w-5 flex items-center justify-center"
|
||||||
|
style="background-color: var(--color-accent)">
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
@@ -16,16 +16,16 @@ from app.modules.enums import FrontendType
|
|||||||
|
|
||||||
def _get_admin_router():
|
def _get_admin_router():
|
||||||
"""Lazy import of admin router to avoid circular imports."""
|
"""Lazy import of admin router to avoid circular imports."""
|
||||||
from app.modules.catalog.routes.api.admin import admin_router
|
from app.modules.catalog.routes.api.admin import router
|
||||||
|
|
||||||
return admin_router
|
return router
|
||||||
|
|
||||||
|
|
||||||
def _get_store_router():
|
def _get_store_router():
|
||||||
"""Lazy import of store router to avoid circular imports."""
|
"""Lazy import of store router to avoid circular imports."""
|
||||||
from app.modules.catalog.routes.api.store import store_router
|
from app.modules.catalog.routes.api.store import router
|
||||||
|
|
||||||
return store_router
|
return router
|
||||||
|
|
||||||
|
|
||||||
def _get_metrics_provider():
|
def _get_metrics_provider():
|
||||||
@@ -121,6 +121,7 @@ catalog_module = ModuleDefinition(
|
|||||||
route="/store/{store_code}/products",
|
route="/store/{store_code}/products",
|
||||||
order=10,
|
order=10,
|
||||||
is_mandatory=True,
|
is_mandatory=True,
|
||||||
|
requires_permission="products.view",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -133,7 +134,7 @@ catalog_module = ModuleDefinition(
|
|||||||
items=[
|
items=[
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="products",
|
id="products",
|
||||||
label_key="storefront.nav.products",
|
label_key="catalog.storefront.nav.products",
|
||||||
icon="shopping-bag",
|
icon="shopping-bag",
|
||||||
route="products",
|
route="products",
|
||||||
order=10,
|
order=10,
|
||||||
@@ -147,10 +148,11 @@ catalog_module = ModuleDefinition(
|
|||||||
items=[
|
items=[
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="search",
|
id="search",
|
||||||
label_key="storefront.actions.search",
|
label_key="catalog.storefront.actions.search",
|
||||||
icon="search",
|
icon="search",
|
||||||
route="",
|
route="",
|
||||||
order=10,
|
order=10,
|
||||||
|
header_template="catalog/storefront/partials/header-search.html",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
291
app/modules/catalog/docs/architecture.md
Normal file
291
app/modules/catalog/docs/architecture.md
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
# Product Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The product management system uses an **independent copy pattern** where store products (`Product`) are fully independent entities that can optionally reference a marketplace source (`MarketplaceProduct`) for display purposes only.
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
| Principle | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| **Full Independence** | Store products have all their own fields - no inheritance or fallback to marketplace |
|
||||||
|
| **Optional Source Reference** | `marketplace_product_id` is nullable - products can be created directly |
|
||||||
|
| **No Reset Functionality** | No "reset to source" - products are independent from the moment of creation |
|
||||||
|
| **Source for Display Only** | Source comparison info is read-only, used for "view original" display |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ MarketplaceProduct │
|
||||||
|
│ (Central Repository - raw imported data from marketplaces) │
|
||||||
|
│ │
|
||||||
|
│ - marketplace_product_id (unique) │
|
||||||
|
│ - gtin, mpn, sku │
|
||||||
|
│ - brand, price_cents, sale_price_cents │
|
||||||
|
│ - is_digital, product_type_enum │
|
||||||
|
│ - translations (via MarketplaceProductTranslation) │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
╳ No runtime dependency
|
||||||
|
│
|
||||||
|
│ Optional FK (for "view source" display only)
|
||||||
|
│ marketplace_product_id (nullable)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Product │
|
||||||
|
│ (Store's Independent Product - fully standalone) │
|
||||||
|
│ │
|
||||||
|
│ === IDENTIFIERS === │
|
||||||
|
│ - store_id (required) │
|
||||||
|
│ - store_sku │
|
||||||
|
│ - gtin, gtin_type │
|
||||||
|
│ │
|
||||||
|
│ === PRODUCT TYPE (own columns) === │
|
||||||
|
│ - is_digital (Boolean) │
|
||||||
|
│ - product_type (String: physical, digital, service, subscription) │
|
||||||
|
│ │
|
||||||
|
│ === PRICING === │
|
||||||
|
│ - price_cents, sale_price_cents │
|
||||||
|
│ - currency, tax_rate_percent │
|
||||||
|
│ │
|
||||||
|
│ === CONTENT === │
|
||||||
|
│ - brand, condition, availability │
|
||||||
|
│ - primary_image_url, additional_images │
|
||||||
|
│ - translations (via ProductTranslation) │
|
||||||
|
│ │
|
||||||
|
│ === STATUS === │
|
||||||
|
│ - is_active, is_featured │
|
||||||
|
│ │
|
||||||
|
│ === SUPPLIER === │
|
||||||
|
│ - supplier, cost_cents │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Product Creation Patterns
|
||||||
|
|
||||||
|
### 1. From Marketplace Source (Import)
|
||||||
|
|
||||||
|
When copying from a marketplace product:
|
||||||
|
- All fields are **copied** at creation time
|
||||||
|
- `marketplace_product_id` is set for source reference
|
||||||
|
- No ongoing relationship - product is immediately independent
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Service copies all fields at import time
|
||||||
|
product = Product(
|
||||||
|
store_id=store.id,
|
||||||
|
marketplace_product_id=marketplace_product.id, # Source reference
|
||||||
|
# All fields copied - no inheritance
|
||||||
|
brand=marketplace_product.brand,
|
||||||
|
price=marketplace_product.price,
|
||||||
|
is_digital=marketplace_product.is_digital,
|
||||||
|
product_type=marketplace_product.product_type_enum,
|
||||||
|
# ... all other fields
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Direct Creation (No Marketplace Source)
|
||||||
|
|
||||||
|
Stores can create products directly without a marketplace source:
|
||||||
|
|
||||||
|
```python
|
||||||
|
product = Product(
|
||||||
|
store_id=store.id,
|
||||||
|
marketplace_product_id=None, # No source
|
||||||
|
store_sku="DIRECT_001",
|
||||||
|
brand="MyBrand",
|
||||||
|
price=29.99,
|
||||||
|
is_digital=True,
|
||||||
|
product_type="digital",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Fields
|
||||||
|
|
||||||
|
### Product Type Fields
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `is_digital` | Boolean | `False` | Whether product is digital (no physical shipping) |
|
||||||
|
| `product_type` | String(20) | `"physical"` | Product type: physical, digital, service, subscription |
|
||||||
|
|
||||||
|
These are **independent columns** on Product, not derived from MarketplaceProduct.
|
||||||
|
|
||||||
|
### Source Reference
|
||||||
|
|
||||||
|
| Field | Type | Nullable | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `marketplace_product_id` | Integer FK | **Yes** | Optional reference to source MarketplaceProduct |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inventory Handling
|
||||||
|
|
||||||
|
Digital and physical products have different inventory behavior:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@property
|
||||||
|
def has_unlimited_inventory(self) -> bool:
|
||||||
|
"""Digital products have unlimited inventory."""
|
||||||
|
return self.is_digital
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_inventory(self) -> int:
|
||||||
|
"""Get total inventory across all locations."""
|
||||||
|
if self.is_digital:
|
||||||
|
return Product.UNLIMITED_INVENTORY # 999999
|
||||||
|
return sum(inv.quantity for inv in self.inventory_entries)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Source Comparison (Display Only)
|
||||||
|
|
||||||
|
For products with a marketplace source, we provide comparison info for display:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_source_comparison_info(self) -> dict:
|
||||||
|
"""Get current values with source values for comparison.
|
||||||
|
|
||||||
|
Used for "view original source" display feature.
|
||||||
|
"""
|
||||||
|
mp = self.marketplace_product
|
||||||
|
return {
|
||||||
|
"price": self.price,
|
||||||
|
"price_source": mp.price if mp else None,
|
||||||
|
"brand": self.brand,
|
||||||
|
"brand_source": mp.brand if mp else None,
|
||||||
|
# ... other fields
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is **read-only** - there's no mechanism to "reset" to source values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Behavior
|
||||||
|
|
||||||
|
### Detail Page
|
||||||
|
|
||||||
|
| Product Type | Source Info Card | Edit Button Text |
|
||||||
|
|-------------|------------------|------------------|
|
||||||
|
| Marketplace-sourced | Shows source info with "View Source" link | "Edit Overrides" |
|
||||||
|
| Directly created | Shows "Direct Creation" badge | "Edit Product" |
|
||||||
|
|
||||||
|
### Info Banner
|
||||||
|
|
||||||
|
- **Marketplace-sourced**: Purple banner - "Store Product Catalog Entry"
|
||||||
|
- **Directly created**: Blue banner - "Directly Created Product"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Product Table Key Columns
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE products (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
store_id INTEGER NOT NULL REFERENCES stores(id),
|
||||||
|
marketplace_product_id INTEGER REFERENCES marketplace_products(id), -- Nullable!
|
||||||
|
|
||||||
|
-- Product Type (independent columns)
|
||||||
|
is_digital BOOLEAN DEFAULT FALSE,
|
||||||
|
product_type VARCHAR(20) DEFAULT 'physical',
|
||||||
|
|
||||||
|
-- Identifiers
|
||||||
|
store_sku VARCHAR,
|
||||||
|
gtin VARCHAR,
|
||||||
|
gtin_type VARCHAR(10),
|
||||||
|
brand VARCHAR,
|
||||||
|
|
||||||
|
-- Pricing (in cents)
|
||||||
|
price_cents INTEGER,
|
||||||
|
sale_price_cents INTEGER,
|
||||||
|
currency VARCHAR(3) DEFAULT 'EUR',
|
||||||
|
tax_rate_percent INTEGER DEFAULT 17,
|
||||||
|
availability VARCHAR,
|
||||||
|
|
||||||
|
-- Media
|
||||||
|
primary_image_url VARCHAR,
|
||||||
|
additional_images JSON,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_featured BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for product type queries
|
||||||
|
CREATE INDEX idx_product_is_digital ON products(is_digital);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration History
|
||||||
|
|
||||||
|
| Migration | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `x2c3d4e5f6g7` | Made `marketplace_product_id` nullable |
|
||||||
|
| `y3d4e5f6g7h8` | Added `is_digital` and `product_type` columns to products |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Create Product (Admin)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/admin/store-products
|
||||||
|
{
|
||||||
|
"store_id": 1,
|
||||||
|
"translations": {
|
||||||
|
"en": {"title": "Product Name", "description": "..."},
|
||||||
|
"fr": {"title": "Nom du produit", "description": "..."}
|
||||||
|
},
|
||||||
|
"store_sku": "SKU001",
|
||||||
|
"brand": "BrandName",
|
||||||
|
"price": 29.99,
|
||||||
|
"is_digital": false,
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Product (Admin)
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH /api/v1/admin/store-products/{id}
|
||||||
|
{
|
||||||
|
"is_digital": true,
|
||||||
|
"price": 39.99,
|
||||||
|
"translations": {
|
||||||
|
"en": {"title": "Updated Name"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Key test scenarios:
|
||||||
|
|
||||||
|
1. **Direct Product Creation** - Create without marketplace source
|
||||||
|
2. **Digital Product Inventory** - Verify unlimited inventory for digital
|
||||||
|
3. **is_digital Column** - Verify it's an independent column, not derived
|
||||||
|
4. **Source Comparison** - Verify read-only source info display
|
||||||
|
|
||||||
|
See:
|
||||||
|
- `tests/unit/models/database/test_product.py`
|
||||||
|
- `tests/integration/api/v1/admin/test_store_products.py`
|
||||||
105
app/modules/catalog/docs/data-model.md
Normal file
105
app/modules/catalog/docs/data-model.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Catalog Data Model
|
||||||
|
|
||||||
|
Entity relationships and database schema for the catalog module.
|
||||||
|
|
||||||
|
## Entity Relationship Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
Store 1──* Product 1──* ProductTranslation
|
||||||
|
│
|
||||||
|
├──* ProductMedia *──1 MediaFile
|
||||||
|
│
|
||||||
|
├──? MarketplaceProduct (source)
|
||||||
|
│
|
||||||
|
└──* Inventory (from inventory module)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Models
|
||||||
|
|
||||||
|
### Product
|
||||||
|
|
||||||
|
Store-specific product with independent copy pattern from marketplace imports. All monetary values stored as integer cents.
|
||||||
|
|
||||||
|
| Field | Type | Constraints | Description |
|
||||||
|
|-------|------|-------------|-------------|
|
||||||
|
| `id` | Integer | PK | Primary key |
|
||||||
|
| `store_id` | Integer | FK, not null | Store ownership |
|
||||||
|
| `marketplace_product_id` | Integer | FK, nullable | Optional marketplace source |
|
||||||
|
| `store_sku` | String | indexed | Store's internal SKU |
|
||||||
|
| `gtin` | String(50) | indexed | EAN/UPC barcode |
|
||||||
|
| `gtin_type` | String(20) | nullable | gtin13, gtin14, gtin12, gtin8, isbn13, isbn10 |
|
||||||
|
| `price_cents` | Integer | nullable | Gross price in cents |
|
||||||
|
| `sale_price_cents` | Integer | nullable | Sale price in cents |
|
||||||
|
| `currency` | String(3) | default "EUR" | Currency code |
|
||||||
|
| `brand` | String | nullable | Product brand |
|
||||||
|
| `condition` | String | nullable | Product condition |
|
||||||
|
| `availability` | String | nullable | Availability status |
|
||||||
|
| `primary_image_url` | String | nullable | Main product image URL |
|
||||||
|
| `additional_images` | JSON | nullable | Array of additional image URLs |
|
||||||
|
| `download_url` | String | nullable | Digital product download URL |
|
||||||
|
| `license_type` | String(50) | nullable | Digital product license |
|
||||||
|
| `tax_rate_percent` | Integer | not null, default 17 | VAT rate (LU: 0, 3, 8, 14, 17) |
|
||||||
|
| `supplier` | String(50) | nullable | codeswholesale, internal, etc. |
|
||||||
|
| `supplier_product_id` | String | nullable | Supplier's product reference |
|
||||||
|
| `cost_cents` | Integer | nullable | Cost to acquire in cents |
|
||||||
|
| `margin_percent_x100` | Integer | nullable | Markup × 100 (2550 = 25.5%) |
|
||||||
|
| `is_digital` | Boolean | default False, indexed | Digital vs physical |
|
||||||
|
| `product_type` | String(20) | default "physical" | physical, digital, service, subscription |
|
||||||
|
| `is_featured` | Boolean | default False | Featured flag |
|
||||||
|
| `is_active` | Boolean | default True | Active flag |
|
||||||
|
| `display_order` | Integer | default 0 | Sort order |
|
||||||
|
| `min_quantity` | Integer | default 1 | Min purchase quantity |
|
||||||
|
| `max_quantity` | Integer | nullable | Max purchase quantity |
|
||||||
|
| `fulfillment_email_template` | String | nullable | Template for digital delivery |
|
||||||
|
| `created_at` | DateTime | tz-aware | Record creation time |
|
||||||
|
| `updated_at` | DateTime | tz-aware | Record update time |
|
||||||
|
|
||||||
|
**Unique Constraint**: `(store_id, marketplace_product_id)`
|
||||||
|
**Composite Indexes**: `(store_id, is_active)`, `(store_id, is_featured)`, `(store_id, store_sku)`, `(supplier, supplier_product_id)`
|
||||||
|
|
||||||
|
**Key Properties**: `price`, `sale_price`, `cost` (euro converters), `net_price_cents` (gross minus VAT), `vat_amount_cents`, `profit_cents`, `profit_margin_percent`, `total_inventory`, `available_inventory`
|
||||||
|
|
||||||
|
### ProductTranslation
|
||||||
|
|
||||||
|
Store-specific multilingual content with SEO fields. Independent copy from marketplace translations.
|
||||||
|
|
||||||
|
| Field | Type | Constraints | Description |
|
||||||
|
|-------|------|-------------|-------------|
|
||||||
|
| `id` | Integer | PK | Primary key |
|
||||||
|
| `product_id` | Integer | FK, not null, cascade | Parent product |
|
||||||
|
| `language` | String(5) | not null | en, fr, de, lb |
|
||||||
|
| `title` | String | nullable | Product title |
|
||||||
|
| `description` | Text | nullable | Full description |
|
||||||
|
| `short_description` | String(500) | nullable | Abbreviated description |
|
||||||
|
| `meta_title` | String(70) | nullable | SEO title |
|
||||||
|
| `meta_description` | String(160) | nullable | SEO description |
|
||||||
|
| `url_slug` | String(255) | nullable | URL-friendly slug |
|
||||||
|
| `created_at` | DateTime | tz-aware | Record creation time |
|
||||||
|
| `updated_at` | DateTime | tz-aware | Record update time |
|
||||||
|
|
||||||
|
**Unique Constraint**: `(product_id, language)`
|
||||||
|
|
||||||
|
### ProductMedia
|
||||||
|
|
||||||
|
Association between products and media files with usage tracking.
|
||||||
|
|
||||||
|
| Field | Type | Constraints | Description |
|
||||||
|
|-------|------|-------------|-------------|
|
||||||
|
| `id` | Integer | PK | Primary key |
|
||||||
|
| `product_id` | Integer | FK, not null, cascade | Product reference |
|
||||||
|
| `media_id` | Integer | FK, not null, cascade | Media file reference |
|
||||||
|
| `usage_type` | String(50) | default "gallery" | main_image, gallery, variant, thumbnail, swatch |
|
||||||
|
| `display_order` | Integer | default 0 | Sort order |
|
||||||
|
| `variant_id` | Integer | nullable | Variant reference |
|
||||||
|
| `created_at` | DateTime | tz-aware | Record creation time |
|
||||||
|
| `updated_at` | DateTime | tz-aware | Record update time |
|
||||||
|
|
||||||
|
**Unique Constraint**: `(product_id, media_id, usage_type)`
|
||||||
|
|
||||||
|
## Design Patterns
|
||||||
|
|
||||||
|
- **Independent copy pattern**: Products are copied from marketplace sources, not linked. Store-specific data diverges independently.
|
||||||
|
- **Money as cents**: All prices, costs, margins stored as integer cents
|
||||||
|
- **Luxembourg VAT**: Supports all LU rates (0%, 3%, 8%, 14%, 17%)
|
||||||
|
- **Multi-type products**: Physical, digital, service, subscription with type-specific fields
|
||||||
|
- **SEO per language**: Meta title and description in each translation
|
||||||
57
app/modules/catalog/docs/index.md
Normal file
57
app/modules/catalog/docs/index.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Product Catalog
|
||||||
|
|
||||||
|
Product catalog browsing and search for storefronts.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| Code | `catalog` |
|
||||||
|
| Classification | Optional |
|
||||||
|
| Dependencies | None |
|
||||||
|
| Status | Active |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- `product_catalog` — Product catalog browsing
|
||||||
|
- `product_search` — Product search and filtering
|
||||||
|
- `product_variants` — Product variant management
|
||||||
|
- `product_categories` — Category hierarchy
|
||||||
|
- `product_attributes` — Custom product attributes
|
||||||
|
- `product_import_export` — Bulk product import/export
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
| Permission | Description |
|
||||||
|
|------------|-------------|
|
||||||
|
| `products.view` | View products |
|
||||||
|
| `products.create` | Create products |
|
||||||
|
| `products.edit` | Edit products |
|
||||||
|
| `products.delete` | Delete products |
|
||||||
|
| `products.import` | Import products |
|
||||||
|
| `products.export` | Export products |
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
See [Data Model](data-model.md) for full entity relationships and schema.
|
||||||
|
|
||||||
|
- **Product** — Store-specific product with pricing, VAT, and supplier fields
|
||||||
|
- **ProductTranslation** — Multilingual content with SEO fields
|
||||||
|
- **ProductMedia** — Product-media associations with usage types
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `*` | `/api/v1/admin/catalog/*` | Admin product management |
|
||||||
|
| `*` | `/api/v1/store/catalog/*` | Store product management |
|
||||||
|
| `GET` | `/api/v1/storefront/catalog/*` | Public product browsing |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
No module-specific configuration.
|
||||||
|
|
||||||
|
## Additional Documentation
|
||||||
|
|
||||||
|
- [Data Model](data-model.md) — Entity relationships and database schema
|
||||||
|
- [Architecture](architecture.md) — Independent product copy pattern and API design
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user