Compare commits
216 Commits
adbecd360b
...
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 |
@@ -111,11 +111,9 @@ language_rules:
|
|||||||
function languageSelector(currentLang, enabledLanguages) { ... }
|
function languageSelector(currentLang, enabledLanguages) { ... }
|
||||||
window.languageSelector = languageSelector;
|
window.languageSelector = languageSelector;
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "static/shop/js/shop-layout.js"
|
file_patterns:
|
||||||
required_patterns:
|
- "static/shop/js/shop-layout.js"
|
||||||
- "function languageSelector"
|
- "static/vendor/js/init-alpine.js"
|
||||||
- "window.languageSelector"
|
|
||||||
file_pattern: "static/vendor/js/init-alpine.js"
|
|
||||||
required_patterns:
|
required_patterns:
|
||||||
- "function languageSelector"
|
- "function languageSelector"
|
||||||
- "window.languageSelector"
|
- "window.languageSelector"
|
||||||
@@ -247,3 +245,26 @@ language_rules:
|
|||||||
pattern:
|
pattern:
|
||||||
file_pattern: "static/locales/*.json"
|
file_pattern: "static/locales/*.json"
|
||||||
check: "valid_json"
|
check: "valid_json"
|
||||||
|
|
||||||
|
- id: "LANG-011"
|
||||||
|
name: "Use $t() not I18n.t() in HTML templates"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
In HTML templates, never use I18n.t() directly. It evaluates once
|
||||||
|
and does NOT re-evaluate when translations finish loading async.
|
||||||
|
|
||||||
|
WRONG (non-reactive, shows raw key then updates):
|
||||||
|
<span x-text="I18n.t('module.key')"></span>
|
||||||
|
|
||||||
|
RIGHT (reactive, updates when translations load):
|
||||||
|
<span x-text="$t('module.key')"></span>
|
||||||
|
|
||||||
|
BEST (server-side, zero flash):
|
||||||
|
<span>{{ _('module.key') }}</span>
|
||||||
|
|
||||||
|
Note: I18n.t() is fine in .js files where it's called inside
|
||||||
|
async callbacks after I18n.init() has completed.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "**/*.html"
|
||||||
|
anti_patterns:
|
||||||
|
- "I18n.t("
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ module_rules:
|
|||||||
en.json
|
en.json
|
||||||
de.json
|
de.json
|
||||||
fr.json
|
fr.json
|
||||||
lu.json
|
lb.json
|
||||||
|
|
||||||
Translation keys are namespaced as {module}.key_name
|
Translation keys are namespaced as {module}.key_name
|
||||||
pattern:
|
pattern:
|
||||||
@@ -269,14 +269,14 @@ module_rules:
|
|||||||
Module locales/ directory should have translation files for
|
Module locales/ directory should have translation files for
|
||||||
all supported languages to ensure consistent i18n.
|
all supported languages to ensure consistent i18n.
|
||||||
|
|
||||||
Supported languages: en, de, fr, lu
|
Supported languages: en, de, fr, lb
|
||||||
|
|
||||||
Structure:
|
Structure:
|
||||||
app/modules/<code>/locales/
|
app/modules/<code>/locales/
|
||||||
├── en.json
|
├── en.json
|
||||||
├── de.json
|
├── de.json
|
||||||
├── fr.json
|
├── fr.json
|
||||||
└── lu.json
|
└── lb.json
|
||||||
|
|
||||||
Missing translations will fall back to English, but it's
|
Missing translations will fall back to English, but it's
|
||||||
better to have all languages covered.
|
better to have all languages covered.
|
||||||
@@ -286,7 +286,7 @@ module_rules:
|
|||||||
- "en.json"
|
- "en.json"
|
||||||
- "de.json"
|
- "de.json"
|
||||||
- "fr.json"
|
- "fr.json"
|
||||||
- "lu.json"
|
- "lb.json"
|
||||||
|
|
||||||
- id: "MOD-007"
|
- id: "MOD-007"
|
||||||
name: "Module definition must match directory structure"
|
name: "Module definition must match directory structure"
|
||||||
|
|||||||
20
.env.example
20
.env.example
@@ -67,10 +67,15 @@ LOG_LEVEL=INFO
|
|||||||
LOG_FILE=logs/app.log
|
LOG_FILE=logs/app.log
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# PLATFORM DOMAIN CONFIGURATION
|
# MAIN DOMAIN CONFIGURATION
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Your main platform domain
|
# Your main platform domain
|
||||||
PLATFORM_DOMAIN=wizard.lu
|
MAIN_DOMAIN=wizard.lu
|
||||||
|
|
||||||
|
# Full base URL for outbound links (emails, billing redirects, etc.)
|
||||||
|
# Must include protocol and port if non-standard
|
||||||
|
# Examples: http://localhost:8000, http://acme.localhost:9999, https://wizard.lu
|
||||||
|
APP_BASE_URL=http://localhost:8000
|
||||||
|
|
||||||
# Custom domain features
|
# Custom domain features
|
||||||
# Enable/disable custom domains
|
# Enable/disable custom domains
|
||||||
@@ -149,6 +154,10 @@ SEED_ORDERS_PER_STORE=10
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# CELERY / REDIS TASK QUEUE
|
# CELERY / REDIS TASK QUEUE
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
# Redis password (must match docker-compose.yml --requirepass flag)
|
||||||
|
# ⚠️ CHANGE THIS IN PRODUCTION! Generate with: openssl rand -hex 16
|
||||||
|
REDIS_PASSWORD=changeme
|
||||||
|
|
||||||
# Redis connection URL (used for Celery broker and backend)
|
# Redis connection URL (used for Celery broker and backend)
|
||||||
# Default works with: docker-compose up -d redis
|
# Default works with: docker-compose up -d redis
|
||||||
REDIS_URL=redis://localhost:6379/0
|
REDIS_URL=redis://localhost:6379/0
|
||||||
@@ -219,7 +228,12 @@ R2_BACKUP_BUCKET=orion-backups
|
|||||||
# See docs/deployment/hetzner-server-setup.md Step 25 for setup guide
|
# See docs/deployment/hetzner-server-setup.md Step 25 for setup guide
|
||||||
# Get Issuer ID from https://pay.google.com/business/console
|
# Get Issuer ID from https://pay.google.com/business/console
|
||||||
# LOYALTY_GOOGLE_ISSUER_ID=3388000000012345678
|
# LOYALTY_GOOGLE_ISSUER_ID=3388000000012345678
|
||||||
# LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/path/to/service-account.json
|
# Production convention: ~/apps/orion/google-wallet-sa.json (app user, mode 600).
|
||||||
|
# Path is validated at startup — file must exist and be readable, otherwise
|
||||||
|
# the app fails fast at import time.
|
||||||
|
# LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=~/apps/orion/google-wallet-sa.json
|
||||||
|
# LOYALTY_GOOGLE_WALLET_ORIGINS=["https://yourdomain.com"]
|
||||||
|
# LOYALTY_DEFAULT_LOGO_URL=https://yourdomain.com/path/to/default-logo.png
|
||||||
|
|
||||||
# Apple Wallet integration (requires Apple Developer account)
|
# Apple Wallet integration (requires Apple Developer account)
|
||||||
# LOYALTY_APPLE_PASS_TYPE_ID=pass.com.example.loyalty
|
# LOYALTY_APPLE_PASS_TYPE_ID=pass.com.example.loyalty
|
||||||
|
|||||||
@@ -37,10 +37,11 @@ jobs:
|
|||||||
run: ruff check .
|
run: ruff check .
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Tests
|
# Tests — unit only (integration tests run locally via make test)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
pytest:
|
pytest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 150
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
@@ -55,10 +56,9 @@ jobs:
|
|||||||
--health-retries 5
|
--health-retries 5
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# act_runner executes jobs in Docker containers on the same network as services,
|
|
||||||
# so use the service name (postgres) as hostname with the internal port (5432)
|
|
||||||
TEST_DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/orion_test"
|
TEST_DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/orion_test"
|
||||||
DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/orion_test"
|
DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/orion_test"
|
||||||
|
LOG_LEVEL: "WARNING"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -73,8 +73,8 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv pip install --system -r requirements.txt -r requirements-test.txt
|
run: uv pip install --system -r requirements.txt -r requirements-test.txt
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run unit tests
|
||||||
run: python -m pytest tests/ -v --tb=short
|
run: python -m pytest -m "unit" -q --tb=short --timeout=120 --no-cov --override-ini="addopts=" -p no:cacheprovider -p no:logging --durations=20
|
||||||
|
|
||||||
validate:
|
validate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
19
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
19
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- Brief description of what this PR does -->
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
|
||||||
|
- [ ] Unit tests pass (`python -m pytest tests/unit/`)
|
||||||
|
- [ ] Integration tests pass (`python -m pytest tests/integration/`)
|
||||||
|
- [ ] Architecture validation passes (`python scripts/validate/validate_all.py`)
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] Code follows project conventions
|
||||||
|
- [ ] No new warnings introduced
|
||||||
|
- [ ] Database migrations included (if applicable)
|
||||||
9
.github/dependabot.yml
vendored
Normal file
9
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "pip"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
labels:
|
||||||
|
- "dependencies"
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -156,11 +156,10 @@ uploads/
|
|||||||
__pypackages__/
|
__pypackages__/
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker-compose.override.yml
|
|
||||||
.dockerignore.local
|
.dockerignore.local
|
||||||
*.override.yml
|
|
||||||
|
|
||||||
# Deployment & Security
|
# Deployment & Security
|
||||||
|
.build-info
|
||||||
deployment-local/
|
deployment-local/
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
|||||||
32
Makefile
32
Makefile
@@ -1,7 +1,7 @@
|
|||||||
# Orion Multi-Tenant E-Commerce Platform Makefile
|
# Orion Multi-Tenant E-Commerce Platform Makefile
|
||||||
# Cross-platform compatible (Windows & Linux)
|
# Cross-platform compatible (Windows & Linux)
|
||||||
|
|
||||||
.PHONY: install install-dev install-docs install-all dev test test-coverage lint format check docker-build docker-up docker-down clean help tailwind-install tailwind-dev tailwind-build tailwind-watch arch-check arch-check-file arch-check-object test-db-up test-db-down test-db-reset test-db-status celery-worker celery-beat celery-dev flower celery-status celery-purge urls infra-check
|
.PHONY: install install-dev install-docs install-all dev test test-coverage lint format check docker-build docker-up docker-down clean help tailwind-install tailwind-dev tailwind-build tailwind-watch arch-check arch-check-file arch-check-object test-db-up test-db-down test-db-reset test-db-status celery-worker celery-beat celery-dev flower celery-status celery-purge urls infra-check test-affected test-affected-dry
|
||||||
|
|
||||||
# Detect OS
|
# Detect OS
|
||||||
ifeq ($(OS),Windows_NT)
|
ifeq ($(OS),Windows_NT)
|
||||||
@@ -249,24 +249,21 @@ ifdef frontend
|
|||||||
endif
|
endif
|
||||||
endif
|
endif
|
||||||
|
|
||||||
# All testpaths (central + module tests)
|
|
||||||
TEST_PATHS := tests/ app/modules/tenancy/tests/ app/modules/catalog/tests/ app/modules/billing/tests/ app/modules/messaging/tests/ app/modules/orders/tests/ app/modules/customers/tests/ app/modules/marketplace/tests/ app/modules/inventory/tests/ app/modules/loyalty/tests/
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||||
@sleep 2
|
@sleep 2
|
||||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
$(PYTHON) -m pytest $(TEST_PATHS) -v $(MARKER_EXPR)
|
$(PYTHON) -m pytest -v $(MARKER_EXPR)
|
||||||
|
|
||||||
test-unit:
|
test-unit:
|
||||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||||
@sleep 2
|
@sleep 2
|
||||||
ifdef module
|
ifdef module
|
||||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
$(PYTHON) -m pytest $(TEST_PATHS) -v -m "unit and $(module)"
|
$(PYTHON) -m pytest -v -m "unit and $(module)"
|
||||||
else
|
else
|
||||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
$(PYTHON) -m pytest $(TEST_PATHS) -v -m unit
|
$(PYTHON) -m pytest -v -m unit
|
||||||
endif
|
endif
|
||||||
|
|
||||||
test-integration:
|
test-integration:
|
||||||
@@ -274,29 +271,38 @@ test-integration:
|
|||||||
@sleep 2
|
@sleep 2
|
||||||
ifdef module
|
ifdef module
|
||||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
$(PYTHON) -m pytest $(TEST_PATHS) -v -m "integration and $(module)"
|
$(PYTHON) -m pytest -v -m "integration and $(module)"
|
||||||
else
|
else
|
||||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
$(PYTHON) -m pytest $(TEST_PATHS) -v -m integration
|
$(PYTHON) -m pytest -v -m integration
|
||||||
endif
|
endif
|
||||||
|
|
||||||
test-coverage:
|
test-coverage:
|
||||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||||
@sleep 2
|
@sleep 2
|
||||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
$(PYTHON) -m pytest $(TEST_PATHS) --cov=app --cov=models --cov=utils --cov=middleware --cov-report=html --cov-report=term-missing $(MARKER_EXPR)
|
$(PYTHON) -m pytest --cov=app --cov=models --cov=utils --cov=middleware --cov-report=html --cov-report=term-missing $(MARKER_EXPR)
|
||||||
|
|
||||||
|
test-affected:
|
||||||
|
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||||
|
@sleep 2
|
||||||
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
|
$(PYTHON) scripts/tests/run_affected_tests.py $(AFFECTED_ARGS)
|
||||||
|
|
||||||
|
test-affected-dry:
|
||||||
|
@$(PYTHON) scripts/tests/run_affected_tests.py --dry-run $(AFFECTED_ARGS)
|
||||||
|
|
||||||
test-fast:
|
test-fast:
|
||||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||||
@sleep 2
|
@sleep 2
|
||||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
$(PYTHON) -m pytest $(TEST_PATHS) -v -m "not slow" $(MARKER_EXPR)
|
$(PYTHON) -m pytest -v -m "not slow" $(MARKER_EXPR)
|
||||||
|
|
||||||
test-slow:
|
test-slow:
|
||||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||||
@sleep 2
|
@sleep 2
|
||||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
$(PYTHON) -m pytest $(TEST_PATHS) -v -m slow
|
$(PYTHON) -m pytest -v -m slow
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# CODE QUALITY
|
# CODE QUALITY
|
||||||
@@ -569,6 +575,8 @@ help:
|
|||||||
@echo " test-unit module=X - Run unit tests for module X"
|
@echo " test-unit module=X - Run unit tests for module X"
|
||||||
@echo " test-integration - Run integration tests only"
|
@echo " test-integration - Run integration tests only"
|
||||||
@echo " test-coverage - Run tests with coverage"
|
@echo " test-coverage - Run tests with coverage"
|
||||||
|
@echo " test-affected - Run tests for modules affected by changes"
|
||||||
|
@echo " test-affected-dry - Show affected modules without running tests"
|
||||||
@echo " test-fast - Run fast tests only"
|
@echo " test-fast - Run fast tests only"
|
||||||
@echo " test frontend=storefront - Run storefront tests"
|
@echo " test frontend=storefront - Run storefront tests"
|
||||||
@echo ""
|
@echo ""
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
script_location = alembic
|
script_location = alembic
|
||||||
prepend_sys_path = .
|
prepend_sys_path = .
|
||||||
version_path_separator = space
|
version_path_separator = space
|
||||||
version_locations = alembic/versions app/modules/billing/migrations/versions app/modules/cart/migrations/versions app/modules/catalog/migrations/versions app/modules/cms/migrations/versions app/modules/customers/migrations/versions app/modules/dev_tools/migrations/versions app/modules/inventory/migrations/versions app/modules/loyalty/migrations/versions app/modules/marketplace/migrations/versions app/modules/messaging/migrations/versions app/modules/orders/migrations/versions app/modules/tenancy/migrations/versions
|
version_locations = alembic/versions app/modules/billing/migrations/versions app/modules/cart/migrations/versions app/modules/catalog/migrations/versions app/modules/cms/migrations/versions app/modules/customers/migrations/versions app/modules/dev_tools/migrations/versions app/modules/hosting/migrations/versions app/modules/inventory/migrations/versions app/modules/loyalty/migrations/versions app/modules/marketplace/migrations/versions app/modules/messaging/migrations/versions app/modules/orders/migrations/versions app/modules/prospecting/migrations/versions app/modules/tenancy/migrations/versions
|
||||||
# This will be overridden by alembic\env.py using settings.database_url
|
# This will be overridden by alembic\env.py using settings.database_url
|
||||||
sqlalchemy.url =
|
sqlalchemy.url =
|
||||||
# for PROD: sqlalchemy.url = postgresql://username:password@localhost:5432/ecommerce_db
|
# for PROD: sqlalchemy.url = postgresql://username:password@localhost:5432/ecommerce_db
|
||||||
|
|||||||
35
alembic/versions/remove_store_platform_is_primary.py
Normal file
35
alembic/versions/remove_store_platform_is_primary.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""Remove is_primary from store_platforms
|
||||||
|
|
||||||
|
The platform is always deterministic from the URL context (path in dev,
|
||||||
|
subdomain/domain in prod) and the JWT carries token_platform_id.
|
||||||
|
The is_primary column was a fallback picker that silently returned the
|
||||||
|
wrong platform for multi-platform stores.
|
||||||
|
|
||||||
|
Revision ID: remove_is_primary_001
|
||||||
|
Revises: billing_001
|
||||||
|
Create Date: 2026-03-09
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "remove_is_primary_001"
|
||||||
|
down_revision = "billing_001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_index("idx_store_platform_primary", table_name="store_platforms")
|
||||||
|
op.drop_column("store_platforms", "is_primary")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"store_platforms",
|
||||||
|
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_store_platform_primary", "store_platforms", ["store_id", "is_primary"]
|
||||||
|
)
|
||||||
118
alembic/versions/softdelete_001_add_soft_delete.py
Normal file
118
alembic/versions/softdelete_001_add_soft_delete.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""Add soft delete columns (deleted_at, deleted_by_id) to business-critical tables.
|
||||||
|
|
||||||
|
Also converts unique constraints on users.email, users.username,
|
||||||
|
stores.store_code, stores.subdomain to partial unique indexes
|
||||||
|
that only apply to non-deleted rows.
|
||||||
|
|
||||||
|
Revision ID: softdelete_001
|
||||||
|
Revises: remove_is_primary_001, customers_002, dev_tools_002, orders_002, tenancy_004
|
||||||
|
Create Date: 2026-03-28
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "softdelete_001"
|
||||||
|
down_revision = (
|
||||||
|
"remove_is_primary_001",
|
||||||
|
"customers_002",
|
||||||
|
"dev_tools_002",
|
||||||
|
"orders_002",
|
||||||
|
"tenancy_004",
|
||||||
|
)
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# Tables receiving soft-delete columns
|
||||||
|
SOFT_DELETE_TABLES = [
|
||||||
|
"users",
|
||||||
|
"merchants",
|
||||||
|
"stores",
|
||||||
|
"customers",
|
||||||
|
"store_users",
|
||||||
|
"orders",
|
||||||
|
"products",
|
||||||
|
"loyalty_programs",
|
||||||
|
"loyalty_cards",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ======================================================================
|
||||||
|
# Step 1: Add deleted_at and deleted_by_id to all soft-delete tables
|
||||||
|
# ======================================================================
|
||||||
|
for table in SOFT_DELETE_TABLES:
|
||||||
|
op.add_column(table, sa.Column("deleted_at", sa.DateTime(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
table,
|
||||||
|
sa.Column(
|
||||||
|
"deleted_by_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(f"ix_{table}_deleted_at", table, ["deleted_at"])
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# Step 2: Replace simple unique constraints with partial unique indexes
|
||||||
|
# (only enforce uniqueness among non-deleted rows)
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
# users.email: drop old unique index, create partial
|
||||||
|
op.drop_index("ix_users_email", table_name="users")
|
||||||
|
op.execute(
|
||||||
|
'CREATE UNIQUE INDEX uq_users_email_active ON users (email) '
|
||||||
|
'WHERE deleted_at IS NULL'
|
||||||
|
)
|
||||||
|
# Keep a non-unique index for lookups on all rows (including deleted)
|
||||||
|
op.create_index("ix_users_email", "users", ["email"])
|
||||||
|
|
||||||
|
# users.username: drop old unique index, create partial
|
||||||
|
op.drop_index("ix_users_username", table_name="users")
|
||||||
|
op.execute(
|
||||||
|
'CREATE UNIQUE INDEX uq_users_username_active ON users (username) '
|
||||||
|
'WHERE deleted_at IS NULL'
|
||||||
|
)
|
||||||
|
op.create_index("ix_users_username", "users", ["username"])
|
||||||
|
|
||||||
|
# stores.store_code: drop old unique index, create partial
|
||||||
|
op.drop_index("ix_stores_store_code", table_name="stores")
|
||||||
|
op.execute(
|
||||||
|
'CREATE UNIQUE INDEX uq_stores_store_code_active ON stores (store_code) '
|
||||||
|
'WHERE deleted_at IS NULL'
|
||||||
|
)
|
||||||
|
op.create_index("ix_stores_store_code", "stores", ["store_code"])
|
||||||
|
|
||||||
|
# stores.subdomain: drop old unique index, create partial
|
||||||
|
op.drop_index("ix_stores_subdomain", table_name="stores")
|
||||||
|
op.execute(
|
||||||
|
'CREATE UNIQUE INDEX uq_stores_subdomain_active ON stores (subdomain) '
|
||||||
|
'WHERE deleted_at IS NULL'
|
||||||
|
)
|
||||||
|
op.create_index("ix_stores_subdomain", "stores", ["subdomain"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Reverse partial unique indexes back to simple unique indexes
|
||||||
|
op.drop_index("ix_stores_subdomain", table_name="stores")
|
||||||
|
op.execute("DROP INDEX IF EXISTS uq_stores_subdomain_active")
|
||||||
|
op.create_index("ix_stores_subdomain", "stores", ["subdomain"], unique=True)
|
||||||
|
|
||||||
|
op.drop_index("ix_stores_store_code", table_name="stores")
|
||||||
|
op.execute("DROP INDEX IF EXISTS uq_stores_store_code_active")
|
||||||
|
op.create_index("ix_stores_store_code", "stores", ["store_code"], unique=True)
|
||||||
|
|
||||||
|
op.drop_index("ix_users_username", table_name="users")
|
||||||
|
op.execute("DROP INDEX IF EXISTS uq_users_username_active")
|
||||||
|
op.create_index("ix_users_username", "users", ["username"], unique=True)
|
||||||
|
|
||||||
|
op.drop_index("ix_users_email", table_name="users")
|
||||||
|
op.execute("DROP INDEX IF EXISTS uq_users_email_active")
|
||||||
|
op.create_index("ix_users_email", "users", ["email"], unique=True)
|
||||||
|
|
||||||
|
# Remove soft-delete columns from all tables
|
||||||
|
for table in reversed(SOFT_DELETE_TABLES):
|
||||||
|
op.drop_index(f"ix_{table}_deleted_at", table_name=table)
|
||||||
|
op.drop_column(table, "deleted_by_id")
|
||||||
|
op.drop_column(table, "deleted_at")
|
||||||
@@ -620,9 +620,9 @@ def require_menu_access(menu_item_id: str, frontend_type: "FrontendType"):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if user_context.is_super_admin:
|
if user_context.is_super_admin:
|
||||||
# Super admin: check user-level config
|
# Super admin: use platform from token if selected, else global (no filtering)
|
||||||
platform_id = None
|
platform_id = user_context.token_platform_id
|
||||||
user_id = user_context.id
|
user_id = None
|
||||||
else:
|
else:
|
||||||
# Platform admin: need platform context
|
# Platform admin: need platform context
|
||||||
# Try to get from request state
|
# Try to get from request state
|
||||||
@@ -1744,3 +1744,39 @@ def get_current_customer_optional(
|
|||||||
except Exception:
|
except Exception:
|
||||||
# Invalid token, store mismatch, or other error
|
# Invalid token, store mismatch, or other error
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# STOREFRONT MODULE GATING
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def make_storefront_module_gate(module_code: str):
|
||||||
|
"""
|
||||||
|
Create a FastAPI dependency that gates storefront routes by module enablement.
|
||||||
|
|
||||||
|
Used by main.py at route registration time: each non-core module's storefront
|
||||||
|
router gets this dependency injected automatically. The framework already knows
|
||||||
|
which module owns each route via RouteInfo.module_code — no hardcoded path map.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
module_code: The module code to check (e.g. "catalog", "orders", "loyalty")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A FastAPI dependency function
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _check_module_enabled(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
from app.modules.service import module_service
|
||||||
|
|
||||||
|
platform = getattr(request.state, "platform", None)
|
||||||
|
if not platform:
|
||||||
|
return # No platform context — let other middleware handle it
|
||||||
|
|
||||||
|
if not module_service.is_module_enabled(db, platform.id, module_code):
|
||||||
|
raise HTTPException(status_code=404, detail="Page not found")
|
||||||
|
|
||||||
|
return _check_module_enabled
|
||||||
|
|||||||
60
app/core/build_info.py
Normal file
60
app/core/build_info.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# app/core/build_info.py
|
||||||
|
"""
|
||||||
|
Build information utilities.
|
||||||
|
|
||||||
|
Reads commit SHA and deploy timestamp from .build-info file
|
||||||
|
(written by scripts/deploy.sh at deploy time), or falls back
|
||||||
|
to git for local development.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BUILD_INFO_FILE = Path(__file__).resolve().parent.parent.parent / ".build-info"
|
||||||
|
_cached_info: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_build_info() -> dict:
|
||||||
|
"""Return build info: commit, deployed_at, environment."""
|
||||||
|
global _cached_info
|
||||||
|
if _cached_info is not None:
|
||||||
|
return _cached_info
|
||||||
|
|
||||||
|
info = {
|
||||||
|
"commit": None,
|
||||||
|
"deployed_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try .build-info file first (written by deploy.sh)
|
||||||
|
if _BUILD_INFO_FILE.is_file():
|
||||||
|
try:
|
||||||
|
data = json.loads(_BUILD_INFO_FILE.read_text())
|
||||||
|
info["commit"] = data.get("commit")
|
||||||
|
info["deployed_at"] = data.get("deployed_at")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to read .build-info: {e}")
|
||||||
|
|
||||||
|
# Fall back to git for local development
|
||||||
|
if not info["commit"]:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "rev-parse", "--short=8", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
info["commit"] = result.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not info["deployed_at"]:
|
||||||
|
info["deployed_at"] = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
_cached_info = info
|
||||||
|
return info
|
||||||
@@ -91,7 +91,7 @@ celery_app.conf.update(
|
|||||||
task_soft_time_limit=25 * 60, # 25 minutes soft limit
|
task_soft_time_limit=25 * 60, # 25 minutes soft limit
|
||||||
# Worker settings
|
# Worker settings
|
||||||
worker_prefetch_multiplier=1, # Disable prefetching for long tasks
|
worker_prefetch_multiplier=1, # Disable prefetching for long tasks
|
||||||
worker_concurrency=4, # Number of concurrent workers
|
worker_concurrency=2, # Keep low on 4GB servers to avoid OOM
|
||||||
# Result backend
|
# Result backend
|
||||||
result_expires=86400, # Results expire after 24 hours
|
result_expires=86400, # Results expire after 24 hours
|
||||||
# Retry policy
|
# Retry policy
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ This module provides classes and functions for:
|
|||||||
- Configuration management via environment variables
|
- Configuration management via environment variables
|
||||||
- Database settings
|
- Database settings
|
||||||
- JWT and authentication configuration
|
- JWT and authentication configuration
|
||||||
- Platform domain and multi-tenancy settings
|
- Main domain and multi-tenancy settings
|
||||||
- Admin initialization settings
|
- Admin initialization settings
|
||||||
|
|
||||||
Note: Environment detection is handled by app.core.environment module.
|
Note: Environment detection is handled by app.core.environment module.
|
||||||
@@ -94,9 +94,14 @@ class Settings(BaseSettings):
|
|||||||
log_file: str | None = None
|
log_file: str | None = None
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# PLATFORM DOMAIN CONFIGURATION
|
# MAIN DOMAIN CONFIGURATION
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
platform_domain: str = "wizard.lu"
|
main_domain: str = "wizard.lu"
|
||||||
|
|
||||||
|
# Full base URL for outbound links (emails, redirects, etc.)
|
||||||
|
# Must include protocol and port if non-standard.
|
||||||
|
# Examples: http://localhost:8000, http://acme.localhost:9999, https://wizard.lu
|
||||||
|
app_base_url: str = "http://localhost:8000"
|
||||||
|
|
||||||
# Custom domain features
|
# Custom domain features
|
||||||
allow_custom_domains: bool = True
|
allow_custom_domains: bool = True
|
||||||
@@ -218,12 +223,15 @@ class Settings(BaseSettings):
|
|||||||
cloudflare_enabled: bool = False # Set to True when using CloudFlare proxy
|
cloudflare_enabled: bool = False # Set to True when using CloudFlare proxy
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# GOOGLE WALLET (LOYALTY MODULE)
|
# APPLE WALLET (LOYALTY MODULE)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
loyalty_google_issuer_id: str | None = None
|
loyalty_apple_pass_type_id: str | None = None
|
||||||
loyalty_google_service_account_json: str | None = None # Path to service account JSON
|
loyalty_apple_team_id: str | None = None
|
||||||
|
loyalty_apple_wwdr_cert_path: str | None = None
|
||||||
|
loyalty_apple_signer_cert_path: str | None = None
|
||||||
|
loyalty_apple_signer_key_path: str | None = None
|
||||||
|
|
||||||
model_config = {"env_file": ".env"}
|
model_config = {"env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
# Singleton settings instance
|
# Singleton settings instance
|
||||||
@@ -342,7 +350,7 @@ def print_environment_info():
|
|||||||
print(f" Database: {settings.database_url}")
|
print(f" Database: {settings.database_url}")
|
||||||
print(f" Debug mode: {settings.debug}")
|
print(f" Debug mode: {settings.debug}")
|
||||||
print(f" API port: {settings.api_port}")
|
print(f" API port: {settings.api_port}")
|
||||||
print(f" Platform: {settings.platform_domain}")
|
print(f" Platform: {settings.main_domain}")
|
||||||
print(f" Secure cookies: {should_use_secure_cookies()}")
|
print(f" Secure cookies: {should_use_secure_cookies()}")
|
||||||
print("=" * 70 + "\n")
|
print("=" * 70 + "\n")
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ Note: This project uses PostgreSQL only. SQLite is not supported.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, event
|
||||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
from sqlalchemy.orm import declarative_base, sessionmaker, with_loader_criteria
|
||||||
from sqlalchemy.pool import QueuePool
|
from sqlalchemy.pool import QueuePool
|
||||||
|
|
||||||
from .config import settings, validate_database_url
|
from .config import settings, validate_database_url
|
||||||
@@ -38,6 +38,45 @@ Base = declarative_base()
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Soft-delete automatic query filter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Any model that inherits SoftDeleteMixin will automatically have
|
||||||
|
# `WHERE deleted_at IS NULL` appended to SELECT queries.
|
||||||
|
# Bypass with: db.execute(stmt, execution_options={"include_deleted": True})
|
||||||
|
# or db.query(Model).execution_options(include_deleted=True).all()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def register_soft_delete_filter(session_factory):
|
||||||
|
"""Register the soft-delete query filter on a session factory.
|
||||||
|
|
||||||
|
Call this for any sessionmaker that should auto-exclude soft-deleted records.
|
||||||
|
Used for both the production SessionLocal and test session factories.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@event.listens_for(session_factory, "do_orm_execute")
|
||||||
|
def _soft_delete_filter(orm_execute_state):
|
||||||
|
if (
|
||||||
|
orm_execute_state.is_select
|
||||||
|
and not orm_execute_state.execution_options.get("include_deleted", False)
|
||||||
|
):
|
||||||
|
from models.database.base import SoftDeleteMixin
|
||||||
|
|
||||||
|
orm_execute_state.statement = orm_execute_state.statement.options(
|
||||||
|
with_loader_criteria(
|
||||||
|
SoftDeleteMixin,
|
||||||
|
lambda cls: cls.deleted_at.is_(None),
|
||||||
|
include_aliases=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return _soft_delete_filter
|
||||||
|
|
||||||
|
|
||||||
|
# Register on the production session factory
|
||||||
|
register_soft_delete_filter(SessionLocal)
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
"""
|
"""
|
||||||
Database session dependency for FastAPI routes.
|
Database session dependency for FastAPI routes.
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ async def lifespan(app: FastAPI):
|
|||||||
grafana_url=settings.grafana_url,
|
grafana_url=settings.grafana_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Validate wallet configurations
|
||||||
|
_validate_wallet_config()
|
||||||
|
|
||||||
logger.info("[OK] Application startup completed")
|
logger.info("[OK] Application startup completed")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
@@ -53,6 +56,72 @@ async def lifespan(app: FastAPI):
|
|||||||
shutdown_observability()
|
shutdown_observability()
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_wallet_config():
|
||||||
|
"""Validate Google/Apple Wallet configuration at startup."""
|
||||||
|
try:
|
||||||
|
from app.modules.loyalty.services.google_wallet_service import (
|
||||||
|
google_wallet_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = google_wallet_service.validate_config()
|
||||||
|
if result["configured"]:
|
||||||
|
if result["credentials_valid"]:
|
||||||
|
logger.info(
|
||||||
|
"[OK] Google Wallet configured (issuer: %s, email: %s)",
|
||||||
|
result["issuer_id"],
|
||||||
|
result.get("service_account_email", "unknown"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for err in result["errors"]:
|
||||||
|
logger.error("[FAIL] Google Wallet config error: %s", err)
|
||||||
|
else:
|
||||||
|
logger.info("[--] Google Wallet not configured (optional)")
|
||||||
|
|
||||||
|
# Apple Wallet config check
|
||||||
|
if settings.loyalty_apple_pass_type_id:
|
||||||
|
import os
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
for field in [
|
||||||
|
"loyalty_apple_team_id",
|
||||||
|
"loyalty_apple_wwdr_cert_path",
|
||||||
|
"loyalty_apple_signer_cert_path",
|
||||||
|
"loyalty_apple_signer_key_path",
|
||||||
|
]:
|
||||||
|
val = getattr(settings, field, None)
|
||||||
|
if not val:
|
||||||
|
missing.append(field)
|
||||||
|
elif field.endswith("_path") and not os.path.isfile(val):
|
||||||
|
logger.error(
|
||||||
|
"[FAIL] Apple Wallet file not found: %s = %s",
|
||||||
|
field,
|
||||||
|
val,
|
||||||
|
)
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
logger.error(
|
||||||
|
"[FAIL] Apple Wallet missing config: %s",
|
||||||
|
", ".join(missing),
|
||||||
|
)
|
||||||
|
elif not any(
|
||||||
|
not os.path.isfile(getattr(settings, f, "") or "")
|
||||||
|
for f in [
|
||||||
|
"loyalty_apple_wwdr_cert_path",
|
||||||
|
"loyalty_apple_signer_cert_path",
|
||||||
|
"loyalty_apple_signer_key_path",
|
||||||
|
]
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"[OK] Apple Wallet configured (pass type: %s)",
|
||||||
|
settings.loyalty_apple_pass_type_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("[--] Apple Wallet not configured (optional)")
|
||||||
|
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("Wallet config validation skipped: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
# === NEW HELPER FUNCTION ===
|
# === NEW HELPER FUNCTION ===
|
||||||
def check_database_ready():
|
def check_database_ready():
|
||||||
"""Check if database is ready (migrations have been run)."""
|
"""Check if database is ready (migrations have been run)."""
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ from datetime import UTC, datetime
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Response
|
from fastapi import APIRouter, Request, Response
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -538,12 +539,20 @@ async def readiness_check() -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
@health_router.get("/metrics")
|
@health_router.get("/metrics")
|
||||||
async def metrics_endpoint() -> Response:
|
async def metrics_endpoint(request: Request) -> Response:
|
||||||
"""
|
"""
|
||||||
Prometheus metrics endpoint.
|
Prometheus metrics endpoint.
|
||||||
|
|
||||||
Returns metrics in Prometheus text format for scraping.
|
Returns metrics in Prometheus text format for scraping.
|
||||||
|
Restricted to localhost and Docker internal networks only.
|
||||||
"""
|
"""
|
||||||
|
client_ip = request.client.host if request.client else None
|
||||||
|
allowed_prefixes = ("127.", "10.", "172.16.", "172.17.", "172.18.", "172.19.",
|
||||||
|
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.",
|
||||||
|
"172.25.", "172.26.", "172.27.", "172.28.", "172.29.",
|
||||||
|
"172.30.", "172.31.", "192.168.", "::1")
|
||||||
|
if not client_ip or not client_ip.startswith(allowed_prefixes):
|
||||||
|
return JSONResponse(status_code=403, content={"detail": "Forbidden"})
|
||||||
content = metrics_registry.generate_latest()
|
content = metrics_registry.generate_latest()
|
||||||
return Response(
|
return Response(
|
||||||
content=content,
|
content=content,
|
||||||
|
|||||||
54
app/core/preview_token.py
Normal file
54
app/core/preview_token.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# app/core/preview_token.py
|
||||||
|
"""
|
||||||
|
Signed preview tokens for POC site previews.
|
||||||
|
|
||||||
|
Generates time-limited JWT tokens that allow viewing storefront pages
|
||||||
|
for stores without active subscriptions (POC sites). The token is
|
||||||
|
validated by StorefrontAccessMiddleware to bypass the subscription gate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PREVIEW_TOKEN_HOURS = 24
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
|
||||||
|
|
||||||
|
def create_preview_token(store_id: int, store_code: str, site_id: int) -> str:
|
||||||
|
"""Create a signed preview token for a POC site.
|
||||||
|
|
||||||
|
Token is valid for PREVIEW_TOKEN_HOURS (default 24h) and is tied
|
||||||
|
to a specific store_id. Shareable with clients for preview access.
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"sub": f"preview:{store_id}",
|
||||||
|
"store_id": store_id,
|
||||||
|
"store_code": store_code,
|
||||||
|
"site_id": site_id,
|
||||||
|
"preview": True,
|
||||||
|
"exp": datetime.now(UTC) + timedelta(hours=PREVIEW_TOKEN_HOURS),
|
||||||
|
"iat": datetime.now(UTC),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.jwt_secret_key, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_preview_token(token: str, store_id: int) -> bool:
|
||||||
|
"""Verify a preview token is valid and matches the store.
|
||||||
|
|
||||||
|
Returns True if:
|
||||||
|
- Token signature is valid
|
||||||
|
- Token has not expired
|
||||||
|
- Token has preview=True claim
|
||||||
|
- Token store_id matches the requested store
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[ALGORITHM])
|
||||||
|
return payload.get("preview") is True and payload.get("store_id") == store_id
|
||||||
|
except JWTError:
|
||||||
|
return False
|
||||||
143
app/core/soft_delete.py
Normal file
143
app/core/soft_delete.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# app/core/soft_delete.py
|
||||||
|
"""
|
||||||
|
Soft-delete utility functions.
|
||||||
|
|
||||||
|
Provides helpers for soft-deleting, restoring, and cascade soft-deleting
|
||||||
|
records that use the SoftDeleteMixin.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from app.core.soft_delete import soft_delete, restore, soft_delete_cascade
|
||||||
|
|
||||||
|
# Simple soft delete
|
||||||
|
soft_delete(db, user, deleted_by_id=admin.id)
|
||||||
|
|
||||||
|
# Cascade soft delete (merchant + all stores + their children)
|
||||||
|
soft_delete_cascade(db, merchant, deleted_by_id=admin.id, cascade_rels=[
|
||||||
|
("stores", [("products", []), ("customers", []), ("orders", []), ("store_users", [])]),
|
||||||
|
])
|
||||||
|
|
||||||
|
# Restore a soft-deleted record
|
||||||
|
from app.modules.tenancy.models import User
|
||||||
|
restore(db, User, entity_id=42, restored_by_id=admin.id)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def soft_delete(db: Session, entity, deleted_by_id: int | None = None) -> None:
|
||||||
|
"""
|
||||||
|
Mark an entity as soft-deleted.
|
||||||
|
|
||||||
|
Sets deleted_at to now and deleted_by_id to the actor.
|
||||||
|
Does NOT call db.commit() — caller is responsible.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session.
|
||||||
|
entity: SQLAlchemy model instance with SoftDeleteMixin.
|
||||||
|
deleted_by_id: ID of the user performing the deletion.
|
||||||
|
"""
|
||||||
|
entity.deleted_at = datetime.now(UTC)
|
||||||
|
entity.deleted_by_id = deleted_by_id
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Soft-deleted {entity.__class__.__name__} id={entity.id} "
|
||||||
|
f"by user_id={deleted_by_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def restore(
|
||||||
|
db: Session,
|
||||||
|
model_class,
|
||||||
|
entity_id: int,
|
||||||
|
restored_by_id: int | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Restore a soft-deleted entity.
|
||||||
|
|
||||||
|
Queries with include_deleted=True to find the record, then clears
|
||||||
|
deleted_at and deleted_by_id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session.
|
||||||
|
model_class: SQLAlchemy model class.
|
||||||
|
entity_id: ID of the entity to restore.
|
||||||
|
restored_by_id: ID of the user performing the restore (for logging).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The restored entity.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If entity not found.
|
||||||
|
"""
|
||||||
|
entity = db.execute(
|
||||||
|
select(model_class).filter(model_class.id == entity_id),
|
||||||
|
execution_options={"include_deleted": True},
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if entity is None:
|
||||||
|
raise ValueError(f"{model_class.__name__} with id={entity_id} not found")
|
||||||
|
|
||||||
|
if entity.deleted_at is None:
|
||||||
|
raise ValueError(f"{model_class.__name__} with id={entity_id} is not deleted")
|
||||||
|
|
||||||
|
entity.deleted_at = None
|
||||||
|
entity.deleted_by_id = None
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Restored {model_class.__name__} id={entity_id} "
|
||||||
|
f"by user_id={restored_by_id}"
|
||||||
|
)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
def soft_delete_cascade(
|
||||||
|
db: Session,
|
||||||
|
entity,
|
||||||
|
deleted_by_id: int | None = None,
|
||||||
|
cascade_rels: list[tuple[str, list]] | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Soft-delete an entity and recursively soft-delete its children.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session.
|
||||||
|
entity: SQLAlchemy model instance with SoftDeleteMixin.
|
||||||
|
deleted_by_id: ID of the user performing the deletion.
|
||||||
|
cascade_rels: List of (relationship_name, child_cascade_rels) tuples.
|
||||||
|
Example: [("stores", [("products", []), ("customers", [])])]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Total number of records soft-deleted (including the root entity).
|
||||||
|
"""
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
# Soft-delete the entity itself
|
||||||
|
soft_delete(db, entity, deleted_by_id)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
# Recursively soft-delete children
|
||||||
|
if cascade_rels:
|
||||||
|
for rel_name, child_cascade in cascade_rels:
|
||||||
|
children = getattr(entity, rel_name, None)
|
||||||
|
if children is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle both collections and single items (uselist=False)
|
||||||
|
if not isinstance(children, list):
|
||||||
|
children = [children]
|
||||||
|
|
||||||
|
for child in children:
|
||||||
|
if hasattr(child, "deleted_at") and child.deleted_at is None:
|
||||||
|
count += soft_delete_cascade(
|
||||||
|
db, child, deleted_by_id, child_cascade
|
||||||
|
)
|
||||||
|
|
||||||
|
return count
|
||||||
@@ -85,8 +85,9 @@ class ErrorPageRenderer:
|
|||||||
Returns:
|
Returns:
|
||||||
HTMLResponse with rendered error page
|
HTMLResponse with rendered error page
|
||||||
"""
|
"""
|
||||||
# Get frontend type
|
# Get frontend type — default to PLATFORM in error rendering context
|
||||||
frontend_type = get_frontend_type(request)
|
# (errors can occur before FrontendTypeMiddleware runs)
|
||||||
|
frontend_type = get_frontend_type(request) or FrontendType.PLATFORM
|
||||||
|
|
||||||
# Prepare template data
|
# Prepare template data
|
||||||
template_data = ErrorPageRenderer._prepare_template_data(
|
template_data = ErrorPageRenderer._prepare_template_data(
|
||||||
@@ -291,7 +292,7 @@ class ErrorPageRenderer:
|
|||||||
# TODO: Implement actual admin check based on JWT/session
|
# TODO: Implement actual admin check based on JWT/session
|
||||||
# For now, check if we're in admin frontend
|
# For now, check if we're in admin frontend
|
||||||
frontend_type = get_frontend_type(request)
|
frontend_type = get_frontend_type(request)
|
||||||
return frontend_type == FrontendType.ADMIN
|
return frontend_type is not None and frontend_type == FrontendType.ADMIN
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _render_basic_html_fallback(
|
def _render_basic_html_fallback(
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ def _redirect_to_login(request: Request) -> RedirectResponse:
|
|||||||
Uses FrontendType detection to determine admin vs store vs storefront login.
|
Uses FrontendType detection to determine admin vs store vs storefront login.
|
||||||
Properly handles multi-access routing (domain, subdomain, path-based).
|
Properly handles multi-access routing (domain, subdomain, path-based).
|
||||||
"""
|
"""
|
||||||
frontend_type = get_frontend_type(request)
|
frontend_type = get_frontend_type(request) or FrontendType.PLATFORM
|
||||||
|
|
||||||
if frontend_type == FrontendType.ADMIN:
|
if frontend_type == FrontendType.ADMIN:
|
||||||
logger.debug("Redirecting to /admin/login")
|
logger.debug("Redirecting to /admin/login")
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
|
|||||||
# api_timeout: int = 30
|
# api_timeout: int = 30
|
||||||
# batch_size: int = 100
|
# batch_size: int = 100
|
||||||
|
|
||||||
model_config = {"env_prefix": "ANALYTICS_"}
|
model_config = {"env_prefix": "ANALYTICS_", "env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
# Export for auto-discovery
|
# Export for auto-discovery
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ analytics_module = ModuleDefinition(
|
|||||||
icon="chart-bar",
|
icon="chart-bar",
|
||||||
route="/store/{store_code}/analytics",
|
route="/store/{store_code}/analytics",
|
||||||
order=20,
|
order=20,
|
||||||
|
requires_permission="analytics.view",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
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.
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
||||||
<span x-html="$icon('location-marker', 'w-6 h-6')"></span>
|
<span x-html="$icon('map-pin', 'w-6 h-6')"></span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="formatNumber(analytics.inventory?.total_locations || 0)"></p>
|
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="formatNumber(analytics.inventory?.total_locations || 0)"></p>
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ class MenuItemDefinition:
|
|||||||
requires_permission: str | None = None
|
requires_permission: str | None = None
|
||||||
badge_source: str | None = None
|
badge_source: str | None = None
|
||||||
is_super_admin_only: bool = False
|
is_super_admin_only: bool = False
|
||||||
|
header_template: str | None = None # Optional partial for custom header rendering
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -497,7 +498,7 @@ class ModuleDefinition:
|
|||||||
#
|
#
|
||||||
# Example:
|
# Example:
|
||||||
# def _get_onboarding_provider():
|
# def _get_onboarding_provider():
|
||||||
# from app.modules.marketplace.services.marketplace_onboarding import (
|
# from app.modules.marketplace.services.marketplace_onboarding_service import (
|
||||||
# marketplace_onboarding_provider,
|
# marketplace_onboarding_provider,
|
||||||
# )
|
# )
|
||||||
# return marketplace_onboarding_provider
|
# return marketplace_onboarding_provider
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
|
|||||||
# api_timeout: int = 30
|
# api_timeout: int = 30
|
||||||
# batch_size: int = 100
|
# batch_size: int = 100
|
||||||
|
|
||||||
model_config = {"env_prefix": "BILLING_"}
|
model_config = {"env_prefix": "BILLING_", "env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
# Export for auto-discovery
|
# Export for auto-discovery
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any
|
|||||||
"""
|
"""
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.modules.billing.models import SubscriptionTier, TierCode
|
from app.modules.billing.models import SubscriptionTier, TierCode
|
||||||
|
from app.modules.billing.services.feature_aggregator import feature_aggregator
|
||||||
|
|
||||||
|
language = getattr(request.state, "language", "fr") or "fr"
|
||||||
|
|
||||||
tiers_db = (
|
tiers_db = (
|
||||||
db.query(SubscriptionTier)
|
db.query(SubscriptionTier)
|
||||||
@@ -48,14 +51,28 @@ def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any
|
|||||||
tiers = []
|
tiers = []
|
||||||
for tier in tiers_db:
|
for tier in tiers_db:
|
||||||
feature_codes = sorted(tier.get_feature_codes())
|
feature_codes = sorted(tier.get_feature_codes())
|
||||||
|
|
||||||
|
# Build features list from declarations for template rendering
|
||||||
|
features = []
|
||||||
|
for code in feature_codes:
|
||||||
|
decl = feature_aggregator.get_declaration(code)
|
||||||
|
if decl:
|
||||||
|
features.append({
|
||||||
|
"code": code,
|
||||||
|
"name_key": decl.name_key,
|
||||||
|
"limit": tier.get_limit_for_feature(code),
|
||||||
|
"is_quantitative": decl.feature_type.value == "quantitative",
|
||||||
|
})
|
||||||
|
|
||||||
tiers.append({
|
tiers.append({
|
||||||
"code": tier.code,
|
"code": tier.code,
|
||||||
"name": tier.name,
|
"name": tier.get_translated_name(language),
|
||||||
"price_monthly": tier.price_monthly_cents / 100,
|
"price_monthly": tier.price_monthly_cents / 100,
|
||||||
"price_annual": (tier.price_annual_cents / 100)
|
"price_annual": (tier.price_annual_cents / 100)
|
||||||
if tier.price_annual_cents
|
if tier.price_annual_cents
|
||||||
else None,
|
else None,
|
||||||
"feature_codes": feature_codes,
|
"feature_codes": feature_codes,
|
||||||
|
"features": features,
|
||||||
"products_limit": tier.get_limit_for_feature("products_limit"),
|
"products_limit": tier.get_limit_for_feature("products_limit"),
|
||||||
"orders_per_month": tier.get_limit_for_feature("orders_per_month"),
|
"orders_per_month": tier.get_limit_for_feature("orders_per_month"),
|
||||||
"team_members": tier.get_limit_for_feature("team_members"),
|
"team_members": tier.get_limit_for_feature("team_members"),
|
||||||
|
|||||||
@@ -103,9 +103,12 @@ class RequireFeature:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Check if store's merchant has access to any of the required features."""
|
"""Check if store's merchant has access to any of the required features."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
|
platform_id = current_user.token_platform_id
|
||||||
|
|
||||||
for feature_code in self.feature_codes:
|
for feature_code in self.feature_codes:
|
||||||
if feature_service.has_feature_for_store(db, store_id, feature_code):
|
if feature_service.has_feature_for_store(
|
||||||
|
db, store_id, feature_code, platform_id=platform_id
|
||||||
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
# None of the features are available
|
# None of the features are available
|
||||||
@@ -136,7 +139,8 @@ class RequireWithinLimit:
|
|||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
|
|
||||||
allowed, message = feature_service.check_resource_limit(
|
allowed, message = feature_service.check_resource_limit(
|
||||||
db, self.feature_code, store_id=store_id
|
db, self.feature_code, store_id=store_id,
|
||||||
|
platform_id=current_user.token_platform_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not allowed:
|
if not allowed:
|
||||||
@@ -176,9 +180,12 @@ def require_feature(*feature_codes: str) -> Callable:
|
|||||||
)
|
)
|
||||||
|
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
|
platform_id = current_user.token_platform_id
|
||||||
|
|
||||||
for feature_code in feature_codes:
|
for feature_code in feature_codes:
|
||||||
if feature_service.has_feature_for_store(db, store_id, feature_code):
|
if feature_service.has_feature_for_store(
|
||||||
|
db, store_id, feature_code, platform_id=platform_id
|
||||||
|
):
|
||||||
return await func(*args, **kwargs)
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
raise FeatureNotAvailableError(feature_code=feature_codes[0])
|
raise FeatureNotAvailableError(feature_code=feature_codes[0])
|
||||||
@@ -195,9 +202,12 @@ def require_feature(*feature_codes: str) -> Callable:
|
|||||||
)
|
)
|
||||||
|
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
|
platform_id = current_user.token_platform_id
|
||||||
|
|
||||||
for feature_code in feature_codes:
|
for feature_code in feature_codes:
|
||||||
if feature_service.has_feature_for_store(db, store_id, feature_code):
|
if feature_service.has_feature_for_store(
|
||||||
|
db, store_id, feature_code, platform_id=platform_id
|
||||||
|
):
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
raise FeatureNotAvailableError(feature_code=feature_codes[0])
|
raise FeatureNotAvailableError(feature_code=feature_codes[0])
|
||||||
|
|||||||
138
app/modules/billing/docs/data-model.md
Normal file
138
app/modules/billing/docs/data-model.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# Billing Data Model
|
||||||
|
|
||||||
|
## Entity Relationship Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────┐
|
||||||
|
│ SubscriptionTier │
|
||||||
|
└────────┬──────────┘
|
||||||
|
│ 1:N
|
||||||
|
▼
|
||||||
|
┌───────────────────┐ ┌──────────────────────┐
|
||||||
|
│ TierFeatureLimit │ │ MerchantSubscription │
|
||||||
|
│ (feature limits) │ │ (per merchant+plat) │
|
||||||
|
└───────────────────┘ └──────────┬───────────┘
|
||||||
|
│
|
||||||
|
┌──────────┼──────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌────────────┐ ┌──────────┐ ┌─────────────┐
|
||||||
|
│ BillingHist│ │StoreAddOn│ │FeatureOverride│
|
||||||
|
└────────────┘ └──────────┘ └─────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌────────────┐
|
||||||
|
│AddOnProduct│
|
||||||
|
└────────────┘
|
||||||
|
|
||||||
|
┌──────────────────────┐
|
||||||
|
│StripeWebhookEvent │ (idempotency tracking)
|
||||||
|
└──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Entities
|
||||||
|
|
||||||
|
### SubscriptionTier
|
||||||
|
|
||||||
|
Defines available subscription plans with pricing and Stripe integration.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | Integer | Primary key |
|
||||||
|
| `code` | String | Unique tier code (`essential`, `professional`, `business`, `enterprise`) |
|
||||||
|
| `name` | String | Display name |
|
||||||
|
| `price_monthly_cents` | Integer | Monthly price in cents |
|
||||||
|
| `price_annual_cents` | Integer | Annual price in cents (optional) |
|
||||||
|
| `stripe_product_id` | String | Stripe product ID |
|
||||||
|
| `stripe_price_monthly_id` | String | Stripe monthly price ID |
|
||||||
|
| `stripe_price_annual_id` | String | Stripe annual price ID |
|
||||||
|
| `display_order` | Integer | Sort order on pricing pages |
|
||||||
|
| `is_active` | Boolean | Available for subscription |
|
||||||
|
| `is_public` | Boolean | Visible to stores |
|
||||||
|
|
||||||
|
### TierFeatureLimit
|
||||||
|
|
||||||
|
Per-tier feature limits — each row links a tier to a feature code with a limit value.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | Integer | Primary key |
|
||||||
|
| `tier_id` | Integer | FK to SubscriptionTier |
|
||||||
|
| `feature_code` | String | Feature identifier (e.g., `max_products`) |
|
||||||
|
| `limit_value` | Integer | Numeric limit (NULL = unlimited) |
|
||||||
|
| `enabled` | Boolean | Whether feature is enabled for this tier |
|
||||||
|
|
||||||
|
### MerchantSubscription
|
||||||
|
|
||||||
|
Per-merchant+platform subscription state. Subscriptions are merchant-level, not store-level.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | Integer | Primary key |
|
||||||
|
| `merchant_id` | Integer | FK to Merchant |
|
||||||
|
| `platform_id` | Integer | FK to Platform |
|
||||||
|
| `tier_id` | Integer | FK to SubscriptionTier |
|
||||||
|
| `tier_code` | String | Tier code (denormalized for convenience) |
|
||||||
|
| `status` | SubscriptionStatus | `trial`, `active`, `past_due`, `cancelled`, `expired` |
|
||||||
|
| `stripe_customer_id` | String | Stripe customer ID |
|
||||||
|
| `stripe_subscription_id` | String | Stripe subscription ID |
|
||||||
|
| `trial_ends_at` | DateTime | Trial expiry |
|
||||||
|
| `period_start` | DateTime | Current billing period start |
|
||||||
|
| `period_end` | DateTime | Current billing period end |
|
||||||
|
|
||||||
|
### MerchantFeatureOverride
|
||||||
|
|
||||||
|
Per-merchant exceptions to tier defaults (e.g., enterprise custom limits).
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | Integer | Primary key |
|
||||||
|
| `merchant_id` | Integer | FK to Merchant |
|
||||||
|
| `feature_code` | String | Feature identifier |
|
||||||
|
| `limit_value` | Integer | Override limit (NULL = unlimited) |
|
||||||
|
|
||||||
|
## Add-on Entities
|
||||||
|
|
||||||
|
### AddOnProduct
|
||||||
|
|
||||||
|
Purchasable add-on items (domains, SSL, email packages).
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | Integer | Primary key |
|
||||||
|
| `code` | String | Unique add-on code |
|
||||||
|
| `name` | String | Display name |
|
||||||
|
| `category` | AddOnCategory | `domain`, `ssl`, `email` |
|
||||||
|
| `price_cents` | Integer | Price in cents |
|
||||||
|
| `billing_period` | BillingPeriod | `monthly` or `yearly` |
|
||||||
|
|
||||||
|
### StoreAddOn
|
||||||
|
|
||||||
|
Add-ons purchased by individual stores.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | Integer | Primary key |
|
||||||
|
| `store_id` | Integer | FK to Store |
|
||||||
|
| `addon_product_id` | Integer | FK to AddOnProduct |
|
||||||
|
| `config` | JSON | Configuration (e.g., domain name) |
|
||||||
|
| `stripe_subscription_item_id` | String | Stripe subscription item ID |
|
||||||
|
| `status` | String | `active`, `cancelled`, `pending_setup` |
|
||||||
|
|
||||||
|
## Supporting Entities
|
||||||
|
|
||||||
|
### BillingHistory
|
||||||
|
|
||||||
|
Invoice and payment history records.
|
||||||
|
|
||||||
|
### StripeWebhookEvent
|
||||||
|
|
||||||
|
Idempotency tracking for Stripe webhook events. Prevents duplicate event processing.
|
||||||
|
|
||||||
|
## Key Relationships
|
||||||
|
|
||||||
|
- A **SubscriptionTier** has many **TierFeatureLimits** (one per feature)
|
||||||
|
- A **Merchant** has one **MerchantSubscription** per Platform
|
||||||
|
- A **MerchantSubscription** references one **SubscriptionTier**
|
||||||
|
- A **Merchant** can have many **MerchantFeatureOverrides** (per-feature)
|
||||||
|
- A **Store** can purchase many **StoreAddOns**
|
||||||
|
- Feature limits are resolved: MerchantFeatureOverride > TierFeatureLimit > default
|
||||||
434
app/modules/billing/docs/feature-gating.md
Normal file
434
app/modules/billing/docs/feature-gating.md
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
# Feature Gating System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The feature gating system provides tier-based access control for platform features. It allows restricting functionality based on store subscription tiers (Essential, Professional, Business, Enterprise) with contextual upgrade prompts when features are locked.
|
||||||
|
|
||||||
|
**Implemented:** December 31, 2025
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Database Models
|
||||||
|
|
||||||
|
Located in `models/database/feature.py`:
|
||||||
|
|
||||||
|
| Model | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `Feature` | Feature definitions with tier requirements |
|
||||||
|
| `StoreFeatureOverride` | Per-store feature overrides (enable/disable) |
|
||||||
|
|
||||||
|
### Feature Model Structure
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Feature(Base):
|
||||||
|
__tablename__ = "features"
|
||||||
|
|
||||||
|
id: int # Primary key
|
||||||
|
code: str # Unique feature code (e.g., "analytics_dashboard")
|
||||||
|
name: str # Display name
|
||||||
|
description: str # User-facing description
|
||||||
|
category: str # Feature category
|
||||||
|
minimum_tier_code: str # Minimum tier required (essential/professional/business/enterprise)
|
||||||
|
minimum_tier_order: int # Tier order for comparison (1-4)
|
||||||
|
is_active: bool # Whether feature is available
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tier Ordering
|
||||||
|
|
||||||
|
| Tier | Order | Code |
|
||||||
|
|------|-------|------|
|
||||||
|
| Essential | 1 | `essential` |
|
||||||
|
| Professional | 2 | `professional` |
|
||||||
|
| Business | 3 | `business` |
|
||||||
|
| Enterprise | 4 | `enterprise` |
|
||||||
|
|
||||||
|
## Feature Categories
|
||||||
|
|
||||||
|
30 features organized into 8 categories:
|
||||||
|
|
||||||
|
### 1. Analytics
|
||||||
|
| Feature Code | Name | Min Tier |
|
||||||
|
|-------------|------|----------|
|
||||||
|
| `basic_analytics` | Basic Analytics | Essential |
|
||||||
|
| `analytics_dashboard` | Analytics Dashboard | Professional |
|
||||||
|
| `advanced_analytics` | Advanced Analytics | Business |
|
||||||
|
| `custom_reports` | Custom Reports | Enterprise |
|
||||||
|
|
||||||
|
### 2. Product Management
|
||||||
|
| Feature Code | Name | Min Tier |
|
||||||
|
|-------------|------|----------|
|
||||||
|
| `basic_products` | Product Management | Essential |
|
||||||
|
| `bulk_product_edit` | Bulk Product Edit | Professional |
|
||||||
|
| `product_variants` | Product Variants | Professional |
|
||||||
|
| `product_bundles` | Product Bundles | Business |
|
||||||
|
| `inventory_alerts` | Inventory Alerts | Professional |
|
||||||
|
|
||||||
|
### 3. Order Management
|
||||||
|
| Feature Code | Name | Min Tier |
|
||||||
|
|-------------|------|----------|
|
||||||
|
| `basic_orders` | Order Management | Essential |
|
||||||
|
| `order_automation` | Order Automation | Professional |
|
||||||
|
| `advanced_fulfillment` | Advanced Fulfillment | Business |
|
||||||
|
| `multi_warehouse` | Multi-Warehouse | Enterprise |
|
||||||
|
|
||||||
|
### 4. Marketing
|
||||||
|
| Feature Code | Name | Min Tier |
|
||||||
|
|-------------|------|----------|
|
||||||
|
| `discount_codes` | Discount Codes | Professional |
|
||||||
|
| `abandoned_cart` | Abandoned Cart Recovery | Business |
|
||||||
|
| `email_marketing` | Email Marketing | Business |
|
||||||
|
| `loyalty_program` | Loyalty Program | Enterprise |
|
||||||
|
|
||||||
|
### 5. Support
|
||||||
|
| Feature Code | Name | Min Tier |
|
||||||
|
|-------------|------|----------|
|
||||||
|
| `basic_support` | Email Support | Essential |
|
||||||
|
| `priority_support` | Priority Support | Professional |
|
||||||
|
| `phone_support` | Phone Support | Business |
|
||||||
|
| `dedicated_manager` | Dedicated Account Manager | Enterprise |
|
||||||
|
|
||||||
|
### 6. Integration
|
||||||
|
| Feature Code | Name | Min Tier |
|
||||||
|
|-------------|------|----------|
|
||||||
|
| `basic_api` | Basic API Access | Professional |
|
||||||
|
| `advanced_api` | Advanced API Access | Business |
|
||||||
|
| `webhooks` | Webhooks | Business |
|
||||||
|
| `custom_integrations` | Custom Integrations | Enterprise |
|
||||||
|
|
||||||
|
### 7. Branding
|
||||||
|
| Feature Code | Name | Min Tier |
|
||||||
|
|-------------|------|----------|
|
||||||
|
| `basic_theme` | Theme Customization | Essential |
|
||||||
|
| `custom_domain` | Custom Domain | Professional |
|
||||||
|
| `white_label` | White Label | Enterprise |
|
||||||
|
| `custom_checkout` | Custom Checkout | Enterprise |
|
||||||
|
|
||||||
|
### 8. Team
|
||||||
|
| Feature Code | Name | Min Tier |
|
||||||
|
|-------------|------|----------|
|
||||||
|
| `team_management` | Team Management | Professional |
|
||||||
|
| `role_permissions` | Role Permissions | Business |
|
||||||
|
| `audit_logs` | Audit Logs | Business |
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
### FeatureService
|
||||||
|
|
||||||
|
Located in `app/services/feature_service.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class FeatureService:
|
||||||
|
"""Service for managing tier-based feature access."""
|
||||||
|
|
||||||
|
# In-memory caching (refreshed every 5 minutes)
|
||||||
|
_feature_cache: dict[str, Feature] = {}
|
||||||
|
_cache_timestamp: datetime | None = None
|
||||||
|
CACHE_TTL_SECONDS = 300
|
||||||
|
|
||||||
|
def has_feature(self, db: Session, store_id: int, feature_code: str) -> bool:
|
||||||
|
"""Check if store has access to a feature."""
|
||||||
|
|
||||||
|
def get_available_features(self, db: Session, store_id: int) -> list[str]:
|
||||||
|
"""Get list of feature codes available to store."""
|
||||||
|
|
||||||
|
def get_all_features_with_status(self, db: Session, store_id: int) -> list[dict]:
|
||||||
|
"""Get all features with availability status for store."""
|
||||||
|
|
||||||
|
def get_feature_info(self, db: Session, feature_code: str) -> dict | None:
|
||||||
|
"""Get full feature information including tier requirements."""
|
||||||
|
```
|
||||||
|
|
||||||
|
### UsageService
|
||||||
|
|
||||||
|
Located in `app/services/usage_service.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class UsageService:
|
||||||
|
"""Service for tracking and managing store usage against tier limits."""
|
||||||
|
|
||||||
|
def get_usage_summary(self, db: Session, store_id: int) -> dict:
|
||||||
|
"""Get comprehensive usage summary with limits and upgrade info."""
|
||||||
|
|
||||||
|
def check_limit(self, db: Session, store_id: int, limit_type: str) -> dict:
|
||||||
|
"""Check specific limit with detailed info."""
|
||||||
|
|
||||||
|
def get_upgrade_info(self, db: Session, store_id: int) -> dict:
|
||||||
|
"""Get upgrade recommendations based on current usage."""
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Enforcement
|
||||||
|
|
||||||
|
### Decorator Pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.core.feature_gate import require_feature
|
||||||
|
|
||||||
|
@router.get("/analytics/advanced")
|
||||||
|
@require_feature("advanced_analytics")
|
||||||
|
async def get_advanced_analytics(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
store_id: int = Depends(get_current_store_id)
|
||||||
|
):
|
||||||
|
# Only accessible if store has advanced_analytics feature
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependency Pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.core.feature_gate import RequireFeature
|
||||||
|
|
||||||
|
@router.get("/marketing/loyalty")
|
||||||
|
async def get_loyalty_program(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: None = Depends(RequireFeature("loyalty_program"))
|
||||||
|
):
|
||||||
|
# Only accessible if store has loyalty_program feature
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exception Handling
|
||||||
|
|
||||||
|
When a feature is not available, `FeatureNotAvailableException` is raised:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class FeatureNotAvailableException(Exception):
|
||||||
|
def __init__(self, feature_code: str, required_tier: str):
|
||||||
|
self.feature_code = feature_code
|
||||||
|
self.required_tier = required_tier
|
||||||
|
super().__init__(f"Feature '{feature_code}' requires {required_tier} tier")
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP Response (403):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Feature 'advanced_analytics' requires Professional tier or higher",
|
||||||
|
"feature_code": "advanced_analytics",
|
||||||
|
"required_tier": "Professional",
|
||||||
|
"upgrade_url": "/store/orion/billing"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Store Features API
|
||||||
|
|
||||||
|
Base: `/api/v1/store/features`
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/features/available` | GET | List available feature codes |
|
||||||
|
| `/features` | GET | All features with availability status |
|
||||||
|
| `/features/{code}` | GET | Single feature info |
|
||||||
|
| `/features/{code}/check` | GET | Quick availability check |
|
||||||
|
|
||||||
|
### Store Usage API
|
||||||
|
|
||||||
|
Base: `/api/v1/store/usage`
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/usage` | GET | Full usage summary with limits |
|
||||||
|
| `/usage/check/{limit_type}` | GET | Check specific limit (orders/products/team_members) |
|
||||||
|
| `/usage/upgrade-info` | GET | Upgrade recommendations |
|
||||||
|
|
||||||
|
### Admin Features API
|
||||||
|
|
||||||
|
Base: `/api/v1/admin/features`
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/features` | GET | List all features |
|
||||||
|
| `/features/{id}` | GET | Get feature details |
|
||||||
|
| `/features/{id}` | PUT | Update feature |
|
||||||
|
| `/features/{id}/toggle` | POST | Toggle feature active status |
|
||||||
|
| `/features/stores/{store_id}/overrides` | GET | Get store overrides |
|
||||||
|
| `/features/stores/{store_id}/overrides` | POST | Create override |
|
||||||
|
|
||||||
|
## Frontend Integration
|
||||||
|
|
||||||
|
### Alpine.js Feature Store
|
||||||
|
|
||||||
|
Located in `static/shared/js/feature-store.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Usage in templates
|
||||||
|
$store.features.has('analytics_dashboard') // Check feature
|
||||||
|
$store.features.loaded // Loading state
|
||||||
|
$store.features.getFeature('advanced_api') // Get feature details
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alpine.js Upgrade Store
|
||||||
|
|
||||||
|
Located in `static/shared/js/upgrade-prompts.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Usage in templates
|
||||||
|
$store.upgrade.shouldShowLimitWarning('orders')
|
||||||
|
$store.upgrade.getUsageString('products')
|
||||||
|
$store.upgrade.hasUpgradeRecommendation
|
||||||
|
```
|
||||||
|
|
||||||
|
### Jinja2 Macros
|
||||||
|
|
||||||
|
Located in `app/templates/shared/macros/feature_gate.html`:
|
||||||
|
|
||||||
|
#### Feature Gate Container
|
||||||
|
```jinja2
|
||||||
|
{% from "shared/macros/feature_gate.html" import feature_gate %}
|
||||||
|
|
||||||
|
{% call feature_gate("analytics_dashboard") %}
|
||||||
|
<div>Analytics content here - only visible if feature available</div>
|
||||||
|
{% endcall %}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Feature Locked Card
|
||||||
|
```jinja2
|
||||||
|
{% from "shared/macros/feature_gate.html" import feature_locked %}
|
||||||
|
|
||||||
|
{{ feature_locked("advanced_analytics", "Advanced Analytics", "Get deeper insights") }}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Upgrade Banner
|
||||||
|
```jinja2
|
||||||
|
{% from "shared/macros/feature_gate.html" import upgrade_banner %}
|
||||||
|
|
||||||
|
{{ upgrade_banner("custom_domain") }}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Usage Limit Warning
|
||||||
|
```jinja2
|
||||||
|
{% from "shared/macros/feature_gate.html" import limit_warning %}
|
||||||
|
|
||||||
|
{{ limit_warning("orders") }} {# Shows warning when approaching limit #}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Usage Progress Bar
|
||||||
|
```jinja2
|
||||||
|
{% from "shared/macros/feature_gate.html" import usage_bar %}
|
||||||
|
|
||||||
|
{{ usage_bar("products", "Products") }}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Tier Badge
|
||||||
|
```jinja2
|
||||||
|
{% from "shared/macros/feature_gate.html" import tier_badge %}
|
||||||
|
|
||||||
|
{{ tier_badge() }} {# Shows current tier as colored badge #}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Store Dashboard Integration
|
||||||
|
|
||||||
|
The store dashboard (`/store/{code}/dashboard`) now includes:
|
||||||
|
|
||||||
|
1. **Tier Badge**: Shows current subscription tier in header
|
||||||
|
2. **Usage Bars**: Visual progress bars for orders, products, team members
|
||||||
|
3. **Upgrade Prompts**: Contextual upgrade recommendations when approaching limits
|
||||||
|
4. **Feature Gates**: Locked sections for premium features
|
||||||
|
|
||||||
|
## Admin Features Page
|
||||||
|
|
||||||
|
Located at `/admin/features`:
|
||||||
|
|
||||||
|
- View all 30 features in categorized table
|
||||||
|
- Toggle features on/off globally
|
||||||
|
- Filter by category
|
||||||
|
- Search by name/code
|
||||||
|
- View tier requirements
|
||||||
|
|
||||||
|
## Admin Tier Management UI
|
||||||
|
|
||||||
|
Located at `/admin/subscription-tiers`:
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
The subscription tiers admin page provides full CRUD functionality for managing subscription tiers and their feature assignments.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
1. **Stats Cards**: Display total tiers, active tiers, public tiers, and estimated MRR
|
||||||
|
2. **Tier Table**: Sortable list of all tiers with:
|
||||||
|
- Display order
|
||||||
|
- Code (colored badge by tier)
|
||||||
|
- Name
|
||||||
|
- Monthly/Annual pricing
|
||||||
|
- Feature count
|
||||||
|
- Status (Active/Private/Inactive)
|
||||||
|
- Actions (Edit Features, Edit, Activate/Deactivate)
|
||||||
|
|
||||||
|
3. **Create/Edit Modal**: Form with all tier fields:
|
||||||
|
- Code and Name
|
||||||
|
- Monthly and Annual pricing (in cents)
|
||||||
|
- Display order
|
||||||
|
- Stripe IDs (optional)
|
||||||
|
- Description
|
||||||
|
- Active/Public toggles
|
||||||
|
|
||||||
|
4. **Feature Assignment Slide-over Panel**:
|
||||||
|
- Opens when clicking the puzzle-piece icon
|
||||||
|
- Shows all features grouped by category
|
||||||
|
- Binary features: checkbox selection with Select all/Deselect all per category
|
||||||
|
- Quantitative features: checkbox + numeric limit input for `limit_value`
|
||||||
|
- Feature count in footer
|
||||||
|
- Save to update tier's feature assignments via `TierFeatureLimitEntry[]`
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `app/templates/admin/subscription-tiers.html` | Page template |
|
||||||
|
| `static/admin/js/subscription-tiers.js` | Alpine.js component |
|
||||||
|
| `app/routes/admin_pages.py` | Route registration |
|
||||||
|
|
||||||
|
### API Endpoints Used
|
||||||
|
|
||||||
|
| Action | Method | Endpoint |
|
||||||
|
|--------|--------|----------|
|
||||||
|
| Load tiers | GET | `/api/v1/admin/subscriptions/tiers` |
|
||||||
|
| Load stats | GET | `/api/v1/admin/subscriptions/stats` |
|
||||||
|
| Create tier | POST | `/api/v1/admin/subscriptions/tiers` |
|
||||||
|
| Update tier | PATCH | `/api/v1/admin/subscriptions/tiers/{code}` |
|
||||||
|
| Delete tier | DELETE | `/api/v1/admin/subscriptions/tiers/{code}` |
|
||||||
|
| Load feature catalog | GET | `/api/v1/admin/subscriptions/features/catalog` |
|
||||||
|
| Get tier feature limits | GET | `/api/v1/admin/subscriptions/features/tiers/{code}/limits` |
|
||||||
|
| Update tier feature limits | PUT | `/api/v1/admin/subscriptions/features/tiers/{code}/limits` |
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
The features are seeded via Alembic migration:
|
||||||
|
|
||||||
|
```
|
||||||
|
alembic/versions/n2c3d4e5f6a7_add_features_table.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates:
|
||||||
|
- `features` table with 30 default features
|
||||||
|
- `store_feature_overrides` table for per-store exceptions
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Unit tests located in:
|
||||||
|
- `tests/unit/services/test_feature_service.py`
|
||||||
|
- `tests/unit/services/test_usage_service.py`
|
||||||
|
|
||||||
|
Run tests:
|
||||||
|
```bash
|
||||||
|
pytest tests/unit/services/test_feature_service.py -v
|
||||||
|
pytest tests/unit/services/test_usage_service.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Compliance
|
||||||
|
|
||||||
|
All JavaScript files follow architecture rules:
|
||||||
|
- JS-003: Alpine components use `store*` naming convention
|
||||||
|
- JS-005: Init guards prevent duplicate initialization
|
||||||
|
- JS-006: Async operations have try/catch error handling
|
||||||
|
- JS-008: API calls use `apiClient` (not raw `fetch()`)
|
||||||
|
- JS-009: Notifications use `Utils.showToast()`
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Subscription Billing](subscription-system.md) - Core subscription system
|
||||||
|
- [Subscription Workflow Plan](subscription-workflow.md) - Implementation roadmap
|
||||||
74
app/modules/billing/docs/index.md
Normal file
74
app/modules/billing/docs/index.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Billing & Subscriptions
|
||||||
|
|
||||||
|
Core subscription management, tier limits, store billing, and invoice history. Provides tier-based feature gating used throughout the platform. Uses the payments module for actual payment processing.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| Code | `billing` |
|
||||||
|
| Classification | Core |
|
||||||
|
| Dependencies | `payments` |
|
||||||
|
| Status | Active |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- `subscription_management` — Subscription lifecycle management
|
||||||
|
- `billing_history` — Billing and payment history
|
||||||
|
- `invoice_generation` — Automatic invoice generation
|
||||||
|
- `subscription_analytics` — Subscription metrics and analytics
|
||||||
|
- `trial_management` — Free trial period management
|
||||||
|
- `limit_overrides` — Per-store tier limit overrides
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
| Permission | Description |
|
||||||
|
|------------|-------------|
|
||||||
|
| `billing.view_tiers` | View subscription tiers |
|
||||||
|
| `billing.manage_tiers` | Manage subscription tiers |
|
||||||
|
| `billing.view_subscriptions` | View subscriptions |
|
||||||
|
| `billing.manage_subscriptions` | Manage subscriptions |
|
||||||
|
| `billing.view_invoices` | View invoices |
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
See [Data Model](data-model.md) for full entity relationships.
|
||||||
|
|
||||||
|
- **SubscriptionTier** — Tier definitions with Stripe price IDs
|
||||||
|
- **TierFeatureLimit** — Per-tier feature limits (feature_code + limit_value)
|
||||||
|
- **MerchantSubscription** — Per-merchant+platform subscription state
|
||||||
|
- **MerchantFeatureOverride** — Per-merchant feature limit overrides
|
||||||
|
- **AddOnProduct / StoreAddOn** — Purchasable add-ons
|
||||||
|
- **BillingHistory** — Invoice and payment records
|
||||||
|
- **StripeWebhookEvent** — Webhook idempotency tracking
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `*` | `/api/v1/admin/billing/*` | Admin billing management |
|
||||||
|
| `*` | `/api/v1/admin/features/*` | Feature/tier management |
|
||||||
|
| `*` | `/api/v1/merchant/billing/*` | Merchant billing endpoints |
|
||||||
|
| `*` | `/api/v1/platform/billing/*` | Platform-wide billing stats |
|
||||||
|
|
||||||
|
## Scheduled Tasks
|
||||||
|
|
||||||
|
| Task | Schedule | Description |
|
||||||
|
|------|----------|-------------|
|
||||||
|
| `billing.reset_period_counters` | Daily 00:05 | Reset period-based usage counters |
|
||||||
|
| `billing.check_trial_expirations` | Daily 01:00 | Check and handle expired trials |
|
||||||
|
| `billing.sync_stripe_status` | Hourly :30 | Sync subscription status with Stripe |
|
||||||
|
| `billing.cleanup_stale_subscriptions` | Weekly Sunday 03:00 | Clean up stale subscription records |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configured via Stripe environment variables and tier definitions in the admin panel.
|
||||||
|
|
||||||
|
## Additional Documentation
|
||||||
|
|
||||||
|
- [Data Model](data-model.md) — Entity relationships and database schema
|
||||||
|
- [Subscription System](subscription-system.md) — Architecture, feature providers, API reference
|
||||||
|
- [Feature Gating](feature-gating.md) — Tier-based feature access control and UI integration
|
||||||
|
- [Tier Management](tier-management.md) — Admin guide for managing subscription tiers
|
||||||
|
- [Subscription Workflow](subscription-workflow.md) — Subscription lifecycle and implementation phases
|
||||||
|
- [Stripe Integration](stripe-integration.md) — Stripe Connect setup, webhooks, payment flow
|
||||||
617
app/modules/billing/docs/stripe-integration.md
Normal file
617
app/modules/billing/docs/stripe-integration.md
Normal file
@@ -0,0 +1,617 @@
|
|||||||
|
# Stripe Payment Integration - Multi-Tenant Ecommerce Platform
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
The payment integration uses **Stripe Connect** to handle multi-store payments, enabling:
|
||||||
|
- Each store to receive payments directly
|
||||||
|
- Platform to collect fees/commissions
|
||||||
|
- Proper financial isolation between stores
|
||||||
|
- Compliance with financial regulations
|
||||||
|
|
||||||
|
## Payment Models
|
||||||
|
|
||||||
|
### Database Models
|
||||||
|
|
||||||
|
```python
|
||||||
|
# models/database/payment.py
|
||||||
|
from decimal import Decimal
|
||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, Numeric
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.core.database import Base
|
||||||
|
from .base import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class StorePaymentConfig(Base, TimestampMixin):
|
||||||
|
"""Store-specific payment configuration."""
|
||||||
|
__tablename__ = "store_payment_configs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
store_id = Column(Integer, ForeignKey("stores.id"), nullable=False, unique=True)
|
||||||
|
|
||||||
|
# Stripe Connect configuration
|
||||||
|
stripe_account_id = Column(String(255)) # Stripe Connect account ID
|
||||||
|
stripe_account_status = Column(String(50)) # pending, active, restricted, inactive
|
||||||
|
stripe_onboarding_url = Column(Text) # Onboarding link for store
|
||||||
|
stripe_dashboard_url = Column(Text) # Store's Stripe dashboard
|
||||||
|
|
||||||
|
# Payment settings
|
||||||
|
accepts_payments = Column(Boolean, default=False)
|
||||||
|
currency = Column(String(3), default="EUR")
|
||||||
|
platform_fee_percentage = Column(Numeric(5, 2), default=2.5) # Platform commission
|
||||||
|
|
||||||
|
# Payout settings
|
||||||
|
payout_schedule = Column(String(20), default="weekly") # daily, weekly, monthly
|
||||||
|
minimum_payout = Column(Numeric(10, 2), default=20.00)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
store = relationship("Store", back_populates="payment_config")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<StorePaymentConfig(store_id={self.store_id}, stripe_account_id='{self.stripe_account_id}')>"
|
||||||
|
|
||||||
|
|
||||||
|
class Payment(Base, TimestampMixin):
|
||||||
|
"""Payment records for orders."""
|
||||||
|
__tablename__ = "payments"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
store_id = Column(Integer, ForeignKey("stores.id"), nullable=False)
|
||||||
|
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||||
|
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False)
|
||||||
|
|
||||||
|
# Stripe payment details
|
||||||
|
stripe_payment_intent_id = Column(String(255), unique=True, index=True)
|
||||||
|
stripe_charge_id = Column(String(255), index=True)
|
||||||
|
stripe_transfer_id = Column(String(255)) # Transfer to store account
|
||||||
|
|
||||||
|
# Payment amounts (in cents to avoid floating point issues)
|
||||||
|
amount_total = Column(Integer, nullable=False) # Total customer payment
|
||||||
|
amount_store = Column(Integer, nullable=False) # Amount to store
|
||||||
|
amount_platform_fee = Column(Integer, nullable=False) # Platform commission
|
||||||
|
currency = Column(String(3), default="EUR")
|
||||||
|
|
||||||
|
# Payment status
|
||||||
|
status = Column(String(50), nullable=False) # pending, succeeded, failed, refunded
|
||||||
|
payment_method = Column(String(50)) # card, bank_transfer, etc.
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
stripe_metadata = Column(Text) # JSON string of Stripe metadata
|
||||||
|
failure_reason = Column(Text)
|
||||||
|
refund_reason = Column(Text)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
paid_at = Column(DateTime)
|
||||||
|
refunded_at = Column(DateTime)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
store = relationship("Store")
|
||||||
|
order = relationship("Order", back_populates="payment")
|
||||||
|
customer = relationship("Customer")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Payment(id={self.id}, order_id={self.order_id}, status='{self.status}')>"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def amount_total_euros(self):
|
||||||
|
"""Convert cents to euros for display."""
|
||||||
|
return self.amount_total / 100
|
||||||
|
|
||||||
|
@property
|
||||||
|
def amount_store_euros(self):
|
||||||
|
"""Convert cents to euros for display."""
|
||||||
|
return self.amount_store / 100
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentMethod(Base, TimestampMixin):
|
||||||
|
"""Saved customer payment methods."""
|
||||||
|
__tablename__ = "payment_methods"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
store_id = Column(Integer, ForeignKey("stores.id"), nullable=False)
|
||||||
|
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False)
|
||||||
|
|
||||||
|
# Stripe payment method details
|
||||||
|
stripe_payment_method_id = Column(String(255), nullable=False, index=True)
|
||||||
|
payment_method_type = Column(String(50), nullable=False) # card, sepa_debit, etc.
|
||||||
|
|
||||||
|
# Card details (if applicable)
|
||||||
|
card_brand = Column(String(50)) # visa, mastercard, etc.
|
||||||
|
card_last4 = Column(String(4))
|
||||||
|
card_exp_month = Column(Integer)
|
||||||
|
card_exp_year = Column(Integer)
|
||||||
|
|
||||||
|
# Settings
|
||||||
|
is_default = Column(Boolean, default=False)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
store = relationship("Store")
|
||||||
|
customer = relationship("Customer")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<PaymentMethod(id={self.id}, customer_id={self.customer_id}, type='{self.payment_method_type}')>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updated Order Model
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Update models/database/order.py
|
||||||
|
class Order(Base, TimestampMixin):
|
||||||
|
# ... existing fields ...
|
||||||
|
|
||||||
|
# Payment integration
|
||||||
|
payment_status = Column(String(50), default="pending") # pending, paid, failed, refunded
|
||||||
|
payment_intent_id = Column(String(255)) # Stripe PaymentIntent ID
|
||||||
|
total_amount_cents = Column(Integer, nullable=False) # Amount in cents
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
payment = relationship("Payment", back_populates="order", uselist=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_amount_euros(self):
|
||||||
|
"""Convert cents to euros for display."""
|
||||||
|
return self.total_amount_cents / 100 if self.total_amount_cents else 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Payment Service Integration
|
||||||
|
|
||||||
|
### Stripe Service
|
||||||
|
|
||||||
|
```python
|
||||||
|
# services/payment_service.py
|
||||||
|
import stripe
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Dict, Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from models.database.payment import Payment, StorePaymentConfig
|
||||||
|
from models.database.order import Order
|
||||||
|
from models.database.store import Store
|
||||||
|
from app.exceptions.payment import *
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Configure Stripe
|
||||||
|
stripe.api_key = settings.stripe_secret_key
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentService:
|
||||||
|
"""Service for handling Stripe payments in multi-tenant environment."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def create_payment_intent(
|
||||||
|
self,
|
||||||
|
store_id: int,
|
||||||
|
order_id: int,
|
||||||
|
amount_euros: Decimal,
|
||||||
|
customer_email: str,
|
||||||
|
metadata: Optional[Dict] = None
|
||||||
|
) -> Dict:
|
||||||
|
"""Create Stripe PaymentIntent for store order."""
|
||||||
|
|
||||||
|
# Get store payment configuration
|
||||||
|
payment_config = self.get_store_payment_config(store_id)
|
||||||
|
if not payment_config.accepts_payments:
|
||||||
|
raise PaymentNotConfiguredException(f"Store {store_id} not configured for payments")
|
||||||
|
|
||||||
|
# Calculate amounts
|
||||||
|
amount_cents = int(amount_euros * 100)
|
||||||
|
platform_fee_cents = int(amount_cents * (payment_config.platform_fee_percentage / 100))
|
||||||
|
store_amount_cents = amount_cents - platform_fee_cents
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create PaymentIntent with Stripe Connect
|
||||||
|
payment_intent = stripe.PaymentIntent.create(
|
||||||
|
amount=amount_cents,
|
||||||
|
currency=payment_config.currency.lower(),
|
||||||
|
application_fee_amount=platform_fee_cents,
|
||||||
|
transfer_data={
|
||||||
|
'destination': payment_config.stripe_account_id,
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
'store_id': str(store_id),
|
||||||
|
'order_id': str(order_id),
|
||||||
|
'platform': 'multi_tenant_ecommerce',
|
||||||
|
**(metadata or {})
|
||||||
|
},
|
||||||
|
receipt_email=customer_email,
|
||||||
|
description=f"Order payment for store {store_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create payment record
|
||||||
|
payment = Payment(
|
||||||
|
store_id=store_id,
|
||||||
|
order_id=order_id,
|
||||||
|
customer_id=self.get_order_customer_id(order_id),
|
||||||
|
stripe_payment_intent_id=payment_intent.id,
|
||||||
|
amount_total=amount_cents,
|
||||||
|
amount_store=store_amount_cents,
|
||||||
|
amount_platform_fee=platform_fee_cents,
|
||||||
|
currency=payment_config.currency,
|
||||||
|
status='pending',
|
||||||
|
stripe_metadata=json.dumps(payment_intent.metadata)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(payment)
|
||||||
|
|
||||||
|
# Update order
|
||||||
|
order = self.db.query(Order).filter(Order.id == order_id).first()
|
||||||
|
if order:
|
||||||
|
order.payment_intent_id = payment_intent.id
|
||||||
|
order.payment_status = 'pending'
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'payment_intent_id': payment_intent.id,
|
||||||
|
'client_secret': payment_intent.client_secret,
|
||||||
|
'amount_total': amount_euros,
|
||||||
|
'amount_store': store_amount_cents / 100,
|
||||||
|
'platform_fee': platform_fee_cents / 100,
|
||||||
|
'currency': payment_config.currency
|
||||||
|
}
|
||||||
|
|
||||||
|
except stripe.error.StripeError as e:
|
||||||
|
logger.error(f"Stripe error creating PaymentIntent: {e}")
|
||||||
|
raise PaymentProcessingException(f"Payment processing failed: {str(e)}")
|
||||||
|
|
||||||
|
def confirm_payment(self, payment_intent_id: str) -> Payment:
|
||||||
|
"""Confirm payment and update records."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Retrieve PaymentIntent from Stripe
|
||||||
|
payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id)
|
||||||
|
|
||||||
|
# Find payment record
|
||||||
|
payment = self.db.query(Payment).filter(
|
||||||
|
Payment.stripe_payment_intent_id == payment_intent_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not payment:
|
||||||
|
raise PaymentNotFoundException(f"Payment not found for intent {payment_intent_id}")
|
||||||
|
|
||||||
|
# Update payment status based on Stripe status
|
||||||
|
if payment_intent.status == 'succeeded':
|
||||||
|
payment.status = 'succeeded'
|
||||||
|
payment.stripe_charge_id = payment_intent.charges.data[0].id if payment_intent.charges.data else None
|
||||||
|
payment.paid_at = datetime.utcnow()
|
||||||
|
|
||||||
|
# Update order status
|
||||||
|
order = self.db.query(Order).filter(Order.id == payment.order_id).first()
|
||||||
|
if order:
|
||||||
|
order.payment_status = 'paid'
|
||||||
|
order.status = 'processing' # Move order to processing
|
||||||
|
|
||||||
|
elif payment_intent.status == 'payment_failed':
|
||||||
|
payment.status = 'failed'
|
||||||
|
payment.failure_reason = payment_intent.last_payment_error.message if payment_intent.last_payment_error else "Unknown error"
|
||||||
|
|
||||||
|
# Update order status
|
||||||
|
order = self.db.query(Order).filter(Order.id == payment.order_id).first()
|
||||||
|
if order:
|
||||||
|
order.payment_status = 'failed'
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
return payment
|
||||||
|
|
||||||
|
except stripe.error.StripeError as e:
|
||||||
|
logger.error(f"Stripe error confirming payment: {e}")
|
||||||
|
raise PaymentProcessingException(f"Payment confirmation failed: {str(e)}")
|
||||||
|
|
||||||
|
def create_store_stripe_account(self, store_id: int, store_data: Dict) -> str:
|
||||||
|
"""Create Stripe Connect account for store."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create Stripe Connect Express account
|
||||||
|
account = stripe.Account.create(
|
||||||
|
type='express',
|
||||||
|
country='LU', # Luxembourg
|
||||||
|
email=store_data.get('business_email'),
|
||||||
|
capabilities={
|
||||||
|
'card_payments': {'requested': True},
|
||||||
|
'transfers': {'requested': True},
|
||||||
|
},
|
||||||
|
business_type='merchant',
|
||||||
|
merchant={
|
||||||
|
'name': store_data.get('business_name'),
|
||||||
|
'phone': store_data.get('business_phone'),
|
||||||
|
'address': {
|
||||||
|
'line1': store_data.get('address_line1'),
|
||||||
|
'city': store_data.get('city'),
|
||||||
|
'postal_code': store_data.get('postal_code'),
|
||||||
|
'country': 'LU'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
'store_id': str(store_id),
|
||||||
|
'platform': 'multi_tenant_ecommerce'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update or create payment configuration
|
||||||
|
payment_config = self.get_or_create_store_payment_config(store_id)
|
||||||
|
payment_config.stripe_account_id = account.id
|
||||||
|
payment_config.stripe_account_status = account.charges_enabled and account.payouts_enabled and 'active' or 'pending'
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
return account.id
|
||||||
|
|
||||||
|
except stripe.error.StripeError as e:
|
||||||
|
logger.error(f"Stripe error creating account: {e}")
|
||||||
|
raise PaymentConfigurationException(f"Failed to create payment account: {str(e)}")
|
||||||
|
|
||||||
|
def create_onboarding_link(self, store_id: int) -> str:
|
||||||
|
"""Create Stripe onboarding link for store."""
|
||||||
|
|
||||||
|
payment_config = self.get_store_payment_config(store_id)
|
||||||
|
if not payment_config.stripe_account_id:
|
||||||
|
raise PaymentNotConfiguredException("Store does not have Stripe account")
|
||||||
|
|
||||||
|
try:
|
||||||
|
account_link = stripe.AccountLink.create(
|
||||||
|
account=payment_config.stripe_account_id,
|
||||||
|
refresh_url=f"{settings.frontend_url}/store/admin/payments/refresh",
|
||||||
|
return_url=f"{settings.frontend_url}/store/admin/payments/success",
|
||||||
|
type='account_onboarding',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update onboarding URL
|
||||||
|
payment_config.stripe_onboarding_url = account_link.url
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
return account_link.url
|
||||||
|
|
||||||
|
except stripe.error.StripeError as e:
|
||||||
|
logger.error(f"Stripe error creating onboarding link: {e}")
|
||||||
|
raise PaymentConfigurationException(f"Failed to create onboarding link: {str(e)}")
|
||||||
|
|
||||||
|
def get_store_payment_config(self, store_id: int) -> StorePaymentConfig:
|
||||||
|
"""Get store payment configuration."""
|
||||||
|
config = self.db.query(StorePaymentConfig).filter(
|
||||||
|
StorePaymentConfig.store_id == store_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not config:
|
||||||
|
raise PaymentNotConfiguredException(f"No payment configuration for store {store_id}")
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
def webhook_handler(self, event_type: str, event_data: Dict) -> None:
|
||||||
|
"""Handle Stripe webhook events."""
|
||||||
|
|
||||||
|
if event_type == 'payment_intent.succeeded':
|
||||||
|
payment_intent_id = event_data['object']['id']
|
||||||
|
self.confirm_payment(payment_intent_id)
|
||||||
|
|
||||||
|
elif event_type == 'payment_intent.payment_failed':
|
||||||
|
payment_intent_id = event_data['object']['id']
|
||||||
|
self.confirm_payment(payment_intent_id)
|
||||||
|
|
||||||
|
elif event_type == 'account.updated':
|
||||||
|
# Update store account status
|
||||||
|
account_id = event_data['object']['id']
|
||||||
|
self.update_store_account_status(account_id, event_data['object'])
|
||||||
|
|
||||||
|
# Add more webhook handlers as needed
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Payment APIs
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app/api/v1/store/payments.py
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from middleware.store_context import require_store_context
|
||||||
|
from models.database.store import Store
|
||||||
|
from services.payment_service import PaymentService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/payments", tags=["store-payments"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
async def get_payment_config(
|
||||||
|
store: Store = Depends(require_store_context()),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get store payment configuration."""
|
||||||
|
payment_service = PaymentService(db)
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = payment_service.get_store_payment_config(store.id)
|
||||||
|
return {
|
||||||
|
"stripe_account_id": config.stripe_account_id,
|
||||||
|
"account_status": config.stripe_account_status,
|
||||||
|
"accepts_payments": config.accepts_payments,
|
||||||
|
"currency": config.currency,
|
||||||
|
"platform_fee_percentage": float(config.platform_fee_percentage),
|
||||||
|
"needs_onboarding": config.stripe_account_status != 'active'
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return {
|
||||||
|
"stripe_account_id": None,
|
||||||
|
"account_status": "not_configured",
|
||||||
|
"accepts_payments": False,
|
||||||
|
"needs_setup": True
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/setup")
|
||||||
|
async def setup_payments(
|
||||||
|
setup_data: dict,
|
||||||
|
store: Store = Depends(require_store_context()),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Set up Stripe payments for store."""
|
||||||
|
payment_service = PaymentService(db)
|
||||||
|
|
||||||
|
store_data = {
|
||||||
|
"business_name": store.name,
|
||||||
|
"business_email": store.business_email,
|
||||||
|
"business_phone": store.business_phone,
|
||||||
|
**setup_data
|
||||||
|
}
|
||||||
|
|
||||||
|
account_id = payment_service.create_store_stripe_account(store.id, store_data)
|
||||||
|
onboarding_url = payment_service.create_onboarding_link(store.id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"stripe_account_id": account_id,
|
||||||
|
"onboarding_url": onboarding_url,
|
||||||
|
"message": "Payment setup initiated. Complete onboarding to accept payments."
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# app/api/v1/platform/stores/payments.py
|
||||||
|
@router.post("/{store_id}/payments/create-intent")
|
||||||
|
async def create_payment_intent(
|
||||||
|
store_id: int,
|
||||||
|
payment_data: dict,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Create payment intent for customer order."""
|
||||||
|
payment_service = PaymentService(db)
|
||||||
|
|
||||||
|
payment_intent = payment_service.create_payment_intent(
|
||||||
|
store_id=store_id,
|
||||||
|
order_id=payment_data['order_id'],
|
||||||
|
amount_euros=Decimal(str(payment_data['amount'])),
|
||||||
|
customer_email=payment_data['customer_email'],
|
||||||
|
metadata=payment_data.get('metadata', {})
|
||||||
|
)
|
||||||
|
|
||||||
|
return payment_intent
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/webhooks/stripe")
|
||||||
|
async def stripe_webhook(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Handle Stripe webhook events."""
|
||||||
|
import stripe
|
||||||
|
|
||||||
|
payload = await request.body()
|
||||||
|
sig_header = request.headers.get('stripe-signature')
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = stripe.Webhook.construct_event(
|
||||||
|
payload, sig_header, settings.stripe_webhook_secret
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid payload")
|
||||||
|
except stripe.error.SignatureVerificationError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid signature")
|
||||||
|
|
||||||
|
payment_service = PaymentService(db)
|
||||||
|
payment_service.webhook_handler(event['type'], event['data'])
|
||||||
|
|
||||||
|
return {"status": "success"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Integration
|
||||||
|
|
||||||
|
### Checkout Process
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// frontend/js/storefront/checkout.js
|
||||||
|
class CheckoutManager {
|
||||||
|
constructor(storeId) {
|
||||||
|
this.storeId = storeId;
|
||||||
|
this.stripe = Stripe(STRIPE_PUBLISHABLE_KEY);
|
||||||
|
this.elements = this.stripe.elements();
|
||||||
|
this.paymentElement = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializePayment(orderData) {
|
||||||
|
// Create payment intent
|
||||||
|
const response = await fetch(`/api/v1/platform/stores/${this.storeId}/payments/create-intent`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
order_id: orderData.orderId,
|
||||||
|
amount: orderData.total,
|
||||||
|
customer_email: orderData.customerEmail
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const { client_secret, amount_total, platform_fee } = await response.json();
|
||||||
|
|
||||||
|
// Display payment breakdown
|
||||||
|
this.displayPaymentBreakdown(amount_total, platform_fee);
|
||||||
|
|
||||||
|
// Create payment element
|
||||||
|
this.paymentElement = this.elements.create('payment', {
|
||||||
|
clientSecret: client_secret
|
||||||
|
});
|
||||||
|
|
||||||
|
this.paymentElement.mount('#payment-element');
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmPayment(orderData) {
|
||||||
|
const { error } = await this.stripe.confirmPayment({
|
||||||
|
elements: this.elements,
|
||||||
|
confirmParams: {
|
||||||
|
return_url: `${window.location.origin}/storefront/order-confirmation`,
|
||||||
|
receipt_email: orderData.customerEmail
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
this.showPaymentError(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updated Workflow Integration
|
||||||
|
|
||||||
|
### Enhanced Customer Purchase Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
Customer adds products to cart
|
||||||
|
↓
|
||||||
|
Customer proceeds to checkout
|
||||||
|
↓
|
||||||
|
System creates Order (payment_status: pending)
|
||||||
|
↓
|
||||||
|
Frontend calls POST /api/v1/platform/stores/{store_id}/payments/create-intent
|
||||||
|
↓
|
||||||
|
PaymentService creates Stripe PaymentIntent with store destination
|
||||||
|
↓
|
||||||
|
Customer completes payment with Stripe Elements
|
||||||
|
↓
|
||||||
|
Stripe webhook confirms payment
|
||||||
|
↓
|
||||||
|
PaymentService updates Order (payment_status: paid, status: processing)
|
||||||
|
↓
|
||||||
|
Store receives order for fulfillment
|
||||||
|
```
|
||||||
|
|
||||||
|
### Payment Configuration Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
Store accesses payment settings
|
||||||
|
↓
|
||||||
|
POST /api/v1/store/payments/setup
|
||||||
|
↓
|
||||||
|
System creates Stripe Connect account
|
||||||
|
↓
|
||||||
|
Store completes Stripe onboarding
|
||||||
|
↓
|
||||||
|
Webhook updates account status to 'active'
|
||||||
|
↓
|
||||||
|
Store can now accept payments
|
||||||
|
```
|
||||||
|
|
||||||
|
This integration provides secure, compliant payment processing while maintaining store isolation and enabling proper revenue distribution between stores and the platform.
|
||||||
182
app/modules/billing/docs/subscription-system.md
Normal file
182
app/modules/billing/docs/subscription-system.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# Subscription & Billing System
|
||||||
|
|
||||||
|
The platform provides a comprehensive subscription and billing system for managing merchant subscriptions, feature-based usage limits, and payments through Stripe.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The billing system enables:
|
||||||
|
|
||||||
|
- **Subscription Tiers**: Database-driven tier definitions with configurable feature limits
|
||||||
|
- **Feature Provider Pattern**: Modules declare features and usage via `FeatureProviderProtocol`, aggregated by `FeatureAggregatorService`
|
||||||
|
- **Dynamic Usage Tracking**: Quantitative features (orders, products, team members) tracked per merchant with dynamic limits from `TierFeatureLimit`
|
||||||
|
- **Binary Feature Gating**: Toggle-based features (analytics, API access, white-label) controlled per tier
|
||||||
|
- **Merchant-Level Billing**: Subscriptions are per merchant+platform, not per store
|
||||||
|
- **Stripe Integration**: Checkout sessions, customer portal, and webhook handling
|
||||||
|
- **Add-ons**: Optional purchasable items (domains, SSL, email packages)
|
||||||
|
- **Capacity Forecasting**: Growth trends and scaling recommendations
|
||||||
|
- **Background Jobs**: Automated subscription lifecycle management
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Key Concepts
|
||||||
|
|
||||||
|
The billing system uses a **feature provider pattern** where:
|
||||||
|
|
||||||
|
1. **`TierFeatureLimit`** replaces hardcoded tier columns (`orders_per_month`, `products_limit`, `team_members`). Each feature limit is a row linking a tier to a feature code with a `limit_value`.
|
||||||
|
2. **`MerchantFeatureOverride`** provides per-merchant exceptions to tier defaults.
|
||||||
|
3. **Module feature providers** implement `FeatureProviderProtocol` to supply current usage data.
|
||||||
|
4. **`FeatureAggregatorService`** collects usage from all providers and combines it with tier limits to produce `FeatureSummary` records.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Frontend Page Request │
|
||||||
|
│ (Store Billing, Admin Subscriptions, Admin Store Detail) │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ FeatureAggregatorService │
|
||||||
|
│ (app/modules/billing/services/feature_service.py) │
|
||||||
|
│ │
|
||||||
|
│ • Collects feature providers from all enabled modules │
|
||||||
|
│ • Queries TierFeatureLimit for limit values │
|
||||||
|
│ • Queries MerchantFeatureOverride for per-merchant limits │
|
||||||
|
│ • Calls provider.get_current_usage() for live counts │
|
||||||
|
│ • Returns FeatureSummary[] with current/limit/percentage │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
|
||||||
|
│ catalog module │ │ orders module │ │ tenancy module │
|
||||||
|
│ products count │ │ orders count │ │ team members │
|
||||||
|
└────────────────┘ └────────────────┘ └────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Feature Types
|
||||||
|
|
||||||
|
| Type | Description | Example |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| **Quantitative** | Has a numeric limit with usage tracking | `max_products` (limit: 200, current: 150) |
|
||||||
|
| **Binary** | Toggle-based, either enabled or disabled | `analytics_dashboard` (enabled/disabled) |
|
||||||
|
|
||||||
|
### FeatureSummary Dataclass
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class FeatureSummary:
|
||||||
|
code: str # e.g., "max_products"
|
||||||
|
name_key: str # i18n key for display name
|
||||||
|
limit: int | None # None = unlimited
|
||||||
|
current: int # Current usage count
|
||||||
|
remaining: int # Remaining before limit
|
||||||
|
percent_used: float # 0.0 to 100.0
|
||||||
|
feature_type: str # "quantitative" or "binary"
|
||||||
|
scope: str # "tier" or "merchant_override"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Services
|
||||||
|
|
||||||
|
| Service | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `FeatureAggregatorService` | Aggregates usage from module providers, resolves tier limits + overrides |
|
||||||
|
| `BillingService` | Subscription operations, checkout, portal |
|
||||||
|
| `SubscriptionService` | Subscription CRUD, tier lookups |
|
||||||
|
| `AdminSubscriptionService` | Admin subscription management |
|
||||||
|
| `StripeService` | Core Stripe API operations |
|
||||||
|
| `CapacityForecastService` | Growth trends, projections |
|
||||||
|
|
||||||
|
### Background Tasks
|
||||||
|
|
||||||
|
| Task | Schedule | Purpose |
|
||||||
|
|------|----------|---------|
|
||||||
|
| `reset_period_counters` | Daily | Reset order counters at period end |
|
||||||
|
| `check_trial_expirations` | Daily | Expire trials without payment method |
|
||||||
|
| `sync_stripe_status` | Hourly | Sync status with Stripe |
|
||||||
|
| `cleanup_stale_subscriptions` | Weekly | Clean up old cancelled subscriptions |
|
||||||
|
| `capture_capacity_snapshot` | Daily | Capture capacity metrics snapshot |
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Store Billing API (`/api/v1/store/billing`)
|
||||||
|
|
||||||
|
| Endpoint | Method | Purpose |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `/subscription` | GET | Current subscription status |
|
||||||
|
| `/tiers` | GET | Available tiers for upgrade |
|
||||||
|
| `/usage` | GET | Dynamic usage metrics (from feature providers) |
|
||||||
|
| `/checkout` | POST | Create Stripe checkout session |
|
||||||
|
| `/portal` | POST | Create Stripe customer portal session |
|
||||||
|
| `/invoices` | GET | Invoice history |
|
||||||
|
| `/change-tier` | POST | Upgrade/downgrade tier |
|
||||||
|
| `/addons` | GET | Available add-on products |
|
||||||
|
| `/my-addons` | GET | Store's purchased add-ons |
|
||||||
|
| `/addons/purchase` | POST | Purchase an add-on |
|
||||||
|
| `/cancel` | POST | Cancel subscription |
|
||||||
|
| `/reactivate` | POST | Reactivate cancelled subscription |
|
||||||
|
|
||||||
|
### Admin Subscription API (`/api/v1/admin/subscriptions`)
|
||||||
|
|
||||||
|
| Endpoint | Method | Purpose |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `/tiers` | GET/POST | List/create tiers |
|
||||||
|
| `/tiers/{code}` | PATCH/DELETE | Update/delete tier |
|
||||||
|
| `/stats` | GET | Subscription statistics |
|
||||||
|
| `/merchants/{id}/platforms/{pid}` | GET/PUT | Get/update merchant subscription |
|
||||||
|
| `/store/{store_id}` | GET | Convenience: subscription + usage for a store |
|
||||||
|
|
||||||
|
### Admin Feature Management API (`/api/v1/admin/subscriptions/features`)
|
||||||
|
|
||||||
|
| Endpoint | Method | Purpose |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `/catalog` | GET | Feature catalog grouped by category |
|
||||||
|
| `/tiers/{code}/limits` | GET/PUT | Get/upsert feature limits for a tier |
|
||||||
|
| `/merchants/{id}/overrides` | GET/PUT | Get/upsert merchant feature overrides |
|
||||||
|
|
||||||
|
## Subscription Tiers
|
||||||
|
|
||||||
|
Tiers are stored in `subscription_tiers` with feature limits in `tier_feature_limits`:
|
||||||
|
|
||||||
|
```
|
||||||
|
SubscriptionTier (essential)
|
||||||
|
├── TierFeatureLimit: max_products = 200
|
||||||
|
├── TierFeatureLimit: max_orders_per_month = 100
|
||||||
|
├── TierFeatureLimit: max_team_members = 1
|
||||||
|
└── TierFeatureLimit: basic_analytics (binary, enabled)
|
||||||
|
|
||||||
|
SubscriptionTier (professional)
|
||||||
|
├── TierFeatureLimit: max_products = NULL (unlimited)
|
||||||
|
├── TierFeatureLimit: max_orders_per_month = 500
|
||||||
|
├── TierFeatureLimit: max_team_members = 3
|
||||||
|
└── TierFeatureLimit: analytics_dashboard (binary, enabled)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Add-ons
|
||||||
|
|
||||||
|
| Code | Name | Category | Price |
|
||||||
|
|------|------|----------|-------|
|
||||||
|
| `domain` | Custom Domain | domain | €15/year |
|
||||||
|
| `ssl_premium` | Premium SSL | ssl | €49/year |
|
||||||
|
| `email_5` | 5 Email Addresses | email | €5/month |
|
||||||
|
| `email_10` | 10 Email Addresses | email | €9/month |
|
||||||
|
| `email_25` | 25 Email Addresses | email | €19/month |
|
||||||
|
|
||||||
|
## Exception Handling
|
||||||
|
|
||||||
|
| Exception | HTTP | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `PaymentSystemNotConfiguredException` | 503 | Stripe not configured |
|
||||||
|
| `TierNotFoundException` | 404 | Invalid tier code |
|
||||||
|
| `StripePriceNotConfiguredException` | 400 | No Stripe price for tier |
|
||||||
|
| `NoActiveSubscriptionException` | 400 | Operation requires subscription |
|
||||||
|
| `SubscriptionNotCancelledException` | 400 | Cannot reactivate active subscription |
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Data Model](data-model.md) — Entity relationships
|
||||||
|
- [Feature Gating](feature-gating.md) — Feature access control and UI integration
|
||||||
|
- [Stripe Integration](stripe-integration.md) — Payment setup
|
||||||
|
- [Tier Management](tier-management.md) — Admin guide for tier management
|
||||||
|
- [Subscription Workflow](subscription-workflow.md) — Subscription lifecycle
|
||||||
|
- [Metrics Provider Pattern](../../architecture/metrics-provider-pattern.md) — Protocol-based metrics
|
||||||
|
- [Capacity Monitoring](../../operations/capacity-monitoring.md) — Monitoring guide
|
||||||
|
- [Capacity Planning](../../architecture/capacity-planning.md) — Infrastructure sizing
|
||||||
454
app/modules/billing/docs/subscription-workflow.md
Normal file
454
app/modules/billing/docs/subscription-workflow.md
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
# Subscription Workflow Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
End-to-end subscription management workflow for stores on the platform.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Store Subscribes to a Tier
|
||||||
|
|
||||||
|
### 1.1 New Store Registration Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Store Registration → Select Tier → Trial Period → Payment Setup → Active Subscription
|
||||||
|
```
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Store creates account (existing flow)
|
||||||
|
2. During onboarding, store selects a tier:
|
||||||
|
- Show tier comparison cards (Essential, Professional, Business, Enterprise)
|
||||||
|
- Highlight features and limits for each tier
|
||||||
|
- Default to 14-day trial on selected tier
|
||||||
|
3. Create `StoreSubscription` record with:
|
||||||
|
- `tier` = selected tier code
|
||||||
|
- `status` = "trial"
|
||||||
|
- `trial_ends_at` = now + 14 days
|
||||||
|
- `period_start` / `period_end` set for trial period
|
||||||
|
4. Before trial ends, prompt store to add payment method
|
||||||
|
5. On payment method added → Create Stripe subscription → Status becomes "active"
|
||||||
|
|
||||||
|
### 1.2 Database Changes Required
|
||||||
|
|
||||||
|
**Add FK relationship to `subscription_tiers`:**
|
||||||
|
```python
|
||||||
|
# StoreSubscription - Add proper FK
|
||||||
|
tier_id = Column(Integer, ForeignKey("subscription_tiers.id"), nullable=True)
|
||||||
|
tier_code = Column(String(20), nullable=False) # Keep for backwards compat
|
||||||
|
|
||||||
|
# Relationship
|
||||||
|
tier_obj = relationship("SubscriptionTier", backref="subscriptions")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Migration:**
|
||||||
|
1. Add `tier_id` column (nullable initially)
|
||||||
|
2. Populate `tier_id` from existing `tier` code values
|
||||||
|
3. Add FK constraint
|
||||||
|
|
||||||
|
### 1.3 API Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/api/v1/store/subscription/tiers` | GET | List available tiers for selection |
|
||||||
|
| `/api/v1/store/subscription/select-tier` | POST | Select tier during onboarding |
|
||||||
|
| `/api/v1/store/subscription/setup-payment` | POST | Create Stripe checkout for payment |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Admin Views Subscription on Store Page
|
||||||
|
|
||||||
|
### 2.1 Store Detail Page Enhancement
|
||||||
|
|
||||||
|
**Location:** `/admin/stores/{store_id}`
|
||||||
|
|
||||||
|
**New Subscription Card:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Subscription [Edit] │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Tier: Professional Status: Active │
|
||||||
|
│ Price: €99/month Since: Jan 15, 2025 │
|
||||||
|
│ Next Billing: Feb 15, 2025 │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Usage This Period │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Orders │ │ Products │ │ Team Members │ │
|
||||||
|
│ │ 234 / 500 │ │ 156 / ∞ │ │ 2 / 3 │ │
|
||||||
|
│ │ ████████░░ │ │ ████████████ │ │ ██████░░░░ │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Add-ons: Custom Domain (mydomain.com), 5 Email Addresses │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Files to Modify
|
||||||
|
|
||||||
|
- `app/templates/admin/store-detail.html` - Add subscription card
|
||||||
|
- `static/admin/js/store-detail.js` - Load subscription data
|
||||||
|
- `app/api/v1/admin/stores.py` - Include subscription in store response
|
||||||
|
|
||||||
|
### 2.3 Admin Quick Actions
|
||||||
|
|
||||||
|
From the store page, admin can:
|
||||||
|
- **Change Tier** - Upgrade/downgrade store
|
||||||
|
- **Override Limits** - Set custom limits (enterprise deals)
|
||||||
|
- **Extend Trial** - Give more trial days
|
||||||
|
- **Cancel Subscription** - With reason
|
||||||
|
- **Manage Add-ons** - Add/remove add-ons
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Tier Upgrade/Downgrade
|
||||||
|
|
||||||
|
### 3.1 Admin-Initiated Change
|
||||||
|
|
||||||
|
**Location:** Admin store page → Subscription card → [Edit] button
|
||||||
|
|
||||||
|
**Modal: Change Subscription Tier**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Change Subscription Tier [X] │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Current: Professional (€99/month) │
|
||||||
|
│ │
|
||||||
|
│ New Tier: │
|
||||||
|
│ ○ Essential (€49/month) - Downgrade │
|
||||||
|
│ ● Business (€199/month) - Upgrade │
|
||||||
|
│ ○ Enterprise (Custom) - Contact required │
|
||||||
|
│ │
|
||||||
|
│ When to apply: │
|
||||||
|
│ ○ Immediately (prorate current period) │
|
||||||
|
│ ● At next billing cycle (Feb 15, 2025) │
|
||||||
|
│ │
|
||||||
|
│ [ ] Notify store by email │
|
||||||
|
│ │
|
||||||
|
│ [Cancel] [Apply Change] │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Store-Initiated Change
|
||||||
|
|
||||||
|
**Location:** Store dashboard → Billing page → [Change Plan]
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. Store clicks "Change Plan" on billing page
|
||||||
|
2. Shows tier comparison with current tier highlighted
|
||||||
|
3. Store selects new tier
|
||||||
|
4. For upgrades:
|
||||||
|
- Show prorated amount for immediate change
|
||||||
|
- Or option to change at next billing
|
||||||
|
- Redirect to Stripe checkout if needed
|
||||||
|
5. For downgrades:
|
||||||
|
- Always schedule for next billing cycle
|
||||||
|
- Show what features they'll lose
|
||||||
|
- Confirmation required
|
||||||
|
|
||||||
|
### 3.3 API Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Actor | Description |
|
||||||
|
|----------|--------|-------|-------------|
|
||||||
|
| `/api/v1/admin/subscriptions/{store_id}/change-tier` | POST | Admin | Change store's tier |
|
||||||
|
| `/api/v1/store/billing/change-tier` | POST | Store | Request tier change |
|
||||||
|
| `/api/v1/store/billing/preview-change` | POST | Store | Preview proration |
|
||||||
|
|
||||||
|
### 3.4 Stripe Integration
|
||||||
|
|
||||||
|
**Upgrade (Immediate):**
|
||||||
|
```python
|
||||||
|
stripe.Subscription.modify(
|
||||||
|
subscription_id,
|
||||||
|
items=[{"price": new_price_id}],
|
||||||
|
proration_behavior="create_prorations"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Downgrade (Scheduled):**
|
||||||
|
```python
|
||||||
|
stripe.Subscription.modify(
|
||||||
|
subscription_id,
|
||||||
|
items=[{"price": new_price_id}],
|
||||||
|
proration_behavior="none",
|
||||||
|
billing_cycle_anchor="unchanged"
|
||||||
|
)
|
||||||
|
# Store scheduled change in our DB
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Add-ons Upselling
|
||||||
|
|
||||||
|
### 4.1 Where Add-ons Are Displayed
|
||||||
|
|
||||||
|
#### A. Store Billing Page
|
||||||
|
```
|
||||||
|
/store/{code}/billing
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Available Add-ons │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ ┌─────────────────────┐ ┌─────────────────────┐ │
|
||||||
|
│ │ 🌐 Custom Domain │ │ 📧 Email Package │ │
|
||||||
|
│ │ €15/year │ │ From €5/month │ │
|
||||||
|
│ │ Use your own domain │ │ 5, 10, or 25 emails │ │
|
||||||
|
│ │ [Add to Plan] │ │ [Add to Plan] │ │
|
||||||
|
│ └─────────────────────┘ └─────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────┐ ┌─────────────────────┐ │
|
||||||
|
│ │ 🔒 Premium SSL │ │ 💾 Extra Storage │ │
|
||||||
|
│ │ €49/year │ │ €5/month per 10GB │ │
|
||||||
|
│ │ EV certificate │ │ More product images │ │
|
||||||
|
│ │ [Add to Plan] │ │ [Add to Plan] │ │
|
||||||
|
│ └─────────────────────┘ └─────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B. Contextual Upsells
|
||||||
|
|
||||||
|
**When store hits a limit:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ ⚠️ You've reached your order limit for this month │
|
||||||
|
│ │
|
||||||
|
│ Upgrade to Professional to get 500 orders/month │
|
||||||
|
│ [Upgrade Now] [Dismiss] │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**In settings when configuring domain:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 🌐 Custom Domain │
|
||||||
|
│ │
|
||||||
|
│ Your shop is available at: myshop.platform.com │
|
||||||
|
│ │
|
||||||
|
│ Want to use your own domain like www.myshop.com? │
|
||||||
|
│ Add the Custom Domain add-on for just €15/year │
|
||||||
|
│ │
|
||||||
|
│ [Add Custom Domain] │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### C. Upgrade Prompts in Tier Comparison
|
||||||
|
|
||||||
|
When showing tier comparison, highlight what add-ons come included:
|
||||||
|
- Professional: Includes 1 custom domain
|
||||||
|
- Business: Includes custom domain + 5 email addresses
|
||||||
|
- Enterprise: All add-ons included
|
||||||
|
|
||||||
|
### 4.2 Add-on Purchase Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Store clicks [Add to Plan]
|
||||||
|
↓
|
||||||
|
Modal: Configure Add-on
|
||||||
|
- Domain: Enter domain name, check availability
|
||||||
|
- Email: Select package (5/10/25)
|
||||||
|
↓
|
||||||
|
Create Stripe checkout session for add-on price
|
||||||
|
↓
|
||||||
|
On success: Create StoreAddOn record
|
||||||
|
↓
|
||||||
|
Provision add-on (domain registration, email setup)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Add-on Management
|
||||||
|
|
||||||
|
**Store can view/manage in Billing page:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Your Add-ons │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Custom Domain myshop.com €15/year [Manage] │
|
||||||
|
│ Email Package 5 addresses €5/month [Manage] │
|
||||||
|
│ │
|
||||||
|
│ Next billing: Feb 15, 2025 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Database: `store_addons` Table
|
||||||
|
|
||||||
|
```python
|
||||||
|
class StoreAddOn(Base):
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
store_id = Column(Integer, ForeignKey("stores.id"))
|
||||||
|
addon_product_id = Column(Integer, ForeignKey("addon_products.id"))
|
||||||
|
|
||||||
|
# Config (e.g., domain name, email count)
|
||||||
|
config = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
# Stripe
|
||||||
|
stripe_subscription_item_id = Column(String(100))
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status = Column(String(20)) # active, cancelled, pending_setup
|
||||||
|
provisioned_at = Column(DateTime)
|
||||||
|
|
||||||
|
# Billing
|
||||||
|
quantity = Column(Integer, default=1)
|
||||||
|
|
||||||
|
created_at = Column(DateTime)
|
||||||
|
cancelled_at = Column(DateTime, nullable=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Implementation Phases
|
||||||
|
|
||||||
|
**Last Updated:** December 31, 2025
|
||||||
|
|
||||||
|
### Phase 1: Database & Core (COMPLETED)
|
||||||
|
- [x] Add `tier_id` FK to StoreSubscription
|
||||||
|
- [x] Create migration with data backfill
|
||||||
|
- [x] Update subscription service to use tier relationship
|
||||||
|
- [x] Update admin subscription endpoints
|
||||||
|
- [x] **NEW:** Add Feature model with 30 features across 8 categories
|
||||||
|
- [x] **NEW:** Create FeatureService with caching for tier-based feature checking
|
||||||
|
- [x] **NEW:** Add UsageService for limit tracking and upgrade recommendations
|
||||||
|
|
||||||
|
### Phase 2: Admin Store Page (PARTIALLY COMPLETE)
|
||||||
|
- [x] Add subscription card to store detail page
|
||||||
|
- [x] Show usage meters (orders, products, team)
|
||||||
|
- [ ] Add "Edit Subscription" modal
|
||||||
|
- [ ] Implement tier change API (admin)
|
||||||
|
- [x] **NEW:** Add Admin Features page (`/admin/features`)
|
||||||
|
- [x] **NEW:** Admin features API (list, update, toggle)
|
||||||
|
|
||||||
|
### Phase 3: Store Billing Page (COMPLETED)
|
||||||
|
- [x] Create `/store/{code}/billing` page
|
||||||
|
- [x] Show current plan and usage
|
||||||
|
- [x] Add tier comparison/change UI
|
||||||
|
- [x] Implement tier change API (store)
|
||||||
|
- [x] Add Stripe checkout integration for upgrades
|
||||||
|
- [x] **NEW:** Add feature gate macros for templates
|
||||||
|
- [x] **NEW:** Add Alpine.js feature store
|
||||||
|
- [x] **NEW:** Add Alpine.js upgrade prompts store
|
||||||
|
- [x] **FIX:** Resolved 89 JS architecture violations (JS-005 through JS-009)
|
||||||
|
|
||||||
|
### Phase 4: Add-ons (COMPLETED)
|
||||||
|
- [x] Seed add-on products in database
|
||||||
|
- [x] Add "Available Add-ons" section to billing page
|
||||||
|
- [x] Implement add-on purchase flow
|
||||||
|
- [x] Create StoreAddOn management (via billing page)
|
||||||
|
- [x] Add contextual upsell prompts
|
||||||
|
- [x] **FIX:** Fix Stripe webhook to create StoreAddOn records
|
||||||
|
|
||||||
|
### Phase 5: Polish & Testing (IN PROGRESS)
|
||||||
|
- [ ] Email notifications for tier changes
|
||||||
|
- [x] Webhook handling for Stripe events
|
||||||
|
- [x] Usage limit enforcement updates
|
||||||
|
- [ ] End-to-end testing (manual testing required)
|
||||||
|
- [x] Documentation (feature-gating-system.md created)
|
||||||
|
|
||||||
|
### Phase 6: Remaining Work (NEW)
|
||||||
|
- [ ] Admin tier change modal (upgrade/downgrade stores)
|
||||||
|
- [ ] Admin subscription override UI (custom limits for enterprise)
|
||||||
|
- [ ] Trial extension from admin panel
|
||||||
|
- [ ] Email notifications for tier changes
|
||||||
|
- [ ] Email notifications for approaching limits
|
||||||
|
- [ ] Grace period handling for failed payments
|
||||||
|
- [ ] Integration tests for full billing workflow
|
||||||
|
- [ ] Stripe test mode checkout verification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Files Created/Modified
|
||||||
|
|
||||||
|
**Last Updated:** December 31, 2025
|
||||||
|
|
||||||
|
### New Files (Created)
|
||||||
|
| File | Purpose | Status |
|
||||||
|
|------|---------|--------|
|
||||||
|
| `app/templates/store/billing.html` | Store billing page | DONE |
|
||||||
|
| `static/store/js/billing.js` | Billing page JS | DONE |
|
||||||
|
| `app/api/v1/store/billing.py` | Store billing endpoints | DONE |
|
||||||
|
| `models/database/feature.py` | Feature & StoreFeatureOverride models | DONE |
|
||||||
|
| `app/services/feature_service.py` | Feature access control service | DONE |
|
||||||
|
| `app/services/usage_service.py` | Usage tracking & limits service | DONE |
|
||||||
|
| `app/core/feature_gate.py` | @require_feature decorator & dependency | DONE |
|
||||||
|
| `app/api/v1/store/features.py` | Store features API | DONE |
|
||||||
|
| `app/api/v1/store/usage.py` | Store usage API | DONE |
|
||||||
|
| `app/api/v1/admin/features.py` | Admin features API | DONE |
|
||||||
|
| `app/templates/admin/features.html` | Admin features management page | DONE |
|
||||||
|
| `app/templates/shared/macros/feature_gate.html` | Jinja2 feature gate macros | DONE |
|
||||||
|
| `static/shared/js/feature-store.js` | Alpine.js feature store | DONE |
|
||||||
|
| `static/shared/js/upgrade-prompts.js` | Alpine.js upgrade prompts | DONE |
|
||||||
|
| `alembic/versions/n2c3d4e5f6a7_add_features_table.py` | Features migration | DONE |
|
||||||
|
| `docs/implementation/feature-gating-system.md` | Feature gating documentation | DONE |
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
| File | Changes | Status |
|
||||||
|
|------|---------|--------|
|
||||||
|
| `models/database/subscription.py` | Add tier_id FK | DONE |
|
||||||
|
| `models/database/__init__.py` | Export Feature models | DONE |
|
||||||
|
| `app/templates/admin/store-detail.html` | Add subscription card | DONE |
|
||||||
|
| `static/admin/js/store-detail.js` | Load subscription data | DONE |
|
||||||
|
| `app/api/v1/admin/stores.py` | Include subscription in response | DONE |
|
||||||
|
| `app/api/v1/admin/__init__.py` | Register features router | DONE |
|
||||||
|
| `app/api/v1/store/__init__.py` | Register features/usage routers | DONE |
|
||||||
|
| `app/services/subscription_service.py` | Tier change logic | DONE |
|
||||||
|
| `app/templates/store/partials/sidebar.html` | Add Billing link | DONE |
|
||||||
|
| `app/templates/store/base.html` | Load feature/upgrade stores | DONE |
|
||||||
|
| `app/templates/store/dashboard.html` | Add tier badge & usage bars | DONE |
|
||||||
|
| `app/handlers/stripe_webhook.py` | Create StoreAddOn on purchase | DONE |
|
||||||
|
| `app/routes/admin_pages.py` | Add features page route | DONE |
|
||||||
|
| `static/shared/js/api-client.js` | Add postFormData() & getBlob() | DONE |
|
||||||
|
|
||||||
|
### Architecture Fixes (48 files)
|
||||||
|
| Rule | Files Fixed | Description |
|
||||||
|
|------|-------------|-------------|
|
||||||
|
| JS-003 | billing.js | Rename billingData→storeBilling |
|
||||||
|
| JS-005 | 15 files | Add init guards |
|
||||||
|
| JS-006 | 39 files | Add try/catch to async init |
|
||||||
|
| JS-008 | 5 files | Use apiClient not fetch |
|
||||||
|
| JS-009 | 30 files | Use Utils.showToast |
|
||||||
|
| TPL-009 | validate_architecture.py | Check store templates too |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. API Summary
|
||||||
|
|
||||||
|
### Admin APIs
|
||||||
|
```
|
||||||
|
GET /admin/stores/{id} # Includes subscription
|
||||||
|
POST /admin/subscriptions/{store_id}/change-tier
|
||||||
|
POST /admin/subscriptions/{store_id}/override-limits
|
||||||
|
POST /admin/subscriptions/{store_id}/extend-trial
|
||||||
|
POST /admin/subscriptions/{store_id}/cancel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Store APIs
|
||||||
|
```
|
||||||
|
GET /store/billing/subscription # Current subscription
|
||||||
|
GET /store/billing/tiers # Available tiers
|
||||||
|
POST /store/billing/preview-change # Preview tier change
|
||||||
|
POST /store/billing/change-tier # Request tier change
|
||||||
|
POST /store/billing/checkout # Stripe checkout session
|
||||||
|
|
||||||
|
GET /store/billing/addons # Available add-ons
|
||||||
|
GET /store/billing/my-addons # Store's add-ons
|
||||||
|
POST /store/billing/addons/purchase # Purchase add-on
|
||||||
|
DELETE /store/billing/addons/{id} # Cancel add-on
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Questions to Resolve
|
||||||
|
|
||||||
|
1. **Trial without payment method?**
|
||||||
|
- Allow full trial without card, or require card upfront?
|
||||||
|
|
||||||
|
2. **Downgrade handling:**
|
||||||
|
- What happens if store has more products than new tier allows?
|
||||||
|
- Block downgrade, or just prevent new products?
|
||||||
|
|
||||||
|
3. **Enterprise tier:**
|
||||||
|
- Self-service or contact sales only?
|
||||||
|
- Custom pricing in UI or hidden?
|
||||||
|
|
||||||
|
4. **Add-on provisioning:**
|
||||||
|
- Domain: Use reseller API or manual process?
|
||||||
|
- Email: Integrate with email provider or manual?
|
||||||
|
|
||||||
|
5. **Grace period:**
|
||||||
|
- How long after payment failure before suspension?
|
||||||
|
- What gets disabled first?
|
||||||
135
app/modules/billing/docs/tier-management.md
Normal file
135
app/modules/billing/docs/tier-management.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Subscription Tier Management
|
||||||
|
|
||||||
|
This guide explains how to manage subscription tiers and assign features to them in the admin panel.
|
||||||
|
|
||||||
|
## Accessing Tier Management
|
||||||
|
|
||||||
|
Navigate to **Admin → Billing & Subscriptions → Subscription Tiers** or go directly to `/admin/subscription-tiers`.
|
||||||
|
|
||||||
|
## Dashboard Overview
|
||||||
|
|
||||||
|
The tier management page displays:
|
||||||
|
|
||||||
|
### Stats Cards
|
||||||
|
- **Total Tiers**: Number of configured subscription tiers
|
||||||
|
- **Active Tiers**: Tiers currently available for subscription
|
||||||
|
- **Public Tiers**: Tiers visible to stores (excludes enterprise/custom)
|
||||||
|
- **Est. MRR**: Estimated Monthly Recurring Revenue
|
||||||
|
|
||||||
|
### Tier Table
|
||||||
|
|
||||||
|
Each tier shows:
|
||||||
|
|
||||||
|
| Column | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| # | Display order (affects pricing page order) |
|
||||||
|
| Code | Unique identifier (e.g., `essential`, `professional`) |
|
||||||
|
| Name | Display name shown to stores |
|
||||||
|
| Monthly | Monthly price in EUR |
|
||||||
|
| Annual | Annual price in EUR (or `-` if not set) |
|
||||||
|
| Orders/Mo | Monthly order limit (or `Unlimited`) |
|
||||||
|
| Products | Product limit (or `Unlimited`) |
|
||||||
|
| Team | Team member limit (or `Unlimited`) |
|
||||||
|
| Features | Number of features assigned |
|
||||||
|
| Status | Active, Private, or Inactive |
|
||||||
|
| Actions | Edit Features, Edit, Activate/Deactivate |
|
||||||
|
|
||||||
|
## Managing Tiers
|
||||||
|
|
||||||
|
### Creating a New Tier
|
||||||
|
|
||||||
|
1. Click **Create Tier** button
|
||||||
|
2. Fill in the tier details:
|
||||||
|
- **Code**: Unique lowercase identifier (cannot be changed after creation)
|
||||||
|
- **Name**: Display name for the tier
|
||||||
|
- **Monthly Price**: Price in cents (e.g., 4900 for €49.00)
|
||||||
|
- **Annual Price**: Optional annual price in cents
|
||||||
|
- **Order Limit**: Leave empty for unlimited
|
||||||
|
- **Product Limit**: Leave empty for unlimited
|
||||||
|
- **Team Members**: Leave empty for unlimited
|
||||||
|
- **Display Order**: Controls sort order on pricing pages
|
||||||
|
- **Active**: Whether tier is available
|
||||||
|
- **Public**: Whether tier is visible to stores
|
||||||
|
3. Click **Create**
|
||||||
|
|
||||||
|
### Editing a Tier
|
||||||
|
|
||||||
|
1. Click the **pencil icon** on the tier row
|
||||||
|
2. Modify the tier properties
|
||||||
|
3. Click **Update**
|
||||||
|
|
||||||
|
Note: The tier code cannot be changed after creation.
|
||||||
|
|
||||||
|
### Activating/Deactivating Tiers
|
||||||
|
|
||||||
|
- Click the **check-circle icon** to activate an inactive tier
|
||||||
|
- Click the **x-circle icon** to deactivate an active tier
|
||||||
|
|
||||||
|
Deactivating a tier:
|
||||||
|
- Does not affect existing subscriptions
|
||||||
|
- Hides the tier from new subscription selection
|
||||||
|
- Can be reactivated at any time
|
||||||
|
|
||||||
|
## Managing Features
|
||||||
|
|
||||||
|
### Assigning Features to a Tier
|
||||||
|
|
||||||
|
1. Click the **puzzle-piece icon** on the tier row
|
||||||
|
2. A slide-over panel opens showing all available features
|
||||||
|
3. Features are grouped by category:
|
||||||
|
- Analytics
|
||||||
|
- Product Management
|
||||||
|
- Order Management
|
||||||
|
- Marketing
|
||||||
|
- Support
|
||||||
|
- Integration
|
||||||
|
- Branding
|
||||||
|
- Team
|
||||||
|
|
||||||
|
4. Check/uncheck features to include in the tier
|
||||||
|
5. Use **Select all** or **Deselect all** per category for bulk actions
|
||||||
|
6. The footer shows the total number of selected features
|
||||||
|
7. Click **Save Features** to apply changes
|
||||||
|
|
||||||
|
### Feature Categories
|
||||||
|
|
||||||
|
| Category | Example Features |
|
||||||
|
|----------|------------------|
|
||||||
|
| Analytics | Basic Analytics, Analytics Dashboard, Custom Reports |
|
||||||
|
| Product Management | Bulk Edit, Variants, Bundles, Inventory Alerts |
|
||||||
|
| Order Management | Order Automation, Advanced Fulfillment, Multi-Warehouse |
|
||||||
|
| Marketing | Discount Codes, Abandoned Cart, Email Marketing, Loyalty |
|
||||||
|
| Support | Email Support, Priority Support, Phone Support, Dedicated Manager |
|
||||||
|
| Integration | Basic API, Advanced API, Webhooks, Custom Integrations |
|
||||||
|
| Branding | Theme Customization, Custom Domain, White Label |
|
||||||
|
| Team | Team Management, Role Permissions, Audit Logs |
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Tier Pricing Strategy
|
||||||
|
|
||||||
|
1. **Essential**: Entry-level with basic features and limits
|
||||||
|
2. **Professional**: Mid-tier with increased limits and key integrations
|
||||||
|
3. **Business**: Full-featured for growing businesses
|
||||||
|
4. **Enterprise**: Custom pricing with unlimited everything
|
||||||
|
|
||||||
|
### Feature Assignment Tips
|
||||||
|
|
||||||
|
- Start with fewer features in lower tiers
|
||||||
|
- Ensure each upgrade tier adds meaningful value
|
||||||
|
- Keep support features as upgrade incentives
|
||||||
|
- API access typically belongs in Business+ tiers
|
||||||
|
|
||||||
|
### Stripe Integration
|
||||||
|
|
||||||
|
For each tier, you can optionally configure:
|
||||||
|
- **Stripe Product ID**: Link to Stripe product
|
||||||
|
- **Stripe Monthly Price ID**: Link to monthly price
|
||||||
|
- **Stripe Annual Price ID**: Link to annual price
|
||||||
|
|
||||||
|
These are required for automated billing via Stripe Checkout.
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Subscription & Billing System](subscription-system.md) - Complete billing documentation
|
||||||
|
- [Feature Gating System](feature-gating.md) - Technical feature gating details
|
||||||
@@ -44,7 +44,7 @@ def upgrade() -> None:
|
|||||||
sa.Column("platform_id", sa.Integer(), sa.ForeignKey("platforms.id", ondelete="CASCADE"), nullable=False, index=True, comment="Reference to the platform"),
|
sa.Column("platform_id", sa.Integer(), sa.ForeignKey("platforms.id", ondelete="CASCADE"), nullable=False, index=True, comment="Reference to the platform"),
|
||||||
sa.Column("tier_id", sa.Integer(), sa.ForeignKey("subscription_tiers.id", ondelete="SET NULL"), nullable=True, index=True, comment="Platform-specific subscription tier"),
|
sa.Column("tier_id", sa.Integer(), sa.ForeignKey("subscription_tiers.id", ondelete="SET NULL"), nullable=True, index=True, comment="Platform-specific subscription tier"),
|
||||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true", comment="Whether the store is active on this platform"),
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true", comment="Whether the store is active on this platform"),
|
||||||
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="false", comment="Whether this is the store's primary platform"),
|
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="false", comment="Whether this is the store's primary platform"), # Removed in migration remove_is_primary_001
|
||||||
sa.Column("custom_subdomain", sa.String(100), nullable=True, comment="Platform-specific subdomain (if different from main subdomain)"),
|
sa.Column("custom_subdomain", sa.String(100), nullable=True, comment="Platform-specific subdomain (if different from main subdomain)"),
|
||||||
sa.Column("settings", sa.JSON(), nullable=True, server_default="{}", comment="Platform-specific store settings"),
|
sa.Column("settings", sa.JSON(), nullable=True, server_default="{}", comment="Platform-specific store settings"),
|
||||||
sa.Column("joined_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, comment="When the store joined this platform"),
|
sa.Column("joined_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, comment="When the store joined this platform"),
|
||||||
@@ -53,7 +53,7 @@ def upgrade() -> None:
|
|||||||
sa.UniqueConstraint("store_id", "platform_id", name="uq_store_platform"),
|
sa.UniqueConstraint("store_id", "platform_id", name="uq_store_platform"),
|
||||||
)
|
)
|
||||||
op.create_index("idx_store_platform_active", "store_platforms", ["store_id", "platform_id", "is_active"])
|
op.create_index("idx_store_platform_active", "store_platforms", ["store_id", "platform_id", "is_active"])
|
||||||
op.create_index("idx_store_platform_primary", "store_platforms", ["store_id", "is_primary"])
|
op.create_index("idx_store_platform_primary", "store_platforms", ["store_id", "is_primary"]) # Removed in migration remove_is_primary_001
|
||||||
|
|
||||||
# --- tier_feature_limits ---
|
# --- tier_feature_limits ---
|
||||||
op.create_table(
|
op.create_table(
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""add name_translations to subscription_tiers
|
||||||
|
|
||||||
|
Revision ID: billing_002
|
||||||
|
Revises: hosting_001
|
||||||
|
Create Date: 2026-03-03
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "billing_002"
|
||||||
|
down_revision = "hosting_001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"subscription_tiers",
|
||||||
|
sa.Column(
|
||||||
|
"name_translations",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=True,
|
||||||
|
comment="Language-keyed name dict for multi-language support",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("subscription_tiers", "name_translations")
|
||||||
@@ -100,6 +100,12 @@ class SubscriptionTier(Base, TimestampMixin):
|
|||||||
|
|
||||||
code = Column(String(30), nullable=False, index=True)
|
code = Column(String(30), nullable=False, index=True)
|
||||||
name = Column(String(100), nullable=False)
|
name = Column(String(100), nullable=False)
|
||||||
|
name_translations = Column(
|
||||||
|
JSON,
|
||||||
|
nullable=True,
|
||||||
|
default=None,
|
||||||
|
comment="Language-keyed name dict for multi-language support",
|
||||||
|
)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
|
|
||||||
# Pricing (in cents for precision)
|
# Pricing (in cents for precision)
|
||||||
@@ -154,6 +160,16 @@ class SubscriptionTier(Base, TimestampMixin):
|
|||||||
"""Check if this tier includes a specific feature."""
|
"""Check if this tier includes a specific feature."""
|
||||||
return feature_code in self.get_feature_codes()
|
return feature_code in self.get_feature_codes()
|
||||||
|
|
||||||
|
def get_translated_name(self, lang: str, default_lang: str = "fr") -> str:
|
||||||
|
"""Get name in the given language, falling back to default_lang then self.name."""
|
||||||
|
if self.name_translations:
|
||||||
|
return (
|
||||||
|
self.name_translations.get(lang)
|
||||||
|
or self.name_translations.get(default_lang)
|
||||||
|
or self.name
|
||||||
|
)
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# AddOnProduct - Purchasable add-ons
|
# AddOnProduct - Purchasable add-ons
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ def get_subscription_status(
|
|||||||
):
|
):
|
||||||
"""Get current subscription status."""
|
"""Get current subscription status."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
subscription, tier = billing_service.get_subscription_with_tier(db, merchant_id, platform_id)
|
subscription, tier = billing_service.get_subscription_with_tier(db, merchant_id, platform_id)
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ def get_available_tiers(
|
|||||||
):
|
):
|
||||||
"""Get available subscription tiers for upgrade/downgrade."""
|
"""Get available subscription tiers for upgrade/downgrade."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
subscription = subscription_service.get_or_create_subscription(db, merchant_id, platform_id)
|
subscription = subscription_service.get_or_create_subscription(db, merchant_id, platform_id)
|
||||||
current_tier_id = subscription.tier_id
|
current_tier_id = subscription.tier_id
|
||||||
@@ -105,7 +105,7 @@ def get_invoices(
|
|||||||
):
|
):
|
||||||
"""Get invoice history."""
|
"""Get invoice history."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
invoices, total = billing_service.get_invoices(db, merchant_id, skip=skip, limit=limit)
|
invoices, total = billing_service.get_invoices(db, merchant_id, skip=skip, limit=limit)
|
||||||
|
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ def purchase_addon(
|
|||||||
store = billing_service.get_store(db, store_id)
|
store = billing_service.get_store(db, store_id)
|
||||||
|
|
||||||
# Build URLs
|
# Build URLs
|
||||||
base_url = f"https://{settings.platform_domain}"
|
base_url = settings.app_base_url.rstrip("/")
|
||||||
success_url = f"{base_url}/store/{store.store_code}/billing?addon_success=true"
|
success_url = f"{base_url}/store/{store.store_code}/billing?addon_success=true"
|
||||||
cancel_url = f"{base_url}/store/{store.store_code}/billing?addon_cancelled=true"
|
cancel_url = f"{base_url}/store/{store.store_code}/billing?addon_cancelled=true"
|
||||||
|
|
||||||
|
|||||||
@@ -55,11 +55,11 @@ def create_checkout_session(
|
|||||||
):
|
):
|
||||||
"""Create a Stripe checkout session for subscription."""
|
"""Create a Stripe checkout session for subscription."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
store_code = subscription_service.get_store_code(db, store_id)
|
store_code = subscription_service.get_store_code(db, store_id)
|
||||||
|
|
||||||
base_url = f"https://{settings.platform_domain}"
|
base_url = settings.app_base_url.rstrip("/")
|
||||||
success_url = f"{base_url}/store/{store_code}/billing?success=true"
|
success_url = f"{base_url}/store/{store_code}/billing?success=true"
|
||||||
cancel_url = f"{base_url}/store/{store_code}/billing?cancelled=true"
|
cancel_url = f"{base_url}/store/{store_code}/billing?cancelled=true"
|
||||||
|
|
||||||
@@ -84,10 +84,10 @@ def create_portal_session(
|
|||||||
):
|
):
|
||||||
"""Create a Stripe customer portal session."""
|
"""Create a Stripe customer portal session."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
store_code = subscription_service.get_store_code(db, store_id)
|
store_code = subscription_service.get_store_code(db, store_id)
|
||||||
return_url = f"https://{settings.platform_domain}/store/{store_code}/billing"
|
return_url = f"{settings.app_base_url.rstrip('/')}/store/{store_code}/billing"
|
||||||
|
|
||||||
result = billing_service.create_portal_session(db, merchant_id, platform_id, return_url)
|
result = billing_service.create_portal_session(db, merchant_id, platform_id, return_url)
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ def cancel_subscription(
|
|||||||
):
|
):
|
||||||
"""Cancel subscription."""
|
"""Cancel subscription."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
result = billing_service.cancel_subscription(
|
result = billing_service.cancel_subscription(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -126,7 +126,7 @@ def reactivate_subscription(
|
|||||||
):
|
):
|
||||||
"""Reactivate a cancelled subscription."""
|
"""Reactivate a cancelled subscription."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
result = billing_service.reactivate_subscription(db, merchant_id, platform_id)
|
result = billing_service.reactivate_subscription(db, merchant_id, platform_id)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -141,7 +141,7 @@ def get_upcoming_invoice(
|
|||||||
):
|
):
|
||||||
"""Preview the upcoming invoice."""
|
"""Preview the upcoming invoice."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
result = billing_service.get_upcoming_invoice(db, merchant_id, platform_id)
|
result = billing_service.get_upcoming_invoice(db, merchant_id, platform_id)
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ def change_tier(
|
|||||||
):
|
):
|
||||||
"""Change subscription tier (upgrade/downgrade)."""
|
"""Change subscription tier (upgrade/downgrade)."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
result = billing_service.change_tier(
|
result = billing_service.change_tier(
|
||||||
db=db,
|
db=db,
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ def get_available_features(
|
|||||||
List of feature codes the store has access to
|
List of feature codes the store has access to
|
||||||
"""
|
"""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
# Get available feature codes
|
# Get available feature codes
|
||||||
feature_codes = feature_service.get_merchant_feature_codes(db, merchant_id, platform_id)
|
feature_codes = feature_service.get_merchant_feature_codes(db, merchant_id, platform_id)
|
||||||
@@ -134,7 +134,7 @@ def get_features(
|
|||||||
List of features with metadata and availability
|
List of features with metadata and availability
|
||||||
"""
|
"""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
# Get all declarations and available codes
|
# Get all declarations and available codes
|
||||||
all_declarations = feature_aggregator.get_all_declarations()
|
all_declarations = feature_aggregator.get_all_declarations()
|
||||||
@@ -197,7 +197,7 @@ def get_features_grouped(
|
|||||||
Useful for rendering feature comparison tables or settings pages.
|
Useful for rendering feature comparison tables or settings pages.
|
||||||
"""
|
"""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
# Get declarations grouped by category and available codes
|
# Get declarations grouped by category and available codes
|
||||||
by_category = feature_aggregator.get_declarations_by_category()
|
by_category = feature_aggregator.get_declarations_by_category()
|
||||||
@@ -246,7 +246,9 @@ def check_feature(
|
|||||||
has_feature and feature_code
|
has_feature and feature_code
|
||||||
"""
|
"""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
has = feature_service.has_feature_for_store(db, store_id, feature_code)
|
has = feature_service.has_feature_for_store(
|
||||||
|
db, store_id, feature_code, platform_id=current_user.token_platform_id
|
||||||
|
)
|
||||||
|
|
||||||
return StoreFeatureCheckResponse(has_feature=has, feature_code=feature_code)
|
return StoreFeatureCheckResponse(has_feature=has, feature_code=feature_code)
|
||||||
|
|
||||||
@@ -270,7 +272,7 @@ def get_feature_detail(
|
|||||||
Feature details with upgrade info if locked
|
Feature details with upgrade info if locked
|
||||||
"""
|
"""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
# Get feature declaration
|
# Get feature declaration
|
||||||
decl = feature_aggregator.get_declaration(feature_code)
|
decl = feature_aggregator.get_declaration(feature_code)
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ class AdminSubscriptionService:
|
|||||||
try:
|
try:
|
||||||
platform = platform_service.get_platform_by_id(db, tier.platform_id)
|
platform = platform_service.get_platform_by_id(db, tier.platform_id)
|
||||||
platform_name = platform.name
|
platform_name = platform.name
|
||||||
except Exception:
|
except Exception: # noqa: EXC-003
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# --- Product ---
|
# --- Product ---
|
||||||
|
|||||||
@@ -108,10 +108,17 @@ class FeatureService:
|
|||||||
# Store -> Merchant Resolution
|
# Store -> Merchant Resolution
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def _get_merchant_for_store(self, db: Session, store_id: int) -> tuple[int | None, int | None]:
|
def _get_merchant_for_store(
|
||||||
|
self, db: Session, store_id: int, platform_id: int | None = None
|
||||||
|
) -> tuple[int | None, int | None]:
|
||||||
"""
|
"""
|
||||||
Resolve store_id to (merchant_id, platform_id).
|
Resolve store_id to (merchant_id, platform_id).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
store_id: Store ID
|
||||||
|
platform_id: Platform ID from JWT. When provided, skips DB lookup.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (merchant_id, platform_id), either may be None
|
Tuple of (merchant_id, platform_id), either may be None
|
||||||
"""
|
"""
|
||||||
@@ -123,7 +130,8 @@ class FeatureService:
|
|||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
merchant_id = store.merchant_id
|
merchant_id = store.merchant_id
|
||||||
platform_id = platform_service.get_primary_platform_id_for_store(db, store_id)
|
if platform_id is None:
|
||||||
|
platform_id = platform_service.get_first_active_platform_id_for_store(db, store_id)
|
||||||
|
|
||||||
return merchant_id, platform_id
|
return merchant_id, platform_id
|
||||||
|
|
||||||
@@ -204,28 +212,29 @@ class FeatureService:
|
|||||||
return subscription.tier.has_feature(feature_code)
|
return subscription.tier.has_feature(feature_code)
|
||||||
|
|
||||||
def has_feature_for_store(
|
def has_feature_for_store(
|
||||||
self, db: Session, store_id: int, feature_code: str
|
self, db: Session, store_id: int, feature_code: str,
|
||||||
|
platform_id: int | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Convenience method that resolves the store -> merchant -> platform
|
Convenience method that resolves the store -> merchant -> platform
|
||||||
hierarchy and checks whether the merchant has access to a feature.
|
hierarchy and checks whether the merchant has access to a feature.
|
||||||
|
|
||||||
Looks up the store's merchant_id and platform_id, then delegates
|
|
||||||
to has_feature().
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session.
|
db: Database session.
|
||||||
store_id: The store ID to resolve.
|
store_id: The store ID to resolve.
|
||||||
feature_code: The feature code to check.
|
feature_code: The feature code to check.
|
||||||
|
platform_id: Platform ID from JWT. When provided, skips DB lookup.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if the resolved merchant has access to the feature,
|
True if the resolved merchant has access to the feature,
|
||||||
False if the store/merchant cannot be resolved or lacks access.
|
False if the store/merchant cannot be resolved or lacks access.
|
||||||
"""
|
"""
|
||||||
merchant_id, platform_id = self._get_merchant_for_store(db, store_id)
|
merchant_id, resolved_platform_id = self._get_merchant_for_store(
|
||||||
if merchant_id is None or platform_id is None:
|
db, store_id, platform_id=platform_id
|
||||||
|
)
|
||||||
|
if merchant_id is None or resolved_platform_id is None:
|
||||||
return False
|
return False
|
||||||
return self.has_feature(db, merchant_id, platform_id, feature_code)
|
return self.has_feature(db, merchant_id, resolved_platform_id, feature_code)
|
||||||
|
|
||||||
def get_merchant_feature_codes(
|
def get_merchant_feature_codes(
|
||||||
self, db: Session, merchant_id: int, platform_id: int
|
self, db: Session, merchant_id: int, platform_id: int
|
||||||
@@ -317,7 +326,7 @@ class FeatureService:
|
|||||||
feature_code: Feature code (e.g., "products_limit")
|
feature_code: Feature code (e.g., "products_limit")
|
||||||
store_id: Store ID (if checking per-store)
|
store_id: Store ID (if checking per-store)
|
||||||
merchant_id: Merchant ID (if already known)
|
merchant_id: Merchant ID (if already known)
|
||||||
platform_id: Platform ID (if already known)
|
platform_id: Platform ID (if already known, e.g. from JWT)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(allowed, error_message) tuple
|
(allowed, error_message) tuple
|
||||||
@@ -326,7 +335,9 @@ class FeatureService:
|
|||||||
|
|
||||||
# Resolve store -> merchant if needed
|
# Resolve store -> merchant if needed
|
||||||
if merchant_id is None and store_id is not None:
|
if merchant_id is None and store_id is not None:
|
||||||
merchant_id, platform_id = self._get_merchant_for_store(db, store_id)
|
merchant_id, platform_id = self._get_merchant_for_store(
|
||||||
|
db, store_id, platform_id=platform_id
|
||||||
|
)
|
||||||
|
|
||||||
if merchant_id is None or platform_id is None:
|
if merchant_id is None or platform_id is None:
|
||||||
return False, "No subscription found"
|
return False, "No subscription found"
|
||||||
|
|||||||
@@ -617,7 +617,7 @@ class SignupService:
|
|||||||
|
|
||||||
# Build login URL
|
# Build login URL
|
||||||
login_url = (
|
login_url = (
|
||||||
f"https://{settings.platform_domain}"
|
f"{settings.app_base_url.rstrip('/')}"
|
||||||
f"/store/{store.store_code}/dashboard"
|
f"/store/{store.store_code}/dashboard"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class StorePlatformSync:
|
|||||||
Upsert StorePlatform for every store belonging to a merchant.
|
Upsert StorePlatform for every store belonging to a merchant.
|
||||||
|
|
||||||
- Existing entry → update is_active (and tier_id if provided)
|
- Existing entry → update is_active (and tier_id if provided)
|
||||||
- Missing + is_active=True → create (set is_primary if store has none)
|
- Missing + is_active=True → create
|
||||||
- Missing + is_active=False → no-op
|
- Missing + is_active=False → no-op
|
||||||
"""
|
"""
|
||||||
stores = store_service.get_stores_by_merchant_id(db, merchant_id)
|
stores = store_service.get_stores_by_merchant_id(db, merchant_id)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class StripeService:
|
|||||||
stripe.api_key = settings.stripe_secret_key
|
stripe.api_key = settings.stripe_secret_key
|
||||||
self._configured = True
|
self._configured = True
|
||||||
else:
|
else:
|
||||||
logger.warning("Stripe API key not configured")
|
logger.debug("Stripe API key not configured")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
@@ -311,6 +311,7 @@ class StripeService:
|
|||||||
trial_days: int | None = None,
|
trial_days: int | None = None,
|
||||||
quantity: int = 1,
|
quantity: int = 1,
|
||||||
metadata: dict | None = None,
|
metadata: dict | None = None,
|
||||||
|
platform_id: int | None = None,
|
||||||
) -> stripe.checkout.Session:
|
) -> stripe.checkout.Session:
|
||||||
"""
|
"""
|
||||||
Create a Stripe Checkout session for subscription signup.
|
Create a Stripe Checkout session for subscription signup.
|
||||||
@@ -324,6 +325,7 @@ class StripeService:
|
|||||||
trial_days: Optional trial period
|
trial_days: Optional trial period
|
||||||
quantity: Number of items (default 1)
|
quantity: Number of items (default 1)
|
||||||
metadata: Additional metadata to store
|
metadata: Additional metadata to store
|
||||||
|
platform_id: Platform ID (from JWT or caller). Falls back to DB lookup.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Stripe Checkout Session object
|
Stripe Checkout Session object
|
||||||
@@ -334,7 +336,8 @@ class StripeService:
|
|||||||
from app.modules.tenancy.services.platform_service import platform_service
|
from app.modules.tenancy.services.platform_service import platform_service
|
||||||
from app.modules.tenancy.services.team_service import team_service
|
from app.modules.tenancy.services.team_service import team_service
|
||||||
|
|
||||||
platform_id = platform_service.get_primary_platform_id_for_store(db, store.id)
|
if platform_id is None:
|
||||||
|
platform_id = platform_service.get_first_active_platform_id_for_store(db, store.id)
|
||||||
subscription = None
|
subscription = None
|
||||||
if store.merchant_id and platform_id:
|
if store.merchant_id and platform_id:
|
||||||
subscription = (
|
subscription = (
|
||||||
|
|||||||
@@ -47,9 +47,16 @@ class SubscriptionService:
|
|||||||
# Store Resolution
|
# Store Resolution
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def resolve_store_to_merchant(self, db: Session, store_id: int) -> tuple[int, int]:
|
def resolve_store_to_merchant(
|
||||||
|
self, db: Session, store_id: int, platform_id: int | None = None
|
||||||
|
) -> tuple[int, int]:
|
||||||
"""Resolve store_id to (merchant_id, platform_id).
|
"""Resolve store_id to (merchant_id, platform_id).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
store_id: Store ID
|
||||||
|
platform_id: Platform ID from JWT token. When provided, skips DB lookup.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ResourceNotFoundException: If store not found or has no platform
|
ResourceNotFoundException: If store not found or has no platform
|
||||||
"""
|
"""
|
||||||
@@ -59,7 +66,8 @@ class SubscriptionService:
|
|||||||
store = store_service.get_store_by_id_optional(db, store_id)
|
store = store_service.get_store_by_id_optional(db, store_id)
|
||||||
if not store or not store.merchant_id:
|
if not store or not store.merchant_id:
|
||||||
raise ResourceNotFoundException("Store", str(store_id))
|
raise ResourceNotFoundException("Store", str(store_id))
|
||||||
platform_id = platform_service.get_primary_platform_id_for_store(db, store_id)
|
if platform_id is None:
|
||||||
|
platform_id = platform_service.get_first_active_platform_id_for_store(db, store_id)
|
||||||
if not platform_id:
|
if not platform_id:
|
||||||
raise ResourceNotFoundException("StorePlatform", f"store_id={store_id}")
|
raise ResourceNotFoundException("StorePlatform", f"store_id={store_id}")
|
||||||
return store.merchant_id, platform_id
|
return store.merchant_id, platform_id
|
||||||
@@ -185,7 +193,7 @@ class SubscriptionService:
|
|||||||
if merchant_id is None:
|
if merchant_id is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
platform_id = platform_service.get_primary_platform_id_for_store(db, store_id)
|
platform_id = platform_service.get_first_active_platform_id_for_store(db, store_id)
|
||||||
if platform_id is None:
|
if platform_id is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
const response = await apiClient.get('/store/usage');
|
const response = await apiClient.get('/store/billing/usage');
|
||||||
this.usage = response;
|
this.usage = response;
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
|
|
||||||
|
|||||||
@@ -66,32 +66,23 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Features list (dynamic from module providers) #}
|
||||||
|
{% if tier.features %}
|
||||||
<ul class="space-y-3 mb-8 text-sm">
|
<ul class="space-y-3 mb-8 text-sm">
|
||||||
|
{% for feat in tier.features %}
|
||||||
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
||||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
</svg>
|
</svg>
|
||||||
{% if tier.orders_per_month %}{{ _("cms.platform.pricing.orders_per_month", count=tier.orders_per_month) }}{% else %}{{ _("cms.platform.pricing.unlimited_orders") }}{% endif %}
|
{% if feat.is_quantitative and feat.limit %}
|
||||||
</li>
|
{{ feat.limit }} {{ _(feat.name_key) }}
|
||||||
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
{% else %}
|
||||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
{{ _(feat.name_key) }}
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
{% endif %}
|
||||||
</svg>
|
|
||||||
{% if tier.products_limit %}{{ _("cms.platform.pricing.products_limit", count=tier.products_limit) }}{% else %}{{ _("cms.platform.pricing.unlimited_products") }}{% endif %}
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
|
||||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
{% if tier.team_members %}{{ _("cms.platform.pricing.team_members", count=tier.team_members) }}{% else %}{{ _("cms.platform.pricing.unlimited_team") }}{% endif %}
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
|
||||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
{{ _("cms.platform.pricing.letzshop_sync") }}
|
|
||||||
</li>
|
</li>
|
||||||
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if tier.is_enterprise %}
|
{% if tier.is_enterprise %}
|
||||||
<a href="/contact" class="block w-full py-3 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white font-semibold rounded-xl text-center hover:bg-gray-300 transition-colors">
|
<a href="/contact" class="block w-full py-3 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white font-semibold rounded-xl text-center hover:bg-gray-300 transition-colors">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{# 3-Step Signup Wizard: Plan → Account → Payment #}
|
{# 3-Step Signup Wizard: Plan → Account → Payment #}
|
||||||
{% extends "platform/base.html" %}
|
{% extends "platform/base.html" %}
|
||||||
|
|
||||||
{% block title %}Start Your Free Trial - Orion{% endblock %}
|
{% block title %}{{ _("cms.platform.signup.page_title") }} - {{ platform.name if platform else 'Orion' }}{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
{# Stripe.js for payment #}
|
{# Stripe.js for payment #}
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
{# Progress Steps #}
|
{# Progress Steps #}
|
||||||
<div class="mb-12">
|
<div class="mb-12">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<template x-for="(stepName, index) in ['Select Plan', 'Account', 'Payment']" :key="index">
|
<template x-for="(stepName, index) in ['{{ _("cms.platform.signup.step_plan") }}', '{{ _("cms.platform.signup.step_account") }}', '{{ _("cms.platform.signup.step_payment") }}']" :key="index">
|
||||||
<div class="flex items-center" :class="index < 2 ? 'flex-1' : ''">
|
<div class="flex items-center" :class="index < 2 ? 'flex-1' : ''">
|
||||||
<div class="flex items-center justify-center w-10 h-10 rounded-full font-semibold transition-colors"
|
<div class="flex items-center justify-center w-10 h-10 rounded-full font-semibold transition-colors"
|
||||||
:class="currentStep > index + 1 ? 'bg-green-500 text-white' : currentStep === index + 1 ? 'bg-indigo-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'">
|
:class="currentStep > index + 1 ? 'bg-green-500 text-white' : currentStep === index + 1 ? 'bg-indigo-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'">
|
||||||
@@ -50,11 +50,11 @@
|
|||||||
STEP 1: SELECT PLAN
|
STEP 1: SELECT PLAN
|
||||||
=============================================================== #}
|
=============================================================== #}
|
||||||
<div x-show="currentStep === 1" class="p-8">
|
<div x-show="currentStep === 1" class="p-8">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Choose Your Plan</h2>
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">{{ _("cms.platform.signup.choose_plan") }}</h2>
|
||||||
|
|
||||||
{# Billing Toggle #}
|
{# Billing Toggle #}
|
||||||
<div class="flex items-center justify-center mb-8 space-x-4">
|
<div class="flex items-center justify-center mb-8 space-x-4">
|
||||||
<span :class="!isAnnual ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-500'">Monthly</span>
|
<span :class="!isAnnual ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-500'">{{ _("cms.platform.pricing.monthly") }}</span>
|
||||||
<button @click="isAnnual = !isAnnual"
|
<button @click="isAnnual = !isAnnual"
|
||||||
class="relative w-12 h-6 rounded-full transition-colors"
|
class="relative w-12 h-6 rounded-full transition-colors"
|
||||||
:class="isAnnual ? 'bg-indigo-600' : 'bg-gray-300'">
|
:class="isAnnual ? 'bg-indigo-600' : 'bg-gray-300'">
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
:class="isAnnual ? 'translate-x-6' : ''"></span>
|
:class="isAnnual ? 'translate-x-6' : ''"></span>
|
||||||
</button>
|
</button>
|
||||||
<span :class="isAnnual ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-500'">
|
<span :class="isAnnual ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-500'">
|
||||||
Annual <span class="text-green-600 text-xs">Save 17%</span>
|
{{ _("cms.platform.pricing.annual") }} <span class="text-green-600 text-xs">{{ _("cms.platform.signup.save_percent", percent=17) }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -80,17 +80,17 @@
|
|||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-white">{{ tier.name }}</h3>
|
<h3 class="font-semibold text-gray-900 dark:text-white">{{ tier.name }}</h3>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500">
|
||||||
{% if tier.orders_per_month %}{{ tier.orders_per_month }} orders/mo{% else %}Unlimited{% endif %}
|
{% if tier.orders_per_month %}{{ tier.orders_per_month }} {{ _("cms.platform.signup.orders_per_month") }}{% else %}{{ _("cms.platform.signup.unlimited") }}{% endif %}
|
||||||
•
|
•
|
||||||
{% if tier.team_members %}{{ tier.team_members }} user{% if tier.team_members > 1 %}s{% endif %}{% else %}Unlimited{% endif %}
|
{% if tier.team_members %}{{ tier.team_members }} {{ _("cms.platform.signup.team_members", count=tier.team_members) }}{% else %}{{ _("cms.platform.signup.unlimited") }}{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<template x-if="!isAnnual">
|
<template x-if="!isAnnual">
|
||||||
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ tier.price_monthly|int }}€/mo</span>
|
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ tier.price_monthly|int }}€{{ _("cms.platform.signup.per_month_short") }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="isAnnual">
|
<template x-if="isAnnual">
|
||||||
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ ((tier.price_annual or tier.price_monthly * 12) / 12)|round(0)|int }}€/mo</span>
|
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ ((tier.price_annual or tier.price_monthly * 12) / 12)|round(0)|int }}€{{ _("cms.platform.signup.per_month_short") }}</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,15 +103,15 @@
|
|||||||
{# Free Trial Note #}
|
{# Free Trial Note #}
|
||||||
<div class="mt-6 p-4 bg-green-50 dark:bg-green-900/20 rounded-xl">
|
<div class="mt-6 p-4 bg-green-50 dark:bg-green-900/20 rounded-xl">
|
||||||
<p class="text-sm text-green-800 dark:text-green-300">
|
<p class="text-sm text-green-800 dark:text-green-300">
|
||||||
<strong>{{ trial_days }}-day free trial.</strong>
|
<strong>{{ trial_days }}-{{ _("cms.platform.signup.trial_info_days") }}</strong>
|
||||||
We'll collect your payment info, but you won't be charged until the trial ends.
|
{{ _("cms.platform.signup.trial_info") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button @click="startSignup()"
|
<button @click="startSignup()"
|
||||||
:disabled="!selectedTier || loading"
|
:disabled="!selectedTier || loading"
|
||||||
class="mt-8 w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition-colors disabled:opacity-50">
|
class="mt-8 w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition-colors disabled:opacity-50">
|
||||||
Continue
|
{{ _("cms.platform.signup.continue") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -119,23 +119,23 @@
|
|||||||
STEP 2: CREATE ACCOUNT
|
STEP 2: CREATE ACCOUNT
|
||||||
=============================================================== #}
|
=============================================================== #}
|
||||||
<div x-show="currentStep === 2" class="p-8">
|
<div x-show="currentStep === 2" class="p-8">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">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">
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||||
<span class="text-red-500">*</span> Required fields
|
<span class="text-red-500">*</span> {{ _("cms.platform.signup.required_fields") }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
First Name <span class="text-red-500">*</span>
|
{{ _("cms.platform.signup.first_name") }} <span class="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text" x-model="account.firstName" required
|
<input type="text" x-model="account.firstName" required
|
||||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Last Name <span class="text-red-500">*</span>
|
{{ _("cms.platform.signup.last_name") }} <span class="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text" x-model="account.lastName" required
|
<input type="text" x-model="account.lastName" required
|
||||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Business Name <span class="text-red-500">*</span>
|
{{ _("cms.platform.signup.merchant_name") }} <span class="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text" x-model="account.merchantName" required
|
<input type="text" x-model="account.merchantName" required
|
||||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Email <span class="text-red-500">*</span>
|
{{ _("cms.platform.signup.email") }} <span class="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="email" x-model="account.email" required
|
<input type="email" x-model="account.email" required
|
||||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
||||||
@@ -160,11 +160,11 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Password <span class="text-red-500">*</span>
|
{{ _("cms.platform.signup.password") }} <span class="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="password" x-model="account.password" required minlength="8"
|
<input type="password" x-model="account.password" required minlength="8"
|
||||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
||||||
<p class="text-xs text-gray-500 mt-1">Minimum 8 characters</p>
|
<p class="text-xs text-gray-500 mt-1">{{ _("cms.platform.signup.password_hint") }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template x-if="accountError">
|
<template x-if="accountError">
|
||||||
@@ -177,12 +177,12 @@
|
|||||||
<div class="mt-8 flex gap-4">
|
<div class="mt-8 flex gap-4">
|
||||||
<button @click="currentStep = 1"
|
<button @click="currentStep = 1"
|
||||||
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
|
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
|
||||||
Back
|
{{ _("cms.platform.signup.back") }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="createAccount()"
|
<button @click="createAccount()"
|
||||||
:disabled="loading || !isAccountValid()"
|
:disabled="loading || !isAccountValid()"
|
||||||
class="flex-1 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl disabled:opacity-50">
|
class="flex-1 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl disabled:opacity-50">
|
||||||
Continue to Payment
|
{{ _("cms.platform.signup.continue_payment") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,8 +191,8 @@
|
|||||||
STEP 3: PAYMENT
|
STEP 3: PAYMENT
|
||||||
=============================================================== #}
|
=============================================================== #}
|
||||||
<div x-show="currentStep === 3" class="p-8">
|
<div x-show="currentStep === 3" class="p-8">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Add Payment Method</h2>
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">{{ _("cms.platform.signup.add_payment") }}</h2>
|
||||||
<p class="text-gray-600 dark:text-gray-400 mb-6">You won't be charged until your {{ trial_days }}-day trial ends.</p>
|
<p class="text-gray-600 dark:text-gray-400 mb-6">{{ _("cms.platform.signup.no_charge_note", trial_days=trial_days) }}</p>
|
||||||
|
|
||||||
{# Stripe Card Element #}
|
{# Stripe Card Element #}
|
||||||
<div id="card-element" class="p-4 border border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-900"></div>
|
<div id="card-element" class="p-4 border border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-900"></div>
|
||||||
@@ -201,16 +201,16 @@
|
|||||||
<div class="mt-8 flex gap-4">
|
<div class="mt-8 flex gap-4">
|
||||||
<button @click="currentStep = 2"
|
<button @click="currentStep = 2"
|
||||||
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
|
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
|
||||||
Back
|
{{ _("cms.platform.signup.back") }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="submitPayment()"
|
<button @click="submitPayment()"
|
||||||
:disabled="loading || paymentProcessing"
|
:disabled="loading || paymentProcessing"
|
||||||
class="flex-1 py-3 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-xl disabled:opacity-50">
|
class="flex-1 py-3 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-xl disabled:opacity-50">
|
||||||
<template x-if="paymentProcessing">
|
<template x-if="paymentProcessing">
|
||||||
<span>Processing...</span>
|
<span>{{ _("cms.platform.signup.processing") }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!paymentProcessing">
|
<template x-if="!paymentProcessing">
|
||||||
<span>Start Free Trial</span>
|
<span>{{ _("cms.platform.signup.start_trial") }}</span>
|
||||||
</template>
|
</template>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,6 +224,13 @@
|
|||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
<script>
|
<script>
|
||||||
function signupWizard() {
|
function signupWizard() {
|
||||||
|
const MSGS = {
|
||||||
|
failedStart: '{{ _("cms.platform.signup.error_start") }}',
|
||||||
|
failedAccount: '{{ _("cms.platform.signup.error_account") }}',
|
||||||
|
paymentNotConfigured: '{{ _("cms.platform.signup.error_payment_config") }}',
|
||||||
|
paymentFailed: '{{ _("cms.platform.signup.error_payment") }}',
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentStep: 1,
|
currentStep: 1,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -286,11 +293,11 @@ function signupWizard() {
|
|||||||
this.sessionId = data.session_id;
|
this.sessionId = data.session_id;
|
||||||
this.currentStep = 2;
|
this.currentStep = 2;
|
||||||
} else {
|
} else {
|
||||||
alert(data.detail || 'Failed to start signup');
|
alert(data.detail || MSGS.failedStart);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
alert('Failed to start signup. Please try again.');
|
alert(MSGS.failedStart);
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
@@ -326,11 +333,11 @@ function signupWizard() {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
this.currentStep = 3;
|
this.currentStep = 3;
|
||||||
} else {
|
} else {
|
||||||
this.accountError = data.detail || 'Failed to create account';
|
this.accountError = data.detail || MSGS.failedAccount;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
this.accountError = 'Failed to create account. Please try again.';
|
this.accountError = MSGS.failedAccount;
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
@@ -379,7 +386,7 @@ function signupWizard() {
|
|||||||
|
|
||||||
async submitPayment() {
|
async submitPayment() {
|
||||||
if (!this.stripe || !this.clientSecret) {
|
if (!this.stripe || !this.clientSecret) {
|
||||||
alert('Payment not configured. Please contact support.');
|
alert(MSGS.paymentNotConfigured);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,11 +423,11 @@ function signupWizard() {
|
|||||||
}
|
}
|
||||||
window.location.href = '/signup/success?store_code=' + data.store_code;
|
window.location.href = '/signup/success?store_code=' + data.store_code;
|
||||||
} else {
|
} else {
|
||||||
alert(data.detail || 'Failed to complete signup');
|
alert(data.detail || MSGS.paymentFailed);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Payment error:', error);
|
console.error('Payment error:', error);
|
||||||
alert('Payment failed. Please try again.');
|
alert(MSGS.paymentFailed);
|
||||||
} finally {
|
} finally {
|
||||||
this.paymentProcessing = false;
|
this.paymentProcessing = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,8 +114,7 @@ def ft_tier_with_features(db, ft_tier):
|
|||||||
TierFeatureLimit(tier_id=ft_tier.id, feature_code="basic_shop", limit_value=None),
|
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),
|
TierFeatureLimit(tier_id=ft_tier.id, feature_code="team_members", limit_value=5),
|
||||||
]
|
]
|
||||||
for f in features:
|
db.add_all(features)
|
||||||
db.add(f)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
# Refresh so the tier's selectin-loaded feature_limits relationship is up to date
|
# Refresh so the tier's selectin-loaded feature_limits relationship is up to date
|
||||||
db.refresh(ft_tier)
|
db.refresh(ft_tier)
|
||||||
|
|||||||
@@ -83,13 +83,12 @@ def billing_extra_platforms(db):
|
|||||||
"""Create additional platforms for multiple subscriptions (unique constraint: merchant+platform)."""
|
"""Create additional platforms for multiple subscriptions (unique constraint: merchant+platform)."""
|
||||||
platforms = []
|
platforms = []
|
||||||
for i in range(2):
|
for i in range(2):
|
||||||
p = Platform(
|
platforms.append(Platform(
|
||||||
code=f"bm_extra_{uuid.uuid4().hex[:8]}",
|
code=f"bm_extra_{uuid.uuid4().hex[:8]}",
|
||||||
name=f"Extra Platform {i}",
|
name=f"Extra Platform {i}",
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
))
|
||||||
db.add(p)
|
db.add_all(platforms)
|
||||||
platforms.append(p)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
for p in platforms:
|
for p in platforms:
|
||||||
db.refresh(p)
|
db.refresh(p)
|
||||||
|
|||||||
@@ -90,8 +90,7 @@ def fs_tier_with_features(db, fs_tier):
|
|||||||
TierFeatureLimit(tier_id=fs_tier.id, feature_code="feature_b", limit_value=100),
|
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),
|
TierFeatureLimit(tier_id=fs_tier.id, feature_code="feature_c", limit_value=50),
|
||||||
]
|
]
|
||||||
for f in features:
|
db.add_all(features)
|
||||||
db.add(f)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return features
|
return features
|
||||||
|
|
||||||
|
|||||||
@@ -39,52 +39,6 @@ class TestStorePlatformSyncCreate:
|
|||||||
assert sp is not None
|
assert sp is not None
|
||||||
assert sp.is_active is True
|
assert sp.is_active is True
|
||||||
|
|
||||||
def test_sync_sets_primary_when_none(self, db, test_store, test_platform):
|
|
||||||
"""First platform synced for a store gets is_primary=True."""
|
|
||||||
self.service.sync_store_platforms_for_merchant(
|
|
||||||
db, test_store.merchant_id, test_platform.id, is_active=True
|
|
||||||
)
|
|
||||||
|
|
||||||
sp = (
|
|
||||||
db.query(StorePlatform)
|
|
||||||
.filter(
|
|
||||||
StorePlatform.store_id == test_store.id,
|
|
||||||
StorePlatform.platform_id == test_platform.id,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
assert sp.is_primary is True
|
|
||||||
|
|
||||||
def test_sync_no_primary_override(self, db, test_store, test_platform, another_platform):
|
|
||||||
"""Second platform synced does not override existing primary."""
|
|
||||||
# First platform becomes primary
|
|
||||||
self.service.sync_store_platforms_for_merchant(
|
|
||||||
db, test_store.merchant_id, test_platform.id, is_active=True
|
|
||||||
)
|
|
||||||
# Second platform should not be primary
|
|
||||||
self.service.sync_store_platforms_for_merchant(
|
|
||||||
db, test_store.merchant_id, another_platform.id, is_active=True
|
|
||||||
)
|
|
||||||
|
|
||||||
sp1 = (
|
|
||||||
db.query(StorePlatform)
|
|
||||||
.filter(
|
|
||||||
StorePlatform.store_id == test_store.id,
|
|
||||||
StorePlatform.platform_id == test_platform.id,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
sp2 = (
|
|
||||||
db.query(StorePlatform)
|
|
||||||
.filter(
|
|
||||||
StorePlatform.store_id == test_store.id,
|
|
||||||
StorePlatform.platform_id == another_platform.id,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
assert sp1.is_primary is True
|
|
||||||
assert sp2.is_primary is False
|
|
||||||
|
|
||||||
def test_sync_sets_tier_id(self, db, test_store, test_platform, sync_tier):
|
def test_sync_sets_tier_id(self, db, test_store, test_platform, sync_tier):
|
||||||
"""Sync passes tier_id to newly created StorePlatform."""
|
"""Sync passes tier_id to newly created StorePlatform."""
|
||||||
self.service.sync_store_platforms_for_merchant(
|
self.service.sync_store_platforms_for_merchant(
|
||||||
@@ -118,7 +72,6 @@ class TestStorePlatformSyncUpdate:
|
|||||||
store_id=test_store.id,
|
store_id=test_store.id,
|
||||||
platform_id=test_platform.id,
|
platform_id=test_platform.id,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
is_primary=True,
|
|
||||||
)
|
)
|
||||||
db.add(sp)
|
db.add(sp)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -137,7 +90,6 @@ class TestStorePlatformSyncUpdate:
|
|||||||
store_id=test_store.id,
|
store_id=test_store.id,
|
||||||
platform_id=test_platform.id,
|
platform_id=test_platform.id,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
is_primary=True,
|
|
||||||
)
|
)
|
||||||
db.add(sp)
|
db.add(sp)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|||||||
@@ -68,10 +68,11 @@ cart_module = ModuleDefinition(
|
|||||||
items=[
|
items=[
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="cart",
|
id="cart",
|
||||||
label_key="storefront.actions.cart",
|
label_key="cart.storefront.actions.cart",
|
||||||
icon="shopping-cart",
|
icon="shopping-cart",
|
||||||
route="cart",
|
route="cart",
|
||||||
order=20,
|
order=20,
|
||||||
|
header_template="cart/storefront/partials/header-cart.html",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
41
app/modules/cart/docs/index.md
Normal file
41
app/modules/cart/docs/index.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Shopping Cart
|
||||||
|
|
||||||
|
Session-based shopping cart for storefronts.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| Code | `cart` |
|
||||||
|
| Classification | Optional |
|
||||||
|
| Dependencies | `inventory`, `catalog` |
|
||||||
|
| Status | Active |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- `cart_management` — Cart creation and management
|
||||||
|
- `cart_persistence` — Session-based cart persistence
|
||||||
|
- `cart_item_operations` — Add/update/remove cart items
|
||||||
|
- `shipping_calculation` — Shipping cost calculation
|
||||||
|
- `promotion_application` — Apply promotions and discounts
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
| Permission | Description |
|
||||||
|
|------------|-------------|
|
||||||
|
| `cart.view` | View cart data |
|
||||||
|
| `cart.manage` | Manage cart settings |
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
- **Cart** — Shopping cart with session tracking and store scoping
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `*` | `/api/v1/storefront/cart/*` | Storefront cart operations |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
No module-specific configuration.
|
||||||
@@ -44,5 +44,10 @@
|
|||||||
"view_desc": "Warenkörbe der Kunden anzeigen",
|
"view_desc": "Warenkörbe der Kunden anzeigen",
|
||||||
"manage": "Warenkörbe verwalten",
|
"manage": "Warenkörbe verwalten",
|
||||||
"manage_desc": "Warenkörbe der Kunden bearbeiten und verwalten"
|
"manage_desc": "Warenkörbe der Kunden bearbeiten und verwalten"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"actions": {
|
||||||
|
"cart": "Warenkorb"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,5 +44,10 @@
|
|||||||
"product_not_available": "Product not available",
|
"product_not_available": "Product not available",
|
||||||
"error_adding": "Error adding item to cart",
|
"error_adding": "Error adding item to cart",
|
||||||
"error_updating": "Error updating cart"
|
"error_updating": "Error updating cart"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"actions": {
|
||||||
|
"cart": "Cart"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,5 +44,10 @@
|
|||||||
"view_desc": "Voir les paniers des clients",
|
"view_desc": "Voir les paniers des clients",
|
||||||
"manage": "Gérer les paniers",
|
"manage": "Gérer les paniers",
|
||||||
"manage_desc": "Modifier et gérer les paniers des clients"
|
"manage_desc": "Modifier et gérer les paniers des clients"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"actions": {
|
||||||
|
"cart": "Panier"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,5 +44,10 @@
|
|||||||
"view_desc": "Clientekuerf kucken",
|
"view_desc": "Clientekuerf kucken",
|
||||||
"manage": "Kuerf verwalten",
|
"manage": "Kuerf verwalten",
|
||||||
"manage_desc": "Clientekuerf änneren a verwalten"
|
"manage_desc": "Clientekuerf änneren a verwalten"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"actions": {
|
||||||
|
"cart": "Kuerf"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
async init() {
|
async init() {
|
||||||
console.log('[SHOP] Cart page initializing...');
|
console.log('[STOREFRONT] Cart page initializing...');
|
||||||
|
|
||||||
// Call parent init to set up sessionId
|
// Call parent init to set up sessionId
|
||||||
if (baseData.init) {
|
if (baseData.init) {
|
||||||
@@ -223,17 +223,17 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`[SHOP] Loading cart for session ${this.sessionId}...`);
|
console.log(`[STOREFRONT] Loading cart for session ${this.sessionId}...`);
|
||||||
const response = await fetch(`/api/v1/storefront/cart/${this.sessionId}`);
|
const response = await fetch(`/api/v1/storefront/cart/${this.sessionId}`);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
this.items = data.items || [];
|
this.items = data.items || [];
|
||||||
this.cartCount = this.totalItems;
|
this.cartCount = this.totalItems;
|
||||||
console.log('[SHOP] Cart loaded:', this.items.length, 'items');
|
console.log('[STOREFRONT] Cart loaded:', this.items.length, 'items');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Failed to load cart:', error);
|
console.error('[STOREFRONT] Failed to load cart:', error);
|
||||||
this.showToast('Failed to load cart', 'error');
|
this.showToast('Failed to load cart', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
@@ -249,7 +249,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.updating = true;
|
this.updating = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[SHOP] Updating quantity:', productId, newQuantity);
|
console.log('[STOREFRONT] Updating quantity:', productId, newQuantity);
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
|
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
|
||||||
{
|
{
|
||||||
@@ -268,7 +268,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
throw new Error('Failed to update quantity');
|
throw new Error('Failed to update quantity');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Update quantity error:', error);
|
console.error('[STOREFRONT] Update quantity error:', error);
|
||||||
this.showToast('Failed to update quantity', 'error');
|
this.showToast('Failed to update quantity', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.updating = false;
|
this.updating = false;
|
||||||
@@ -280,7 +280,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.updating = true;
|
this.updating = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[SHOP] Removing item:', productId);
|
console.log('[STOREFRONT] Removing item:', productId);
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
|
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
|
||||||
{
|
{
|
||||||
@@ -295,7 +295,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
throw new Error('Failed to remove item');
|
throw new Error('Failed to remove item');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Remove item error:', error);
|
console.error('[STOREFRONT] Remove item error:', error);
|
||||||
this.showToast('Failed to remove item', 'error');
|
this.showToast('Failed to remove item', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.updating = false;
|
this.updating = false;
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{# cart/storefront/partials/header-cart.html #}
|
||||||
|
{# Cart icon with badge for storefront header — provided by cart module #}
|
||||||
|
<a href="{{ base_url }}cart" class="relative p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||||
|
<span class="w-5 h-5" x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
|
||||||
|
<span x-show="cartCount > 0"
|
||||||
|
x-text="cartCount"
|
||||||
|
class="absolute -top-1 -right-1 bg-accent text-white text-xs rounded-full h-5 w-5 flex items-center justify-center"
|
||||||
|
style="background-color: var(--color-accent)">
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
@@ -134,7 +134,7 @@ catalog_module = ModuleDefinition(
|
|||||||
items=[
|
items=[
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="products",
|
id="products",
|
||||||
label_key="storefront.nav.products",
|
label_key="catalog.storefront.nav.products",
|
||||||
icon="shopping-bag",
|
icon="shopping-bag",
|
||||||
route="products",
|
route="products",
|
||||||
order=10,
|
order=10,
|
||||||
@@ -148,10 +148,11 @@ catalog_module = ModuleDefinition(
|
|||||||
items=[
|
items=[
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="search",
|
id="search",
|
||||||
label_key="storefront.actions.search",
|
label_key="catalog.storefront.actions.search",
|
||||||
icon="search",
|
icon="search",
|
||||||
route="",
|
route="",
|
||||||
order=10,
|
order=10,
|
||||||
|
header_template="catalog/storefront/partials/header-search.html",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
291
app/modules/catalog/docs/architecture.md
Normal file
291
app/modules/catalog/docs/architecture.md
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
# Product Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The product management system uses an **independent copy pattern** where store products (`Product`) are fully independent entities that can optionally reference a marketplace source (`MarketplaceProduct`) for display purposes only.
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
| Principle | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| **Full Independence** | Store products have all their own fields - no inheritance or fallback to marketplace |
|
||||||
|
| **Optional Source Reference** | `marketplace_product_id` is nullable - products can be created directly |
|
||||||
|
| **No Reset Functionality** | No "reset to source" - products are independent from the moment of creation |
|
||||||
|
| **Source for Display Only** | Source comparison info is read-only, used for "view original" display |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ MarketplaceProduct │
|
||||||
|
│ (Central Repository - raw imported data from marketplaces) │
|
||||||
|
│ │
|
||||||
|
│ - marketplace_product_id (unique) │
|
||||||
|
│ - gtin, mpn, sku │
|
||||||
|
│ - brand, price_cents, sale_price_cents │
|
||||||
|
│ - is_digital, product_type_enum │
|
||||||
|
│ - translations (via MarketplaceProductTranslation) │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
╳ No runtime dependency
|
||||||
|
│
|
||||||
|
│ Optional FK (for "view source" display only)
|
||||||
|
│ marketplace_product_id (nullable)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Product │
|
||||||
|
│ (Store's Independent Product - fully standalone) │
|
||||||
|
│ │
|
||||||
|
│ === IDENTIFIERS === │
|
||||||
|
│ - store_id (required) │
|
||||||
|
│ - store_sku │
|
||||||
|
│ - gtin, gtin_type │
|
||||||
|
│ │
|
||||||
|
│ === PRODUCT TYPE (own columns) === │
|
||||||
|
│ - is_digital (Boolean) │
|
||||||
|
│ - product_type (String: physical, digital, service, subscription) │
|
||||||
|
│ │
|
||||||
|
│ === PRICING === │
|
||||||
|
│ - price_cents, sale_price_cents │
|
||||||
|
│ - currency, tax_rate_percent │
|
||||||
|
│ │
|
||||||
|
│ === CONTENT === │
|
||||||
|
│ - brand, condition, availability │
|
||||||
|
│ - primary_image_url, additional_images │
|
||||||
|
│ - translations (via ProductTranslation) │
|
||||||
|
│ │
|
||||||
|
│ === STATUS === │
|
||||||
|
│ - is_active, is_featured │
|
||||||
|
│ │
|
||||||
|
│ === SUPPLIER === │
|
||||||
|
│ - supplier, cost_cents │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Product Creation Patterns
|
||||||
|
|
||||||
|
### 1. From Marketplace Source (Import)
|
||||||
|
|
||||||
|
When copying from a marketplace product:
|
||||||
|
- All fields are **copied** at creation time
|
||||||
|
- `marketplace_product_id` is set for source reference
|
||||||
|
- No ongoing relationship - product is immediately independent
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Service copies all fields at import time
|
||||||
|
product = Product(
|
||||||
|
store_id=store.id,
|
||||||
|
marketplace_product_id=marketplace_product.id, # Source reference
|
||||||
|
# All fields copied - no inheritance
|
||||||
|
brand=marketplace_product.brand,
|
||||||
|
price=marketplace_product.price,
|
||||||
|
is_digital=marketplace_product.is_digital,
|
||||||
|
product_type=marketplace_product.product_type_enum,
|
||||||
|
# ... all other fields
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Direct Creation (No Marketplace Source)
|
||||||
|
|
||||||
|
Stores can create products directly without a marketplace source:
|
||||||
|
|
||||||
|
```python
|
||||||
|
product = Product(
|
||||||
|
store_id=store.id,
|
||||||
|
marketplace_product_id=None, # No source
|
||||||
|
store_sku="DIRECT_001",
|
||||||
|
brand="MyBrand",
|
||||||
|
price=29.99,
|
||||||
|
is_digital=True,
|
||||||
|
product_type="digital",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Fields
|
||||||
|
|
||||||
|
### Product Type Fields
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `is_digital` | Boolean | `False` | Whether product is digital (no physical shipping) |
|
||||||
|
| `product_type` | String(20) | `"physical"` | Product type: physical, digital, service, subscription |
|
||||||
|
|
||||||
|
These are **independent columns** on Product, not derived from MarketplaceProduct.
|
||||||
|
|
||||||
|
### Source Reference
|
||||||
|
|
||||||
|
| Field | Type | Nullable | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `marketplace_product_id` | Integer FK | **Yes** | Optional reference to source MarketplaceProduct |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inventory Handling
|
||||||
|
|
||||||
|
Digital and physical products have different inventory behavior:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@property
|
||||||
|
def has_unlimited_inventory(self) -> bool:
|
||||||
|
"""Digital products have unlimited inventory."""
|
||||||
|
return self.is_digital
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_inventory(self) -> int:
|
||||||
|
"""Get total inventory across all locations."""
|
||||||
|
if self.is_digital:
|
||||||
|
return Product.UNLIMITED_INVENTORY # 999999
|
||||||
|
return sum(inv.quantity for inv in self.inventory_entries)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Source Comparison (Display Only)
|
||||||
|
|
||||||
|
For products with a marketplace source, we provide comparison info for display:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_source_comparison_info(self) -> dict:
|
||||||
|
"""Get current values with source values for comparison.
|
||||||
|
|
||||||
|
Used for "view original source" display feature.
|
||||||
|
"""
|
||||||
|
mp = self.marketplace_product
|
||||||
|
return {
|
||||||
|
"price": self.price,
|
||||||
|
"price_source": mp.price if mp else None,
|
||||||
|
"brand": self.brand,
|
||||||
|
"brand_source": mp.brand if mp else None,
|
||||||
|
# ... other fields
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is **read-only** - there's no mechanism to "reset" to source values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Behavior
|
||||||
|
|
||||||
|
### Detail Page
|
||||||
|
|
||||||
|
| Product Type | Source Info Card | Edit Button Text |
|
||||||
|
|-------------|------------------|------------------|
|
||||||
|
| Marketplace-sourced | Shows source info with "View Source" link | "Edit Overrides" |
|
||||||
|
| Directly created | Shows "Direct Creation" badge | "Edit Product" |
|
||||||
|
|
||||||
|
### Info Banner
|
||||||
|
|
||||||
|
- **Marketplace-sourced**: Purple banner - "Store Product Catalog Entry"
|
||||||
|
- **Directly created**: Blue banner - "Directly Created Product"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Product Table Key Columns
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE products (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
store_id INTEGER NOT NULL REFERENCES stores(id),
|
||||||
|
marketplace_product_id INTEGER REFERENCES marketplace_products(id), -- Nullable!
|
||||||
|
|
||||||
|
-- Product Type (independent columns)
|
||||||
|
is_digital BOOLEAN DEFAULT FALSE,
|
||||||
|
product_type VARCHAR(20) DEFAULT 'physical',
|
||||||
|
|
||||||
|
-- Identifiers
|
||||||
|
store_sku VARCHAR,
|
||||||
|
gtin VARCHAR,
|
||||||
|
gtin_type VARCHAR(10),
|
||||||
|
brand VARCHAR,
|
||||||
|
|
||||||
|
-- Pricing (in cents)
|
||||||
|
price_cents INTEGER,
|
||||||
|
sale_price_cents INTEGER,
|
||||||
|
currency VARCHAR(3) DEFAULT 'EUR',
|
||||||
|
tax_rate_percent INTEGER DEFAULT 17,
|
||||||
|
availability VARCHAR,
|
||||||
|
|
||||||
|
-- Media
|
||||||
|
primary_image_url VARCHAR,
|
||||||
|
additional_images JSON,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_featured BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for product type queries
|
||||||
|
CREATE INDEX idx_product_is_digital ON products(is_digital);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration History
|
||||||
|
|
||||||
|
| Migration | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `x2c3d4e5f6g7` | Made `marketplace_product_id` nullable |
|
||||||
|
| `y3d4e5f6g7h8` | Added `is_digital` and `product_type` columns to products |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Create Product (Admin)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/admin/store-products
|
||||||
|
{
|
||||||
|
"store_id": 1,
|
||||||
|
"translations": {
|
||||||
|
"en": {"title": "Product Name", "description": "..."},
|
||||||
|
"fr": {"title": "Nom du produit", "description": "..."}
|
||||||
|
},
|
||||||
|
"store_sku": "SKU001",
|
||||||
|
"brand": "BrandName",
|
||||||
|
"price": 29.99,
|
||||||
|
"is_digital": false,
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Product (Admin)
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH /api/v1/admin/store-products/{id}
|
||||||
|
{
|
||||||
|
"is_digital": true,
|
||||||
|
"price": 39.99,
|
||||||
|
"translations": {
|
||||||
|
"en": {"title": "Updated Name"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Key test scenarios:
|
||||||
|
|
||||||
|
1. **Direct Product Creation** - Create without marketplace source
|
||||||
|
2. **Digital Product Inventory** - Verify unlimited inventory for digital
|
||||||
|
3. **is_digital Column** - Verify it's an independent column, not derived
|
||||||
|
4. **Source Comparison** - Verify read-only source info display
|
||||||
|
|
||||||
|
See:
|
||||||
|
- `tests/unit/models/database/test_product.py`
|
||||||
|
- `tests/integration/api/v1/admin/test_store_products.py`
|
||||||
105
app/modules/catalog/docs/data-model.md
Normal file
105
app/modules/catalog/docs/data-model.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Catalog Data Model
|
||||||
|
|
||||||
|
Entity relationships and database schema for the catalog module.
|
||||||
|
|
||||||
|
## Entity Relationship Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
Store 1──* Product 1──* ProductTranslation
|
||||||
|
│
|
||||||
|
├──* ProductMedia *──1 MediaFile
|
||||||
|
│
|
||||||
|
├──? MarketplaceProduct (source)
|
||||||
|
│
|
||||||
|
└──* Inventory (from inventory module)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Models
|
||||||
|
|
||||||
|
### Product
|
||||||
|
|
||||||
|
Store-specific product with independent copy pattern from marketplace imports. All monetary values stored as integer cents.
|
||||||
|
|
||||||
|
| Field | Type | Constraints | Description |
|
||||||
|
|-------|------|-------------|-------------|
|
||||||
|
| `id` | Integer | PK | Primary key |
|
||||||
|
| `store_id` | Integer | FK, not null | Store ownership |
|
||||||
|
| `marketplace_product_id` | Integer | FK, nullable | Optional marketplace source |
|
||||||
|
| `store_sku` | String | indexed | Store's internal SKU |
|
||||||
|
| `gtin` | String(50) | indexed | EAN/UPC barcode |
|
||||||
|
| `gtin_type` | String(20) | nullable | gtin13, gtin14, gtin12, gtin8, isbn13, isbn10 |
|
||||||
|
| `price_cents` | Integer | nullable | Gross price in cents |
|
||||||
|
| `sale_price_cents` | Integer | nullable | Sale price in cents |
|
||||||
|
| `currency` | String(3) | default "EUR" | Currency code |
|
||||||
|
| `brand` | String | nullable | Product brand |
|
||||||
|
| `condition` | String | nullable | Product condition |
|
||||||
|
| `availability` | String | nullable | Availability status |
|
||||||
|
| `primary_image_url` | String | nullable | Main product image URL |
|
||||||
|
| `additional_images` | JSON | nullable | Array of additional image URLs |
|
||||||
|
| `download_url` | String | nullable | Digital product download URL |
|
||||||
|
| `license_type` | String(50) | nullable | Digital product license |
|
||||||
|
| `tax_rate_percent` | Integer | not null, default 17 | VAT rate (LU: 0, 3, 8, 14, 17) |
|
||||||
|
| `supplier` | String(50) | nullable | codeswholesale, internal, etc. |
|
||||||
|
| `supplier_product_id` | String | nullable | Supplier's product reference |
|
||||||
|
| `cost_cents` | Integer | nullable | Cost to acquire in cents |
|
||||||
|
| `margin_percent_x100` | Integer | nullable | Markup × 100 (2550 = 25.5%) |
|
||||||
|
| `is_digital` | Boolean | default False, indexed | Digital vs physical |
|
||||||
|
| `product_type` | String(20) | default "physical" | physical, digital, service, subscription |
|
||||||
|
| `is_featured` | Boolean | default False | Featured flag |
|
||||||
|
| `is_active` | Boolean | default True | Active flag |
|
||||||
|
| `display_order` | Integer | default 0 | Sort order |
|
||||||
|
| `min_quantity` | Integer | default 1 | Min purchase quantity |
|
||||||
|
| `max_quantity` | Integer | nullable | Max purchase quantity |
|
||||||
|
| `fulfillment_email_template` | String | nullable | Template for digital delivery |
|
||||||
|
| `created_at` | DateTime | tz-aware | Record creation time |
|
||||||
|
| `updated_at` | DateTime | tz-aware | Record update time |
|
||||||
|
|
||||||
|
**Unique Constraint**: `(store_id, marketplace_product_id)`
|
||||||
|
**Composite Indexes**: `(store_id, is_active)`, `(store_id, is_featured)`, `(store_id, store_sku)`, `(supplier, supplier_product_id)`
|
||||||
|
|
||||||
|
**Key Properties**: `price`, `sale_price`, `cost` (euro converters), `net_price_cents` (gross minus VAT), `vat_amount_cents`, `profit_cents`, `profit_margin_percent`, `total_inventory`, `available_inventory`
|
||||||
|
|
||||||
|
### ProductTranslation
|
||||||
|
|
||||||
|
Store-specific multilingual content with SEO fields. Independent copy from marketplace translations.
|
||||||
|
|
||||||
|
| Field | Type | Constraints | Description |
|
||||||
|
|-------|------|-------------|-------------|
|
||||||
|
| `id` | Integer | PK | Primary key |
|
||||||
|
| `product_id` | Integer | FK, not null, cascade | Parent product |
|
||||||
|
| `language` | String(5) | not null | en, fr, de, lb |
|
||||||
|
| `title` | String | nullable | Product title |
|
||||||
|
| `description` | Text | nullable | Full description |
|
||||||
|
| `short_description` | String(500) | nullable | Abbreviated description |
|
||||||
|
| `meta_title` | String(70) | nullable | SEO title |
|
||||||
|
| `meta_description` | String(160) | nullable | SEO description |
|
||||||
|
| `url_slug` | String(255) | nullable | URL-friendly slug |
|
||||||
|
| `created_at` | DateTime | tz-aware | Record creation time |
|
||||||
|
| `updated_at` | DateTime | tz-aware | Record update time |
|
||||||
|
|
||||||
|
**Unique Constraint**: `(product_id, language)`
|
||||||
|
|
||||||
|
### ProductMedia
|
||||||
|
|
||||||
|
Association between products and media files with usage tracking.
|
||||||
|
|
||||||
|
| Field | Type | Constraints | Description |
|
||||||
|
|-------|------|-------------|-------------|
|
||||||
|
| `id` | Integer | PK | Primary key |
|
||||||
|
| `product_id` | Integer | FK, not null, cascade | Product reference |
|
||||||
|
| `media_id` | Integer | FK, not null, cascade | Media file reference |
|
||||||
|
| `usage_type` | String(50) | default "gallery" | main_image, gallery, variant, thumbnail, swatch |
|
||||||
|
| `display_order` | Integer | default 0 | Sort order |
|
||||||
|
| `variant_id` | Integer | nullable | Variant reference |
|
||||||
|
| `created_at` | DateTime | tz-aware | Record creation time |
|
||||||
|
| `updated_at` | DateTime | tz-aware | Record update time |
|
||||||
|
|
||||||
|
**Unique Constraint**: `(product_id, media_id, usage_type)`
|
||||||
|
|
||||||
|
## Design Patterns
|
||||||
|
|
||||||
|
- **Independent copy pattern**: Products are copied from marketplace sources, not linked. Store-specific data diverges independently.
|
||||||
|
- **Money as cents**: All prices, costs, margins stored as integer cents
|
||||||
|
- **Luxembourg VAT**: Supports all LU rates (0%, 3%, 8%, 14%, 17%)
|
||||||
|
- **Multi-type products**: Physical, digital, service, subscription with type-specific fields
|
||||||
|
- **SEO per language**: Meta title and description in each translation
|
||||||
57
app/modules/catalog/docs/index.md
Normal file
57
app/modules/catalog/docs/index.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Product Catalog
|
||||||
|
|
||||||
|
Product catalog browsing and search for storefronts.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| Code | `catalog` |
|
||||||
|
| Classification | Optional |
|
||||||
|
| Dependencies | None |
|
||||||
|
| Status | Active |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- `product_catalog` — Product catalog browsing
|
||||||
|
- `product_search` — Product search and filtering
|
||||||
|
- `product_variants` — Product variant management
|
||||||
|
- `product_categories` — Category hierarchy
|
||||||
|
- `product_attributes` — Custom product attributes
|
||||||
|
- `product_import_export` — Bulk product import/export
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
| Permission | Description |
|
||||||
|
|------------|-------------|
|
||||||
|
| `products.view` | View products |
|
||||||
|
| `products.create` | Create products |
|
||||||
|
| `products.edit` | Edit products |
|
||||||
|
| `products.delete` | Delete products |
|
||||||
|
| `products.import` | Import products |
|
||||||
|
| `products.export` | Export products |
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
See [Data Model](data-model.md) for full entity relationships and schema.
|
||||||
|
|
||||||
|
- **Product** — Store-specific product with pricing, VAT, and supplier fields
|
||||||
|
- **ProductTranslation** — Multilingual content with SEO fields
|
||||||
|
- **ProductMedia** — Product-media associations with usage types
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `*` | `/api/v1/admin/catalog/*` | Admin product management |
|
||||||
|
| `*` | `/api/v1/store/catalog/*` | Store product management |
|
||||||
|
| `GET` | `/api/v1/storefront/catalog/*` | Public product browsing |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
No module-specific configuration.
|
||||||
|
|
||||||
|
## Additional Documentation
|
||||||
|
|
||||||
|
- [Data Model](data-model.md) — Entity relationships and database schema
|
||||||
|
- [Architecture](architecture.md) — Independent product copy pattern and API design
|
||||||
@@ -89,5 +89,13 @@
|
|||||||
"products_import_desc": "Massenimport von Produkten",
|
"products_import_desc": "Massenimport von Produkten",
|
||||||
"products_export": "Produkte exportieren",
|
"products_export": "Produkte exportieren",
|
||||||
"products_export_desc": "Produktdaten exportieren"
|
"products_export_desc": "Produktdaten exportieren"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"nav": {
|
||||||
|
"products": "Produkte"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"search": "Suchen"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,5 +107,13 @@
|
|||||||
"menu": {
|
"menu": {
|
||||||
"products_inventory": "Products & Inventory",
|
"products_inventory": "Products & Inventory",
|
||||||
"all_products": "All Products"
|
"all_products": "All Products"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"nav": {
|
||||||
|
"products": "Products"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"search": "Search"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,5 +89,13 @@
|
|||||||
"products_import_desc": "Importation en masse de produits",
|
"products_import_desc": "Importation en masse de produits",
|
||||||
"products_export": "Exporter les produits",
|
"products_export": "Exporter les produits",
|
||||||
"products_export_desc": "Exporter les données produits"
|
"products_export_desc": "Exporter les données produits"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"nav": {
|
||||||
|
"products": "Produits"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"search": "Rechercher"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,5 +89,13 @@
|
|||||||
"products_import_desc": "Massenimport vu Produiten",
|
"products_import_desc": "Massenimport vu Produiten",
|
||||||
"products_export": "Produiten exportéieren",
|
"products_export": "Produiten exportéieren",
|
||||||
"products_export_desc": "Produitdaten exportéieren"
|
"products_export_desc": "Produitdaten exportéieren"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"nav": {
|
||||||
|
"products": "Produkter"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"search": "Sichen"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ from sqlalchemy.orm import relationship
|
|||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
from app.utils.money import cents_to_euros, euros_to_cents
|
from app.utils.money import cents_to_euros, euros_to_cents
|
||||||
from models.database.base import TimestampMixin
|
from models.database.base import SoftDeleteMixin, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
class Product(Base, TimestampMixin):
|
class Product(Base, TimestampMixin, SoftDeleteMixin):
|
||||||
"""Store-specific product.
|
"""Store-specific product.
|
||||||
|
|
||||||
Products can be created from marketplace imports or directly by stores.
|
Products can be created from marketplace imports or directly by stores.
|
||||||
|
|||||||
@@ -30,11 +30,10 @@ router = APIRouter()
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
|
|
||||||
@router.get("/products", response_class=HTMLResponse, include_in_schema=False)
|
@router.get("/products", response_class=HTMLResponse, include_in_schema=False)
|
||||||
async def shop_products_page(request: Request, db: Session = Depends(get_db)):
|
async def shop_products_page(request: Request, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Render shop homepage / product catalog.
|
Render product catalog listing.
|
||||||
Shows featured products and categories.
|
Shows featured products and categories.
|
||||||
"""
|
"""
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
@@ -192,9 +192,11 @@ class ProductService:
|
|||||||
True if deleted
|
True if deleted
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
from app.core.soft_delete import soft_delete
|
||||||
|
|
||||||
product = self.get_product(db, store_id, product_id)
|
product = self.get_product(db, store_id, product_id)
|
||||||
|
|
||||||
db.delete(product)
|
soft_delete(db, product, deleted_by_id=None)
|
||||||
|
|
||||||
logger.info(f"Deleted product {product_id} from store {store_id} catalog")
|
logger.info(f"Deleted product {product_id} from store {store_id} catalog")
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
{% from 'shared/macros/headers.html' import detail_page_header %}
|
{% from 'shared/macros/headers.html' import detail_page_header %}
|
||||||
{% from 'shared/macros/modals.html' import media_picker_modal %}
|
{% from 'shared/macros/modals.html' import media_picker_modal %}
|
||||||
{% from 'shared/macros/richtext.html' import quill_css, quill_js, quill_editor %}
|
{% from 'shared/macros/richtext.html' import quill_css, quill_js, quill_editor %}
|
||||||
|
{% from 'shared/macros/inputs.html' import entity_selector %}
|
||||||
|
|
||||||
{% block title %}Create Store Product{% endblock %}
|
{% block title %}Create Store Product{% endblock %}
|
||||||
|
|
||||||
@@ -16,48 +17,6 @@
|
|||||||
{{ quill_js() }}
|
{{ quill_js() }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
|
||||||
<!-- Tom Select CSS with local fallback -->
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/css/tom-select.default.min.css"
|
|
||||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/store/tom-select.default.min.css') }}';"
|
|
||||||
/>
|
|
||||||
<style>
|
|
||||||
/* Tom Select dark mode overrides */
|
|
||||||
.dark .ts-wrapper .ts-control {
|
|
||||||
background-color: rgb(55 65 81);
|
|
||||||
border-color: rgb(75 85 99);
|
|
||||||
color: rgb(209 213 219);
|
|
||||||
}
|
|
||||||
.dark .ts-wrapper .ts-control input {
|
|
||||||
color: rgb(209 213 219);
|
|
||||||
}
|
|
||||||
.dark .ts-wrapper .ts-control input::placeholder {
|
|
||||||
color: rgb(156 163 175);
|
|
||||||
}
|
|
||||||
.dark .ts-dropdown {
|
|
||||||
background-color: rgb(55 65 81);
|
|
||||||
border-color: rgb(75 85 99);
|
|
||||||
color: rgb(209 213 219);
|
|
||||||
}
|
|
||||||
.dark .ts-dropdown .option {
|
|
||||||
color: rgb(209 213 219);
|
|
||||||
}
|
|
||||||
.dark .ts-dropdown .option.active {
|
|
||||||
background-color: rgb(147 51 234);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
.dark .ts-dropdown .option:hover {
|
|
||||||
background-color: rgb(75 85 99);
|
|
||||||
}
|
|
||||||
.dark .ts-wrapper.focus .ts-control {
|
|
||||||
border-color: rgb(147 51 234);
|
|
||||||
box-shadow: 0 0 0 1px rgb(147 51 234);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% call detail_page_header("'Create Store Product'", '/admin/store-products') %}
|
{% call detail_page_header("'Create Store Product'", '/admin/store-products') %}
|
||||||
<span>Add a new product to a store's catalog</span>
|
<span>Add a new product to a store's catalog</span>
|
||||||
@@ -72,8 +31,7 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<div class="max-w-md">
|
<div class="max-w-md">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Select Store <span class="text-red-500">*</span></label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Select Store <span class="text-red-500">*</span></label>
|
||||||
<select id="store-select" x-ref="storeSelect" placeholder="Search store...">
|
{{ entity_selector(ref_name='storeSelect', id='store-select', placeholder='Search store...', width='w-full') }}
|
||||||
</select>
|
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">The store whose catalog this product will be added to</p>
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">The store whose catalog this product will be added to</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,62 +5,18 @@
|
|||||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||||
{% from 'shared/macros/tables.html' import table_wrapper %}
|
{% from 'shared/macros/tables.html' import table_wrapper %}
|
||||||
{% from 'shared/macros/modals.html' import modal_simple %}
|
{% from 'shared/macros/modals.html' import modal_simple %}
|
||||||
|
{% from 'shared/macros/inputs.html' import entity_selector, entity_selected_badge %}
|
||||||
|
|
||||||
{% block title %}Store Products{% endblock %}
|
{% block title %}Store Products{% endblock %}
|
||||||
|
|
||||||
{% block alpine_data %}adminStoreProducts(){% endblock %}
|
{% block alpine_data %}adminStoreProducts(){% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
|
||||||
<!-- Tom Select CSS with local fallback -->
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/css/tom-select.default.min.css"
|
|
||||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/store/tom-select.default.min.css') }}';"
|
|
||||||
/>
|
|
||||||
<style>
|
|
||||||
/* Tom Select dark mode overrides */
|
|
||||||
.dark .ts-wrapper .ts-control {
|
|
||||||
background-color: rgb(55 65 81);
|
|
||||||
border-color: rgb(75 85 99);
|
|
||||||
color: rgb(209 213 219);
|
|
||||||
}
|
|
||||||
.dark .ts-wrapper .ts-control input {
|
|
||||||
color: rgb(209 213 219);
|
|
||||||
}
|
|
||||||
.dark .ts-wrapper .ts-control input::placeholder {
|
|
||||||
color: rgb(156 163 175);
|
|
||||||
}
|
|
||||||
.dark .ts-dropdown {
|
|
||||||
background-color: rgb(55 65 81);
|
|
||||||
border-color: rgb(75 85 99);
|
|
||||||
color: rgb(209 213 219);
|
|
||||||
}
|
|
||||||
.dark .ts-dropdown .option {
|
|
||||||
color: rgb(209 213 219);
|
|
||||||
}
|
|
||||||
.dark .ts-dropdown .option.active {
|
|
||||||
background-color: rgb(147 51 234);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
.dark .ts-dropdown .option:hover {
|
|
||||||
background-color: rgb(75 85 99);
|
|
||||||
}
|
|
||||||
.dark .ts-wrapper.focus .ts-control {
|
|
||||||
border-color: rgb(147 51 234);
|
|
||||||
box-shadow: 0 0 0 1px rgb(147 51 234);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Page Header with Store Selector -->
|
<!-- Page Header with Store Selector -->
|
||||||
{% call page_header_flex(title='Store Products', subtitle='Browse store-specific product catalogs with override capability') %}
|
{% call page_header_flex(title='Store Products', subtitle='Browse store-specific product catalogs with override capability') %}
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<!-- Store Autocomplete (Tom Select) -->
|
<!-- Store Autocomplete (Tom Select) -->
|
||||||
<div class="w-80">
|
{{ entity_selector(ref_name='storeSelect', id='store-select', placeholder='Filter by store...') }}
|
||||||
<select id="store-select" x-ref="storeSelect" placeholder="Filter by store...">
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{{ refresh_button(loading_var='loading', onclick='refresh()', variant='secondary') }}
|
{{ refresh_button(loading_var='loading', onclick='refresh()', variant='secondary') }}
|
||||||
<a
|
<a
|
||||||
href="/admin/store-products/create"
|
href="/admin/store-products/create"
|
||||||
@@ -73,23 +29,7 @@
|
|||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
|
||||||
<!-- Selected Store Info -->
|
<!-- Selected Store Info -->
|
||||||
<div x-show="selectedStore" x-transition class="mb-6 p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
{{ entity_selected_badge(entity_var='selectedStore', clear_fn='clearStoreFilter()', code_field='store_code', color='purple') }}
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="w-8 h-8 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
|
||||||
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300" x-text="selectedStore?.name?.charAt(0).toUpperCase()"></span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="font-medium text-purple-800 dark:text-purple-200" x-text="selectedStore?.name"></span>
|
|
||||||
<span class="ml-2 text-xs text-purple-600 dark:text-purple-400 font-mono" x-text="selectedStore?.store_code"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button @click="clearStoreFilter()" class="text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 text-sm flex items-center gap-1">
|
|
||||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
|
||||||
Clear filter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{ loading_state('Loading products...') }}
|
{{ loading_state('Loading products...') }}
|
||||||
|
|
||||||
|
|||||||
@@ -187,8 +187,8 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
console.log('[SHOP] Category page initializing...');
|
console.log('[STOREFRONT] Category page initializing...');
|
||||||
console.log('[SHOP] Category slug:', this.categorySlug);
|
console.log('[STOREFRONT] Category slug:', this.categorySlug);
|
||||||
|
|
||||||
// Convert slug to display name
|
// Convert slug to display name
|
||||||
this.categoryName = this.categorySlug
|
this.categoryName = this.categorySlug
|
||||||
@@ -213,7 +213,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
params.append('sort', this.sortBy);
|
params.append('sort', this.sortBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[SHOP] Loading category products from /api/v1/storefront/products?${params}`);
|
console.log(`[STOREFRONT] Loading category products from /api/v1/storefront/products?${params}`);
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/storefront/products?${params}`);
|
const response = await fetch(`/api/v1/storefront/products?${params}`);
|
||||||
|
|
||||||
@@ -223,12 +223,12 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
console.log(`[SHOP] Loaded ${data.products.length} products (total: ${data.total})`);
|
console.log(`[STOREFRONT] Loaded ${data.products.length} products (total: ${data.total})`);
|
||||||
|
|
||||||
this.products = data.products;
|
this.products = data.products;
|
||||||
this.total = data.total;
|
this.total = data.total;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Failed to load category products:', error);
|
console.error('[STOREFRONT] Failed to load category products:', error);
|
||||||
this.showToast('Failed to load products', 'error');
|
this.showToast('Failed to load products', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
@@ -243,7 +243,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async addToCart(product) {
|
async addToCart(product) {
|
||||||
console.log('[SHOP] Adding to cart:', product);
|
console.log('[STOREFRONT] Adding to cart:', product);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||||
@@ -262,16 +262,16 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.log('[SHOP] Add to cart success:', result);
|
console.log('[STOREFRONT] Add to cart success:', result);
|
||||||
this.cartCount += 1;
|
this.cartCount += 1;
|
||||||
this.showToast(`${product.marketplace_product.title} added to cart`, 'success');
|
this.showToast(`${product.marketplace_product.title} added to cart`, 'success');
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
console.error('[SHOP] Add to cart error:', error);
|
console.error('[STOREFRONT] Add to cart error:', error);
|
||||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Add to cart exception:', error);
|
console.error('[STOREFRONT] Add to cart exception:', error);
|
||||||
this.showToast('Failed to add to cart', 'error');
|
this.showToast('Failed to add to cart', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{# catalog/storefront/partials/header-search.html #}
|
||||||
|
{# Search button for storefront header — provided by catalog module #}
|
||||||
|
<button @click="openSearch()" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||||
|
<span class="w-5 h-5" x-html="$icon('search', 'w-5 h-5')"></span>
|
||||||
|
</button>
|
||||||
@@ -256,16 +256,16 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
async init() {
|
async init() {
|
||||||
console.log('[SHOP] Product detail page initializing...');
|
console.log('[STOREFRONT] Product detail page initializing...');
|
||||||
|
|
||||||
// Call parent init to set up sessionId
|
// Call parent init to set up sessionId
|
||||||
if (baseData.init) {
|
if (baseData.init) {
|
||||||
baseData.init.call(this);
|
baseData.init.call(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[SHOP] Product ID:', this.productId);
|
console.log('[STOREFRONT] Product ID:', this.productId);
|
||||||
console.log('[SHOP] Store ID:', this.storeId);
|
console.log('[STOREFRONT] Store ID:', this.storeId);
|
||||||
console.log('[SHOP] Session ID:', this.sessionId);
|
console.log('[STOREFRONT] Session ID:', this.sessionId);
|
||||||
|
|
||||||
await this.loadProduct();
|
await this.loadProduct();
|
||||||
},
|
},
|
||||||
@@ -275,7 +275,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`[SHOP] Loading product ${this.productId}...`);
|
console.log(`[STOREFRONT] Loading product ${this.productId}...`);
|
||||||
const response = await fetch(`/api/v1/storefront/products/${this.productId}`);
|
const response = await fetch(`/api/v1/storefront/products/${this.productId}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -283,7 +283,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.product = await response.json();
|
this.product = await response.json();
|
||||||
console.log('[SHOP] Product loaded:', this.product);
|
console.log('[STOREFRONT] Product loaded:', this.product);
|
||||||
|
|
||||||
// Set default image
|
// Set default image
|
||||||
if (this.product?.marketplace_product?.image_link) {
|
if (this.product?.marketplace_product?.image_link) {
|
||||||
@@ -297,7 +297,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
await this.loadRelatedProducts();
|
await this.loadRelatedProducts();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Failed to load product:', error);
|
console.error('[STOREFRONT] Failed to load product:', error);
|
||||||
this.showToast('Failed to load product', 'error');
|
this.showToast('Failed to load product', 'error');
|
||||||
// Redirect back to products after error
|
// Redirect back to products after error
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -320,10 +320,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
.filter(p => p.id !== parseInt(this.productId))
|
.filter(p => p.id !== parseInt(this.productId))
|
||||||
.slice(0, 4);
|
.slice(0, 4);
|
||||||
|
|
||||||
console.log('[SHOP] Loaded related products:', this.relatedProducts.length);
|
console.log('[STOREFRONT] Loaded related products:', this.relatedProducts.length);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Failed to load related products:', error);
|
console.error('[STOREFRONT] Failed to load related products:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -356,7 +356,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
// Add to cart
|
// Add to cart
|
||||||
async addToCart() {
|
async addToCart() {
|
||||||
if (!this.canAddToCart) {
|
if (!this.canAddToCart) {
|
||||||
console.warn('[SHOP] Cannot add to cart:', {
|
console.warn('[STOREFRONT] Cannot add to cart:', {
|
||||||
canAddToCart: this.canAddToCart,
|
canAddToCart: this.canAddToCart,
|
||||||
isActive: this.product?.is_active,
|
isActive: this.product?.is_active,
|
||||||
inventory: this.product?.available_inventory,
|
inventory: this.product?.available_inventory,
|
||||||
@@ -374,7 +374,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
quantity: this.quantity
|
quantity: this.quantity
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[SHOP] Adding to cart:', {
|
console.log('[STOREFRONT] Adding to cart:', {
|
||||||
url,
|
url,
|
||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
productId: this.productId,
|
productId: this.productId,
|
||||||
@@ -390,14 +390,14 @@ document.addEventListener('alpine:init', () => {
|
|||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[SHOP] Add to cart response:', {
|
console.log('[STOREFRONT] Add to cart response:', {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
ok: response.ok
|
ok: response.ok
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.log('[SHOP] Add to cart success:', result);
|
console.log('[STOREFRONT] Add to cart success:', result);
|
||||||
|
|
||||||
this.cartCount += this.quantity;
|
this.cartCount += this.quantity;
|
||||||
this.showToast(
|
this.showToast(
|
||||||
@@ -409,11 +409,11 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.quantity = this.product?.min_quantity || 1;
|
this.quantity = this.product?.min_quantity || 1;
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
console.error('[SHOP] Add to cart error response:', error);
|
console.error('[STOREFRONT] Add to cart error response:', error);
|
||||||
throw new Error(error.detail || 'Failed to add to cart');
|
throw new Error(error.detail || 'Failed to add to cart');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Add to cart exception:', error);
|
console.error('[STOREFRONT] Add to cart exception:', error);
|
||||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.addingToCart = false;
|
this.addingToCart = false;
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
console.log('[SHOP] Products page initializing...');
|
console.log('[STOREFRONT] Products page initializing...');
|
||||||
await this.loadProducts();
|
await this.loadProducts();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
params.append('search', this.filters.search);
|
params.append('search', this.filters.search);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[SHOP] Loading products from /api/v1/storefront/products?${params}`);
|
console.log(`[STOREFRONT] Loading products from /api/v1/storefront/products?${params}`);
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/storefront/products?${params}`);
|
const response = await fetch(`/api/v1/storefront/products?${params}`);
|
||||||
|
|
||||||
@@ -188,12 +188,12 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
console.log(`[SHOP] Loaded ${data.products.length} products (total: ${data.total})`);
|
console.log(`[STOREFRONT] Loaded ${data.products.length} products (total: ${data.total})`);
|
||||||
|
|
||||||
this.products = data.products;
|
this.products = data.products;
|
||||||
this.pagination.total = data.total;
|
this.pagination.total = data.total;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Failed to load products:', error);
|
console.error('[STOREFRONT] Failed to load products:', error);
|
||||||
this.showToast('Failed to load products', 'error');
|
this.showToast('Failed to load products', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
@@ -208,7 +208,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
// formatPrice is inherited from storefrontLayoutData() via spread operator
|
// formatPrice is inherited from storefrontLayoutData() via spread operator
|
||||||
|
|
||||||
async addToCart(product) {
|
async addToCart(product) {
|
||||||
console.log('[SHOP] Adding to cart:', product);
|
console.log('[STOREFRONT] Adding to cart:', product);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||||
@@ -227,16 +227,16 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.log('[SHOP] Add to cart success:', result);
|
console.log('[STOREFRONT] Add to cart success:', result);
|
||||||
this.cartCount += 1;
|
this.cartCount += 1;
|
||||||
this.showToast(`${product.marketplace_product.title} added to cart`, 'success');
|
this.showToast(`${product.marketplace_product.title} added to cart`, 'success');
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
console.error('[SHOP] Add to cart error:', error);
|
console.error('[STOREFRONT] Add to cart error:', error);
|
||||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Add to cart exception:', error);
|
console.error('[STOREFRONT] Add to cart exception:', error);
|
||||||
this.showToast('Failed to add to cart', 'error');
|
this.showToast('Failed to add to cart', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
console.log('[SHOP] Search page initializing...');
|
console.log('[STOREFRONT] Search page initializing...');
|
||||||
|
|
||||||
// Check for query parameter in URL
|
// Check for query parameter in URL
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
@@ -254,7 +254,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
limit: this.perPage
|
limit: this.perPage
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[SHOP] Searching: /api/v1/storefront/products/search?${params}`);
|
console.log(`[STOREFRONT] Searching: /api/v1/storefront/products/search?${params}`);
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/storefront/products/search?${params}`);
|
const response = await fetch(`/api/v1/storefront/products/search?${params}`);
|
||||||
|
|
||||||
@@ -264,12 +264,12 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
console.log(`[SHOP] Search found ${data.total} results`);
|
console.log(`[STOREFRONT] Search found ${data.total} results`);
|
||||||
|
|
||||||
this.products = data.products;
|
this.products = data.products;
|
||||||
this.total = data.total;
|
this.total = data.total;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Search failed:', error);
|
console.error('[STOREFRONT] Search failed:', error);
|
||||||
this.showToast('Search failed. Please try again.', 'error');
|
this.showToast('Search failed. Please try again.', 'error');
|
||||||
this.products = [];
|
this.products = [];
|
||||||
this.total = 0;
|
this.total = 0;
|
||||||
@@ -289,7 +289,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async addToCart(product) {
|
async addToCart(product) {
|
||||||
console.log('[SHOP] Adding to cart:', product);
|
console.log('[STOREFRONT] Adding to cart:', product);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||||
@@ -308,16 +308,16 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.log('[SHOP] Add to cart success:', result);
|
console.log('[STOREFRONT] Add to cart success:', result);
|
||||||
this.cartCount += 1;
|
this.cartCount += 1;
|
||||||
this.showToast(`${product.marketplace_product?.title || 'Product'} added to cart`, 'success');
|
this.showToast(`${product.marketplace_product?.title || 'Product'} added to cart`, 'success');
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
console.error('[SHOP] Add to cart error:', error);
|
console.error('[STOREFRONT] Add to cart error:', error);
|
||||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Add to cart exception:', error);
|
console.error('[STOREFRONT] Add to cart exception:', error);
|
||||||
this.showToast('Failed to add to cart', 'error');
|
this.showToast('Failed to add to cart', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
console.log('[SHOP] Wishlist page initializing...');
|
console.log('[STOREFRONT] Wishlist page initializing...');
|
||||||
|
|
||||||
// Check if user is logged in
|
// Check if user is logged in
|
||||||
this.isLoggedIn = await this.checkLoginStatus();
|
this.isLoggedIn = await this.checkLoginStatus();
|
||||||
@@ -168,7 +168,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[SHOP] Loading wishlist...');
|
console.log('[STOREFRONT] Loading wishlist...');
|
||||||
|
|
||||||
const response = await fetch('/api/v1/storefront/wishlist');
|
const response = await fetch('/api/v1/storefront/wishlist');
|
||||||
|
|
||||||
@@ -182,11 +182,11 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
console.log(`[SHOP] Loaded ${data.items?.length || 0} wishlist items`);
|
console.log(`[STOREFRONT] Loaded ${data.items?.length || 0} wishlist items`);
|
||||||
|
|
||||||
this.items = data.items || [];
|
this.items = data.items || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Failed to load wishlist:', error);
|
console.error('[STOREFRONT] Failed to load wishlist:', error);
|
||||||
this.showToast('Failed to load wishlist', 'error');
|
this.showToast('Failed to load wishlist', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
@@ -195,7 +195,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
async removeFromWishlist(item) {
|
async removeFromWishlist(item) {
|
||||||
try {
|
try {
|
||||||
console.log('[SHOP] Removing from wishlist:', item);
|
console.log('[STOREFRONT] Removing from wishlist:', item);
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/storefront/wishlist/${item.id}`, {
|
const response = await fetch(`/api/v1/storefront/wishlist/${item.id}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
@@ -208,13 +208,13 @@ document.addEventListener('alpine:init', () => {
|
|||||||
throw new Error('Failed to remove from wishlist');
|
throw new Error('Failed to remove from wishlist');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Failed to remove from wishlist:', error);
|
console.error('[STOREFRONT] Failed to remove from wishlist:', error);
|
||||||
this.showToast('Failed to remove from wishlist', 'error');
|
this.showToast('Failed to remove from wishlist', 'error');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async addToCart(product) {
|
async addToCart(product) {
|
||||||
console.log('[SHOP] Adding to cart:', product);
|
console.log('[STOREFRONT] Adding to cart:', product);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||||
@@ -233,16 +233,16 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.log('[SHOP] Add to cart success:', result);
|
console.log('[STOREFRONT] Add to cart success:', result);
|
||||||
this.cartCount += 1;
|
this.cartCount += 1;
|
||||||
this.showToast(`${product.marketplace_product?.title || 'Product'} added to cart`, 'success');
|
this.showToast(`${product.marketplace_product?.title || 'Product'} added to cart`, 'success');
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
console.error('[SHOP] Add to cart error:', error);
|
console.error('[STOREFRONT] Add to cart error:', error);
|
||||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Add to cart exception:', error);
|
console.error('[STOREFRONT] Add to cart exception:', error);
|
||||||
this.showToast('Failed to add to cart', 'error');
|
this.showToast('Failed to add to cart', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
app/modules/checkout/docs/index.md
Normal file
41
app/modules/checkout/docs/index.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Checkout
|
||||||
|
|
||||||
|
Checkout and order creation for storefronts.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| Code | `checkout` |
|
||||||
|
| Classification | Optional |
|
||||||
|
| Dependencies | `cart`, `orders`, `payments`, `customers` |
|
||||||
|
| Status | Active |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- `checkout_flow` — Multi-step checkout process
|
||||||
|
- `order_creation` — Cart-to-order conversion
|
||||||
|
- `payment_processing` — Payment integration during checkout
|
||||||
|
- `checkout_validation` — Address and cart validation
|
||||||
|
- `guest_checkout` — Checkout without customer account
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
| Permission | Description |
|
||||||
|
|------------|-------------|
|
||||||
|
| `checkout.view_settings` | View checkout settings |
|
||||||
|
| `checkout.manage_settings` | Manage checkout configuration |
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
Checkout is a stateless flow that creates orders — no dedicated models.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `*` | `/api/v1/storefront/checkout/*` | Storefront checkout flow |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
No module-specific configuration.
|
||||||
@@ -11,7 +11,6 @@ Requires customer authentication for order placement.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -92,15 +91,21 @@ def place_order(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update customer stats
|
# Update customer order stats (owned by orders module)
|
||||||
customer.total_orders = (customer.total_orders or 0) + 1
|
from app.modules.orders.services.customer_order_service import (
|
||||||
customer.total_spent = (customer.total_spent or 0) + order.total_amount
|
customer_order_service,
|
||||||
customer.last_order_date = datetime.now(UTC)
|
)
|
||||||
db.flush()
|
|
||||||
|
stats = customer_order_service.record_order(
|
||||||
|
db=db,
|
||||||
|
store_id=store.id,
|
||||||
|
customer_id=customer.id,
|
||||||
|
total_amount_cents=order.total_amount_cents,
|
||||||
|
)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Updated customer stats: total_orders={customer.total_orders}, "
|
f"Updated customer order stats: total_orders={stats.total_orders}, "
|
||||||
f"total_spent={customer.total_spent}"
|
f"total_spent_cents={stats.total_spent_cents}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Clear cart (get session_id from request cookies or headers)
|
# Clear cart (get session_id from request cookies or headers)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
|
|||||||
# api_timeout: int = 30
|
# api_timeout: int = 30
|
||||||
# batch_size: int = 100
|
# batch_size: int = 100
|
||||||
|
|
||||||
model_config = {"env_prefix": "CMS_"}
|
model_config = {"env_prefix": "CMS_", "env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
# Export for auto-discovery
|
# Export for auto-discovery
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any
|
|||||||
|
|
||||||
header_pages = []
|
header_pages = []
|
||||||
footer_pages = []
|
footer_pages = []
|
||||||
|
legal_pages = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
header_pages = content_page_service.list_platform_pages(
|
header_pages = content_page_service.list_platform_pages(
|
||||||
@@ -53,8 +54,11 @@ def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any
|
|||||||
footer_pages = content_page_service.list_platform_pages(
|
footer_pages = content_page_service.list_platform_pages(
|
||||||
db, platform_id=platform_id, footer_only=True, include_unpublished=False
|
db, platform_id=platform_id, footer_only=True, include_unpublished=False
|
||||||
)
|
)
|
||||||
|
legal_pages = content_page_service.list_platform_pages(
|
||||||
|
db, platform_id=platform_id, legal_only=True, include_unpublished=False
|
||||||
|
)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[CMS] Platform context: {len(header_pages)} header, {len(footer_pages)} footer pages"
|
f"[CMS] Platform context: {len(header_pages)} header, {len(footer_pages)} footer, {len(legal_pages)} legal pages"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[CMS] Failed to load platform navigation pages: {e}")
|
logger.warning(f"[CMS] Failed to load platform navigation pages: {e}")
|
||||||
@@ -62,7 +66,7 @@ def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any
|
|||||||
return {
|
return {
|
||||||
"header_pages": header_pages,
|
"header_pages": header_pages,
|
||||||
"footer_pages": footer_pages,
|
"footer_pages": footer_pages,
|
||||||
"legal_pages": [], # TODO: Add legal pages support if needed
|
"legal_pages": legal_pages,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
604
app/modules/cms/docs/architecture.md
Normal file
604
app/modules/cms/docs/architecture.md
Normal file
@@ -0,0 +1,604 @@
|
|||||||
|
# Content Management System (CMS)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Content Management System allows platform administrators and stores to manage static content pages like About, FAQ, Contact, Shipping, Returns, Privacy Policy, Terms of Service, etc.
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
- ✅ Platform-level default content
|
||||||
|
- ✅ Store-specific overrides
|
||||||
|
- ✅ Fallback system (store → platform default)
|
||||||
|
- ✅ Rich text content (HTML/Markdown)
|
||||||
|
- ✅ SEO metadata
|
||||||
|
- ✅ Published/Draft status
|
||||||
|
- ✅ Navigation management (footer/header)
|
||||||
|
- ✅ Display order control
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Two-Tier Content System
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ CONTENT LOOKUP FLOW │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Request: /about
|
||||||
|
|
||||||
|
1. Check for store-specific override
|
||||||
|
↓
|
||||||
|
SELECT * FROM content_pages
|
||||||
|
WHERE store_id = 123 AND slug = 'about' AND is_published = true
|
||||||
|
↓
|
||||||
|
Found? ✅ Return store content
|
||||||
|
❌ Continue to step 2
|
||||||
|
|
||||||
|
2. Check for platform default
|
||||||
|
↓
|
||||||
|
SELECT * FROM content_pages
|
||||||
|
WHERE store_id IS NULL AND slug = 'about' AND is_published = true
|
||||||
|
↓
|
||||||
|
Found? ✅ Return platform content
|
||||||
|
❌ Return 404 or default template
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE content_pages (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
|
||||||
|
-- Store association (NULL = platform default)
|
||||||
|
store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Page identification
|
||||||
|
slug VARCHAR(100) NOT NULL, -- about, faq, contact, shipping, returns
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
|
||||||
|
-- Content
|
||||||
|
content TEXT NOT NULL, -- HTML or Markdown
|
||||||
|
content_format VARCHAR(20) DEFAULT 'html', -- html, markdown
|
||||||
|
|
||||||
|
-- SEO
|
||||||
|
meta_description VARCHAR(300),
|
||||||
|
meta_keywords VARCHAR(300),
|
||||||
|
|
||||||
|
-- Publishing
|
||||||
|
is_published BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
published_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- Navigation placement
|
||||||
|
display_order INTEGER DEFAULT 0,
|
||||||
|
show_in_footer BOOLEAN DEFAULT TRUE, -- Quick Links column
|
||||||
|
show_in_header BOOLEAN DEFAULT FALSE, -- Top navigation
|
||||||
|
show_in_legal BOOLEAN DEFAULT FALSE, -- Bottom bar with copyright
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
||||||
|
|
||||||
|
-- Author tracking
|
||||||
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT uq_store_slug UNIQUE (store_id, slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_store_published ON content_pages (store_id, is_published);
|
||||||
|
CREATE INDEX idx_slug_published ON content_pages (slug, is_published);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Platform Administrator Workflow
|
||||||
|
|
||||||
|
**1. Create Platform Default Pages**
|
||||||
|
|
||||||
|
Platform admins create default content that all stores inherit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/v1/admin/content-pages/platform
|
||||||
|
{
|
||||||
|
"slug": "about",
|
||||||
|
"title": "About Us",
|
||||||
|
"content": "<h1>About Us</h1><p>We are a marketplace...</p>",
|
||||||
|
"content_format": "html",
|
||||||
|
"meta_description": "Learn more about our marketplace",
|
||||||
|
"is_published": true,
|
||||||
|
"show_in_header": true,
|
||||||
|
"show_in_footer": true,
|
||||||
|
"show_in_legal": false,
|
||||||
|
"display_order": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common Platform Defaults:**
|
||||||
|
- `about` - About Us
|
||||||
|
- `contact` - Contact Us
|
||||||
|
- `faq` - Frequently Asked Questions
|
||||||
|
- `shipping` - Shipping Information
|
||||||
|
- `returns` - Return Policy
|
||||||
|
- `privacy` - Privacy Policy
|
||||||
|
- `terms` - Terms of Service
|
||||||
|
- `help` - Help Center
|
||||||
|
|
||||||
|
**2. View All Content Pages**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/v1/admin/content-pages/
|
||||||
|
GET /api/v1/admin/content-pages/?store_id=123 # Filter by store
|
||||||
|
GET /api/v1/admin/content-pages/platform # Only platform defaults
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Update Platform Default**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PUT /api/v1/admin/content-pages/1
|
||||||
|
{
|
||||||
|
"title": "Updated About Us",
|
||||||
|
"content": "<h1>About Our Platform</h1>...",
|
||||||
|
"is_published": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Store Workflow
|
||||||
|
|
||||||
|
**1. View Available Pages**
|
||||||
|
|
||||||
|
Stores see their overrides + platform defaults:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/v1/store/{code}/content-pages/
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"slug": "about",
|
||||||
|
"title": "About Orion", // Store override
|
||||||
|
"is_store_override": true,
|
||||||
|
"is_platform_page": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"slug": "shipping",
|
||||||
|
"title": "Shipping Information", // Platform default
|
||||||
|
"is_store_override": false,
|
||||||
|
"is_platform_page": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Create Store Override**
|
||||||
|
|
||||||
|
Store creates custom "About" page:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/v1/store/{code}/content-pages/
|
||||||
|
{
|
||||||
|
"slug": "about",
|
||||||
|
"title": "About Orion",
|
||||||
|
"content": "<h1>About Orion</h1><p>We specialize in...</p>",
|
||||||
|
"is_published": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This overrides the platform default for this store only.
|
||||||
|
|
||||||
|
**3. View Only Store Overrides**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/v1/store/{code}/content-pages/overrides
|
||||||
|
```
|
||||||
|
|
||||||
|
Shows what the store has customized (excludes platform defaults).
|
||||||
|
|
||||||
|
**4. Delete Override (Revert to Platform Default)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DELETE /api/v1/store/{code}/content-pages/15
|
||||||
|
```
|
||||||
|
|
||||||
|
After deletion, platform default will be shown again.
|
||||||
|
|
||||||
|
### Storefront (Public)
|
||||||
|
|
||||||
|
**1. Get Page Content**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/v1/storefront/content-pages/about
|
||||||
|
```
|
||||||
|
|
||||||
|
Automatically uses store context from middleware:
|
||||||
|
- Returns store override if exists
|
||||||
|
- Falls back to platform default
|
||||||
|
- Returns 404 if neither exists
|
||||||
|
|
||||||
|
**2. Get Navigation Links**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get all navigation pages
|
||||||
|
GET /api/v1/storefront/content-pages/navigation
|
||||||
|
|
||||||
|
# Filter by placement
|
||||||
|
GET /api/v1/storefront/content-pages/navigation?header_only=true
|
||||||
|
GET /api/v1/storefront/content-pages/navigation?footer_only=true
|
||||||
|
GET /api/v1/storefront/content-pages/navigation?legal_only=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns published pages filtered by navigation placement.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── models/database/
|
||||||
|
│ └── content_page.py ← Database model
|
||||||
|
│
|
||||||
|
├── services/
|
||||||
|
│ └── content_page_service.py ← Business logic
|
||||||
|
│
|
||||||
|
├── api/v1/
|
||||||
|
│ ├── admin/
|
||||||
|
│ │ └── content_pages.py ← Admin API endpoints
|
||||||
|
│ ├── store/
|
||||||
|
│ │ └── content_pages.py ← Store API endpoints
|
||||||
|
│ └── storefront/
|
||||||
|
│ └── content_pages.py ← Public API endpoints
|
||||||
|
│
|
||||||
|
└── templates/storefront/
|
||||||
|
├── about.html ← Content page template
|
||||||
|
├── faq.html
|
||||||
|
├── contact.html
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Template Integration
|
||||||
|
|
||||||
|
### Generic Content Page Template
|
||||||
|
|
||||||
|
Create a reusable template for all content pages:
|
||||||
|
|
||||||
|
```jinja2
|
||||||
|
{# app/templates/storefront/content-page.html #}
|
||||||
|
{% extends "storefront/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ page.title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block meta_description %}
|
||||||
|
{{ page.meta_description or page.title }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
|
||||||
|
{# Breadcrumbs #}
|
||||||
|
<nav class="mb-6">
|
||||||
|
<a href="{{ base_url }}" class="text-primary hover:underline">Home</a>
|
||||||
|
<span class="mx-2">/</span>
|
||||||
|
<span class="text-gray-600">{{ page.title }}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{# Page Title #}
|
||||||
|
<h1 class="text-4xl font-bold text-gray-900 dark:text-gray-100 mb-8">
|
||||||
|
{{ page.title }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{# Content #}
|
||||||
|
<div class="prose dark:prose-invert max-w-none">
|
||||||
|
{% if page.content_format == 'markdown' %}
|
||||||
|
{{ page.content | markdown }}
|
||||||
|
{% else %}
|
||||||
|
{{ page.content | safe }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Last updated #}
|
||||||
|
{% if page.updated_at %}
|
||||||
|
<div class="mt-12 pt-6 border-t text-sm text-gray-500">
|
||||||
|
Last updated: {{ page.updated_at }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Route Handler
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app/routes/storefront_pages.py
|
||||||
|
|
||||||
|
from app.services.content_page_service import content_page_service
|
||||||
|
|
||||||
|
@router.get("/{slug}", response_class=HTMLResponse)
|
||||||
|
async def content_page(
|
||||||
|
slug: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generic content page handler.
|
||||||
|
|
||||||
|
Loads content from database with store override support.
|
||||||
|
"""
|
||||||
|
store = getattr(request.state, 'store', None)
|
||||||
|
store_id = store.id if store else None
|
||||||
|
|
||||||
|
page = content_page_service.get_page_for_store(
|
||||||
|
db,
|
||||||
|
slug=slug,
|
||||||
|
store_id=store_id,
|
||||||
|
include_unpublished=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if not page:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Page not found: {slug}")
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"storefront/content-page.html",
|
||||||
|
get_storefront_context(request, page=page)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dynamic Footer Navigation
|
||||||
|
|
||||||
|
Update footer to load links from database:
|
||||||
|
|
||||||
|
```jinja2
|
||||||
|
{# app/templates/storefront/base.html #}
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div class="grid grid-cols-3">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4>Quick Links</h4>
|
||||||
|
<ul>
|
||||||
|
{% for page in footer_pages %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ base_url }}{{ page.slug }}">
|
||||||
|
{{ page.title }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Content Formatting
|
||||||
|
|
||||||
|
**HTML Content:**
|
||||||
|
```html
|
||||||
|
<h1>About Us</h1>
|
||||||
|
<p>We are a <strong>leading marketplace</strong> for...</p>
|
||||||
|
<ul>
|
||||||
|
<li>Quality products</li>
|
||||||
|
<li>Fast shipping</li>
|
||||||
|
<li>Great support</li>
|
||||||
|
</ul>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Markdown Content:**
|
||||||
|
```markdown
|
||||||
|
# About Us
|
||||||
|
|
||||||
|
We are a **leading marketplace** for...
|
||||||
|
|
||||||
|
- Quality products
|
||||||
|
- Fast shipping
|
||||||
|
- Great support
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. SEO Optimization
|
||||||
|
|
||||||
|
Always provide meta descriptions:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meta_description": "Learn about our marketplace, mission, and values. We connect stores with customers worldwide.",
|
||||||
|
"meta_keywords": "about us, marketplace, e-commerce, mission"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Draft → Published Workflow
|
||||||
|
|
||||||
|
1. Create page with `is_published: false`
|
||||||
|
2. Preview using `include_unpublished=true` parameter
|
||||||
|
3. Review and edit
|
||||||
|
4. Publish with `is_published: true`
|
||||||
|
|
||||||
|
### 4. Navigation Management
|
||||||
|
|
||||||
|
The CMS supports three navigation placement categories:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ HEADER (show_in_header=true) │
|
||||||
|
│ [Logo] About Us Contact [Login] [Sign Up] │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ PAGE CONTENT │
|
||||||
|
│ │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ FOOTER (show_in_footer=true) │
|
||||||
|
│ ┌──────────────┬──────────────┬────────────┬──────────────┐ │
|
||||||
|
│ │ Quick Links │ Platform │ Contact │ Social │ │
|
||||||
|
│ │ • About │ • Admin │ • Email │ • Twitter │ │
|
||||||
|
│ │ • FAQ │ • Store │ • Phone │ • LinkedIn │ │
|
||||||
|
│ │ • Contact │ │ │ │ │
|
||||||
|
│ │ • Shipping │ │ │ │ │
|
||||||
|
│ └──────────────┴──────────────┴────────────┴──────────────┘ │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ LEGAL BAR (show_in_legal=true) │
|
||||||
|
│ © 2025 Orion Privacy Policy │ Terms │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Navigation Categories:**
|
||||||
|
|
||||||
|
| Category | Field | Location | Typical Pages |
|
||||||
|
|----------|-------|----------|---------------|
|
||||||
|
| Header | `show_in_header` | Top navigation bar | About, Contact |
|
||||||
|
| Footer | `show_in_footer` | Quick Links column | FAQ, Shipping, Returns |
|
||||||
|
| Legal | `show_in_legal` | Bottom bar with © | Privacy, Terms |
|
||||||
|
|
||||||
|
**Use `display_order` to control link ordering within each category:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Platform defaults with navigation placement
|
||||||
|
"about": display_order=1, show_in_header=True, show_in_footer=True
|
||||||
|
"contact": display_order=2, show_in_header=True, show_in_footer=True
|
||||||
|
"faq": display_order=3, show_in_footer=True
|
||||||
|
"shipping": display_order=4, show_in_footer=True
|
||||||
|
"returns": display_order=5, show_in_footer=True
|
||||||
|
"privacy": display_order=6, show_in_legal=True
|
||||||
|
"terms": display_order=7, show_in_legal=True
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Content Reversion
|
||||||
|
|
||||||
|
To revert store override back to platform default:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Store deletes their custom page
|
||||||
|
DELETE /api/v1/store/{code}/content-pages/15
|
||||||
|
|
||||||
|
# Platform default will now be shown automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Page Slugs
|
||||||
|
|
||||||
|
Standard slugs to implement:
|
||||||
|
|
||||||
|
| Slug | Title | Header | Footer | Legal | Order |
|
||||||
|
|------|-------|--------|--------|-------|-------|
|
||||||
|
| `about` | About Us | ✅ | ✅ | ❌ | 1 |
|
||||||
|
| `contact` | Contact Us | ✅ | ✅ | ❌ | 2 |
|
||||||
|
| `faq` | FAQ | ❌ | ✅ | ❌ | 3 |
|
||||||
|
| `shipping` | Shipping Info | ❌ | ✅ | ❌ | 4 |
|
||||||
|
| `returns` | Returns | ❌ | ✅ | ❌ | 5 |
|
||||||
|
| `privacy` | Privacy Policy | ❌ | ❌ | ✅ | 6 |
|
||||||
|
| `terms` | Terms of Service | ❌ | ❌ | ✅ | 7 |
|
||||||
|
| `help` | Help Center | ❌ | ✅ | ❌ | 8 |
|
||||||
|
| `size-guide` | Size Guide | ❌ | ❌ | ❌ | - |
|
||||||
|
| `careers` | Careers | ❌ | ❌ | ❌ | - |
|
||||||
|
| `cookies` | Cookie Policy | ❌ | ❌ | ✅ | 8 |
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **HTML Sanitization**: If using HTML format, sanitize user input to prevent XSS
|
||||||
|
2. **Authorization**: Stores can only edit their own pages
|
||||||
|
3. **Published Status**: Only published pages visible to public
|
||||||
|
4. **Store Isolation**: Stores cannot see/edit other store's content
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
|
||||||
|
### Initial Setup
|
||||||
|
|
||||||
|
1. **Create Platform Defaults**:
|
||||||
|
```bash
|
||||||
|
python scripts/seed/create_default_content_pages.py
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Migrate Existing Static Templates**:
|
||||||
|
- Convert existing HTML templates to database content
|
||||||
|
- Preserve existing URLs and SEO
|
||||||
|
|
||||||
|
3. **Update Routes**:
|
||||||
|
- Add generic content page route handler
|
||||||
|
- Remove individual route handlers for each page
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Possible improvements:
|
||||||
|
|
||||||
|
- **Version History**: Track content changes over time
|
||||||
|
- **Rich Text Editor**: WYSIWYG editor in admin/store panel
|
||||||
|
- **Image Management**: Upload and insert images
|
||||||
|
- **Templates**: Pre-built page templates for common pages
|
||||||
|
- **Localization**: Multi-language content support
|
||||||
|
- **Scheduled Publishing**: Publish pages at specific times
|
||||||
|
- **Content Approval**: Admin review before store pages go live
|
||||||
|
|
||||||
|
## API Reference Summary
|
||||||
|
|
||||||
|
### Admin Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/admin/content-pages/ # List all pages
|
||||||
|
GET /api/v1/admin/content-pages/platform # List platform defaults
|
||||||
|
POST /api/v1/admin/content-pages/platform # Create platform default
|
||||||
|
GET /api/v1/admin/content-pages/{id} # Get specific page
|
||||||
|
PUT /api/v1/admin/content-pages/{id} # Update page
|
||||||
|
DELETE /api/v1/admin/content-pages/{id} # Delete page
|
||||||
|
```
|
||||||
|
|
||||||
|
### Store Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/store/{code}/content-pages/ # List all (store + platform)
|
||||||
|
GET /api/v1/store/{code}/content-pages/overrides # List store overrides only
|
||||||
|
GET /api/v1/store/{code}/content-pages/{slug} # Get specific page
|
||||||
|
POST /api/v1/store/{code}/content-pages/ # Create store override
|
||||||
|
PUT /api/v1/store/{code}/content-pages/{id} # Update store page
|
||||||
|
DELETE /api/v1/store/{code}/content-pages/{id} # Delete store page
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storefront (Public) Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/storefront/content-pages/navigation # Get navigation links
|
||||||
|
GET /api/v1/storefront/content-pages/{slug} # Get page content
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Complete Workflow
|
||||||
|
|
||||||
|
**1. Platform Admin Creates Defaults:**
|
||||||
|
```bash
|
||||||
|
# Create "About" page
|
||||||
|
curl -X POST /api/v1/admin/content-pages/platform \
|
||||||
|
-H "Authorization: Bearer <admin_token>" \
|
||||||
|
-d '{
|
||||||
|
"slug": "about",
|
||||||
|
"title": "About Our Marketplace",
|
||||||
|
"content": "<h1>About</h1><p>Default content...</p>",
|
||||||
|
"is_published": true
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. All Stores See Platform Default:**
|
||||||
|
- Store A visits: `store-a.com/about` → Shows platform default
|
||||||
|
- Store B visits: `store-b.com/about` → Shows platform default
|
||||||
|
|
||||||
|
**3. Store A Creates Override:**
|
||||||
|
```bash
|
||||||
|
curl -X POST /api/v1/store/store-a/content-pages/ \
|
||||||
|
-H "Authorization: Bearer <store_token>" \
|
||||||
|
-d '{
|
||||||
|
"slug": "about",
|
||||||
|
"title": "About Store A",
|
||||||
|
"content": "<h1>About Store A</h1><p>Custom content...</p>",
|
||||||
|
"is_published": true
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Now:**
|
||||||
|
- Store A visits: `store-a.com/about` → Shows Store A custom content
|
||||||
|
- Store B visits: `store-b.com/about` → Still shows platform default
|
||||||
|
|
||||||
|
**5. Store A Reverts to Default:**
|
||||||
|
```bash
|
||||||
|
curl -X DELETE /api/v1/store/store-a/content-pages/15 \
|
||||||
|
-H "Authorization: Bearer <store_token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**6. Result:**
|
||||||
|
- Store A visits: `store-a.com/about` → Shows platform default again
|
||||||
115
app/modules/cms/docs/data-model.md
Normal file
115
app/modules/cms/docs/data-model.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# CMS Data Model
|
||||||
|
|
||||||
|
Entity relationships and database schema for the CMS module.
|
||||||
|
|
||||||
|
## Entity Relationship Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
Platform 1──* ContentPage
|
||||||
|
Store 1──* ContentPage
|
||||||
|
Store 1──* MediaFile
|
||||||
|
Store 1──1 StoreTheme
|
||||||
|
```
|
||||||
|
|
||||||
|
## Models
|
||||||
|
|
||||||
|
### ContentPage
|
||||||
|
|
||||||
|
Multi-language content pages with platform/store hierarchy. Pages can be platform marketing pages, store defaults, or store-specific overrides.
|
||||||
|
|
||||||
|
| Field | Type | Constraints | Description |
|
||||||
|
|-------|------|-------------|-------------|
|
||||||
|
| `id` | Integer | PK | Primary key |
|
||||||
|
| `platform_id` | Integer | FK, not null, indexed | Platform this page belongs to |
|
||||||
|
| `store_id` | Integer | FK, nullable, indexed | Store association (null = platform/default) |
|
||||||
|
| `is_platform_page` | Boolean | not null, default False | Platform marketing page vs store default |
|
||||||
|
| `slug` | String(100) | not null, indexed | Page identifier (about, faq, contact, etc.) |
|
||||||
|
| `title` | String(200) | not null | Page title |
|
||||||
|
| `content` | Text | not null | HTML or Markdown content |
|
||||||
|
| `content_format` | String(20) | default "html" | Format: html, markdown |
|
||||||
|
| `template` | String(50) | default "default" | Template: default, minimal, modern, full |
|
||||||
|
| `sections` | JSON | nullable | Structured homepage sections with i18n |
|
||||||
|
| `title_translations` | JSON | nullable | Language-keyed title dict {en, fr, de, lb} |
|
||||||
|
| `content_translations` | JSON | nullable | Language-keyed content dict {en, fr, de, lb} |
|
||||||
|
| `meta_description` | String(300) | nullable | SEO meta description |
|
||||||
|
| `meta_keywords` | String(300) | nullable | SEO keywords |
|
||||||
|
| `is_published` | Boolean | not null, default False | Publication status |
|
||||||
|
| `published_at` | DateTime | nullable, tz-aware | Publication timestamp |
|
||||||
|
| `display_order` | Integer | not null, default 0 | Menu/footer ordering |
|
||||||
|
| `show_in_footer` | Boolean | not null, default True | Footer visibility |
|
||||||
|
| `show_in_header` | Boolean | not null, default False | Header navigation |
|
||||||
|
| `show_in_legal` | Boolean | not null, default False | Legal bar visibility |
|
||||||
|
| `created_at` | DateTime | tz-aware | Record creation time |
|
||||||
|
| `updated_at` | DateTime | tz-aware | Record update time |
|
||||||
|
| `created_by` | Integer | FK, nullable | Creator user ID |
|
||||||
|
| `updated_by` | Integer | FK, nullable | Updater user ID |
|
||||||
|
|
||||||
|
**Unique Constraint**: `(platform_id, store_id, slug)`
|
||||||
|
**Composite Indexes**: `(platform_id, store_id, is_published)`, `(platform_id, slug, is_published)`, `(platform_id, is_platform_page)`
|
||||||
|
|
||||||
|
**Page tiers**: platform → store_default (store_id null, not platform) → store_override (store_id set)
|
||||||
|
|
||||||
|
### MediaFile
|
||||||
|
|
||||||
|
Media files (images, videos, documents) managed per-store.
|
||||||
|
|
||||||
|
| Field | Type | Constraints | Description |
|
||||||
|
|-------|------|-------------|-------------|
|
||||||
|
| `id` | Integer | PK | Primary key |
|
||||||
|
| `store_id` | Integer | FK, not null, indexed | Store owner |
|
||||||
|
| `filename` | String(255) | not null, indexed | UUID-based stored filename |
|
||||||
|
| `original_filename` | String(255) | nullable | Original upload name |
|
||||||
|
| `file_path` | String(500) | not null | Relative path from uploads/ |
|
||||||
|
| `media_type` | String(20) | not null | image, video, document |
|
||||||
|
| `mime_type` | String(100) | nullable | MIME type |
|
||||||
|
| `file_size` | Integer | nullable | File size in bytes |
|
||||||
|
| `width` | Integer | nullable | Image/video width in pixels |
|
||||||
|
| `height` | Integer | nullable | Image/video height in pixels |
|
||||||
|
| `thumbnail_path` | String(500) | nullable | Path to thumbnail |
|
||||||
|
| `alt_text` | String(500) | nullable | Alt text for images |
|
||||||
|
| `description` | Text | nullable | File description |
|
||||||
|
| `folder` | String(100) | default "general" | Folder: products, general, etc. |
|
||||||
|
| `tags` | JSON | nullable | Tags for categorization |
|
||||||
|
| `extra_metadata` | JSON | nullable | Additional metadata (EXIF, etc.) |
|
||||||
|
| `is_optimized` | Boolean | default False | Optimization status |
|
||||||
|
| `optimized_size` | Integer | nullable | Size after optimization |
|
||||||
|
| `usage_count` | Integer | default 0 | Usage tracking |
|
||||||
|
| `created_at` | DateTime | tz-aware | Record creation time |
|
||||||
|
| `updated_at` | DateTime | tz-aware | Record update time |
|
||||||
|
|
||||||
|
**Composite Indexes**: `(store_id, folder)`, `(store_id, media_type)`
|
||||||
|
|
||||||
|
### StoreTheme
|
||||||
|
|
||||||
|
Per-store theme configuration including colors, fonts, layout, and branding. One-to-one with Store.
|
||||||
|
|
||||||
|
| Field | Type | Constraints | Description |
|
||||||
|
|-------|------|-------------|-------------|
|
||||||
|
| `id` | Integer | PK | Primary key |
|
||||||
|
| `store_id` | Integer | FK, unique, not null | One-to-one with store |
|
||||||
|
| `theme_name` | String(100) | default "default" | Preset: default, modern, classic, minimal, vibrant |
|
||||||
|
| `is_active` | Boolean | default True | Theme active status |
|
||||||
|
| `colors` | JSON | default {...} | Color scheme: primary, secondary, accent, background, text, border |
|
||||||
|
| `font_family_heading` | String(100) | default "Inter, sans-serif" | Heading font |
|
||||||
|
| `font_family_body` | String(100) | default "Inter, sans-serif" | Body font |
|
||||||
|
| `logo_url` | String(500) | nullable | Store logo path |
|
||||||
|
| `logo_dark_url` | String(500) | nullable | Dark mode logo |
|
||||||
|
| `favicon_url` | String(500) | nullable | Favicon path |
|
||||||
|
| `banner_url` | String(500) | nullable | Homepage banner |
|
||||||
|
| `layout_style` | String(50) | default "grid" | Layout: grid, list, masonry |
|
||||||
|
| `header_style` | String(50) | default "fixed" | Header: fixed, static, transparent |
|
||||||
|
| `product_card_style` | String(50) | default "modern" | Card: modern, classic, minimal |
|
||||||
|
| `custom_css` | Text | nullable | Custom CSS overrides |
|
||||||
|
| `social_links` | JSON | default {} | Social media URLs |
|
||||||
|
| `meta_title_template` | String(200) | nullable | SEO title template |
|
||||||
|
| `meta_description` | Text | nullable | SEO meta description |
|
||||||
|
| `created_at` | DateTime | tz-aware | Record creation time |
|
||||||
|
| `updated_at` | DateTime | tz-aware | Record update time |
|
||||||
|
|
||||||
|
## Design Patterns
|
||||||
|
|
||||||
|
- **Three-tier content hierarchy**: Platform pages → store defaults → store overrides
|
||||||
|
- **JSON translations**: Title and content translations stored as JSON dicts with language keys
|
||||||
|
- **Media organization**: Files organized by store and folder with type classification
|
||||||
|
- **Theme presets**: Named presets with full customization via JSON color scheme and CSS overrides
|
||||||
|
- **SEO support**: Meta description, keywords, and title templates on pages and themes
|
||||||
287
app/modules/cms/docs/email-templates-guide.md
Normal file
287
app/modules/cms/docs/email-templates-guide.md
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
# Email Templates Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Orion platform provides a comprehensive email template system that allows:
|
||||||
|
|
||||||
|
- **Platform Administrators**: Manage all email templates across the platform
|
||||||
|
- **Stores**: Customize customer-facing emails with their own branding
|
||||||
|
|
||||||
|
This guide covers how to use the email template system from both perspectives.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## For Stores
|
||||||
|
|
||||||
|
### Accessing Email Templates
|
||||||
|
|
||||||
|
1. Log in to your store dashboard
|
||||||
|
2. Navigate to **Settings** > **Email Templates** in the sidebar
|
||||||
|
3. You'll see a list of all customizable email templates
|
||||||
|
|
||||||
|
### Understanding Template Status
|
||||||
|
|
||||||
|
Each template shows its customization status:
|
||||||
|
|
||||||
|
| Status | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| **Platform Default** | Using the standard Orion template |
|
||||||
|
| **Customized** | You have created a custom version |
|
||||||
|
| Language badges (green) | Languages where you have customizations |
|
||||||
|
|
||||||
|
### Customizing a Template
|
||||||
|
|
||||||
|
1. Click on any template to open the edit modal
|
||||||
|
2. Select the language tab you want to customize (EN, FR, DE, LB)
|
||||||
|
3. Edit the following fields:
|
||||||
|
- **Subject**: The email subject line
|
||||||
|
- **HTML Body**: The rich HTML content
|
||||||
|
- **Plain Text Body**: Fallback for email clients that don't support HTML
|
||||||
|
|
||||||
|
4. Click **Save** to save your customization
|
||||||
|
|
||||||
|
### Template Variables
|
||||||
|
|
||||||
|
Templates use special variables that are automatically replaced with actual values. Common variables include:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `{{ customer_name }}` | Customer's first name |
|
||||||
|
| `{{ order_number }}` | Order reference number |
|
||||||
|
| `{{ store_name }}` | Your store name |
|
||||||
|
| `{{ platform_name }}` | Platform name (Orion or your whitelabel name) |
|
||||||
|
|
||||||
|
Each template shows its available variables in the reference panel.
|
||||||
|
|
||||||
|
### Previewing Templates
|
||||||
|
|
||||||
|
Before saving, you can preview your template:
|
||||||
|
|
||||||
|
1. Click **Preview** in the edit modal
|
||||||
|
2. A preview window shows how the email will look
|
||||||
|
3. Sample data is used for all variables
|
||||||
|
|
||||||
|
### Testing Templates
|
||||||
|
|
||||||
|
To send a test email:
|
||||||
|
|
||||||
|
1. Click **Send Test Email** in the edit modal
|
||||||
|
2. Enter your email address
|
||||||
|
3. Click **Send**
|
||||||
|
4. Check your inbox to see the actual email
|
||||||
|
|
||||||
|
### Reverting to Platform Default
|
||||||
|
|
||||||
|
If you want to remove your customization and use the platform default:
|
||||||
|
|
||||||
|
1. Open the template edit modal
|
||||||
|
2. Click **Revert to Default**
|
||||||
|
3. Confirm the action
|
||||||
|
|
||||||
|
Your customization will be deleted and the platform template will be used.
|
||||||
|
|
||||||
|
### Available Templates for Stores
|
||||||
|
|
||||||
|
| Template | Category | Description |
|
||||||
|
|----------|----------|-------------|
|
||||||
|
| Welcome Email | AUTH | Sent when a customer registers |
|
||||||
|
| Password Reset | AUTH | Password reset link |
|
||||||
|
| Order Confirmation | ORDERS | Sent after order placement |
|
||||||
|
| Shipping Notification | ORDERS | Sent when order is shipped |
|
||||||
|
|
||||||
|
**Note:** Billing and subscription emails are platform-only and cannot be customized.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## For Platform Administrators
|
||||||
|
|
||||||
|
### Accessing Email Templates
|
||||||
|
|
||||||
|
1. Log in to the admin dashboard
|
||||||
|
2. Navigate to **System** > **Email Templates** in the sidebar
|
||||||
|
3. You'll see all platform templates grouped by category
|
||||||
|
|
||||||
|
### Template Categories
|
||||||
|
|
||||||
|
| Category | Description | Store Override |
|
||||||
|
|----------|-------------|-----------------|
|
||||||
|
| AUTH | Authentication emails | Allowed |
|
||||||
|
| ORDERS | Order-related emails | Allowed |
|
||||||
|
| BILLING | Subscription/payment emails | **Not Allowed** |
|
||||||
|
| SYSTEM | System notifications | Allowed |
|
||||||
|
| MARKETING | Promotional emails | Allowed |
|
||||||
|
|
||||||
|
### Editing Platform Templates
|
||||||
|
|
||||||
|
1. Click on any template to open the edit modal
|
||||||
|
2. Select the language tab (EN, FR, DE, LB)
|
||||||
|
3. Edit the subject and body content
|
||||||
|
4. Click **Save**
|
||||||
|
|
||||||
|
**Important:** Changes to platform templates affect:
|
||||||
|
- All stores who haven't customized the template
|
||||||
|
- New stores automatically
|
||||||
|
|
||||||
|
### Creating New Templates
|
||||||
|
|
||||||
|
To add a new template:
|
||||||
|
|
||||||
|
1. Use the database seed script or migration
|
||||||
|
2. Define the template code, category, and languages
|
||||||
|
3. Set `is_platform_only` if stores shouldn't override it
|
||||||
|
|
||||||
|
### Viewing Email Logs
|
||||||
|
|
||||||
|
To see email delivery history:
|
||||||
|
|
||||||
|
1. Open a template
|
||||||
|
2. Click **View Logs**
|
||||||
|
3. See recent emails sent using this template
|
||||||
|
|
||||||
|
Logs show:
|
||||||
|
- Recipient email
|
||||||
|
- Send date/time
|
||||||
|
- Delivery status
|
||||||
|
- Store (if applicable)
|
||||||
|
|
||||||
|
### Template Best Practices
|
||||||
|
|
||||||
|
1. **Use all 4 languages**: Provide content in EN, FR, DE, and LB
|
||||||
|
2. **Test before publishing**: Always send test emails
|
||||||
|
3. **Include plain text**: Not all email clients support HTML
|
||||||
|
4. **Use consistent branding**: Follow Orion brand guidelines
|
||||||
|
5. **Keep subjects short**: Under 60 characters for mobile
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Language Resolution
|
||||||
|
|
||||||
|
When sending an email, the system determines the language in this order:
|
||||||
|
|
||||||
|
1. **Customer's preferred language** (if set in their profile)
|
||||||
|
2. **Store's storefront language** (if customer doesn't have preference)
|
||||||
|
3. **Platform default** (French - "fr")
|
||||||
|
|
||||||
|
### Template Resolution for Stores
|
||||||
|
|
||||||
|
1. System checks if store has a custom override
|
||||||
|
2. If yes, uses store's template
|
||||||
|
3. If no, falls back to platform template
|
||||||
|
4. If requested language unavailable, falls back to English
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branding
|
||||||
|
|
||||||
|
### Standard Stores
|
||||||
|
|
||||||
|
Standard stores' emails include Orion branding:
|
||||||
|
- Orion logo in header
|
||||||
|
- "Powered by Orion" footer
|
||||||
|
|
||||||
|
### Whitelabel Stores
|
||||||
|
|
||||||
|
Enterprise-tier stores with whitelabel enabled:
|
||||||
|
- No Orion branding
|
||||||
|
- Store's logo in header
|
||||||
|
- Custom footer (if configured)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Email Template Variables Reference
|
||||||
|
|
||||||
|
### Authentication Templates
|
||||||
|
|
||||||
|
#### signup_welcome
|
||||||
|
```
|
||||||
|
{{ first_name }} - Customer's first name
|
||||||
|
{{ merchant_name }} - Store merchant name
|
||||||
|
{{ email }} - Customer's email
|
||||||
|
{{ login_url }} - Link to login page
|
||||||
|
{{ trial_days }} - Trial period length
|
||||||
|
{{ tier_name }} - Subscription tier
|
||||||
|
```
|
||||||
|
|
||||||
|
#### password_reset
|
||||||
|
```
|
||||||
|
{{ customer_name }} - Customer's name
|
||||||
|
{{ reset_link }} - Password reset URL
|
||||||
|
{{ expiry_hours }} - Link expiration time
|
||||||
|
```
|
||||||
|
|
||||||
|
### Order Templates
|
||||||
|
|
||||||
|
#### order_confirmation
|
||||||
|
```
|
||||||
|
{{ customer_name }} - Customer's name
|
||||||
|
{{ order_number }} - Order reference
|
||||||
|
{{ order_total }} - Order total amount
|
||||||
|
{{ order_items_count }} - Number of items
|
||||||
|
{{ order_date }} - Order date
|
||||||
|
{{ shipping_address }} - Delivery address
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Variables (All Templates)
|
||||||
|
|
||||||
|
```
|
||||||
|
{{ platform_name }} - "Orion" or whitelabel name
|
||||||
|
{{ platform_logo_url }} - Platform logo URL
|
||||||
|
{{ support_email }} - Support email address
|
||||||
|
{{ store_name }} - Store's business name
|
||||||
|
{{ store_logo_url }} - Store's logo URL
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Email Not Received
|
||||||
|
|
||||||
|
1. Check spam/junk folder
|
||||||
|
2. Verify email address is correct
|
||||||
|
3. Check email logs in admin dashboard
|
||||||
|
4. Verify SMTP configuration
|
||||||
|
|
||||||
|
### Template Not Applying
|
||||||
|
|
||||||
|
1. Clear browser cache
|
||||||
|
2. Verify the correct language is selected
|
||||||
|
3. Check if store override exists
|
||||||
|
4. Verify template is not platform-only
|
||||||
|
|
||||||
|
### Variables Not Replaced
|
||||||
|
|
||||||
|
1. Check variable spelling (case-sensitive)
|
||||||
|
2. Ensure variable is available for this template
|
||||||
|
3. Wrap variables in `{{ }}` syntax
|
||||||
|
4. Check for typos in variable names
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
For developers integrating with the email system:
|
||||||
|
|
||||||
|
### Sending a Template Email
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.services.email_service import EmailService
|
||||||
|
|
||||||
|
email_service = EmailService(db)
|
||||||
|
email_service.send_template(
|
||||||
|
template_code="order_confirmation",
|
||||||
|
to_email="customer@example.com",
|
||||||
|
to_name="John Doe",
|
||||||
|
language="fr",
|
||||||
|
variables={
|
||||||
|
"customer_name": "John",
|
||||||
|
"order_number": "ORD-12345",
|
||||||
|
"order_total": "99.99",
|
||||||
|
},
|
||||||
|
store_id=store.id,
|
||||||
|
related_type="order",
|
||||||
|
related_id=order.id,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Email Templates Architecture](../cms/email-templates.md) for full technical documentation.
|
||||||
458
app/modules/cms/docs/email-templates.md
Normal file
458
app/modules/cms/docs/email-templates.md
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
# Email Template System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The email template system provides comprehensive email customization for the Orion platform with the following features:
|
||||||
|
|
||||||
|
- **Platform-level templates** with store overrides
|
||||||
|
- **Orion branding** by default (removed for Enterprise whitelabel tier)
|
||||||
|
- **Platform-only templates** that cannot be overridden (billing, subscriptions)
|
||||||
|
- **Admin UI** for editing platform templates
|
||||||
|
- **Store UI** for customizing customer-facing emails
|
||||||
|
- **4-language support** (en, fr, de, lb)
|
||||||
|
- **Smart language resolution** (customer → store → platform default)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Database Models
|
||||||
|
|
||||||
|
#### EmailTemplate (Platform Templates)
|
||||||
|
**File:** `models/database/email.py`
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | Integer | Primary key |
|
||||||
|
| `code` | String(100) | Unique template identifier |
|
||||||
|
| `language` | String(5) | Language code (en, fr, de, lb) |
|
||||||
|
| `name` | String(255) | Human-readable name |
|
||||||
|
| `description` | Text | Template description |
|
||||||
|
| `category` | Enum | AUTH, ORDERS, BILLING, SYSTEM, MARKETING |
|
||||||
|
| `subject` | String(500) | Email subject line (Jinja2) |
|
||||||
|
| `body_html` | Text | HTML body (Jinja2) |
|
||||||
|
| `body_text` | Text | Plain text body (Jinja2) |
|
||||||
|
| `variables` | JSON | List of available variables |
|
||||||
|
| `is_platform_only` | Boolean | Cannot be overridden by stores |
|
||||||
|
| `required_variables` | Text | Comma-separated required variables |
|
||||||
|
|
||||||
|
**Key Methods:**
|
||||||
|
- `get_by_code_and_language(db, code, language)` - Get specific template
|
||||||
|
- `get_overridable_templates(db)` - Get templates stores can customize
|
||||||
|
|
||||||
|
#### StoreEmailTemplate (Store Overrides)
|
||||||
|
**File:** `models/database/store_email_template.py`
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | Integer | Primary key |
|
||||||
|
| `store_id` | Integer | FK to stores.id |
|
||||||
|
| `template_code` | String(100) | References EmailTemplate.code |
|
||||||
|
| `language` | String(5) | Language code |
|
||||||
|
| `name` | String(255) | Custom name (optional) |
|
||||||
|
| `subject` | String(500) | Custom subject |
|
||||||
|
| `body_html` | Text | Custom HTML body |
|
||||||
|
| `body_text` | Text | Custom plain text body |
|
||||||
|
| `created_at` | DateTime | Creation timestamp |
|
||||||
|
| `updated_at` | DateTime | Last update timestamp |
|
||||||
|
|
||||||
|
**Key Methods:**
|
||||||
|
- `get_override(db, store_id, code, language)` - Get store override
|
||||||
|
- `create_or_update(db, store_id, code, language, ...)` - Upsert override
|
||||||
|
- `delete_override(db, store_id, code, language)` - Revert to platform default
|
||||||
|
- `get_all_overrides_for_store(db, store_id)` - List all store overrides
|
||||||
|
|
||||||
|
### Unique Constraint
|
||||||
|
```sql
|
||||||
|
UNIQUE (store_id, template_code, language)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Email Template Service
|
||||||
|
|
||||||
|
**File:** `app/services/email_template_service.py`
|
||||||
|
|
||||||
|
The `EmailTemplateService` encapsulates all email template business logic, keeping API endpoints clean and focused on request/response handling.
|
||||||
|
|
||||||
|
### Admin Methods
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `list_platform_templates()` | List all platform templates grouped by code |
|
||||||
|
| `get_template_categories()` | Get list of template categories |
|
||||||
|
| `get_platform_template(code)` | Get template with all language versions |
|
||||||
|
| `update_platform_template(code, language, data)` | Update platform template content |
|
||||||
|
| `preview_template(code, language, variables)` | Generate preview with sample data |
|
||||||
|
| `get_template_logs(code, limit)` | Get email logs for template |
|
||||||
|
|
||||||
|
### Store Methods
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `list_overridable_templates(store_id)` | List templates store can customize |
|
||||||
|
| `get_store_template(store_id, code, language)` | Get template (override or platform default) |
|
||||||
|
| `create_or_update_store_override(store_id, code, language, data)` | Save store customization |
|
||||||
|
| `delete_store_override(store_id, code, language)` | Revert to platform default |
|
||||||
|
| `preview_store_template(store_id, code, language, variables)` | Preview with store branding |
|
||||||
|
|
||||||
|
### Usage Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.services.email_template_service import EmailTemplateService
|
||||||
|
|
||||||
|
service = EmailTemplateService(db)
|
||||||
|
|
||||||
|
# List templates for admin
|
||||||
|
templates = service.list_platform_templates()
|
||||||
|
|
||||||
|
# Get store's view of a template
|
||||||
|
template_data = service.get_store_template(store_id, "order_confirmation", "fr")
|
||||||
|
|
||||||
|
# Create store override
|
||||||
|
service.create_or_update_store_override(
|
||||||
|
store_id=store.id,
|
||||||
|
code="order_confirmation",
|
||||||
|
language="fr",
|
||||||
|
subject="Votre commande {{ order_number }}",
|
||||||
|
body_html="<html>...</html>",
|
||||||
|
body_text="Plain text...",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Email Service
|
||||||
|
|
||||||
|
**File:** `app/services/email_service.py`
|
||||||
|
|
||||||
|
### Language Resolution
|
||||||
|
|
||||||
|
Priority order for determining email language:
|
||||||
|
|
||||||
|
1. **Customer preferred language** (if customer exists)
|
||||||
|
2. **Store storefront language** (store.storefront_language)
|
||||||
|
3. **Platform default** (`en`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def resolve_language(
|
||||||
|
self,
|
||||||
|
customer_id: int | None,
|
||||||
|
store_id: int | None,
|
||||||
|
explicit_language: str | None = None
|
||||||
|
) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
### Template Resolution
|
||||||
|
|
||||||
|
```python
|
||||||
|
def resolve_template(
|
||||||
|
self,
|
||||||
|
template_code: str,
|
||||||
|
language: str,
|
||||||
|
store_id: int | None = None
|
||||||
|
) -> ResolvedTemplate
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
1. If `store_id` provided and template **not** platform-only:
|
||||||
|
- Look for `StoreEmailTemplate` override
|
||||||
|
- Fall back to platform `EmailTemplate`
|
||||||
|
2. If no store or platform-only:
|
||||||
|
- Use platform `EmailTemplate`
|
||||||
|
3. Language fallback: `requested_language` → `en`
|
||||||
|
|
||||||
|
### Branding Resolution
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_branding(self, store_id: int | None) -> BrandingContext
|
||||||
|
```
|
||||||
|
|
||||||
|
| Scenario | Platform Name | Platform Logo |
|
||||||
|
|----------|--------------|---------------|
|
||||||
|
| No store | Orion | Orion logo |
|
||||||
|
| Standard store | Orion | Orion logo |
|
||||||
|
| Whitelabel store | Store name | Store logo |
|
||||||
|
|
||||||
|
Whitelabel is determined by the `white_label` feature flag on the store.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Admin API
|
||||||
|
|
||||||
|
**File:** `app/api/v1/admin/email_templates.py`
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/v1/admin/email-templates` | List all platform templates |
|
||||||
|
| GET | `/api/v1/admin/email-templates/categories` | Get template categories |
|
||||||
|
| GET | `/api/v1/admin/email-templates/{code}` | Get template (all languages) |
|
||||||
|
| GET | `/api/v1/admin/email-templates/{code}/{language}` | Get specific language version |
|
||||||
|
| PUT | `/api/v1/admin/email-templates/{code}/{language}` | Update template |
|
||||||
|
| POST | `/api/v1/admin/email-templates/{code}/preview` | Preview with sample data |
|
||||||
|
| POST | `/api/v1/admin/email-templates/{code}/test` | Send test email |
|
||||||
|
| GET | `/api/v1/admin/email-templates/{code}/logs` | View email logs for template |
|
||||||
|
|
||||||
|
### Store API
|
||||||
|
|
||||||
|
**File:** `app/api/v1/store/email_templates.py`
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/v1/store/email-templates` | List overridable templates |
|
||||||
|
| GET | `/api/v1/store/email-templates/{code}` | Get template with override status |
|
||||||
|
| GET | `/api/v1/store/email-templates/{code}/{language}` | Get specific language (override or default) |
|
||||||
|
| PUT | `/api/v1/store/email-templates/{code}/{language}` | Create/update override |
|
||||||
|
| DELETE | `/api/v1/store/email-templates/{code}/{language}` | Reset to platform default |
|
||||||
|
| POST | `/api/v1/store/email-templates/{code}/preview` | Preview with store branding |
|
||||||
|
| POST | `/api/v1/store/email-templates/{code}/test` | Send test email |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Interface
|
||||||
|
|
||||||
|
### Admin UI
|
||||||
|
|
||||||
|
**Page:** `/admin/email-templates`
|
||||||
|
**Template:** `app/templates/admin/email-templates.html`
|
||||||
|
**JavaScript:** `static/admin/js/email-templates.js`
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Template list with category filtering
|
||||||
|
- Edit modal with language tabs (en, fr, de, lb)
|
||||||
|
- Platform-only indicator badge
|
||||||
|
- Variable reference panel
|
||||||
|
- HTML preview in iframe
|
||||||
|
- Send test email functionality
|
||||||
|
|
||||||
|
### Store UI
|
||||||
|
|
||||||
|
**Page:** `/store/{store_code}/email-templates`
|
||||||
|
**Template:** `app/templates/store/email-templates.html`
|
||||||
|
**JavaScript:** `static/store/js/email-templates.js`
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- List of overridable templates with customization status
|
||||||
|
- Language override badges (green = customized)
|
||||||
|
- Edit modal with:
|
||||||
|
- Language tabs
|
||||||
|
- Source indicator (store override vs platform default)
|
||||||
|
- Platform template reference
|
||||||
|
- Revert to default button
|
||||||
|
- Preview and test email functionality
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Template Categories
|
||||||
|
|
||||||
|
| Category | Description | Platform-Only |
|
||||||
|
|----------|-------------|---------------|
|
||||||
|
| AUTH | Authentication emails (welcome, password reset) | No |
|
||||||
|
| ORDERS | Order-related emails (confirmation, shipped) | No |
|
||||||
|
| BILLING | Subscription/payment emails | Yes |
|
||||||
|
| SYSTEM | System emails (team invites, alerts) | No |
|
||||||
|
| MARKETING | Marketing/promotional emails | No |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Available Templates
|
||||||
|
|
||||||
|
### Customer-Facing (Overridable)
|
||||||
|
|
||||||
|
| Code | Category | Languages | Description |
|
||||||
|
|------|----------|-----------|-------------|
|
||||||
|
| `signup_welcome` | AUTH | en, fr, de, lb | Welcome email after store signup |
|
||||||
|
| `order_confirmation` | ORDERS | en, fr, de, lb | Order confirmation to customer |
|
||||||
|
| `password_reset` | AUTH | en, fr, de, lb | Password reset link |
|
||||||
|
| `team_invite` | SYSTEM | en | Team member invitation |
|
||||||
|
|
||||||
|
### Platform-Only (Not Overridable)
|
||||||
|
|
||||||
|
| Code | Category | Languages | Description |
|
||||||
|
|------|----------|-----------|-------------|
|
||||||
|
| `subscription_welcome` | BILLING | en | Subscription confirmation |
|
||||||
|
| `payment_failed` | BILLING | en | Failed payment notification |
|
||||||
|
| `subscription_cancelled` | BILLING | en | Cancellation confirmation |
|
||||||
|
| `trial_ending` | BILLING | en | Trial ending reminder |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Template Variables
|
||||||
|
|
||||||
|
### Common Variables (Injected Automatically)
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `platform_name` | "Orion" or store name (whitelabel) |
|
||||||
|
| `platform_logo_url` | Platform logo URL |
|
||||||
|
| `support_email` | Support email address |
|
||||||
|
| `store_name` | Store business name |
|
||||||
|
| `store_logo_url` | Store logo URL |
|
||||||
|
|
||||||
|
### Template-Specific Variables
|
||||||
|
|
||||||
|
#### signup_welcome
|
||||||
|
- `first_name`, `merchant_name`, `email`, `store_code`
|
||||||
|
- `login_url`, `trial_days`, `tier_name`
|
||||||
|
|
||||||
|
#### order_confirmation
|
||||||
|
- `customer_name`, `order_number`, `order_total`
|
||||||
|
- `order_items_count`, `order_date`, `shipping_address`
|
||||||
|
|
||||||
|
#### password_reset
|
||||||
|
- `customer_name`, `reset_link`, `expiry_hours`
|
||||||
|
|
||||||
|
#### team_invite
|
||||||
|
- `invitee_name`, `inviter_name`, `store_name`
|
||||||
|
- `role`, `accept_url`, `expires_in_days`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
**File:** `alembic/versions/u9c0d1e2f3g4_add_store_email_templates.py`
|
||||||
|
|
||||||
|
Run migration:
|
||||||
|
```bash
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
The migration:
|
||||||
|
1. Adds `is_platform_only` and `required_variables` columns to `email_templates`
|
||||||
|
2. Creates `store_email_templates` table
|
||||||
|
3. Adds unique constraint on `(store_id, template_code, language)`
|
||||||
|
4. Creates indexes for performance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seeding Templates
|
||||||
|
|
||||||
|
**File:** `scripts/seed/seed_email_templates.py`
|
||||||
|
|
||||||
|
Run seed script:
|
||||||
|
```bash
|
||||||
|
python scripts/seed/seed_email_templates.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The script:
|
||||||
|
- Creates/updates all platform templates
|
||||||
|
- Supports all 4 languages for customer-facing templates
|
||||||
|
- Sets `is_platform_only` flag for billing templates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **XSS Prevention**: HTML templates are rendered server-side with Jinja2 escaping
|
||||||
|
2. **Access Control**: Stores can only view/edit their own overrides
|
||||||
|
3. **Platform-only Protection**: API enforces `is_platform_only` flag
|
||||||
|
4. **Template Validation**: Jinja2 syntax validated before save
|
||||||
|
5. **Rate Limiting**: Test email sending subject to rate limits
|
||||||
|
6. **Token Hashing**: Password reset tokens stored as SHA256 hashes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Sending a Template Email
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.services.email_service import EmailService
|
||||||
|
|
||||||
|
email_svc = EmailService(db)
|
||||||
|
email_log = email_svc.send_template(
|
||||||
|
template_code="order_confirmation",
|
||||||
|
to_email="customer@example.com",
|
||||||
|
variables={
|
||||||
|
"customer_name": "John Doe",
|
||||||
|
"order_number": "ORD-12345",
|
||||||
|
"order_total": "€99.99",
|
||||||
|
"order_items_count": "3",
|
||||||
|
"order_date": "2024-01-15",
|
||||||
|
"shipping_address": "123 Main St, Luxembourg"
|
||||||
|
},
|
||||||
|
store_id=store.id, # Optional: enables store override lookup
|
||||||
|
customer_id=customer.id, # Optional: for language resolution
|
||||||
|
language="fr" # Optional: explicit language override
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating a Store Override
|
||||||
|
|
||||||
|
```python
|
||||||
|
from models.database.store_email_template import StoreEmailTemplate
|
||||||
|
|
||||||
|
override = StoreEmailTemplate.create_or_update(
|
||||||
|
db=db,
|
||||||
|
store_id=store.id,
|
||||||
|
template_code="order_confirmation",
|
||||||
|
language="fr",
|
||||||
|
subject="Confirmation de votre commande {{ order_number }}",
|
||||||
|
body_html="<html>...</html>",
|
||||||
|
body_text="Plain text version..."
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reverting to Platform Default
|
||||||
|
|
||||||
|
```python
|
||||||
|
StoreEmailTemplate.delete_override(
|
||||||
|
db=db,
|
||||||
|
store_id=store.id,
|
||||||
|
template_code="order_confirmation",
|
||||||
|
language="fr"
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
├── alembic/versions/
|
||||||
|
│ └── u9c0d1e2f3g4_add_store_email_templates.py
|
||||||
|
├── app/
|
||||||
|
│ ├── api/v1/
|
||||||
|
│ │ ├── admin/
|
||||||
|
│ │ │ └── email_templates.py
|
||||||
|
│ │ └── store/
|
||||||
|
│ │ └── email_templates.py
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── admin_pages.py (route added)
|
||||||
|
│ │ └── store_pages.py (route added)
|
||||||
|
│ ├── services/
|
||||||
|
│ │ ├── email_service.py (enhanced)
|
||||||
|
│ │ └── email_template_service.py (new - business logic)
|
||||||
|
│ └── templates/
|
||||||
|
│ ├── admin/
|
||||||
|
│ │ ├── email-templates.html
|
||||||
|
│ │ └── partials/sidebar.html (link added)
|
||||||
|
│ └── store/
|
||||||
|
│ ├── email-templates.html
|
||||||
|
│ └── partials/sidebar.html (link added)
|
||||||
|
├── models/
|
||||||
|
│ ├── database/
|
||||||
|
│ │ ├── email.py (enhanced)
|
||||||
|
│ │ └── store_email_template.py
|
||||||
|
│ └── schema/
|
||||||
|
│ └── email.py
|
||||||
|
├── scripts/
|
||||||
|
│ └── seed_email_templates.py (enhanced)
|
||||||
|
└── static/
|
||||||
|
├── admin/js/
|
||||||
|
│ └── email-templates.js
|
||||||
|
└── store/js/
|
||||||
|
└── email-templates.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Email Templates User Guide](email-templates-guide.md) - How to use the email template system
|
||||||
|
- [Password Reset Implementation](../../implementation/password-reset-implementation.md) - Password reset feature using email templates
|
||||||
|
- [Architecture Fixes (January 2026)](../../development/architecture-fixes-2026-01.md) - Architecture validation fixes
|
||||||
414
app/modules/cms/docs/implementation.md
Normal file
414
app/modules/cms/docs/implementation.md
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
# CMS Implementation Guide
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
This guide shows you how to implement the Content Management System for static pages.
|
||||||
|
|
||||||
|
## What Was Implemented
|
||||||
|
|
||||||
|
✅ **Database Model**: `models/database/content_page.py`
|
||||||
|
✅ **Service Layer**: `app/services/content_page_service.py`
|
||||||
|
✅ **Admin API**: `app/api/v1/admin/content_pages.py`
|
||||||
|
✅ **Store API**: `app/api/v1/store/content_pages.py`
|
||||||
|
✅ **Storefront API**: `app/api/v1/storefront/content_pages.py`
|
||||||
|
✅ **Documentation**: Full CMS documentation in `docs/features/content-management-system.md`
|
||||||
|
|
||||||
|
## Next Steps to Activate
|
||||||
|
|
||||||
|
### 1. Create Database Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create Alembic migration
|
||||||
|
alembic revision --autogenerate -m "Add content_pages table"
|
||||||
|
|
||||||
|
# Review the generated migration in alembic/versions/
|
||||||
|
|
||||||
|
# Run migration
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Add Relationship to Store Model
|
||||||
|
|
||||||
|
Edit `models/database/store.py` and add this relationship:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Add this import
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
# Add this relationship to Store class
|
||||||
|
content_pages = relationship("ContentPage", back_populates="store", cascade="all, delete-orphan")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Register API Routers
|
||||||
|
|
||||||
|
Edit the appropriate router files to include the new endpoints:
|
||||||
|
|
||||||
|
**Admin Router** (`app/api/v1/admin/__init__.py`):
|
||||||
|
```python
|
||||||
|
from app.api.v1.admin import content_pages
|
||||||
|
|
||||||
|
api_router.include_router(
|
||||||
|
content_pages.router,
|
||||||
|
prefix="/content-pages",
|
||||||
|
tags=["admin-content-pages"]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Store Router** (`app/api/v1/store/__init__.py`):
|
||||||
|
```python
|
||||||
|
from app.api.v1.store import content_pages
|
||||||
|
|
||||||
|
api_router.include_router(
|
||||||
|
content_pages.router,
|
||||||
|
prefix="/{store_code}/content-pages",
|
||||||
|
tags=["store-content-pages"]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Storefront Router** (`app/api/v1/storefront/__init__.py` or create if doesn't exist):
|
||||||
|
```python
|
||||||
|
from app.api.v1.storefront import content_pages
|
||||||
|
|
||||||
|
api_router.include_router(
|
||||||
|
content_pages.router,
|
||||||
|
prefix="/content-pages",
|
||||||
|
tags=["storefront-content-pages"]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Update Storefront Routes to Use CMS
|
||||||
|
|
||||||
|
Edit `app/routes/storefront_pages.py` to add a generic content page handler:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.services.content_page_service import content_page_service
|
||||||
|
|
||||||
|
@router.get("/{slug}", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
async def generic_content_page(
|
||||||
|
slug: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generic content page handler.
|
||||||
|
Handles: /about, /faq, /contact, /shipping, /returns, /privacy, /terms, etc.
|
||||||
|
"""
|
||||||
|
store = getattr(request.state, 'store', None)
|
||||||
|
store_id = store.id if store else None
|
||||||
|
|
||||||
|
page = content_page_service.get_page_for_store(
|
||||||
|
db,
|
||||||
|
slug=slug,
|
||||||
|
store_id=store_id,
|
||||||
|
include_unpublished=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if not page:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Page not found: {slug}")
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"storefront/content-page.html",
|
||||||
|
get_storefront_context(request, page=page)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Create Generic Content Page Template
|
||||||
|
|
||||||
|
Create `app/templates/storefront/content-page.html`:
|
||||||
|
|
||||||
|
```jinja2
|
||||||
|
{# app/templates/storefront/content-page.html #}
|
||||||
|
{% extends "storefront/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ page.title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block meta_description %}
|
||||||
|
{{ page.meta_description or page.title }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
|
||||||
|
{# Breadcrumbs #}
|
||||||
|
<nav class="mb-6 text-sm">
|
||||||
|
<a href="{{ base_url }}" class="text-primary hover:underline">Home</a>
|
||||||
|
<span class="mx-2 text-gray-400">/</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-300">{{ page.title }}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{# Page Title #}
|
||||||
|
<h1 class="text-4xl font-bold text-gray-900 dark:text-gray-100 mb-8">
|
||||||
|
{{ page.title }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{# Content #}
|
||||||
|
<div class="prose prose-lg dark:prose-invert max-w-none">
|
||||||
|
{{ page.content | safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Last updated #}
|
||||||
|
{% if page.updated_at %}
|
||||||
|
<div class="mt-12 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Last updated: {{ page.updated_at.strftime('%B %d, %Y') }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Update Footer to Load Navigation Dynamically
|
||||||
|
|
||||||
|
Edit `app/templates/storefront/base.html` to load navigation from database.
|
||||||
|
|
||||||
|
First, update the context helper to include footer pages:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app/routes/storefront_pages.py
|
||||||
|
|
||||||
|
def get_storefront_context(request: Request, **extra_context) -> dict:
|
||||||
|
# ... existing code ...
|
||||||
|
|
||||||
|
# Load footer navigation pages
|
||||||
|
db = next(get_db())
|
||||||
|
try:
|
||||||
|
footer_pages = content_page_service.list_pages_for_store(
|
||||||
|
db,
|
||||||
|
store_id=store.id if store else None,
|
||||||
|
include_unpublished=False,
|
||||||
|
footer_only=True
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"request": request,
|
||||||
|
"store": store,
|
||||||
|
"theme": theme,
|
||||||
|
"clean_path": clean_path,
|
||||||
|
"access_method": access_method,
|
||||||
|
"base_url": base_url,
|
||||||
|
"footer_pages": footer_pages, # Add this
|
||||||
|
**extra_context
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
```
|
||||||
|
|
||||||
|
Then update the footer template:
|
||||||
|
|
||||||
|
```jinja2
|
||||||
|
{# app/templates/storefront/base.html - Footer section #}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold mb-4">Quick Links</h4>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{% for page in footer_pages %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ base_url }}{{ page.slug }}"
|
||||||
|
class="text-gray-600 hover:text-primary dark:text-gray-400">
|
||||||
|
{{ page.title }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Create Default Platform Pages (Script)
|
||||||
|
|
||||||
|
Create `scripts/seed/create_default_content_pages.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create default platform content pages."""
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.services.content_page_service import content_page_service
|
||||||
|
|
||||||
|
def create_defaults():
|
||||||
|
db: Session = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# About Us
|
||||||
|
content_page_service.create_page(
|
||||||
|
db,
|
||||||
|
slug="about",
|
||||||
|
title="About Us",
|
||||||
|
content="""
|
||||||
|
<h2>Welcome to Our Marketplace</h2>
|
||||||
|
<p>We connect quality stores with customers worldwide.</p>
|
||||||
|
<p>Our mission is to provide a seamless shopping experience...</p>
|
||||||
|
""",
|
||||||
|
is_published=True,
|
||||||
|
show_in_footer=True,
|
||||||
|
display_order=1
|
||||||
|
)
|
||||||
|
|
||||||
|
# Shipping Information
|
||||||
|
content_page_service.create_page(
|
||||||
|
db,
|
||||||
|
slug="shipping",
|
||||||
|
title="Shipping Information",
|
||||||
|
content="""
|
||||||
|
<h2>Shipping Policy</h2>
|
||||||
|
<p>We offer fast and reliable shipping...</p>
|
||||||
|
""",
|
||||||
|
is_published=True,
|
||||||
|
show_in_footer=True,
|
||||||
|
display_order=2
|
||||||
|
)
|
||||||
|
|
||||||
|
# Returns
|
||||||
|
content_page_service.create_page(
|
||||||
|
db,
|
||||||
|
slug="returns",
|
||||||
|
title="Returns & Refunds",
|
||||||
|
content="""
|
||||||
|
<h2>Return Policy</h2>
|
||||||
|
<p>30-day return policy on all items...</p>
|
||||||
|
""",
|
||||||
|
is_published=True,
|
||||||
|
show_in_footer=True,
|
||||||
|
display_order=3
|
||||||
|
)
|
||||||
|
|
||||||
|
# Privacy Policy
|
||||||
|
content_page_service.create_page(
|
||||||
|
db,
|
||||||
|
slug="privacy",
|
||||||
|
title="Privacy Policy",
|
||||||
|
content="""
|
||||||
|
<h2>Privacy Policy</h2>
|
||||||
|
<p>Your privacy is important to us...</p>
|
||||||
|
""",
|
||||||
|
is_published=True,
|
||||||
|
show_in_footer=True,
|
||||||
|
display_order=4
|
||||||
|
)
|
||||||
|
|
||||||
|
# Terms of Service
|
||||||
|
content_page_service.create_page(
|
||||||
|
db,
|
||||||
|
slug="terms",
|
||||||
|
title="Terms of Service",
|
||||||
|
content="""
|
||||||
|
<h2>Terms of Service</h2>
|
||||||
|
<p>By using our platform, you agree to...</p>
|
||||||
|
""",
|
||||||
|
is_published=True,
|
||||||
|
show_in_footer=True,
|
||||||
|
display_order=5
|
||||||
|
)
|
||||||
|
|
||||||
|
# Contact
|
||||||
|
content_page_service.create_page(
|
||||||
|
db,
|
||||||
|
slug="contact",
|
||||||
|
title="Contact Us",
|
||||||
|
content="""
|
||||||
|
<h2>Get in Touch</h2>
|
||||||
|
<p>Have questions? We'd love to hear from you!</p>
|
||||||
|
<p>Email: support@example.com</p>
|
||||||
|
""",
|
||||||
|
is_published=True,
|
||||||
|
show_in_footer=True,
|
||||||
|
display_order=6
|
||||||
|
)
|
||||||
|
|
||||||
|
# FAQ
|
||||||
|
content_page_service.create_page(
|
||||||
|
db,
|
||||||
|
slug="faq",
|
||||||
|
title="Frequently Asked Questions",
|
||||||
|
content="""
|
||||||
|
<h2>FAQ</h2>
|
||||||
|
<h3>How do I place an order?</h3>
|
||||||
|
<p>Simply browse our products...</p>
|
||||||
|
""",
|
||||||
|
is_published=True,
|
||||||
|
show_in_footer=True,
|
||||||
|
display_order=7
|
||||||
|
)
|
||||||
|
|
||||||
|
print("✅ Created default content pages successfully")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error: {e}")
|
||||||
|
db.rollback()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
create_defaults()
|
||||||
|
```
|
||||||
|
|
||||||
|
Run it:
|
||||||
|
```bash
|
||||||
|
python scripts/seed/create_default_content_pages.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### 1. Test Platform Defaults
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create platform default
|
||||||
|
curl -X POST http://localhost:8000/api/v1/admin/content-pages/platform \
|
||||||
|
-H "Authorization: Bearer <admin_token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"slug": "about",
|
||||||
|
"title": "About Our Marketplace",
|
||||||
|
"content": "<h1>About</h1><p>Platform default content</p>",
|
||||||
|
"is_published": true,
|
||||||
|
"show_in_footer": true
|
||||||
|
}'
|
||||||
|
|
||||||
|
# View in storefront
|
||||||
|
curl http://localhost:8000/store/orion/about
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Test Store Override
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create store override
|
||||||
|
curl -X POST http://localhost:8000/api/v1/store/orion/content-pages/ \
|
||||||
|
-H "Authorization: Bearer <store_token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"slug": "about",
|
||||||
|
"title": "About Orion",
|
||||||
|
"content": "<h1>About Orion</h1><p>Custom store content</p>",
|
||||||
|
"is_published": true
|
||||||
|
}'
|
||||||
|
|
||||||
|
# View in storefront (should show store content)
|
||||||
|
curl http://localhost:8000/store/orion/about
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Test Fallback
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Delete store override
|
||||||
|
curl -X DELETE http://localhost:8000/api/v1/store/orion/content-pages/{id} \
|
||||||
|
-H "Authorization: Bearer <store_token>"
|
||||||
|
|
||||||
|
# View in storefront (should fall back to platform default)
|
||||||
|
curl http://localhost:8000/store/orion/about
|
||||||
|
```
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
You now have a complete CMS system that allows:
|
||||||
|
|
||||||
|
1. **Platform admins** to create default content for all stores
|
||||||
|
2. **Stores** to override specific pages with custom content
|
||||||
|
3. **Automatic fallback** to platform defaults when store hasn't customized
|
||||||
|
4. **Dynamic navigation** loading from database
|
||||||
|
5. **SEO optimization** with meta tags
|
||||||
|
6. **Draft/Published workflow** for content management
|
||||||
|
|
||||||
|
All pages are accessible via their slug: `/about`, `/faq`, `/contact`, etc. with proper store context and routing support!
|
||||||
61
app/modules/cms/docs/index.md
Normal file
61
app/modules/cms/docs/index.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Content Management
|
||||||
|
|
||||||
|
Content pages, media library, and store themes.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| Code | `cms` |
|
||||||
|
| Classification | Core |
|
||||||
|
| Dependencies | None |
|
||||||
|
| Status | Active |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- `cms_basic` — Basic content page management
|
||||||
|
- `cms_custom_pages` — Custom page creation
|
||||||
|
- `cms_unlimited_pages` — Unlimited pages (tier-gated)
|
||||||
|
- `cms_templates` — Page templates
|
||||||
|
- `cms_seo` — SEO metadata management
|
||||||
|
- `media_library` — Media file upload and management
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
| Permission | Description |
|
||||||
|
|------------|-------------|
|
||||||
|
| `cms.view_pages` | View content pages |
|
||||||
|
| `cms.manage_pages` | Create/edit/delete pages |
|
||||||
|
| `cms.view_media` | View media library |
|
||||||
|
| `cms.manage_media` | Upload/delete media files |
|
||||||
|
| `cms.manage_themes` | Manage store themes |
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
See [Data Model](data-model.md) for full entity relationships and schema.
|
||||||
|
|
||||||
|
- **ContentPage** — Multi-language content pages with platform/store hierarchy
|
||||||
|
- **MediaFile** — Media files with optimization and folder organization
|
||||||
|
- **StoreTheme** — Theme presets, colors, fonts, and branding
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `*` | `/api/v1/admin/content-pages/*` | Content page CRUD |
|
||||||
|
| `*` | `/api/v1/admin/media/*` | Media library management |
|
||||||
|
| `*` | `/api/v1/admin/images/*` | Image upload/management |
|
||||||
|
| `*` | `/api/v1/admin/store-themes/*` | Theme management |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
No module-specific configuration.
|
||||||
|
|
||||||
|
## Additional Documentation
|
||||||
|
|
||||||
|
- [Data Model](data-model.md) — Entity relationships and database schema
|
||||||
|
- [Architecture](architecture.md) — CMS architecture and database schema
|
||||||
|
- [Implementation](implementation.md) — Implementation checklist and status
|
||||||
|
- [Email Templates](email-templates.md) — Email template system architecture
|
||||||
|
- [Email Templates Guide](email-templates-guide.md) — Template customization guide
|
||||||
|
- [Media Library](media-library.md) — Media library usage guide
|
||||||
182
app/modules/cms/docs/media-library.md
Normal file
182
app/modules/cms/docs/media-library.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# Media Library
|
||||||
|
|
||||||
|
The media library provides centralized management of uploaded files (images, documents) for stores. Each store has their own isolated media storage.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
- **Storage Location**: `uploads/stores/{store_id}/{folder}/`
|
||||||
|
- **Supported Types**: Images (JPG, PNG, GIF, WebP), Documents (PDF)
|
||||||
|
- **Max File Size**: 10MB per file
|
||||||
|
- **Automatic Thumbnails**: Generated for images (200x200px)
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Admin Media Management
|
||||||
|
|
||||||
|
Admins can manage media for any store:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/admin/media/stores/{store_id} # List store's media
|
||||||
|
POST /api/v1/admin/media/stores/{store_id}/upload # Upload file
|
||||||
|
GET /api/v1/admin/media/stores/{store_id}/{id} # Get media details
|
||||||
|
DELETE /api/v1/admin/media/stores/{store_id}/{id} # Delete media
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `skip` | int | Pagination offset (default: 0) |
|
||||||
|
| `limit` | int | Items per page (default: 100, max: 1000) |
|
||||||
|
| `media_type` | string | Filter by type: `image`, `video`, `document` |
|
||||||
|
| `folder` | string | Filter by folder: `products`, `general`, etc. |
|
||||||
|
| `search` | string | Search by filename |
|
||||||
|
|
||||||
|
### Upload Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "File uploaded successfully",
|
||||||
|
"media": {
|
||||||
|
"id": 1,
|
||||||
|
"filename": "product-image.jpg",
|
||||||
|
"file_url": "/uploads/stores/1/products/abc123.jpg",
|
||||||
|
"url": "/uploads/stores/1/products/abc123.jpg",
|
||||||
|
"thumbnail_url": "/uploads/stores/1/thumbnails/thumb_abc123.jpg",
|
||||||
|
"media_type": "image",
|
||||||
|
"file_size": 245760,
|
||||||
|
"width": 1200,
|
||||||
|
"height": 800
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Media Picker Component
|
||||||
|
|
||||||
|
A reusable Alpine.js component for selecting images from the media library.
|
||||||
|
|
||||||
|
### Usage in Templates
|
||||||
|
|
||||||
|
```jinja2
|
||||||
|
{% from 'shared/macros/modals.html' import media_picker_modal %}
|
||||||
|
|
||||||
|
{# Single image selection #}
|
||||||
|
{{ media_picker_modal(
|
||||||
|
id='media-picker-main',
|
||||||
|
show_var='showMediaPicker',
|
||||||
|
store_id_var='storeId',
|
||||||
|
title='Select Image'
|
||||||
|
) }}
|
||||||
|
|
||||||
|
{# Multiple image selection #}
|
||||||
|
{{ media_picker_modal(
|
||||||
|
id='media-picker-additional',
|
||||||
|
show_var='showMediaPickerAdditional',
|
||||||
|
store_id_var='storeId',
|
||||||
|
multi_select=true,
|
||||||
|
title='Select Additional Images'
|
||||||
|
) }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript Integration
|
||||||
|
|
||||||
|
Include the media picker mixin in your Alpine.js component:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function myComponent() {
|
||||||
|
return {
|
||||||
|
...data(),
|
||||||
|
|
||||||
|
// Include media picker functionality
|
||||||
|
...mediaPickerMixin(() => this.storeId, false),
|
||||||
|
|
||||||
|
storeId: null,
|
||||||
|
|
||||||
|
// Override to handle selected image
|
||||||
|
setMainImage(media) {
|
||||||
|
this.form.image_url = media.url;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Override for multiple images
|
||||||
|
addAdditionalImages(mediaList) {
|
||||||
|
const urls = mediaList.map(m => m.url);
|
||||||
|
this.form.additional_images.push(...urls);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Media Picker Mixin API
|
||||||
|
|
||||||
|
| Property/Method | Description |
|
||||||
|
|-----------------|-------------|
|
||||||
|
| `showMediaPicker` | Boolean to show/hide main image picker modal |
|
||||||
|
| `showMediaPickerAdditional` | Boolean to show/hide additional images picker |
|
||||||
|
| `mediaPickerState` | Object containing loading, media array, selected items |
|
||||||
|
| `openMediaPickerMain()` | Open picker for main image |
|
||||||
|
| `openMediaPickerAdditional()` | Open picker for additional images |
|
||||||
|
| `loadMediaLibrary()` | Fetch media from API |
|
||||||
|
| `uploadMediaFile(event)` | Handle file upload |
|
||||||
|
| `toggleMediaSelection(media)` | Select/deselect a media item |
|
||||||
|
| `confirmMediaSelection()` | Confirm selection and call callbacks |
|
||||||
|
| `setMainImage(media)` | Override to handle main image selection |
|
||||||
|
| `addAdditionalImages(mediaList)` | Override to handle multiple selections |
|
||||||
|
|
||||||
|
## File Storage
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
uploads/
|
||||||
|
└── stores/
|
||||||
|
└── {store_id}/
|
||||||
|
├── products/ # Product images
|
||||||
|
├── general/ # General uploads
|
||||||
|
└── thumbnails/ # Auto-generated thumbnails
|
||||||
|
```
|
||||||
|
|
||||||
|
### URL Paths
|
||||||
|
|
||||||
|
Files are served from `/uploads/` path:
|
||||||
|
- Full image: `/uploads/stores/1/products/image.jpg`
|
||||||
|
- Thumbnail: `/uploads/stores/1/thumbnails/thumb_image.jpg`
|
||||||
|
|
||||||
|
## Database Model
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MediaFile(Base):
|
||||||
|
id: int
|
||||||
|
store_id: int
|
||||||
|
filename: str # Stored filename (UUID-based)
|
||||||
|
original_filename: str # Original upload name
|
||||||
|
file_path: str # Relative path from uploads/
|
||||||
|
thumbnail_path: str # Thumbnail relative path
|
||||||
|
media_type: str # image, video, document
|
||||||
|
mime_type: str # image/jpeg, etc.
|
||||||
|
file_size: int # Bytes
|
||||||
|
width: int # Image width
|
||||||
|
height: int # Image height
|
||||||
|
folder: str # products, general, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Product Images
|
||||||
|
|
||||||
|
Products support both a main image and additional images:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Product(Base):
|
||||||
|
primary_image_url: str # Main product image
|
||||||
|
additional_images: list[str] # Array of additional image URLs
|
||||||
|
```
|
||||||
|
|
||||||
|
### In Product Forms
|
||||||
|
|
||||||
|
The product create/edit forms include:
|
||||||
|
1. **Main Image**: Single image with preview and media picker
|
||||||
|
2. **Additional Images**: Grid of images with add/remove functionality
|
||||||
|
|
||||||
|
Both support:
|
||||||
|
- Browsing the store's media library
|
||||||
|
- Uploading new images directly
|
||||||
|
- Entering external URLs manually
|
||||||
@@ -102,7 +102,8 @@
|
|||||||
"signup_without": "Ohne Letzshop registrieren",
|
"signup_without": "Ohne Letzshop registrieren",
|
||||||
"looking_up": "Suche Ihren Shop...",
|
"looking_up": "Suche Ihren Shop...",
|
||||||
"found": "Gefunden:",
|
"found": "Gefunden:",
|
||||||
"claimed_badge": "Bereits beansprucht"
|
"claimed_badge": "Bereits beansprucht",
|
||||||
|
"error_lookup": "Suche fehlgeschlagen. Bitte versuchen Sie es erneut."
|
||||||
},
|
},
|
||||||
"signup": {
|
"signup": {
|
||||||
"step_plan": "Plan wählen",
|
"step_plan": "Plan wählen",
|
||||||
@@ -130,7 +131,18 @@
|
|||||||
"no_charge_note": "Sie werden erst nach Ablauf Ihrer {trial_days}-tägigen Testphase belastet.",
|
"no_charge_note": "Sie werden erst nach Ablauf Ihrer {trial_days}-tägigen Testphase belastet.",
|
||||||
"processing": "Verarbeitung...",
|
"processing": "Verarbeitung...",
|
||||||
"start_trial": "Kostenlose Testversion starten",
|
"start_trial": "Kostenlose Testversion starten",
|
||||||
"creating_account": "Erstelle Ihr Konto..."
|
"creating_account": "Erstelle Ihr Konto...",
|
||||||
|
"page_title": "Starten Sie Ihre kostenlose Testversion",
|
||||||
|
"required_fields": "Pflichtfelder",
|
||||||
|
"trial_info_days": "-Tage kostenlose Testversion.",
|
||||||
|
"error_start": "Anmeldung konnte nicht gestartet werden. Bitte versuchen Sie es erneut.",
|
||||||
|
"error_account": "Konto konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
|
||||||
|
"error_payment_config": "Zahlung nicht konfiguriert. Bitte kontaktieren Sie den Support.",
|
||||||
|
"error_payment": "Zahlung fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||||
|
"orders_per_month": "Bestellungen/Mo.",
|
||||||
|
"unlimited": "Unbegrenzt",
|
||||||
|
"team_members": "Benutzer",
|
||||||
|
"per_month_short": "/Mo."
|
||||||
},
|
},
|
||||||
"success": {
|
"success": {
|
||||||
"title": "Willkommen bei Orion!",
|
"title": "Willkommen bei Orion!",
|
||||||
@@ -152,6 +164,17 @@
|
|||||||
"subtitle": "Schließen Sie sich Letzshop-Händlern an, die Orion für ihre Bestellverwaltung vertrauen. Starten Sie heute Ihre {trial_days}-tägige kostenlose Testversion.",
|
"subtitle": "Schließen Sie sich Letzshop-Händlern an, die Orion für ihre Bestellverwaltung vertrauen. Starten Sie heute Ihre {trial_days}-tägige kostenlose Testversion.",
|
||||||
"button": "Kostenlos testen"
|
"button": "Kostenlos testen"
|
||||||
},
|
},
|
||||||
|
"content_page": {
|
||||||
|
"home": "Startseite",
|
||||||
|
"published": "Veröffentlicht am",
|
||||||
|
"last_updated": "Zuletzt aktualisiert:",
|
||||||
|
"cta_about_title": "Bereit loszulegen?",
|
||||||
|
"cta_contact_title": "Haben Sie Fragen?",
|
||||||
|
"cta_about_subtitle": "Schließen Sie sich Tausenden von Shops an, die bereits auf unserer Plattform verkaufen",
|
||||||
|
"cta_contact_subtitle": "Unser Team ist hier, um Ihnen zum Erfolg zu verhelfen",
|
||||||
|
"cta_about_button": "Vertrieb kontaktieren",
|
||||||
|
"cta_contact_button": "Senden Sie uns eine Nachricht"
|
||||||
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"tagline": "Leichtes OMS für Letzshop-Verkäufer. Verwalten Sie Bestellungen, Lager und Rechnungen.",
|
"tagline": "Leichtes OMS für Letzshop-Verkäufer. Verwalten Sie Bestellungen, Lager und Rechnungen.",
|
||||||
"quick_links": "Schnelllinks",
|
"quick_links": "Schnelllinks",
|
||||||
@@ -162,7 +185,8 @@
|
|||||||
"terms": "Nutzungsbedingungen",
|
"terms": "Nutzungsbedingungen",
|
||||||
"about": "Über uns",
|
"about": "Über uns",
|
||||||
"faq": "FAQ",
|
"faq": "FAQ",
|
||||||
"contact_us": "Kontaktieren Sie uns"
|
"contact_us": "Kontaktieren Sie uns",
|
||||||
|
"all_rights_reserved": "Alle Rechte vorbehalten."
|
||||||
},
|
},
|
||||||
"modern": {
|
"modern": {
|
||||||
"badge_integration": "Offizielle Integration",
|
"badge_integration": "Offizielle Integration",
|
||||||
@@ -197,7 +221,115 @@
|
|||||||
"features_subtitle": "Die operativen Tools, die Letzshop nicht bietet",
|
"features_subtitle": "Die operativen Tools, die Letzshop nicht bietet",
|
||||||
"cta_final_title": "Bereit, die Kontrolle über Ihr Letzshop-Geschäft zu übernehmen?",
|
"cta_final_title": "Bereit, die Kontrolle über Ihr Letzshop-Geschäft zu übernehmen?",
|
||||||
"cta_final_subtitle": "Schließen Sie sich luxemburgischen Händlern an, die aufgehört haben, gegen Tabellenkalkulationen zu kämpfen, und begonnen haben, ihr Geschäft auszubauen.",
|
"cta_final_subtitle": "Schließen Sie sich luxemburgischen Händlern an, die aufgehört haben, gegen Tabellenkalkulationen zu kämpfen, und begonnen haben, ihr Geschäft auszubauen.",
|
||||||
"cta_final_note": "Keine Kreditkarte erforderlich. Einrichtung in 5 Minuten. Volle Professional-Funktionen während der Testphase."
|
"cta_final_note": "Keine Kreditkarte erforderlich. Einrichtung in 5 Minuten. Volle Professional-Funktionen während der Testphase.",
|
||||||
|
"page_title": "Orion - Das Back-Office für Letzshop-Verkäufer",
|
||||||
|
"features_badge": "Funktionen",
|
||||||
|
"dashboard_title": "Orion Dashboard",
|
||||||
|
"todays_orders": "Heutige Bestellungen",
|
||||||
|
"revenue": "Umsatz",
|
||||||
|
"low_stock": "Geringer Bestand",
|
||||||
|
"items_need_restock": "Artikel nachbestellen",
|
||||||
|
"recent_orders": "Aktuelle Bestellungen von Letzshop",
|
||||||
|
"confirmed": "Bestätigt",
|
||||||
|
"shipped": "Versendet",
|
||||||
|
"feat_order_sync": "Automatische Bestellsynchronisation",
|
||||||
|
"feat_order_sync_desc": "Bestellungen von Letzshop erscheinen sofort. Bestätigen und Tracking-Nummern automatisch synchronisieren.",
|
||||||
|
"feat_order_sync_1": "Echtzeit-Synchronisation",
|
||||||
|
"feat_order_sync_2": "Bestätigung mit einem Klick",
|
||||||
|
"feat_order_sync_3": "Tracking-Nummern-Synchronisation",
|
||||||
|
"feat_inventory": "Echte Lagerverwaltung",
|
||||||
|
"feat_inventory_desc": "Eine einzige Wahrheitsquelle für alle Bestände. Lagerorte, Reservierungen und eingehende Bestandsverfolgung.",
|
||||||
|
"feat_inventory_1": "Produktlagerorte (Fächer)",
|
||||||
|
"feat_inventory_2": "Bestandsreservierungen",
|
||||||
|
"feat_inventory_3": "Niedrigbestand-Warnungen",
|
||||||
|
"feat_invoicing": "Intelligente MwSt-Rechnungsstellung",
|
||||||
|
"feat_invoicing_desc": "PDF-Rechnungen mit korrekten MwSt-Sätzen erstellen. Luxemburg, EU-Länder, B2B-Reverse-Charge.",
|
||||||
|
"feat_invoicing_1": "Luxemburg 17% MwSt",
|
||||||
|
"feat_invoicing_2": "EU-Bestimmungsland-MwSt (OSS)",
|
||||||
|
"feat_invoicing_3": "B2B-Reverse-Charge",
|
||||||
|
"feat_customers": "Besitzen Sie Ihre Kunden",
|
||||||
|
"feat_customers_desc": "Alle Kundendaten in Ihrer Datenbank. Export zu Mailchimp für Marketingkampagnen.",
|
||||||
|
"feat_customers_1": "Bestellhistorie pro Kunde",
|
||||||
|
"feat_customers_2": "Lebenszeitwert-Tracking",
|
||||||
|
"feat_customers_3": "CSV-Export für Marketing",
|
||||||
|
"feat_team": "Teamverwaltung",
|
||||||
|
"feat_team_desc": "Laden Sie Teammitglieder mit rollenbasierten Berechtigungen ein. Alle arbeiten von einem Dashboard.",
|
||||||
|
"feat_team_1": "Mehrere Benutzer",
|
||||||
|
"feat_team_2": "Rollenbasierter Zugang",
|
||||||
|
"feat_team_3": "Aktivitätsprotokoll",
|
||||||
|
"feat_purchase_orders": "Bestellungen",
|
||||||
|
"feat_purchase_orders_desc": "Verfolgen Sie eingehende Bestände von Lieferanten. Wissen Sie, was bestellt ist und wann es ankommt.",
|
||||||
|
"feat_purchase_orders_1": "Lieferantenbestellungen verfolgen",
|
||||||
|
"feat_purchase_orders_2": "Voraussichtliche Ankunftsdaten",
|
||||||
|
"feat_purchase_orders_3": "Empfang und Bestandsaktualisierung",
|
||||||
|
"pricing_badge": "Preise",
|
||||||
|
"pricing_title": "Einfache, transparente Preisgestaltung",
|
||||||
|
"pricing_subtitle": "Keine Gebühren pro Bestellung. Keine versteckten Kosten. Fester Monatstarif.",
|
||||||
|
"pricing_per_month": "/Monat",
|
||||||
|
"pricing_trial_note": "Alle Pläne beinhalten eine 14-tägige kostenlose Testphase. Keine Kreditkarte erforderlich.",
|
||||||
|
"tier_essential": "Essential",
|
||||||
|
"tier_essential_desc": "Für Solo-Shops am Anfang",
|
||||||
|
"tier_essential_price": "49 EUR",
|
||||||
|
"tier_essential_feat_1": "100 Bestellungen/Monat",
|
||||||
|
"tier_essential_feat_2": "200 Produkte",
|
||||||
|
"tier_essential_feat_3": "Luxemburg MwSt-Rechnungen",
|
||||||
|
"tier_essential_feat_4": "1 Teammitglied",
|
||||||
|
"tier_essential_cta": "Kostenlos testen",
|
||||||
|
"tier_professional": "Professional",
|
||||||
|
"tier_professional_desc": "Für wachsende Multichannel-Verkäufer",
|
||||||
|
"tier_professional_price": "99 EUR",
|
||||||
|
"tier_professional_badge": "AM BELIEBTESTEN",
|
||||||
|
"tier_professional_feat_1": "500 Bestellungen/Monat",
|
||||||
|
"tier_professional_feat_2": "Unbegrenzte Produkte",
|
||||||
|
"tier_professional_feat_3": "EU MwSt-Rechnungen",
|
||||||
|
"tier_professional_feat_4": "Produktlagerorte",
|
||||||
|
"tier_professional_feat_5": "Bestellungen",
|
||||||
|
"tier_professional_feat_6": "Kundenexport",
|
||||||
|
"tier_professional_feat_7": "3 Teammitglieder",
|
||||||
|
"tier_professional_cta": "Kostenlos testen",
|
||||||
|
"tier_business": "Business",
|
||||||
|
"tier_business_desc": "Für Großvolumen-Betriebe",
|
||||||
|
"tier_business_price": "199 EUR",
|
||||||
|
"tier_business_feat_1": "2.000 Bestellungen/Monat",
|
||||||
|
"tier_business_feat_2": "Alles in Professional",
|
||||||
|
"tier_business_feat_3": "Analyse-Dashboard",
|
||||||
|
"tier_business_feat_4": "API-Zugang",
|
||||||
|
"tier_business_feat_5": "Buchhaltungsexport",
|
||||||
|
"tier_business_feat_6": "10 Teammitglieder",
|
||||||
|
"tier_business_cta": "Kostenlos testen",
|
||||||
|
"tier_enterprise": "Enterprise",
|
||||||
|
"tier_enterprise_desc": "Für große Betriebe und Agenturen",
|
||||||
|
"tier_enterprise_price": "399+ EUR",
|
||||||
|
"tier_enterprise_feat_1": "Unbegrenzte Bestellungen",
|
||||||
|
"tier_enterprise_feat_2": "Alles in Business",
|
||||||
|
"tier_enterprise_feat_3": "White-Label-Option",
|
||||||
|
"tier_enterprise_feat_4": "Individuelle Integrationen",
|
||||||
|
"tier_enterprise_feat_5": "99,9% SLA",
|
||||||
|
"tier_enterprise_feat_6": "Dedizierter Support",
|
||||||
|
"tier_enterprise_cta": "Vertrieb kontaktieren",
|
||||||
|
"testimonial_badge": "Für Luxemburg entwickelt",
|
||||||
|
"testimonial_quote": "Endlich ein Tool, das versteht, was Letzshop-Verkäufer wirklich brauchen. Keine Tabellenkalkulationen mehr, keine MwSt-Kopfschmerzen mehr.",
|
||||||
|
"testimonial_name": "Marie L.",
|
||||||
|
"testimonial_location": "Letzshop Store, Luxemburg-Stadt",
|
||||||
|
"cta_final_trial": "Starten Sie Ihre 14-tägige kostenlose Testphase"
|
||||||
|
},
|
||||||
|
"minimal": {
|
||||||
|
"page_title_fallback": "Startseite",
|
||||||
|
"marketplace_suffix": "Marktplatz",
|
||||||
|
"fallback_title_1": "Multi-Store",
|
||||||
|
"fallback_title_2": "Marktplatz",
|
||||||
|
"fallback_subtitle": "Der einfachste Weg, Ihren Online-Shop zu starten und sich mit Kunden weltweit zu verbinden.",
|
||||||
|
"get_started": "Loslegen",
|
||||||
|
"feat_fast": "Schnell",
|
||||||
|
"feat_fast_desc": "Blitzschnelle Leistung, optimiert für Conversions",
|
||||||
|
"feat_secure": "Sicher",
|
||||||
|
"feat_secure_desc": "Sicherheit auf Enterprise-Niveau für Ihre Sicherheit",
|
||||||
|
"feat_custom": "Individuell",
|
||||||
|
"feat_custom_desc": "Vollständig anpassbar an Ihre Markenidentität",
|
||||||
|
"cta_title": "Bereit zum Start?",
|
||||||
|
"cta_subtitle": "Treten Sie noch heute unserem Marktplatz bei",
|
||||||
|
"cta_contact": "Kontakt",
|
||||||
|
"cta_learn_more": "Mehr erfahren"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"features": {
|
"features": {
|
||||||
@@ -246,5 +378,25 @@
|
|||||||
"manage_media_desc": "Mediendateien hochladen, bearbeiten und löschen",
|
"manage_media_desc": "Mediendateien hochladen, bearbeiten und löschen",
|
||||||
"manage_themes": "Themes verwalten",
|
"manage_themes": "Themes verwalten",
|
||||||
"manage_themes_desc": "Shop-Themes konfigurieren und anpassen"
|
"manage_themes_desc": "Shop-Themes konfigurieren und anpassen"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"failed_to_delete_page": "Seite konnte nicht gelöscht werden: {error}",
|
||||||
|
"media_updated_successfully": "Medium erfolgreich aktualisiert",
|
||||||
|
"media_deleted_successfully": "Medium erfolgreich gelöscht",
|
||||||
|
"url_copied_to_clipboard": "URL in die Zwischenablage kopiert",
|
||||||
|
"failed_to_copy_url": "URL konnte nicht kopiert werden"
|
||||||
|
},
|
||||||
|
"confirmations": {
|
||||||
|
"delete_file": "Sind Sie sicher, dass Sie diese Datei löschen möchten? Dies kann nicht rückgängig gemacht werden."
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"my_account": "Mein Konto",
|
||||||
|
"learn_more": "Mehr erfahren",
|
||||||
|
"explore": "Entdecken",
|
||||||
|
"quick_links": "Schnellzugriff",
|
||||||
|
"information": "Informationen",
|
||||||
|
"about": "Über uns",
|
||||||
|
"contact": "Kontakt",
|
||||||
|
"faq": "FAQ"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,8 @@
|
|||||||
"signup_without": "Sign Up Without Letzshop",
|
"signup_without": "Sign Up Without Letzshop",
|
||||||
"looking_up": "Looking up your shop...",
|
"looking_up": "Looking up your shop...",
|
||||||
"found": "Found:",
|
"found": "Found:",
|
||||||
"claimed_badge": "Already Claimed"
|
"claimed_badge": "Already Claimed",
|
||||||
|
"error_lookup": "Failed to lookup. Please try again."
|
||||||
},
|
},
|
||||||
"signup": {
|
"signup": {
|
||||||
"step_plan": "Select Plan",
|
"step_plan": "Select Plan",
|
||||||
@@ -130,7 +131,18 @@
|
|||||||
"no_charge_note": "You won't be charged until your {trial_days}-day trial ends.",
|
"no_charge_note": "You won't be charged until your {trial_days}-day trial ends.",
|
||||||
"processing": "Processing...",
|
"processing": "Processing...",
|
||||||
"start_trial": "Start Free Trial",
|
"start_trial": "Start Free Trial",
|
||||||
"creating_account": "Creating your account..."
|
"creating_account": "Creating your account...",
|
||||||
|
"page_title": "Start Your Free Trial",
|
||||||
|
"required_fields": "Required fields",
|
||||||
|
"trial_info_days": "day free trial.",
|
||||||
|
"error_start": "Failed to start signup. Please try again.",
|
||||||
|
"error_account": "Failed to create account. Please try again.",
|
||||||
|
"error_payment_config": "Payment not configured. Please contact support.",
|
||||||
|
"error_payment": "Payment failed. Please try again.",
|
||||||
|
"orders_per_month": "orders/mo",
|
||||||
|
"unlimited": "Unlimited",
|
||||||
|
"team_members": "users",
|
||||||
|
"per_month_short": "/mo"
|
||||||
},
|
},
|
||||||
"success": {
|
"success": {
|
||||||
"title": "Welcome to Orion!",
|
"title": "Welcome to Orion!",
|
||||||
@@ -152,6 +164,17 @@
|
|||||||
"subtitle": "Join Letzshop stores who trust Orion for their order management. Start your {trial_days}-day free trial today.",
|
"subtitle": "Join Letzshop stores who trust Orion for their order management. Start your {trial_days}-day free trial today.",
|
||||||
"button": "Start Free Trial"
|
"button": "Start Free Trial"
|
||||||
},
|
},
|
||||||
|
"content_page": {
|
||||||
|
"home": "Home",
|
||||||
|
"published": "Published",
|
||||||
|
"last_updated": "Last updated:",
|
||||||
|
"cta_about_title": "Ready to Get Started?",
|
||||||
|
"cta_contact_title": "Have Questions?",
|
||||||
|
"cta_about_subtitle": "Join thousands of stores already selling on our platform",
|
||||||
|
"cta_contact_subtitle": "Our team is here to help you succeed",
|
||||||
|
"cta_about_button": "Contact Sales",
|
||||||
|
"cta_contact_button": "Send Us a Message"
|
||||||
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"tagline": "Lightweight OMS for Letzshop sellers. Manage orders, inventory, and invoicing.",
|
"tagline": "Lightweight OMS for Letzshop sellers. Manage orders, inventory, and invoicing.",
|
||||||
"quick_links": "Quick Links",
|
"quick_links": "Quick Links",
|
||||||
@@ -162,7 +185,8 @@
|
|||||||
"terms": "Terms of Service",
|
"terms": "Terms of Service",
|
||||||
"about": "About Us",
|
"about": "About Us",
|
||||||
"faq": "FAQ",
|
"faq": "FAQ",
|
||||||
"contact_us": "Contact Us"
|
"contact_us": "Contact Us",
|
||||||
|
"all_rights_reserved": "All rights reserved."
|
||||||
},
|
},
|
||||||
"modern": {
|
"modern": {
|
||||||
"badge_integration": "Official Integration",
|
"badge_integration": "Official Integration",
|
||||||
@@ -197,7 +221,115 @@
|
|||||||
"features_subtitle": "The operational tools Letzshop doesn't provide",
|
"features_subtitle": "The operational tools Letzshop doesn't provide",
|
||||||
"cta_final_title": "Ready to Take Control of Your Letzshop Business?",
|
"cta_final_title": "Ready to Take Control of Your Letzshop Business?",
|
||||||
"cta_final_subtitle": "Join Luxembourg stores who've stopped fighting spreadsheets and started growing their business.",
|
"cta_final_subtitle": "Join Luxembourg stores who've stopped fighting spreadsheets and started growing their business.",
|
||||||
"cta_final_note": "No credit card required. Setup in 5 minutes. Full Professional features during trial."
|
"cta_final_note": "No credit card required. Setup in 5 minutes. Full Professional features during trial.",
|
||||||
|
"page_title": "Orion - The Back-Office for Letzshop Sellers",
|
||||||
|
"features_badge": "Features",
|
||||||
|
"dashboard_title": "Orion Dashboard",
|
||||||
|
"todays_orders": "Today's Orders",
|
||||||
|
"revenue": "Revenue",
|
||||||
|
"low_stock": "Low Stock",
|
||||||
|
"items_need_restock": "items need restock",
|
||||||
|
"recent_orders": "Recent Orders from Letzshop",
|
||||||
|
"confirmed": "Confirmed",
|
||||||
|
"shipped": "Shipped",
|
||||||
|
"feat_order_sync": "Automatic Order Sync",
|
||||||
|
"feat_order_sync_desc": "Orders from Letzshop appear instantly. Confirm orders and sync tracking numbers back automatically.",
|
||||||
|
"feat_order_sync_1": "Real-time sync",
|
||||||
|
"feat_order_sync_2": "One-click confirmation",
|
||||||
|
"feat_order_sync_3": "Tracking number sync",
|
||||||
|
"feat_inventory": "Real Inventory Management",
|
||||||
|
"feat_inventory_desc": "One source of truth for all stock. Locations, reservations, and incoming stock tracking.",
|
||||||
|
"feat_inventory_1": "Product locations (bins)",
|
||||||
|
"feat_inventory_2": "Stock reservations",
|
||||||
|
"feat_inventory_3": "Low stock alerts",
|
||||||
|
"feat_invoicing": "Smart VAT Invoicing",
|
||||||
|
"feat_invoicing_desc": "Generate PDF invoices with correct VAT rates. Luxembourg, EU countries, B2B reverse charge.",
|
||||||
|
"feat_invoicing_1": "Luxembourg 17% VAT",
|
||||||
|
"feat_invoicing_2": "EU destination VAT (OSS)",
|
||||||
|
"feat_invoicing_3": "B2B reverse charge",
|
||||||
|
"feat_customers": "Own Your Customers",
|
||||||
|
"feat_customers_desc": "All customer data in your database. Export to Mailchimp for marketing campaigns.",
|
||||||
|
"feat_customers_1": "Order history per customer",
|
||||||
|
"feat_customers_2": "Lifetime value tracking",
|
||||||
|
"feat_customers_3": "CSV export for marketing",
|
||||||
|
"feat_team": "Team Management",
|
||||||
|
"feat_team_desc": "Invite team members with role-based permissions. Everyone works from one dashboard.",
|
||||||
|
"feat_team_1": "Multiple users",
|
||||||
|
"feat_team_2": "Role-based access",
|
||||||
|
"feat_team_3": "Activity logging",
|
||||||
|
"feat_purchase_orders": "Purchase Orders",
|
||||||
|
"feat_purchase_orders_desc": "Track incoming stock from suppliers. Know what's on order and when it arrives.",
|
||||||
|
"feat_purchase_orders_1": "Track supplier orders",
|
||||||
|
"feat_purchase_orders_2": "Expected arrival dates",
|
||||||
|
"feat_purchase_orders_3": "Receive and update stock",
|
||||||
|
"pricing_badge": "Pricing",
|
||||||
|
"pricing_title": "Simple, Transparent Pricing",
|
||||||
|
"pricing_subtitle": "No per-order fees. No hidden costs. Flat monthly rate.",
|
||||||
|
"pricing_per_month": "/month",
|
||||||
|
"pricing_trial_note": "All plans include a 14-day free trial. No credit card required.",
|
||||||
|
"tier_essential": "Essential",
|
||||||
|
"tier_essential_desc": "For solo stores getting started",
|
||||||
|
"tier_essential_price": "EUR 49",
|
||||||
|
"tier_essential_feat_1": "100 orders/month",
|
||||||
|
"tier_essential_feat_2": "200 products",
|
||||||
|
"tier_essential_feat_3": "Luxembourg VAT invoices",
|
||||||
|
"tier_essential_feat_4": "1 team member",
|
||||||
|
"tier_essential_cta": "Start Free Trial",
|
||||||
|
"tier_professional": "Professional",
|
||||||
|
"tier_professional_desc": "For growing multi-channel sellers",
|
||||||
|
"tier_professional_price": "EUR 99",
|
||||||
|
"tier_professional_badge": "MOST POPULAR",
|
||||||
|
"tier_professional_feat_1": "500 orders/month",
|
||||||
|
"tier_professional_feat_2": "Unlimited products",
|
||||||
|
"tier_professional_feat_3": "EU VAT invoices",
|
||||||
|
"tier_professional_feat_4": "Product locations",
|
||||||
|
"tier_professional_feat_5": "Purchase orders",
|
||||||
|
"tier_professional_feat_6": "Customer export",
|
||||||
|
"tier_professional_feat_7": "3 team members",
|
||||||
|
"tier_professional_cta": "Start Free Trial",
|
||||||
|
"tier_business": "Business",
|
||||||
|
"tier_business_desc": "For high-volume operations",
|
||||||
|
"tier_business_price": "EUR 199",
|
||||||
|
"tier_business_feat_1": "2,000 orders/month",
|
||||||
|
"tier_business_feat_2": "Everything in Professional",
|
||||||
|
"tier_business_feat_3": "Analytics dashboard",
|
||||||
|
"tier_business_feat_4": "API access",
|
||||||
|
"tier_business_feat_5": "Accounting export",
|
||||||
|
"tier_business_feat_6": "10 team members",
|
||||||
|
"tier_business_cta": "Start Free Trial",
|
||||||
|
"tier_enterprise": "Enterprise",
|
||||||
|
"tier_enterprise_desc": "For large operations & agencies",
|
||||||
|
"tier_enterprise_price": "EUR 399+",
|
||||||
|
"tier_enterprise_feat_1": "Unlimited orders",
|
||||||
|
"tier_enterprise_feat_2": "Everything in Business",
|
||||||
|
"tier_enterprise_feat_3": "White-label option",
|
||||||
|
"tier_enterprise_feat_4": "Custom integrations",
|
||||||
|
"tier_enterprise_feat_5": "99.9% SLA",
|
||||||
|
"tier_enterprise_feat_6": "Dedicated support",
|
||||||
|
"tier_enterprise_cta": "Contact Sales",
|
||||||
|
"testimonial_badge": "Built for Luxembourg",
|
||||||
|
"testimonial_quote": "Finally, a tool that understands what Letzshop sellers actually need. No more spreadsheets, no more VAT headaches.",
|
||||||
|
"testimonial_name": "Marie L.",
|
||||||
|
"testimonial_location": "Letzshop Store, Luxembourg City",
|
||||||
|
"cta_final_trial": "Start Your 14-Day Free Trial"
|
||||||
|
},
|
||||||
|
"minimal": {
|
||||||
|
"page_title_fallback": "Home",
|
||||||
|
"marketplace_suffix": "Marketplace",
|
||||||
|
"fallback_title_1": "Multi-Store",
|
||||||
|
"fallback_title_2": "Marketplace",
|
||||||
|
"fallback_subtitle": "The simplest way to launch your online store and connect with customers worldwide.",
|
||||||
|
"get_started": "Get Started",
|
||||||
|
"feat_fast": "Fast",
|
||||||
|
"feat_fast_desc": "Lightning-fast performance optimized for conversions",
|
||||||
|
"feat_secure": "Secure",
|
||||||
|
"feat_secure_desc": "Enterprise-grade security for your peace of mind",
|
||||||
|
"feat_custom": "Custom",
|
||||||
|
"feat_custom_desc": "Fully customizable to match your brand identity",
|
||||||
|
"cta_title": "Ready to launch?",
|
||||||
|
"cta_subtitle": "Join our marketplace today",
|
||||||
|
"cta_contact": "Contact Us",
|
||||||
|
"cta_learn_more": "Learn More"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
@@ -256,5 +388,15 @@
|
|||||||
"content_pages": "Content Pages",
|
"content_pages": "Content Pages",
|
||||||
"store_themes": "Store Themes",
|
"store_themes": "Store Themes",
|
||||||
"media_library": "Media Library"
|
"media_library": "Media Library"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"my_account": "My Account",
|
||||||
|
"learn_more": "Learn More",
|
||||||
|
"explore": "Explore",
|
||||||
|
"quick_links": "Quick Links",
|
||||||
|
"information": "Information",
|
||||||
|
"about": "About Us",
|
||||||
|
"contact": "Contact",
|
||||||
|
"faq": "FAQ"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,8 @@
|
|||||||
"signup_without": "S'inscrire sans Letzshop",
|
"signup_without": "S'inscrire sans Letzshop",
|
||||||
"looking_up": "Recherche de votre boutique...",
|
"looking_up": "Recherche de votre boutique...",
|
||||||
"found": "Trouvé :",
|
"found": "Trouvé :",
|
||||||
"claimed_badge": "Déjà réclamée"
|
"claimed_badge": "Déjà réclamée",
|
||||||
|
"error_lookup": "La recherche a échoué. Veuillez réessayer."
|
||||||
},
|
},
|
||||||
"signup": {
|
"signup": {
|
||||||
"step_plan": "Choisir le plan",
|
"step_plan": "Choisir le plan",
|
||||||
@@ -130,7 +131,18 @@
|
|||||||
"no_charge_note": "Vous ne serez pas débité avant la fin de votre essai de {trial_days} jours.",
|
"no_charge_note": "Vous ne serez pas débité avant la fin de votre essai de {trial_days} jours.",
|
||||||
"processing": "Traitement en cours...",
|
"processing": "Traitement en cours...",
|
||||||
"start_trial": "Démarrer l'essai gratuit",
|
"start_trial": "Démarrer l'essai gratuit",
|
||||||
"creating_account": "Création de votre compte..."
|
"creating_account": "Création de votre compte...",
|
||||||
|
"page_title": "Démarrez votre essai gratuit",
|
||||||
|
"required_fields": "Champs obligatoires",
|
||||||
|
"trial_info_days": "jours d'essai gratuit.",
|
||||||
|
"error_start": "Échec du démarrage de l'inscription. Veuillez réessayer.",
|
||||||
|
"error_account": "Échec de la création du compte. Veuillez réessayer.",
|
||||||
|
"error_payment_config": "Paiement non configuré. Veuillez contacter le support.",
|
||||||
|
"error_payment": "Le paiement a échoué. Veuillez réessayer.",
|
||||||
|
"orders_per_month": "commandes/mois",
|
||||||
|
"unlimited": "Illimité",
|
||||||
|
"team_members": "utilisateurs",
|
||||||
|
"per_month_short": "/mois"
|
||||||
},
|
},
|
||||||
"success": {
|
"success": {
|
||||||
"title": "Bienvenue sur Orion !",
|
"title": "Bienvenue sur Orion !",
|
||||||
@@ -152,6 +164,17 @@
|
|||||||
"subtitle": "Rejoignez les vendeurs Letzshop qui font confiance à Orion pour leur gestion de commandes. Commencez votre essai gratuit de {trial_days} jours aujourd'hui.",
|
"subtitle": "Rejoignez les vendeurs Letzshop qui font confiance à Orion pour leur gestion de commandes. Commencez votre essai gratuit de {trial_days} jours aujourd'hui.",
|
||||||
"button": "Essai gratuit"
|
"button": "Essai gratuit"
|
||||||
},
|
},
|
||||||
|
"content_page": {
|
||||||
|
"home": "Accueil",
|
||||||
|
"published": "Publié le",
|
||||||
|
"last_updated": "Dernière mise à jour :",
|
||||||
|
"cta_about_title": "Prêt à commencer ?",
|
||||||
|
"cta_contact_title": "Des questions ?",
|
||||||
|
"cta_about_subtitle": "Rejoignez des milliers de boutiques qui vendent déjà sur notre plateforme",
|
||||||
|
"cta_contact_subtitle": "Notre équipe est là pour vous aider à réussir",
|
||||||
|
"cta_about_button": "Contactez-nous",
|
||||||
|
"cta_contact_button": "Envoyez-nous un message"
|
||||||
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"tagline": "OMS léger pour les vendeurs Letzshop. Gérez commandes, stocks et facturation.",
|
"tagline": "OMS léger pour les vendeurs Letzshop. Gérez commandes, stocks et facturation.",
|
||||||
"quick_links": "Liens rapides",
|
"quick_links": "Liens rapides",
|
||||||
@@ -162,7 +185,8 @@
|
|||||||
"terms": "Conditions d'utilisation",
|
"terms": "Conditions d'utilisation",
|
||||||
"about": "À propos",
|
"about": "À propos",
|
||||||
"faq": "FAQ",
|
"faq": "FAQ",
|
||||||
"contact_us": "Nous contacter"
|
"contact_us": "Nous contacter",
|
||||||
|
"all_rights_reserved": "Tous droits réservés."
|
||||||
},
|
},
|
||||||
"modern": {
|
"modern": {
|
||||||
"badge_integration": "Intégration officielle",
|
"badge_integration": "Intégration officielle",
|
||||||
@@ -197,7 +221,115 @@
|
|||||||
"features_subtitle": "Les outils opérationnels que Letzshop ne fournit pas",
|
"features_subtitle": "Les outils opérationnels que Letzshop ne fournit pas",
|
||||||
"cta_final_title": "Prêt à prendre le contrôle de votre entreprise Letzshop ?",
|
"cta_final_title": "Prêt à prendre le contrôle de votre entreprise Letzshop ?",
|
||||||
"cta_final_subtitle": "Rejoignez les vendeurs luxembourgeois qui ont arrêté de lutter contre les tableurs et ont commencé à développer leur entreprise.",
|
"cta_final_subtitle": "Rejoignez les vendeurs luxembourgeois qui ont arrêté de lutter contre les tableurs et ont commencé à développer leur entreprise.",
|
||||||
"cta_final_note": "Aucune carte de crédit requise. Configuration en 5 minutes. Toutes les fonctionnalités Pro pendant l'essai."
|
"cta_final_note": "Aucune carte de crédit requise. Configuration en 5 minutes. Toutes les fonctionnalités Pro pendant l'essai.",
|
||||||
|
"page_title": "Orion - Le back-office pour les vendeurs Letzshop",
|
||||||
|
"features_badge": "Fonctionnalités",
|
||||||
|
"dashboard_title": "Tableau de bord Orion",
|
||||||
|
"todays_orders": "Commandes du jour",
|
||||||
|
"revenue": "Chiffre d'affaires",
|
||||||
|
"low_stock": "Stock faible",
|
||||||
|
"items_need_restock": "articles à réapprovisionner",
|
||||||
|
"recent_orders": "Commandes récentes de Letzshop",
|
||||||
|
"confirmed": "Confirmée",
|
||||||
|
"shipped": "Expédiée",
|
||||||
|
"feat_order_sync": "Synchronisation automatique des commandes",
|
||||||
|
"feat_order_sync_desc": "Les commandes Letzshop apparaissent instantanément. Confirmez et synchronisez les numéros de suivi automatiquement.",
|
||||||
|
"feat_order_sync_1": "Synchronisation en temps réel",
|
||||||
|
"feat_order_sync_2": "Confirmation en un clic",
|
||||||
|
"feat_order_sync_3": "Synchronisation des numéros de suivi",
|
||||||
|
"feat_inventory": "Gestion réelle des stocks",
|
||||||
|
"feat_inventory_desc": "Une source unique de vérité pour tous les stocks. Emplacements, réservations et suivi des stocks entrants.",
|
||||||
|
"feat_inventory_1": "Emplacements produits (bacs)",
|
||||||
|
"feat_inventory_2": "Réservations de stock",
|
||||||
|
"feat_inventory_3": "Alertes de stock faible",
|
||||||
|
"feat_invoicing": "Facturation TVA intelligente",
|
||||||
|
"feat_invoicing_desc": "Générez des factures PDF avec les taux de TVA corrects. Luxembourg, pays UE, autoliquidation B2B.",
|
||||||
|
"feat_invoicing_1": "TVA Luxembourg 17%",
|
||||||
|
"feat_invoicing_2": "TVA destination UE (OSS)",
|
||||||
|
"feat_invoicing_3": "Autoliquidation B2B",
|
||||||
|
"feat_customers": "Possédez vos clients",
|
||||||
|
"feat_customers_desc": "Toutes les données clients dans votre base. Exportez vers Mailchimp pour vos campagnes marketing.",
|
||||||
|
"feat_customers_1": "Historique des commandes par client",
|
||||||
|
"feat_customers_2": "Suivi de la valeur à vie",
|
||||||
|
"feat_customers_3": "Export CSV pour le marketing",
|
||||||
|
"feat_team": "Gestion d'équipe",
|
||||||
|
"feat_team_desc": "Invitez des membres avec des permissions basées sur les rôles. Tout le monde travaille depuis un tableau de bord.",
|
||||||
|
"feat_team_1": "Utilisateurs multiples",
|
||||||
|
"feat_team_2": "Accès basé sur les rôles",
|
||||||
|
"feat_team_3": "Journal d'activité",
|
||||||
|
"feat_purchase_orders": "Bons de commande",
|
||||||
|
"feat_purchase_orders_desc": "Suivez les stocks entrants des fournisseurs. Sachez ce qui est commandé et quand ça arrive.",
|
||||||
|
"feat_purchase_orders_1": "Suivi des commandes fournisseurs",
|
||||||
|
"feat_purchase_orders_2": "Dates d'arrivée prévues",
|
||||||
|
"feat_purchase_orders_3": "Réception et mise à jour du stock",
|
||||||
|
"pricing_badge": "Tarifs",
|
||||||
|
"pricing_title": "Tarification simple et transparente",
|
||||||
|
"pricing_subtitle": "Pas de frais par commande. Pas de coûts cachés. Tarif mensuel fixe.",
|
||||||
|
"pricing_per_month": "/mois",
|
||||||
|
"pricing_trial_note": "Tous les plans incluent un essai gratuit de 14 jours. Aucune carte de crédit requise.",
|
||||||
|
"tier_essential": "Essentiel",
|
||||||
|
"tier_essential_desc": "Pour les boutiques solo qui débutent",
|
||||||
|
"tier_essential_price": "49 EUR",
|
||||||
|
"tier_essential_feat_1": "100 commandes/mois",
|
||||||
|
"tier_essential_feat_2": "200 produits",
|
||||||
|
"tier_essential_feat_3": "Factures TVA Luxembourg",
|
||||||
|
"tier_essential_feat_4": "1 membre d'équipe",
|
||||||
|
"tier_essential_cta": "Essai gratuit",
|
||||||
|
"tier_professional": "Professionnel",
|
||||||
|
"tier_professional_desc": "Pour les vendeurs multicanaux en croissance",
|
||||||
|
"tier_professional_price": "99 EUR",
|
||||||
|
"tier_professional_badge": "LE PLUS POPULAIRE",
|
||||||
|
"tier_professional_feat_1": "500 commandes/mois",
|
||||||
|
"tier_professional_feat_2": "Produits illimités",
|
||||||
|
"tier_professional_feat_3": "Factures TVA UE",
|
||||||
|
"tier_professional_feat_4": "Emplacements produits",
|
||||||
|
"tier_professional_feat_5": "Bons de commande",
|
||||||
|
"tier_professional_feat_6": "Export clients",
|
||||||
|
"tier_professional_feat_7": "3 membres d'équipe",
|
||||||
|
"tier_professional_cta": "Essai gratuit",
|
||||||
|
"tier_business": "Business",
|
||||||
|
"tier_business_desc": "Pour les opérations à haut volume",
|
||||||
|
"tier_business_price": "199 EUR",
|
||||||
|
"tier_business_feat_1": "2 000 commandes/mois",
|
||||||
|
"tier_business_feat_2": "Tout dans Professionnel",
|
||||||
|
"tier_business_feat_3": "Tableau de bord analytique",
|
||||||
|
"tier_business_feat_4": "Accès API",
|
||||||
|
"tier_business_feat_5": "Export comptable",
|
||||||
|
"tier_business_feat_6": "10 membres d'équipe",
|
||||||
|
"tier_business_cta": "Essai gratuit",
|
||||||
|
"tier_enterprise": "Entreprise",
|
||||||
|
"tier_enterprise_desc": "Pour les grandes opérations et agences",
|
||||||
|
"tier_enterprise_price": "399+ EUR",
|
||||||
|
"tier_enterprise_feat_1": "Commandes illimitées",
|
||||||
|
"tier_enterprise_feat_2": "Tout dans Business",
|
||||||
|
"tier_enterprise_feat_3": "Option marque blanche",
|
||||||
|
"tier_enterprise_feat_4": "Intégrations personnalisées",
|
||||||
|
"tier_enterprise_feat_5": "SLA 99,9%",
|
||||||
|
"tier_enterprise_feat_6": "Support dédié",
|
||||||
|
"tier_enterprise_cta": "Contacter les ventes",
|
||||||
|
"testimonial_badge": "Conçu pour le Luxembourg",
|
||||||
|
"testimonial_quote": "Enfin, un outil qui comprend ce dont les vendeurs Letzshop ont vraiment besoin. Plus de tableurs, plus de casse-tête TVA.",
|
||||||
|
"testimonial_name": "Marie L.",
|
||||||
|
"testimonial_location": "Boutique Letzshop, Luxembourg-Ville",
|
||||||
|
"cta_final_trial": "Commencez votre essai gratuit de 14 jours"
|
||||||
|
},
|
||||||
|
"minimal": {
|
||||||
|
"page_title_fallback": "Accueil",
|
||||||
|
"marketplace_suffix": "Marketplace",
|
||||||
|
"fallback_title_1": "Marketplace",
|
||||||
|
"fallback_title_2": "Multi-Boutiques",
|
||||||
|
"fallback_subtitle": "Le moyen le plus simple de lancer votre boutique en ligne et de vous connecter avec des clients du monde entier.",
|
||||||
|
"get_started": "Commencer",
|
||||||
|
"feat_fast": "Rapide",
|
||||||
|
"feat_fast_desc": "Performance ultra-rapide optimisée pour les conversions",
|
||||||
|
"feat_secure": "Sécurisé",
|
||||||
|
"feat_secure_desc": "Sécurité de niveau entreprise pour votre tranquillité d'esprit",
|
||||||
|
"feat_custom": "Personnalisable",
|
||||||
|
"feat_custom_desc": "Entièrement personnalisable pour correspondre à votre identité de marque",
|
||||||
|
"cta_title": "Prêt à vous lancer ?",
|
||||||
|
"cta_subtitle": "Rejoignez notre marketplace aujourd'hui",
|
||||||
|
"cta_contact": "Contactez-nous",
|
||||||
|
"cta_learn_more": "En savoir plus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"features": {
|
"features": {
|
||||||
@@ -246,5 +378,25 @@
|
|||||||
"manage_media_desc": "Télécharger, modifier et supprimer les fichiers médias",
|
"manage_media_desc": "Télécharger, modifier et supprimer les fichiers médias",
|
||||||
"manage_themes": "Gérer les thèmes",
|
"manage_themes": "Gérer les thèmes",
|
||||||
"manage_themes_desc": "Configurer et personnaliser les thèmes"
|
"manage_themes_desc": "Configurer et personnaliser les thèmes"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"failed_to_delete_page": "Impossible de supprimer la page : {error}",
|
||||||
|
"media_updated_successfully": "Média mis à jour avec succès",
|
||||||
|
"media_deleted_successfully": "Média supprimé avec succès",
|
||||||
|
"url_copied_to_clipboard": "URL copié dans le presse-papiers",
|
||||||
|
"failed_to_copy_url": "Impossible de copier l'URL"
|
||||||
|
},
|
||||||
|
"confirmations": {
|
||||||
|
"delete_file": "Êtes-vous sûr de vouloir supprimer ce fichier ? Cette action est irréversible."
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"my_account": "Mon Compte",
|
||||||
|
"learn_more": "En savoir plus",
|
||||||
|
"explore": "Découvrir",
|
||||||
|
"quick_links": "Liens rapides",
|
||||||
|
"information": "Informations",
|
||||||
|
"about": "À propos",
|
||||||
|
"contact": "Contact",
|
||||||
|
"faq": "FAQ"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,8 @@
|
|||||||
"signup_without": "Ouni Letzshop registréieren",
|
"signup_without": "Ouni Letzshop registréieren",
|
||||||
"looking_up": "Sich Äre Buttek...",
|
"looking_up": "Sich Äre Buttek...",
|
||||||
"found": "Fonnt:",
|
"found": "Fonnt:",
|
||||||
"claimed_badge": "Scho reklaméiert"
|
"claimed_badge": "Scho reklaméiert",
|
||||||
|
"error_lookup": "D'Sich huet feelgeschloen. Probéiert w.e.g. nach eng Kéier."
|
||||||
},
|
},
|
||||||
"signup": {
|
"signup": {
|
||||||
"step_plan": "Plang wielen",
|
"step_plan": "Plang wielen",
|
||||||
@@ -130,7 +131,18 @@
|
|||||||
"no_charge_note": "Dir gitt eréischt nom Enn vun Ärer {trial_days}-Deeg Testperiod belaaschtt.",
|
"no_charge_note": "Dir gitt eréischt nom Enn vun Ärer {trial_days}-Deeg Testperiod belaaschtt.",
|
||||||
"processing": "Veraarbechtung...",
|
"processing": "Veraarbechtung...",
|
||||||
"start_trial": "Gratis Testversioun starten",
|
"start_trial": "Gratis Testversioun starten",
|
||||||
"creating_account": "Erstellt Äre Kont..."
|
"creating_account": "Erstellt Äre Kont...",
|
||||||
|
"page_title": "Start Är gratis Testversioun",
|
||||||
|
"required_fields": "Obligatoresch Felder",
|
||||||
|
"trial_info_days": "-Deeg gratis Testversioun.",
|
||||||
|
"error_start": "Umeldung konnt net gestart ginn. Probéiert w.e.g. nach eng Kéier.",
|
||||||
|
"error_account": "Kont konnt net erstallt ginn. Probéiert w.e.g. nach eng Kéier.",
|
||||||
|
"error_payment_config": "Bezuelung net konfiguréiert. Kontaktéiert w.e.g. de Support.",
|
||||||
|
"error_payment": "Bezuelung feelgeschloen. Probéiert w.e.g. nach eng Kéier.",
|
||||||
|
"orders_per_month": "Bestellungen/Mount",
|
||||||
|
"unlimited": "Onbegrenzt",
|
||||||
|
"team_members": "Benotzer",
|
||||||
|
"per_month_short": "/Mount"
|
||||||
},
|
},
|
||||||
"success": {
|
"success": {
|
||||||
"title": "Wëllkomm bei Orion!",
|
"title": "Wëllkomm bei Orion!",
|
||||||
@@ -152,6 +164,17 @@
|
|||||||
"subtitle": "Schléisst Iech Letzshop Händler un déi Orion fir hir Bestellungsverwaltung vertrauen. Fänkt haut Är {trial_days}-Deeg gratis Testversioun un.",
|
"subtitle": "Schléisst Iech Letzshop Händler un déi Orion fir hir Bestellungsverwaltung vertrauen. Fänkt haut Är {trial_days}-Deeg gratis Testversioun un.",
|
||||||
"button": "Gratis Testen"
|
"button": "Gratis Testen"
|
||||||
},
|
},
|
||||||
|
"content_page": {
|
||||||
|
"home": "Haaptsäit",
|
||||||
|
"published": "Publizéiert den",
|
||||||
|
"last_updated": "Lescht aktualiséiert:",
|
||||||
|
"cta_about_title": "Prett fir unzefänken?",
|
||||||
|
"cta_contact_title": "Hutt Dir Froen?",
|
||||||
|
"cta_about_subtitle": "Schléisst Iech Dausende vu Butteker un déi scho op eiser Plattform verkafen",
|
||||||
|
"cta_contact_subtitle": "Eist Team ass hei fir Iech ze hëllefen",
|
||||||
|
"cta_about_button": "Kontaktéiert de Verkaf",
|
||||||
|
"cta_contact_button": "Schéckt eis eng Noriicht"
|
||||||
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"tagline": "Liichtt OMS fir Letzshop Verkeefer. Verwaltt Bestellungen, Lager an Rechnungen.",
|
"tagline": "Liichtt OMS fir Letzshop Verkeefer. Verwaltt Bestellungen, Lager an Rechnungen.",
|
||||||
"quick_links": "Séier Linken",
|
"quick_links": "Séier Linken",
|
||||||
@@ -162,7 +185,8 @@
|
|||||||
"terms": "Notzungsbedéngungen",
|
"terms": "Notzungsbedéngungen",
|
||||||
"about": "Iwwer eis",
|
"about": "Iwwer eis",
|
||||||
"faq": "FAQ",
|
"faq": "FAQ",
|
||||||
"contact_us": "Kontaktéiert eis"
|
"contact_us": "Kontaktéiert eis",
|
||||||
|
"all_rights_reserved": "All Rechter virbehalen."
|
||||||
},
|
},
|
||||||
"modern": {
|
"modern": {
|
||||||
"badge_integration": "Offiziell Integratioun",
|
"badge_integration": "Offiziell Integratioun",
|
||||||
@@ -197,7 +221,115 @@
|
|||||||
"features_subtitle": "D'operativ Tools déi Letzshop net bitt",
|
"features_subtitle": "D'operativ Tools déi Letzshop net bitt",
|
||||||
"cta_final_title": "Prett fir d'Kontroll iwwer Äert Letzshop Geschäft ze iwwerhuelen?",
|
"cta_final_title": "Prett fir d'Kontroll iwwer Äert Letzshop Geschäft ze iwwerhuelen?",
|
||||||
"cta_final_subtitle": "Schléisst Iech lëtzebuerger Händler un déi opgehalen hunn géint Tabellen ze kämpfen an ugefaang hunn hiert Geschäft auszbauen.",
|
"cta_final_subtitle": "Schléisst Iech lëtzebuerger Händler un déi opgehalen hunn géint Tabellen ze kämpfen an ugefaang hunn hiert Geschäft auszbauen.",
|
||||||
"cta_final_note": "Keng Kreditkaart néideg. Setup an 5 Minutten. Voll Professional Fonctiounen während der Testperiod."
|
"cta_final_note": "Keng Kreditkaart néideg. Setup an 5 Minutten. Voll Professional Fonctiounen während der Testperiod.",
|
||||||
|
"page_title": "Orion - De Back-Office fir Letzshop Verkeefer",
|
||||||
|
"features_badge": "Fonctiounen",
|
||||||
|
"dashboard_title": "Orion Dashboard",
|
||||||
|
"todays_orders": "Bestellunge vun haut",
|
||||||
|
"revenue": "Ëmsaz",
|
||||||
|
"low_stock": "Niddrege Bestand",
|
||||||
|
"items_need_restock": "Artikelen nei beschtellen",
|
||||||
|
"recent_orders": "Rezent Bestellunge vu Letzshop",
|
||||||
|
"confirmed": "Confirméiert",
|
||||||
|
"shipped": "Verschéckt",
|
||||||
|
"feat_order_sync": "Automatesch Bestellungssynchronisatioun",
|
||||||
|
"feat_order_sync_desc": "Bestellunge vu Letzshop erschéngen direkt. Confirméiert a synchroniséiert Tracking-Nummeren automatesch.",
|
||||||
|
"feat_order_sync_1": "Echtzeit-Synchronisatioun",
|
||||||
|
"feat_order_sync_2": "Confirmatioun mat engem Klick",
|
||||||
|
"feat_order_sync_3": "Tracking-Nummere Synchronisatioun",
|
||||||
|
"feat_inventory": "Richteg Lagerverwaltung",
|
||||||
|
"feat_inventory_desc": "Eng eenzeg Quell vun der Wouerecht fir all Bestänn. Lagerplazen, Reservatiounen an erakommen Bestandsverfolgung.",
|
||||||
|
"feat_inventory_1": "Produktlagerplazen (Fächer)",
|
||||||
|
"feat_inventory_2": "Bestandsreservatiounen",
|
||||||
|
"feat_inventory_3": "Niddreg-Bestand Alarmer",
|
||||||
|
"feat_invoicing": "Intelligent TVA Rechnungsstellung",
|
||||||
|
"feat_invoicing_desc": "PDF Rechnunge mat korrekten TVA Sätz erstellen. Lëtzebuerg, EU-Länner, B2B Reverse-Charge.",
|
||||||
|
"feat_invoicing_1": "Lëtzebuerg 17% TVA",
|
||||||
|
"feat_invoicing_2": "EU Destinatioun TVA (OSS)",
|
||||||
|
"feat_invoicing_3": "B2B Reverse-Charge",
|
||||||
|
"feat_customers": "Besëtzt Är Clienten",
|
||||||
|
"feat_customers_desc": "All Clientsdaten an Ärer Datebank. Export op Mailchimp fir Marketingcampagnen.",
|
||||||
|
"feat_customers_1": "Bestellhistoire pro Client",
|
||||||
|
"feat_customers_2": "Liewen-Zäitwäert Tracking",
|
||||||
|
"feat_customers_3": "CSV Export fir Marketing",
|
||||||
|
"feat_team": "Teamverwaltung",
|
||||||
|
"feat_team_desc": "Invitéiert Teammemberen mat rollbaséierten Berechtigungen. Jiddereen schafft vun engem Dashboard.",
|
||||||
|
"feat_team_1": "Méi Benotzer",
|
||||||
|
"feat_team_2": "Rollbaséierten Zougang",
|
||||||
|
"feat_team_3": "Aktivitéitsprotokoll",
|
||||||
|
"feat_purchase_orders": "Bestellungen",
|
||||||
|
"feat_purchase_orders_desc": "Verfolgt erakommen Bestänn vu Fournisseuren. Wësst wat bestallt ass a wéini et ukënnt.",
|
||||||
|
"feat_purchase_orders_1": "Fournisseur-Bestellunge verfolgen",
|
||||||
|
"feat_purchase_orders_2": "Erwaart Ukonfts-Datumer",
|
||||||
|
"feat_purchase_orders_3": "Empfang an Bestandsaktualiséierung",
|
||||||
|
"pricing_badge": "Präisser",
|
||||||
|
"pricing_title": "Einfach, transparent Präisgestaltung",
|
||||||
|
"pricing_subtitle": "Keng Gebühre pro Bestellung. Keng verstoppte Käschten. Feste Monatspräis.",
|
||||||
|
"pricing_per_month": "/Mount",
|
||||||
|
"pricing_trial_note": "All Pläng enthale eng 14-Deeg gratis Testperiod. Keng Kreditkaart néideg.",
|
||||||
|
"tier_essential": "Essential",
|
||||||
|
"tier_essential_desc": "Fir Solo-Butteker um Ufank",
|
||||||
|
"tier_essential_price": "49 EUR",
|
||||||
|
"tier_essential_feat_1": "100 Bestellungen/Mount",
|
||||||
|
"tier_essential_feat_2": "200 Produkter",
|
||||||
|
"tier_essential_feat_3": "Lëtzebuerg TVA Rechnungen",
|
||||||
|
"tier_essential_feat_4": "1 Teammember",
|
||||||
|
"tier_essential_cta": "Gratis testen",
|
||||||
|
"tier_professional": "Professional",
|
||||||
|
"tier_professional_desc": "Fir wuessend Multichannel-Verkeefer",
|
||||||
|
"tier_professional_price": "99 EUR",
|
||||||
|
"tier_professional_badge": "AM BELÉIFSTEN",
|
||||||
|
"tier_professional_feat_1": "500 Bestellungen/Mount",
|
||||||
|
"tier_professional_feat_2": "Onlimitéiert Produkter",
|
||||||
|
"tier_professional_feat_3": "EU TVA Rechnungen",
|
||||||
|
"tier_professional_feat_4": "Produktlagerplazen",
|
||||||
|
"tier_professional_feat_5": "Bestellungen",
|
||||||
|
"tier_professional_feat_6": "Clienten-Export",
|
||||||
|
"tier_professional_feat_7": "3 Teammemberen",
|
||||||
|
"tier_professional_cta": "Gratis testen",
|
||||||
|
"tier_business": "Business",
|
||||||
|
"tier_business_desc": "Fir grouss Volummen Operatiounen",
|
||||||
|
"tier_business_price": "199 EUR",
|
||||||
|
"tier_business_feat_1": "2.000 Bestellungen/Mount",
|
||||||
|
"tier_business_feat_2": "Alles an Professional",
|
||||||
|
"tier_business_feat_3": "Analyse Dashboard",
|
||||||
|
"tier_business_feat_4": "API Zougang",
|
||||||
|
"tier_business_feat_5": "Comptabilitéitsexport",
|
||||||
|
"tier_business_feat_6": "10 Teammemberen",
|
||||||
|
"tier_business_cta": "Gratis testen",
|
||||||
|
"tier_enterprise": "Enterprise",
|
||||||
|
"tier_enterprise_desc": "Fir grouss Betriber an Agenturen",
|
||||||
|
"tier_enterprise_price": "399+ EUR",
|
||||||
|
"tier_enterprise_feat_1": "Onlimitéiert Bestellungen",
|
||||||
|
"tier_enterprise_feat_2": "Alles an Business",
|
||||||
|
"tier_enterprise_feat_3": "White-Label Optioun",
|
||||||
|
"tier_enterprise_feat_4": "Individuell Integratiounen",
|
||||||
|
"tier_enterprise_feat_5": "99,9% SLA",
|
||||||
|
"tier_enterprise_feat_6": "Dedizéierten Support",
|
||||||
|
"tier_enterprise_cta": "Vertrieb kontaktéieren",
|
||||||
|
"testimonial_badge": "Gemaach fir Lëtzebuerg",
|
||||||
|
"testimonial_quote": "Endlech en Tool dat versteet wat Letzshop Verkeefer wierklech brauchen. Keng Tabelle méi, keng TVA Kappwéi méi.",
|
||||||
|
"testimonial_name": "Marie L.",
|
||||||
|
"testimonial_location": "Letzshop Buttek, Stad Lëtzebuerg",
|
||||||
|
"cta_final_trial": "Start Är 14-Deeg gratis Testperiod"
|
||||||
|
},
|
||||||
|
"minimal": {
|
||||||
|
"page_title_fallback": "Heempage",
|
||||||
|
"marketplace_suffix": "Marktplaz",
|
||||||
|
"fallback_title_1": "Multi-Store",
|
||||||
|
"fallback_title_2": "Marktplaz",
|
||||||
|
"fallback_subtitle": "Deen einfachste Wee fir Ären Online-Shop ze starten an Iech mat Clientë weltwäit ze verbannen.",
|
||||||
|
"get_started": "Ufänken",
|
||||||
|
"feat_fast": "Séier",
|
||||||
|
"feat_fast_desc": "Blëtzséier Leeschtung optimiséiert fir Conversiounen",
|
||||||
|
"feat_secure": "Sécher",
|
||||||
|
"feat_secure_desc": "Enterprise-Niveau Sécherheet fir Är Gemittlechkeet",
|
||||||
|
"feat_custom": "Individuell",
|
||||||
|
"feat_custom_desc": "Komplett personaliséierbar fir zu Ärer Mark ze passen",
|
||||||
|
"cta_title": "Prett fir ze starten?",
|
||||||
|
"cta_subtitle": "Trëtt haut eisem Marktplaz bäi",
|
||||||
|
"cta_contact": "Kontaktéiert eis",
|
||||||
|
"cta_learn_more": "Méi erfahren"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"features": {
|
"features": {
|
||||||
@@ -246,5 +378,25 @@
|
|||||||
"manage_media_desc": "Mediefichieren eroplueden, änneren a läschen",
|
"manage_media_desc": "Mediefichieren eroplueden, änneren a läschen",
|
||||||
"manage_themes": "Themes verwalten",
|
"manage_themes": "Themes verwalten",
|
||||||
"manage_themes_desc": "Buttek-Themes konfiguréieren an upassen"
|
"manage_themes_desc": "Buttek-Themes konfiguréieren an upassen"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"failed_to_delete_page": "Konnt d'Säit net läschen: {error}",
|
||||||
|
"media_updated_successfully": "Medium erfollegräich aktualiséiert",
|
||||||
|
"media_deleted_successfully": "Medium erfollegräich geläscht",
|
||||||
|
"url_copied_to_clipboard": "URL an d'Tëschëlag kopéiert",
|
||||||
|
"failed_to_copy_url": "Konnt den URL net kopéieren"
|
||||||
|
},
|
||||||
|
"confirmations": {
|
||||||
|
"delete_file": "Sidd Dir sécher datt Dir dëse Fichier läsche wëllt? Dat kann net réckgängeg gemaach ginn."
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"my_account": "Mäi Kont",
|
||||||
|
"learn_more": "Méi gewuer ginn",
|
||||||
|
"explore": "Entdecken",
|
||||||
|
"quick_links": "Schnellzougrëff",
|
||||||
|
"information": "Informatiounen",
|
||||||
|
"about": "Iwwer eis",
|
||||||
|
"contact": "Kontakt",
|
||||||
|
"faq": "FAQ"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user