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) { ... }
|
||||
window.languageSelector = languageSelector;
|
||||
pattern:
|
||||
file_pattern: "static/shop/js/shop-layout.js"
|
||||
required_patterns:
|
||||
- "function languageSelector"
|
||||
- "window.languageSelector"
|
||||
file_pattern: "static/vendor/js/init-alpine.js"
|
||||
file_patterns:
|
||||
- "static/shop/js/shop-layout.js"
|
||||
- "static/vendor/js/init-alpine.js"
|
||||
required_patterns:
|
||||
- "function languageSelector"
|
||||
- "window.languageSelector"
|
||||
@@ -247,3 +245,26 @@ language_rules:
|
||||
pattern:
|
||||
file_pattern: "static/locales/*.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
|
||||
de.json
|
||||
fr.json
|
||||
lu.json
|
||||
lb.json
|
||||
|
||||
Translation keys are namespaced as {module}.key_name
|
||||
pattern:
|
||||
@@ -269,14 +269,14 @@ module_rules:
|
||||
Module locales/ directory should have translation files for
|
||||
all supported languages to ensure consistent i18n.
|
||||
|
||||
Supported languages: en, de, fr, lu
|
||||
Supported languages: en, de, fr, lb
|
||||
|
||||
Structure:
|
||||
app/modules/<code>/locales/
|
||||
├── en.json
|
||||
├── de.json
|
||||
├── fr.json
|
||||
└── lu.json
|
||||
└── lb.json
|
||||
|
||||
Missing translations will fall back to English, but it's
|
||||
better to have all languages covered.
|
||||
@@ -286,7 +286,7 @@ module_rules:
|
||||
- "en.json"
|
||||
- "de.json"
|
||||
- "fr.json"
|
||||
- "lu.json"
|
||||
- "lb.json"
|
||||
|
||||
- id: "MOD-007"
|
||||
name: "Module definition must match directory structure"
|
||||
@@ -692,8 +692,9 @@ module_rules:
|
||||
name: "Modules with routers should use get_*_with_routers pattern"
|
||||
severity: "info"
|
||||
description: |
|
||||
Modules that define routers (admin_router, vendor_router, etc.)
|
||||
should follow the lazy import pattern with a dedicated function:
|
||||
Modules that define routers should follow the lazy import pattern
|
||||
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:
|
||||
|
||||
@@ -704,12 +705,12 @@ module_rules:
|
||||
|
||||
WRONG:
|
||||
# Direct router assignment at module level
|
||||
module.admin_router = admin_router
|
||||
module.admin_router = router
|
||||
|
||||
RIGHT:
|
||||
def _get_admin_router():
|
||||
from app.modules.orders.routes.admin import admin_router
|
||||
return admin_router
|
||||
from app.modules.orders.routes.api.admin import router
|
||||
return router
|
||||
|
||||
def get_orders_module_with_routers() -> ModuleDefinition:
|
||||
orders_module.admin_router = _get_admin_router()
|
||||
@@ -761,3 +762,96 @@ module_rules:
|
||||
file_pattern: "main.py"
|
||||
validates:
|
||||
- "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
|
||||
|
||||
# =============================================================================
|
||||
# PLATFORM DOMAIN CONFIGURATION
|
||||
# MAIN DOMAIN CONFIGURATION
|
||||
# =============================================================================
|
||||
# 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
|
||||
# Enable/disable custom domains
|
||||
@@ -149,6 +154,10 @@ SEED_ORDERS_PER_STORE=10
|
||||
# =============================================================================
|
||||
# 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)
|
||||
# Default works with: docker-compose up -d redis
|
||||
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
|
||||
# Get Issuer ID from https://pay.google.com/business/console
|
||||
# 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)
|
||||
# LOYALTY_APPLE_PASS_TYPE_ID=pass.com.example.loyalty
|
||||
|
||||
@@ -37,10 +37,11 @@ jobs:
|
||||
run: ruff check .
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# Tests — unit only (integration tests run locally via make test)
|
||||
# ---------------------------------------------------------------------------
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
@@ -55,10 +56,9 @@ jobs:
|
||||
--health-retries 5
|
||||
|
||||
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"
|
||||
DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/orion_test"
|
||||
LOG_LEVEL: "WARNING"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -73,8 +73,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: uv pip install --system -r requirements.txt -r requirements-test.txt
|
||||
|
||||
- name: Run tests
|
||||
run: python -m pytest tests/ -v --tb=short
|
||||
- name: Run unit tests
|
||||
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:
|
||||
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__/
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
.dockerignore.local
|
||||
*.override.yml
|
||||
|
||||
# Deployment & Security
|
||||
.build-info
|
||||
deployment-local/
|
||||
*.pem
|
||||
*.key
|
||||
@@ -190,3 +189,6 @@ static/shared/css/tailwind.css
|
||||
# Export files
|
||||
orion_letzshop_export_*.csv
|
||||
exports/
|
||||
|
||||
# Security audit (needs revamping)
|
||||
scripts/security-audit/
|
||||
|
||||
2
.idea/inspectionProfiles/Project_Default.xml
generated
2
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -7,4 +7,4 @@
|
||||
</option>
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
</component>
|
||||
|
||||
2
.idea/inspectionProfiles/profiles_settings.xml
generated
2
.idea/inspectionProfiles/profiles_settings.xml
generated
@@ -3,4 +3,4 @@
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
||||
</component>
|
||||
|
||||
2
.idea/modules.xml
generated
2
.idea/modules.xml
generated
@@ -5,4 +5,4 @@
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/fastapi-multitenant-ecommerce.iml" filepath="$PROJECT_DIR$/.idea/fastapi-multitenant-ecommerce.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
</project>
|
||||
|
||||
32
Makefile
32
Makefile
@@ -1,7 +1,7 @@
|
||||
# Orion Multi-Tenant E-Commerce Platform Makefile
|
||||
# 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
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@@ -249,24 +249,21 @@ ifdef frontend
|
||||
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:
|
||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||
@sleep 2
|
||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||
$(PYTHON) -m pytest $(TEST_PATHS) -v $(MARKER_EXPR)
|
||||
$(PYTHON) -m pytest -v $(MARKER_EXPR)
|
||||
|
||||
test-unit:
|
||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||
@sleep 2
|
||||
ifdef module
|
||||
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
|
||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||
$(PYTHON) -m pytest $(TEST_PATHS) -v -m unit
|
||||
$(PYTHON) -m pytest -v -m unit
|
||||
endif
|
||||
|
||||
test-integration:
|
||||
@@ -274,29 +271,38 @@ test-integration:
|
||||
@sleep 2
|
||||
ifdef module
|
||||
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
|
||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||
$(PYTHON) -m pytest $(TEST_PATHS) -v -m integration
|
||||
$(PYTHON) -m pytest -v -m integration
|
||||
endif
|
||||
|
||||
test-coverage:
|
||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||
@sleep 2
|
||||
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:
|
||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||
@sleep 2
|
||||
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:
|
||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||
@sleep 2
|
||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||
$(PYTHON) -m pytest $(TEST_PATHS) -v -m slow
|
||||
$(PYTHON) -m pytest -v -m slow
|
||||
|
||||
# =============================================================================
|
||||
# CODE QUALITY
|
||||
@@ -569,6 +575,8 @@ help:
|
||||
@echo " test-unit module=X - Run unit tests for module X"
|
||||
@echo " test-integration - Run integration tests only"
|
||||
@echo " test-coverage - Run tests with coverage"
|
||||
@echo " test-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 frontend=storefront - Run storefront tests"
|
||||
@echo ""
|
||||
|
||||
@@ -120,4 +120,4 @@
|
||||
won't be supported unlike CMS pages where he can create pretty much anything - btw let s make a note that number of pages should be defined in tiers)
|
||||
3/ custom domain setup: admin should be contacted to setup. same for SSL. custom emails. (this should be readonly for now)
|
||||
4/ API keys: stripe keys should be there
|
||||
5/ sections in settings page are not displayed properly: general , localization etc take 2/3 of the screen size
|
||||
5/ sections in settings page are not displayed properly: general , localization etc take 2/3 of the screen size
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
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
|
||||
sqlalchemy.url =
|
||||
# for PROD: sqlalchemy.url = postgresql://username:password@localhost:5432/ecommerce_db
|
||||
|
||||
@@ -1 +1 @@
|
||||
Generic single-database configuration.
|
||||
Generic single-database configuration.
|
||||
|
||||
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
|
||||
from datetime import UTC
|
||||
|
||||
from fastapi import Cookie, Depends, Request
|
||||
from fastapi import Cookie, Depends, HTTPException, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
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 User as UserModel
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
from middleware.auth import AuthManager
|
||||
from middleware.rate_limiter import RateLimiter
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
# Initialize dependencies
|
||||
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(
|
||||
credentials: HTTPAuthorizationCredentials | 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.
|
||||
|
||||
Usage:
|
||||
admin_router = APIRouter(
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(require_module_access("messaging", FrontendType.ADMIN))]
|
||||
)
|
||||
|
||||
store_router = APIRouter(
|
||||
router = APIRouter(
|
||||
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:
|
||||
# Super admin: check user-level config
|
||||
platform_id = None
|
||||
user_id = user_context.id
|
||||
# Super admin: use platform from token if selected, else global (no filtering)
|
||||
platform_id = user_context.token_platform_id
|
||||
user_id = None
|
||||
else:
|
||||
# Platform admin: need platform context
|
||||
# Try to get from request state
|
||||
@@ -1544,6 +1557,55 @@ def get_user_permissions(
|
||||
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)
|
||||
# ============================================================================
|
||||
@@ -1682,3 +1744,39 @@ def get_current_customer_optional(
|
||||
except Exception:
|
||||
# Invalid token, store mismatch, or other error
|
||||
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.
|
||||
|
||||
Handles the multi-step signup flow:
|
||||
1. Start signup (select tier)
|
||||
2. Claim Letzshop store (optional)
|
||||
3. Create account
|
||||
1. Start signup (select tier + platform)
|
||||
2. Create account (user + merchant)
|
||||
3. Create store
|
||||
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).
|
||||
"""
|
||||
@@ -20,9 +23,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.environment import should_use_secure_cookies
|
||||
from app.modules.marketplace.services.platform_signup_service import (
|
||||
platform_signup_service,
|
||||
)
|
||||
from app.modules.billing.services.signup_service import signup_service
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -34,10 +35,12 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SignupStartRequest(BaseModel):
|
||||
"""Start signup - select tier."""
|
||||
"""Start signup - select tier and platform."""
|
||||
|
||||
tier_code: str
|
||||
is_annual: bool = False
|
||||
platform_code: str
|
||||
language: str = "fr"
|
||||
|
||||
|
||||
class SignupStartResponse(BaseModel):
|
||||
@@ -46,26 +49,11 @@ class SignupStartResponse(BaseModel):
|
||||
session_id: str
|
||||
tier_code: str
|
||||
is_annual: bool
|
||||
|
||||
|
||||
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
|
||||
platform_code: str
|
||||
|
||||
|
||||
class CreateAccountRequest(BaseModel):
|
||||
"""Create account."""
|
||||
"""Create account (user + merchant)."""
|
||||
|
||||
session_id: str
|
||||
email: EmailStr
|
||||
@@ -77,12 +65,30 @@ class CreateAccountRequest(BaseModel):
|
||||
|
||||
|
||||
class CreateAccountResponse(BaseModel):
|
||||
"""Response from account creation."""
|
||||
"""Response from account creation (includes auto-created store)."""
|
||||
|
||||
session_id: str
|
||||
user_id: int
|
||||
store_id: int
|
||||
merchant_id: int
|
||||
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):
|
||||
@@ -127,43 +133,21 @@ async def start_signup(request: SignupStartRequest) -> SignupStartResponse:
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
session_id = platform_signup_service.create_session(
|
||||
session_id = signup_service.create_session(
|
||||
tier_code=request.tier_code,
|
||||
is_annual=request.is_annual,
|
||||
platform_code=request.platform_code,
|
||||
language=request.language,
|
||||
)
|
||||
|
||||
return SignupStartResponse(
|
||||
session_id=session_id,
|
||||
tier_code=request.tier_code,
|
||||
is_annual=request.is_annual,
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
platform_code=request.platform_code,
|
||||
)
|
||||
|
||||
|
||||
@@ -173,12 +157,13 @@ async def create_account(
|
||||
db: Session = Depends(get_db),
|
||||
) -> CreateAccountResponse:
|
||||
"""
|
||||
Create user and store accounts.
|
||||
Create user and merchant accounts.
|
||||
|
||||
Step 3: User provides account details.
|
||||
Creates User, Merchant, Store, and Stripe Customer.
|
||||
Step 2: User provides account details.
|
||||
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,
|
||||
session_id=request.session_id,
|
||||
email=request.email,
|
||||
@@ -192,8 +177,35 @@ async def create_account(
|
||||
return CreateAccountResponse(
|
||||
session_id=request.session_id,
|
||||
user_id=result.user_id,
|
||||
store_id=result.store_id,
|
||||
merchant_id=result.merchant_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.
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -228,7 +240,7 @@ async def complete_signup(
|
||||
Step 5: Verify SetupIntent, attach payment method, create subscription.
|
||||
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,
|
||||
session_id=request.session_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.
|
||||
"""
|
||||
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 {
|
||||
|
||||
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
|
||||
# Worker settings
|
||||
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_expires=86400, # Results expire after 24 hours
|
||||
# Retry policy
|
||||
|
||||
@@ -6,7 +6,7 @@ This module provides classes and functions for:
|
||||
- Configuration management via environment variables
|
||||
- Database settings
|
||||
- JWT and authentication configuration
|
||||
- Platform domain and multi-tenancy settings
|
||||
- Main domain and multi-tenancy settings
|
||||
- Admin initialization settings
|
||||
|
||||
Note: Environment detection is handled by app.core.environment module.
|
||||
@@ -94,9 +94,14 @@ class Settings(BaseSettings):
|
||||
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
|
||||
allow_custom_domains: bool = True
|
||||
@@ -218,12 +223,15 @@ class Settings(BaseSettings):
|
||||
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_google_service_account_json: str | None = None # Path to service account JSON
|
||||
loyalty_apple_pass_type_id: str | None = None
|
||||
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
|
||||
@@ -342,7 +350,7 @@ def print_environment_info():
|
||||
print(f" Database: {settings.database_url}")
|
||||
print(f" Debug mode: {settings.debug}")
|
||||
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("=" * 70 + "\n")
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ Note: This project uses PostgreSQL only. SQLite is not supported.
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker, with_loader_criteria
|
||||
from sqlalchemy.pool import QueuePool
|
||||
|
||||
from .config import settings, validate_database_url
|
||||
@@ -38,6 +38,45 @@ Base = declarative_base()
|
||||
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():
|
||||
"""
|
||||
Database session dependency for FastAPI routes.
|
||||
|
||||
@@ -44,6 +44,9 @@ async def lifespan(app: FastAPI):
|
||||
grafana_url=settings.grafana_url,
|
||||
)
|
||||
|
||||
# Validate wallet configurations
|
||||
_validate_wallet_config()
|
||||
|
||||
logger.info("[OK] Application startup completed")
|
||||
|
||||
yield
|
||||
@@ -53,6 +56,72 @@ async def lifespan(app: FastAPI):
|
||||
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 ===
|
||||
def check_database_ready():
|
||||
"""Check if database is ready (migrations have been run)."""
|
||||
|
||||
@@ -34,7 +34,8 @@ from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Response
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -538,12 +539,20 @@ async def readiness_check() -> dict[str, Any]:
|
||||
|
||||
|
||||
@health_router.get("/metrics")
|
||||
async def metrics_endpoint() -> Response:
|
||||
async def metrics_endpoint(request: Request) -> Response:
|
||||
"""
|
||||
Prometheus metrics endpoint.
|
||||
|
||||
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()
|
||||
return Response(
|
||||
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:
|
||||
HTMLResponse with rendered error page
|
||||
"""
|
||||
# Get frontend type
|
||||
frontend_type = get_frontend_type(request)
|
||||
# Get frontend type — default to PLATFORM in error rendering context
|
||||
# (errors can occur before FrontendTypeMiddleware runs)
|
||||
frontend_type = get_frontend_type(request) or FrontendType.PLATFORM
|
||||
|
||||
# Prepare template data
|
||||
template_data = ErrorPageRenderer._prepare_template_data(
|
||||
@@ -291,7 +292,7 @@ class ErrorPageRenderer:
|
||||
# TODO: Implement actual admin check based on JWT/session
|
||||
# For now, check if we're in admin frontend
|
||||
frontend_type = get_frontend_type(request)
|
||||
return frontend_type == FrontendType.ADMIN
|
||||
return frontend_type is not None and frontend_type == FrontendType.ADMIN
|
||||
|
||||
@staticmethod
|
||||
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.
|
||||
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:
|
||||
logger.debug("Redirecting to /admin/login")
|
||||
|
||||
@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
|
||||
# api_timeout: int = 30
|
||||
# batch_size: int = 100
|
||||
|
||||
model_config = {"env_prefix": "ANALYTICS_"}
|
||||
model_config = {"env_prefix": "ANALYTICS_", "env_file": ".env", "extra": "ignore"}
|
||||
|
||||
|
||||
# Export for auto-discovery
|
||||
|
||||
@@ -96,11 +96,13 @@ analytics_module = ModuleDefinition(
|
||||
icon="chart-bar",
|
||||
route="/store/{store_code}/analytics",
|
||||
order=20,
|
||||
requires_permission="analytics.view",
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
requires=["catalog", "inventory", "marketplace", "orders"], # Imports from these modules
|
||||
is_core=False,
|
||||
# =========================================================================
|
||||
# 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": {
|
||||
"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...",
|
||||
"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": {
|
||||
"analytics": "Analytics"
|
||||
}
|
||||
|
||||
@@ -16,5 +16,13 @@
|
||||
},
|
||||
"menu": {
|
||||
"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": {
|
||||
"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.
|
||||
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.pages import store_router as store_page_router
|
||||
from app.modules.analytics.routes.api import store_router
|
||||
from app.modules.analytics.routes.pages import store_page_router
|
||||
|
||||
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
|
||||
return store_router
|
||||
if name == "store_page_router":
|
||||
from app.modules.analytics.routes.pages import store_router
|
||||
return store_router
|
||||
from app.modules.analytics.routes.pages import router
|
||||
return router
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@@ -31,7 +31,7 @@ router = APIRouter(
|
||||
prefix="/analytics",
|
||||
dependencies=[Depends(require_module_access("analytics", FrontendType.STORE))],
|
||||
)
|
||||
store_router = router # Alias for discovery
|
||||
router = router # Alias for discovery
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -7,11 +7,15 @@ Store pages for analytics dashboard.
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Request
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_store_from_cookie_or_header, get_db
|
||||
from app.api.deps import (
|
||||
get_db,
|
||||
get_resolved_store_code,
|
||||
require_store_page_permission,
|
||||
)
|
||||
from app.modules.core.services.platform_settings_service import (
|
||||
platform_settings_service, # MOD-004 - shared platform service
|
||||
)
|
||||
@@ -73,12 +77,12 @@ def get_store_context(
|
||||
|
||||
|
||||
@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(
|
||||
request: Request,
|
||||
store_code: str = Path(..., description="Store code"),
|
||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||
store_code: str = Depends(get_resolved_store_code),
|
||||
current_user: User = Depends(require_store_page_permission("analytics.view")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
|
||||
@@ -15,23 +15,13 @@ import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
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 (
|
||||
AdminOperationException,
|
||||
StoreNotFoundException,
|
||||
)
|
||||
from app.modules.tenancy.models import Store, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -58,84 +48,56 @@ class StatsService:
|
||||
StoreNotFoundException: If store doesn't exist
|
||||
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
|
||||
store = db.query(Store).filter(Store.id == store_id).first()
|
||||
store = store_service.get_store_by_id_optional(db, store_id)
|
||||
if not store:
|
||||
raise StoreNotFoundException(str(store_id), identifier_type="id")
|
||||
|
||||
try:
|
||||
# Catalog statistics
|
||||
total_catalog_products = (
|
||||
db.query(Product)
|
||||
.filter(Product.store_id == store_id, Product.is_active == True)
|
||||
.count()
|
||||
total_catalog_products = product_service.get_store_product_count(
|
||||
db, store_id, active_only=True,
|
||||
)
|
||||
|
||||
featured_products = (
|
||||
db.query(Product)
|
||||
.filter(
|
||||
Product.store_id == store_id,
|
||||
Product.is_featured == True,
|
||||
Product.is_active == True,
|
||||
)
|
||||
.count()
|
||||
featured_products = product_service.get_store_product_count(
|
||||
db, store_id, active_only=True, featured_only=True,
|
||||
)
|
||||
|
||||
# Staging statistics
|
||||
# TODO: This is fragile - MarketplaceProduct uses store_name (string) not store_id
|
||||
# Should add store_id foreign key to MarketplaceProduct for robust querying
|
||||
# For now, matching by store name which could fail if names don't match exactly
|
||||
staging_products = (
|
||||
db.query(MarketplaceProduct)
|
||||
.filter(MarketplaceProduct.store_name == store.name)
|
||||
.count()
|
||||
staging_products = marketplace_product_service.get_staging_product_count(
|
||||
db, store_name=store.name,
|
||||
)
|
||||
|
||||
# Inventory statistics
|
||||
total_inventory = (
|
||||
db.query(func.sum(Inventory.quantity))
|
||||
.filter(Inventory.store_id == store_id)
|
||||
.scalar()
|
||||
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
|
||||
)
|
||||
inv_stats = inventory_service.get_store_inventory_stats(db, store_id)
|
||||
total_inventory = inv_stats["total"]
|
||||
reserved_inventory = inv_stats["reserved"]
|
||||
inventory_locations = inv_stats["locations"]
|
||||
|
||||
# Import statistics
|
||||
total_imports = (
|
||||
db.query(MarketplaceImportJob)
|
||||
.filter(MarketplaceImportJob.store_id == store_id)
|
||||
.count()
|
||||
)
|
||||
|
||||
successful_imports = (
|
||||
db.query(MarketplaceImportJob)
|
||||
.filter(
|
||||
MarketplaceImportJob.store_id == store_id,
|
||||
MarketplaceImportJob.status == "completed",
|
||||
)
|
||||
.count()
|
||||
import_stats = marketplace_import_job_service.get_import_job_stats(
|
||||
db, store_id=store_id,
|
||||
)
|
||||
total_imports = import_stats["total"]
|
||||
successful_imports = import_stats["completed"]
|
||||
|
||||
# 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
|
||||
total_customers = (
|
||||
db.query(Customer).filter(Customer.store_id == store_id).count()
|
||||
)
|
||||
total_customers = customer_service.get_store_customer_count(db, store_id)
|
||||
|
||||
# Return flat structure compatible with StoreDashboardStatsResponse schema
|
||||
# The endpoint will restructure this into nested format
|
||||
@@ -204,8 +166,15 @@ class StatsService:
|
||||
StoreNotFoundException: If store doesn't exist
|
||||
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
|
||||
store = db.query(Store).filter(Store.id == store_id).first()
|
||||
store = store_service.get_store_by_id_optional(db, store_id)
|
||||
if not store:
|
||||
raise StoreNotFoundException(str(store_id), identifier_type="id")
|
||||
|
||||
@@ -215,28 +184,17 @@ class StatsService:
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# Import activity
|
||||
recent_imports = (
|
||||
db.query(MarketplaceImportJob)
|
||||
.filter(
|
||||
MarketplaceImportJob.store_id == store_id,
|
||||
MarketplaceImportJob.created_at >= start_date,
|
||||
)
|
||||
.count()
|
||||
import_stats = marketplace_import_job_service.get_import_job_stats(
|
||||
db, store_id=store_id,
|
||||
)
|
||||
recent_imports = import_stats["total"]
|
||||
|
||||
# Products added to catalog
|
||||
products_added = (
|
||||
db.query(Product)
|
||||
.filter(
|
||||
Product.store_id == store_id, Product.created_at >= start_date
|
||||
)
|
||||
.count()
|
||||
)
|
||||
products_added = product_service.get_store_product_count(db, store_id)
|
||||
|
||||
# Inventory changes
|
||||
inventory_entries = (
|
||||
db.query(Inventory).filter(Inventory.store_id == store_id).count()
|
||||
)
|
||||
inv_stats = inventory_service.get_store_inventory_stats(db, store_id)
|
||||
inventory_entries = inv_stats.get("locations", 0)
|
||||
|
||||
return {
|
||||
"period": period,
|
||||
@@ -271,19 +229,15 @@ class StatsService:
|
||||
Returns dict compatible with StoreStatsResponse schema.
|
||||
Keys: total, verified, pending, inactive (mapped from internal names)
|
||||
"""
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
|
||||
try:
|
||||
total_stores = db.query(Store).count()
|
||||
active_stores = db.query(Store).filter(Store.is_active == True).count()
|
||||
verified_stores = (
|
||||
db.query(Store).filter(Store.is_verified == True).count()
|
||||
)
|
||||
total_stores = store_service.get_total_store_count(db)
|
||||
active_stores = store_service.get_total_store_count(db, active_only=True)
|
||||
inactive_stores = total_stores - active_stores
|
||||
# Pending = active but not yet verified
|
||||
pending_stores = (
|
||||
db.query(Store)
|
||||
.filter(Store.is_active == True, Store.is_verified == False)
|
||||
.count()
|
||||
)
|
||||
# Use store_service for verified/pending counts
|
||||
verified_stores = store_service.get_store_count_by_status(db, verified=True)
|
||||
pending_stores = store_service.get_store_count_by_status(db, active=True, verified=False)
|
||||
|
||||
return {
|
||||
"total": total_stores,
|
||||
@@ -318,21 +272,22 @@ class StatsService:
|
||||
AdminOperationException: If database query fails
|
||||
"""
|
||||
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
|
||||
total_stores = db.query(Store).filter(Store.is_active == True).count()
|
||||
total_stores = store_service.get_total_store_count(db, active_only=True)
|
||||
|
||||
# Products
|
||||
total_catalog_products = db.query(Product).count()
|
||||
unique_brands = self._get_unique_brands_count(db)
|
||||
unique_categories = self._get_unique_categories_count(db)
|
||||
total_catalog_products = product_service.get_total_product_count(db)
|
||||
unique_brands = marketplace_product_service.get_distinct_brand_count(db)
|
||||
unique_categories = marketplace_product_service.get_distinct_category_count(db)
|
||||
|
||||
# Marketplaces
|
||||
unique_marketplaces = (
|
||||
db.query(MarketplaceProduct.marketplace)
|
||||
.filter(MarketplaceProduct.marketplace.isnot(None))
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
unique_marketplaces = marketplace_product_service.get_distinct_marketplace_count(db)
|
||||
|
||||
# Inventory
|
||||
inventory_stats = self._get_inventory_statistics(db)
|
||||
@@ -368,31 +323,11 @@ class StatsService:
|
||||
AdminOperationException: If database query fails
|
||||
"""
|
||||
try:
|
||||
marketplace_stats = (
|
||||
db.query(
|
||||
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()
|
||||
from app.modules.marketplace.services.marketplace_product_service import (
|
||||
marketplace_product_service,
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"marketplace": stat.marketplace,
|
||||
"total_products": stat.total_products,
|
||||
"unique_stores": stat.unique_stores,
|
||||
"unique_brands": stat.unique_brands,
|
||||
}
|
||||
for stat in marketplace_stats
|
||||
]
|
||||
return marketplace_product_service.get_marketplace_breakdown(db)
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(
|
||||
@@ -417,20 +352,10 @@ class StatsService:
|
||||
AdminOperationException: If database query fails
|
||||
"""
|
||||
try:
|
||||
total_users = db.query(User).count()
|
||||
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()
|
||||
from app.modules.tenancy.services.admin_service import admin_service
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"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
|
||||
),
|
||||
}
|
||||
user_stats = admin_service.get_user_statistics(db)
|
||||
return user_stats
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Failed to get user statistics: {str(e)}")
|
||||
raise AdminOperationException(
|
||||
@@ -451,38 +376,19 @@ class StatsService:
|
||||
AdminOperationException: If database query fails
|
||||
"""
|
||||
try:
|
||||
total = db.query(MarketplaceImportJob).count()
|
||||
pending = (
|
||||
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()
|
||||
from app.modules.marketplace.services.marketplace_import_job_service import (
|
||||
marketplace_import_job_service,
|
||||
)
|
||||
|
||||
stats = marketplace_import_job_service.get_import_job_stats(db)
|
||||
total = stats["total"]
|
||||
completed = stats["completed"]
|
||||
return {
|
||||
"total": total,
|
||||
"pending": pending,
|
||||
"processing": processing,
|
||||
"pending": stats["pending"],
|
||||
"processing": stats.get("processing", 0),
|
||||
"completed": completed,
|
||||
"failed": failed,
|
||||
"failed": stats["failed"],
|
||||
"success_rate": (completed / total * 100) if total > 0 else 0,
|
||||
}
|
||||
except SQLAlchemyError as e:
|
||||
@@ -548,58 +454,13 @@ class StatsService:
|
||||
}
|
||||
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]:
|
||||
"""
|
||||
Get inventory-related statistics.
|
||||
"""Get inventory-related statistics via inventory service."""
|
||||
from app.modules.inventory.services.inventory_service import inventory_service
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
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
|
||||
total_entries = inventory_service.get_total_inventory_count(db)
|
||||
total_quantity = inventory_service.get_total_inventory_quantity(db)
|
||||
total_reserved = inventory_service.get_total_reserved_quantity(db)
|
||||
|
||||
return {
|
||||
"total_entries": total_entries,
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
</div>
|
||||
<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">
|
||||
<span x-html="$icon('location-marker', 'w-6 h-6')"></span>
|
||||
<span x-html="$icon('map-pin', 'w-6 h-6')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<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.features import FeatureProviderProtocol
|
||||
from app.modules.contracts.metrics import MetricsProviderProtocol
|
||||
from app.modules.contracts.onboarding import OnboardingProviderProtocol
|
||||
from app.modules.contracts.widgets import DashboardWidgetProviderProtocol
|
||||
|
||||
from app.modules.enums import FrontendType
|
||||
@@ -94,6 +95,7 @@ class MenuItemDefinition:
|
||||
requires_permission: str | None = None
|
||||
badge_source: str | None = None
|
||||
is_super_admin_only: bool = False
|
||||
header_template: str | None = None # Optional partial for custom header rendering
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -486,6 +488,29 @@ class ModuleDefinition:
|
||||
# to report where media is being used.
|
||||
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)
|
||||
# =========================================================================
|
||||
@@ -955,6 +980,24 @@ class ModuleDefinition:
|
||||
return None
|
||||
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
|
||||
# =========================================================================
|
||||
|
||||
@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
|
||||
# api_timeout: int = 30
|
||||
# batch_size: int = 100
|
||||
|
||||
model_config = {"env_prefix": "BILLING_"}
|
||||
model_config = {"env_prefix": "BILLING_", "env_file": ".env", "extra": "ignore"}
|
||||
|
||||
|
||||
# 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.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 = (
|
||||
db.query(SubscriptionTier)
|
||||
@@ -48,14 +51,28 @@ def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any
|
||||
tiers = []
|
||||
for tier in tiers_db:
|
||||
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({
|
||||
"code": tier.code,
|
||||
"name": tier.name,
|
||||
"name": tier.get_translated_name(language),
|
||||
"price_monthly": tier.price_monthly_cents / 100,
|
||||
"price_annual": (tier.price_annual_cents / 100)
|
||||
if tier.price_annual_cents
|
||||
else None,
|
||||
"feature_codes": feature_codes,
|
||||
"features": features,
|
||||
"products_limit": tier.get_limit_for_feature("products_limit"),
|
||||
"orders_per_month": tier.get_limit_for_feature("orders_per_month"),
|
||||
"team_members": tier.get_limit_for_feature("team_members"),
|
||||
@@ -77,16 +94,16 @@ def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any
|
||||
|
||||
def _get_admin_router():
|
||||
"""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():
|
||||
"""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():
|
||||
@@ -241,6 +258,7 @@ billing_module = ModuleDefinition(
|
||||
icon="currency-euro",
|
||||
route="/store/{store_code}/invoices",
|
||||
order=30,
|
||||
requires_permission="billing.view_invoices",
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -256,6 +274,7 @@ billing_module = ModuleDefinition(
|
||||
icon="credit-card",
|
||||
route="/store/{store_code}/billing",
|
||||
order=30,
|
||||
requires_permission="billing.view_subscriptions",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -103,9 +103,12 @@ class RequireFeature:
|
||||
) -> None:
|
||||
"""Check if store's merchant has access to any of the required features."""
|
||||
store_id = current_user.token_store_id
|
||||
platform_id = current_user.token_platform_id
|
||||
|
||||
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
|
||||
|
||||
# None of the features are available
|
||||
@@ -136,7 +139,8 @@ class RequireWithinLimit:
|
||||
store_id = current_user.token_store_id
|
||||
|
||||
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:
|
||||
@@ -176,9 +180,12 @@ def require_feature(*feature_codes: str) -> Callable:
|
||||
)
|
||||
|
||||
store_id = current_user.token_store_id
|
||||
platform_id = current_user.token_platform_id
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
platform_id = current_user.token_platform_id
|
||||
|
||||
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)
|
||||
|
||||
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",
|
||||
"account_settings": "Kontoeinstellungen",
|
||||
"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",
|
||||
"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": {
|
||||
"subscription_updated": "Subscription updated successfully",
|
||||
"tier_created": "Tier created successfully",
|
||||
|
||||
@@ -134,5 +134,17 @@
|
||||
"invoices": "Factures",
|
||||
"account_settings": "Paramètres du compte",
|
||||
"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",
|
||||
"account_settings": "Kont-Astellungen",
|
||||
"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("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_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("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"),
|
||||
@@ -53,7 +53,7 @@ def upgrade() -> None:
|
||||
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_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 ---
|
||||
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)
|
||||
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)
|
||||
|
||||
# Pricing (in cents for precision)
|
||||
@@ -154,6 +160,16 @@ class SubscriptionTier(Base, TimestampMixin):
|
||||
"""Check if this tier includes a specific feature."""
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
from app.modules.billing.routes.api.admin import admin_router
|
||||
from app.modules.billing.routes.api.store import store_router
|
||||
from app.modules.billing.routes.api.admin import router as admin_router
|
||||
from app.modules.billing.routes.api.store import router as store_router
|
||||
|
||||
__all__ = ["admin_router", "store_router"]
|
||||
|
||||
@@ -35,12 +35,12 @@ from app.modules.billing.services import (
|
||||
subscription_service,
|
||||
)
|
||||
from app.modules.enums import FrontendType
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Admin router with module access control
|
||||
admin_router = APIRouter(
|
||||
router = APIRouter(
|
||||
prefix="/subscriptions",
|
||||
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(
|
||||
include_inactive: bool = Query(False, description="Include inactive tiers"),
|
||||
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(
|
||||
tier_code: str = Path(..., description="Tier code"),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
@@ -88,7 +88,7 @@ def get_subscription_tier(
|
||||
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(
|
||||
tier_data: SubscriptionTierCreate,
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
@@ -103,7 +103,7 @@ def create_subscription_tier(
|
||||
return resp
|
||||
|
||||
|
||||
@admin_router.patch("/tiers/{tier_code}", response_model=SubscriptionTierResponse)
|
||||
@router.patch("/tiers/{tier_code}", response_model=SubscriptionTierResponse)
|
||||
def update_subscription_tier(
|
||||
tier_data: SubscriptionTierUpdate,
|
||||
tier_code: str = Path(..., description="Tier code"),
|
||||
@@ -120,7 +120,7 @@ def update_subscription_tier(
|
||||
return resp
|
||||
|
||||
|
||||
@admin_router.delete("/tiers/{tier_code}", status_code=204)
|
||||
@router.delete("/tiers/{tier_code}", status_code=204)
|
||||
def delete_subscription_tier(
|
||||
tier_code: str = Path(..., description="Tier code"),
|
||||
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(
|
||||
page: int = Query(1, ge=1),
|
||||
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(
|
||||
merchant_id: int = Path(..., description="Merchant ID"),
|
||||
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(
|
||||
db, merchant_id
|
||||
)
|
||||
return {"subscriptions": results}
|
||||
return {"subscriptions": results} # noqa: API001
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
@router.post(
|
||||
"/merchants/{merchant_id}/platforms/{platform_id}",
|
||||
response_model=MerchantSubscriptionAdminResponse,
|
||||
status_code=201,
|
||||
@@ -226,7 +226,7 @@ def create_merchant_subscription(
|
||||
return MerchantSubscriptionAdminResponse.model_validate(sub)
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
@router.get(
|
||||
"/merchants/{merchant_id}/platforms/{platform_id}",
|
||||
response_model=MerchantSubscriptionAdminResponse,
|
||||
)
|
||||
@@ -243,7 +243,7 @@ def get_merchant_subscription(
|
||||
return MerchantSubscriptionAdminResponse.model_validate(sub)
|
||||
|
||||
|
||||
@admin_router.patch(
|
||||
@router.patch(
|
||||
"/merchants/{merchant_id}/platforms/{platform_id}",
|
||||
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(
|
||||
store_id: int = Path(..., description="Store ID"),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
@@ -284,7 +284,7 @@ def get_subscription_for_store(
|
||||
of subscription entries with feature usage metrics.
|
||||
"""
|
||||
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(
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
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(
|
||||
page: int = Query(1, ge=1),
|
||||
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
|
||||
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_service import feature_service
|
||||
from app.modules.enums import FrontendType
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
|
||||
admin_features_router = APIRouter(
|
||||
prefix="/features",
|
||||
@@ -86,11 +86,11 @@ def get_feature_catalog(
|
||||
|
||||
|
||||
@admin_features_router.get(
|
||||
"/tiers/{tier_code}/limits",
|
||||
"/tiers/{tier_id}/limits",
|
||||
response_model=list[TierFeatureLimitEntry],
|
||||
)
|
||||
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),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -100,7 +100,7 @@ def get_tier_feature_limits(
|
||||
Returns all TierFeatureLimit rows associated with the tier,
|
||||
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 [
|
||||
TierFeatureLimitEntry(
|
||||
@@ -113,12 +113,12 @@ def get_tier_feature_limits(
|
||||
|
||||
|
||||
@admin_features_router.put(
|
||||
"/tiers/{tier_code}/limits",
|
||||
"/tiers/{tier_id}/limits",
|
||||
response_model=list[TierFeatureLimitEntry],
|
||||
)
|
||||
def upsert_tier_feature_limits(
|
||||
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),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -136,15 +136,15 @@ def upsert_tier_feature_limits(
|
||||
raise InvalidFeatureCodesError(invalid_codes)
|
||||
|
||||
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()
|
||||
|
||||
logger.info(
|
||||
"Admin %s replaced tier '%s' feature limits (%d entries)",
|
||||
"Admin %s replaced tier %d feature limits (%d entries)",
|
||||
current_user.id,
|
||||
tier_code,
|
||||
tier_id,
|
||||
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.enums import FrontendType
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Store router with module access control
|
||||
store_router = APIRouter(
|
||||
router = APIRouter(
|
||||
prefix="/billing",
|
||||
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(
|
||||
current_user: UserContext = Depends(get_current_store_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get current subscription status."""
|
||||
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)
|
||||
|
||||
@@ -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(
|
||||
current_user: UserContext = Depends(get_current_store_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get available subscription tiers for upgrade/downgrade."""
|
||||
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)
|
||||
current_tier_id = subscription.tier_id
|
||||
@@ -96,7 +96,7 @@ def get_available_tiers(
|
||||
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(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
@@ -105,7 +105,7 @@ def get_invoices(
|
||||
):
|
||||
"""Get invoice history."""
|
||||
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)
|
||||
|
||||
@@ -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_usage import store_usage_router
|
||||
|
||||
store_router.include_router(store_features_router, tags=["store-features"])
|
||||
store_router.include_router(store_checkout_router, tags=["store-billing"])
|
||||
store_router.include_router(store_addons_router, tags=["store-billing-addons"])
|
||||
store_router.include_router(store_usage_router, tags=["store-usage"])
|
||||
router.include_router(store_features_router, tags=["store-features"])
|
||||
router.include_router(store_checkout_router, tags=["store-billing"])
|
||||
router.include_router(store_addons_router, tags=["store-billing-addons"])
|
||||
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.modules.billing.services import billing_service
|
||||
from app.modules.enums import FrontendType
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
|
||||
store_addons_router = APIRouter(
|
||||
prefix="/addons",
|
||||
@@ -144,7 +144,7 @@ def purchase_addon(
|
||||
store = billing_service.get_store(db, store_id)
|
||||
|
||||
# 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"
|
||||
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.subscription_service import subscription_service
|
||||
from app.modules.enums import FrontendType
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
|
||||
store_checkout_router = APIRouter(
|
||||
dependencies=[Depends(require_module_access("billing", FrontendType.STORE))],
|
||||
@@ -55,11 +55,11 @@ def create_checkout_session(
|
||||
):
|
||||
"""Create a Stripe checkout session for subscription."""
|
||||
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)
|
||||
|
||||
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"
|
||||
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."""
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -102,7 +102,7 @@ def cancel_subscription(
|
||||
):
|
||||
"""Cancel subscription."""
|
||||
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(
|
||||
db=db,
|
||||
@@ -126,7 +126,7 @@ def reactivate_subscription(
|
||||
):
|
||||
"""Reactivate a cancelled subscription."""
|
||||
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)
|
||||
db.commit()
|
||||
@@ -141,7 +141,7 @@ def get_upcoming_invoice(
|
||||
):
|
||||
"""Preview the upcoming invoice."""
|
||||
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)
|
||||
|
||||
@@ -161,7 +161,7 @@ def change_tier(
|
||||
):
|
||||
"""Change subscription tier (upgrade/downgrade)."""
|
||||
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(
|
||||
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.subscription_service import subscription_service
|
||||
from app.modules.enums import FrontendType
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
|
||||
store_features_router = APIRouter(
|
||||
prefix="/features",
|
||||
@@ -95,7 +95,7 @@ def get_available_features(
|
||||
List of feature codes the store has access to
|
||||
"""
|
||||
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
|
||||
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
|
||||
"""
|
||||
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
|
||||
all_declarations = feature_aggregator.get_all_declarations()
|
||||
@@ -197,7 +197,7 @@ def get_features_grouped(
|
||||
Useful for rendering feature comparison tables or settings pages.
|
||||
"""
|
||||
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
|
||||
by_category = feature_aggregator.get_declarations_by_category()
|
||||
@@ -246,7 +246,9 @@ def check_feature(
|
||||
has_feature and feature_code
|
||||
"""
|
||||
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)
|
||||
|
||||
@@ -270,7 +272,7 @@ def get_feature_detail(
|
||||
Feature details with upgrade info if locked
|
||||
"""
|
||||
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
|
||||
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.modules.billing.services.usage_service import usage_service
|
||||
from app.modules.enums import FrontendType
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
|
||||
store_usage_router = APIRouter(
|
||||
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.modules.core.utils.page_context import get_context_for_frontend
|
||||
from app.modules.enums import FrontendType
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
from app.templates_config import templates
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
ROUTE_CONFIG = {
|
||||
"prefix": "/billing",
|
||||
|
||||
@@ -8,7 +8,7 @@ Platform (unauthenticated) pages for pricing and signup:
|
||||
- Signup success
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
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.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()
|
||||
|
||||
|
||||
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."""
|
||||
from app.modules.billing.models import SubscriptionTier, TierCode
|
||||
|
||||
tiers_db = (
|
||||
db.query(SubscriptionTier)
|
||||
.filter(
|
||||
SubscriptionTier.is_active == True,
|
||||
SubscriptionTier.is_public == True,
|
||||
)
|
||||
.order_by(SubscriptionTier.display_order)
|
||||
.all()
|
||||
query = db.query(SubscriptionTier).filter(
|
||||
SubscriptionTier.is_active == True,
|
||||
SubscriptionTier.is_public == True,
|
||||
SubscriptionTier.platform_id == platform_id,
|
||||
)
|
||||
tiers_db = query.order_by(SubscriptionTier.display_order).all()
|
||||
|
||||
tiers = []
|
||||
for tier in tiers_db:
|
||||
@@ -63,9 +71,12 @@ async def pricing_page(
|
||||
):
|
||||
"""
|
||||
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["tiers"] = _get_tiers_data(db)
|
||||
context["tiers"] = _get_tiers_data(db, platform_id=platform.id)
|
||||
context["page_title"] = "Pricing"
|
||||
|
||||
return templates.TemplateResponse(
|
||||
@@ -89,15 +100,19 @@ async def signup_page(
|
||||
"""
|
||||
Multi-step signup wizard.
|
||||
|
||||
Routes to platform-specific signup templates. Each platform defines
|
||||
its own signup flow (different steps, different UI).
|
||||
|
||||
Query params:
|
||||
- tier: Pre-selected tier code
|
||||
- annual: Pre-select annual billing
|
||||
"""
|
||||
platform = _require_platform(request)
|
||||
context = get_platform_context(request, db)
|
||||
context["page_title"] = "Start Your Free Trial"
|
||||
context["selected_tier"] = tier
|
||||
context["is_annual"] = annual
|
||||
context["tiers"] = _get_tiers_data(db)
|
||||
context["tiers"] = _get_tiers_data(db, platform_id=platform.id)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"billing/platform/signup.html",
|
||||
|
||||
@@ -7,11 +7,15 @@ Store pages for billing management:
|
||||
- Invoices
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Request
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_store_from_cookie_or_header, get_db
|
||||
from app.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.tenancy.models import User
|
||||
from app.templates_config import templates
|
||||
@@ -25,11 +29,11 @@ router = APIRouter()
|
||||
|
||||
|
||||
@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(
|
||||
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),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -44,11 +48,11 @@ async def store_billing_page(
|
||||
|
||||
|
||||
@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(
|
||||
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),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
|
||||
@@ -217,5 +217,3 @@ class MerchantPortalInvoiceListResponse(BaseModel):
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ from app.modules.billing.services.platform_pricing_service import (
|
||||
PlatformPricingService,
|
||||
platform_pricing_service,
|
||||
)
|
||||
from app.modules.billing.services.signup_service import (
|
||||
SignupService,
|
||||
signup_service,
|
||||
)
|
||||
from app.modules.billing.services.store_platform_sync_service import (
|
||||
StorePlatformSync,
|
||||
store_platform_sync,
|
||||
@@ -65,4 +69,6 @@ __all__ = [
|
||||
"TierInfoData",
|
||||
"UpgradeTierData",
|
||||
"LimitCheckData",
|
||||
"SignupService",
|
||||
"signup_service",
|
||||
]
|
||||
|
||||
@@ -13,7 +13,7 @@ import logging
|
||||
from math import ceil
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.exceptions import (
|
||||
BusinessLogicException,
|
||||
@@ -27,7 +27,7 @@ from app.modules.billing.models import (
|
||||
SubscriptionStatus,
|
||||
SubscriptionTier,
|
||||
)
|
||||
from app.modules.tenancy.models import Merchant
|
||||
from app.modules.tenancy.exceptions import PlatformNotFoundException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -35,6 +35,141 @@ logger = logging.getLogger(__name__)
|
||||
class AdminSubscriptionService:
|
||||
"""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
|
||||
# =========================================================================
|
||||
@@ -85,6 +220,9 @@ class AdminSubscriptionService:
|
||||
|
||||
tier = SubscriptionTier(**tier_data)
|
||||
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}")
|
||||
return tier
|
||||
@@ -95,9 +233,21 @@ class AdminSubscriptionService:
|
||||
"""Update a subscription tier."""
|
||||
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():
|
||||
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}")
|
||||
return tier
|
||||
|
||||
@@ -143,8 +293,9 @@ class AdminSubscriptionService:
|
||||
) -> dict:
|
||||
"""List merchant subscriptions with filtering and pagination."""
|
||||
query = (
|
||||
db.query(MerchantSubscription, Merchant)
|
||||
.join(Merchant, MerchantSubscription.merchant_id == Merchant.id)
|
||||
db.query(MerchantSubscription)
|
||||
.join(MerchantSubscription.merchant)
|
||||
.options(joinedload(MerchantSubscription.merchant))
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
@@ -155,20 +306,35 @@ class AdminSubscriptionService:
|
||||
SubscriptionTier, MerchantSubscription.tier_id == SubscriptionTier.id
|
||||
).filter(SubscriptionTier.code == tier)
|
||||
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
|
||||
total = query.count()
|
||||
|
||||
# Paginate
|
||||
offset = (page - 1) * per_page
|
||||
results = (
|
||||
subs = (
|
||||
query.order_by(MerchantSubscription.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(per_page)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Return (sub, merchant) tuples for backward compatibility with callers
|
||||
results = [(sub, sub.merchant) for sub in subs]
|
||||
|
||||
return {
|
||||
"results": results,
|
||||
"total": total,
|
||||
@@ -181,9 +347,9 @@ class AdminSubscriptionService:
|
||||
self, db: Session, merchant_id: int, platform_id: int
|
||||
) -> tuple:
|
||||
"""Get subscription for a specific merchant on a platform."""
|
||||
result = (
|
||||
db.query(MerchantSubscription, Merchant)
|
||||
.join(Merchant, MerchantSubscription.merchant_id == Merchant.id)
|
||||
sub = (
|
||||
db.query(MerchantSubscription)
|
||||
.options(joinedload(MerchantSubscription.merchant))
|
||||
.filter(
|
||||
MerchantSubscription.merchant_id == merchant_id,
|
||||
MerchantSubscription.platform_id == platform_id,
|
||||
@@ -191,13 +357,13 @@ class AdminSubscriptionService:
|
||||
.first()
|
||||
)
|
||||
|
||||
if not result:
|
||||
if not sub:
|
||||
raise ResourceNotFoundException(
|
||||
"Subscription",
|
||||
f"merchant_id={merchant_id}, platform_id={platform_id}",
|
||||
)
|
||||
|
||||
return result
|
||||
return sub, sub.merchant
|
||||
|
||||
def update_subscription(
|
||||
self, db: Session, merchant_id: int, platform_id: int, update_data: dict
|
||||
@@ -242,10 +408,7 @@ class AdminSubscriptionService:
|
||||
status: str | None = None,
|
||||
) -> dict:
|
||||
"""List billing history across all merchants."""
|
||||
query = (
|
||||
db.query(BillingHistory, Merchant)
|
||||
.join(Merchant, BillingHistory.merchant_id == Merchant.id)
|
||||
)
|
||||
query = db.query(BillingHistory)
|
||||
|
||||
if merchant_id:
|
||||
query = query.filter(BillingHistory.merchant_id == merchant_id)
|
||||
@@ -255,13 +418,29 @@ class AdminSubscriptionService:
|
||||
total = query.count()
|
||||
|
||||
offset = (page - 1) * per_page
|
||||
results = (
|
||||
invoices = (
|
||||
query.order_by(BillingHistory.invoice_date.desc())
|
||||
.offset(offset)
|
||||
.limit(per_page)
|
||||
.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 {
|
||||
"results": results,
|
||||
"total": total,
|
||||
@@ -276,16 +455,20 @@ class AdminSubscriptionService:
|
||||
|
||||
def get_platform_names_map(self, db: Session) -> dict[int, str]:
|
||||
"""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:
|
||||
"""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()
|
||||
return p.name if p else None
|
||||
try:
|
||||
p = platform_service.get_platform_by_id(db, platform_id)
|
||||
return p.name
|
||||
except PlatformNotFoundException:
|
||||
return None
|
||||
|
||||
# =========================================================================
|
||||
# Merchant Subscriptions with Usage
|
||||
@@ -359,9 +542,9 @@ class AdminSubscriptionService:
|
||||
Convenience method for admin store detail page. Resolves
|
||||
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:
|
||||
raise ResourceNotFoundException("Store", str(store_id))
|
||||
|
||||
|
||||
@@ -155,8 +155,8 @@ class BillingService:
|
||||
trial_days = settings.stripe_trial_days
|
||||
|
||||
# Get merchant for Stripe customer creation
|
||||
from app.modules.tenancy.models import Merchant
|
||||
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
|
||||
from app.modules.tenancy.services.merchant_service import merchant_service
|
||||
merchant = merchant_service.get_merchant_by_id_optional(db, merchant_id)
|
||||
|
||||
session = stripe_service.create_checkout_session(
|
||||
db=db,
|
||||
@@ -494,8 +494,8 @@ class BillingService:
|
||||
if not addon.stripe_price_id:
|
||||
raise BillingException(f"Stripe price not configured for add-on '{addon_code}'")
|
||||
|
||||
from app.modules.tenancy.models import Store
|
||||
store = db.query(Store).filter(Store.id == store_id).first()
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
store = store_service.get_store_by_id_optional(db, store_id)
|
||||
|
||||
session = stripe_service.create_checkout_session(
|
||||
db=db,
|
||||
|
||||
@@ -108,28 +108,30 @@ class FeatureService:
|
||||
# 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).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
store_id: Store ID
|
||||
platform_id: Platform ID from JWT. When provided, skips DB lookup.
|
||||
|
||||
Returns:
|
||||
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:
|
||||
return None, None
|
||||
|
||||
merchant_id = store.merchant_id
|
||||
# Get primary platform_id from StorePlatform junction
|
||||
sp = (
|
||||
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
|
||||
if platform_id is None:
|
||||
platform_id = platform_service.get_first_active_platform_id_for_store(db, store_id)
|
||||
|
||||
return merchant_id, platform_id
|
||||
|
||||
@@ -142,19 +144,14 @@ class FeatureService:
|
||||
Returns all active platform IDs for the store's merchant,
|
||||
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:
|
||||
return None, []
|
||||
|
||||
platform_ids = [
|
||||
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()
|
||||
]
|
||||
platform_ids = platform_service.get_active_platform_ids_for_store(db, store_id)
|
||||
return store.merchant_id, platform_ids
|
||||
|
||||
def _get_subscription(
|
||||
@@ -215,28 +212,29 @@ class FeatureService:
|
||||
return subscription.tier.has_feature(feature_code)
|
||||
|
||||
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:
|
||||
"""
|
||||
Convenience method that resolves the store -> merchant -> platform
|
||||
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:
|
||||
db: Database session.
|
||||
store_id: The store ID to resolve.
|
||||
feature_code: The feature code to check.
|
||||
platform_id: Platform ID from JWT. When provided, skips DB lookup.
|
||||
|
||||
Returns:
|
||||
True if the resolved merchant has access to the feature,
|
||||
False if the store/merchant cannot be resolved or lacks access.
|
||||
"""
|
||||
merchant_id, platform_id = self._get_merchant_for_store(db, store_id)
|
||||
if merchant_id is None or platform_id is None:
|
||||
merchant_id, resolved_platform_id = self._get_merchant_for_store(
|
||||
db, store_id, platform_id=platform_id
|
||||
)
|
||||
if merchant_id is None or resolved_platform_id is None:
|
||||
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(
|
||||
self, db: Session, merchant_id: int, platform_id: int
|
||||
@@ -328,7 +326,7 @@ class FeatureService:
|
||||
feature_code: Feature code (e.g., "products_limit")
|
||||
store_id: Store ID (if checking per-store)
|
||||
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:
|
||||
(allowed, error_message) tuple
|
||||
@@ -337,7 +335,9 @@ class FeatureService:
|
||||
|
||||
# Resolve store -> merchant if needed
|
||||
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:
|
||||
return False, "No subscription found"
|
||||
@@ -450,30 +450,24 @@ class FeatureService:
|
||||
# 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."""
|
||||
from app.modules.billing.services import admin_subscription_service
|
||||
|
||||
tier = admin_subscription_service.get_tier_by_code(db, tier_code)
|
||||
return (
|
||||
db.query(TierFeatureLimit)
|
||||
.filter(TierFeatureLimit.tier_id == tier.id)
|
||||
.filter(TierFeatureLimit.tier_id == tier_id)
|
||||
.order_by(TierFeatureLimit.feature_code)
|
||||
.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."""
|
||||
from app.modules.billing.services import admin_subscription_service
|
||||
|
||||
tier = admin_subscription_service.get_tier_by_code(db, tier_code)
|
||||
db.query(TierFeatureLimit).filter(TierFeatureLimit.tier_id == tier.id).delete()
|
||||
db.query(TierFeatureLimit).filter(TierFeatureLimit.tier_id == tier_id).delete()
|
||||
new_rows = []
|
||||
for entry in entries:
|
||||
if not entry.get("enabled", True):
|
||||
continue
|
||||
row = TierFeatureLimit(
|
||||
tier_id=tier.id,
|
||||
tier_id=tier_id,
|
||||
feature_code=entry["feature_code"],
|
||||
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 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__)
|
||||
|
||||
@@ -31,59 +32,23 @@ class StorePlatformSync:
|
||||
Upsert StorePlatform for every store belonging to a merchant.
|
||||
|
||||
- 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
|
||||
"""
|
||||
stores = (
|
||||
db.query(Store)
|
||||
.filter(Store.merchant_id == merchant_id)
|
||||
.all()
|
||||
)
|
||||
stores = store_service.get_stores_by_merchant_id(db, merchant_id)
|
||||
|
||||
if not stores:
|
||||
return
|
||||
|
||||
for store in stores:
|
||||
existing = (
|
||||
db.query(StorePlatform)
|
||||
.filter(
|
||||
StorePlatform.store_id == store.id,
|
||||
StorePlatform.platform_id == platform_id,
|
||||
)
|
||||
.first()
|
||||
result = platform_service.ensure_store_platform(
|
||||
db, store.id, platform_id, is_active, tier_id
|
||||
)
|
||||
|
||||
if existing:
|
||||
existing.is_active = is_active
|
||||
if tier_id is not None:
|
||||
existing.tier_id = tier_id
|
||||
if result:
|
||||
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}"
|
||||
)
|
||||
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()
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@ Provides:
|
||||
- Webhook event construction
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import stripe
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -23,7 +26,9 @@ from app.modules.billing.exceptions import (
|
||||
from app.modules.billing.models import (
|
||||
MerchantSubscription,
|
||||
)
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,7 +46,7 @@ class StripeService:
|
||||
stripe.api_key = settings.stripe_secret_key
|
||||
self._configured = True
|
||||
else:
|
||||
logger.warning("Stripe API key not configured")
|
||||
logger.debug("Stripe API key not configured")
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
@@ -88,6 +93,38 @@ class StripeService:
|
||||
)
|
||||
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:
|
||||
"""Get a Stripe customer by ID."""
|
||||
self._check_configured()
|
||||
@@ -274,6 +311,7 @@ class StripeService:
|
||||
trial_days: int | None = None,
|
||||
quantity: int = 1,
|
||||
metadata: dict | None = None,
|
||||
platform_id: int | None = None,
|
||||
) -> stripe.checkout.Session:
|
||||
"""
|
||||
Create a Stripe Checkout session for subscription signup.
|
||||
@@ -287,6 +325,7 @@ class StripeService:
|
||||
trial_days: Optional trial period
|
||||
quantity: Number of items (default 1)
|
||||
metadata: Additional metadata to store
|
||||
platform_id: Platform ID (from JWT or caller). Falls back to DB lookup.
|
||||
|
||||
Returns:
|
||||
Stripe Checkout Session object
|
||||
@@ -294,10 +333,11 @@ class StripeService:
|
||||
self._check_configured()
|
||||
|
||||
# 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()
|
||||
platform_id = sp[0] if sp else None
|
||||
if platform_id is None:
|
||||
platform_id = platform_service.get_first_active_platform_id_for_store(db, store.id)
|
||||
subscription = None
|
||||
if store.merchant_id and platform_id:
|
||||
subscription = (
|
||||
@@ -313,16 +353,7 @@ class StripeService:
|
||||
customer_id = subscription.stripe_customer_id
|
||||
else:
|
||||
# Get store owner email
|
||||
from app.modules.tenancy.models import StoreUser
|
||||
|
||||
owner = (
|
||||
db.query(StoreUser)
|
||||
.filter(
|
||||
StoreUser.store_id == store.id,
|
||||
StoreUser.is_owner == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
owner = team_service.get_store_owner(db, store.id)
|
||||
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")
|
||||
|
||||
@@ -47,23 +47,30 @@ class SubscriptionService:
|
||||
# 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).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
store_id: Store ID
|
||||
platform_id: Platform ID from JWT token. When provided, skips DB lookup.
|
||||
|
||||
Raises:
|
||||
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:
|
||||
raise ResourceNotFoundException("Store", str(store_id))
|
||||
sp = db.query(StorePlatform.platform_id).filter(
|
||||
StorePlatform.store_id == store_id
|
||||
).first()
|
||||
if not sp:
|
||||
if platform_id is None:
|
||||
platform_id = platform_service.get_first_active_platform_id_for_store(db, store_id)
|
||||
if not platform_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:
|
||||
"""Get the store_code for a given store_id.
|
||||
@@ -71,9 +78,9 @@ class SubscriptionService:
|
||||
Raises:
|
||||
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:
|
||||
raise ResourceNotFoundException("Store", str(store_id))
|
||||
return store.store_code
|
||||
@@ -175,9 +182,10 @@ class SubscriptionService:
|
||||
The merchant subscription, or None if the store, merchant,
|
||||
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:
|
||||
return None
|
||||
|
||||
@@ -185,17 +193,7 @@ class SubscriptionService:
|
||||
if merchant_id is None:
|
||||
return None
|
||||
|
||||
# Get platform_id from store
|
||||
platform_id = getattr(store, "platform_id", None)
|
||||
if platform_id is None:
|
||||
from app.modules.tenancy.models import StorePlatform
|
||||
sp = (
|
||||
db.query(StorePlatform.platform_id)
|
||||
.filter(StorePlatform.store_id == store_id)
|
||||
.first()
|
||||
)
|
||||
platform_id = sp[0] if sp else None
|
||||
|
||||
platform_id = platform_service.get_first_active_platform_id_for_store(db, store_id)
|
||||
if platform_id is None:
|
||||
return None
|
||||
|
||||
@@ -394,5 +392,60 @@ class SubscriptionService:
|
||||
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
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
@@ -14,12 +14,10 @@ and feature_service for limit resolution.
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.billing.models import MerchantSubscription, SubscriptionTier
|
||||
from app.modules.billing.services.feature_aggregator import feature_aggregator
|
||||
from app.modules.tenancy.models import StoreUser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -222,12 +220,9 @@ class UsageService:
|
||||
|
||||
def _get_team_member_count(self, db: Session, store_id: int) -> int:
|
||||
"""Get active team member count for store."""
|
||||
return (
|
||||
db.query(func.count(StoreUser.id))
|
||||
.filter(StoreUser.store_id == store_id, StoreUser.is_active == True) # noqa: E712
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
from app.modules.tenancy.services.team_service import team_service
|
||||
|
||||
return team_service.get_active_team_member_count(db, store_id)
|
||||
|
||||
def _calculate_usage_metrics(
|
||||
self, db: Session, store_id: int, subscription: MerchantSubscription | None
|
||||
|
||||
@@ -273,7 +273,7 @@ function adminSubscriptionTiers() {
|
||||
|
||||
try {
|
||||
// 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}]
|
||||
this.selectedFeatures = [];
|
||||
for (const entry of (data || [])) {
|
||||
@@ -327,7 +327,7 @@ function adminSubscriptionTiers() {
|
||||
}));
|
||||
|
||||
await apiClient.put(
|
||||
`/admin/subscriptions/features/tiers/${this.selectedTierForFeatures.code}/limits`,
|
||||
`/admin/subscriptions/features/tiers/${this.selectedTierForFeatures.id}/limits`,
|
||||
entries
|
||||
);
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
this.error = null;
|
||||
|
||||
// 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.tierCode = response.tier_code;
|
||||
@@ -116,7 +116,7 @@
|
||||
if (!storeCode) return;
|
||||
|
||||
try {
|
||||
const response = await apiClient.get('/store/features');
|
||||
const response = await apiClient.get('/store/billing/features');
|
||||
|
||||
// Build map for quick lookup
|
||||
this.featuresMap = {};
|
||||
@@ -178,15 +178,22 @@
|
||||
},
|
||||
|
||||
/**
|
||||
* Get store code from URL
|
||||
* Get store code from server-rendered value or URL fallback
|
||||
* @returns {string|null}
|
||||
*/
|
||||
getStoreCode() {
|
||||
if (window.STORE_CODE) return window.STORE_CODE;
|
||||
const path = window.location.pathname;
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
// Direct: /store/{code}/...
|
||||
if (segments[0] === 'store' && 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;
|
||||
},
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
const response = await apiClient.get('/store/usage');
|
||||
const response = await apiClient.get('/store/billing/usage');
|
||||
this.usage = response;
|
||||
this.loaded = true;
|
||||
|
||||
@@ -139,9 +139,10 @@
|
||||
},
|
||||
|
||||
/**
|
||||
* Get store code from URL
|
||||
* Get store code from server-rendered value or URL fallback
|
||||
*/
|
||||
getStoreCode() {
|
||||
if (window.STORE_CODE) return window.STORE_CODE;
|
||||
const path = window.location.pathname;
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
if (segments[0] === 'store' && segments[1]) {
|
||||
|
||||
@@ -66,32 +66,23 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# Features list (dynamic from module providers) #}
|
||||
{% if tier.features %}
|
||||
<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">
|
||||
<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.orders_per_month %}{{ _("cms.platform.pricing.orders_per_month", count=tier.orders_per_month) }}{% else %}{{ _("cms.platform.pricing.unlimited_orders") }}{% 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.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") }}
|
||||
{% if feat.is_quantitative and feat.limit %}
|
||||
{{ feat.limit }} {{ _(feat.name_key) }}
|
||||
{% else %}
|
||||
{{ _(feat.name_key) }}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% 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">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{# app/templates/platform/signup.html #}
|
||||
{# Multi-step Signup Wizard #}
|
||||
{# 3-Step Signup Wizard: Plan → Account → Payment #}
|
||||
{% 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 %}
|
||||
{# Stripe.js for payment #}
|
||||
@@ -16,8 +16,8 @@
|
||||
{# Progress Steps #}
|
||||
<div class="mb-12">
|
||||
<div class="flex items-center justify-between">
|
||||
<template x-for="(stepName, index) in ['Select Plan', 'Claim Shop', 'Account', 'Payment']" :key="index">
|
||||
<div class="flex items-center" :class="index < 3 ? 'flex-1' : ''">
|
||||
<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 < 2 ? 'flex-1' : ''">
|
||||
<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'">
|
||||
<template x-if="currentStep > index + 1">
|
||||
@@ -32,7 +32,7 @@
|
||||
<span class="ml-2 text-sm font-medium hidden sm:inline"
|
||||
:class="currentStep >= index + 1 ? 'text-gray-900 dark:text-white' : 'text-gray-500'"
|
||||
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="h-full bg-indigo-600 rounded transition-all"
|
||||
:style="'width: ' + (currentStep > index + 1 ? '100%' : '0%')"></div>
|
||||
@@ -50,11 +50,11 @@
|
||||
STEP 1: SELECT PLAN
|
||||
=============================================================== #}
|
||||
<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 #}
|
||||
<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"
|
||||
class="relative w-12 h-6 rounded-full transition-colors"
|
||||
:class="isAnnual ? 'bg-indigo-600' : 'bg-gray-300'">
|
||||
@@ -62,7 +62,7 @@
|
||||
:class="isAnnual ? 'translate-x-6' : ''"></span>
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -80,17 +80,17 @@
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">{{ tier.name }}</h3>
|
||||
<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>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,82 +103,39 @@
|
||||
{# Free Trial Note #}
|
||||
<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">
|
||||
<strong>{{ trial_days }}-day free trial.</strong>
|
||||
We'll collect your payment info, but you won't be charged until the trial ends.
|
||||
<strong>{{ trial_days }}-{{ _("cms.platform.signup.trial_info_days") }}</strong>
|
||||
{{ _("cms.platform.signup.trial_info") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button @click="startSignup()"
|
||||
: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">
|
||||
Continue
|
||||
{{ _("cms.platform.signup.continue") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# ===============================================================
|
||||
STEP 2: CLAIM LETZSHOP SHOP (Optional)
|
||||
STEP 2: CREATE ACCOUNT
|
||||
=============================================================== #}
|
||||
<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>
|
||||
<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>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">{{ _("cms.platform.signup.create_account") }}</h2>
|
||||
<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>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<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>
|
||||
<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"/>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<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"/>
|
||||
@@ -187,7 +144,7 @@
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<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"/>
|
||||
@@ -195,7 +152,7 @@
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<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"/>
|
||||
@@ -203,11 +160,11 @@
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<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"/>
|
||||
<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>
|
||||
|
||||
<template x-if="accountError">
|
||||
@@ -218,42 +175,42 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
Back
|
||||
{{ _("cms.platform.signup.back") }}
|
||||
</button>
|
||||
<button @click="createAccount()"
|
||||
:disabled="loading || !isAccountValid()"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ===============================================================
|
||||
STEP 4: PAYMENT
|
||||
STEP 3: PAYMENT
|
||||
=============================================================== #}
|
||||
<div x-show="currentStep === 4" class="p-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Add Payment Method</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>
|
||||
<div x-show="currentStep === 3" class="p-8">
|
||||
<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">{{ _("cms.platform.signup.no_charge_note", trial_days=trial_days) }}</p>
|
||||
|
||||
{# 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-errors" class="text-red-600 text-sm mt-2"></div>
|
||||
|
||||
<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">
|
||||
Back
|
||||
{{ _("cms.platform.signup.back") }}
|
||||
</button>
|
||||
<button @click="submitPayment()"
|
||||
:disabled="loading || paymentProcessing"
|
||||
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">
|
||||
<span>Processing...</span>
|
||||
<span>{{ _("cms.platform.signup.processing") }}</span>
|
||||
</template>
|
||||
<template x-if="!paymentProcessing">
|
||||
<span>Start Free Trial</span>
|
||||
<span>{{ _("cms.platform.signup.start_trial") }}</span>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
@@ -267,6 +224,13 @@
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
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 {
|
||||
currentStep: 1,
|
||||
loading: false,
|
||||
@@ -276,12 +240,7 @@ function signupWizard() {
|
||||
selectedTier: '{{ selected_tier or "professional" }}',
|
||||
isAnnual: {{ 'true' if is_annual else 'false' }},
|
||||
|
||||
// Step 2: Letzshop
|
||||
letzshopUrl: '',
|
||||
letzshopStore: null,
|
||||
letzshopError: null,
|
||||
|
||||
// Step 3: Account
|
||||
// Step 2: Account
|
||||
account: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
@@ -291,7 +250,7 @@ function signupWizard() {
|
||||
},
|
||||
accountError: null,
|
||||
|
||||
// Step 4: Payment
|
||||
// Step 3: Payment
|
||||
stripe: null,
|
||||
cardElement: null,
|
||||
paymentProcessing: false,
|
||||
@@ -306,13 +265,10 @@ function signupWizard() {
|
||||
if (params.get('annual') === '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) => {
|
||||
if (step === 4) {
|
||||
if (step === 3) {
|
||||
this.initStripe();
|
||||
}
|
||||
});
|
||||
@@ -326,7 +282,9 @@ function signupWizard() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
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.currentStep = 2;
|
||||
} else {
|
||||
alert(data.detail || 'Failed to start signup');
|
||||
alert(data.detail || MSGS.failedStart);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Failed to start signup. Please try again.');
|
||||
alert(MSGS.failedStart);
|
||||
} finally {
|
||||
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() {
|
||||
return this.account.firstName.trim() &&
|
||||
this.account.lastName.trim() &&
|
||||
@@ -426,13 +331,13 @@ function signupWizard() {
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
this.currentStep = 4;
|
||||
this.currentStep = 3;
|
||||
} else {
|
||||
this.accountError = data.detail || 'Failed to create account';
|
||||
this.accountError = data.detail || MSGS.failedAccount;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
this.accountError = 'Failed to create account. Please try again.';
|
||||
this.accountError = MSGS.failedAccount;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -481,7 +386,7 @@ function signupWizard() {
|
||||
|
||||
async submitPayment() {
|
||||
if (!this.stripe || !this.clientSecret) {
|
||||
alert('Payment not configured. Please contact support.');
|
||||
alert(MSGS.paymentNotConfigured);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -515,15 +420,14 @@ function signupWizard() {
|
||||
if (data.access_token) {
|
||||
localStorage.setItem('store_token', data.access_token);
|
||||
localStorage.setItem('storeCode', data.store_code);
|
||||
console.log('Store token stored for automatic login');
|
||||
}
|
||||
window.location.href = '/signup/success?store_code=' + data.store_code;
|
||||
} else {
|
||||
alert(data.detail || 'Failed to complete signup');
|
||||
alert(data.detail || MSGS.paymentFailed);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Payment error:', error);
|
||||
alert('Payment failed. Please try again.');
|
||||
alert(MSGS.paymentFailed);
|
||||
} finally {
|
||||
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,
|
||||
)
|
||||
from app.modules.tenancy.models import Merchant, Platform, User
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
from main import app
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
|
||||
@@ -83,13 +83,12 @@ def billing_extra_platforms(db):
|
||||
"""Create additional platforms for multiple subscriptions (unique constraint: merchant+platform)."""
|
||||
platforms = []
|
||||
for i in range(2):
|
||||
p = Platform(
|
||||
platforms.append(Platform(
|
||||
code=f"bm_extra_{uuid.uuid4().hex[:8]}",
|
||||
name=f"Extra Platform {i}",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(p)
|
||||
platforms.append(p)
|
||||
))
|
||||
db.add_all(platforms)
|
||||
db.commit()
|
||||
for p in platforms:
|
||||
db.refresh(p)
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
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.tenancy.models import Platform
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -16,3 +19,222 @@ class TestFeatureService:
|
||||
def test_service_instantiation(self):
|
||||
"""Service can be instantiated."""
|
||||
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_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):
|
||||
"""Sync passes tier_id to newly created StorePlatform."""
|
||||
self.service.sync_store_platforms_for_merchant(
|
||||
@@ -118,7 +72,6 @@ class TestStorePlatformSyncUpdate:
|
||||
store_id=test_store.id,
|
||||
platform_id=test_platform.id,
|
||||
is_active=True,
|
||||
is_primary=True,
|
||||
)
|
||||
db.add(sp)
|
||||
db.flush()
|
||||
@@ -137,7 +90,6 @@ class TestStorePlatformSyncUpdate:
|
||||
store_id=test_store.id,
|
||||
platform_id=test_platform.id,
|
||||
is_active=True,
|
||||
is_primary=True,
|
||||
)
|
||||
db.add(sp)
|
||||
db.flush()
|
||||
|
||||
@@ -68,10 +68,11 @@ cart_module = ModuleDefinition(
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="cart",
|
||||
label_key="storefront.actions.cart",
|
||||
label_key="cart.storefront.actions.cart",
|
||||
icon="shopping-cart",
|
||||
route="cart",
|
||||
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",
|
||||
"description": "Warenkorbverwaltung für Kunden",
|
||||
"cart": {
|
||||
"title": "Ihr Warenkorb",
|
||||
"empty": "Ihr Warenkorb ist leer",
|
||||
"empty_subtitle": "Fügen Sie Artikel hinzu, um einzukaufen",
|
||||
"continue_shopping": "Weiter einkaufen",
|
||||
"proceed_to_checkout": "Zur Kasse"
|
||||
},
|
||||
"item": {
|
||||
"product": "Produkt",
|
||||
"quantity": "Menge",
|
||||
"price": "Preis",
|
||||
"total": "Gesamt",
|
||||
"remove": "Entfernen",
|
||||
"update": "Aktualisieren"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Bestellübersicht",
|
||||
"subtotal": "Zwischensumme",
|
||||
"shipping": "Versand",
|
||||
"estimated_shipping": "Wird an der Kasse berechnet",
|
||||
"tax": "MwSt.",
|
||||
"total": "Gesamtsumme"
|
||||
},
|
||||
"validation": {
|
||||
"invalid_quantity": "Ungültige Menge",
|
||||
"min_quantity": "Mindestmenge ist {min}",
|
||||
"max_quantity": "Höchstmenge ist {max}",
|
||||
"insufficient_inventory": "Nur {available} verfügbar"
|
||||
},
|
||||
"messages": {
|
||||
"item_added": "Artikel zum Warenkorb hinzugefügt",
|
||||
"item_updated": "Warenkorb aktualisiert",
|
||||
"item_removed": "Artikel aus dem Warenkorb entfernt",
|
||||
"cart_cleared": "Warenkorb geleert",
|
||||
"product_not_available": "Produkt nicht verfügbar",
|
||||
"error_adding": "Fehler beim Hinzufügen zum Warenkorb",
|
||||
"error_updating": "Fehler beim Aktualisieren des Warenkorbs"
|
||||
}
|
||||
"title": "Warenkorb",
|
||||
"description": "Warenkorbverwaltung für Kunden",
|
||||
"cart": {
|
||||
"title": "Ihr Warenkorb",
|
||||
"empty": "Ihr Warenkorb ist leer",
|
||||
"empty_subtitle": "Fügen Sie Artikel hinzu, um einzukaufen",
|
||||
"continue_shopping": "Weiter einkaufen",
|
||||
"proceed_to_checkout": "Zur Kasse"
|
||||
},
|
||||
"item": {
|
||||
"product": "Produkt",
|
||||
"quantity": "Menge",
|
||||
"price": "Preis",
|
||||
"total": "Gesamt",
|
||||
"remove": "Entfernen",
|
||||
"update": "Aktualisieren"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Bestellübersicht",
|
||||
"subtotal": "Zwischensumme",
|
||||
"shipping": "Versand",
|
||||
"estimated_shipping": "Wird an der Kasse berechnet",
|
||||
"tax": "MwSt.",
|
||||
"total": "Gesamtsumme"
|
||||
},
|
||||
"validation": {
|
||||
"invalid_quantity": "Ungültige Menge",
|
||||
"min_quantity": "Mindestmenge ist {min}",
|
||||
"max_quantity": "Höchstmenge ist {max}",
|
||||
"insufficient_inventory": "Nur {available} verfügbar"
|
||||
},
|
||||
"messages": {
|
||||
"item_added": "Artikel zum Warenkorb hinzugefügt",
|
||||
"item_updated": "Warenkorb aktualisiert",
|
||||
"item_removed": "Artikel aus dem Warenkorb entfernt",
|
||||
"cart_cleared": "Warenkorb geleert",
|
||||
"product_not_available": "Produkt nicht verfügbar",
|
||||
"error_adding": "Fehler beim Hinzufügen zum Warenkorb",
|
||||
"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",
|
||||
"description": "Shopping cart management for customers",
|
||||
"cart": {
|
||||
"title": "Your Cart",
|
||||
"empty": "Your cart is empty",
|
||||
"empty_subtitle": "Add items to start shopping",
|
||||
"continue_shopping": "Continue Shopping",
|
||||
"proceed_to_checkout": "Proceed to Checkout"
|
||||
},
|
||||
"item": {
|
||||
"product": "Product",
|
||||
"quantity": "Quantity",
|
||||
"price": "Price",
|
||||
"total": "Total",
|
||||
"remove": "Remove",
|
||||
"update": "Update"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Order Summary",
|
||||
"subtotal": "Subtotal",
|
||||
"shipping": "Shipping",
|
||||
"estimated_shipping": "Calculated at checkout",
|
||||
"tax": "Tax",
|
||||
"total": "Total"
|
||||
},
|
||||
"validation": {
|
||||
"invalid_quantity": "Invalid quantity",
|
||||
"min_quantity": "Minimum quantity is {min}",
|
||||
"max_quantity": "Maximum quantity is {max}",
|
||||
"insufficient_inventory": "Only {available} available"
|
||||
},
|
||||
"messages": {
|
||||
"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"
|
||||
}
|
||||
"title": "Shopping Cart",
|
||||
"description": "Shopping cart management for customers",
|
||||
"cart": {
|
||||
"title": "Your Cart",
|
||||
"empty": "Your cart is empty",
|
||||
"empty_subtitle": "Add items to start shopping",
|
||||
"continue_shopping": "Continue Shopping",
|
||||
"proceed_to_checkout": "Proceed to Checkout"
|
||||
},
|
||||
"item": {
|
||||
"product": "Product",
|
||||
"quantity": "Quantity",
|
||||
"price": "Price",
|
||||
"total": "Total",
|
||||
"remove": "Remove",
|
||||
"update": "Update"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Order Summary",
|
||||
"subtotal": "Subtotal",
|
||||
"shipping": "Shipping",
|
||||
"estimated_shipping": "Calculated at checkout",
|
||||
"tax": "Tax",
|
||||
"total": "Total"
|
||||
},
|
||||
"validation": {
|
||||
"invalid_quantity": "Invalid quantity",
|
||||
"min_quantity": "Minimum quantity is {min}",
|
||||
"max_quantity": "Maximum quantity is {max}",
|
||||
"insufficient_inventory": "Only {available} available"
|
||||
},
|
||||
"permissions": {
|
||||
"view": "View Carts",
|
||||
"view_desc": "View customer shopping carts",
|
||||
"manage": "Manage Carts",
|
||||
"manage_desc": "Modify and manage customer carts"
|
||||
},
|
||||
"messages": {
|
||||
"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",
|
||||
"description": "Gestion du panier pour les clients",
|
||||
"cart": {
|
||||
"title": "Votre panier",
|
||||
"empty": "Votre panier est vide",
|
||||
"empty_subtitle": "Ajoutez des articles pour commencer vos achats",
|
||||
"continue_shopping": "Continuer mes achats",
|
||||
"proceed_to_checkout": "Passer à la caisse"
|
||||
},
|
||||
"item": {
|
||||
"product": "Produit",
|
||||
"quantity": "Quantité",
|
||||
"price": "Prix",
|
||||
"total": "Total",
|
||||
"remove": "Supprimer",
|
||||
"update": "Mettre à jour"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Récapitulatif de commande",
|
||||
"subtotal": "Sous-total",
|
||||
"shipping": "Livraison",
|
||||
"estimated_shipping": "Calculé à la caisse",
|
||||
"tax": "TVA",
|
||||
"total": "Total"
|
||||
},
|
||||
"validation": {
|
||||
"invalid_quantity": "Quantité invalide",
|
||||
"min_quantity": "Quantité minimum: {min}",
|
||||
"max_quantity": "Quantité maximum: {max}",
|
||||
"insufficient_inventory": "Seulement {available} disponible(s)"
|
||||
},
|
||||
"messages": {
|
||||
"item_added": "Article ajouté au panier",
|
||||
"item_updated": "Panier mis à jour",
|
||||
"item_removed": "Article supprimé du panier",
|
||||
"cart_cleared": "Panier vidé",
|
||||
"product_not_available": "Produit non disponible",
|
||||
"error_adding": "Erreur lors de l'ajout au panier",
|
||||
"error_updating": "Erreur lors de la mise à jour du panier"
|
||||
}
|
||||
"title": "Panier",
|
||||
"description": "Gestion du panier pour les clients",
|
||||
"cart": {
|
||||
"title": "Votre panier",
|
||||
"empty": "Votre panier est vide",
|
||||
"empty_subtitle": "Ajoutez des articles pour commencer vos achats",
|
||||
"continue_shopping": "Continuer mes achats",
|
||||
"proceed_to_checkout": "Passer à la caisse"
|
||||
},
|
||||
"item": {
|
||||
"product": "Produit",
|
||||
"quantity": "Quantité",
|
||||
"price": "Prix",
|
||||
"total": "Total",
|
||||
"remove": "Supprimer",
|
||||
"update": "Mettre à jour"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Récapitulatif de commande",
|
||||
"subtotal": "Sous-total",
|
||||
"shipping": "Livraison",
|
||||
"estimated_shipping": "Calculé à la caisse",
|
||||
"tax": "TVA",
|
||||
"total": "Total"
|
||||
},
|
||||
"validation": {
|
||||
"invalid_quantity": "Quantité invalide",
|
||||
"min_quantity": "Quantité minimum: {min}",
|
||||
"max_quantity": "Quantité maximum: {max}",
|
||||
"insufficient_inventory": "Seulement {available} disponible(s)"
|
||||
},
|
||||
"messages": {
|
||||
"item_added": "Article ajouté au panier",
|
||||
"item_updated": "Panier mis à jour",
|
||||
"item_removed": "Article supprimé du panier",
|
||||
"cart_cleared": "Panier vidé",
|
||||
"product_not_available": "Produit non disponible",
|
||||
"error_adding": "Erreur lors de l'ajout au 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",
|
||||
"description": "Kuerfverwaltung fir Clienten",
|
||||
"cart": {
|
||||
"title": "Äre Kuerf",
|
||||
"empty": "Äre Kuerf ass eidel",
|
||||
"empty_subtitle": "Setzt Artikelen derbäi fir anzekafen",
|
||||
"continue_shopping": "Weider akafen",
|
||||
"proceed_to_checkout": "Zur Keess"
|
||||
},
|
||||
"item": {
|
||||
"product": "Produkt",
|
||||
"quantity": "Unzuel",
|
||||
"price": "Präis",
|
||||
"total": "Gesamt",
|
||||
"remove": "Ewechhuelen",
|
||||
"update": "Aktualiséieren"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Bestelliwwersiicht",
|
||||
"subtotal": "Zwëschesumm",
|
||||
"shipping": "Liwwerung",
|
||||
"estimated_shipping": "Gëtt bei der Keess berechent",
|
||||
"tax": "MwSt.",
|
||||
"total": "Gesamtsumm"
|
||||
},
|
||||
"validation": {
|
||||
"invalid_quantity": "Ongëlteg Unzuel",
|
||||
"min_quantity": "Mindestunzuel ass {min}",
|
||||
"max_quantity": "Héichstunzuel ass {max}",
|
||||
"insufficient_inventory": "Nëmmen {available} verfügbar"
|
||||
},
|
||||
"messages": {
|
||||
"item_added": "Artikel an de Kuerf gesat",
|
||||
"item_updated": "Kuerf aktualiséiert",
|
||||
"item_removed": "Artikel aus dem Kuerf ewechgeholl",
|
||||
"cart_cleared": "Kuerf eidel gemaach",
|
||||
"product_not_available": "Produkt net verfügbar",
|
||||
"error_adding": "Feeler beim Derbäisetzen an de Kuerf",
|
||||
"error_updating": "Feeler beim Aktualiséiere vum Kuerf"
|
||||
}
|
||||
"title": "Akafskuerf",
|
||||
"description": "Kuerfverwaltung fir Clienten",
|
||||
"cart": {
|
||||
"title": "Äre Kuerf",
|
||||
"empty": "Äre Kuerf ass eidel",
|
||||
"empty_subtitle": "Setzt Artikelen derbäi fir anzekafen",
|
||||
"continue_shopping": "Weider akafen",
|
||||
"proceed_to_checkout": "Zur Keess"
|
||||
},
|
||||
"item": {
|
||||
"product": "Produkt",
|
||||
"quantity": "Unzuel",
|
||||
"price": "Präis",
|
||||
"total": "Gesamt",
|
||||
"remove": "Ewechhuelen",
|
||||
"update": "Aktualiséieren"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Bestelliwwersiicht",
|
||||
"subtotal": "Zwëschesumm",
|
||||
"shipping": "Liwwerung",
|
||||
"estimated_shipping": "Gëtt bei der Keess berechent",
|
||||
"tax": "MwSt.",
|
||||
"total": "Gesamtsumm"
|
||||
},
|
||||
"validation": {
|
||||
"invalid_quantity": "Ongëlteg Unzuel",
|
||||
"min_quantity": "Mindestunzuel ass {min}",
|
||||
"max_quantity": "Héichstunzuel ass {max}",
|
||||
"insufficient_inventory": "Nëmmen {available} verfügbar"
|
||||
},
|
||||
"messages": {
|
||||
"item_added": "Artikel an de Kuerf gesat",
|
||||
"item_updated": "Kuerf aktualiséiert",
|
||||
"item_removed": "Artikel aus dem Kuerf ewechgeholl",
|
||||
"cart_cleared": "Kuerf eidel gemaach",
|
||||
"product_not_available": "Produkt net verfügbar",
|
||||
"error_adding": "Feeler beim Derbäisetzen an de 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.catalog.exceptions import ProductNotFoundException
|
||||
from app.modules.catalog.models import Product
|
||||
from app.utils.money import cents_to_euros
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -146,19 +145,18 @@ class CartService:
|
||||
)
|
||||
|
||||
# Verify product exists and belongs to store
|
||||
product = (
|
||||
db.query(Product)
|
||||
.filter(
|
||||
and_(
|
||||
Product.id == product_id,
|
||||
Product.store_id == store_id,
|
||||
Product.is_active == True,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
from app.modules.catalog.services.product_service import product_service
|
||||
|
||||
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(
|
||||
"[CART_SERVICE] Product not found",
|
||||
extra={"product_id": product_id, "store_id": store_id},
|
||||
@@ -323,19 +321,14 @@ class CartService:
|
||||
)
|
||||
|
||||
# Verify product still exists and is active
|
||||
product = (
|
||||
db.query(Product)
|
||||
.filter(
|
||||
and_(
|
||||
Product.id == product_id,
|
||||
Product.store_id == store_id,
|
||||
Product.is_active == True,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
from app.modules.catalog.services.product_service import product_service
|
||||
|
||||
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))
|
||||
|
||||
# Check inventory
|
||||
|
||||
@@ -175,7 +175,7 @@
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('shoppingCart', () => {
|
||||
const baseData = shopLayoutData();
|
||||
const baseData = storefrontLayoutData();
|
||||
|
||||
return {
|
||||
...baseData,
|
||||
@@ -208,7 +208,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
// Initialize
|
||||
async init() {
|
||||
console.log('[SHOP] Cart page initializing...');
|
||||
console.log('[STOREFRONT] Cart page initializing...');
|
||||
|
||||
// Call parent init to set up sessionId
|
||||
if (baseData.init) {
|
||||
@@ -223,17 +223,17 @@ document.addEventListener('alpine:init', () => {
|
||||
this.loading = true;
|
||||
|
||||
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}`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.items = data.items || [];
|
||||
this.cartCount = this.totalItems;
|
||||
console.log('[SHOP] Cart loaded:', this.items.length, 'items');
|
||||
console.log('[STOREFRONT] Cart loaded:', this.items.length, 'items');
|
||||
}
|
||||
} 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');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
@@ -249,7 +249,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.updating = true;
|
||||
|
||||
try {
|
||||
console.log('[SHOP] Updating quantity:', productId, newQuantity);
|
||||
console.log('[STOREFRONT] Updating quantity:', productId, newQuantity);
|
||||
const response = await fetch(
|
||||
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
|
||||
{
|
||||
@@ -268,7 +268,7 @@ document.addEventListener('alpine:init', () => {
|
||||
throw new Error('Failed to update quantity');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Update quantity error:', error);
|
||||
console.error('[STOREFRONT] Update quantity error:', error);
|
||||
this.showToast('Failed to update quantity', 'error');
|
||||
} finally {
|
||||
this.updating = false;
|
||||
@@ -280,7 +280,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.updating = true;
|
||||
|
||||
try {
|
||||
console.log('[SHOP] Removing item:', productId);
|
||||
console.log('[STOREFRONT] Removing item:', productId);
|
||||
const response = await fetch(
|
||||
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
|
||||
{
|
||||
@@ -295,7 +295,7 @@ document.addEventListener('alpine:init', () => {
|
||||
throw new Error('Failed to remove item');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Remove item error:', error);
|
||||
console.error('[STOREFRONT] Remove item error:', error);
|
||||
this.showToast('Failed to remove item', 'error');
|
||||
} finally {
|
||||
this.updating = false;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user