Compare commits
354 Commits
874e254c11
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 56c94ac2f4 | |||
| 255ac6525e | |||
| 10e37e749b | |||
| f23990a4d9 | |||
| 62b83b46a4 | |||
| f8b2429533 | |||
| 3883927be0 | |||
| 39e02f0d9b | |||
| 29593f4c61 | |||
| 220f7e3a08 | |||
| 258aa6a34b | |||
| 51bcc9f874 | |||
| eafa086c73 | |||
| ab2daf99bd | |||
| 1cf9fea40a | |||
| cd4f83f2cb | |||
| 457350908a | |||
| e759282116 | |||
| 1df1b2bfca | |||
| 51a2114e02 | |||
| 21e4ac5124 | |||
| 3ade1b9354 | |||
| b5bb9415f6 | |||
| bb3d6f0012 | |||
| c92fe1261b | |||
| ca152cd544 | |||
| 914967edcc | |||
| 64fe58c171 | |||
| 3044490a3e | |||
| adc36246b8 | |||
| dd9dc04328 | |||
| 4a60d75a13 | |||
| e98eddc168 | |||
| 8cd09f3f89 | |||
| 4c1608f78a | |||
| 24219e4d9a | |||
| fde58bea06 | |||
| 52b78ce346 | |||
| f804ff8442 | |||
| d9abb275a5 | |||
| 4b56eb7ab1 | |||
| 27ac7f3e28 | |||
| dfd42c1b10 | |||
| 297b8a8d5a | |||
| 91fb4b0757 | |||
| f4386e97ee | |||
| e8c9fc7e7d | |||
| d591200df8 | |||
| 83af32eb88 | |||
| 2a49e3d30f | |||
| 6e40e16017 | |||
| dd09bcaeec | |||
| 013eafd775 | |||
| 07cd66a0e3 | |||
| 73d453d78a | |||
| d4e9fed719 | |||
| 3e93f64c6b | |||
| 377d2d3ae8 | |||
| b51f9e8e30 | |||
| d380437594 | |||
| cff0af31be | |||
| e492e5f71c | |||
| 9a5b7dd061 | |||
| b3051b423a | |||
| bc951a36d9 | |||
| 2e043260eb | |||
| 1828ac85eb | |||
| 50a4fc38a7 | |||
| 30f3dae5a3 | |||
| 4c750f0268 | |||
| 59b0d8977a | |||
| 2bc03ed97c | |||
| 91963f3b87 | |||
| 3ae0b579d3 | |||
| 972ee1e5d0 | |||
| 70f2803dd3 | |||
| a247622d23 | |||
| 50d50fcbd0 | |||
| b306a5e8f4 | |||
| 28b08580c8 | |||
| 754bfca87d | |||
| 1decb4572c | |||
| d685341b04 | |||
| 0c6d8409c7 | |||
| f81851445e | |||
| 4748368809 | |||
| f310363f7c | |||
| 95f0eac079 | |||
| 11dcfdad73 | |||
| 01f7add8dd | |||
| 0d1007282a | |||
| 2a15c14ee8 | |||
| bc5e227d81 | |||
| 8a70259445 | |||
| 823935c016 | |||
| dab5560de8 | |||
| 157b4c6ec3 | |||
| 211c46ebbc | |||
| d81e9a3fa4 | |||
| fd0de714a4 | |||
| c6b155520c | |||
| 66b77e747d | |||
| 71b5eb1758 | |||
| b4f01210d9 | |||
| 9bceeaac9c | |||
| 332960de30 | |||
| 0455e63a2e | |||
| aaed1b2d01 | |||
| 9dee534b2f | |||
| beef3ce76b | |||
| 884a694718 | |||
| 4cafbe9610 | |||
| 19923ed26b | |||
| 46f8d227b8 | |||
| 95e4956216 | |||
| 77e520bbce | |||
| 518bace534 | |||
| fcde2d68fc | |||
| 5a33f68743 | |||
| 040cbd1962 | |||
| b679c9687d | |||
| 314360a394 | |||
| 44a0c38016 | |||
| da9e1ab293 | |||
| 5de297a804 | |||
| 4429674100 | |||
| 316ec42566 | |||
| 894832c62b | |||
| 1d90bfe044 | |||
| ce0caa5685 | |||
| 33f823aba0 | |||
| edd55cd2fd | |||
| f3344b2859 | |||
| 1107de989b | |||
| a423bcf03e | |||
| 661547f6cf | |||
| 3015a490f9 | |||
| 5b4ed79f87 | |||
| 52a5f941fe | |||
| 6161d69ba2 | |||
| f41f72b86f | |||
| 644bf158cd | |||
| f89c0382f0 | |||
| 11b8e31a29 | |||
| 0ddef13124 | |||
| 60bed05d3f | |||
| 40da2d6b11 | |||
| d96e0ea1b4 | |||
| 7d652716bb | |||
| b6047f5b7d | |||
| 366d4b9765 | |||
| 540205402f | |||
| 07fab01f6a | |||
| 6c07f6cbb2 | |||
| bc7431943a | |||
| adec17cd02 | |||
| a28d5d1de5 | |||
| 502473eee4 | |||
| 183f55c7b3 | |||
| 169a774b9c | |||
| ebbe6d62b8 | |||
| c2c0e3c740 | |||
| 4a1f71a312 | |||
| 5dd5e01dc6 | |||
| 694a1cd1a5 | |||
| 826ef2ddd2 | |||
| a1cc05cd3d | |||
| 19d267587b | |||
| 9a13aee8ed | |||
| 9c39a9703f | |||
| 395707951e | |||
| 34bf961309 | |||
| 44acf5e442 | |||
| b3224ba13d | |||
| 93b7279c3a | |||
| 29d942322d | |||
| 8c8975239a | |||
| f766a72480 | |||
| 618376aa39 | |||
| efca9734d2 | |||
| 6acd783754 | |||
| 8cf5da6914 | |||
| eee33d6a1b | |||
| aefca3115e | |||
| 319900623a | |||
| a77a8a3a98 | |||
| f141cc4e6a | |||
| 2287f4597d | |||
| 8136739233 | |||
| 2ca313c3c7 | |||
| 27802e47c2 | |||
| 14d5ff97f3 | |||
| b9b8ffadcb | |||
| 31ced5f759 | |||
| 802cc6b137 | |||
| 45260b6b82 | |||
| fa758b7e31 | |||
| a099bfdc48 | |||
| cb9a829684 | |||
| c4e9e4e646 | |||
| 8c449d7baa | |||
| 820ab1aaa4 | |||
| 2268f32f51 | |||
| b68d542258 | |||
| a7392de9f6 | |||
| 3c7e4458af | |||
| 8b147f53c6 | |||
| 784bcb9d23 | |||
| b8aa484653 | |||
| 05c53e1865 | |||
| 6dec1e3ca6 | |||
| f631283286 | |||
| f631322b4e | |||
| e61e02fb39 | |||
| b5b73559b5 | |||
| 28dca65a06 | |||
| adbecd360b | |||
| ef9ea29643 | |||
| f8a2394da5 | |||
| 4d07418f44 | |||
| bf64f82613 | |||
| 9684747d08 | |||
| 2078ce35b2 | |||
| 22ae63b414 | |||
| 78ee05f50e | |||
| 6d6eba75bf | |||
| a709adaee8 | |||
| 8d5c8a52e6 | |||
| d8f0cf16c7 | |||
| 93a2d9baff | |||
| 35d1559162 | |||
| ce822af883 | |||
| 4ebd419987 | |||
| 2b29867093 | |||
| 30c4593e0f | |||
| 8c0967e215 | |||
| 86e85a98b8 | |||
| e3a52f6536 | |||
| 4aa6f76e46 | |||
| f95db7c0b1 | |||
| 2b55e7458b | |||
| c82210795f | |||
| cb3bc3c118 | |||
| 962862ccc1 | |||
| 3053bc5d92 | |||
| 79a88b0a36 | |||
| e7f8e61717 | |||
| d480b59df4 | |||
| ce5b54f27b | |||
| 6a82d7c12d | |||
| f1e7baaa6c | |||
| 6b46a78e72 | |||
| d648c921b7 | |||
| 3df75e2e78 | |||
| 92a434530f | |||
| 01146d5c97 | |||
| d0d5aadaf7 | |||
| 56afb9192b | |||
| a4519035df | |||
| c9b2ecbdff | |||
| 1194731f33 | |||
| 12c1c3c511 | |||
| 81cf84ed28 | |||
| a6e6d9be8e | |||
| ec888f2e94 | |||
| 53dfe018c2 | |||
| 3de69e55a1 | |||
| cfce6c0ca4 | |||
| 2833ff1476 | |||
| f47c680cb8 | |||
| 32e4aa6564 | |||
| 6c78827c7f | |||
| 0389294b1a | |||
| cd935988c4 | |||
| 05d31a7fc5 | |||
| 272b62fbd3 | |||
| 32acc76b49 | |||
| d36783a7f1 | |||
| 2fc157d7b2 | |||
| 506171503d | |||
| be248222bc | |||
| 716a4e3d15 | |||
| 467b1510f4 | |||
| 5c8fbd21c7 | |||
| 1f3042547b | |||
| d7a383f3d7 | |||
| b77952bf89 | |||
| ff852f1ab3 | |||
| 42b894094a | |||
| b9ac252a9f | |||
| 51e512ec08 | |||
| f517a7ccd7 | |||
| c47a394a7b | |||
| 1eef69f300 | |||
| 1dcb0e6c33 | |||
| ef21d47533 | |||
| 6c5969e4e1 | |||
| 6a739bf670 | |||
| ffa12f0255 | |||
| 93731b7173 | |||
| e5dbd7ef1a | |||
| 167bb50f4f | |||
| 182610283d | |||
| e23788cb7d | |||
| 573b0180ad | |||
| d9fc52d47a | |||
| a8b29750a5 | |||
| 2c710ad416 | |||
| 682213fdee | |||
| 3d1586f025 | |||
| 64082ca877 | |||
| 67260e9322 | |||
| 44568893fd | |||
| 10fdf91dfa | |||
| 8ee8c398ce | |||
| 3a7cf29386 | |||
| eaab47f2f8 | |||
| 6458ab13d7 | |||
| 0b701fb847 | |||
| f67510b706 | |||
| 8c715cfde3 | |||
| 4bce16fb73 | |||
| 1cb659e3a5 | |||
| 3ec58c1524 | |||
| b382090771 | |||
| 5474fc5301 | |||
| cd596b85b3 | |||
| eedc463207 | |||
| 677e5211f9 | |||
| 10aa75aa69 | |||
| aad18c27ab | |||
| b0db8133a0 | |||
| 1b8a40f1ff | |||
| f84c5d903e | |||
| ef7187b508 | |||
| 488d5a6f0e | |||
| 3c2b559282 | |||
| 62e418c473 | |||
| 688896d856 | |||
| cf08e1a6c8 | |||
| ba130d4171 | |||
| e9253fbd84 | |||
| 34ee7bb7ad | |||
| 481deaa67d | |||
| 11f1909f68 | |||
| 9154eec871 | |||
| b0a40200c1 | |||
| 8dcc4145aa | |||
| 77b76afb3f | |||
| 8968e7d9cd | |||
| 531487f5c9 | |||
| 9c27fa02b0 | |||
| 7c43d6f4a2 | |||
| 9173448645 |
@@ -145,7 +145,7 @@ api_endpoint_rules:
|
|||||||
- Dependencies (app/api/deps.py) - authentication/authorization validation
|
- Dependencies (app/api/deps.py) - authentication/authorization validation
|
||||||
- Services (app/services/) - business logic validation
|
- Services (app/services/) - business logic validation
|
||||||
|
|
||||||
The global exception handler catches all WizamartException subclasses and
|
The global exception handler catches all OrionException subclasses and
|
||||||
converts them to appropriate HTTP responses.
|
converts them to appropriate HTTP responses.
|
||||||
|
|
||||||
WRONG (endpoint raises exception):
|
WRONG (endpoint raises exception):
|
||||||
@@ -192,7 +192,9 @@ api_endpoint_rules:
|
|||||||
def stripe_webhook(request: Request):
|
def stripe_webhook(request: Request):
|
||||||
...
|
...
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "app/api/v1/**/*.py"
|
file_pattern:
|
||||||
|
- "app/api/v1/**/*.py"
|
||||||
|
- "app/modules/*/routes/api/**/*.py"
|
||||||
required_if_not_public:
|
required_if_not_public:
|
||||||
- "Depends(get_current_"
|
- "Depends(get_current_"
|
||||||
auto_exclude_files:
|
auto_exclude_files:
|
||||||
@@ -205,11 +207,14 @@ api_endpoint_rules:
|
|||||||
name: "Multi-tenant endpoints must scope queries to vendor_id"
|
name: "Multi-tenant endpoints must scope queries to vendor_id"
|
||||||
severity: "error"
|
severity: "error"
|
||||||
description: |
|
description: |
|
||||||
All queries in vendor/shop contexts must filter by vendor_id.
|
All queries in vendor/storefront contexts must filter by vendor_id.
|
||||||
Use request.state.vendor_id from middleware.
|
Use request.state.vendor_id from middleware.
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "app/api/v1/vendor/**/*.py"
|
file_pattern:
|
||||||
file_pattern: "app/api/v1/storefront/**/*.py"
|
- "app/api/v1/vendor/**/*.py"
|
||||||
|
- "app/modules/*/routes/api/store*.py"
|
||||||
|
- "app/api/v1/storefront/**/*.py"
|
||||||
|
- "app/modules/*/routes/api/storefront*.py"
|
||||||
discouraged_patterns:
|
discouraged_patterns:
|
||||||
- "db.query(.*).all()"
|
- "db.query(.*).all()"
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ auth_rules:
|
|||||||
description: |
|
description: |
|
||||||
Authentication must use JWT tokens in Authorization: Bearer header
|
Authentication must use JWT tokens in Authorization: Bearer header
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "app/api/**/*.py"
|
file_pattern:
|
||||||
|
- "app/api/**/*.py"
|
||||||
|
- "app/modules/*/routes/api/**/*.py"
|
||||||
enforcement: "middleware"
|
enforcement: "middleware"
|
||||||
|
|
||||||
- id: "AUTH-002"
|
- id: "AUTH-002"
|
||||||
@@ -18,7 +20,9 @@ auth_rules:
|
|||||||
description: |
|
description: |
|
||||||
Use Depends(get_current_admin/vendor/customer) for role checks
|
Use Depends(get_current_admin/vendor/customer) for role checks
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "app/api/v1/**/*.py"
|
file_pattern:
|
||||||
|
- "app/api/v1/**/*.py"
|
||||||
|
- "app/modules/*/routes/api/**/*.py"
|
||||||
required: "Depends\\(get_current_"
|
required: "Depends\\(get_current_"
|
||||||
|
|
||||||
- id: "AUTH-003"
|
- id: "AUTH-003"
|
||||||
@@ -36,10 +40,10 @@ auth_rules:
|
|||||||
description: |
|
description: |
|
||||||
Two vendor context patterns exist - use the appropriate one:
|
Two vendor context patterns exist - use the appropriate one:
|
||||||
|
|
||||||
1. SHOP ENDPOINTS (public, no authentication required):
|
1. STOREFRONT ENDPOINTS (public, no authentication required):
|
||||||
- Use: vendor: Vendor = Depends(require_vendor_context())
|
- Use: vendor: Vendor = Depends(require_vendor_context())
|
||||||
- Vendor is detected from URL/subdomain/domain
|
- Vendor is detected from URL/subdomain/domain
|
||||||
- File pattern: app/api/v1/storefront/**/*.py
|
- File pattern: app/api/v1/storefront/**/*.py, app/modules/*/routes/api/storefront*.py
|
||||||
- Mark as public with: # public
|
- Mark as public with: # public
|
||||||
|
|
||||||
2. VENDOR API ENDPOINTS (authenticated):
|
2. VENDOR API ENDPOINTS (authenticated):
|
||||||
@@ -49,15 +53,19 @@ auth_rules:
|
|||||||
- File pattern: app/api/v1/vendor/**/*.py
|
- File pattern: app/api/v1/vendor/**/*.py
|
||||||
|
|
||||||
DEPRECATED for vendor APIs:
|
DEPRECATED for vendor APIs:
|
||||||
- require_vendor_context() - only for shop endpoints
|
- require_vendor_context() - only for storefront endpoints
|
||||||
- getattr(request.state, "vendor", None) without permission dependency
|
- getattr(request.state, "vendor", None) without permission dependency
|
||||||
|
|
||||||
See: docs/backend/vendor-in-token-architecture.md
|
See: docs/backend/vendor-in-token-architecture.md
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "app/api/v1/vendor/**/*.py"
|
file_pattern:
|
||||||
|
- "app/api/v1/vendor/**/*.py"
|
||||||
|
- "app/modules/*/routes/api/store*.py"
|
||||||
anti_patterns:
|
anti_patterns:
|
||||||
- "require_vendor_context\\(\\)"
|
- "require_vendor_context\\(\\)"
|
||||||
file_pattern: "app/api/v1/storefront/**/*.py"
|
file_pattern:
|
||||||
|
- "app/api/v1/storefront/**/*.py"
|
||||||
|
- "app/modules/*/routes/api/storefront*.py"
|
||||||
required_patterns:
|
required_patterns:
|
||||||
- "require_vendor_context\\(\\)|# public"
|
- "require_vendor_context\\(\\)|# public"
|
||||||
|
|
||||||
@@ -149,7 +157,9 @@ multi_tenancy_rules:
|
|||||||
description: |
|
description: |
|
||||||
In vendor/shop contexts, all database queries must filter by vendor_id
|
In vendor/shop contexts, all database queries must filter by vendor_id
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "app/services/**/*.py"
|
file_pattern:
|
||||||
|
- "app/services/**/*.py"
|
||||||
|
- "app/modules/*/services/**/*.py"
|
||||||
context: "vendor_shop"
|
context: "vendor_shop"
|
||||||
required_pattern: ".filter\\(.*vendor_id.*\\)"
|
required_pattern: ".filter\\(.*vendor_id.*\\)"
|
||||||
|
|
||||||
@@ -159,5 +169,7 @@ multi_tenancy_rules:
|
|||||||
description: |
|
description: |
|
||||||
Queries must never access data from other vendors
|
Queries must never access data from other vendors
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "app/services/**/*.py"
|
file_pattern:
|
||||||
|
- "app/services/**/*.py"
|
||||||
|
- "app/modules/*/services/**/*.py"
|
||||||
enforcement: "database_query_level"
|
enforcement: "database_query_level"
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ exception_rules:
|
|||||||
Create domain-specific exceptions in app/exceptions/ for better
|
Create domain-specific exceptions in app/exceptions/ for better
|
||||||
error handling and clarity.
|
error handling and clarity.
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "app/exceptions/**/*.py"
|
file_pattern:
|
||||||
|
- "app/exceptions/**/*.py"
|
||||||
|
- "app/modules/*/exceptions.py"
|
||||||
encouraged_structure: |
|
encouraged_structure: |
|
||||||
class VendorError(Exception):
|
class VendorError(Exception):
|
||||||
"""Base exception for vendor-related errors"""
|
"""Base exception for vendor-related errors"""
|
||||||
@@ -34,21 +36,25 @@ exception_rules:
|
|||||||
description: |
|
description: |
|
||||||
When catching exceptions, log them with context and stack trace.
|
When catching exceptions, log them with context and stack trace.
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "app/services/**/*.py"
|
file_pattern:
|
||||||
|
- "app/services/**/*.py"
|
||||||
|
- "app/modules/*/services/**/*.py"
|
||||||
encouraged_patterns:
|
encouraged_patterns:
|
||||||
- "logger.error"
|
- "logger.error"
|
||||||
- "exc_info=True"
|
- "exc_info=True"
|
||||||
|
|
||||||
- id: "EXC-004"
|
- id: "EXC-004"
|
||||||
name: "Domain exceptions must inherit from WizamartException"
|
name: "Domain exceptions must inherit from OrionException"
|
||||||
severity: "error"
|
severity: "error"
|
||||||
description: |
|
description: |
|
||||||
All custom domain exceptions must inherit from WizamartException (or its
|
All custom domain exceptions must inherit from OrionException (or its
|
||||||
subclasses like ResourceNotFoundException, ValidationException, etc.).
|
subclasses like ResourceNotFoundException, ValidationException, etc.).
|
||||||
This ensures the global exception handler catches and converts them properly.
|
This ensures the global exception handler catches and converts them properly.
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "app/exceptions/**/*.py"
|
file_pattern:
|
||||||
required_base_class: "WizamartException"
|
- "app/exceptions/**/*.py"
|
||||||
|
- "app/modules/*/exceptions.py"
|
||||||
|
required_base_class: "OrionException"
|
||||||
example_good: |
|
example_good: |
|
||||||
class VendorNotFoundException(ResourceNotFoundException):
|
class VendorNotFoundException(ResourceNotFoundException):
|
||||||
def __init__(self, vendor_code: str):
|
def __init__(self, vendor_code: str):
|
||||||
@@ -59,7 +65,7 @@ exception_rules:
|
|||||||
severity: "error"
|
severity: "error"
|
||||||
description: |
|
description: |
|
||||||
The global exception handler must be set up in app initialization to
|
The global exception handler must be set up in app initialization to
|
||||||
catch WizamartException and convert to HTTP responses.
|
catch OrionException and convert to HTTP responses.
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "app/main.py"
|
file_pattern: "app/main.py"
|
||||||
required_patterns:
|
required_patterns:
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ javascript_rules:
|
|||||||
- Page URLs (not API calls) like window.location.href = `/vendor/${vendorCode}/...`
|
- Page URLs (not API calls) like window.location.href = `/vendor/${vendorCode}/...`
|
||||||
|
|
||||||
Why this matters:
|
Why this matters:
|
||||||
- Including vendorCode causes 404 errors ("/vendor/wizamart/orders" not found)
|
- Including vendorCode causes 404 errors ("/vendor/orion/orders" not found)
|
||||||
- The JWT token already identifies the vendor
|
- The JWT token already identifies the vendor
|
||||||
- Consistent with the API design pattern
|
- Consistent with the API design pattern
|
||||||
pattern:
|
pattern:
|
||||||
@@ -238,6 +238,50 @@ javascript_rules:
|
|||||||
exceptions:
|
exceptions:
|
||||||
- "utils.js"
|
- "utils.js"
|
||||||
|
|
||||||
|
- id: "JS-015"
|
||||||
|
name: "Use confirm_modal macros, not native confirm()"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
All confirmation dialogs must use the project's confirm_modal or
|
||||||
|
confirm_modal_dynamic Jinja2 macros from shared/macros/modals.html.
|
||||||
|
Never use the native browser confirm() dialog.
|
||||||
|
|
||||||
|
The modal macros provide:
|
||||||
|
- Consistent styled dialogs matching the admin/store theme
|
||||||
|
- Dark mode support
|
||||||
|
- Variant colors (danger=red, warning=yellow, info=blue)
|
||||||
|
- Icon support
|
||||||
|
- Double-confirm pattern for destructive operations
|
||||||
|
|
||||||
|
WRONG (native browser dialog):
|
||||||
|
if (!confirm('Are you sure you want to delete this?')) return;
|
||||||
|
if (!confirm(I18n.t('confirmations.delete'))) return;
|
||||||
|
|
||||||
|
RIGHT (state variable + modal macro):
|
||||||
|
// In JS: add state variable and remove confirm() guard
|
||||||
|
showDeleteModal: false,
|
||||||
|
async deleteItem() {
|
||||||
|
// No confirm() guard — modal already confirmed
|
||||||
|
await apiClient.delete('/admin/items/' + this.item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In template: button sets state, macro shows modal
|
||||||
|
<button @click="showDeleteModal = true">Delete</button>
|
||||||
|
{{ confirm_modal('deleteModal', 'Delete Item', 'Are you sure?',
|
||||||
|
'deleteItem()', 'showDeleteModal', 'Delete', 'Cancel', 'danger') }}
|
||||||
|
|
||||||
|
For dynamic messages (containing JS expressions):
|
||||||
|
{{ confirm_modal_dynamic('deleteModal', 'Delete Item',
|
||||||
|
"'Delete ' + item.name + '?'",
|
||||||
|
'deleteItem()', 'showDeleteModal', 'Delete', 'Cancel', 'danger') }}
|
||||||
|
pattern:
|
||||||
|
file_pattern: "static/**/js/**/*.js"
|
||||||
|
anti_patterns:
|
||||||
|
- "confirm\\("
|
||||||
|
exceptions:
|
||||||
|
- "utils.js"
|
||||||
|
- "vendor/"
|
||||||
|
|
||||||
- id: "JS-010"
|
- id: "JS-010"
|
||||||
name: "Use PlatformSettings for pagination rows per page"
|
name: "Use PlatformSettings for pagination rows per page"
|
||||||
severity: "error"
|
severity: "error"
|
||||||
|
|||||||
@@ -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("
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Architecture Rules - Model Rules
|
# Architecture Rules - Model Rules
|
||||||
# Rules for models/database/*.py and models/schema/*.py files
|
# Rules for models/database/*.py, models/schema/*.py, app/modules/*/models/**/*.py, and app/modules/*/schemas/**/*.py files
|
||||||
|
|
||||||
model_rules:
|
model_rules:
|
||||||
|
|
||||||
@@ -10,7 +10,9 @@ model_rules:
|
|||||||
All database models must inherit from SQLAlchemy Base and use proper
|
All database models must inherit from SQLAlchemy Base and use proper
|
||||||
column definitions with types and constraints.
|
column definitions with types and constraints.
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "models/database/**/*.py"
|
file_pattern:
|
||||||
|
- "models/database/**/*.py"
|
||||||
|
- "app/modules/*/models/**/*.py"
|
||||||
required_patterns:
|
required_patterns:
|
||||||
- "class.*\\(Base\\):"
|
- "class.*\\(Base\\):"
|
||||||
|
|
||||||
@@ -21,7 +23,10 @@ model_rules:
|
|||||||
Never mix SQLAlchemy and Pydantic in the same model.
|
Never mix SQLAlchemy and Pydantic in the same model.
|
||||||
SQLAlchemy = database schema, Pydantic = API validation/serialization.
|
SQLAlchemy = database schema, Pydantic = API validation/serialization.
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "models/**/*.py"
|
file_pattern:
|
||||||
|
- "models/**/*.py"
|
||||||
|
- "app/modules/*/models/**/*.py"
|
||||||
|
- "app/modules/*/schemas/**/*.py"
|
||||||
anti_patterns:
|
anti_patterns:
|
||||||
- "class.*\\(Base, BaseModel\\):"
|
- "class.*\\(Base, BaseModel\\):"
|
||||||
|
|
||||||
@@ -31,7 +36,9 @@ model_rules:
|
|||||||
description: |
|
description: |
|
||||||
Pydantic response models must enable from_attributes to work with SQLAlchemy models.
|
Pydantic response models must enable from_attributes to work with SQLAlchemy models.
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "models/schema/**/*.py"
|
file_pattern:
|
||||||
|
- "models/schema/**/*.py"
|
||||||
|
- "app/modules/*/schemas/**/*.py"
|
||||||
required_in_response_models:
|
required_in_response_models:
|
||||||
- "from_attributes = True"
|
- "from_attributes = True"
|
||||||
|
|
||||||
@@ -51,5 +58,7 @@ model_rules:
|
|||||||
Junction/join tables use both entity names in plural:
|
Junction/join tables use both entity names in plural:
|
||||||
- Good: vendor_users, order_items, product_translations
|
- Good: vendor_users, order_items, product_translations
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "models/database/**/*.py"
|
file_pattern:
|
||||||
|
- "models/database/**/*.py"
|
||||||
|
- "app/modules/*/models/**/*.py"
|
||||||
check: "table_naming_plural"
|
check: "table_naming_plural"
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -154,16 +154,16 @@ module_rules:
|
|||||||
severity: "warning"
|
severity: "warning"
|
||||||
description: |
|
description: |
|
||||||
Self-contained modules should have an exceptions.py file defining
|
Self-contained modules should have an exceptions.py file defining
|
||||||
module-specific exceptions that inherit from WizamartException.
|
module-specific exceptions that inherit from OrionException.
|
||||||
|
|
||||||
Structure:
|
Structure:
|
||||||
app/modules/{module}/exceptions.py
|
app/modules/{module}/exceptions.py
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
# app/modules/analytics/exceptions.py
|
# app/modules/analytics/exceptions.py
|
||||||
from app.exceptions import WizamartException
|
from app.exceptions import OrionException
|
||||||
|
|
||||||
class AnalyticsException(WizamartException):
|
class AnalyticsException(OrionException):
|
||||||
"""Base exception for analytics module."""
|
"""Base exception for analytics module."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -269,14 +269,14 @@ module_rules:
|
|||||||
Module locales/ directory should have translation files for
|
Module locales/ directory should have translation files for
|
||||||
all supported languages to ensure consistent i18n.
|
all supported languages to ensure consistent i18n.
|
||||||
|
|
||||||
Supported languages: en, de, fr, lu
|
Supported languages: en, de, fr, lb
|
||||||
|
|
||||||
Structure:
|
Structure:
|
||||||
app/modules/<code>/locales/
|
app/modules/<code>/locales/
|
||||||
├── en.json
|
├── en.json
|
||||||
├── de.json
|
├── de.json
|
||||||
├── fr.json
|
├── fr.json
|
||||||
└── lu.json
|
└── lb.json
|
||||||
|
|
||||||
Missing translations will fall back to English, but it's
|
Missing translations will fall back to English, but it's
|
||||||
better to have all languages covered.
|
better to have all languages covered.
|
||||||
@@ -286,7 +286,7 @@ module_rules:
|
|||||||
- "en.json"
|
- "en.json"
|
||||||
- "de.json"
|
- "de.json"
|
||||||
- "fr.json"
|
- "fr.json"
|
||||||
- "lu.json"
|
- "lb.json"
|
||||||
|
|
||||||
- id: "MOD-007"
|
- id: "MOD-007"
|
||||||
name: "Module definition must match directory structure"
|
name: "Module definition must match directory structure"
|
||||||
@@ -692,8 +692,9 @@ module_rules:
|
|||||||
name: "Modules with routers should use get_*_with_routers pattern"
|
name: "Modules with routers should use get_*_with_routers pattern"
|
||||||
severity: "info"
|
severity: "info"
|
||||||
description: |
|
description: |
|
||||||
Modules that define routers (admin_router, vendor_router, etc.)
|
Modules that define routers should follow the lazy import pattern
|
||||||
should follow the lazy import pattern with a dedicated function:
|
with a dedicated function. Route files use `router` as the variable
|
||||||
|
name; consumer code distinguishes via `admin_router`/`store_router`.
|
||||||
|
|
||||||
def get_{module}_module_with_routers() -> ModuleDefinition:
|
def get_{module}_module_with_routers() -> ModuleDefinition:
|
||||||
|
|
||||||
@@ -704,12 +705,12 @@ module_rules:
|
|||||||
|
|
||||||
WRONG:
|
WRONG:
|
||||||
# Direct router assignment at module level
|
# Direct router assignment at module level
|
||||||
module.admin_router = admin_router
|
module.admin_router = router
|
||||||
|
|
||||||
RIGHT:
|
RIGHT:
|
||||||
def _get_admin_router():
|
def _get_admin_router():
|
||||||
from app.modules.orders.routes.admin import admin_router
|
from app.modules.orders.routes.api.admin import router
|
||||||
return admin_router
|
return router
|
||||||
|
|
||||||
def get_orders_module_with_routers() -> ModuleDefinition:
|
def get_orders_module_with_routers() -> ModuleDefinition:
|
||||||
orders_module.admin_router = _get_admin_router()
|
orders_module.admin_router = _get_admin_router()
|
||||||
@@ -761,3 +762,96 @@ module_rules:
|
|||||||
file_pattern: "main.py"
|
file_pattern: "main.py"
|
||||||
validates:
|
validates:
|
||||||
- "module_locales mount BEFORE module_static mount"
|
- "module_locales mount BEFORE module_static mount"
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Cross-Module Boundary Rules
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
- id: "MOD-025"
|
||||||
|
name: "Modules must NOT import models from other modules"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
Modules must access data from other modules through their SERVICE layer,
|
||||||
|
never by importing and querying their models directly.
|
||||||
|
|
||||||
|
This is the "services over models" principle: if module A needs data
|
||||||
|
from module B, it MUST call module B's service methods.
|
||||||
|
|
||||||
|
WRONG (direct model import):
|
||||||
|
# app/modules/orders/services/order_service.py
|
||||||
|
from app.modules.catalog.models import Product # FORBIDDEN
|
||||||
|
|
||||||
|
class OrderService:
|
||||||
|
def get_order_details(self, db, order_id):
|
||||||
|
product = db.query(Product).filter_by(id=pid).first()
|
||||||
|
|
||||||
|
RIGHT (service call):
|
||||||
|
# app/modules/orders/services/order_service.py
|
||||||
|
from app.modules.catalog.services import product_service
|
||||||
|
|
||||||
|
class OrderService:
|
||||||
|
def get_order_details(self, db, order_id):
|
||||||
|
product = product_service.get_product_by_id(db, pid)
|
||||||
|
|
||||||
|
ALSO RIGHT (provider protocol for core→optional):
|
||||||
|
# app/modules/core/services/stats_aggregator.py
|
||||||
|
from app.modules.contracts.metrics import MetricsProviderProtocol
|
||||||
|
# Discover providers through registry, no direct imports
|
||||||
|
|
||||||
|
EXCEPTIONS:
|
||||||
|
- Test fixtures may create models from other modules for setup
|
||||||
|
- TYPE_CHECKING imports for type hints are allowed
|
||||||
|
- Tenancy models (User, Store, Merchant, Platform) may be imported
|
||||||
|
as type hints in route signatures where FastAPI requires it,
|
||||||
|
but queries must go through tenancy services
|
||||||
|
|
||||||
|
WHY THIS MATTERS:
|
||||||
|
- Encapsulation: Modules own their data access patterns
|
||||||
|
- Refactoring: Module B can change its schema without breaking A
|
||||||
|
- Testability: Mock services, not database queries
|
||||||
|
- Consistency: Clear API boundaries between modules
|
||||||
|
- Decoupling: Modules can evolve independently
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/modules/*/services/**/*.py"
|
||||||
|
anti_patterns:
|
||||||
|
- "from app\\.modules\\.(?!<own_module>)\\.models import"
|
||||||
|
exceptions:
|
||||||
|
- "TYPE_CHECKING"
|
||||||
|
- "tests/"
|
||||||
|
|
||||||
|
- id: "MOD-026"
|
||||||
|
name: "Cross-module data access must use service methods"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
When a module needs data from another module, it must use that
|
||||||
|
module's public service API. Each module should expose service
|
||||||
|
methods for common data access patterns.
|
||||||
|
|
||||||
|
Service methods a module should expose:
|
||||||
|
- get_{entity}_by_id(db, id) -> Entity or None
|
||||||
|
- list_{entities}(db, filters) -> list[Entity]
|
||||||
|
- get_{entity}_count(db, filters) -> int
|
||||||
|
- search_{entities}(db, query, filters) -> list[Entity]
|
||||||
|
|
||||||
|
WRONG (direct query across module boundary):
|
||||||
|
# In orders module
|
||||||
|
count = db.query(func.count(Product.id)).scalar()
|
||||||
|
|
||||||
|
RIGHT (call catalog service):
|
||||||
|
# In orders module
|
||||||
|
count = product_service.get_product_count(db, store_id=store_id)
|
||||||
|
|
||||||
|
This applies to:
|
||||||
|
- Simple lookups (get by ID)
|
||||||
|
- List/search queries
|
||||||
|
- Aggregation queries (count, sum)
|
||||||
|
- Join queries (should be decomposed into service calls)
|
||||||
|
|
||||||
|
WHY THIS MATTERS:
|
||||||
|
- Single source of truth for data access logic
|
||||||
|
- Easier to add caching, validation, or access control
|
||||||
|
- Clear contract between modules
|
||||||
|
- Simpler testing with service mocks
|
||||||
|
pattern:
|
||||||
|
file_pattern: "app/modules/*/services/**/*.py"
|
||||||
|
check: "cross_module_service_usage"
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ money_handling_rules:
|
|||||||
|
|
||||||
Column naming convention: Use `_cents` suffix for all monetary columns.
|
Column naming convention: Use `_cents` suffix for all monetary columns.
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "models/database/**/*.py"
|
file_pattern:
|
||||||
|
- "models/database/**/*.py"
|
||||||
|
- "app/modules/*/models/**/*.py"
|
||||||
required_patterns:
|
required_patterns:
|
||||||
- "_cents = Column(Integer"
|
- "_cents = Column(Integer"
|
||||||
anti_patterns:
|
anti_patterns:
|
||||||
@@ -79,7 +81,9 @@ money_handling_rules:
|
|||||||
|
|
||||||
Or use model validators to convert before response serialization.
|
Or use model validators to convert before response serialization.
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "models/schema/**/*.py"
|
file_pattern:
|
||||||
|
- "models/schema/**/*.py"
|
||||||
|
- "app/modules/*/schemas/**/*.py"
|
||||||
check: "money_response_format"
|
check: "money_response_format"
|
||||||
|
|
||||||
- id: "MON-004"
|
- id: "MON-004"
|
||||||
@@ -124,7 +128,9 @@ money_handling_rules:
|
|||||||
tax = subtotal * 0.17 # Floating point!
|
tax = subtotal * 0.17 # Floating point!
|
||||||
total = subtotal + tax
|
total = subtotal + tax
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "app/services/**/*.py"
|
file_pattern:
|
||||||
|
- "app/services/**/*.py"
|
||||||
|
- "app/modules/*/services/**/*.py"
|
||||||
check: "money_arithmetic"
|
check: "money_arithmetic"
|
||||||
|
|
||||||
- id: "MON-006"
|
- id: "MON-006"
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ naming_rules:
|
|||||||
- "__init__.py"
|
- "__init__.py"
|
||||||
- "auth.py"
|
- "auth.py"
|
||||||
- "health.py"
|
- "health.py"
|
||||||
|
- "store.py"
|
||||||
|
- "admin.py"
|
||||||
|
- "platform.py"
|
||||||
|
- "storefront.py"
|
||||||
|
|
||||||
- id: "NAM-002"
|
- id: "NAM-002"
|
||||||
name: "Service files use SINGULAR + 'service' suffix"
|
name: "Service files use SINGULAR + 'service' suffix"
|
||||||
@@ -22,8 +26,17 @@ naming_rules:
|
|||||||
description: |
|
description: |
|
||||||
Service files should use singular name + _service (vendor_service.py)
|
Service files should use singular name + _service (vendor_service.py)
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "app/services/**/*.py"
|
file_pattern:
|
||||||
|
- "app/services/**/*.py"
|
||||||
|
- "app/modules/*/services/**/*.py"
|
||||||
check: "service_naming"
|
check: "service_naming"
|
||||||
|
exceptions:
|
||||||
|
- "*_features.py"
|
||||||
|
- "*_metrics.py"
|
||||||
|
- "*_widgets.py"
|
||||||
|
- "*_aggregator.py"
|
||||||
|
- "*_provider.py"
|
||||||
|
- "*_presets.py"
|
||||||
|
|
||||||
- id: "NAM-003"
|
- id: "NAM-003"
|
||||||
name: "Model files use SINGULAR names"
|
name: "Model files use SINGULAR names"
|
||||||
@@ -31,14 +44,16 @@ naming_rules:
|
|||||||
description: |
|
description: |
|
||||||
Both database and schema model files use singular names (product.py)
|
Both database and schema model files use singular names (product.py)
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "models/**/*.py"
|
file_pattern:
|
||||||
|
- "models/**/*.py"
|
||||||
|
- "app/modules/*/models/**/*.py"
|
||||||
check: "singular_naming"
|
check: "singular_naming"
|
||||||
|
|
||||||
- id: "NAM-004"
|
- id: "NAM-004"
|
||||||
name: "Use consistent terminology: vendor not shop"
|
name: "Use consistent terminology: vendor not shop"
|
||||||
severity: "warning"
|
severity: "warning"
|
||||||
description: |
|
description: |
|
||||||
Use 'vendor' consistently, not 'shop' (except for shop frontend)
|
Use 'vendor' consistently, not 'shop' (except for storefront)
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "app/**/*.py"
|
file_pattern: "app/**/*.py"
|
||||||
discouraged_terms:
|
discouraged_terms:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Architecture Rules - Service Layer Rules
|
# Architecture Rules - Service Layer Rules
|
||||||
# Rules for app/services/**/*.py files
|
# Rules for app/services/**/*.py and app/modules/*/services/**/*.py files
|
||||||
|
|
||||||
service_layer_rules:
|
service_layer_rules:
|
||||||
|
|
||||||
@@ -10,7 +10,9 @@ service_layer_rules:
|
|||||||
Services are business logic layer - they should NOT know about HTTP.
|
Services are business logic layer - they should NOT know about HTTP.
|
||||||
Raise domain-specific exceptions instead (ValueError, custom exceptions).
|
Raise domain-specific exceptions instead (ValueError, custom exceptions).
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "app/services/**/*.py"
|
file_pattern:
|
||||||
|
- "app/services/**/*.py"
|
||||||
|
- "app/modules/*/services/**/*.py"
|
||||||
anti_patterns:
|
anti_patterns:
|
||||||
- "raise HTTPException"
|
- "raise HTTPException"
|
||||||
- "from fastapi import HTTPException"
|
- "from fastapi import HTTPException"
|
||||||
@@ -22,7 +24,9 @@ service_layer_rules:
|
|||||||
Services should raise meaningful domain exceptions, not generic Exception.
|
Services should raise meaningful domain exceptions, not generic Exception.
|
||||||
Create custom exception classes for business rule violations.
|
Create custom exception classes for business rule violations.
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "app/services/**/*.py"
|
file_pattern:
|
||||||
|
- "app/services/**/*.py"
|
||||||
|
- "app/modules/*/services/**/*.py"
|
||||||
discouraged_patterns:
|
discouraged_patterns:
|
||||||
- "raise Exception\\("
|
- "raise Exception\\("
|
||||||
|
|
||||||
@@ -33,7 +37,9 @@ service_layer_rules:
|
|||||||
Service methods should receive database session as a parameter for testability
|
Service methods should receive database session as a parameter for testability
|
||||||
and transaction control. Never create session inside service.
|
and transaction control. Never create session inside service.
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "app/services/**/*.py"
|
file_pattern:
|
||||||
|
- "app/services/**/*.py"
|
||||||
|
- "app/modules/*/services/**/*.py"
|
||||||
required_in_method_signature:
|
required_in_method_signature:
|
||||||
- "db: Session"
|
- "db: Session"
|
||||||
anti_patterns:
|
anti_patterns:
|
||||||
@@ -47,7 +53,9 @@ service_layer_rules:
|
|||||||
Service methods should accept Pydantic models for complex inputs
|
Service methods should accept Pydantic models for complex inputs
|
||||||
to ensure type safety and validation.
|
to ensure type safety and validation.
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "app/services/**/*.py"
|
file_pattern:
|
||||||
|
- "app/services/**/*.py"
|
||||||
|
- "app/modules/*/services/**/*.py"
|
||||||
encouraged_patterns:
|
encouraged_patterns:
|
||||||
- "BaseModel"
|
- "BaseModel"
|
||||||
|
|
||||||
@@ -57,7 +65,9 @@ service_layer_rules:
|
|||||||
description: |
|
description: |
|
||||||
All database queries must be scoped to vendor_id to prevent cross-tenant data access.
|
All database queries must be scoped to vendor_id to prevent cross-tenant data access.
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "app/services/**/*.py"
|
file_pattern:
|
||||||
|
- "app/services/**/*.py"
|
||||||
|
- "app/modules/*/services/**/*.py"
|
||||||
check: "vendor_scoping"
|
check: "vendor_scoping"
|
||||||
|
|
||||||
- id: "SVC-006"
|
- id: "SVC-006"
|
||||||
@@ -74,11 +84,22 @@ service_layer_rules:
|
|||||||
|
|
||||||
The endpoint should call db.commit() after all service operations succeed.
|
The endpoint should call db.commit() after all service operations succeed.
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "app/services/**/*.py"
|
file_pattern:
|
||||||
|
- "app/services/**/*.py"
|
||||||
|
- "app/modules/*/services/**/*.py"
|
||||||
anti_patterns:
|
anti_patterns:
|
||||||
- "db.commit()"
|
- "db.commit()"
|
||||||
exceptions:
|
exceptions:
|
||||||
- "log_service.py"
|
- "log_service.py"
|
||||||
|
- "card_service.py"
|
||||||
|
- "wallet_service.py"
|
||||||
|
- "program_service.py"
|
||||||
|
- "points_service.py"
|
||||||
|
- "apple_wallet_service.py"
|
||||||
|
- "pin_service.py"
|
||||||
|
- "stamp_service.py"
|
||||||
|
- "google_wallet_service.py"
|
||||||
|
- "theme_presets.py"
|
||||||
|
|
||||||
- id: "SVC-007"
|
- id: "SVC-007"
|
||||||
name: "Service return types must match API response schemas"
|
name: "Service return types must match API response schemas"
|
||||||
@@ -113,5 +134,7 @@ service_layer_rules:
|
|||||||
result = service.get_stats(db)
|
result = service.get_stats(db)
|
||||||
StatsResponse(**result) # Raises if keys don't match
|
StatsResponse(**result) # Raises if keys don't match
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "app/services/**/*.py"
|
file_pattern:
|
||||||
|
- "app/services/**/*.py"
|
||||||
|
- "app/modules/*/services/**/*.py"
|
||||||
check: "schema_compatibility"
|
check: "schema_compatibility"
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ rules:
|
|||||||
type: file_exists
|
type: file_exists
|
||||||
paths:
|
paths:
|
||||||
- ".github/PULL_REQUEST_TEMPLATE.md"
|
- ".github/PULL_REQUEST_TEMPLATE.md"
|
||||||
- ".gitlab/merge_request_templates/*.md"
|
- "CONTRIBUTING.md"
|
||||||
message: "Pull request template recommended"
|
message: "Pull request template recommended"
|
||||||
|
|
||||||
- id: CHANGE-REV-002
|
- id: CHANGE-REV-002
|
||||||
@@ -74,7 +74,6 @@ rules:
|
|||||||
type: file_exists
|
type: file_exists
|
||||||
paths:
|
paths:
|
||||||
- ".github/CODEOWNERS"
|
- ".github/CODEOWNERS"
|
||||||
- "CODEOWNERS" # GitLab uses root CODEOWNERS or .gitlab/CODEOWNERS
|
|
||||||
- "CODEOWNERS"
|
- "CODEOWNERS"
|
||||||
message: "Consider defining code owners for critical paths"
|
message: "Consider defining code owners for critical paths"
|
||||||
|
|
||||||
@@ -91,7 +90,7 @@ rules:
|
|||||||
paths:
|
paths:
|
||||||
- ".github/workflows/ci.yml"
|
- ".github/workflows/ci.yml"
|
||||||
- ".github/workflows/test.yml"
|
- ".github/workflows/test.yml"
|
||||||
- ".gitlab-ci.yml"
|
- ".gitea/workflows/*.yml"
|
||||||
message: "CI workflow for automated testing required"
|
message: "CI workflow for automated testing required"
|
||||||
|
|
||||||
- id: CHANGE-CI-002
|
- id: CHANGE-CI-002
|
||||||
@@ -102,7 +101,7 @@ rules:
|
|||||||
type: pattern_recommended
|
type: pattern_recommended
|
||||||
paths:
|
paths:
|
||||||
- ".github/workflows/*.yml"
|
- ".github/workflows/*.yml"
|
||||||
- ".gitlab-ci.yml"
|
- ".gitea/workflows/*.yml"
|
||||||
patterns:
|
patterns:
|
||||||
- "security|bandit|safety|snyk|trivy"
|
- "security|bandit|safety|snyk|trivy"
|
||||||
message: "Consider security scanning in CI pipeline"
|
message: "Consider security scanning in CI pipeline"
|
||||||
@@ -115,7 +114,7 @@ rules:
|
|||||||
type: pattern_required
|
type: pattern_required
|
||||||
paths:
|
paths:
|
||||||
- ".github/workflows/*.yml"
|
- ".github/workflows/*.yml"
|
||||||
- ".gitlab-ci.yml"
|
- ".gitea/workflows/*.yml"
|
||||||
patterns:
|
patterns:
|
||||||
- "ruff|flake8|pylint|mypy|lint"
|
- "ruff|flake8|pylint|mypy|lint"
|
||||||
message: "Code quality checks required in CI"
|
message: "Code quality checks required in CI"
|
||||||
@@ -146,7 +145,7 @@ rules:
|
|||||||
paths:
|
paths:
|
||||||
- ".github/workflows/release.yml"
|
- ".github/workflows/release.yml"
|
||||||
- ".github/workflows/deploy.yml"
|
- ".github/workflows/deploy.yml"
|
||||||
- ".gitlab-ci.yml"
|
- ".gitea/workflows/*.yml"
|
||||||
- "Dockerfile"
|
- "Dockerfile"
|
||||||
message: "Automated deployment process recommended"
|
message: "Automated deployment process recommended"
|
||||||
|
|
||||||
@@ -199,7 +198,7 @@ rules:
|
|||||||
paths:
|
paths:
|
||||||
- "Dockerfile"
|
- "Dockerfile"
|
||||||
- ".github/workflows/*.yml"
|
- ".github/workflows/*.yml"
|
||||||
- ".gitlab-ci.yml"
|
- ".gitea/workflows/*.yml"
|
||||||
patterns:
|
patterns:
|
||||||
- "tag|version|:v"
|
- "tag|version|:v"
|
||||||
message: "Container image versioning recommended"
|
message: "Container image versioning recommended"
|
||||||
|
|||||||
@@ -122,10 +122,9 @@ rules:
|
|||||||
type: file_exists
|
type: file_exists
|
||||||
paths:
|
paths:
|
||||||
- ".github/PULL_REQUEST_TEMPLATE.md"
|
- ".github/PULL_REQUEST_TEMPLATE.md"
|
||||||
- ".gitlab/merge_request_templates/*.md"
|
|
||||||
- "CONTRIBUTING.md"
|
- "CONTRIBUTING.md"
|
||||||
- ".github/workflows/*.yml"
|
- ".github/workflows/*.yml"
|
||||||
- ".gitlab-ci.yml"
|
- ".gitea/workflows/*.yml"
|
||||||
message: "Code review process must be documented/enforced"
|
message: "Code review process must be documented/enforced"
|
||||||
|
|
||||||
- id: COMP-POL-002
|
- id: COMP-POL-002
|
||||||
@@ -138,8 +137,7 @@ rules:
|
|||||||
- ".github/CODEOWNERS"
|
- ".github/CODEOWNERS"
|
||||||
- "CODEOWNERS"
|
- "CODEOWNERS"
|
||||||
- ".github/workflows/*.yml"
|
- ".github/workflows/*.yml"
|
||||||
- ".gitlab-ci.yml"
|
- ".gitea/workflows/*.yml"
|
||||||
- ".gitlab-ci.yml"
|
|
||||||
message: "Document change approval requirements"
|
message: "Document change approval requirements"
|
||||||
|
|
||||||
- id: COMP-POL-003
|
- id: COMP-POL-003
|
||||||
@@ -166,7 +164,7 @@ rules:
|
|||||||
type: file_exists
|
type: file_exists
|
||||||
paths:
|
paths:
|
||||||
- ".github/workflows/ci.yml"
|
- ".github/workflows/ci.yml"
|
||||||
- ".gitlab-ci.yml"
|
- ".gitea/workflows/*.yml"
|
||||||
- "pytest.ini"
|
- "pytest.ini"
|
||||||
- "pyproject.toml"
|
- "pyproject.toml"
|
||||||
patterns:
|
patterns:
|
||||||
@@ -181,7 +179,7 @@ rules:
|
|||||||
type: file_exists
|
type: file_exists
|
||||||
paths:
|
paths:
|
||||||
- ".github/workflows/*.yml"
|
- ".github/workflows/*.yml"
|
||||||
- ".gitlab-ci.yml"
|
- ".gitea/workflows/*.yml"
|
||||||
patterns:
|
patterns:
|
||||||
- "deploy|release"
|
- "deploy|release"
|
||||||
message: "Deployment process must be automated and logged"
|
message: "Deployment process must be automated and logged"
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ rules:
|
|||||||
paths:
|
paths:
|
||||||
- "SECURITY.md"
|
- "SECURITY.md"
|
||||||
- ".github/SECURITY.md"
|
- ".github/SECURITY.md"
|
||||||
- ".gitlab/SECURITY.md"
|
- ".gitea/SECURITY.md"
|
||||||
message: "Security policy (SECURITY.md) required"
|
message: "Security policy (SECURITY.md) required"
|
||||||
|
|
||||||
- id: DOC-SEC-002
|
- id: DOC-SEC-002
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ rules:
|
|||||||
type: file_exists
|
type: file_exists
|
||||||
paths:
|
paths:
|
||||||
- ".github/workflows/*.yml"
|
- ".github/workflows/*.yml"
|
||||||
- ".gitlab-ci.yml"
|
- ".gitea/workflows/*.yml"
|
||||||
patterns:
|
patterns:
|
||||||
- "safety|pip-audit|snyk|dependabot"
|
- "safety|pip-audit|snyk|dependabot"
|
||||||
message: "Dependency vulnerability scanning required"
|
message: "Dependency vulnerability scanning required"
|
||||||
@@ -70,7 +70,7 @@ rules:
|
|||||||
type: file_exists
|
type: file_exists
|
||||||
paths:
|
paths:
|
||||||
- ".github/dependabot.yml"
|
- ".github/dependabot.yml"
|
||||||
- ".gitlab-ci.yml" # GitLab uses built-in dependency scanning
|
- ".gitea/workflows/*.yml"
|
||||||
message: "Consider enabling Dependabot for security updates"
|
message: "Consider enabling Dependabot for security updates"
|
||||||
|
|
||||||
- id: THIRD-VULN-003
|
- id: THIRD-VULN-003
|
||||||
@@ -81,7 +81,7 @@ rules:
|
|||||||
type: pattern_recommended
|
type: pattern_recommended
|
||||||
paths:
|
paths:
|
||||||
- ".github/workflows/*.yml"
|
- ".github/workflows/*.yml"
|
||||||
- ".gitlab-ci.yml"
|
- ".gitea/workflows/*.yml"
|
||||||
patterns:
|
patterns:
|
||||||
- "trivy|grype|snyk.*container"
|
- "trivy|grype|snyk.*container"
|
||||||
message: "Consider container image vulnerability scanning"
|
message: "Consider container image vulnerability scanning"
|
||||||
|
|||||||
21
.dockerignore
Normal file
21
.dockerignore
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
site/
|
||||||
|
docs/
|
||||||
|
exports/
|
||||||
|
alembic/versions_backup/
|
||||||
|
*.csv
|
||||||
|
*.md
|
||||||
|
!requirements.txt
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
.architecture-rules/
|
||||||
|
.performance-rules/
|
||||||
|
.security-rules/
|
||||||
|
mkdocs.yml
|
||||||
|
monitoring/
|
||||||
77
.env.example
77
.env.example
@@ -6,7 +6,7 @@ DEBUG=False
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# PROJECT INFORMATION
|
# PROJECT INFORMATION
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
PROJECT_NAME=Wizamart - Multi-Store Marketplace Platform
|
PROJECT_NAME=Orion - Multi-Store Marketplace Platform
|
||||||
DESCRIPTION=Multi-tenants multi-themes ecommerce application
|
DESCRIPTION=Multi-tenants multi-themes ecommerce application
|
||||||
VERSION=2.2.0
|
VERSION=2.2.0
|
||||||
|
|
||||||
@@ -14,17 +14,17 @@ VERSION=2.2.0
|
|||||||
# DATABASE CONFIGURATION (PostgreSQL required)
|
# DATABASE CONFIGURATION (PostgreSQL required)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Default works with: docker-compose up -d db
|
# Default works with: docker-compose up -d db
|
||||||
DATABASE_URL=postgresql://wizamart_user:secure_password@localhost:5432/wizamart_db
|
DATABASE_URL=postgresql://orion_user:secure_password@localhost:5432/orion_db
|
||||||
|
|
||||||
# For production, use your PostgreSQL connection string:
|
# For production, use your PostgreSQL connection string:
|
||||||
# DATABASE_URL=postgresql://username:password@production-host:5432/wizamart_db
|
# DATABASE_URL=postgresql://username:password@production-host:5432/orion_db
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# ADMIN INITIALIZATION
|
# ADMIN INITIALIZATION
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# These are used by init_production.py to create the platform admin
|
# These are used by init_production.py to create the platform admin
|
||||||
# ⚠️ CHANGE THESE IN PRODUCTION!
|
# ⚠️ CHANGE THESE IN PRODUCTION!
|
||||||
ADMIN_EMAIL=admin@wizamart.com
|
ADMIN_EMAIL=admin@wizard.lu
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=change-me-in-production
|
ADMIN_PASSWORD=change-me-in-production
|
||||||
ADMIN_FIRST_NAME=Platform
|
ADMIN_FIRST_NAME=Platform
|
||||||
@@ -49,9 +49,9 @@ API_PORT=8000
|
|||||||
# Development
|
# Development
|
||||||
DOCUMENTATION_URL=http://localhost:8001
|
DOCUMENTATION_URL=http://localhost:8001
|
||||||
# Staging
|
# Staging
|
||||||
# DOCUMENTATION_URL=https://staging-docs.wizamart.com
|
# DOCUMENTATION_URL=https://staging-docs.wizard.lu
|
||||||
# Production
|
# Production
|
||||||
# DOCUMENTATION_URL=https://docs.wizamart.com
|
# DOCUMENTATION_URL=https://docs.wizard.lu
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# RATE LIMITING
|
# RATE LIMITING
|
||||||
@@ -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=wizamart.com
|
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
|
||||||
@@ -85,7 +90,7 @@ SSL_PROVIDER=letsencrypt
|
|||||||
AUTO_PROVISION_SSL=False
|
AUTO_PROVISION_SSL=False
|
||||||
|
|
||||||
# DNS verification
|
# DNS verification
|
||||||
DNS_VERIFICATION_PREFIX=_wizamart-verify
|
DNS_VERIFICATION_PREFIX=_wizard-verify
|
||||||
DNS_VERIFICATION_TTL=3600
|
DNS_VERIFICATION_TTL=3600
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -103,8 +108,8 @@ STRIPE_TRIAL_DAYS=30
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Provider: smtp, sendgrid, mailgun, ses
|
# Provider: smtp, sendgrid, mailgun, ses
|
||||||
EMAIL_PROVIDER=smtp
|
EMAIL_PROVIDER=smtp
|
||||||
EMAIL_FROM_ADDRESS=noreply@wizamart.com
|
EMAIL_FROM_ADDRESS=noreply@wizard.lu
|
||||||
EMAIL_FROM_NAME=Wizamart
|
EMAIL_FROM_NAME=Wizard
|
||||||
EMAIL_REPLY_TO=
|
EMAIL_REPLY_TO=
|
||||||
|
|
||||||
# SMTP Settings (used when EMAIL_PROVIDER=smtp)
|
# SMTP Settings (used when EMAIL_PROVIDER=smtp)
|
||||||
@@ -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
|
||||||
@@ -173,6 +182,14 @@ SENTRY_DSN=
|
|||||||
SENTRY_ENVIRONMENT=production
|
SENTRY_ENVIRONMENT=production
|
||||||
SENTRY_TRACES_SAMPLE_RATE=0.1
|
SENTRY_TRACES_SAMPLE_RATE=0.1
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MONITORING
|
||||||
|
# =============================================================================
|
||||||
|
ENABLE_METRICS=true
|
||||||
|
GRAFANA_URL=https://grafana.wizard.lu
|
||||||
|
GRAFANA_ADMIN_USER=admin
|
||||||
|
GRAFANA_ADMIN_PASSWORD=changeme
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# CLOUDFLARE R2 STORAGE
|
# CLOUDFLARE R2 STORAGE
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -185,13 +202,49 @@ STORAGE_BACKEND=local
|
|||||||
R2_ACCOUNT_ID=
|
R2_ACCOUNT_ID=
|
||||||
R2_ACCESS_KEY_ID=
|
R2_ACCESS_KEY_ID=
|
||||||
R2_SECRET_ACCESS_KEY=
|
R2_SECRET_ACCESS_KEY=
|
||||||
R2_BUCKET_NAME=wizamart-media
|
R2_BUCKET_NAME=orion-media
|
||||||
|
|
||||||
# Public URL for R2 bucket (optional - for custom domain)
|
# Public URL for R2 bucket (optional - for custom domain)
|
||||||
# If not set, uses Cloudflare's default R2 public URL
|
# If not set, uses Cloudflare's default R2 public URL
|
||||||
# Example: https://media.yoursite.com
|
# Example: https://media.yoursite.com
|
||||||
R2_PUBLIC_URL=
|
R2_PUBLIC_URL=
|
||||||
|
|
||||||
|
# Cloudflare R2 backup bucket (used by scripts/backup.sh --upload)
|
||||||
|
R2_BACKUP_BUCKET=orion-backups
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LOYALTY MODULE
|
||||||
|
# =============================================================================
|
||||||
|
# Anti-fraud defaults (all optional, shown values are defaults)
|
||||||
|
# LOYALTY_DEFAULT_COOLDOWN_MINUTES=15
|
||||||
|
# LOYALTY_MAX_DAILY_STAMPS=5
|
||||||
|
# LOYALTY_PIN_MAX_FAILED_ATTEMPTS=5
|
||||||
|
# LOYALTY_PIN_LOCKOUT_MINUTES=30
|
||||||
|
|
||||||
|
# Points configuration
|
||||||
|
# LOYALTY_DEFAULT_POINTS_PER_EURO=10
|
||||||
|
|
||||||
|
# Google Wallet integration
|
||||||
|
# See docs/deployment/hetzner-server-setup.md Step 25 for setup guide
|
||||||
|
# Get Issuer ID from https://pay.google.com/business/console
|
||||||
|
# LOYALTY_GOOGLE_ISSUER_ID=3388000000012345678
|
||||||
|
# Production convention: ~/apps/orion/google-wallet-sa.json (app user, mode 600).
|
||||||
|
# Path is validated at startup — file must exist and be readable, otherwise
|
||||||
|
# the app fails fast at import time.
|
||||||
|
# LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=~/apps/orion/google-wallet-sa.json
|
||||||
|
# LOYALTY_GOOGLE_WALLET_ORIGINS=["https://yourdomain.com"]
|
||||||
|
# LOYALTY_DEFAULT_LOGO_URL=https://yourdomain.com/path/to/default-logo.png
|
||||||
|
|
||||||
|
# Apple Wallet integration (requires Apple Developer account)
|
||||||
|
# LOYALTY_APPLE_PASS_TYPE_ID=pass.com.example.loyalty
|
||||||
|
# LOYALTY_APPLE_TEAM_ID=ABCD1234
|
||||||
|
# LOYALTY_APPLE_WWDR_CERT_PATH=/path/to/wwdr.pem
|
||||||
|
# LOYALTY_APPLE_SIGNER_CERT_PATH=/path/to/signer.pem
|
||||||
|
# LOYALTY_APPLE_SIGNER_KEY_PATH=/path/to/signer.key
|
||||||
|
|
||||||
|
# QR code size in pixels (default: 300)
|
||||||
|
# LOYALTY_QR_CODE_SIZE=300
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# CLOUDFLARE CDN / PROXY
|
# CLOUDFLARE CDN / PROXY
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Gitea Actions CI/CD Configuration
|
# Gitea Actions CI/CD Configuration
|
||||||
# ==================================
|
# ==================================
|
||||||
# Equivalent of the GitLab CI pipeline, using GitHub Actions-compatible syntax.
|
# Uses GitHub Actions-compatible syntax. Requires Gitea 1.19+ with Actions enabled.
|
||||||
# Requires Gitea 1.19+ with Actions enabled.
|
# Requires Gitea 1.19+ with Actions enabled.
|
||||||
|
|
||||||
name: CI
|
name: CI
|
||||||
@@ -37,28 +37,28 @@ 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
|
||||||
env:
|
env:
|
||||||
POSTGRES_DB: wizamart_test
|
POSTGRES_DB: orion_test
|
||||||
POSTGRES_USER: test_user
|
POSTGRES_USER: test_user
|
||||||
POSTGRES_PASSWORD: test_password
|
POSTGRES_PASSWORD: test_password
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd "pg_isready -U test_user -d wizamart_test"
|
--health-cmd "pg_isready -U test_user -d orion_test"
|
||||||
--health-interval 10s
|
--health-interval 10s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 5
|
--health-retries 5
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# act_runner executes jobs in Docker containers on the same network as services,
|
TEST_DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/orion_test"
|
||||||
# so use the service name (postgres) as hostname with the internal port (5432)
|
DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/orion_test"
|
||||||
TEST_DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/wizamart_test"
|
LOG_LEVEL: "WARNING"
|
||||||
DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/wizamart_test"
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -73,10 +73,10 @@ 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
|
||||||
|
|
||||||
architecture:
|
validate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: "postgresql://dummy:dummy@localhost:5432/dummy"
|
DATABASE_URL: "postgresql://dummy:dummy@localhost:5432/dummy"
|
||||||
@@ -94,8 +94,17 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv pip install --system -r requirements.txt
|
run: uv pip install --system -r requirements.txt
|
||||||
|
|
||||||
- name: Validate architecture
|
- name: Validate architecture patterns
|
||||||
run: python scripts/validate/validate_architecture.py
|
run: python scripts/validate/validate_all.py --architecture
|
||||||
|
|
||||||
|
- name: Validate security patterns
|
||||||
|
run: python scripts/validate/validate_all.py --security
|
||||||
|
|
||||||
|
- name: Validate performance patterns
|
||||||
|
run: python scripts/validate/validate_all.py --performance
|
||||||
|
|
||||||
|
- name: Validate audit patterns
|
||||||
|
run: python scripts/validate/validate_all.py --audit
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Security (non-blocking)
|
# Security (non-blocking)
|
||||||
@@ -116,32 +125,13 @@ jobs:
|
|||||||
- name: Run pip-audit
|
- name: Run pip-audit
|
||||||
run: pip-audit --requirement requirements.txt || true
|
run: pip-audit --requirement requirements.txt || true
|
||||||
|
|
||||||
audit:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
continue-on-error: true
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: ${{ env.PYTHON_VERSION }}
|
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
run: pip install uv
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: uv pip install --system -r requirements.txt -r requirements-dev.txt
|
|
||||||
|
|
||||||
- name: Run audit
|
|
||||||
run: python scripts/validate/validate_audit.py
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Build (docs - only on push to master)
|
# Build (docs - only on push to master)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
docs:
|
docs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||||
needs: [ruff, pytest, architecture]
|
needs: [ruff, pytest, validate]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -158,8 +148,20 @@ jobs:
|
|||||||
- name: Build docs
|
- name: Build docs
|
||||||
run: mkdocs build
|
run: mkdocs build
|
||||||
|
|
||||||
- name: Upload docs artifact
|
# ---------------------------------------------------------------------------
|
||||||
uses: actions/upload-artifact@v4
|
# Deploy (master-only, after lint + tests + validate pass)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||||
|
needs: [ruff, pytest, validate]
|
||||||
|
steps:
|
||||||
|
- name: Deploy to production
|
||||||
|
uses: appleboy/ssh-action@v1
|
||||||
with:
|
with:
|
||||||
name: docs-site
|
host: ${{ secrets.DEPLOY_HOST }}
|
||||||
path: site/
|
username: ${{ secrets.DEPLOY_USER }}
|
||||||
|
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
port: 22
|
||||||
|
command_timeout: 10m
|
||||||
|
script: cd ${{ secrets.DEPLOY_PATH }} && bash scripts/deploy.sh
|
||||||
|
|||||||
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"
|
||||||
13
.gitignore
vendored
13
.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
|
||||||
@@ -168,6 +167,11 @@ deployment-local/
|
|||||||
secrets/
|
secrets/
|
||||||
credentials/
|
credentials/
|
||||||
|
|
||||||
|
# Google Cloud service account keys
|
||||||
|
*-service-account.json
|
||||||
|
google-wallet-sa.json
|
||||||
|
orion-*.json
|
||||||
|
|
||||||
# Alembic
|
# Alembic
|
||||||
# Note: Keep alembic/versions/ tracked for migrations
|
# Note: Keep alembic/versions/ tracked for migrations
|
||||||
# alembic/versions/*.pyc is already covered by __pycache__
|
# alembic/versions/*.pyc is already covered by __pycache__
|
||||||
@@ -183,5 +187,8 @@ tailadmin-free-tailwind-dashboard-template/
|
|||||||
static/shared/css/tailwind.css
|
static/shared/css/tailwind.css
|
||||||
|
|
||||||
# Export files
|
# Export files
|
||||||
wizamart_letzshop_export_*.csv
|
orion_letzshop_export_*.csv
|
||||||
exports/
|
exports/
|
||||||
|
|
||||||
|
# Security audit (needs revamping)
|
||||||
|
scripts/security-audit/
|
||||||
|
|||||||
130
.gitlab-ci.yml
130
.gitlab-ci.yml
@@ -1,130 +0,0 @@
|
|||||||
# GitLab CI/CD Configuration
|
|
||||||
# =========================
|
|
||||||
|
|
||||||
stages:
|
|
||||||
- lint
|
|
||||||
- test
|
|
||||||
- security
|
|
||||||
- build
|
|
||||||
|
|
||||||
variables:
|
|
||||||
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
|
|
||||||
PYTHON_VERSION: "3.11"
|
|
||||||
|
|
||||||
# Cache dependencies between jobs
|
|
||||||
cache:
|
|
||||||
paths:
|
|
||||||
- .cache/pip
|
|
||||||
- .venv/
|
|
||||||
|
|
||||||
# Lint Stage
|
|
||||||
# ----------
|
|
||||||
|
|
||||||
ruff:
|
|
||||||
stage: lint
|
|
||||||
image: python:${PYTHON_VERSION}
|
|
||||||
before_script:
|
|
||||||
- pip install uv
|
|
||||||
- uv sync --frozen
|
|
||||||
script:
|
|
||||||
- .venv/bin/ruff check .
|
|
||||||
rules:
|
|
||||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
||||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
||||||
|
|
||||||
# Test Stage
|
|
||||||
# ----------
|
|
||||||
|
|
||||||
pytest:
|
|
||||||
stage: test
|
|
||||||
image: python:${PYTHON_VERSION}
|
|
||||||
services:
|
|
||||||
- name: postgres:15
|
|
||||||
alias: postgres
|
|
||||||
variables:
|
|
||||||
# PostgreSQL service configuration
|
|
||||||
POSTGRES_DB: wizamart_test
|
|
||||||
POSTGRES_USER: test_user
|
|
||||||
POSTGRES_PASSWORD: test_password
|
|
||||||
# Application database URL for tests
|
|
||||||
TEST_DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/wizamart_test"
|
|
||||||
# Skip database validation during import (tests use TEST_DATABASE_URL)
|
|
||||||
DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/wizamart_test"
|
|
||||||
before_script:
|
|
||||||
- pip install uv
|
|
||||||
- uv sync --frozen
|
|
||||||
# Wait for PostgreSQL to be ready
|
|
||||||
- apt-get update && apt-get install -y postgresql-client
|
|
||||||
- for i in $(seq 1 30); do pg_isready -h postgres -U test_user && break || sleep 1; done
|
|
||||||
script:
|
|
||||||
- .venv/bin/python -m pytest tests/ -v --tb=short
|
|
||||||
coverage: '/TOTAL.*\s+(\d+%)/'
|
|
||||||
artifacts:
|
|
||||||
reports:
|
|
||||||
junit: report.xml
|
|
||||||
coverage_report:
|
|
||||||
coverage_format: cobertura
|
|
||||||
path: coverage.xml
|
|
||||||
rules:
|
|
||||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
||||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
||||||
|
|
||||||
architecture:
|
|
||||||
stage: test
|
|
||||||
image: python:${PYTHON_VERSION}
|
|
||||||
variables:
|
|
||||||
# Set DATABASE_URL to satisfy validation (not actually used by validator)
|
|
||||||
DATABASE_URL: "postgresql://dummy:dummy@localhost:5432/dummy"
|
|
||||||
before_script:
|
|
||||||
- pip install uv
|
|
||||||
- uv sync --frozen
|
|
||||||
script:
|
|
||||||
- .venv/bin/python scripts/validate/validate_architecture.py
|
|
||||||
rules:
|
|
||||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
||||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
||||||
|
|
||||||
# Security Stage
|
|
||||||
# --------------
|
|
||||||
|
|
||||||
dependency_scanning:
|
|
||||||
stage: security
|
|
||||||
image: python:${PYTHON_VERSION}
|
|
||||||
before_script:
|
|
||||||
- pip install pip-audit
|
|
||||||
script:
|
|
||||||
- pip-audit --requirement requirements.txt || true
|
|
||||||
allow_failure: true
|
|
||||||
rules:
|
|
||||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
||||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
||||||
|
|
||||||
audit:
|
|
||||||
stage: security
|
|
||||||
image: python:${PYTHON_VERSION}
|
|
||||||
before_script:
|
|
||||||
- pip install uv
|
|
||||||
- uv sync --frozen
|
|
||||||
script:
|
|
||||||
- .venv/bin/python scripts/validate/validate_audit.py
|
|
||||||
allow_failure: true
|
|
||||||
rules:
|
|
||||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
||||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
||||||
|
|
||||||
# Build Stage
|
|
||||||
# -----------
|
|
||||||
|
|
||||||
docs:
|
|
||||||
stage: build
|
|
||||||
image: python:${PYTHON_VERSION}
|
|
||||||
before_script:
|
|
||||||
- pip install uv
|
|
||||||
- uv sync --frozen
|
|
||||||
script:
|
|
||||||
- .venv/bin/mkdocs build
|
|
||||||
artifacts:
|
|
||||||
paths:
|
|
||||||
- site/
|
|
||||||
rules:
|
|
||||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
# Run manually: pre-commit run --all-files
|
# Run manually: pre-commit run --all-files
|
||||||
|
|
||||||
repos:
|
repos:
|
||||||
# Architecture validation
|
# Code validators (architecture, security, performance, audit)
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
- id: validate-architecture
|
- id: validate-architecture
|
||||||
@@ -16,6 +16,33 @@ repos:
|
|||||||
additional_dependencies: [pyyaml]
|
additional_dependencies: [pyyaml]
|
||||||
verbose: true
|
verbose: true
|
||||||
|
|
||||||
|
- id: validate-security
|
||||||
|
name: Validate Security Patterns
|
||||||
|
entry: python scripts/validate/validate_all.py --security
|
||||||
|
language: python
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
|
additional_dependencies: [pyyaml]
|
||||||
|
verbose: true
|
||||||
|
|
||||||
|
- id: validate-performance
|
||||||
|
name: Validate Performance Patterns
|
||||||
|
entry: python scripts/validate/validate_all.py --performance
|
||||||
|
language: python
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
|
additional_dependencies: [pyyaml]
|
||||||
|
verbose: true
|
||||||
|
|
||||||
|
- id: validate-audit
|
||||||
|
name: Validate Audit Patterns
|
||||||
|
entry: python scripts/validate/validate_all.py --audit
|
||||||
|
language: python
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
|
additional_dependencies: [pyyaml]
|
||||||
|
verbose: true
|
||||||
|
|
||||||
# Python code quality
|
# Python code quality
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.5.0
|
rev: v4.5.0
|
||||||
@@ -23,21 +50,16 @@ repos:
|
|||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
|
exclude: mkdocs.yml # Uses Python tags (!!python/name) unsupported by basic YAML checker
|
||||||
- id: check-added-large-files
|
- id: check-added-large-files
|
||||||
args: ['--maxkb=1000']
|
args: ['--maxkb=1000']
|
||||||
- id: check-json
|
- id: check-json
|
||||||
- id: check-merge-conflict
|
- id: check-merge-conflict
|
||||||
- id: debug-statements
|
- id: debug-statements
|
||||||
|
|
||||||
# Python formatting (optional - uncomment if you want)
|
# Ruff - linting and import sorting (replaces black + isort)
|
||||||
# - repo: https://github.com/psf/black
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
# rev: 23.12.1
|
rev: v0.8.4
|
||||||
# hooks:
|
hooks:
|
||||||
# - id: black
|
- id: ruff
|
||||||
# language_version: python3
|
args: [--fix, --exit-non-zero-on-fix]
|
||||||
|
|
||||||
# Python import sorting (optional)
|
|
||||||
# - repo: https://github.com/pycqa/isort
|
|
||||||
# rev: 5.13.2
|
|
||||||
# hooks:
|
|
||||||
# - id: isort
|
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ return {
|
|||||||
|
|
||||||
### Duplicate /shop/ Prefix
|
### Duplicate /shop/ Prefix
|
||||||
|
|
||||||
**Problem:** Routes like `/stores/wizamart/shop/shop/products/4`
|
**Problem:** Routes like `/stores/orion/shop/shop/products/4`
|
||||||
|
|
||||||
**Root Cause:**
|
**Root Cause:**
|
||||||
```python
|
```python
|
||||||
@@ -136,7 +136,7 @@ All routes in `shop_pages.py` fixed.
|
|||||||
|
|
||||||
### Missing /shop/ in Template Links
|
### Missing /shop/ in Template Links
|
||||||
|
|
||||||
**Problem:** Links went to `/stores/wizamart/products` instead of `/shop/products`
|
**Problem:** Links went to `/stores/orion/products` instead of `/shop/products`
|
||||||
|
|
||||||
**Fix:** Updated all templates:
|
**Fix:** Updated all templates:
|
||||||
- `shop/base.html` - Header, footer, navigation
|
- `shop/base.html` - Header, footer, navigation
|
||||||
@@ -290,15 +290,15 @@ Comprehensive guide covering:
|
|||||||
### Test URLs
|
### Test URLs
|
||||||
```
|
```
|
||||||
Landing Pages:
|
Landing Pages:
|
||||||
- http://localhost:8000/stores/wizamart/
|
- http://localhost:8000/stores/orion/
|
||||||
- http://localhost:8000/stores/fashionhub/
|
- http://localhost:8000/stores/fashionhub/
|
||||||
- http://localhost:8000/stores/bookstore/
|
- http://localhost:8000/stores/bookstore/
|
||||||
|
|
||||||
Shop Pages:
|
Shop Pages:
|
||||||
- http://localhost:8000/stores/wizamart/shop/
|
- http://localhost:8000/stores/orion/shop/
|
||||||
- http://localhost:8000/stores/wizamart/shop/products
|
- http://localhost:8000/stores/orion/shop/products
|
||||||
- http://localhost:8000/stores/wizamart/shop/products/1
|
- http://localhost:8000/stores/orion/shop/products/1
|
||||||
- http://localhost:8000/stores/wizamart/shop/cart
|
- http://localhost:8000/stores/orion/shop/cart
|
||||||
```
|
```
|
||||||
|
|
||||||
## Breaking Changes
|
## Breaking Changes
|
||||||
|
|||||||
70
Makefile
70
Makefile
@@ -1,7 +1,7 @@
|
|||||||
# Wizamart 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
|
.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)
|
||||||
@@ -44,7 +44,7 @@ setup: install-all migrate-up init-prod
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
$(PYTHON) -m uvicorn main:app --reload --host 0.0.0.0 --port 9999
|
$(PYTHON) -m uvicorn main:app --reload --host 0.0.0.0 --port $(or $(API_PORT),8000)
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# DATABASE MIGRATIONS
|
# DATABASE MIGRATIONS
|
||||||
@@ -104,22 +104,19 @@ init-prod:
|
|||||||
@echo "Step 0/6: Ensuring database exists (running migrations)..."
|
@echo "Step 0/6: Ensuring database exists (running migrations)..."
|
||||||
@$(PYTHON) -m alembic upgrade heads
|
@$(PYTHON) -m alembic upgrade heads
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Step 1/6: Creating admin user and platform settings..."
|
@echo "Step 1/5: Creating admin user and platform settings..."
|
||||||
$(PYTHON) scripts/seed/init_production.py
|
$(PYTHON) scripts/seed/init_production.py
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Step 2/6: Initializing log settings..."
|
@echo "Step 2/5: Initializing log settings..."
|
||||||
$(PYTHON) scripts/seed/init_log_settings.py
|
$(PYTHON) scripts/seed/init_log_settings.py
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Step 3/6: Creating default CMS content pages..."
|
@echo "Step 3/5: Creating default CMS content pages..."
|
||||||
$(PYTHON) scripts/seed/create_default_content_pages.py
|
$(PYTHON) scripts/seed/create_default_content_pages.py
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Step 4/6: Creating platform pages and landing..."
|
@echo "Step 4/5: Seeding email templates..."
|
||||||
$(PYTHON) scripts/seed/create_platform_pages.py
|
|
||||||
@echo ""
|
|
||||||
@echo "Step 5/6: Seeding email templates..."
|
|
||||||
$(PYTHON) scripts/seed/seed_email_templates.py
|
$(PYTHON) scripts/seed/seed_email_templates.py
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Step 6/6: Seeding subscription tiers..."
|
@echo "Step 5/5: Seeding subscription tiers..."
|
||||||
@echo " (Handled by init_production.py Step 6)"
|
@echo " (Handled by init_production.py Step 6)"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "✅ Production initialization completed"
|
@echo "✅ Production initialization completed"
|
||||||
@@ -132,7 +129,7 @@ seed-tiers:
|
|||||||
|
|
||||||
# First-time installation - Complete setup with configuration validation
|
# First-time installation - Complete setup with configuration validation
|
||||||
platform-install:
|
platform-install:
|
||||||
@echo "🚀 WIZAMART PLATFORM INSTALLATION"
|
@echo "🚀 ORION PLATFORM INSTALLATION"
|
||||||
@echo "=================================="
|
@echo "=================================="
|
||||||
$(PYTHON) scripts/seed/install.py
|
$(PYTHON) scripts/seed/install.py
|
||||||
|
|
||||||
@@ -176,6 +173,12 @@ db-reset:
|
|||||||
$(PYTHON) -m alembic upgrade head
|
$(PYTHON) -m alembic upgrade head
|
||||||
@echo "Initializing production data..."
|
@echo "Initializing production data..."
|
||||||
$(PYTHON) scripts/seed/init_production.py
|
$(PYTHON) scripts/seed/init_production.py
|
||||||
|
@echo "Initializing log settings..."
|
||||||
|
$(PYTHON) scripts/seed/init_log_settings.py
|
||||||
|
@echo "Creating default CMS content pages..."
|
||||||
|
$(PYTHON) scripts/seed/create_default_content_pages.py
|
||||||
|
@echo "Seeding email templates..."
|
||||||
|
$(PYTHON) scripts/seed/seed_email_templates.py
|
||||||
@echo "Seeding demo data..."
|
@echo "Seeding demo data..."
|
||||||
ifeq ($(DETECTED_OS),Windows)
|
ifeq ($(DETECTED_OS),Windows)
|
||||||
@set SEED_MODE=reset&& set FORCE_RESET=true&& $(PYTHON) scripts/seed/seed_demo.py
|
@set SEED_MODE=reset&& set FORCE_RESET=true&& $(PYTHON) scripts/seed/seed_demo.py
|
||||||
@@ -195,10 +198,6 @@ create-cms-defaults:
|
|||||||
$(PYTHON) scripts/seed/create_default_content_pages.py
|
$(PYTHON) scripts/seed/create_default_content_pages.py
|
||||||
@echo "✅ CMS defaults created"
|
@echo "✅ CMS defaults created"
|
||||||
|
|
||||||
create-platform-pages:
|
|
||||||
@echo "🏠 Creating platform pages and landing..."
|
|
||||||
$(PYTHON) scripts/seed/create_platform_pages.py
|
|
||||||
@echo "✅ Platform pages created"
|
|
||||||
|
|
||||||
init-logging:
|
init-logging:
|
||||||
@echo "📝 Initializing log settings..."
|
@echo "📝 Initializing log settings..."
|
||||||
@@ -235,7 +234,7 @@ test-db-status:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Test database URL
|
# Test database URL
|
||||||
TEST_DB_URL := postgresql://test_user:test_password@localhost:5433/wizamart_test
|
TEST_DB_URL := postgresql://test_user:test_password@localhost:5433/orion_test
|
||||||
|
|
||||||
# Build pytest marker expression from module= and frontend= params
|
# Build pytest marker expression from module= and frontend= params
|
||||||
MARKER_EXPR :=
|
MARKER_EXPR :=
|
||||||
@@ -250,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:
|
||||||
@@ -275,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
|
||||||
@@ -504,6 +509,10 @@ urls-prod:
|
|||||||
urls-check:
|
urls-check:
|
||||||
@$(PYTHON) scripts/show_urls.py --check
|
@$(PYTHON) scripts/show_urls.py --check
|
||||||
|
|
||||||
|
infra-check:
|
||||||
|
@echo "Running infrastructure verification..."
|
||||||
|
bash scripts/verify-server.sh
|
||||||
|
|
||||||
check-env:
|
check-env:
|
||||||
@echo "Checking Python environment..."
|
@echo "Checking Python environment..."
|
||||||
@echo "Detected OS: $(DETECTED_OS)"
|
@echo "Detected OS: $(DETECTED_OS)"
|
||||||
@@ -530,7 +539,7 @@ endif
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Wizamart Platform Development Commands"
|
@echo "Orion Platform Development Commands"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "=== SETUP ==="
|
@echo "=== SETUP ==="
|
||||||
@echo " install - Install production dependencies"
|
@echo " install - Install production dependencies"
|
||||||
@@ -566,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 ""
|
||||||
@@ -609,6 +620,7 @@ help:
|
|||||||
@echo " urls-dev - Show development URLs only"
|
@echo " urls-dev - Show development URLs only"
|
||||||
@echo " urls-prod - Show production URLs only"
|
@echo " urls-prod - Show production URLs only"
|
||||||
@echo " urls-check - Check dev URLs with curl (server must be running)"
|
@echo " urls-check - Check dev URLs with curl (server must be running)"
|
||||||
|
@echo " infra-check - Run infrastructure verification (verify-server.sh)"
|
||||||
@echo " clean - Clean build artifacts"
|
@echo " clean - Clean build artifacts"
|
||||||
@echo " check-env - Check Python environment and OS"
|
@echo " check-env - Check Python environment and OS"
|
||||||
@echo ""
|
@echo ""
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ This FastAPI application provides a complete ecommerce backend solution designed
|
|||||||
### Project Structure
|
### Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
wizamart/
|
orion/
|
||||||
├── main.py # FastAPI application entry point
|
├── main.py # FastAPI application entry point
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── core/
|
│ ├── core/
|
||||||
@@ -179,8 +179,8 @@ make qa
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
# Clone the repository
|
||||||
git clone <wizamart-repo>
|
git clone <orion-repo>
|
||||||
cd wizamart-repo
|
cd orion-repo
|
||||||
|
|
||||||
# Create virtual environment
|
# Create virtual environment
|
||||||
python -m venv venv
|
python -m venv venv
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
If you discover a security vulnerability in this project, please report it responsibly:
|
If you discover a security vulnerability in this project, please report it responsibly:
|
||||||
|
|
||||||
1. **Do not** open a public issue
|
1. **Do not** open a public issue
|
||||||
2. Email the security team at: security@wizamart.com
|
2. Email the security team at: security@orion.lu
|
||||||
3. Include:
|
3. Include:
|
||||||
- Description of the vulnerability
|
- Description of the vulnerability
|
||||||
- Steps to reproduce
|
- Steps to reproduce
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Terminology Guide
|
# Terminology Guide
|
||||||
|
|
||||||
This document defines the standard terminology used throughout the Wizamart codebase.
|
This document defines the standard terminology used throughout the Orion codebase.
|
||||||
|
|
||||||
## Core Multi-Tenant Entities
|
## Core Multi-Tenant Entities
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ Landing pages have been created for three stores with different templates.
|
|||||||
|
|
||||||
## 📍 Test URLs
|
## 📍 Test URLs
|
||||||
|
|
||||||
### 1. WizaMart - Modern Template
|
### 1. Orion - Modern Template
|
||||||
**Landing Page:**
|
**Landing Page:**
|
||||||
- http://localhost:8000/stores/wizamart/
|
- http://localhost:8000/stores/orion/
|
||||||
|
|
||||||
**Shop Page:**
|
**Shop Page:**
|
||||||
- http://localhost:8000/stores/wizamart/shop/
|
- http://localhost:8000/stores/orion/shop/
|
||||||
|
|
||||||
**What to expect:**
|
**What to expect:**
|
||||||
- Full-screen hero section with animations
|
- Full-screen hero section with animations
|
||||||
@@ -93,8 +93,8 @@ db.close()
|
|||||||
"
|
"
|
||||||
```
|
```
|
||||||
|
|
||||||
Then visit: http://localhost:8000/stores/wizamart/
|
Then visit: http://localhost:8000/stores/orion/
|
||||||
- Should automatically redirect to: http://localhost:8000/stores/wizamart/shop/
|
- Should automatically redirect to: http://localhost:8000/stores/orion/shop/
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -111,17 +111,17 @@ Or programmatically:
|
|||||||
```python
|
```python
|
||||||
from scripts.create_landing_page import create_landing_page
|
from scripts.create_landing_page import create_landing_page
|
||||||
|
|
||||||
# Change WizaMart to default template
|
# Change Orion to default template
|
||||||
create_landing_page('wizamart', template='default')
|
create_landing_page('orion', template='default')
|
||||||
|
|
||||||
# Change to minimal
|
# Change to minimal
|
||||||
create_landing_page('wizamart', template='minimal')
|
create_landing_page('orion', template='minimal')
|
||||||
|
|
||||||
# Change to full
|
# Change to full
|
||||||
create_landing_page('wizamart', template='full')
|
create_landing_page('orion', template='full')
|
||||||
|
|
||||||
# Change back to modern
|
# Change back to modern
|
||||||
create_landing_page('wizamart', template='modern')
|
create_landing_page('orion', template='modern')
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -130,7 +130,7 @@ create_landing_page('wizamart', template='modern')
|
|||||||
|
|
||||||
| Store | Subdomain | Template | Landing Page URL |
|
| Store | Subdomain | Template | Landing Page URL |
|
||||||
|--------|-----------|----------|------------------|
|
|--------|-----------|----------|------------------|
|
||||||
| WizaMart | wizamart | **modern** | http://localhost:8000/stores/wizamart/ |
|
| Orion | orion | **modern** | http://localhost:8000/stores/orion/ |
|
||||||
| Fashion Hub | fashionhub | **minimal** | http://localhost:8000/stores/fashionhub/ |
|
| Fashion Hub | fashionhub | **minimal** | http://localhost:8000/stores/fashionhub/ |
|
||||||
| The Book Store | bookstore | **full** | http://localhost:8000/stores/bookstore/ |
|
| The Book Store | bookstore | **full** | http://localhost:8000/stores/bookstore/ |
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ sqlite3 letzshop.db "SELECT id, store_id, slug, title, template, is_published FR
|
|||||||
|
|
||||||
Expected output:
|
Expected output:
|
||||||
```
|
```
|
||||||
8|1|landing|Welcome to WizaMart|modern|1
|
8|1|landing|Welcome to Orion|modern|1
|
||||||
9|2|landing|Fashion Hub - Style & Elegance|minimal|1
|
9|2|landing|Fashion Hub - Style & Elegance|minimal|1
|
||||||
10|3|landing|The Book Store - Your Literary Haven|full|1
|
10|3|landing|The Book Store - Your Literary Haven|full|1
|
||||||
```
|
```
|
||||||
@@ -180,7 +180,7 @@ Expected output:
|
|||||||
|
|
||||||
## ✅ Success Checklist
|
## ✅ Success Checklist
|
||||||
|
|
||||||
- [ ] WizaMart landing page loads (modern template)
|
- [ ] Orion landing page loads (modern template)
|
||||||
- [ ] Fashion Hub landing page loads (minimal template)
|
- [ ] Fashion Hub landing page loads (minimal template)
|
||||||
- [ ] Book Store landing page loads (full template)
|
- [ ] Book Store landing page loads (full template)
|
||||||
- [ ] "Shop Now" buttons work correctly
|
- [ ] "Shop Now" buttons work correctly
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ try:
|
|||||||
from app.modules.billing.models import ( # noqa: F401
|
from app.modules.billing.models import ( # noqa: F401
|
||||||
AddOnProduct,
|
AddOnProduct,
|
||||||
BillingHistory,
|
BillingHistory,
|
||||||
CapacitySnapshot,
|
|
||||||
MerchantFeatureOverride,
|
MerchantFeatureOverride,
|
||||||
MerchantSubscription,
|
MerchantSubscription,
|
||||||
StoreAddOn,
|
StoreAddOn,
|
||||||
@@ -90,7 +89,7 @@ try:
|
|||||||
TierFeatureLimit,
|
TierFeatureLimit,
|
||||||
)
|
)
|
||||||
|
|
||||||
print(" ✓ Billing models (9)")
|
print(" ✓ Billing models (8)")
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
_import_errors.append(f"billing: {e}")
|
_import_errors.append(f"billing: {e}")
|
||||||
print(f" ✗ Billing models failed: {e}")
|
print(f" ✗ Billing models failed: {e}")
|
||||||
@@ -263,6 +262,19 @@ except ImportError as e:
|
|||||||
_import_errors.append(f"dev_tools: {e}")
|
_import_errors.append(f"dev_tools: {e}")
|
||||||
print(f" ✗ Dev Tools models failed: {e}")
|
print(f" ✗ Dev Tools models failed: {e}")
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# MONITORING MODULE (1 model)
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
try:
|
||||||
|
from app.modules.monitoring.models import ( # noqa: F401
|
||||||
|
CapacitySnapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(" ✓ Monitoring models (1)")
|
||||||
|
except ImportError as e:
|
||||||
|
_import_errors.append(f"monitoring: {e}")
|
||||||
|
print(f" ✗ Monitoring models failed: {e}")
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# SUMMARY
|
# SUMMARY
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
26
alembic/versions/a44f4956cfb1_merge_heads.py
Normal file
26
alembic/versions/a44f4956cfb1_merge_heads.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""merge heads
|
||||||
|
|
||||||
|
Revision ID: a44f4956cfb1
|
||||||
|
Revises: z_store_domain_platform_id, tenancy_001
|
||||||
|
Create Date: 2026-02-17 16:10:36.287976
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'a44f4956cfb1'
|
||||||
|
down_revision: Union[str, None] = ('z_store_domain_platform_id', 'tenancy_001')
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -19,9 +19,9 @@ def upgrade() -> None:
|
|||||||
"platforms",
|
"platforms",
|
||||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||||
sa.Column("code", sa.String(50), unique=True, nullable=False, index=True, comment="Unique platform identifier (e.g., 'oms', 'loyalty', 'sites')"),
|
sa.Column("code", sa.String(50), unique=True, nullable=False, index=True, comment="Unique platform identifier (e.g., 'oms', 'loyalty', 'sites')"),
|
||||||
sa.Column("name", sa.String(100), nullable=False, comment="Display name (e.g., 'Wizamart OMS')"),
|
sa.Column("name", sa.String(100), nullable=False, comment="Display name (e.g., 'Orion OMS')"),
|
||||||
sa.Column("description", sa.Text(), nullable=True, comment="Platform description for admin/marketing purposes"),
|
sa.Column("description", sa.Text(), nullable=True, comment="Platform description for admin/marketing purposes"),
|
||||||
sa.Column("domain", sa.String(255), unique=True, nullable=True, index=True, comment="Production domain (e.g., 'oms.lu', 'loyalty.lu')"),
|
sa.Column("domain", sa.String(255), unique=True, nullable=True, index=True, comment="Production domain (e.g., 'omsflow.lu', 'rewardflow.lu')"),
|
||||||
sa.Column("path_prefix", sa.String(50), unique=True, nullable=True, index=True, comment="Development path prefix (e.g., 'oms' for localhost:9999/oms/*)"),
|
sa.Column("path_prefix", sa.String(50), unique=True, nullable=True, index=True, comment="Development path prefix (e.g., 'oms' for localhost:9999/oms/*)"),
|
||||||
sa.Column("logo", sa.String(500), nullable=True, comment="Logo URL for light mode"),
|
sa.Column("logo", sa.String(500), nullable=True, comment="Logo URL for light mode"),
|
||||||
sa.Column("logo_dark", sa.String(500), nullable=True, comment="Logo URL for dark mode"),
|
sa.Column("logo_dark", sa.String(500), nullable=True, comment="Logo URL for dark mode"),
|
||||||
|
|||||||
35
alembic/versions/remove_store_platform_is_primary.py
Normal file
35
alembic/versions/remove_store_platform_is_primary.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""Remove is_primary from store_platforms
|
||||||
|
|
||||||
|
The platform is always deterministic from the URL context (path in dev,
|
||||||
|
subdomain/domain in prod) and the JWT carries token_platform_id.
|
||||||
|
The is_primary column was a fallback picker that silently returned the
|
||||||
|
wrong platform for multi-platform stores.
|
||||||
|
|
||||||
|
Revision ID: remove_is_primary_001
|
||||||
|
Revises: billing_001
|
||||||
|
Create Date: 2026-03-09
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "remove_is_primary_001"
|
||||||
|
down_revision = "billing_001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_index("idx_store_platform_primary", table_name="store_platforms")
|
||||||
|
op.drop_column("store_platforms", "is_primary")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"store_platforms",
|
||||||
|
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_store_platform_primary", "store_platforms", ["store_id", "is_primary"]
|
||||||
|
)
|
||||||
118
alembic/versions/softdelete_001_add_soft_delete.py
Normal file
118
alembic/versions/softdelete_001_add_soft_delete.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""Add soft delete columns (deleted_at, deleted_by_id) to business-critical tables.
|
||||||
|
|
||||||
|
Also converts unique constraints on users.email, users.username,
|
||||||
|
stores.store_code, stores.subdomain to partial unique indexes
|
||||||
|
that only apply to non-deleted rows.
|
||||||
|
|
||||||
|
Revision ID: softdelete_001
|
||||||
|
Revises: remove_is_primary_001, customers_002, dev_tools_002, orders_002, tenancy_004
|
||||||
|
Create Date: 2026-03-28
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "softdelete_001"
|
||||||
|
down_revision = (
|
||||||
|
"remove_is_primary_001",
|
||||||
|
"customers_002",
|
||||||
|
"dev_tools_002",
|
||||||
|
"orders_002",
|
||||||
|
"tenancy_004",
|
||||||
|
)
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# Tables receiving soft-delete columns
|
||||||
|
SOFT_DELETE_TABLES = [
|
||||||
|
"users",
|
||||||
|
"merchants",
|
||||||
|
"stores",
|
||||||
|
"customers",
|
||||||
|
"store_users",
|
||||||
|
"orders",
|
||||||
|
"products",
|
||||||
|
"loyalty_programs",
|
||||||
|
"loyalty_cards",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ======================================================================
|
||||||
|
# Step 1: Add deleted_at and deleted_by_id to all soft-delete tables
|
||||||
|
# ======================================================================
|
||||||
|
for table in SOFT_DELETE_TABLES:
|
||||||
|
op.add_column(table, sa.Column("deleted_at", sa.DateTime(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
table,
|
||||||
|
sa.Column(
|
||||||
|
"deleted_by_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(f"ix_{table}_deleted_at", table, ["deleted_at"])
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# Step 2: Replace simple unique constraints with partial unique indexes
|
||||||
|
# (only enforce uniqueness among non-deleted rows)
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
# users.email: drop old unique index, create partial
|
||||||
|
op.drop_index("ix_users_email", table_name="users")
|
||||||
|
op.execute(
|
||||||
|
'CREATE UNIQUE INDEX uq_users_email_active ON users (email) '
|
||||||
|
'WHERE deleted_at IS NULL'
|
||||||
|
)
|
||||||
|
# Keep a non-unique index for lookups on all rows (including deleted)
|
||||||
|
op.create_index("ix_users_email", "users", ["email"])
|
||||||
|
|
||||||
|
# users.username: drop old unique index, create partial
|
||||||
|
op.drop_index("ix_users_username", table_name="users")
|
||||||
|
op.execute(
|
||||||
|
'CREATE UNIQUE INDEX uq_users_username_active ON users (username) '
|
||||||
|
'WHERE deleted_at IS NULL'
|
||||||
|
)
|
||||||
|
op.create_index("ix_users_username", "users", ["username"])
|
||||||
|
|
||||||
|
# stores.store_code: drop old unique index, create partial
|
||||||
|
op.drop_index("ix_stores_store_code", table_name="stores")
|
||||||
|
op.execute(
|
||||||
|
'CREATE UNIQUE INDEX uq_stores_store_code_active ON stores (store_code) '
|
||||||
|
'WHERE deleted_at IS NULL'
|
||||||
|
)
|
||||||
|
op.create_index("ix_stores_store_code", "stores", ["store_code"])
|
||||||
|
|
||||||
|
# stores.subdomain: drop old unique index, create partial
|
||||||
|
op.drop_index("ix_stores_subdomain", table_name="stores")
|
||||||
|
op.execute(
|
||||||
|
'CREATE UNIQUE INDEX uq_stores_subdomain_active ON stores (subdomain) '
|
||||||
|
'WHERE deleted_at IS NULL'
|
||||||
|
)
|
||||||
|
op.create_index("ix_stores_subdomain", "stores", ["subdomain"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Reverse partial unique indexes back to simple unique indexes
|
||||||
|
op.drop_index("ix_stores_subdomain", table_name="stores")
|
||||||
|
op.execute("DROP INDEX IF EXISTS uq_stores_subdomain_active")
|
||||||
|
op.create_index("ix_stores_subdomain", "stores", ["subdomain"], unique=True)
|
||||||
|
|
||||||
|
op.drop_index("ix_stores_store_code", table_name="stores")
|
||||||
|
op.execute("DROP INDEX IF EXISTS uq_stores_store_code_active")
|
||||||
|
op.create_index("ix_stores_store_code", "stores", ["store_code"], unique=True)
|
||||||
|
|
||||||
|
op.drop_index("ix_users_username", table_name="users")
|
||||||
|
op.execute("DROP INDEX IF EXISTS uq_users_username_active")
|
||||||
|
op.create_index("ix_users_username", "users", ["username"], unique=True)
|
||||||
|
|
||||||
|
op.drop_index("ix_users_email", table_name="users")
|
||||||
|
op.execute("DROP INDEX IF EXISTS uq_users_email_active")
|
||||||
|
op.create_index("ix_users_email", "users", ["email"], unique=True)
|
||||||
|
|
||||||
|
# Remove soft-delete columns from all tables
|
||||||
|
for table in reversed(SOFT_DELETE_TABLES):
|
||||||
|
op.drop_index(f"ix_{table}_deleted_at", table_name=table)
|
||||||
|
op.drop_column(table, "deleted_by_id")
|
||||||
|
op.drop_column(table, "deleted_at")
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""add unique constraints for custom_subdomain and store domain per platform
|
||||||
|
|
||||||
|
Revision ID: z_unique_subdomain_domain
|
||||||
|
Revises: a44f4956cfb1
|
||||||
|
Create Date: 2026-02-26
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "z_unique_subdomain_domain"
|
||||||
|
down_revision = ("a44f4956cfb1", "tenancy_003")
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# StorePlatform: same custom_subdomain cannot be claimed twice on the same platform
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_custom_subdomain_platform",
|
||||||
|
"store_platforms",
|
||||||
|
["custom_subdomain", "platform_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# StoreDomain: a store can have at most one custom domain per platform
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_store_domain_platform",
|
||||||
|
"store_domains",
|
||||||
|
["store_id", "platform_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("uq_store_domain_platform", "store_domains", type_="unique")
|
||||||
|
op.drop_constraint("uq_custom_subdomain_platform", "store_platforms", type_="unique")
|
||||||
@@ -248,7 +248,7 @@ def upgrade() -> None:
|
|||||||
existing_nullable=True)
|
existing_nullable=True)
|
||||||
op.alter_column("platforms", "domain",
|
op.alter_column("platforms", "domain",
|
||||||
existing_type=sa.VARCHAR(length=255),
|
existing_type=sa.VARCHAR(length=255),
|
||||||
comment="Production domain (e.g., 'oms.lu', 'loyalty.lu')",
|
comment="Production domain (e.g., 'omsflow.lu', 'rewardflow.lu')",
|
||||||
existing_nullable=True)
|
existing_nullable=True)
|
||||||
op.alter_column("platforms", "path_prefix",
|
op.alter_column("platforms", "path_prefix",
|
||||||
existing_type=sa.VARCHAR(length=50),
|
existing_type=sa.VARCHAR(length=50),
|
||||||
@@ -518,7 +518,7 @@ def downgrade() -> None:
|
|||||||
op.alter_column("platforms", "domain",
|
op.alter_column("platforms", "domain",
|
||||||
existing_type=sa.VARCHAR(length=255),
|
existing_type=sa.VARCHAR(length=255),
|
||||||
comment=None,
|
comment=None,
|
||||||
existing_comment="Production domain (e.g., 'oms.lu', 'loyalty.lu')",
|
existing_comment="Production domain (e.g., 'omsflow.lu', 'rewardflow.lu')",
|
||||||
existing_nullable=True)
|
existing_nullable=True)
|
||||||
op.alter_column("platforms", "description",
|
op.alter_column("platforms", "description",
|
||||||
existing_type=sa.TEXT(),
|
existing_type=sa.TEXT(),
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ def upgrade() -> None:
|
|||||||
supported_languages, is_active, is_public, theme_config, settings,
|
supported_languages, is_active, is_public, theme_config, settings,
|
||||||
created_at, updated_at)
|
created_at, updated_at)
|
||||||
VALUES ('oms', 'Wizamart OMS', 'Order Management System for Luxembourg merchants',
|
VALUES ('oms', 'Wizamart OMS', 'Order Management System for Luxembourg merchants',
|
||||||
'oms.lu', 'oms', 'fr', '["fr", "de", "en"]', true, true, '{}', '{}',
|
'omsflow.lu', 'oms', 'fr', '["fr", "de", "en"]', true, true, '{}', '{}',
|
||||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
""")
|
""")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ def upgrade() -> None:
|
|||||||
supported_languages, is_active, is_public, theme_config, settings,
|
supported_languages, is_active, is_public, theme_config, settings,
|
||||||
created_at, updated_at)
|
created_at, updated_at)
|
||||||
VALUES ('loyalty', 'Loyalty+', 'Customer loyalty program platform for Luxembourg businesses',
|
VALUES ('loyalty', 'Loyalty+', 'Customer loyalty program platform for Luxembourg businesses',
|
||||||
'loyalty.lu', 'loyalty', 'fr', '["fr", "de", "en"]', true, true,
|
'rewardflow.lu', 'loyalty', 'fr', '["fr", "de", "en"]', true, true,
|
||||||
'{"primary_color": "#8B5CF6", "secondary_color": "#A78BFA"}',
|
'{"primary_color": "#8B5CF6", "secondary_color": "#A78BFA"}',
|
||||||
'{"features": ["points", "rewards", "tiers", "analytics"]}',
|
'{"features": ["points", "rewards", "tiers", "analytics"]}',
|
||||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
|||||||
171
app/api/deps.py
171
app/api/deps.py
@@ -20,26 +20,26 @@ MERCHANT ROUTES (/merchants/*):
|
|||||||
- Role: store (merchant owners are store-role users who own merchants)
|
- Role: store (merchant owners are store-role users who own merchants)
|
||||||
- Validates: User owns the merchant via Merchant.owner_user_id
|
- Validates: User owns the merchant via Merchant.owner_user_id
|
||||||
|
|
||||||
CUSTOMER/SHOP ROUTES (/shop/account/*):
|
CUSTOMER/STOREFRONT ROUTES (/storefront/account/*):
|
||||||
- Cookie: customer_token (path=/shop) OR Authorization header
|
- Cookie: customer_token (path=/storefront) OR Authorization header
|
||||||
- Role: customer only
|
- Role: customer only
|
||||||
- Blocks: admins, stores
|
- Blocks: admins, stores
|
||||||
- Note: Public shop pages (/shop/products, etc.) don't require auth
|
- Note: Public storefront pages (/storefront/products, etc.) don't require auth
|
||||||
|
|
||||||
This dual authentication approach supports:
|
This dual authentication approach supports:
|
||||||
- HTML pages: Use cookies (automatic browser behavior)
|
- HTML pages: Use cookies (automatic browser behavior)
|
||||||
- API calls: Use Authorization headers (explicit JavaScript control)
|
- API calls: Use Authorization headers (explicit JavaScript control)
|
||||||
|
|
||||||
The cookie path restrictions prevent cross-context cookie leakage:
|
The cookie path restrictions prevent cross-context cookie leakage:
|
||||||
- admin_token is NEVER sent to /store/* or /shop/*
|
- admin_token is NEVER sent to /store/* or /storefront/*
|
||||||
- store_token is NEVER sent to /admin/* or /shop/*
|
- store_token is NEVER sent to /admin/* or /storefront/*
|
||||||
- customer_token is NEVER sent to /admin/* or /store/*
|
- customer_token is NEVER sent to /admin/* or /store/*
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC
|
from datetime import UTC
|
||||||
|
|
||||||
from fastapi import Cookie, Depends, Request
|
from fastapi import Cookie, Depends, HTTPException, Request
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -56,10 +56,10 @@ from app.modules.tenancy.exceptions import (
|
|||||||
)
|
)
|
||||||
from app.modules.tenancy.models import Store
|
from app.modules.tenancy.models import Store
|
||||||
from app.modules.tenancy.models import User as UserModel
|
from app.modules.tenancy.models import User as UserModel
|
||||||
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
from app.modules.tenancy.services.store_service import store_service
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
from middleware.auth import AuthManager
|
from middleware.auth import AuthManager
|
||||||
from middleware.rate_limiter import RateLimiter
|
from middleware.rate_limiter import RateLimiter
|
||||||
from models.schema.auth import UserContext
|
|
||||||
|
|
||||||
# Initialize dependencies
|
# Initialize dependencies
|
||||||
security = HTTPBearer(auto_error=False) # auto_error=False prevents automatic 403
|
security = HTTPBearer(auto_error=False) # auto_error=False prevents automatic 403
|
||||||
@@ -73,6 +73,19 @@ logger = logging.getLogger(__name__)
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def get_resolved_store_code(request: Request) -> str:
|
||||||
|
"""Get store code from path parameter (path-based) or middleware (subdomain/custom domain)."""
|
||||||
|
# Path parameter from double-mount prefix (/store/{store_code}/...)
|
||||||
|
store_code = request.path_params.get("store_code")
|
||||||
|
if store_code:
|
||||||
|
return store_code
|
||||||
|
# Middleware-resolved store (subdomain or custom domain)
|
||||||
|
store = getattr(request.state, "store", None)
|
||||||
|
if store:
|
||||||
|
return store.store_code
|
||||||
|
raise HTTPException(status_code=404, detail="Store not found")
|
||||||
|
|
||||||
|
|
||||||
def _get_token_from_request(
|
def _get_token_from_request(
|
||||||
credentials: HTTPAuthorizationCredentials | None,
|
credentials: HTTPAuthorizationCredentials | None,
|
||||||
cookie_value: str | None,
|
cookie_value: str | None,
|
||||||
@@ -155,6 +168,23 @@ def _get_user_model(user_context: UserContext, db: Session) -> UserModel:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# PLATFORM CONTEXT
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def require_platform(request: Request):
|
||||||
|
"""Dependency that requires platform context from middleware.
|
||||||
|
|
||||||
|
Raises HTTPException(400) if no platform is set on request.state.
|
||||||
|
Use as a FastAPI dependency in endpoints that need platform context.
|
||||||
|
"""
|
||||||
|
platform = getattr(request.state, "platform", None)
|
||||||
|
if not platform:
|
||||||
|
raise HTTPException(status_code=400, detail="Platform context required")
|
||||||
|
return platform
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# ADMIN AUTHENTICATION
|
# ADMIN AUTHENTICATION
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -200,7 +230,7 @@ def get_current_admin_from_cookie_or_header(
|
|||||||
user = _validate_user_token(token, db)
|
user = _validate_user_token(token, db)
|
||||||
|
|
||||||
# Verify user is admin
|
# Verify user is admin
|
||||||
if user.role != "admin":
|
if not user.is_admin:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Non-admin user {user.username} attempted admin route: {request.url.path}"
|
f"Non-admin user {user.username} attempted admin route: {request.url.path}"
|
||||||
)
|
)
|
||||||
@@ -235,7 +265,7 @@ def get_current_admin_api(
|
|||||||
|
|
||||||
user = _validate_user_token(credentials.credentials, db)
|
user = _validate_user_token(credentials.credentials, db)
|
||||||
|
|
||||||
if user.role != "admin":
|
if not user.is_admin:
|
||||||
logger.warning(f"Non-admin user {user.username} attempted admin API")
|
logger.warning(f"Non-admin user {user.username} attempted admin API")
|
||||||
raise AdminRequiredException("Admin privileges required")
|
raise AdminRequiredException("Admin privileges required")
|
||||||
|
|
||||||
@@ -399,7 +429,7 @@ def get_admin_with_platform_context(
|
|||||||
|
|
||||||
user = _validate_user_token(token, db)
|
user = _validate_user_token(token, db)
|
||||||
|
|
||||||
if user.role != "admin":
|
if not user.is_admin:
|
||||||
raise AdminRequiredException("Admin privileges required")
|
raise AdminRequiredException("Admin privileges required")
|
||||||
|
|
||||||
# Super admins bypass platform context
|
# Super admins bypass platform context
|
||||||
@@ -444,11 +474,11 @@ def require_module_access(module_code: str, frontend_type: FrontendType):
|
|||||||
tied to a specific menu item.
|
tied to a specific menu item.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
admin_router = APIRouter(
|
router = APIRouter(
|
||||||
dependencies=[Depends(require_module_access("messaging", FrontendType.ADMIN))]
|
dependencies=[Depends(require_module_access("messaging", FrontendType.ADMIN))]
|
||||||
)
|
)
|
||||||
|
|
||||||
store_router = APIRouter(
|
router = APIRouter(
|
||||||
dependencies=[Depends(require_module_access("billing", FrontendType.STORE))]
|
dependencies=[Depends(require_module_access("billing", FrontendType.STORE))]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -590,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
|
||||||
@@ -702,7 +732,7 @@ def get_current_store_from_cookie_or_header(
|
|||||||
user = _validate_user_token(token, db)
|
user = _validate_user_token(token, db)
|
||||||
|
|
||||||
# CRITICAL: Block admins from store routes
|
# CRITICAL: Block admins from store routes
|
||||||
if user.role == "admin":
|
if user.is_admin:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Admin user {user.username} attempted store route: {request.url.path}"
|
f"Admin user {user.username} attempted store route: {request.url.path}"
|
||||||
)
|
)
|
||||||
@@ -710,8 +740,8 @@ def get_current_store_from_cookie_or_header(
|
|||||||
"Store access only - admins cannot use store portal"
|
"Store access only - admins cannot use store portal"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify user is store
|
# Verify user is store user (merchant_owner or store_member)
|
||||||
if user.role != "store":
|
if not user.is_store_user:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Non-store user {user.username} attempted store route: {request.url.path}"
|
f"Non-store user {user.username} attempted store route: {request.url.path}"
|
||||||
)
|
)
|
||||||
@@ -749,11 +779,11 @@ def get_current_store_api(
|
|||||||
user = _validate_user_token(credentials.credentials, db)
|
user = _validate_user_token(credentials.credentials, db)
|
||||||
|
|
||||||
# Block admins from store API
|
# Block admins from store API
|
||||||
if user.role == "admin":
|
if user.is_admin:
|
||||||
logger.warning(f"Admin user {user.username} attempted store API")
|
logger.warning(f"Admin user {user.username} attempted store API")
|
||||||
raise InsufficientPermissionsException("Store access only")
|
raise InsufficientPermissionsException("Store access only")
|
||||||
|
|
||||||
if user.role != "store":
|
if not user.is_store_user:
|
||||||
logger.warning(f"Non-store user {user.username} attempted store API")
|
logger.warning(f"Non-store user {user.username} attempted store API")
|
||||||
raise InsufficientPermissionsException("Store privileges required")
|
raise InsufficientPermissionsException("Store privileges required")
|
||||||
|
|
||||||
@@ -1019,7 +1049,7 @@ def get_merchant_for_current_user_page(
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# CUSTOMER AUTHENTICATION (SHOP)
|
# CUSTOMER AUTHENTICATION (STOREFRONT)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@@ -1095,7 +1125,7 @@ def _validate_customer_token(token: str, request: Request, db: Session):
|
|||||||
raise InvalidTokenException("Customer account is inactive")
|
raise InvalidTokenException("Customer account is inactive")
|
||||||
|
|
||||||
# Validate store context matches token
|
# Validate store context matches token
|
||||||
# This prevents using a customer token from store A on store B's shop
|
# This prevents using a customer token from store A on store B's storefront
|
||||||
request_store = getattr(request.state, "store", None)
|
request_store = getattr(request.state, "store", None)
|
||||||
if request_store and token_store_id:
|
if request_store and token_store_id:
|
||||||
if request_store.id != token_store_id:
|
if request_store.id != token_store_id:
|
||||||
@@ -1123,8 +1153,8 @@ def get_current_customer_from_cookie_or_header(
|
|||||||
"""
|
"""
|
||||||
Get current customer from customer_token cookie or Authorization header.
|
Get current customer from customer_token cookie or Authorization header.
|
||||||
|
|
||||||
Used for shop account HTML pages (/shop/account/*) that need cookie-based auth.
|
Used for storefront account HTML pages (/storefront/account/*) that need cookie-based auth.
|
||||||
Note: Public shop pages (/shop/products, etc.) don't use this dependency.
|
Note: Public storefront pages (/storefront/products, etc.) don't use this dependency.
|
||||||
|
|
||||||
Validates that token store_id matches request store (URL-based detection).
|
Validates that token store_id matches request store (URL-based detection).
|
||||||
|
|
||||||
@@ -1164,7 +1194,7 @@ def get_current_customer_api(
|
|||||||
"""
|
"""
|
||||||
Get current customer from Authorization header ONLY.
|
Get current customer from Authorization header ONLY.
|
||||||
|
|
||||||
Used for shop API endpoints that should not accept cookies.
|
Used for storefront API endpoints that should not accept cookies.
|
||||||
Validates that token store_id matches request store (URL-based detection).
|
Validates that token store_id matches request store (URL-based detection).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1527,6 +1557,55 @@ def get_user_permissions(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# PAGE-LEVEL PERMISSION GUARDS (For Store Page Routes)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def require_store_page_permission(permission: str):
|
||||||
|
"""
|
||||||
|
Dependency factory to require a specific store permission for page routes.
|
||||||
|
|
||||||
|
Same as require_store_permission but raises InsufficientStorePermissionsException
|
||||||
|
which the exception handler intercepts for HTML requests (redirecting to login).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@router.get("/products", response_class=HTMLResponse)
|
||||||
|
def store_products_page(
|
||||||
|
request: Request,
|
||||||
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
|
current_user: User = Depends(require_store_page_permission("products.view")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
|
||||||
|
def permission_checker(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: UserContext = Depends(get_current_store_from_cookie_or_header),
|
||||||
|
) -> UserContext:
|
||||||
|
if not current_user.token_store_id:
|
||||||
|
raise InvalidTokenException(
|
||||||
|
"Token missing store information. Please login again."
|
||||||
|
)
|
||||||
|
|
||||||
|
store_id = current_user.token_store_id
|
||||||
|
store = store_service.get_store_by_id(db, store_id)
|
||||||
|
request.state.store = store
|
||||||
|
|
||||||
|
user_model = _get_user_model(current_user, db)
|
||||||
|
if not user_model.has_store_permission(store.id, permission):
|
||||||
|
raise InsufficientStorePermissionsException(
|
||||||
|
required_permission=permission,
|
||||||
|
store_code=store.store_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
return permission_checker
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# OPTIONAL AUTHENTICATION (For Login Page Redirects)
|
# OPTIONAL AUTHENTICATION (For Login Page Redirects)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -1570,7 +1649,7 @@ def get_current_admin_optional(
|
|||||||
user = _validate_user_token(token, db)
|
user = _validate_user_token(token, db)
|
||||||
|
|
||||||
# Verify user is admin
|
# Verify user is admin
|
||||||
if user.role == "admin":
|
if user.is_admin:
|
||||||
return UserContext.from_user(user, include_store_context=False)
|
return UserContext.from_user(user, include_store_context=False)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Invalid token or other error
|
# Invalid token or other error
|
||||||
@@ -1616,8 +1695,8 @@ def get_current_store_optional(
|
|||||||
# Validate token and get user
|
# Validate token and get user
|
||||||
user = _validate_user_token(token, db)
|
user = _validate_user_token(token, db)
|
||||||
|
|
||||||
# Verify user is store
|
# Verify user is a store user
|
||||||
if user.role == "store":
|
if user.is_store_user:
|
||||||
return UserContext.from_user(user)
|
return UserContext.from_user(user)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Invalid token or other error
|
# Invalid token or other error
|
||||||
@@ -1665,3 +1744,39 @@ def get_current_customer_optional(
|
|||||||
except Exception:
|
except Exception:
|
||||||
# Invalid token, store mismatch, or other error
|
# Invalid token, store mismatch, or other error
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# STOREFRONT MODULE GATING
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def make_storefront_module_gate(module_code: str):
|
||||||
|
"""
|
||||||
|
Create a FastAPI dependency that gates storefront routes by module enablement.
|
||||||
|
|
||||||
|
Used by main.py at route registration time: each non-core module's storefront
|
||||||
|
router gets this dependency injected automatically. The framework already knows
|
||||||
|
which module owns each route via RouteInfo.module_code — no hardcoded path map.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
module_code: The module code to check (e.g. "catalog", "orders", "loyalty")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A FastAPI dependency function
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _check_module_enabled(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
from app.modules.service import module_service
|
||||||
|
|
||||||
|
platform = getattr(request.state, "platform", None)
|
||||||
|
if not platform:
|
||||||
|
return # No platform context — let other middleware handle it
|
||||||
|
|
||||||
|
if not module_service.is_module_enabled(db, platform.id, module_code):
|
||||||
|
raise HTTPException(status_code=404, detail="Page not found")
|
||||||
|
|
||||||
|
return _check_module_enabled
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
Platform signup API endpoints.
|
Platform signup API endpoints.
|
||||||
|
|
||||||
Handles the multi-step signup flow:
|
Handles the multi-step signup flow:
|
||||||
1. Start signup (select tier)
|
1. Start signup (select tier + platform)
|
||||||
2. Claim Letzshop store (optional)
|
2. Create account (user + merchant)
|
||||||
3. Create account
|
3. Create store
|
||||||
4. Setup payment (collect card via SetupIntent)
|
4. Setup payment (collect card via SetupIntent)
|
||||||
5. Complete signup (create subscription with trial)
|
5. Complete signup (create Stripe subscription with trial)
|
||||||
|
|
||||||
|
Platform-specific steps (e.g., OMS Letzshop claiming) are handled
|
||||||
|
by their respective modules and call into this core flow.
|
||||||
|
|
||||||
All endpoints are public (no authentication required).
|
All endpoints are public (no authentication required).
|
||||||
"""
|
"""
|
||||||
@@ -20,9 +23,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.environment import should_use_secure_cookies
|
from app.core.environment import should_use_secure_cookies
|
||||||
from app.modules.marketplace.services.platform_signup_service import (
|
from app.modules.billing.services.signup_service import signup_service
|
||||||
platform_signup_service,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -34,10 +35,12 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class SignupStartRequest(BaseModel):
|
class SignupStartRequest(BaseModel):
|
||||||
"""Start signup - select tier."""
|
"""Start signup - select tier and platform."""
|
||||||
|
|
||||||
tier_code: str
|
tier_code: str
|
||||||
is_annual: bool = False
|
is_annual: bool = False
|
||||||
|
platform_code: str
|
||||||
|
language: str = "fr"
|
||||||
|
|
||||||
|
|
||||||
class SignupStartResponse(BaseModel):
|
class SignupStartResponse(BaseModel):
|
||||||
@@ -46,26 +49,11 @@ class SignupStartResponse(BaseModel):
|
|||||||
session_id: str
|
session_id: str
|
||||||
tier_code: str
|
tier_code: str
|
||||||
is_annual: bool
|
is_annual: bool
|
||||||
|
platform_code: str
|
||||||
|
|
||||||
class ClaimStoreRequest(BaseModel):
|
|
||||||
"""Claim Letzshop store."""
|
|
||||||
|
|
||||||
session_id: str
|
|
||||||
letzshop_slug: str
|
|
||||||
letzshop_store_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ClaimStoreResponse(BaseModel):
|
|
||||||
"""Response from store claim."""
|
|
||||||
|
|
||||||
session_id: str
|
|
||||||
letzshop_slug: str
|
|
||||||
store_name: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class CreateAccountRequest(BaseModel):
|
class CreateAccountRequest(BaseModel):
|
||||||
"""Create account."""
|
"""Create account (user + merchant)."""
|
||||||
|
|
||||||
session_id: str
|
session_id: str
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
@@ -77,12 +65,30 @@ class CreateAccountRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CreateAccountResponse(BaseModel):
|
class CreateAccountResponse(BaseModel):
|
||||||
"""Response from account creation."""
|
"""Response from account creation (includes auto-created store)."""
|
||||||
|
|
||||||
session_id: str
|
session_id: str
|
||||||
user_id: int
|
user_id: int
|
||||||
store_id: int
|
merchant_id: int
|
||||||
stripe_customer_id: str
|
stripe_customer_id: str
|
||||||
|
store_id: int
|
||||||
|
store_code: str
|
||||||
|
|
||||||
|
|
||||||
|
class CreateStoreRequest(BaseModel):
|
||||||
|
"""Create store for the merchant."""
|
||||||
|
|
||||||
|
session_id: str
|
||||||
|
store_name: str | None = None
|
||||||
|
language: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreateStoreResponse(BaseModel):
|
||||||
|
"""Response from store creation."""
|
||||||
|
|
||||||
|
session_id: str
|
||||||
|
store_id: int
|
||||||
|
store_code: str
|
||||||
|
|
||||||
|
|
||||||
class SetupPaymentRequest(BaseModel):
|
class SetupPaymentRequest(BaseModel):
|
||||||
@@ -127,43 +133,21 @@ async def start_signup(request: SignupStartRequest) -> SignupStartResponse:
|
|||||||
"""
|
"""
|
||||||
Start the signup process.
|
Start the signup process.
|
||||||
|
|
||||||
Step 1: User selects a tier and billing period.
|
Step 1: User selects a tier, billing period, and platform.
|
||||||
Creates a signup session to track the flow.
|
Creates a signup session to track the flow.
|
||||||
"""
|
"""
|
||||||
session_id = platform_signup_service.create_session(
|
session_id = signup_service.create_session(
|
||||||
tier_code=request.tier_code,
|
tier_code=request.tier_code,
|
||||||
is_annual=request.is_annual,
|
is_annual=request.is_annual,
|
||||||
|
platform_code=request.platform_code,
|
||||||
|
language=request.language,
|
||||||
)
|
)
|
||||||
|
|
||||||
return SignupStartResponse(
|
return SignupStartResponse(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
tier_code=request.tier_code,
|
tier_code=request.tier_code,
|
||||||
is_annual=request.is_annual,
|
is_annual=request.is_annual,
|
||||||
)
|
platform_code=request.platform_code,
|
||||||
|
|
||||||
|
|
||||||
@router.post("/signup/claim-store", response_model=ClaimStoreResponse) # public
|
|
||||||
async def claim_letzshop_store(
|
|
||||||
request: ClaimStoreRequest,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> ClaimStoreResponse:
|
|
||||||
"""
|
|
||||||
Claim a Letzshop store.
|
|
||||||
|
|
||||||
Step 2 (optional): User claims their Letzshop shop.
|
|
||||||
This pre-fills store info during account creation.
|
|
||||||
"""
|
|
||||||
store_name = platform_signup_service.claim_store(
|
|
||||||
db=db,
|
|
||||||
session_id=request.session_id,
|
|
||||||
letzshop_slug=request.letzshop_slug,
|
|
||||||
letzshop_store_id=request.letzshop_store_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return ClaimStoreResponse(
|
|
||||||
session_id=request.session_id,
|
|
||||||
letzshop_slug=request.letzshop_slug,
|
|
||||||
store_name=store_name,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -173,12 +157,13 @@ async def create_account(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> CreateAccountResponse:
|
) -> CreateAccountResponse:
|
||||||
"""
|
"""
|
||||||
Create user and store accounts.
|
Create user and merchant accounts.
|
||||||
|
|
||||||
Step 3: User provides account details.
|
Step 2: User provides account details.
|
||||||
Creates User, Merchant, Store, and Stripe Customer.
|
Creates User, Merchant, and Stripe Customer.
|
||||||
|
Store creation is a separate step.
|
||||||
"""
|
"""
|
||||||
result = platform_signup_service.create_account(
|
result = signup_service.create_account(
|
||||||
db=db,
|
db=db,
|
||||||
session_id=request.session_id,
|
session_id=request.session_id,
|
||||||
email=request.email,
|
email=request.email,
|
||||||
@@ -192,8 +177,35 @@ async def create_account(
|
|||||||
return CreateAccountResponse(
|
return CreateAccountResponse(
|
||||||
session_id=request.session_id,
|
session_id=request.session_id,
|
||||||
user_id=result.user_id,
|
user_id=result.user_id,
|
||||||
store_id=result.store_id,
|
merchant_id=result.merchant_id,
|
||||||
stripe_customer_id=result.stripe_customer_id,
|
stripe_customer_id=result.stripe_customer_id,
|
||||||
|
store_id=result.store_id,
|
||||||
|
store_code=result.store_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/signup/create-store", response_model=CreateStoreResponse) # public
|
||||||
|
async def create_store(
|
||||||
|
request: CreateStoreRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> CreateStoreResponse:
|
||||||
|
"""
|
||||||
|
Create the first store for the merchant.
|
||||||
|
|
||||||
|
Step 3: User names their store (defaults to merchant name).
|
||||||
|
Creates Store, StorePlatform, and MerchantSubscription.
|
||||||
|
"""
|
||||||
|
result = signup_service.create_store(
|
||||||
|
db=db,
|
||||||
|
session_id=request.session_id,
|
||||||
|
store_name=request.store_name,
|
||||||
|
language=request.language,
|
||||||
|
)
|
||||||
|
|
||||||
|
return CreateStoreResponse(
|
||||||
|
session_id=request.session_id,
|
||||||
|
store_id=result.store_id,
|
||||||
|
store_code=result.store_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -205,7 +217,7 @@ async def setup_payment(request: SetupPaymentRequest) -> SetupPaymentResponse:
|
|||||||
Step 4: Collect card details without charging.
|
Step 4: Collect card details without charging.
|
||||||
The card will be charged after the trial period ends.
|
The card will be charged after the trial period ends.
|
||||||
"""
|
"""
|
||||||
client_secret, stripe_customer_id = platform_signup_service.setup_payment(
|
client_secret, stripe_customer_id = signup_service.setup_payment(
|
||||||
session_id=request.session_id,
|
session_id=request.session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -228,7 +240,7 @@ async def complete_signup(
|
|||||||
Step 5: Verify SetupIntent, attach payment method, create subscription.
|
Step 5: Verify SetupIntent, attach payment method, create subscription.
|
||||||
Also sets HTTP-only cookie for page navigation and returns token for localStorage.
|
Also sets HTTP-only cookie for page navigation and returns token for localStorage.
|
||||||
"""
|
"""
|
||||||
result = platform_signup_service.complete_signup(
|
result = signup_service.complete_signup(
|
||||||
db=db,
|
db=db,
|
||||||
session_id=request.session_id,
|
session_id=request.session_id,
|
||||||
setup_intent_id=request.setup_intent_id,
|
setup_intent_id=request.setup_intent_id,
|
||||||
@@ -265,7 +277,7 @@ async def get_signup_session(session_id: str) -> dict:
|
|||||||
|
|
||||||
Useful for resuming an incomplete signup.
|
Useful for resuming an incomplete signup.
|
||||||
"""
|
"""
|
||||||
session = platform_signup_service.get_session_or_raise(session_id)
|
session = signup_service.get_session_or_raise(session_id)
|
||||||
|
|
||||||
# Return safe subset of session data
|
# Return safe subset of session data
|
||||||
return {
|
return {
|
||||||
|
|||||||
60
app/core/build_info.py
Normal file
60
app/core/build_info.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# app/core/build_info.py
|
||||||
|
"""
|
||||||
|
Build information utilities.
|
||||||
|
|
||||||
|
Reads commit SHA and deploy timestamp from .build-info file
|
||||||
|
(written by scripts/deploy.sh at deploy time), or falls back
|
||||||
|
to git for local development.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BUILD_INFO_FILE = Path(__file__).resolve().parent.parent.parent / ".build-info"
|
||||||
|
_cached_info: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_build_info() -> dict:
|
||||||
|
"""Return build info: commit, deployed_at, environment."""
|
||||||
|
global _cached_info
|
||||||
|
if _cached_info is not None:
|
||||||
|
return _cached_info
|
||||||
|
|
||||||
|
info = {
|
||||||
|
"commit": None,
|
||||||
|
"deployed_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try .build-info file first (written by deploy.sh)
|
||||||
|
if _BUILD_INFO_FILE.is_file():
|
||||||
|
try:
|
||||||
|
data = json.loads(_BUILD_INFO_FILE.read_text())
|
||||||
|
info["commit"] = data.get("commit")
|
||||||
|
info["deployed_at"] = data.get("deployed_at")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to read .build-info: {e}")
|
||||||
|
|
||||||
|
# Fall back to git for local development
|
||||||
|
if not info["commit"]:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "rev-parse", "--short=8", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
info["commit"] = result.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not info["deployed_at"]:
|
||||||
|
info["deployed_at"] = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
_cached_info = info
|
||||||
|
return info
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# app/core/celery_config.py
|
# app/core/celery_config.py
|
||||||
"""
|
"""
|
||||||
Celery configuration for Wizamart background task processing.
|
Celery configuration for Orion background task processing.
|
||||||
|
|
||||||
This module configures Celery with Redis as the broker and result backend.
|
This module configures Celery with Redis as the broker and result backend.
|
||||||
It includes:
|
It includes:
|
||||||
@@ -9,13 +9,6 @@ It includes:
|
|||||||
- Task retry policies
|
- Task retry policies
|
||||||
- Sentry integration for error tracking
|
- Sentry integration for error tracking
|
||||||
- Module-based task discovery (discovers tasks from app/modules/*/tasks/)
|
- Module-based task discovery (discovers tasks from app/modules/*/tasks/)
|
||||||
|
|
||||||
Task Discovery:
|
|
||||||
- Legacy tasks: Explicitly listed in the 'include' parameter
|
|
||||||
- Module tasks: Auto-discovered via discover_module_tasks()
|
|
||||||
|
|
||||||
As modules are migrated, their tasks will move from the legacy include list
|
|
||||||
to automatic discovery from the module's tasks/ directory.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -48,43 +41,32 @@ if SENTRY_DSN:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# TASK DISCOVERY
|
# TASK DISCOVERY
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Legacy tasks (will be migrated to modules over time)
|
|
||||||
# MIGRATION STATUS:
|
|
||||||
# - subscription: MIGRATED to billing module (kept for capture_capacity_snapshot -> monitoring)
|
|
||||||
# - marketplace, letzshop, export: MIGRATED to marketplace module
|
|
||||||
# - code_quality, test_runner: Will migrate to dev-tools module
|
|
||||||
LEGACY_TASK_MODULES: list[str] = [
|
|
||||||
# All legacy tasks have been migrated to their respective modules.
|
|
||||||
# Task discovery now happens via app.modules.tasks.discover_module_tasks()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def get_all_task_modules() -> list[str]:
|
def get_all_task_modules() -> list[str]:
|
||||||
"""
|
"""
|
||||||
Get all task modules (legacy + module-based).
|
Get all task modules via module-based discovery.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Combined list of legacy task modules and discovered module tasks
|
List of discovered module task packages
|
||||||
"""
|
"""
|
||||||
all_modules = list(LEGACY_TASK_MODULES)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from app.modules.tasks import discover_module_tasks
|
from app.modules.tasks import discover_module_tasks
|
||||||
|
|
||||||
module_tasks = discover_module_tasks()
|
module_tasks = discover_module_tasks()
|
||||||
all_modules.extend(module_tasks)
|
|
||||||
logger.info(f"Discovered {len(module_tasks)} module task packages")
|
logger.info(f"Discovered {len(module_tasks)} module task packages")
|
||||||
|
return module_tasks
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.warning(f"Could not import module task discovery: {e}")
|
logger.warning(f"Could not import module task discovery: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error discovering module tasks: {e}")
|
logger.error(f"Error discovering module tasks: {e}")
|
||||||
|
|
||||||
return all_modules
|
return []
|
||||||
|
|
||||||
|
|
||||||
# Create Celery application
|
# Create Celery application
|
||||||
celery_app = Celery(
|
celery_app = Celery(
|
||||||
"wizamart",
|
"orion",
|
||||||
broker=REDIS_URL,
|
broker=REDIS_URL,
|
||||||
backend=REDIS_URL,
|
backend=REDIS_URL,
|
||||||
include=get_all_task_modules(),
|
include=get_all_task_modules(),
|
||||||
@@ -109,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.
|
||||||
@@ -27,7 +27,7 @@ class Settings(BaseSettings):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# PROJECT INFORMATION
|
# PROJECT INFORMATION
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
project_name: str = "Wizamart - Multi-Store Marketplace Platform"
|
project_name: str = "Orion - Multi-Store Marketplace Platform"
|
||||||
version: str = "2.2.0"
|
version: str = "2.2.0"
|
||||||
|
|
||||||
# Clean description without HTML
|
# Clean description without HTML
|
||||||
@@ -47,12 +47,12 @@ class Settings(BaseSettings):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# DATABASE (PostgreSQL only)
|
# DATABASE (PostgreSQL only)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
database_url: str = "postgresql://wizamart_user:secure_password@localhost:5432/wizamart_db"
|
database_url: str = "postgresql://orion_user:secure_password@localhost:5432/orion_db"
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# ADMIN INITIALIZATION (for init_production.py)
|
# ADMIN INITIALIZATION (for init_production.py)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
admin_email: str = "admin@wizamart.com"
|
admin_email: str = "admin@orion.lu"
|
||||||
admin_username: str = "admin"
|
admin_username: str = "admin"
|
||||||
admin_password: str = "admin123" # CHANGE IN PRODUCTION!
|
admin_password: str = "admin123" # CHANGE IN PRODUCTION!
|
||||||
admin_first_name: str = "Platform"
|
admin_first_name: str = "Platform"
|
||||||
@@ -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 = "wizamart.com"
|
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
|
||||||
@@ -107,7 +112,7 @@ class Settings(BaseSettings):
|
|||||||
auto_provision_ssl: bool = False
|
auto_provision_ssl: bool = False
|
||||||
|
|
||||||
# DNS verification
|
# DNS verification
|
||||||
dns_verification_prefix: str = "_wizamart-verify"
|
dns_verification_prefix: str = "_orion-verify"
|
||||||
dns_verification_ttl: int = 3600
|
dns_verification_ttl: int = 3600
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -130,8 +135,8 @@ class Settings(BaseSettings):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Provider: smtp, sendgrid, mailgun, ses
|
# Provider: smtp, sendgrid, mailgun, ses
|
||||||
email_provider: str = "smtp"
|
email_provider: str = "smtp"
|
||||||
email_from_address: str = "noreply@wizamart.com"
|
email_from_address: str = "noreply@orion.lu"
|
||||||
email_from_name: str = "Wizamart"
|
email_from_name: str = "Orion"
|
||||||
email_reply_to: str = "" # Optional reply-to address
|
email_reply_to: str = "" # Optional reply-to address
|
||||||
|
|
||||||
# SMTP Settings (used when email_provider=smtp)
|
# SMTP Settings (used when email_provider=smtp)
|
||||||
@@ -194,6 +199,14 @@ class Settings(BaseSettings):
|
|||||||
sentry_environment: str = "development" # development, staging, production
|
sentry_environment: str = "development" # development, staging, production
|
||||||
sentry_traces_sample_rate: float = 0.1 # 10% of transactions for performance monitoring
|
sentry_traces_sample_rate: float = 0.1 # 10% of transactions for performance monitoring
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MONITORING
|
||||||
|
# =============================================================================
|
||||||
|
enable_metrics: bool = False
|
||||||
|
grafana_url: str = "https://grafana.wizard.lu"
|
||||||
|
grafana_admin_user: str = "admin"
|
||||||
|
grafana_admin_password: str = ""
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# CLOUDFLARE R2 STORAGE
|
# CLOUDFLARE R2 STORAGE
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -201,7 +214,7 @@ class Settings(BaseSettings):
|
|||||||
r2_account_id: str | None = None
|
r2_account_id: str | None = None
|
||||||
r2_access_key_id: str | None = None
|
r2_access_key_id: str | None = None
|
||||||
r2_secret_access_key: str | None = None
|
r2_secret_access_key: str | None = None
|
||||||
r2_bucket_name: str = "wizamart-media"
|
r2_bucket_name: str = "orion-media"
|
||||||
r2_public_url: str | None = None # Custom domain for public access (e.g., https://media.yoursite.com)
|
r2_public_url: str | None = None # Custom domain for public access (e.g., https://media.yoursite.com)
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -209,7 +222,16 @@ 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
|
||||||
|
|
||||||
model_config = {"env_file": ".env"}
|
# =============================================================================
|
||||||
|
# APPLE WALLET (LOYALTY MODULE)
|
||||||
|
# =============================================================================
|
||||||
|
loyalty_apple_pass_type_id: str | None = None
|
||||||
|
loyalty_apple_team_id: str | None = None
|
||||||
|
loyalty_apple_wwdr_cert_path: str | None = None
|
||||||
|
loyalty_apple_signer_cert_path: str | None = None
|
||||||
|
loyalty_apple_signer_key_path: str | None = None
|
||||||
|
|
||||||
|
model_config = {"env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
# Singleton settings instance
|
# Singleton settings instance
|
||||||
@@ -328,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.
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ Single source of truth for detecting which frontend type a request targets.
|
|||||||
Handles both development (path-based) and production (domain-based) routing.
|
Handles both development (path-based) and production (domain-based) routing.
|
||||||
|
|
||||||
Detection priority:
|
Detection priority:
|
||||||
1. Admin subdomain (admin.oms.lu)
|
1. Admin subdomain (admin.omsflow.lu)
|
||||||
2. Path-based admin/store (/admin/*, /store/*, /api/v1/admin/*)
|
2. Path-based admin/store (/admin/*, /store/*, /api/v1/admin/*)
|
||||||
3. Custom domain lookup (mybakery.lu -> STOREFRONT)
|
3. Custom domain lookup (mybakery.lu -> STOREFRONT)
|
||||||
4. Store subdomain (wizamart.oms.lu -> STOREFRONT)
|
4. Store subdomain (orion.omsflow.lu -> STOREFRONT)
|
||||||
5. Storefront paths (/storefront/*, /api/v1/storefront/*)
|
5. Storefront paths (/storefront/*, /api/v1/storefront/*)
|
||||||
6. Default to PLATFORM (marketing pages)
|
6. Default to PLATFORM (marketing pages)
|
||||||
|
|
||||||
@@ -46,9 +46,6 @@ class FrontendDetector:
|
|||||||
STOREFRONT_PATH_PREFIXES = (
|
STOREFRONT_PATH_PREFIXES = (
|
||||||
"/storefront",
|
"/storefront",
|
||||||
"/api/v1/storefront",
|
"/api/v1/storefront",
|
||||||
"/shop", # Legacy support
|
|
||||||
"/api/v1/shop", # Legacy support
|
|
||||||
"/stores/", # Path-based store access
|
|
||||||
)
|
)
|
||||||
MERCHANT_PATH_PREFIXES = ("/merchants", "/api/v1/merchants")
|
MERCHANT_PATH_PREFIXES = ("/merchants", "/api/v1/merchants")
|
||||||
PLATFORM_PATH_PREFIXES = ("/api/v1/platform",)
|
PLATFORM_PATH_PREFIXES = ("/api/v1/platform",)
|
||||||
@@ -64,7 +61,7 @@ class FrontendDetector:
|
|||||||
Detect frontend type from request.
|
Detect frontend type from request.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
host: Request host header (e.g., "oms.lu", "wizamart.oms.lu", "localhost:8000")
|
host: Request host header (e.g., "omsflow.lu", "orion.omsflow.lu", "localhost:8000")
|
||||||
path: Request path (e.g., "/admin/stores", "/storefront/products")
|
path: Request path (e.g., "/admin/stores", "/storefront/products")
|
||||||
has_store_context: True if request.state.store is set (from middleware)
|
has_store_context: True if request.state.store is set (from middleware)
|
||||||
|
|
||||||
@@ -84,7 +81,7 @@ class FrontendDetector:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 1. Admin subdomain (admin.oms.lu)
|
# 1. Admin subdomain (admin.omsflow.lu)
|
||||||
if subdomain == "admin":
|
if subdomain == "admin":
|
||||||
logger.debug("[FRONTEND_DETECTOR] Detected ADMIN from subdomain")
|
logger.debug("[FRONTEND_DETECTOR] Detected ADMIN from subdomain")
|
||||||
return FrontendType.ADMIN
|
return FrontendType.ADMIN
|
||||||
@@ -112,8 +109,8 @@ class FrontendDetector:
|
|||||||
logger.debug("[FRONTEND_DETECTOR] Detected PLATFORM from path")
|
logger.debug("[FRONTEND_DETECTOR] Detected PLATFORM from path")
|
||||||
return FrontendType.PLATFORM
|
return FrontendType.PLATFORM
|
||||||
|
|
||||||
# 3. Store subdomain detection (wizamart.oms.lu)
|
# 3. Store subdomain detection (orion.omsflow.lu)
|
||||||
# If subdomain exists and is not reserved -> it's a store shop
|
# If subdomain exists and is not reserved -> it's a store storefront
|
||||||
if subdomain and subdomain not in cls.RESERVED_SUBDOMAINS:
|
if subdomain and subdomain not in cls.RESERVED_SUBDOMAINS:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[FRONTEND_DETECTOR] Detected STOREFRONT from subdomain: {subdomain}"
|
f"[FRONTEND_DETECTOR] Detected STOREFRONT from subdomain: {subdomain}"
|
||||||
@@ -140,7 +137,7 @@ class FrontendDetector:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def _get_subdomain(cls, host: str) -> str | None:
|
def _get_subdomain(cls, host: str) -> str | None:
|
||||||
"""
|
"""
|
||||||
Extract subdomain from host (e.g., 'wizamart' from 'wizamart.oms.lu').
|
Extract subdomain from host (e.g., 'orion' from 'orion.omsflow.lu').
|
||||||
|
|
||||||
Returns None for localhost, IP addresses, or root domains.
|
Returns None for localhost, IP addresses, or root domains.
|
||||||
Handles special case of admin.localhost for development.
|
Handles special case of admin.localhost for development.
|
||||||
@@ -197,13 +194,3 @@ class FrontendDetector:
|
|||||||
def is_api_request(cls, path: str) -> bool:
|
def is_api_request(cls, path: str) -> bool:
|
||||||
"""Check if request is for API endpoints (any frontend's API)."""
|
"""Check if request is for API endpoints (any frontend's API)."""
|
||||||
return path.startswith("/api/")
|
return path.startswith("/api/")
|
||||||
|
|
||||||
|
|
||||||
# Convenience function for backwards compatibility
|
|
||||||
def get_frontend_type(host: str, path: str, has_store_context: bool = False) -> FrontendType:
|
|
||||||
"""
|
|
||||||
Convenience function to detect frontend type.
|
|
||||||
|
|
||||||
Wrapper around FrontendDetector.detect() for simpler imports.
|
|
||||||
"""
|
|
||||||
return FrontendDetector.detect(host, path, has_store_context)
|
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ from sqlalchemy import text
|
|||||||
|
|
||||||
from middleware.auth import AuthManager
|
from middleware.auth import AuthManager
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
from .database import engine
|
from .database import engine
|
||||||
from .logging import setup_logging
|
from .logging import setup_logging
|
||||||
|
from .observability import init_observability, shutdown_observability
|
||||||
|
|
||||||
# Remove this import if not needed: from models.database.base import Base
|
# Remove this import if not needed: from models.database.base import Base
|
||||||
|
|
||||||
@@ -32,14 +34,92 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
# === STARTUP ===
|
# === STARTUP ===
|
||||||
app_logger = setup_logging()
|
app_logger = setup_logging()
|
||||||
app_logger.info("Starting Wizamart multi-tenant platform")
|
app_logger.info("Starting Orion multi-tenant platform")
|
||||||
|
|
||||||
|
init_observability(
|
||||||
|
enable_metrics=settings.enable_metrics,
|
||||||
|
sentry_dsn=settings.sentry_dsn,
|
||||||
|
environment=settings.sentry_environment,
|
||||||
|
flower_url=settings.flower_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
|
||||||
|
|
||||||
# === SHUTDOWN ===
|
# === SHUTDOWN ===
|
||||||
app_logger.info("Shutting down Wizamart platform")
|
app_logger.info("Shutting down Orion platform")
|
||||||
# Add cleanup tasks here if needed
|
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 ===
|
||||||
|
|||||||
@@ -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__)
|
||||||
|
|
||||||
@@ -515,17 +516,6 @@ external_tools = ExternalToolConfig()
|
|||||||
health_router = APIRouter(tags=["Health"])
|
health_router = APIRouter(tags=["Health"])
|
||||||
|
|
||||||
|
|
||||||
@health_router.get("/health")
|
|
||||||
async def health_check() -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Aggregated health check endpoint.
|
|
||||||
|
|
||||||
Returns combined health status from all registered checks.
|
|
||||||
"""
|
|
||||||
result = health_registry.run_all()
|
|
||||||
return result.to_dict()
|
|
||||||
|
|
||||||
|
|
||||||
@health_router.get("/health/live")
|
@health_router.get("/health/live")
|
||||||
async def liveness_check() -> dict[str, str]:
|
async def liveness_check() -> dict[str, str]:
|
||||||
"""
|
"""
|
||||||
@@ -542,21 +532,27 @@ async def readiness_check() -> dict[str, Any]:
|
|||||||
Kubernetes readiness probe endpoint.
|
Kubernetes readiness probe endpoint.
|
||||||
|
|
||||||
Returns 200 if the application is ready to serve traffic.
|
Returns 200 if the application is ready to serve traffic.
|
||||||
|
Includes individual check details with name, status, and latency.
|
||||||
"""
|
"""
|
||||||
result = health_registry.run_all()
|
result = health_registry.run_all()
|
||||||
return {
|
return result.to_dict()
|
||||||
"status": "ready" if result.status != HealthStatus.UNHEALTHY else "not_ready",
|
|
||||||
"health": result.status.value,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@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,
|
||||||
@@ -579,6 +575,44 @@ async def external_tools_endpoint() -> dict[str, str | None]:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _register_infrastructure_health_checks() -> None:
|
||||||
|
"""Register health checks for core infrastructure (PostgreSQL, Redis)."""
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
@health_registry.register("database")
|
||||||
|
def check_database() -> HealthCheckResult:
|
||||||
|
try:
|
||||||
|
from .database import engine
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
conn.execute(text("SELECT 1"))
|
||||||
|
return HealthCheckResult(name="database", status=HealthStatus.HEALTHY)
|
||||||
|
except Exception as e:
|
||||||
|
return HealthCheckResult(
|
||||||
|
name="database",
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
@health_registry.register("redis")
|
||||||
|
def check_redis() -> HealthCheckResult:
|
||||||
|
try:
|
||||||
|
import redis
|
||||||
|
|
||||||
|
r = redis.from_url(settings.redis_url, socket_connect_timeout=2)
|
||||||
|
r.ping()
|
||||||
|
r.close()
|
||||||
|
return HealthCheckResult(name="redis", status=HealthStatus.HEALTHY)
|
||||||
|
except Exception as e:
|
||||||
|
return HealthCheckResult(
|
||||||
|
name="redis",
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def init_observability(
|
def init_observability(
|
||||||
enable_metrics: bool = False,
|
enable_metrics: bool = False,
|
||||||
sentry_dsn: str | None = None,
|
sentry_dsn: str | None = None,
|
||||||
@@ -598,6 +632,9 @@ def init_observability(
|
|||||||
"""
|
"""
|
||||||
logger.info("Initializing observability stack...")
|
logger.info("Initializing observability stack...")
|
||||||
|
|
||||||
|
# Register infrastructure health checks
|
||||||
|
_register_infrastructure_health_checks()
|
||||||
|
|
||||||
# Enable metrics if requested
|
# Enable metrics if requested
|
||||||
if enable_metrics:
|
if enable_metrics:
|
||||||
metrics_registry.enable()
|
metrics_registry.enable()
|
||||||
|
|||||||
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
|
||||||
@@ -33,16 +33,16 @@ from .base import (
|
|||||||
BusinessLogicException,
|
BusinessLogicException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
ExternalServiceException,
|
ExternalServiceException,
|
||||||
|
OrionException,
|
||||||
RateLimitException,
|
RateLimitException,
|
||||||
ResourceNotFoundException,
|
ResourceNotFoundException,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
ValidationException,
|
ValidationException,
|
||||||
WizamartException,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Base exception class
|
# Base exception class
|
||||||
"WizamartException",
|
"OrionException",
|
||||||
# Validation and business logic
|
# Validation and business logic
|
||||||
"ValidationException",
|
"ValidationException",
|
||||||
"BusinessLogicException",
|
"BusinessLogicException",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ This module provides classes and functions for:
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
class WizamartException(Exception):
|
class OrionException(Exception):
|
||||||
"""Base exception class for all custom exceptions."""
|
"""Base exception class for all custom exceptions."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -39,7 +39,7 @@ class WizamartException(Exception):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
class ValidationException(WizamartException):
|
class ValidationException(OrionException):
|
||||||
"""Raised when request validation fails."""
|
"""Raised when request validation fails."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -60,7 +60,7 @@ class ValidationException(WizamartException):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class AuthenticationException(WizamartException):
|
class AuthenticationException(OrionException):
|
||||||
"""Raised when authentication fails."""
|
"""Raised when authentication fails."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -77,7 +77,7 @@ class AuthenticationException(WizamartException):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class AuthorizationException(WizamartException):
|
class AuthorizationException(OrionException):
|
||||||
"""Raised when user lacks permission for an operation."""
|
"""Raised when user lacks permission for an operation."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -94,7 +94,7 @@ class AuthorizationException(WizamartException):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ResourceNotFoundException(WizamartException):
|
class ResourceNotFoundException(OrionException):
|
||||||
"""Raised when a requested resource is not found."""
|
"""Raised when a requested resource is not found."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -120,7 +120,7 @@ class ResourceNotFoundException(WizamartException):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ConflictException(WizamartException):
|
class ConflictException(OrionException):
|
||||||
"""Raised when a resource conflict occurs."""
|
"""Raised when a resource conflict occurs."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -137,7 +137,7 @@ class ConflictException(WizamartException):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class BusinessLogicException(WizamartException):
|
class BusinessLogicException(OrionException):
|
||||||
"""Raised when business logic rules are violated."""
|
"""Raised when business logic rules are violated."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -154,7 +154,7 @@ class BusinessLogicException(WizamartException):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ExternalServiceException(WizamartException):
|
class ExternalServiceException(OrionException):
|
||||||
"""Raised when an external service fails."""
|
"""Raised when an external service fails."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -175,7 +175,7 @@ class ExternalServiceException(WizamartException):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class RateLimitException(WizamartException):
|
class RateLimitException(OrionException):
|
||||||
"""Raised when rate limit is exceeded."""
|
"""Raised when rate limit is exceeded."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -196,7 +196,7 @@ class RateLimitException(WizamartException):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ServiceUnavailableException(WizamartException):
|
class ServiceUnavailableException(OrionException):
|
||||||
"""Raised when service is unavailable."""
|
"""Raised when service is unavailable."""
|
||||||
|
|
||||||
def __init__(self, message: str = "Service temporarily unavailable"):
|
def __init__(self, message: str = "Service temporarily unavailable"):
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from fastapi.responses import JSONResponse, RedirectResponse
|
|||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
from middleware.frontend_type import get_frontend_type
|
from middleware.frontend_type import get_frontend_type
|
||||||
|
|
||||||
from .base import WizamartException
|
from .base import OrionException
|
||||||
from .error_renderer import ErrorPageRenderer
|
from .error_renderer import ErrorPageRenderer
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -28,8 +28,8 @@ logger = logging.getLogger(__name__)
|
|||||||
def setup_exception_handlers(app):
|
def setup_exception_handlers(app):
|
||||||
"""Setup exception handlers for the FastAPI app."""
|
"""Setup exception handlers for the FastAPI app."""
|
||||||
|
|
||||||
@app.exception_handler(WizamartException)
|
@app.exception_handler(OrionException)
|
||||||
async def custom_exception_handler(request: Request, exc: WizamartException):
|
async def custom_exception_handler(request: Request, exc: OrionException):
|
||||||
"""Handle custom exceptions with context-aware rendering."""
|
"""Handle custom exceptions with context-aware rendering."""
|
||||||
|
|
||||||
# Special handling for auth errors on HTML page requests (redirect to login)
|
# Special handling for auth errors on HTML page requests (redirect to login)
|
||||||
@@ -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")
|
||||||
@@ -434,14 +434,19 @@ def _redirect_to_login(request: Request) -> RedirectResponse:
|
|||||||
|
|
||||||
base_url = "/"
|
base_url = "/"
|
||||||
if access_method == "path" and store:
|
if access_method == "path" and store:
|
||||||
full_prefix = (
|
platform = getattr(request.state, "platform", None)
|
||||||
store_context.get("full_prefix", "/store/")
|
platform_original_path = getattr(request.state, "platform_original_path", None)
|
||||||
if store_context
|
if platform and platform_original_path and platform_original_path.startswith("/platforms/"):
|
||||||
else "/store/"
|
base_url = f"/platforms/{platform.code}/storefront/{store.store_code}/"
|
||||||
)
|
else:
|
||||||
base_url = f"{full_prefix}{store.subdomain}/"
|
full_prefix = (
|
||||||
|
store_context.get("full_prefix", "/storefront/")
|
||||||
|
if store_context
|
||||||
|
else "/storefront/"
|
||||||
|
)
|
||||||
|
base_url = f"{full_prefix}{store.store_code}/"
|
||||||
|
|
||||||
login_url = f"{base_url}storefront/account/login"
|
login_url = f"{base_url}account/login"
|
||||||
logger.debug(f"Redirecting to {login_url}")
|
logger.debug(f"Redirecting to {login_url}")
|
||||||
return RedirectResponse(url=login_url, status_code=302)
|
return RedirectResponse(url=login_url, status_code=302)
|
||||||
# Fallback to root for unknown contexts (PLATFORM)
|
# Fallback to root for unknown contexts (PLATFORM)
|
||||||
|
|||||||
@@ -380,6 +380,24 @@ class StripeWebhookHandler:
|
|||||||
f"Tier changed to {tier.code} for merchant {subscription.merchant_id}"
|
f"Tier changed to {tier.code} for merchant {subscription.merchant_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Sync store_platforms based on subscription status
|
||||||
|
from app.modules.billing.services.store_platform_sync_service import (
|
||||||
|
store_platform_sync,
|
||||||
|
)
|
||||||
|
|
||||||
|
active_statuses = {
|
||||||
|
SubscriptionStatus.TRIAL.value,
|
||||||
|
SubscriptionStatus.ACTIVE.value,
|
||||||
|
SubscriptionStatus.PAST_DUE.value,
|
||||||
|
SubscriptionStatus.CANCELLED.value,
|
||||||
|
}
|
||||||
|
store_platform_sync.sync_store_platforms_for_merchant(
|
||||||
|
db,
|
||||||
|
subscription.merchant_id,
|
||||||
|
subscription.platform_id,
|
||||||
|
is_active=subscription.status in active_statuses,
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"Subscription updated for merchant {subscription.merchant_id}")
|
logger.info(f"Subscription updated for merchant {subscription.merchant_id}")
|
||||||
return {"action": "updated", "merchant_id": subscription.merchant_id}
|
return {"action": "updated", "merchant_id": subscription.merchant_id}
|
||||||
|
|
||||||
@@ -435,6 +453,15 @@ class StripeWebhookHandler:
|
|||||||
if addon_count > 0:
|
if addon_count > 0:
|
||||||
logger.info(f"Cancelled {addon_count} add-ons for merchant {merchant_id}")
|
logger.info(f"Cancelled {addon_count} add-ons for merchant {merchant_id}")
|
||||||
|
|
||||||
|
# Deactivate store_platforms for the deleted subscription's platform
|
||||||
|
from app.modules.billing.services.store_platform_sync_service import (
|
||||||
|
store_platform_sync,
|
||||||
|
)
|
||||||
|
|
||||||
|
store_platform_sync.sync_store_platforms_for_merchant(
|
||||||
|
db, merchant_id, subscription.platform_id, is_active=False
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"Subscription deleted for merchant {merchant_id}")
|
logger.info(f"Subscription deleted for merchant {merchant_id}")
|
||||||
return {
|
return {
|
||||||
"action": "cancelled",
|
"action": "cancelled",
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ from app.modules.registry import (
|
|||||||
is_internal_module,
|
is_internal_module,
|
||||||
)
|
)
|
||||||
from app.modules.service import ModuleService, module_service
|
from app.modules.service import ModuleService, module_service
|
||||||
from app.modules.task_base import DatabaseTask, ModuleTask
|
from app.modules.task_base import ModuleTask
|
||||||
from app.modules.tasks import (
|
from app.modules.tasks import (
|
||||||
build_beat_schedule,
|
build_beat_schedule,
|
||||||
discover_module_tasks,
|
discover_module_tasks,
|
||||||
@@ -87,7 +87,6 @@ __all__ = [
|
|||||||
"ScheduledTask",
|
"ScheduledTask",
|
||||||
# Task support
|
# Task support
|
||||||
"ModuleTask",
|
"ModuleTask",
|
||||||
"DatabaseTask",
|
|
||||||
"discover_module_tasks",
|
"discover_module_tasks",
|
||||||
"build_beat_schedule",
|
"build_beat_schedule",
|
||||||
"parse_schedule",
|
"parse_schedule",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
|
|||||||
# api_timeout: int = 30
|
# api_timeout: int = 30
|
||||||
# batch_size: int = 100
|
# batch_size: int = 100
|
||||||
|
|
||||||
model_config = {"env_prefix": "ANALYTICS_"}
|
model_config = {"env_prefix": "ANALYTICS_", "env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
# Export for auto-discovery
|
# Export for auto-discovery
|
||||||
|
|||||||
@@ -96,11 +96,13 @@ analytics_module = ModuleDefinition(
|
|||||||
icon="chart-bar",
|
icon="chart-bar",
|
||||||
route="/store/{store_code}/analytics",
|
route="/store/{store_code}/analytics",
|
||||||
order=20,
|
order=20,
|
||||||
|
requires_permission="analytics.view",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
requires=["catalog", "inventory", "marketplace", "orders"], # Imports from these modules
|
||||||
is_core=False,
|
is_core=False,
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Self-Contained Module Configuration
|
# Self-Contained Module Configuration
|
||||||
|
|||||||
42
app/modules/analytics/docs/index.md
Normal file
42
app/modules/analytics/docs/index.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Analytics & Reporting
|
||||||
|
|
||||||
|
Dashboard analytics, custom reports, and data exports.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| Code | `analytics` |
|
||||||
|
| Classification | Optional |
|
||||||
|
| Dependencies | `catalog`, `inventory`, `marketplace`, `orders` |
|
||||||
|
| Status | Active |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- `basic_reports` — Standard built-in reports
|
||||||
|
- `analytics_dashboard` — Analytics dashboard widgets
|
||||||
|
- `custom_reports` — Custom report builder
|
||||||
|
- `export_reports` — Report data export
|
||||||
|
- `usage_metrics` — Platform usage metrics
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
| Permission | Description |
|
||||||
|
|------------|-------------|
|
||||||
|
| `analytics.view` | View analytics and reports |
|
||||||
|
| `analytics.export` | Export report data |
|
||||||
|
| `analytics.manage_dashboards` | Create/edit custom dashboards |
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
Analytics primarily queries data from other modules (orders, inventory, catalog).
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `GET` | `/api/v1/store/analytics/*` | Store analytics data |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
No module-specific configuration.
|
||||||
@@ -11,7 +11,7 @@ from app.exceptions.base import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ReportGenerationException(BusinessLogicException):
|
class ReportGenerationException(BusinessLogicException): # noqa: MOD025
|
||||||
"""Raised when report generation fails."""
|
"""Raised when report generation fails."""
|
||||||
|
|
||||||
def __init__(self, report_type: str, reason: str):
|
def __init__(self, report_type: str, reason: str):
|
||||||
@@ -21,7 +21,7 @@ class ReportGenerationException(BusinessLogicException):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class InvalidDateRangeException(ValidationException):
|
class InvalidDateRangeException(ValidationException): # noqa: MOD025
|
||||||
"""Raised when an invalid date range is provided."""
|
"""Raised when an invalid date range is provided."""
|
||||||
|
|
||||||
def __init__(self, start_date: str, end_date: str):
|
def __init__(self, start_date: str, end_date: str):
|
||||||
|
|||||||
@@ -16,5 +16,13 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"analytics": "Analytik"
|
"analytics": "Analytik"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"view": "Analytik anzeigen",
|
||||||
|
"view_desc": "Zugriff auf Analytik-Dashboards und Berichte",
|
||||||
|
"export": "Analytik exportieren",
|
||||||
|
"export_desc": "Analytikdaten und Berichte exportieren",
|
||||||
|
"manage_dashboards": "Dashboards verwalten",
|
||||||
|
"manage_dashboards_desc": "Analytik-Dashboards erstellen und konfigurieren"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,14 @@
|
|||||||
"loading": "Loading analytics...",
|
"loading": "Loading analytics...",
|
||||||
"error_loading": "Failed to load analytics data"
|
"error_loading": "Failed to load analytics data"
|
||||||
},
|
},
|
||||||
|
"permissions": {
|
||||||
|
"view": "View Analytics",
|
||||||
|
"view_desc": "Access analytics dashboards and reports",
|
||||||
|
"export": "Export Analytics",
|
||||||
|
"export_desc": "Export analytics data and reports",
|
||||||
|
"manage_dashboards": "Manage Dashboards",
|
||||||
|
"manage_dashboards_desc": "Create and configure analytics dashboards"
|
||||||
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"analytics": "Analytics"
|
"analytics": "Analytics"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,5 +16,13 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"analytics": "Analytique"
|
"analytics": "Analytique"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"view": "Voir l'analytique",
|
||||||
|
"view_desc": "Accéder aux tableaux de bord et rapports analytiques",
|
||||||
|
"export": "Exporter l'analytique",
|
||||||
|
"export_desc": "Exporter les données et rapports analytiques",
|
||||||
|
"manage_dashboards": "Gérer les tableaux de bord",
|
||||||
|
"manage_dashboards_desc": "Créer et configurer les tableaux de bord analytiques"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,5 +16,13 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"analytics": "Analytik"
|
"analytics": "Analytik"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"view": "Analytik kucken",
|
||||||
|
"view_desc": "Zougang zu Analytik-Dashboards a Berichter",
|
||||||
|
"export": "Analytik exportéieren",
|
||||||
|
"export_desc": "Analytikdaten a Berichter exportéieren",
|
||||||
|
"manage_dashboards": "Dashboards verwalten",
|
||||||
|
"manage_dashboards_desc": "Analytik-Dashboards erstellen a konfiguréieren"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"analytics": {
|
|
||||||
"page_title": "Analysen",
|
|
||||||
"dashboard_title": "Analyse-Dashboard",
|
|
||||||
"dashboard_subtitle": "Kuckt Är Buttek Leeschtungsmetriken an Abléck",
|
|
||||||
"period_7d": "Lescht 7 Deeg",
|
|
||||||
"period_30d": "Lescht 30 Deeg",
|
|
||||||
"period_90d": "Lescht 90 Deeg",
|
|
||||||
"period_1y": "Lescht Joer",
|
|
||||||
"imports_count": "Importer",
|
|
||||||
"products_added": "Produkter bäigesat",
|
|
||||||
"inventory_locations": "Lagerplazen",
|
|
||||||
"data_since": "Donnéeë vun",
|
|
||||||
"loading": "Analysen ginn gelueden...",
|
|
||||||
"error_loading": "Analysedonnéeën konnten net geluede ginn"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,8 +7,8 @@ with module-based access control.
|
|||||||
|
|
||||||
NOTE: Routers are NOT auto-imported to avoid circular dependencies.
|
NOTE: Routers are NOT auto-imported to avoid circular dependencies.
|
||||||
Import directly from api/ or pages/ as needed:
|
Import directly from api/ or pages/ as needed:
|
||||||
from app.modules.analytics.routes.api import store_router as store_api_router
|
from app.modules.analytics.routes.api import store_router
|
||||||
from app.modules.analytics.routes.pages import store_router as store_page_router
|
from app.modules.analytics.routes.pages import store_page_router
|
||||||
|
|
||||||
Note: Analytics module has no admin routes - admin uses dashboard.
|
Note: Analytics module has no admin routes - admin uses dashboard.
|
||||||
"""
|
"""
|
||||||
@@ -25,6 +25,6 @@ def __getattr__(name: str):
|
|||||||
from app.modules.analytics.routes.api import store_router
|
from app.modules.analytics.routes.api import store_router
|
||||||
return store_router
|
return store_router
|
||||||
if name == "store_page_router":
|
if name == "store_page_router":
|
||||||
from app.modules.analytics.routes.pages import store_router
|
from app.modules.analytics.routes.pages import router
|
||||||
return store_router
|
return router
|
||||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ router = APIRouter(
|
|||||||
prefix="/analytics",
|
prefix="/analytics",
|
||||||
dependencies=[Depends(require_module_access("analytics", FrontendType.STORE))],
|
dependencies=[Depends(require_module_access("analytics", FrontendType.STORE))],
|
||||||
)
|
)
|
||||||
store_router = router # Alias for discovery
|
router = router # Alias for discovery
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ Store pages for analytics dashboard.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Path, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_store_from_cookie_or_header, get_db
|
from app.api.deps import (
|
||||||
|
get_db,
|
||||||
|
get_resolved_store_code,
|
||||||
|
require_store_page_permission,
|
||||||
|
)
|
||||||
from app.modules.core.services.platform_settings_service import (
|
from app.modules.core.services.platform_settings_service import (
|
||||||
platform_settings_service, # MOD-004 - shared platform service
|
platform_settings_service, # MOD-004 - shared platform service
|
||||||
)
|
)
|
||||||
@@ -73,12 +77,12 @@ def get_store_context(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/analytics", response_class=HTMLResponse, include_in_schema=False
|
"/analytics", response_class=HTMLResponse, include_in_schema=False
|
||||||
)
|
)
|
||||||
async def store_analytics_page(
|
async def store_analytics_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
store_code: str = Path(..., description="Store code"),
|
store_code: str = Depends(get_resolved_store_code),
|
||||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
current_user: User = Depends(require_store_page_permission("analytics.view")),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -2,51 +2,22 @@
|
|||||||
"""
|
"""
|
||||||
Analytics module Pydantic schemas.
|
Analytics module Pydantic schemas.
|
||||||
|
|
||||||
This is the canonical location for analytics schemas.
|
This is the canonical location for analytics-specific schemas.
|
||||||
|
For core dashboard schemas, import from app.modules.core.schemas.dashboard.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from app.modules.analytics.schemas.stats import (
|
from app.modules.analytics.schemas.stats import (
|
||||||
AdminDashboardResponse,
|
|
||||||
CodeQualityDashboardStatsResponse,
|
CodeQualityDashboardStatsResponse,
|
||||||
CustomerStatsResponse,
|
CustomerStatsResponse,
|
||||||
ImportStatsResponse,
|
|
||||||
MarketplaceStatsResponse,
|
|
||||||
OrderStatsBasicResponse,
|
|
||||||
OrderStatsResponse,
|
OrderStatsResponse,
|
||||||
PlatformStatsResponse,
|
|
||||||
ProductStatsResponse,
|
|
||||||
StatsResponse,
|
|
||||||
StoreAnalyticsCatalog,
|
StoreAnalyticsCatalog,
|
||||||
StoreAnalyticsImports,
|
StoreAnalyticsImports,
|
||||||
StoreAnalyticsInventory,
|
StoreAnalyticsInventory,
|
||||||
StoreAnalyticsResponse,
|
StoreAnalyticsResponse,
|
||||||
StoreCustomerStats,
|
|
||||||
StoreDashboardStatsResponse,
|
|
||||||
StoreInfo,
|
|
||||||
StoreOrderStats,
|
|
||||||
StoreProductStats,
|
|
||||||
StoreRevenueStats,
|
|
||||||
StoreStatsResponse,
|
|
||||||
UserStatsResponse,
|
|
||||||
ValidatorStats,
|
ValidatorStats,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"StatsResponse",
|
|
||||||
"MarketplaceStatsResponse",
|
|
||||||
"ImportStatsResponse",
|
|
||||||
"UserStatsResponse",
|
|
||||||
"StoreStatsResponse",
|
|
||||||
"ProductStatsResponse",
|
|
||||||
"PlatformStatsResponse",
|
|
||||||
"OrderStatsBasicResponse",
|
|
||||||
"AdminDashboardResponse",
|
|
||||||
"StoreProductStats",
|
|
||||||
"StoreOrderStats",
|
|
||||||
"StoreCustomerStats",
|
|
||||||
"StoreRevenueStats",
|
|
||||||
"StoreInfo",
|
|
||||||
"StoreDashboardStatsResponse",
|
|
||||||
"StoreAnalyticsImports",
|
"StoreAnalyticsImports",
|
||||||
"StoreAnalyticsCatalog",
|
"StoreAnalyticsCatalog",
|
||||||
"StoreAnalyticsInventory",
|
"StoreAnalyticsInventory",
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"""
|
"""
|
||||||
Analytics module schemas for statistics and reporting.
|
Analytics module schemas for statistics and reporting.
|
||||||
|
|
||||||
Base dashboard schemas are defined in core.schemas.dashboard.
|
Base dashboard schemas are defined in app.modules.core.schemas.dashboard.
|
||||||
This module re-exports them for backward compatibility and adds
|
Import them from there directly. This module contains only
|
||||||
analytics-specific schemas (trends, reports, etc.).
|
analytics-specific schemas (trends, reports, etc.).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -13,26 +13,6 @@ from typing import Any
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
# Re-export base dashboard schemas from core for backward compatibility
|
|
||||||
# These are the canonical definitions in core module
|
|
||||||
from app.modules.core.schemas.dashboard import (
|
|
||||||
AdminDashboardResponse,
|
|
||||||
ImportStatsResponse,
|
|
||||||
MarketplaceStatsResponse,
|
|
||||||
OrderStatsBasicResponse,
|
|
||||||
PlatformStatsResponse,
|
|
||||||
ProductStatsResponse,
|
|
||||||
StatsResponse,
|
|
||||||
StoreCustomerStats,
|
|
||||||
StoreDashboardStatsResponse,
|
|
||||||
StoreInfo,
|
|
||||||
StoreOrderStats,
|
|
||||||
StoreProductStats,
|
|
||||||
StoreRevenueStats,
|
|
||||||
StoreStatsResponse,
|
|
||||||
UserStatsResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Store Analytics (Analytics-specific, not in core)
|
# Store Analytics (Analytics-specific, not in core)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -151,22 +131,6 @@ class OrderStatsResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Re-exported from core.schemas.dashboard (for backward compatibility)
|
|
||||||
"StatsResponse",
|
|
||||||
"MarketplaceStatsResponse",
|
|
||||||
"ImportStatsResponse",
|
|
||||||
"UserStatsResponse",
|
|
||||||
"StoreStatsResponse",
|
|
||||||
"ProductStatsResponse",
|
|
||||||
"PlatformStatsResponse",
|
|
||||||
"OrderStatsBasicResponse",
|
|
||||||
"AdminDashboardResponse",
|
|
||||||
"StoreProductStats",
|
|
||||||
"StoreOrderStats",
|
|
||||||
"StoreCustomerStats",
|
|
||||||
"StoreRevenueStats",
|
|
||||||
"StoreInfo",
|
|
||||||
"StoreDashboardStatsResponse",
|
|
||||||
# Analytics-specific schemas
|
# Analytics-specific schemas
|
||||||
"StoreAnalyticsImports",
|
"StoreAnalyticsImports",
|
||||||
"StoreAnalyticsCatalog",
|
"StoreAnalyticsCatalog",
|
||||||
|
|||||||
@@ -15,19 +15,13 @@ import logging
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.modules.catalog.models import Product
|
|
||||||
from app.modules.customers.models.customer import Customer
|
|
||||||
from app.modules.inventory.models import Inventory
|
|
||||||
from app.modules.marketplace.models import MarketplaceImportJob, MarketplaceProduct
|
|
||||||
from app.modules.orders.models import Order
|
|
||||||
from app.modules.tenancy.exceptions import (
|
from app.modules.tenancy.exceptions import (
|
||||||
AdminOperationException,
|
AdminOperationException,
|
||||||
StoreNotFoundException,
|
StoreNotFoundException,
|
||||||
)
|
)
|
||||||
from app.modules.tenancy.models import Store, User
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -54,84 +48,56 @@ class StatsService:
|
|||||||
StoreNotFoundException: If store doesn't exist
|
StoreNotFoundException: If store doesn't exist
|
||||||
AdminOperationException: If database query fails
|
AdminOperationException: If database query fails
|
||||||
"""
|
"""
|
||||||
|
from app.modules.catalog.services.product_service import product_service
|
||||||
|
from app.modules.customers.services.customer_service import customer_service
|
||||||
|
from app.modules.inventory.services.inventory_service import inventory_service
|
||||||
|
from app.modules.marketplace.services.marketplace_import_job_service import (
|
||||||
|
marketplace_import_job_service,
|
||||||
|
)
|
||||||
|
from app.modules.marketplace.services.marketplace_product_service import (
|
||||||
|
marketplace_product_service,
|
||||||
|
)
|
||||||
|
from app.modules.orders.services.order_service import order_service
|
||||||
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
# Verify store exists
|
# Verify store exists
|
||||||
store = db.query(Store).filter(Store.id == store_id).first()
|
store = store_service.get_store_by_id_optional(db, store_id)
|
||||||
if not store:
|
if not store:
|
||||||
raise StoreNotFoundException(str(store_id), identifier_type="id")
|
raise StoreNotFoundException(str(store_id), identifier_type="id")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Catalog statistics
|
# Catalog statistics
|
||||||
total_catalog_products = (
|
total_catalog_products = product_service.get_store_product_count(
|
||||||
db.query(Product)
|
db, store_id, active_only=True,
|
||||||
.filter(Product.store_id == store_id, Product.is_active == True)
|
|
||||||
.count()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
featured_products = (
|
featured_products = product_service.get_store_product_count(
|
||||||
db.query(Product)
|
db, store_id, active_only=True, featured_only=True,
|
||||||
.filter(
|
|
||||||
Product.store_id == store_id,
|
|
||||||
Product.is_featured == True,
|
|
||||||
Product.is_active == True,
|
|
||||||
)
|
|
||||||
.count()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Staging statistics
|
# Staging statistics
|
||||||
# TODO: This is fragile - MarketplaceProduct uses store_name (string) not store_id
|
staging_products = marketplace_product_service.get_staging_product_count(
|
||||||
# Should add store_id foreign key to MarketplaceProduct for robust querying
|
db, store_name=store.name,
|
||||||
# For now, matching by store name which could fail if names don't match exactly
|
|
||||||
staging_products = (
|
|
||||||
db.query(MarketplaceProduct)
|
|
||||||
.filter(MarketplaceProduct.store_name == store.name)
|
|
||||||
.count()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Inventory statistics
|
# Inventory statistics
|
||||||
total_inventory = (
|
inv_stats = inventory_service.get_store_inventory_stats(db, store_id)
|
||||||
db.query(func.sum(Inventory.quantity))
|
total_inventory = inv_stats["total"]
|
||||||
.filter(Inventory.store_id == store_id)
|
reserved_inventory = inv_stats["reserved"]
|
||||||
.scalar()
|
inventory_locations = inv_stats["locations"]
|
||||||
or 0
|
|
||||||
)
|
|
||||||
|
|
||||||
reserved_inventory = (
|
|
||||||
db.query(func.sum(Inventory.reserved_quantity))
|
|
||||||
.filter(Inventory.store_id == store_id)
|
|
||||||
.scalar()
|
|
||||||
or 0
|
|
||||||
)
|
|
||||||
|
|
||||||
inventory_locations = (
|
|
||||||
db.query(func.count(func.distinct(Inventory.location)))
|
|
||||||
.filter(Inventory.store_id == store_id)
|
|
||||||
.scalar()
|
|
||||||
or 0
|
|
||||||
)
|
|
||||||
|
|
||||||
# Import statistics
|
# Import statistics
|
||||||
total_imports = (
|
import_stats = marketplace_import_job_service.get_import_job_stats(
|
||||||
db.query(MarketplaceImportJob)
|
db, store_id=store_id,
|
||||||
.filter(MarketplaceImportJob.store_id == store_id)
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
successful_imports = (
|
|
||||||
db.query(MarketplaceImportJob)
|
|
||||||
.filter(
|
|
||||||
MarketplaceImportJob.store_id == store_id,
|
|
||||||
MarketplaceImportJob.status == "completed",
|
|
||||||
)
|
|
||||||
.count()
|
|
||||||
)
|
)
|
||||||
|
total_imports = import_stats["total"]
|
||||||
|
successful_imports = import_stats["completed"]
|
||||||
|
|
||||||
# Orders
|
# Orders
|
||||||
total_orders = db.query(Order).filter(Order.store_id == store_id).count()
|
total_orders = order_service.get_store_order_count(db, store_id)
|
||||||
|
|
||||||
# Customers
|
# Customers
|
||||||
total_customers = (
|
total_customers = customer_service.get_store_customer_count(db, store_id)
|
||||||
db.query(Customer).filter(Customer.store_id == store_id).count()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Return flat structure compatible with StoreDashboardStatsResponse schema
|
# Return flat structure compatible with StoreDashboardStatsResponse schema
|
||||||
# The endpoint will restructure this into nested format
|
# The endpoint will restructure this into nested format
|
||||||
@@ -171,7 +137,7 @@ class StatsService:
|
|||||||
|
|
||||||
except StoreNotFoundException:
|
except StoreNotFoundException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except SQLAlchemyError as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to retrieve store statistics for store {store_id}: {str(e)}"
|
f"Failed to retrieve store statistics for store {store_id}: {str(e)}"
|
||||||
)
|
)
|
||||||
@@ -200,8 +166,15 @@ class StatsService:
|
|||||||
StoreNotFoundException: If store doesn't exist
|
StoreNotFoundException: If store doesn't exist
|
||||||
AdminOperationException: If database query fails
|
AdminOperationException: If database query fails
|
||||||
"""
|
"""
|
||||||
|
from app.modules.catalog.services.product_service import product_service
|
||||||
|
from app.modules.inventory.services.inventory_service import inventory_service
|
||||||
|
from app.modules.marketplace.services.marketplace_import_job_service import (
|
||||||
|
marketplace_import_job_service,
|
||||||
|
)
|
||||||
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
# Verify store exists
|
# Verify store exists
|
||||||
store = db.query(Store).filter(Store.id == store_id).first()
|
store = store_service.get_store_by_id_optional(db, store_id)
|
||||||
if not store:
|
if not store:
|
||||||
raise StoreNotFoundException(str(store_id), identifier_type="id")
|
raise StoreNotFoundException(str(store_id), identifier_type="id")
|
||||||
|
|
||||||
@@ -211,28 +184,17 @@ class StatsService:
|
|||||||
start_date = datetime.utcnow() - timedelta(days=days)
|
start_date = datetime.utcnow() - timedelta(days=days)
|
||||||
|
|
||||||
# Import activity
|
# Import activity
|
||||||
recent_imports = (
|
import_stats = marketplace_import_job_service.get_import_job_stats(
|
||||||
db.query(MarketplaceImportJob)
|
db, store_id=store_id,
|
||||||
.filter(
|
|
||||||
MarketplaceImportJob.store_id == store_id,
|
|
||||||
MarketplaceImportJob.created_at >= start_date,
|
|
||||||
)
|
|
||||||
.count()
|
|
||||||
)
|
)
|
||||||
|
recent_imports = import_stats["total"]
|
||||||
|
|
||||||
# Products added to catalog
|
# Products added to catalog
|
||||||
products_added = (
|
products_added = product_service.get_store_product_count(db, store_id)
|
||||||
db.query(Product)
|
|
||||||
.filter(
|
|
||||||
Product.store_id == store_id, Product.created_at >= start_date
|
|
||||||
)
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Inventory changes
|
# Inventory changes
|
||||||
inventory_entries = (
|
inv_stats = inventory_service.get_store_inventory_stats(db, store_id)
|
||||||
db.query(Inventory).filter(Inventory.store_id == store_id).count()
|
inventory_entries = inv_stats.get("locations", 0)
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"period": period,
|
"period": period,
|
||||||
@@ -250,7 +212,7 @@ class StatsService:
|
|||||||
|
|
||||||
except StoreNotFoundException:
|
except StoreNotFoundException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except SQLAlchemyError as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to retrieve store analytics for store {store_id}: {str(e)}"
|
f"Failed to retrieve store analytics for store {store_id}: {str(e)}"
|
||||||
)
|
)
|
||||||
@@ -267,37 +229,26 @@ class StatsService:
|
|||||||
Returns dict compatible with StoreStatsResponse schema.
|
Returns dict compatible with StoreStatsResponse schema.
|
||||||
Keys: total, verified, pending, inactive (mapped from internal names)
|
Keys: total, verified, pending, inactive (mapped from internal names)
|
||||||
"""
|
"""
|
||||||
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
try:
|
try:
|
||||||
total_stores = db.query(Store).count()
|
total_stores = store_service.get_total_store_count(db)
|
||||||
active_stores = db.query(Store).filter(Store.is_active == True).count()
|
active_stores = store_service.get_total_store_count(db, active_only=True)
|
||||||
verified_stores = (
|
|
||||||
db.query(Store).filter(Store.is_verified == True).count()
|
|
||||||
)
|
|
||||||
inactive_stores = total_stores - active_stores
|
inactive_stores = total_stores - active_stores
|
||||||
# Pending = active but not yet verified
|
# Use store_service for verified/pending counts
|
||||||
pending_stores = (
|
verified_stores = store_service.get_store_count_by_status(db, verified=True)
|
||||||
db.query(Store)
|
pending_stores = store_service.get_store_count_by_status(db, active=True, verified=False)
|
||||||
.filter(Store.is_active == True, Store.is_verified == False)
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
# Schema-compatible fields (StoreStatsResponse)
|
|
||||||
"total": total_stores,
|
"total": total_stores,
|
||||||
"verified": verified_stores,
|
"verified": verified_stores,
|
||||||
"pending": pending_stores,
|
"pending": pending_stores,
|
||||||
"inactive": inactive_stores,
|
"inactive": inactive_stores,
|
||||||
# Legacy fields for backward compatibility
|
|
||||||
"total_stores": total_stores,
|
|
||||||
"active_stores": active_stores,
|
|
||||||
"inactive_stores": inactive_stores,
|
|
||||||
"verified_stores": verified_stores,
|
|
||||||
"pending_stores": pending_stores,
|
|
||||||
"verification_rate": (
|
"verification_rate": (
|
||||||
(verified_stores / total_stores * 100) if total_stores > 0 else 0
|
(verified_stores / total_stores * 100) if total_stores > 0 else 0
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except SQLAlchemyError as e:
|
||||||
logger.error(f"Failed to get store statistics: {str(e)}")
|
logger.error(f"Failed to get store statistics: {str(e)}")
|
||||||
raise AdminOperationException(
|
raise AdminOperationException(
|
||||||
operation="get_store_statistics", reason="Database query failed"
|
operation="get_store_statistics", reason="Database query failed"
|
||||||
@@ -321,21 +272,22 @@ class StatsService:
|
|||||||
AdminOperationException: If database query fails
|
AdminOperationException: If database query fails
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
from app.modules.catalog.services.product_service import product_service
|
||||||
|
from app.modules.marketplace.services.marketplace_product_service import (
|
||||||
|
marketplace_product_service,
|
||||||
|
)
|
||||||
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
# Stores
|
# Stores
|
||||||
total_stores = db.query(Store).filter(Store.is_active == True).count()
|
total_stores = store_service.get_total_store_count(db, active_only=True)
|
||||||
|
|
||||||
# Products
|
# Products
|
||||||
total_catalog_products = db.query(Product).count()
|
total_catalog_products = product_service.get_total_product_count(db)
|
||||||
unique_brands = self._get_unique_brands_count(db)
|
unique_brands = marketplace_product_service.get_distinct_brand_count(db)
|
||||||
unique_categories = self._get_unique_categories_count(db)
|
unique_categories = marketplace_product_service.get_distinct_category_count(db)
|
||||||
|
|
||||||
# Marketplaces
|
# Marketplaces
|
||||||
unique_marketplaces = (
|
unique_marketplaces = marketplace_product_service.get_distinct_marketplace_count(db)
|
||||||
db.query(MarketplaceProduct.marketplace)
|
|
||||||
.filter(MarketplaceProduct.marketplace.isnot(None))
|
|
||||||
.distinct()
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Inventory
|
# Inventory
|
||||||
inventory_stats = self._get_inventory_statistics(db)
|
inventory_stats = self._get_inventory_statistics(db)
|
||||||
@@ -350,7 +302,7 @@ class StatsService:
|
|||||||
"total_inventory_quantity": inventory_stats.get("total_quantity", 0),
|
"total_inventory_quantity": inventory_stats.get("total_quantity", 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except SQLAlchemyError as e:
|
||||||
logger.error(f"Failed to retrieve comprehensive statistics: {str(e)}")
|
logger.error(f"Failed to retrieve comprehensive statistics: {str(e)}")
|
||||||
raise AdminOperationException(
|
raise AdminOperationException(
|
||||||
operation="get_comprehensive_stats",
|
operation="get_comprehensive_stats",
|
||||||
@@ -371,33 +323,13 @@ class StatsService:
|
|||||||
AdminOperationException: If database query fails
|
AdminOperationException: If database query fails
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
marketplace_stats = (
|
from app.modules.marketplace.services.marketplace_product_service import (
|
||||||
db.query(
|
marketplace_product_service,
|
||||||
MarketplaceProduct.marketplace,
|
|
||||||
func.count(MarketplaceProduct.id).label("total_products"),
|
|
||||||
func.count(func.distinct(MarketplaceProduct.store_name)).label(
|
|
||||||
"unique_stores"
|
|
||||||
),
|
|
||||||
func.count(func.distinct(MarketplaceProduct.brand)).label(
|
|
||||||
"unique_brands"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.filter(MarketplaceProduct.marketplace.isnot(None))
|
|
||||||
.group_by(MarketplaceProduct.marketplace)
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return [
|
return marketplace_product_service.get_marketplace_breakdown(db)
|
||||||
{
|
|
||||||
"marketplace": stat.marketplace,
|
|
||||||
"total_products": stat.total_products,
|
|
||||||
"unique_stores": stat.unique_stores,
|
|
||||||
"unique_brands": stat.unique_brands,
|
|
||||||
}
|
|
||||||
for stat in marketplace_stats
|
|
||||||
]
|
|
||||||
|
|
||||||
except Exception as e:
|
except SQLAlchemyError as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to retrieve marketplace breakdown statistics: {str(e)}"
|
f"Failed to retrieve marketplace breakdown statistics: {str(e)}"
|
||||||
)
|
)
|
||||||
@@ -420,21 +352,11 @@ class StatsService:
|
|||||||
AdminOperationException: If database query fails
|
AdminOperationException: If database query fails
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
total_users = db.query(User).count()
|
from app.modules.tenancy.services.admin_service import admin_service
|
||||||
active_users = db.query(User).filter(User.is_active == True).count()
|
|
||||||
inactive_users = total_users - active_users
|
|
||||||
admin_users = db.query(User).filter(User.role == "admin").count()
|
|
||||||
|
|
||||||
return {
|
user_stats = admin_service.get_user_statistics(db)
|
||||||
"total_users": total_users,
|
return user_stats
|
||||||
"active_users": active_users,
|
except SQLAlchemyError as e:
|
||||||
"inactive_users": inactive_users,
|
|
||||||
"admin_users": admin_users,
|
|
||||||
"activation_rate": (
|
|
||||||
(active_users / total_users * 100) if total_users > 0 else 0
|
|
||||||
),
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get user statistics: {str(e)}")
|
logger.error(f"Failed to get user statistics: {str(e)}")
|
||||||
raise AdminOperationException(
|
raise AdminOperationException(
|
||||||
operation="get_user_statistics", reason="Database query failed"
|
operation="get_user_statistics", reason="Database query failed"
|
||||||
@@ -454,46 +376,22 @@ class StatsService:
|
|||||||
AdminOperationException: If database query fails
|
AdminOperationException: If database query fails
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
total = db.query(MarketplaceImportJob).count()
|
from app.modules.marketplace.services.marketplace_import_job_service import (
|
||||||
pending = (
|
marketplace_import_job_service,
|
||||||
db.query(MarketplaceImportJob)
|
|
||||||
.filter(MarketplaceImportJob.status == "pending")
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
processing = (
|
|
||||||
db.query(MarketplaceImportJob)
|
|
||||||
.filter(MarketplaceImportJob.status == "processing")
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
completed = (
|
|
||||||
db.query(MarketplaceImportJob)
|
|
||||||
.filter(
|
|
||||||
MarketplaceImportJob.status.in_(
|
|
||||||
["completed", "completed_with_errors"]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
failed = (
|
|
||||||
db.query(MarketplaceImportJob)
|
|
||||||
.filter(MarketplaceImportJob.status == "failed")
|
|
||||||
.count()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
stats = marketplace_import_job_service.get_import_job_stats(db)
|
||||||
|
total = stats["total"]
|
||||||
|
completed = stats["completed"]
|
||||||
return {
|
return {
|
||||||
# Frontend-expected fields
|
|
||||||
"total": total,
|
"total": total,
|
||||||
"pending": pending,
|
"pending": stats["pending"],
|
||||||
"processing": processing,
|
"processing": stats.get("processing", 0),
|
||||||
"completed": completed,
|
"completed": completed,
|
||||||
"failed": failed,
|
"failed": stats["failed"],
|
||||||
# Legacy fields for backward compatibility
|
|
||||||
"total_imports": total,
|
|
||||||
"completed_imports": completed,
|
|
||||||
"failed_imports": failed,
|
|
||||||
"success_rate": (completed / total * 100) if total > 0 else 0,
|
"success_rate": (completed / total * 100) if total > 0 else 0,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except SQLAlchemyError as e:
|
||||||
logger.error(f"Failed to get import statistics: {str(e)}")
|
logger.error(f"Failed to get import statistics: {str(e)}")
|
||||||
return {
|
return {
|
||||||
"total": 0,
|
"total": 0,
|
||||||
@@ -501,9 +399,6 @@ class StatsService:
|
|||||||
"processing": 0,
|
"processing": 0,
|
||||||
"completed": 0,
|
"completed": 0,
|
||||||
"failed": 0,
|
"failed": 0,
|
||||||
"total_imports": 0,
|
|
||||||
"completed_imports": 0,
|
|
||||||
"failed_imports": 0,
|
|
||||||
"success_rate": 0,
|
"success_rate": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,58 +454,13 @@ class StatsService:
|
|||||||
}
|
}
|
||||||
return period_map.get(period, 30)
|
return period_map.get(period, 30)
|
||||||
|
|
||||||
def _get_unique_brands_count(self, db: Session) -> int:
|
|
||||||
"""
|
|
||||||
Get count of unique brands.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Count of unique brands
|
|
||||||
"""
|
|
||||||
return (
|
|
||||||
db.query(MarketplaceProduct.brand)
|
|
||||||
.filter(
|
|
||||||
MarketplaceProduct.brand.isnot(None), MarketplaceProduct.brand != ""
|
|
||||||
)
|
|
||||||
.distinct()
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get_unique_categories_count(self, db: Session) -> int:
|
|
||||||
"""
|
|
||||||
Get count of unique categories.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Count of unique categories
|
|
||||||
"""
|
|
||||||
return (
|
|
||||||
db.query(MarketplaceProduct.google_product_category)
|
|
||||||
.filter(
|
|
||||||
MarketplaceProduct.google_product_category.isnot(None),
|
|
||||||
MarketplaceProduct.google_product_category != "",
|
|
||||||
)
|
|
||||||
.distinct()
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get_inventory_statistics(self, db: Session) -> dict[str, int]:
|
def _get_inventory_statistics(self, db: Session) -> dict[str, int]:
|
||||||
"""
|
"""Get inventory-related statistics via inventory service."""
|
||||||
Get inventory-related statistics.
|
from app.modules.inventory.services.inventory_service import inventory_service
|
||||||
|
|
||||||
Args:
|
total_entries = inventory_service.get_total_inventory_count(db)
|
||||||
db: Database session
|
total_quantity = inventory_service.get_total_inventory_quantity(db)
|
||||||
|
total_reserved = inventory_service.get_total_reserved_quantity(db)
|
||||||
Returns:
|
|
||||||
Dictionary with inventory statistics
|
|
||||||
"""
|
|
||||||
total_entries = db.query(Inventory).count()
|
|
||||||
total_quantity = db.query(func.sum(Inventory.quantity)).scalar() or 0
|
|
||||||
total_reserved = db.query(func.sum(Inventory.reserved_quantity)).scalar() or 0
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_entries": total_entries,
|
"total_entries": total_entries,
|
||||||
|
|||||||
@@ -143,7 +143,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
||||||
<span x-html="$icon('location-marker', 'w-6 h-6')"></span>
|
<span x-html="$icon('map-pin', 'w-6 h-6')"></span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="formatNumber(analytics.inventory?.total_locations || 0)"></p>
|
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="formatNumber(analytics.inventory?.total_locations || 0)"></p>
|
||||||
@@ -227,5 +227,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
<script src="{{ url_for('analytics_static', path='store/js/analytics.js') }}"></script>
|
<script defer src="{{ url_for('analytics_static', path='store/js/analytics.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ if TYPE_CHECKING:
|
|||||||
from app.modules.contracts.cms import MediaUsageProviderProtocol
|
from app.modules.contracts.cms import MediaUsageProviderProtocol
|
||||||
from app.modules.contracts.features import FeatureProviderProtocol
|
from app.modules.contracts.features import FeatureProviderProtocol
|
||||||
from app.modules.contracts.metrics import MetricsProviderProtocol
|
from app.modules.contracts.metrics import MetricsProviderProtocol
|
||||||
|
from app.modules.contracts.onboarding import OnboardingProviderProtocol
|
||||||
from app.modules.contracts.widgets import DashboardWidgetProviderProtocol
|
from app.modules.contracts.widgets import DashboardWidgetProviderProtocol
|
||||||
|
|
||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
@@ -94,6 +95,7 @@ class MenuItemDefinition:
|
|||||||
requires_permission: str | None = None
|
requires_permission: str | None = None
|
||||||
badge_source: str | None = None
|
badge_source: str | None = None
|
||||||
is_super_admin_only: bool = False
|
is_super_admin_only: bool = False
|
||||||
|
header_template: str | None = None # Optional partial for custom header rendering
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -486,6 +488,29 @@ class ModuleDefinition:
|
|||||||
# to report where media is being used.
|
# to report where media is being used.
|
||||||
media_usage_provider: "Callable[[], MediaUsageProviderProtocol] | None" = None
|
media_usage_provider: "Callable[[], MediaUsageProviderProtocol] | None" = None
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Onboarding Provider (Module-Driven Post-Signup Onboarding)
|
||||||
|
# =========================================================================
|
||||||
|
# Callable that returns an OnboardingProviderProtocol implementation.
|
||||||
|
# Modules declare onboarding steps (what needs to be configured after signup)
|
||||||
|
# and provide completion checks. The core module's OnboardingAggregator
|
||||||
|
# discovers and aggregates all providers into a dashboard checklist banner.
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# def _get_onboarding_provider():
|
||||||
|
# from app.modules.marketplace.services.marketplace_onboarding_service import (
|
||||||
|
# marketplace_onboarding_provider,
|
||||||
|
# )
|
||||||
|
# return marketplace_onboarding_provider
|
||||||
|
#
|
||||||
|
# marketplace_module = ModuleDefinition(
|
||||||
|
# code="marketplace",
|
||||||
|
# onboarding_provider=_get_onboarding_provider,
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# The provider will be discovered by core's OnboardingAggregator service.
|
||||||
|
onboarding_provider: "Callable[[], OnboardingProviderProtocol] | None" = None
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Menu Item Methods (Legacy - uses menu_items dict of IDs)
|
# Menu Item Methods (Legacy - uses menu_items dict of IDs)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -955,6 +980,24 @@ class ModuleDefinition:
|
|||||||
return None
|
return None
|
||||||
return self.media_usage_provider()
|
return self.media_usage_provider()
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Onboarding Provider Methods
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def has_onboarding_provider(self) -> bool:
|
||||||
|
"""Check if this module has an onboarding provider."""
|
||||||
|
return self.onboarding_provider is not None
|
||||||
|
|
||||||
|
def get_onboarding_provider_instance(self) -> "OnboardingProviderProtocol | None":
|
||||||
|
"""Get the onboarding provider instance for this module.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
OnboardingProviderProtocol instance, or None
|
||||||
|
"""
|
||||||
|
if self.onboarding_provider is None:
|
||||||
|
return None
|
||||||
|
return self.onboarding_provider()
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Magic Methods
|
# Magic Methods
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
|
|||||||
# api_timeout: int = 30
|
# api_timeout: int = 30
|
||||||
# batch_size: int = 100
|
# batch_size: int = 100
|
||||||
|
|
||||||
model_config = {"env_prefix": "BILLING_"}
|
model_config = {"env_prefix": "BILLING_", "env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
# Export for auto-discovery
|
# Export for auto-discovery
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any
|
|||||||
"""
|
"""
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.modules.billing.models import SubscriptionTier, TierCode
|
from app.modules.billing.models import SubscriptionTier, TierCode
|
||||||
|
from app.modules.billing.services.feature_aggregator import feature_aggregator
|
||||||
|
|
||||||
|
language = getattr(request.state, "language", "fr") or "fr"
|
||||||
|
|
||||||
tiers_db = (
|
tiers_db = (
|
||||||
db.query(SubscriptionTier)
|
db.query(SubscriptionTier)
|
||||||
@@ -48,14 +51,28 @@ def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any
|
|||||||
tiers = []
|
tiers = []
|
||||||
for tier in tiers_db:
|
for tier in tiers_db:
|
||||||
feature_codes = sorted(tier.get_feature_codes())
|
feature_codes = sorted(tier.get_feature_codes())
|
||||||
|
|
||||||
|
# Build features list from declarations for template rendering
|
||||||
|
features = []
|
||||||
|
for code in feature_codes:
|
||||||
|
decl = feature_aggregator.get_declaration(code)
|
||||||
|
if decl:
|
||||||
|
features.append({
|
||||||
|
"code": code,
|
||||||
|
"name_key": decl.name_key,
|
||||||
|
"limit": tier.get_limit_for_feature(code),
|
||||||
|
"is_quantitative": decl.feature_type.value == "quantitative",
|
||||||
|
})
|
||||||
|
|
||||||
tiers.append({
|
tiers.append({
|
||||||
"code": tier.code,
|
"code": tier.code,
|
||||||
"name": tier.name,
|
"name": tier.get_translated_name(language),
|
||||||
"price_monthly": tier.price_monthly_cents / 100,
|
"price_monthly": tier.price_monthly_cents / 100,
|
||||||
"price_annual": (tier.price_annual_cents / 100)
|
"price_annual": (tier.price_annual_cents / 100)
|
||||||
if tier.price_annual_cents
|
if tier.price_annual_cents
|
||||||
else None,
|
else None,
|
||||||
"feature_codes": feature_codes,
|
"feature_codes": feature_codes,
|
||||||
|
"features": features,
|
||||||
"products_limit": tier.get_limit_for_feature("products_limit"),
|
"products_limit": tier.get_limit_for_feature("products_limit"),
|
||||||
"orders_per_month": tier.get_limit_for_feature("orders_per_month"),
|
"orders_per_month": tier.get_limit_for_feature("orders_per_month"),
|
||||||
"team_members": tier.get_limit_for_feature("team_members"),
|
"team_members": tier.get_limit_for_feature("team_members"),
|
||||||
@@ -77,16 +94,23 @@ def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any
|
|||||||
|
|
||||||
def _get_admin_router():
|
def _get_admin_router():
|
||||||
"""Lazy import of admin router to avoid circular imports."""
|
"""Lazy import of admin router to avoid circular imports."""
|
||||||
from app.modules.billing.routes.api.admin import admin_router
|
from app.modules.billing.routes.api.admin import router
|
||||||
|
|
||||||
return admin_router
|
return router
|
||||||
|
|
||||||
|
|
||||||
def _get_store_router():
|
def _get_store_router():
|
||||||
"""Lazy import of store router to avoid circular imports."""
|
"""Lazy import of store router to avoid circular imports."""
|
||||||
from app.modules.billing.routes.api.store import store_router
|
from app.modules.billing.routes.api.store import router
|
||||||
|
|
||||||
return store_router
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def _get_metrics_provider():
|
||||||
|
"""Lazy import of metrics provider to avoid circular imports."""
|
||||||
|
from app.modules.billing.services.billing_metrics import billing_metrics_provider
|
||||||
|
|
||||||
|
return billing_metrics_provider
|
||||||
|
|
||||||
|
|
||||||
def _get_feature_provider():
|
def _get_feature_provider():
|
||||||
@@ -158,6 +182,10 @@ billing_module = ModuleDefinition(
|
|||||||
"billing", # Store billing dashboard
|
"billing", # Store billing dashboard
|
||||||
"invoices", # Store invoice history
|
"invoices", # Store invoice history
|
||||||
],
|
],
|
||||||
|
FrontendType.MERCHANT: [
|
||||||
|
"subscriptions", # Merchant subscriptions
|
||||||
|
"invoices", # Merchant billing history
|
||||||
|
],
|
||||||
},
|
},
|
||||||
# New module-driven menu definitions
|
# New module-driven menu definitions
|
||||||
menus={
|
menus={
|
||||||
@@ -192,6 +220,31 @@ billing_module = ModuleDefinition(
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
FrontendType.MERCHANT: [
|
||||||
|
MenuSectionDefinition(
|
||||||
|
id="billing",
|
||||||
|
label_key="billing.menu.billing_subscriptions",
|
||||||
|
icon="credit-card",
|
||||||
|
order=50,
|
||||||
|
items=[
|
||||||
|
MenuItemDefinition(
|
||||||
|
id="subscriptions",
|
||||||
|
label_key="billing.menu.subscriptions",
|
||||||
|
icon="clipboard-list",
|
||||||
|
route="/merchants/billing/subscriptions",
|
||||||
|
order=10,
|
||||||
|
is_mandatory=True,
|
||||||
|
),
|
||||||
|
MenuItemDefinition(
|
||||||
|
id="invoices",
|
||||||
|
label_key="billing.menu.billing_history",
|
||||||
|
icon="currency-euro",
|
||||||
|
route="/merchants/billing/invoices",
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
FrontendType.STORE: [
|
FrontendType.STORE: [
|
||||||
MenuSectionDefinition(
|
MenuSectionDefinition(
|
||||||
id="sales",
|
id="sales",
|
||||||
@@ -205,6 +258,7 @@ billing_module = ModuleDefinition(
|
|||||||
icon="currency-euro",
|
icon="currency-euro",
|
||||||
route="/store/{store_code}/invoices",
|
route="/store/{store_code}/invoices",
|
||||||
order=30,
|
order=30,
|
||||||
|
requires_permission="billing.view_invoices",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -220,6 +274,7 @@ billing_module = ModuleDefinition(
|
|||||||
icon="credit-card",
|
icon="credit-card",
|
||||||
route="/store/{store_code}/billing",
|
route="/store/{store_code}/billing",
|
||||||
order=30,
|
order=30,
|
||||||
|
requires_permission="billing.view_subscriptions",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -271,6 +326,8 @@ billing_module = ModuleDefinition(
|
|||||||
],
|
],
|
||||||
# Feature provider for feature flags
|
# Feature provider for feature flags
|
||||||
feature_provider=_get_feature_provider,
|
feature_provider=_get_feature_provider,
|
||||||
|
# Metrics provider for subscription metrics
|
||||||
|
metrics_provider=_get_metrics_provider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -19,24 +19,18 @@ from app.exceptions.base import (
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
# Base billing exception
|
# Base billing exception
|
||||||
"BillingException",
|
"BillingException",
|
||||||
"BillingServiceError", # Alias for backwards compatibility
|
|
||||||
# Subscription exceptions
|
# Subscription exceptions
|
||||||
"SubscriptionNotFoundException",
|
"SubscriptionNotFoundException",
|
||||||
"NoActiveSubscriptionException",
|
"NoActiveSubscriptionException",
|
||||||
"NoActiveSubscriptionError", # Alias for backwards compatibility
|
|
||||||
"SubscriptionNotCancelledException",
|
"SubscriptionNotCancelledException",
|
||||||
"SubscriptionNotCancelledError", # Alias for backwards compatibility
|
|
||||||
"SubscriptionAlreadyCancelledException",
|
"SubscriptionAlreadyCancelledException",
|
||||||
# Tier exceptions
|
# Tier exceptions
|
||||||
"TierNotFoundException",
|
"TierNotFoundException",
|
||||||
"TierNotFoundError",
|
|
||||||
"TierLimitExceededException",
|
"TierLimitExceededException",
|
||||||
# Payment exceptions
|
# Payment exceptions
|
||||||
"PaymentSystemNotConfiguredException",
|
"PaymentSystemNotConfiguredException",
|
||||||
"PaymentSystemNotConfiguredError", # Alias for backwards compatibility
|
|
||||||
"StripeNotConfiguredException",
|
"StripeNotConfiguredException",
|
||||||
"StripePriceNotConfiguredException",
|
"StripePriceNotConfiguredException",
|
||||||
"StripePriceNotConfiguredError", # Alias for backwards compatibility
|
|
||||||
"PaymentFailedException",
|
"PaymentFailedException",
|
||||||
# Webhook exceptions
|
# Webhook exceptions
|
||||||
"InvalidWebhookSignatureException",
|
"InvalidWebhookSignatureException",
|
||||||
@@ -44,8 +38,6 @@ __all__ = [
|
|||||||
"WebhookVerificationException",
|
"WebhookVerificationException",
|
||||||
# Feature exceptions
|
# Feature exceptions
|
||||||
"FeatureNotFoundException",
|
"FeatureNotFoundException",
|
||||||
"FeatureNotFoundError",
|
|
||||||
"FeatureNotAvailableException",
|
|
||||||
"InvalidFeatureCodesError",
|
"InvalidFeatureCodesError",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -62,10 +54,6 @@ class BillingException(BusinessLogicException):
|
|||||||
super().__init__(message=message, error_code=error_code, details=details)
|
super().__init__(message=message, error_code=error_code, details=details)
|
||||||
|
|
||||||
|
|
||||||
# Alias for backwards compatibility with billing_service.py
|
|
||||||
BillingServiceError = BillingException
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Subscription Exceptions
|
# Subscription Exceptions
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -92,10 +80,6 @@ class NoActiveSubscriptionException(BusinessLogicException):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Alias for backwards compatibility with billing_service.py
|
|
||||||
NoActiveSubscriptionError = NoActiveSubscriptionException
|
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionNotCancelledException(BusinessLogicException):
|
class SubscriptionNotCancelledException(BusinessLogicException):
|
||||||
"""Raised when trying to reactivate a subscription that is not cancelled."""
|
"""Raised when trying to reactivate a subscription that is not cancelled."""
|
||||||
|
|
||||||
@@ -106,11 +90,7 @@ class SubscriptionNotCancelledException(BusinessLogicException):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Alias for backwards compatibility with billing_service.py
|
class SubscriptionAlreadyCancelledException(BusinessLogicException): # noqa: MOD025
|
||||||
SubscriptionNotCancelledError = SubscriptionNotCancelledException
|
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionAlreadyCancelledException(BusinessLogicException):
|
|
||||||
"""Raised when trying to cancel an already cancelled subscription."""
|
"""Raised when trying to cancel an already cancelled subscription."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -138,18 +118,6 @@ class TierNotFoundException(ResourceNotFoundException):
|
|||||||
self.tier_code = tier_code
|
self.tier_code = tier_code
|
||||||
|
|
||||||
|
|
||||||
class TierNotFoundError(ResourceNotFoundException):
|
|
||||||
"""Subscription tier not found (alternate naming)."""
|
|
||||||
|
|
||||||
def __init__(self, tier_code: str):
|
|
||||||
super().__init__(
|
|
||||||
resource_type="SubscriptionTier",
|
|
||||||
identifier=tier_code,
|
|
||||||
message=f"Tier '{tier_code}' not found",
|
|
||||||
)
|
|
||||||
self.tier_code = tier_code
|
|
||||||
|
|
||||||
|
|
||||||
class TierLimitExceededException(BillingException):
|
class TierLimitExceededException(BillingException):
|
||||||
"""Raised when a tier limit is exceeded."""
|
"""Raised when a tier limit is exceeded."""
|
||||||
|
|
||||||
@@ -180,10 +148,6 @@ class PaymentSystemNotConfiguredException(ServiceUnavailableException):
|
|||||||
super().__init__(message="Payment system not configured")
|
super().__init__(message="Payment system not configured")
|
||||||
|
|
||||||
|
|
||||||
# Alias for backwards compatibility with billing_service.py
|
|
||||||
PaymentSystemNotConfiguredError = PaymentSystemNotConfiguredException
|
|
||||||
|
|
||||||
|
|
||||||
class StripeNotConfiguredException(BillingException):
|
class StripeNotConfiguredException(BillingException):
|
||||||
"""Raised when Stripe is not configured."""
|
"""Raised when Stripe is not configured."""
|
||||||
|
|
||||||
@@ -206,11 +170,7 @@ class StripePriceNotConfiguredException(BusinessLogicException):
|
|||||||
self.tier_code = tier_code
|
self.tier_code = tier_code
|
||||||
|
|
||||||
|
|
||||||
# Alias for backwards compatibility with billing_service.py
|
class PaymentFailedException(BillingException): # noqa: MOD025
|
||||||
StripePriceNotConfiguredError = StripePriceNotConfiguredException
|
|
||||||
|
|
||||||
|
|
||||||
class PaymentFailedException(BillingException):
|
|
||||||
"""Raised when a payment fails."""
|
"""Raised when a payment fails."""
|
||||||
|
|
||||||
def __init__(self, message: str, stripe_error: str | None = None):
|
def __init__(self, message: str, stripe_error: str | None = None):
|
||||||
@@ -277,37 +237,6 @@ class FeatureNotFoundException(ResourceNotFoundException):
|
|||||||
self.feature_code = feature_code
|
self.feature_code = feature_code
|
||||||
|
|
||||||
|
|
||||||
class FeatureNotFoundError(ResourceNotFoundException):
|
|
||||||
"""Feature not found (alternate naming)."""
|
|
||||||
|
|
||||||
def __init__(self, feature_code: str):
|
|
||||||
super().__init__(
|
|
||||||
resource_type="Feature",
|
|
||||||
identifier=feature_code,
|
|
||||||
message=f"Feature '{feature_code}' not found",
|
|
||||||
)
|
|
||||||
self.feature_code = feature_code
|
|
||||||
|
|
||||||
|
|
||||||
class FeatureNotAvailableException(BillingException):
|
|
||||||
"""Raised when a feature is not available in current tier."""
|
|
||||||
|
|
||||||
def __init__(self, feature: str, current_tier: str, required_tier: str):
|
|
||||||
message = f"Feature '{feature}' requires {required_tier} tier (current: {current_tier})"
|
|
||||||
super().__init__(
|
|
||||||
message=message,
|
|
||||||
error_code="FEATURE_NOT_AVAILABLE",
|
|
||||||
details={
|
|
||||||
"feature": feature,
|
|
||||||
"current_tier": current_tier,
|
|
||||||
"required_tier": required_tier,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.feature = feature
|
|
||||||
self.current_tier = current_tier
|
|
||||||
self.required_tier = required_tier
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidFeatureCodesError(ValidationException):
|
class InvalidFeatureCodesError(ValidationException):
|
||||||
"""Invalid feature codes provided."""
|
"""Invalid feature codes provided."""
|
||||||
|
|
||||||
|
|||||||
@@ -134,5 +134,17 @@
|
|||||||
"invoices": "Rechnungen",
|
"invoices": "Rechnungen",
|
||||||
"account_settings": "Kontoeinstellungen",
|
"account_settings": "Kontoeinstellungen",
|
||||||
"billing": "Abrechnung"
|
"billing": "Abrechnung"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"view_tiers": "Tarife anzeigen",
|
||||||
|
"view_tiers_desc": "Details der Abonnement-Tarife anzeigen",
|
||||||
|
"manage_tiers": "Tarife verwalten",
|
||||||
|
"manage_tiers_desc": "Abonnement-Tarife erstellen und konfigurieren",
|
||||||
|
"view_subscriptions": "Abonnements anzeigen",
|
||||||
|
"view_subscriptions_desc": "Abonnementdetails anzeigen",
|
||||||
|
"manage_subscriptions": "Abonnements verwalten",
|
||||||
|
"manage_subscriptions_desc": "Abonnements und Abrechnung verwalten",
|
||||||
|
"view_invoices": "Rechnungen anzeigen",
|
||||||
|
"view_invoices_desc": "Rechnungen und Abrechnungsverlauf anzeigen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,18 @@
|
|||||||
"current": "Current Plan",
|
"current": "Current Plan",
|
||||||
"recommended": "Recommended"
|
"recommended": "Recommended"
|
||||||
},
|
},
|
||||||
|
"permissions": {
|
||||||
|
"view_tiers": "View Tiers",
|
||||||
|
"view_tiers_desc": "View subscription tier details",
|
||||||
|
"manage_tiers": "Manage Tiers",
|
||||||
|
"manage_tiers_desc": "Create and configure subscription tiers",
|
||||||
|
"view_subscriptions": "View Subscriptions",
|
||||||
|
"view_subscriptions_desc": "View store subscription details",
|
||||||
|
"manage_subscriptions": "Manage Subscriptions",
|
||||||
|
"manage_subscriptions_desc": "Manage store subscriptions and billing",
|
||||||
|
"view_invoices": "View Invoices",
|
||||||
|
"view_invoices_desc": "View billing invoices and history"
|
||||||
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"subscription_updated": "Subscription updated successfully",
|
"subscription_updated": "Subscription updated successfully",
|
||||||
"tier_created": "Tier created successfully",
|
"tier_created": "Tier created successfully",
|
||||||
|
|||||||
@@ -134,5 +134,17 @@
|
|||||||
"invoices": "Factures",
|
"invoices": "Factures",
|
||||||
"account_settings": "Paramètres du compte",
|
"account_settings": "Paramètres du compte",
|
||||||
"billing": "Facturation"
|
"billing": "Facturation"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"view_tiers": "Voir les niveaux",
|
||||||
|
"view_tiers_desc": "Voir les détails des niveaux d'abonnement",
|
||||||
|
"manage_tiers": "Gérer les niveaux",
|
||||||
|
"manage_tiers_desc": "Créer et configurer les niveaux d'abonnement",
|
||||||
|
"view_subscriptions": "Voir les abonnements",
|
||||||
|
"view_subscriptions_desc": "Voir les détails des abonnements",
|
||||||
|
"manage_subscriptions": "Gérer les abonnements",
|
||||||
|
"manage_subscriptions_desc": "Gérer les abonnements et la facturation",
|
||||||
|
"view_invoices": "Voir les factures",
|
||||||
|
"view_invoices_desc": "Voir les factures et l'historique de facturation"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,5 +134,17 @@
|
|||||||
"invoices": "Rechnungen",
|
"invoices": "Rechnungen",
|
||||||
"account_settings": "Kont-Astellungen",
|
"account_settings": "Kont-Astellungen",
|
||||||
"billing": "Ofrechnung"
|
"billing": "Ofrechnung"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"view_tiers": "Tariffer kucken",
|
||||||
|
"view_tiers_desc": "Detailer vun den Abonnement-Tariffer kucken",
|
||||||
|
"manage_tiers": "Tariffer verwalten",
|
||||||
|
"manage_tiers_desc": "Abonnement-Tariffer erstellen a konfiguréieren",
|
||||||
|
"view_subscriptions": "Abonnementer kucken",
|
||||||
|
"view_subscriptions_desc": "Abonnementdetailer kucken",
|
||||||
|
"manage_subscriptions": "Abonnementer verwalten",
|
||||||
|
"manage_subscriptions_desc": "Abonnementer an Ofrechnung verwalten",
|
||||||
|
"view_invoices": "Rechnunge kucken",
|
||||||
|
"view_invoices_desc": "Rechnungen an Ofrechnungsverlaf kucken"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ def upgrade() -> None:
|
|||||||
sa.Column("platform_id", sa.Integer(), sa.ForeignKey("platforms.id", ondelete="CASCADE"), nullable=False, index=True, comment="Reference to the platform"),
|
sa.Column("platform_id", sa.Integer(), sa.ForeignKey("platforms.id", ondelete="CASCADE"), nullable=False, index=True, comment="Reference to the platform"),
|
||||||
sa.Column("tier_id", sa.Integer(), sa.ForeignKey("subscription_tiers.id", ondelete="SET NULL"), nullable=True, index=True, comment="Platform-specific subscription tier"),
|
sa.Column("tier_id", sa.Integer(), sa.ForeignKey("subscription_tiers.id", ondelete="SET NULL"), nullable=True, index=True, comment="Platform-specific subscription tier"),
|
||||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true", comment="Whether the store is active on this platform"),
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true", comment="Whether the store is active on this platform"),
|
||||||
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="false", comment="Whether this is the store's primary platform"),
|
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="false", comment="Whether this is the store's primary platform"), # Removed in migration remove_is_primary_001
|
||||||
sa.Column("custom_subdomain", sa.String(100), nullable=True, comment="Platform-specific subdomain (if different from main subdomain)"),
|
sa.Column("custom_subdomain", sa.String(100), nullable=True, comment="Platform-specific subdomain (if different from main subdomain)"),
|
||||||
sa.Column("settings", sa.JSON(), nullable=True, server_default="{}", comment="Platform-specific store settings"),
|
sa.Column("settings", sa.JSON(), nullable=True, server_default="{}", comment="Platform-specific store settings"),
|
||||||
sa.Column("joined_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, comment="When the store joined this platform"),
|
sa.Column("joined_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, comment="When the store joined this platform"),
|
||||||
@@ -53,7 +53,7 @@ def upgrade() -> None:
|
|||||||
sa.UniqueConstraint("store_id", "platform_id", name="uq_store_platform"),
|
sa.UniqueConstraint("store_id", "platform_id", name="uq_store_platform"),
|
||||||
)
|
)
|
||||||
op.create_index("idx_store_platform_active", "store_platforms", ["store_id", "platform_id", "is_active"])
|
op.create_index("idx_store_platform_active", "store_platforms", ["store_id", "platform_id", "is_active"])
|
||||||
op.create_index("idx_store_platform_primary", "store_platforms", ["store_id", "is_primary"])
|
op.create_index("idx_store_platform_primary", "store_platforms", ["store_id", "is_primary"]) # Removed in migration remove_is_primary_001
|
||||||
|
|
||||||
# --- tier_feature_limits ---
|
# --- tier_feature_limits ---
|
||||||
op.create_table(
|
op.create_table(
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""add name_translations to subscription_tiers
|
||||||
|
|
||||||
|
Revision ID: billing_002
|
||||||
|
Revises: hosting_001
|
||||||
|
Create Date: 2026-03-03
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "billing_002"
|
||||||
|
down_revision = "hosting_001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"subscription_tiers",
|
||||||
|
sa.Column(
|
||||||
|
"name_translations",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=True,
|
||||||
|
comment="Language-keyed name dict for multi-language support",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("subscription_tiers", "name_translations")
|
||||||
@@ -22,7 +22,6 @@ from app.modules.billing.models.subscription import (
|
|||||||
AddOnProduct,
|
AddOnProduct,
|
||||||
BillingHistory,
|
BillingHistory,
|
||||||
BillingPeriod,
|
BillingPeriod,
|
||||||
CapacitySnapshot,
|
|
||||||
StoreAddOn,
|
StoreAddOn,
|
||||||
StripeWebhookEvent,
|
StripeWebhookEvent,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
@@ -46,7 +45,6 @@ __all__ = [
|
|||||||
"StoreAddOn",
|
"StoreAddOn",
|
||||||
"StripeWebhookEvent",
|
"StripeWebhookEvent",
|
||||||
"BillingHistory",
|
"BillingHistory",
|
||||||
"CapacitySnapshot",
|
|
||||||
# Merchant Subscription
|
# Merchant Subscription
|
||||||
"MerchantSubscription",
|
"MerchantSubscription",
|
||||||
# Feature Limits
|
# Feature Limits
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class MerchantSubscription(Base, TimestampMixin):
|
|||||||
|
|
||||||
Example:
|
Example:
|
||||||
Merchant "Boucherie Luxembourg" subscribes to:
|
Merchant "Boucherie Luxembourg" subscribes to:
|
||||||
- Wizamart OMS (Professional tier)
|
- Orion OMS (Professional tier)
|
||||||
- Loyalty+ (Essential tier)
|
- Loyalty+ (Essential tier)
|
||||||
|
|
||||||
Their stores inherit features from the merchant's subscription.
|
Their stores inherit features from the merchant's subscription.
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ from sqlalchemy import (
|
|||||||
ForeignKey,
|
ForeignKey,
|
||||||
Index,
|
Index,
|
||||||
Integer,
|
Integer,
|
||||||
Numeric,
|
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
)
|
)
|
||||||
@@ -101,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)
|
||||||
@@ -155,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
|
||||||
@@ -345,61 +360,3 @@ class BillingHistory(Base, TimestampMixin):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<BillingHistory(store_id={self.store_id}, invoice='{self.invoice_number}', status='{self.status}')>"
|
return f"<BillingHistory(store_id={self.store_id}, invoice='{self.invoice_number}', status='{self.status}')>"
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Capacity Planning
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class CapacitySnapshot(Base, TimestampMixin):
|
|
||||||
"""
|
|
||||||
Daily snapshot of platform capacity metrics.
|
|
||||||
|
|
||||||
Used for growth trending and capacity forecasting.
|
|
||||||
Captured daily by background job.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "capacity_snapshots"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
snapshot_date = Column(DateTime(timezone=True), nullable=False, unique=True, index=True)
|
|
||||||
|
|
||||||
# Store metrics
|
|
||||||
total_stores = Column(Integer, default=0, nullable=False)
|
|
||||||
active_stores = Column(Integer, default=0, nullable=False)
|
|
||||||
trial_stores = Column(Integer, default=0, nullable=False)
|
|
||||||
|
|
||||||
# Subscription metrics
|
|
||||||
total_subscriptions = Column(Integer, default=0, nullable=False)
|
|
||||||
active_subscriptions = Column(Integer, default=0, nullable=False)
|
|
||||||
|
|
||||||
# Resource metrics
|
|
||||||
total_products = Column(Integer, default=0, nullable=False)
|
|
||||||
total_orders_month = Column(Integer, default=0, nullable=False)
|
|
||||||
total_team_members = Column(Integer, default=0, nullable=False)
|
|
||||||
|
|
||||||
# Storage metrics
|
|
||||||
storage_used_gb = Column(Numeric(10, 2), default=0, nullable=False)
|
|
||||||
db_size_mb = Column(Numeric(10, 2), default=0, nullable=False)
|
|
||||||
|
|
||||||
# Capacity metrics (theoretical limits from subscriptions)
|
|
||||||
theoretical_products_limit = Column(Integer, nullable=True)
|
|
||||||
theoretical_orders_limit = Column(Integer, nullable=True)
|
|
||||||
theoretical_team_limit = Column(Integer, nullable=True)
|
|
||||||
|
|
||||||
# Tier distribution (JSON: {"essential": 10, "professional": 5, ...})
|
|
||||||
tier_distribution = Column(JSON, nullable=True)
|
|
||||||
|
|
||||||
# Performance metrics
|
|
||||||
avg_response_ms = Column(Integer, nullable=True)
|
|
||||||
peak_cpu_percent = Column(Numeric(5, 2), nullable=True)
|
|
||||||
peak_memory_percent = Column(Numeric(5, 2), nullable=True)
|
|
||||||
|
|
||||||
# Indexes
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_capacity_snapshots_date", "snapshot_date"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<CapacitySnapshot(date={self.snapshot_date}, stores={self.total_stores})>"
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Each main router (admin.py, store.py) aggregates its related sub-routers interna
|
|||||||
Merchant routes are auto-discovered from merchant.py.
|
Merchant routes are auto-discovered from merchant.py.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from app.modules.billing.routes.api.admin import admin_router
|
from app.modules.billing.routes.api.admin import router as admin_router
|
||||||
from app.modules.billing.routes.api.store import store_router
|
from app.modules.billing.routes.api.store import router as store_router
|
||||||
|
|
||||||
__all__ = ["admin_router", "store_router"]
|
__all__ = ["admin_router", "store_router"]
|
||||||
|
|||||||
@@ -11,12 +11,11 @@ Provides admin API endpoints for subscription and billing management:
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_admin_api, require_module_access
|
from app.api.deps import get_current_admin_api, require_module_access
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.exceptions import ResourceNotFoundException
|
|
||||||
from app.modules.billing.schemas import (
|
from app.modules.billing.schemas import (
|
||||||
BillingHistoryListResponse,
|
BillingHistoryListResponse,
|
||||||
BillingHistoryWithMerchant,
|
BillingHistoryWithMerchant,
|
||||||
@@ -36,12 +35,12 @@ from app.modules.billing.services import (
|
|||||||
subscription_service,
|
subscription_service,
|
||||||
)
|
)
|
||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
from models.schema.auth import UserContext
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Admin router with module access control
|
# Admin router with module access control
|
||||||
admin_router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/subscriptions",
|
prefix="/subscriptions",
|
||||||
dependencies=[Depends(require_module_access("billing", FrontendType.ADMIN))],
|
dependencies=[Depends(require_module_access("billing", FrontendType.ADMIN))],
|
||||||
)
|
)
|
||||||
@@ -52,7 +51,7 @@ admin_router = APIRouter(
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("/tiers", response_model=SubscriptionTierListResponse)
|
@router.get("/tiers", response_model=SubscriptionTierListResponse)
|
||||||
def list_subscription_tiers(
|
def list_subscription_tiers(
|
||||||
include_inactive: bool = Query(False, description="Include inactive tiers"),
|
include_inactive: bool = Query(False, description="Include inactive tiers"),
|
||||||
platform_id: int | None = Query(None, description="Filter tiers by platform"),
|
platform_id: int | None = Query(None, description="Filter tiers by platform"),
|
||||||
@@ -62,13 +61,12 @@ def list_subscription_tiers(
|
|||||||
"""List all subscription tiers."""
|
"""List all subscription tiers."""
|
||||||
tiers = admin_subscription_service.get_tiers(db, include_inactive=include_inactive, platform_id=platform_id)
|
tiers = admin_subscription_service.get_tiers(db, include_inactive=include_inactive, platform_id=platform_id)
|
||||||
|
|
||||||
from app.modules.tenancy.models import Platform
|
platforms_map = admin_subscription_service.get_platform_names_map(db)
|
||||||
|
|
||||||
platforms_map = {p.id: p.name for p in db.query(Platform).all()}
|
|
||||||
tiers_response = []
|
tiers_response = []
|
||||||
for t in tiers:
|
for t in tiers:
|
||||||
resp = SubscriptionTierResponse.model_validate(t)
|
resp = SubscriptionTierResponse.model_validate(t)
|
||||||
resp.platform_name = platforms_map.get(t.platform_id) if t.platform_id else None
|
resp.platform_name = platforms_map.get(t.platform_id) if t.platform_id else None
|
||||||
|
resp.feature_codes = sorted(t.get_feature_codes())
|
||||||
tiers_response.append(resp)
|
tiers_response.append(resp)
|
||||||
|
|
||||||
return SubscriptionTierListResponse(
|
return SubscriptionTierListResponse(
|
||||||
@@ -77,7 +75,7 @@ def list_subscription_tiers(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("/tiers/{tier_code}", response_model=SubscriptionTierResponse)
|
@router.get("/tiers/{tier_code}", response_model=SubscriptionTierResponse)
|
||||||
def get_subscription_tier(
|
def get_subscription_tier(
|
||||||
tier_code: str = Path(..., description="Tier code"),
|
tier_code: str = Path(..., description="Tier code"),
|
||||||
current_user: UserContext = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
@@ -85,10 +83,12 @@ def get_subscription_tier(
|
|||||||
):
|
):
|
||||||
"""Get a specific subscription tier by code."""
|
"""Get a specific subscription tier by code."""
|
||||||
tier = admin_subscription_service.get_tier_by_code(db, tier_code)
|
tier = admin_subscription_service.get_tier_by_code(db, tier_code)
|
||||||
return SubscriptionTierResponse.model_validate(tier)
|
resp = SubscriptionTierResponse.model_validate(tier)
|
||||||
|
resp.feature_codes = sorted(tier.get_feature_codes())
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@admin_router.post("/tiers", response_model=SubscriptionTierResponse, status_code=201)
|
@router.post("/tiers", response_model=SubscriptionTierResponse, status_code=201)
|
||||||
def create_subscription_tier(
|
def create_subscription_tier(
|
||||||
tier_data: SubscriptionTierCreate,
|
tier_data: SubscriptionTierCreate,
|
||||||
current_user: UserContext = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
@@ -98,10 +98,12 @@ def create_subscription_tier(
|
|||||||
tier = admin_subscription_service.create_tier(db, tier_data.model_dump())
|
tier = admin_subscription_service.create_tier(db, tier_data.model_dump())
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(tier)
|
db.refresh(tier)
|
||||||
return SubscriptionTierResponse.model_validate(tier)
|
resp = SubscriptionTierResponse.model_validate(tier)
|
||||||
|
resp.feature_codes = sorted(tier.get_feature_codes())
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@admin_router.patch("/tiers/{tier_code}", response_model=SubscriptionTierResponse)
|
@router.patch("/tiers/{tier_code}", response_model=SubscriptionTierResponse)
|
||||||
def update_subscription_tier(
|
def update_subscription_tier(
|
||||||
tier_data: SubscriptionTierUpdate,
|
tier_data: SubscriptionTierUpdate,
|
||||||
tier_code: str = Path(..., description="Tier code"),
|
tier_code: str = Path(..., description="Tier code"),
|
||||||
@@ -113,10 +115,12 @@ def update_subscription_tier(
|
|||||||
tier = admin_subscription_service.update_tier(db, tier_code, update_data)
|
tier = admin_subscription_service.update_tier(db, tier_code, update_data)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(tier)
|
db.refresh(tier)
|
||||||
return SubscriptionTierResponse.model_validate(tier)
|
resp = SubscriptionTierResponse.model_validate(tier)
|
||||||
|
resp.feature_codes = sorted(tier.get_feature_codes())
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@admin_router.delete("/tiers/{tier_code}", status_code=204)
|
@router.delete("/tiers/{tier_code}", status_code=204)
|
||||||
def delete_subscription_tier(
|
def delete_subscription_tier(
|
||||||
tier_code: str = Path(..., description="Tier code"),
|
tier_code: str = Path(..., description="Tier code"),
|
||||||
current_user: UserContext = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
@@ -132,7 +136,7 @@ def delete_subscription_tier(
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("", response_model=MerchantSubscriptionListResponse)
|
@router.get("", response_model=MerchantSubscriptionListResponse)
|
||||||
def list_merchant_subscriptions(
|
def list_merchant_subscriptions(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
per_page: int = Query(20, ge=1, le=100),
|
per_page: int = Query(20, ge=1, le=100),
|
||||||
@@ -147,18 +151,17 @@ def list_merchant_subscriptions(
|
|||||||
db, page=page, per_page=per_page, status=status, tier=tier, search=search
|
db, page=page, per_page=per_page, status=status, tier=tier, search=search
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.modules.tenancy.models import Platform
|
platforms_map = admin_subscription_service.get_platform_names_map(db)
|
||||||
|
|
||||||
subscriptions = []
|
subscriptions = []
|
||||||
for sub, merchant in data["results"]:
|
for sub, merchant in data["results"]:
|
||||||
sub_resp = MerchantSubscriptionAdminResponse.model_validate(sub)
|
sub_resp = MerchantSubscriptionAdminResponse.model_validate(sub)
|
||||||
tier_name = sub.tier.name if sub.tier else None
|
tier_name = sub.tier.name if sub.tier else None
|
||||||
platform = db.query(Platform).filter(Platform.id == sub.platform_id).first()
|
|
||||||
subscriptions.append(
|
subscriptions.append(
|
||||||
MerchantSubscriptionWithMerchant(
|
MerchantSubscriptionWithMerchant(
|
||||||
**sub_resp.model_dump(),
|
**sub_resp.model_dump(),
|
||||||
merchant_name=merchant.name,
|
merchant_name=merchant.name,
|
||||||
platform_name=platform.name if platform else "",
|
platform_name=platforms_map.get(sub.platform_id, ""),
|
||||||
tier_name=tier_name,
|
tier_name=tier_name,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -172,7 +175,20 @@ def list_merchant_subscriptions(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.post(
|
@router.get("/merchants/{merchant_id}")
|
||||||
|
def get_merchant_subscriptions(
|
||||||
|
merchant_id: int = Path(..., description="Merchant ID"),
|
||||||
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get all subscriptions for a merchant with tier info and feature usage."""
|
||||||
|
results = admin_subscription_service.get_merchant_subscriptions_with_usage(
|
||||||
|
db, merchant_id
|
||||||
|
)
|
||||||
|
return {"subscriptions": results} # noqa: API001
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
"/merchants/{merchant_id}/platforms/{platform_id}",
|
"/merchants/{merchant_id}/platforms/{platform_id}",
|
||||||
response_model=MerchantSubscriptionAdminResponse,
|
response_model=MerchantSubscriptionAdminResponse,
|
||||||
status_code=201,
|
status_code=201,
|
||||||
@@ -210,7 +226,7 @@ def create_merchant_subscription(
|
|||||||
return MerchantSubscriptionAdminResponse.model_validate(sub)
|
return MerchantSubscriptionAdminResponse.model_validate(sub)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get(
|
@router.get(
|
||||||
"/merchants/{merchant_id}/platforms/{platform_id}",
|
"/merchants/{merchant_id}/platforms/{platform_id}",
|
||||||
response_model=MerchantSubscriptionAdminResponse,
|
response_model=MerchantSubscriptionAdminResponse,
|
||||||
)
|
)
|
||||||
@@ -227,7 +243,7 @@ def get_merchant_subscription(
|
|||||||
return MerchantSubscriptionAdminResponse.model_validate(sub)
|
return MerchantSubscriptionAdminResponse.model_validate(sub)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.patch(
|
@router.patch(
|
||||||
"/merchants/{merchant_id}/platforms/{platform_id}",
|
"/merchants/{merchant_id}/platforms/{platform_id}",
|
||||||
response_model=MerchantSubscriptionAdminResponse,
|
response_model=MerchantSubscriptionAdminResponse,
|
||||||
)
|
)
|
||||||
@@ -254,7 +270,7 @@ def update_merchant_subscription(
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("/store/{store_id}")
|
@router.get("/store/{store_id}")
|
||||||
def get_subscription_for_store(
|
def get_subscription_for_store(
|
||||||
store_id: int = Path(..., description="Store ID"),
|
store_id: int = Path(..., description="Store ID"),
|
||||||
current_user: UserContext = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
@@ -267,58 +283,8 @@ def get_subscription_for_store(
|
|||||||
store -> merchant -> all platform subscriptions and returns a list
|
store -> merchant -> all platform subscriptions and returns a list
|
||||||
of subscription entries with feature usage metrics.
|
of subscription entries with feature usage metrics.
|
||||||
"""
|
"""
|
||||||
from app.modules.billing.services.feature_service import feature_service
|
results = admin_subscription_service.get_subscriptions_for_store(db, store_id)
|
||||||
from app.modules.tenancy.models import Platform
|
return {"subscriptions": results} # noqa: API001
|
||||||
|
|
||||||
# Resolve store to merchant + all platform IDs
|
|
||||||
merchant_id, platform_ids = feature_service._get_merchant_and_platforms_for_store(db, store_id)
|
|
||||||
if merchant_id is None or not platform_ids:
|
|
||||||
raise HTTPException(status_code=404, detail="Store not found or has no platform association")
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for pid in platform_ids:
|
|
||||||
try:
|
|
||||||
sub, merchant = admin_subscription_service.get_subscription(db, merchant_id, pid)
|
|
||||||
except ResourceNotFoundException:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get feature summary
|
|
||||||
features_summary = feature_service.get_merchant_features_summary(db, merchant_id, pid)
|
|
||||||
|
|
||||||
# Build tier info
|
|
||||||
tier_info = None
|
|
||||||
if sub.tier:
|
|
||||||
tier_info = {
|
|
||||||
"code": sub.tier.code,
|
|
||||||
"name": sub.tier.name,
|
|
||||||
"feature_codes": [fl.feature_code for fl in (sub.tier.feature_limits or [])],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build usage metrics (quantitative features only)
|
|
||||||
usage_metrics = []
|
|
||||||
for fs in features_summary:
|
|
||||||
if fs.feature_type == "quantitative" and fs.enabled:
|
|
||||||
usage_metrics.append({
|
|
||||||
"name": fs.name_key.replace("_", " ").title(),
|
|
||||||
"current": fs.current or 0,
|
|
||||||
"limit": fs.limit,
|
|
||||||
"percentage": fs.percent_used or 0,
|
|
||||||
"is_unlimited": fs.limit is None,
|
|
||||||
"is_at_limit": fs.remaining == 0 if fs.remaining is not None else False,
|
|
||||||
"is_approaching_limit": (fs.percent_used or 0) >= 80,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Resolve platform name
|
|
||||||
platform = db.query(Platform).filter(Platform.id == pid).first()
|
|
||||||
|
|
||||||
results.append({
|
|
||||||
"subscription": MerchantSubscriptionAdminResponse.model_validate(sub).model_dump(),
|
|
||||||
"tier": tier_info,
|
|
||||||
"features": usage_metrics,
|
|
||||||
"platform_name": platform.name if platform else "",
|
|
||||||
})
|
|
||||||
|
|
||||||
return {"subscriptions": results}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -326,7 +292,7 @@ def get_subscription_for_store(
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("/stats", response_model=SubscriptionStatsResponse)
|
@router.get("/stats", response_model=SubscriptionStatsResponse)
|
||||||
def get_subscription_stats(
|
def get_subscription_stats(
|
||||||
current_user: UserContext = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -341,7 +307,7 @@ def get_subscription_stats(
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("/billing/history", response_model=BillingHistoryListResponse)
|
@router.get("/billing/history", response_model=BillingHistoryListResponse)
|
||||||
def list_billing_history(
|
def list_billing_history(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
per_page: int = Query(20, ge=1, le=100),
|
per_page: int = Query(20, ge=1, le=100),
|
||||||
@@ -394,4 +360,4 @@ def list_billing_history(
|
|||||||
# Include the features router to aggregate all billing-related admin routes
|
# Include the features router to aggregate all billing-related admin routes
|
||||||
from app.modules.billing.routes.api.admin_features import admin_features_router
|
from app.modules.billing.routes.api.admin_features import admin_features_router
|
||||||
|
|
||||||
admin_router.include_router(admin_features_router, tags=["admin-features"])
|
router.include_router(admin_features_router, tags=["admin-features"])
|
||||||
|
|||||||
@@ -12,16 +12,12 @@ All routes require module access control for the 'billing' module.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
from fastapi import APIRouter, Depends, Path
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_admin_api, require_module_access
|
from app.api.deps import get_current_admin_api, require_module_access
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.modules.billing.models import SubscriptionTier
|
from app.modules.billing.exceptions import InvalidFeatureCodesError
|
||||||
from app.modules.billing.models.tier_feature_limit import (
|
|
||||||
MerchantFeatureOverride,
|
|
||||||
TierFeatureLimit,
|
|
||||||
)
|
|
||||||
from app.modules.billing.schemas import (
|
from app.modules.billing.schemas import (
|
||||||
FeatureCatalogResponse,
|
FeatureCatalogResponse,
|
||||||
FeatureDeclarationResponse,
|
FeatureDeclarationResponse,
|
||||||
@@ -30,8 +26,9 @@ from app.modules.billing.schemas import (
|
|||||||
TierFeatureLimitEntry,
|
TierFeatureLimitEntry,
|
||||||
)
|
)
|
||||||
from app.modules.billing.services.feature_aggregator import feature_aggregator
|
from app.modules.billing.services.feature_aggregator import feature_aggregator
|
||||||
|
from app.modules.billing.services.feature_service import feature_service
|
||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
from models.schema.auth import UserContext
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
|
|
||||||
admin_features_router = APIRouter(
|
admin_features_router = APIRouter(
|
||||||
prefix="/features",
|
prefix="/features",
|
||||||
@@ -40,23 +37,6 @@ admin_features_router = APIRouter(
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Helper Functions
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def _get_tier_or_404(db: Session, tier_code: str) -> SubscriptionTier:
|
|
||||||
"""Look up a SubscriptionTier by code, raising 404 if not found."""
|
|
||||||
tier = (
|
|
||||||
db.query(SubscriptionTier)
|
|
||||||
.filter(SubscriptionTier.code == tier_code)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if not tier:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Tier '{tier_code}' not found")
|
|
||||||
return tier
|
|
||||||
|
|
||||||
|
|
||||||
def _declaration_to_response(decl) -> FeatureDeclarationResponse:
|
def _declaration_to_response(decl) -> FeatureDeclarationResponse:
|
||||||
"""Convert a FeatureDeclaration dataclass to its Pydantic response schema."""
|
"""Convert a FeatureDeclaration dataclass to its Pydantic response schema."""
|
||||||
return FeatureDeclarationResponse(
|
return FeatureDeclarationResponse(
|
||||||
@@ -106,11 +86,11 @@ def get_feature_catalog(
|
|||||||
|
|
||||||
|
|
||||||
@admin_features_router.get(
|
@admin_features_router.get(
|
||||||
"/tiers/{tier_code}/limits",
|
"/tiers/{tier_id}/limits",
|
||||||
response_model=list[TierFeatureLimitEntry],
|
response_model=list[TierFeatureLimitEntry],
|
||||||
)
|
)
|
||||||
def get_tier_feature_limits(
|
def get_tier_feature_limits(
|
||||||
tier_code: str = Path(..., description="Tier code"),
|
tier_id: int = Path(..., description="Tier ID"),
|
||||||
current_user: UserContext = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -120,14 +100,7 @@ def get_tier_feature_limits(
|
|||||||
Returns all TierFeatureLimit rows associated with the tier,
|
Returns all TierFeatureLimit rows associated with the tier,
|
||||||
each containing a feature_code and its optional limit_value.
|
each containing a feature_code and its optional limit_value.
|
||||||
"""
|
"""
|
||||||
tier = _get_tier_or_404(db, tier_code)
|
rows = feature_service.get_tier_feature_limits(db, tier_id)
|
||||||
|
|
||||||
rows = (
|
|
||||||
db.query(TierFeatureLimit)
|
|
||||||
.filter(TierFeatureLimit.tier_id == tier.id)
|
|
||||||
.order_by(TierFeatureLimit.feature_code)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
TierFeatureLimitEntry(
|
TierFeatureLimitEntry(
|
||||||
@@ -140,12 +113,12 @@ def get_tier_feature_limits(
|
|||||||
|
|
||||||
|
|
||||||
@admin_features_router.put(
|
@admin_features_router.put(
|
||||||
"/tiers/{tier_code}/limits",
|
"/tiers/{tier_id}/limits",
|
||||||
response_model=list[TierFeatureLimitEntry],
|
response_model=list[TierFeatureLimitEntry],
|
||||||
)
|
)
|
||||||
def upsert_tier_feature_limits(
|
def upsert_tier_feature_limits(
|
||||||
entries: list[TierFeatureLimitEntry],
|
entries: list[TierFeatureLimitEntry],
|
||||||
tier_code: str = Path(..., description="Tier code"),
|
tier_id: int = Path(..., description="Tier ID"),
|
||||||
current_user: UserContext = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -156,39 +129,22 @@ def upsert_tier_feature_limits(
|
|||||||
inserts the provided entries. Only entries with enabled=True
|
inserts the provided entries. Only entries with enabled=True
|
||||||
are persisted (disabled entries are simply omitted).
|
are persisted (disabled entries are simply omitted).
|
||||||
"""
|
"""
|
||||||
tier = _get_tier_or_404(db, tier_code)
|
|
||||||
|
|
||||||
# Validate feature codes against the catalog
|
# Validate feature codes against the catalog
|
||||||
submitted_codes = {e.feature_code for e in entries}
|
submitted_codes = {e.feature_code for e in entries}
|
||||||
invalid_codes = feature_aggregator.validate_feature_codes(submitted_codes)
|
invalid_codes = feature_aggregator.validate_feature_codes(submitted_codes)
|
||||||
if invalid_codes:
|
if invalid_codes:
|
||||||
raise HTTPException(
|
raise InvalidFeatureCodesError(invalid_codes)
|
||||||
status_code=422,
|
|
||||||
detail=f"Unknown feature codes: {sorted(invalid_codes)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete existing limits for this tier
|
new_rows = feature_service.upsert_tier_feature_limits(
|
||||||
db.query(TierFeatureLimit).filter(TierFeatureLimit.tier_id == tier.id).delete()
|
db, tier_id, [e.model_dump() for e in entries]
|
||||||
|
)
|
||||||
# Insert new limits (only enabled entries)
|
|
||||||
new_rows = []
|
|
||||||
for entry in entries:
|
|
||||||
if not entry.enabled:
|
|
||||||
continue
|
|
||||||
row = TierFeatureLimit(
|
|
||||||
tier_id=tier.id,
|
|
||||||
feature_code=entry.feature_code,
|
|
||||||
limit_value=entry.limit_value,
|
|
||||||
)
|
|
||||||
db.add(row)
|
|
||||||
new_rows.append(row)
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Admin %s replaced tier '%s' feature limits (%d entries)",
|
"Admin %s replaced tier %d feature limits (%d entries)",
|
||||||
current_user.id,
|
current_user.id,
|
||||||
tier_code,
|
tier_id,
|
||||||
len(new_rows),
|
len(new_rows),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -222,12 +178,7 @@ def get_merchant_feature_overrides(
|
|||||||
Returns MerchantFeatureOverride rows that allow per-merchant
|
Returns MerchantFeatureOverride rows that allow per-merchant
|
||||||
exceptions to the default tier limits (e.g. granting extra products).
|
exceptions to the default tier limits (e.g. granting extra products).
|
||||||
"""
|
"""
|
||||||
rows = (
|
rows = feature_service.get_merchant_overrides(db, merchant_id)
|
||||||
db.query(MerchantFeatureOverride)
|
|
||||||
.filter(MerchantFeatureOverride.merchant_id == merchant_id)
|
|
||||||
.order_by(MerchantFeatureOverride.feature_code)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
return [MerchantFeatureOverrideResponse.model_validate(row) for row in rows]
|
return [MerchantFeatureOverrideResponse.model_validate(row) for row in rows]
|
||||||
|
|
||||||
@@ -251,50 +202,23 @@ def upsert_merchant_feature_overrides(
|
|||||||
|
|
||||||
The platform_id is derived from the admin's current platform context.
|
The platform_id is derived from the admin's current platform context.
|
||||||
"""
|
"""
|
||||||
|
from app.exceptions import ValidationException
|
||||||
|
|
||||||
platform_id = current_user.token_platform_id
|
platform_id = current_user.token_platform_id
|
||||||
if not platform_id:
|
if not platform_id:
|
||||||
raise HTTPException(
|
raise ValidationException(
|
||||||
status_code=400,
|
message="Platform context required. Select a platform first.",
|
||||||
detail="Platform context required. Select a platform first.",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate feature codes against the catalog
|
# Validate feature codes against the catalog
|
||||||
submitted_codes = {e.feature_code for e in entries}
|
submitted_codes = {e.feature_code for e in entries}
|
||||||
invalid_codes = feature_aggregator.validate_feature_codes(submitted_codes)
|
invalid_codes = feature_aggregator.validate_feature_codes(submitted_codes)
|
||||||
if invalid_codes:
|
if invalid_codes:
|
||||||
raise HTTPException(
|
raise InvalidFeatureCodesError(invalid_codes)
|
||||||
status_code=422,
|
|
||||||
detail=f"Unknown feature codes: {sorted(invalid_codes)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
results = []
|
results = feature_service.upsert_merchant_overrides(
|
||||||
for entry in entries:
|
db, merchant_id, platform_id, [e.model_dump() for e in entries]
|
||||||
existing = (
|
)
|
||||||
db.query(MerchantFeatureOverride)
|
|
||||||
.filter(
|
|
||||||
MerchantFeatureOverride.merchant_id == merchant_id,
|
|
||||||
MerchantFeatureOverride.platform_id == platform_id,
|
|
||||||
MerchantFeatureOverride.feature_code == entry.feature_code,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
existing.limit_value = entry.limit_value
|
|
||||||
existing.is_enabled = entry.is_enabled
|
|
||||||
existing.reason = entry.reason
|
|
||||||
results.append(existing)
|
|
||||||
else:
|
|
||||||
row = MerchantFeatureOverride(
|
|
||||||
merchant_id=merchant_id,
|
|
||||||
platform_id=platform_id,
|
|
||||||
feature_code=entry.feature_code,
|
|
||||||
limit_value=entry.limit_value,
|
|
||||||
is_enabled=entry.is_enabled,
|
|
||||||
reason=entry.reason,
|
|
||||||
)
|
|
||||||
db.add(row)
|
|
||||||
results.append(row)
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -9,128 +9,44 @@ for all billing service calls.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_store_api, require_module_access
|
from app.api.deps import get_current_store_api, require_module_access
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.modules.billing.schemas.billing import (
|
||||||
|
InvoiceListResponse,
|
||||||
|
InvoiceResponse,
|
||||||
|
SubscriptionStatusResponse,
|
||||||
|
TierListResponse,
|
||||||
|
TierResponse,
|
||||||
|
)
|
||||||
from app.modules.billing.services import billing_service, subscription_service
|
from app.modules.billing.services import billing_service, subscription_service
|
||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
from app.modules.tenancy.models import User
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Store router with module access control
|
# Store router with module access control
|
||||||
store_router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/billing",
|
prefix="/billing",
|
||||||
dependencies=[Depends(require_module_access("billing", FrontendType.STORE))],
|
dependencies=[Depends(require_module_access("billing", FrontendType.STORE))],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Helpers
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_store_to_merchant(db: Session, store_id: int) -> tuple[int, int]:
|
|
||||||
"""Resolve store_id to (merchant_id, platform_id)."""
|
|
||||||
from app.modules.tenancy.models import Store, StorePlatform
|
|
||||||
|
|
||||||
store = db.query(Store).filter(Store.id == store_id).first()
|
|
||||||
if not store or not store.merchant_id:
|
|
||||||
raise HTTPException(status_code=404, detail="Store not found")
|
|
||||||
|
|
||||||
sp = db.query(StorePlatform.platform_id).filter(
|
|
||||||
StorePlatform.store_id == store_id
|
|
||||||
).first()
|
|
||||||
if not sp:
|
|
||||||
raise HTTPException(status_code=404, detail="Store not linked to platform")
|
|
||||||
|
|
||||||
return store.merchant_id, sp[0]
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Schemas
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionStatusResponse(BaseModel):
|
|
||||||
"""Current subscription status."""
|
|
||||||
|
|
||||||
tier_code: str
|
|
||||||
tier_name: str
|
|
||||||
status: str
|
|
||||||
is_trial: bool
|
|
||||||
trial_ends_at: str | None = None
|
|
||||||
period_start: str | None = None
|
|
||||||
period_end: str | None = None
|
|
||||||
cancelled_at: str | None = None
|
|
||||||
cancellation_reason: str | None = None
|
|
||||||
has_payment_method: bool
|
|
||||||
last_payment_error: str | None = None
|
|
||||||
feature_codes: list[str] = []
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
class TierResponse(BaseModel):
|
|
||||||
"""Subscription tier information."""
|
|
||||||
|
|
||||||
code: str
|
|
||||||
name: str
|
|
||||||
description: str | None = None
|
|
||||||
price_monthly_cents: int
|
|
||||||
price_annual_cents: int | None = None
|
|
||||||
feature_codes: list[str] = []
|
|
||||||
is_current: bool = False
|
|
||||||
can_upgrade: bool = False
|
|
||||||
can_downgrade: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class TierListResponse(BaseModel):
|
|
||||||
"""List of available tiers."""
|
|
||||||
|
|
||||||
tiers: list[TierResponse]
|
|
||||||
current_tier: str
|
|
||||||
|
|
||||||
|
|
||||||
class InvoiceResponse(BaseModel):
|
|
||||||
"""Invoice information."""
|
|
||||||
|
|
||||||
id: int
|
|
||||||
invoice_number: str | None = None
|
|
||||||
invoice_date: str
|
|
||||||
due_date: str | None = None
|
|
||||||
total_cents: int
|
|
||||||
amount_paid_cents: int
|
|
||||||
currency: str
|
|
||||||
status: str
|
|
||||||
pdf_url: str | None = None
|
|
||||||
hosted_url: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class InvoiceListResponse(BaseModel):
|
|
||||||
"""List of invoices."""
|
|
||||||
|
|
||||||
invoices: list[InvoiceResponse]
|
|
||||||
total: int
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Core Billing Endpoints
|
# Core Billing Endpoints
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@store_router.get("/subscription", response_model=SubscriptionStatusResponse)
|
@router.get("/subscription", response_model=SubscriptionStatusResponse)
|
||||||
def get_subscription_status(
|
def get_subscription_status(
|
||||||
current_user: User = Depends(get_current_store_api),
|
current_user: UserContext = Depends(get_current_store_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get current subscription status."""
|
"""Get current subscription status."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = _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)
|
||||||
|
|
||||||
@@ -160,14 +76,14 @@ def get_subscription_status(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@store_router.get("/tiers", response_model=TierListResponse)
|
@router.get("/tiers", response_model=TierListResponse)
|
||||||
def get_available_tiers(
|
def get_available_tiers(
|
||||||
current_user: User = Depends(get_current_store_api),
|
current_user: UserContext = Depends(get_current_store_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get available subscription tiers for upgrade/downgrade."""
|
"""Get available subscription tiers for upgrade/downgrade."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = _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
|
||||||
@@ -180,16 +96,16 @@ def get_available_tiers(
|
|||||||
return TierListResponse(tiers=tier_responses, current_tier=current_tier_code)
|
return TierListResponse(tiers=tier_responses, current_tier=current_tier_code)
|
||||||
|
|
||||||
|
|
||||||
@store_router.get("/invoices", response_model=InvoiceListResponse)
|
@router.get("/invoices", response_model=InvoiceListResponse)
|
||||||
def get_invoices(
|
def get_invoices(
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(20, ge=1, le=100),
|
limit: int = Query(20, ge=1, le=100),
|
||||||
current_user: User = Depends(get_current_store_api),
|
current_user: UserContext = Depends(get_current_store_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get invoice history."""
|
"""Get invoice history."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = _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)
|
||||||
|
|
||||||
@@ -222,7 +138,7 @@ from app.modules.billing.routes.api.store_checkout import store_checkout_router
|
|||||||
from app.modules.billing.routes.api.store_features import store_features_router
|
from app.modules.billing.routes.api.store_features import store_features_router
|
||||||
from app.modules.billing.routes.api.store_usage import store_usage_router
|
from app.modules.billing.routes.api.store_usage import store_usage_router
|
||||||
|
|
||||||
store_router.include_router(store_features_router, tags=["store-features"])
|
router.include_router(store_features_router, tags=["store-features"])
|
||||||
store_router.include_router(store_checkout_router, tags=["store-billing"])
|
router.include_router(store_checkout_router, tags=["store-billing"])
|
||||||
store_router.include_router(store_addons_router, tags=["store-billing-addons"])
|
router.include_router(store_addons_router, tags=["store-billing-addons"])
|
||||||
store_router.include_router(store_usage_router, tags=["store-usage"])
|
router.include_router(store_usage_router, tags=["store-usage"])
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from app.core.config import settings
|
|||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.modules.billing.services import billing_service
|
from app.modules.billing.services import billing_service
|
||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
from models.schema.auth import UserContext
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
|
|
||||||
store_addons_router = APIRouter(
|
store_addons_router = APIRouter(
|
||||||
prefix="/addons",
|
prefix="/addons",
|
||||||
@@ -144,7 +144,7 @@ def purchase_addon(
|
|||||||
store = billing_service.get_store(db, store_id)
|
store = billing_service.get_store(db, store_id)
|
||||||
|
|
||||||
# Build URLs
|
# Build URLs
|
||||||
base_url = f"https://{settings.platform_domain}"
|
base_url = settings.app_base_url.rstrip("/")
|
||||||
success_url = f"{base_url}/store/{store.store_code}/billing?addon_success=true"
|
success_url = f"{base_url}/store/{store.store_code}/billing?addon_success=true"
|
||||||
cancel_url = f"{base_url}/store/{store.store_code}/billing?addon_cancelled=true"
|
cancel_url = f"{base_url}/store/{store.store_code}/billing?addon_cancelled=true"
|
||||||
|
|
||||||
|
|||||||
@@ -15,16 +15,26 @@ Resolves store_id to (merchant_id, platform_id) for all billing service calls.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_store_api, require_module_access
|
from app.api.deps import get_current_store_api, require_module_access
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.modules.billing.schemas.billing import (
|
||||||
|
CancelRequest,
|
||||||
|
CancelResponse,
|
||||||
|
ChangeTierRequest,
|
||||||
|
ChangeTierResponse,
|
||||||
|
CheckoutRequest,
|
||||||
|
CheckoutResponse,
|
||||||
|
PortalResponse,
|
||||||
|
UpcomingInvoiceResponse,
|
||||||
|
)
|
||||||
from app.modules.billing.services import billing_service
|
from app.modules.billing.services import billing_service
|
||||||
|
from app.modules.billing.services.subscription_service import subscription_service
|
||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
from models.schema.auth import UserContext
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
|
|
||||||
store_checkout_router = APIRouter(
|
store_checkout_router = APIRouter(
|
||||||
dependencies=[Depends(require_module_access("billing", FrontendType.STORE))],
|
dependencies=[Depends(require_module_access("billing", FrontendType.STORE))],
|
||||||
@@ -32,91 +42,6 @@ store_checkout_router = APIRouter(
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Helpers
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_store_to_merchant(db: Session, store_id: int) -> tuple[int, int]:
|
|
||||||
"""Resolve store_id to (merchant_id, platform_id)."""
|
|
||||||
from app.modules.tenancy.models import Store, StorePlatform
|
|
||||||
|
|
||||||
store = db.query(Store).filter(Store.id == store_id).first()
|
|
||||||
if not store or not store.merchant_id:
|
|
||||||
raise HTTPException(status_code=404, detail="Store not found")
|
|
||||||
|
|
||||||
sp = db.query(StorePlatform.platform_id).filter(
|
|
||||||
StorePlatform.store_id == store_id
|
|
||||||
).first()
|
|
||||||
if not sp:
|
|
||||||
raise HTTPException(status_code=404, detail="Store not linked to platform")
|
|
||||||
|
|
||||||
return store.merchant_id, sp[0]
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Schemas
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class CheckoutRequest(BaseModel):
|
|
||||||
"""Request to create a checkout session."""
|
|
||||||
|
|
||||||
tier_code: str
|
|
||||||
is_annual: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class CheckoutResponse(BaseModel):
|
|
||||||
"""Checkout session response."""
|
|
||||||
|
|
||||||
checkout_url: str
|
|
||||||
session_id: str
|
|
||||||
|
|
||||||
|
|
||||||
class PortalResponse(BaseModel):
|
|
||||||
"""Customer portal session response."""
|
|
||||||
|
|
||||||
portal_url: str
|
|
||||||
|
|
||||||
|
|
||||||
class CancelRequest(BaseModel):
|
|
||||||
"""Request to cancel subscription."""
|
|
||||||
|
|
||||||
reason: str | None = None
|
|
||||||
immediately: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class CancelResponse(BaseModel):
|
|
||||||
"""Cancellation response."""
|
|
||||||
|
|
||||||
message: str
|
|
||||||
effective_date: str
|
|
||||||
|
|
||||||
|
|
||||||
class UpcomingInvoiceResponse(BaseModel):
|
|
||||||
"""Upcoming invoice preview."""
|
|
||||||
|
|
||||||
amount_due_cents: int
|
|
||||||
currency: str
|
|
||||||
next_payment_date: str | None = None
|
|
||||||
line_items: list[dict] = []
|
|
||||||
|
|
||||||
|
|
||||||
class ChangeTierRequest(BaseModel):
|
|
||||||
"""Request to change subscription tier."""
|
|
||||||
|
|
||||||
tier_code: str
|
|
||||||
is_annual: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class ChangeTierResponse(BaseModel):
|
|
||||||
"""Response for tier change."""
|
|
||||||
|
|
||||||
message: str
|
|
||||||
new_tier: str
|
|
||||||
effective_immediately: bool
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Endpoints
|
# Endpoints
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -130,15 +55,13 @@ 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 = _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)
|
||||||
|
|
||||||
from app.modules.tenancy.models import Store
|
store_code = subscription_service.get_store_code(db, store_id)
|
||||||
|
|
||||||
store = db.query(Store).filter(Store.id == store_id).first()
|
base_url = settings.app_base_url.rstrip("/")
|
||||||
|
success_url = f"{base_url}/store/{store_code}/billing?success=true"
|
||||||
base_url = f"https://{settings.platform_domain}"
|
cancel_url = f"{base_url}/store/{store_code}/billing?cancelled=true"
|
||||||
success_url = f"{base_url}/store/{store.store_code}/billing?success=true"
|
|
||||||
cancel_url = f"{base_url}/store/{store.store_code}/billing?cancelled=true"
|
|
||||||
|
|
||||||
result = billing_service.create_checkout_session(
|
result = billing_service.create_checkout_session(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -161,12 +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 = _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)
|
||||||
|
|
||||||
from app.modules.tenancy.models import Store
|
store_code = subscription_service.get_store_code(db, store_id)
|
||||||
|
return_url = f"{settings.app_base_url.rstrip('/')}/store/{store_code}/billing"
|
||||||
store = db.query(Store).filter(Store.id == store_id).first()
|
|
||||||
return_url = f"https://{settings.platform_domain}/store/{store.store_code}/billing"
|
|
||||||
|
|
||||||
result = billing_service.create_portal_session(db, merchant_id, platform_id, return_url)
|
result = billing_service.create_portal_session(db, merchant_id, platform_id, return_url)
|
||||||
|
|
||||||
@@ -181,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 = _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,
|
||||||
@@ -205,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 = _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()
|
||||||
@@ -220,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 = _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)
|
||||||
|
|
||||||
@@ -240,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 = _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,
|
||||||
|
|||||||
@@ -19,18 +19,26 @@ All routes require module access control for the 'billing' module.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_store_api, require_module_access
|
from app.api.deps import get_current_store_api, require_module_access
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.modules.billing.exceptions import FeatureNotFoundError
|
from app.modules.billing.exceptions import FeatureNotFoundException
|
||||||
|
from app.modules.billing.schemas.billing import (
|
||||||
|
CategoryListResponse,
|
||||||
|
FeatureCodeListResponse,
|
||||||
|
FeatureDetailResponse,
|
||||||
|
FeatureGroupedResponse,
|
||||||
|
FeatureListResponse,
|
||||||
|
FeatureResponse,
|
||||||
|
StoreFeatureCheckResponse,
|
||||||
|
)
|
||||||
from app.modules.billing.services.feature_aggregator import feature_aggregator
|
from app.modules.billing.services.feature_aggregator import feature_aggregator
|
||||||
from app.modules.billing.services.feature_service import feature_service
|
from app.modules.billing.services.feature_service import feature_service
|
||||||
from app.modules.billing.services.subscription_service import subscription_service
|
from app.modules.billing.services.subscription_service import subscription_service
|
||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
from models.schema.auth import UserContext
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
|
|
||||||
store_features_router = APIRouter(
|
store_features_router = APIRouter(
|
||||||
prefix="/features",
|
prefix="/features",
|
||||||
@@ -39,100 +47,6 @@ store_features_router = APIRouter(
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Helpers
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_store_to_merchant(db: Session, store_id: int) -> tuple[int, int]:
|
|
||||||
"""Resolve store_id to (merchant_id, platform_id)."""
|
|
||||||
from app.modules.tenancy.models import Store, StorePlatform
|
|
||||||
|
|
||||||
store = db.query(Store).filter(Store.id == store_id).first()
|
|
||||||
if not store or not store.merchant_id:
|
|
||||||
raise HTTPException(status_code=404, detail="Store not found")
|
|
||||||
|
|
||||||
sp = db.query(StorePlatform.platform_id).filter(
|
|
||||||
StorePlatform.store_id == store_id
|
|
||||||
).first()
|
|
||||||
if not sp:
|
|
||||||
raise HTTPException(status_code=404, detail="Store not linked to platform")
|
|
||||||
|
|
||||||
return store.merchant_id, sp[0]
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Response Schemas
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class FeatureCodeListResponse(BaseModel):
|
|
||||||
"""Simple list of available feature codes for quick checks."""
|
|
||||||
|
|
||||||
features: list[str]
|
|
||||||
tier_code: str
|
|
||||||
tier_name: str
|
|
||||||
|
|
||||||
|
|
||||||
class FeatureResponse(BaseModel):
|
|
||||||
"""Full feature information."""
|
|
||||||
|
|
||||||
code: str
|
|
||||||
name: str
|
|
||||||
description: str | None = None
|
|
||||||
category: str
|
|
||||||
feature_type: str | None = None
|
|
||||||
ui_icon: str | None = None
|
|
||||||
is_available: bool
|
|
||||||
|
|
||||||
|
|
||||||
class FeatureListResponse(BaseModel):
|
|
||||||
"""List of features with metadata."""
|
|
||||||
|
|
||||||
features: list[FeatureResponse]
|
|
||||||
available_count: int
|
|
||||||
total_count: int
|
|
||||||
tier_code: str
|
|
||||||
tier_name: str
|
|
||||||
|
|
||||||
|
|
||||||
class FeatureDetailResponse(BaseModel):
|
|
||||||
"""Single feature detail with upgrade info."""
|
|
||||||
|
|
||||||
code: str
|
|
||||||
name: str
|
|
||||||
description: str | None = None
|
|
||||||
category: str
|
|
||||||
feature_type: str | None = None
|
|
||||||
ui_icon: str | None = None
|
|
||||||
is_available: bool
|
|
||||||
# Upgrade info (only if not available)
|
|
||||||
upgrade_tier_code: str | None = None
|
|
||||||
upgrade_tier_name: str | None = None
|
|
||||||
upgrade_tier_price_monthly_cents: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class CategoryListResponse(BaseModel):
|
|
||||||
"""List of feature categories."""
|
|
||||||
|
|
||||||
categories: list[str]
|
|
||||||
|
|
||||||
|
|
||||||
class FeatureGroupedResponse(BaseModel):
|
|
||||||
"""Features grouped by category."""
|
|
||||||
|
|
||||||
categories: dict[str, list[FeatureResponse]]
|
|
||||||
available_count: int
|
|
||||||
total_count: int
|
|
||||||
|
|
||||||
|
|
||||||
class FeatureCheckResponse(BaseModel):
|
|
||||||
"""Quick feature availability check response."""
|
|
||||||
|
|
||||||
has_feature: bool
|
|
||||||
feature_code: str
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Internal Helpers
|
# Internal Helpers
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -181,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 = _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)
|
||||||
@@ -220,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 = _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()
|
||||||
@@ -283,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 = _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()
|
||||||
@@ -313,7 +227,7 @@ def get_features_grouped(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@store_features_router.get("/check/{feature_code}", response_model=FeatureCheckResponse)
|
@store_features_router.get("/check/{feature_code}", response_model=StoreFeatureCheckResponse)
|
||||||
def check_feature(
|
def check_feature(
|
||||||
feature_code: str,
|
feature_code: str,
|
||||||
current_user: UserContext = Depends(get_current_store_api),
|
current_user: UserContext = Depends(get_current_store_api),
|
||||||
@@ -332,9 +246,11 @@ 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 FeatureCheckResponse(has_feature=has, feature_code=feature_code)
|
return StoreFeatureCheckResponse(has_feature=has, feature_code=feature_code)
|
||||||
|
|
||||||
|
|
||||||
@store_features_router.get("/{feature_code}", response_model=FeatureDetailResponse)
|
@store_features_router.get("/{feature_code}", response_model=FeatureDetailResponse)
|
||||||
@@ -356,12 +272,12 @@ 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 = _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)
|
||||||
if not decl:
|
if not decl:
|
||||||
raise FeatureNotFoundError(feature_code)
|
raise FeatureNotFoundException(feature_code)
|
||||||
|
|
||||||
# Check availability
|
# Check availability
|
||||||
is_available = feature_service.has_feature(db, merchant_id, platform_id, feature_code)
|
is_available = feature_service.has_feature(db, merchant_id, platform_id, feature_code)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from app.api.deps import get_current_store_api, require_module_access
|
|||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.modules.billing.services.usage_service import usage_service
|
from app.modules.billing.services.usage_service import usage_service
|
||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
from models.schema.auth import UserContext
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
|
|
||||||
store_usage_router = APIRouter(
|
store_usage_router = APIRouter(
|
||||||
prefix="/usage",
|
prefix="/usage",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user