Compare commits

...

3 Commits

Author SHA1 Message Date
05d31a7fc5 docs: add Google Wallet setup guide and loyalty env vars
Some checks failed
CI / ruff (push) Successful in 11s
CI / pytest (push) Failing after 45m26s
CI / validate (push) Successful in 22s
CI / dependency-scanning (push) Successful in 29s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
Step 25 in Hetzner docs with full Google Cloud/Wallet Console setup,
service account configuration, local testing, and architecture diagrams.
Loyalty module env vars added to environment.md and .env.example.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 00:31:43 +01:00
272b62fbd3 docs: update documentation for platform-aware storefront routing
Update 8 documentation files to reflect new URL scheme:
- Dev: /platforms/{code}/storefront/{store_code}/
- Prod: subdomain.platform.lu/ (root path = storefront)
- Rename DEFAULT_PLATFORM_CODE to MAIN_PLATFORM_CODE
- Replace hardcoded platform_id=1 with dynamic values
- Update route examples, middleware descriptions, code samples

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:56:26 +01:00
32acc76b49 feat: platform-aware storefront routing and billing improvements
Overhaul storefront URL routing to be platform-aware:
- Dev: /platforms/{code}/storefront/{store_code}/
- Prod: subdomain.platform.lu/ (internally rewritten to /storefront/)
- Add subdomain detection in PlatformContextMiddleware
- Add /storefront/ path rewrite for prod mode (subdomain/custom domain)
- Remove all silent platform fallbacks (platform_id=1)
- Add require_platform dependency for clean endpoint validation
- Update route registration, templates, module definitions, base_url calc
- Update StoreContextMiddleware for /storefront/ path detection
- Remove /stores/ from FrontendDetector STOREFRONT_PATH_PREFIXES

Billing service improvements:
- Add store_platform_sync_service to keep store_platforms in sync
- Make tier lookups platform-aware across billing services
- Add tiers for all platforms in seed data
- Add demo subscriptions to seed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:42:41 +01:00
67 changed files with 1374 additions and 424 deletions

View File

@@ -203,6 +203,34 @@ R2_PUBLIC_URL=
# Cloudflare R2 backup bucket (used by scripts/backup.sh --upload) # Cloudflare R2 backup bucket (used by scripts/backup.sh --upload)
R2_BACKUP_BUCKET=orion-backups 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
# LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/path/to/service-account.json
# 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
# ============================================================================= # =============================================================================

View File

@@ -155,6 +155,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
# ============================================================================ # ============================================================================

View File

@@ -46,7 +46,6 @@ class FrontendDetector:
STOREFRONT_PATH_PREFIXES = ( STOREFRONT_PATH_PREFIXES = (
"/storefront", "/storefront",
"/api/v1/storefront", "/api/v1/storefront",
"/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",)

View File

@@ -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",

View File

@@ -16,7 +16,6 @@ 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,
@@ -284,16 +283,7 @@ 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)
# Resolve store to merchant
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 ResourceNotFoundException("Store", str(store_id))
results = admin_subscription_service.get_merchant_subscriptions_with_usage(
db, merchant_id
)
return {"subscriptions": results} return {"subscriptions": results}

View File

@@ -21,6 +21,10 @@ from app.modules.billing.services.platform_pricing_service import (
PlatformPricingService, PlatformPricingService,
platform_pricing_service, platform_pricing_service,
) )
from app.modules.billing.services.store_platform_sync_service import (
StorePlatformSync,
store_platform_sync,
)
from app.modules.billing.services.stripe_service import ( from app.modules.billing.services.stripe_service import (
StripeService, StripeService,
stripe_service, stripe_service,
@@ -42,6 +46,8 @@ from app.modules.billing.services.usage_service import (
__all__ = [ __all__ = [
"SubscriptionService", "SubscriptionService",
"subscription_service", "subscription_service",
"StorePlatformSync",
"store_platform_sync",
"StripeService", "StripeService",
"stripe_service", "stripe_service",
"AdminSubscriptionService", "AdminSubscriptionService",

View File

@@ -56,13 +56,14 @@ class AdminSubscriptionService:
return query.order_by(SubscriptionTier.display_order).all() return query.order_by(SubscriptionTier.display_order).all()
def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier: def get_tier_by_code(
"""Get a subscription tier by code.""" self, db: Session, tier_code: str, platform_id: int | None = None
tier = ( ) -> SubscriptionTier:
db.query(SubscriptionTier) """Get a subscription tier by code, optionally scoped to a platform."""
.filter(SubscriptionTier.code == tier_code) query = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code)
.first() if platform_id is not None:
) query = query.filter(SubscriptionTier.platform_id == platform_id)
tier = query.first()
if not tier: if not tier:
raise TierNotFoundException(tier_code) raise TierNotFoundException(tier_code)
@@ -214,7 +215,7 @@ class AdminSubscriptionService:
db, merchant_id, platform_id, tier_code, sub.is_annual db, merchant_id, platform_id, tier_code, sub.is_annual
) )
else: else:
tier = self.get_tier_by_code(db, tier_code) tier = self.get_tier_by_code(db, tier_code, platform_id=platform_id)
sub.tier_id = tier.id sub.tier_id = tier.id
for field, value in update_data.items(): for field, value in update_data.items():
@@ -350,6 +351,22 @@ class AdminSubscriptionService:
return results return results
def get_subscriptions_for_store(
self, db: Session, store_id: int
) -> list[dict]:
"""Get subscriptions + feature usage for a store (resolves to merchant).
Convenience method for admin store detail page. Resolves
store -> merchant -> all platform subscriptions.
"""
from app.modules.tenancy.models import Store
store = db.query(Store).filter(Store.id == store_id).first()
if not store or not store.merchant_id:
raise ResourceNotFoundException("Store", str(store_id))
return self.get_merchant_subscriptions_with_usage(db, store.merchant_id)
# ========================================================================= # =========================================================================
# Statistics # Statistics
# ========================================================================= # =========================================================================

View File

@@ -88,21 +88,22 @@ class BillingService:
return tier_list, tier_order return tier_list, tier_order
def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier: def get_tier_by_code(
self, db: Session, tier_code: str, platform_id: int | None = None
) -> SubscriptionTier:
""" """
Get a tier by its code. Get a tier by its code, optionally scoped to a platform.
Raises: Raises:
TierNotFoundException: If tier doesn't exist TierNotFoundException: If tier doesn't exist
""" """
tier = ( query = db.query(SubscriptionTier).filter(
db.query(SubscriptionTier)
.filter(
SubscriptionTier.code == tier_code, SubscriptionTier.code == tier_code,
SubscriptionTier.is_active == True, # noqa: E712 SubscriptionTier.is_active == True, # noqa: E712
) )
.first() if platform_id is not None:
) query = query.filter(SubscriptionTier.platform_id == platform_id)
tier = query.first()
if not tier: if not tier:
raise TierNotFoundException(tier_code) raise TierNotFoundException(tier_code)
@@ -133,7 +134,7 @@ class BillingService:
if not stripe_service.is_configured: if not stripe_service.is_configured:
raise PaymentSystemNotConfiguredException() raise PaymentSystemNotConfiguredException()
tier = self.get_tier_by_code(db, tier_code) tier = self.get_tier_by_code(db, tier_code, platform_id=platform_id)
price_id = ( price_id = (
tier.stripe_price_annual_id tier.stripe_price_annual_id
@@ -410,7 +411,7 @@ class BillingService:
if not subscription or not subscription.stripe_subscription_id: if not subscription or not subscription.stripe_subscription_id:
raise NoActiveSubscriptionException() raise NoActiveSubscriptionException()
tier = self.get_tier_by_code(db, new_tier_code) tier = self.get_tier_by_code(db, new_tier_code, platform_id=platform_id)
price_id = ( price_id = (
tier.stripe_price_annual_id tier.stripe_price_annual_id

View File

@@ -28,16 +28,17 @@ class PlatformPricingService:
.all() .all()
) )
def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier | None: def get_tier_by_code(
"""Get a specific tier by code from the database.""" self, db: Session, tier_code: str, platform_id: int | None = None
return ( ) -> SubscriptionTier | None:
db.query(SubscriptionTier) """Get a specific tier by code from the database, optionally scoped to a platform."""
.filter( query = db.query(SubscriptionTier).filter(
SubscriptionTier.code == tier_code, SubscriptionTier.code == tier_code,
SubscriptionTier.is_active == True, SubscriptionTier.is_active == True,
) )
.first() if platform_id is not None:
) query = query.filter(SubscriptionTier.platform_id == platform_id)
return query.first()
def get_active_addons(self, db: Session) -> list[AddOnProduct]: def get_active_addons(self, db: Session) -> list[AddOnProduct]:
"""Get all active add-on products from the database.""" """Get all active add-on products from the database."""

View File

@@ -0,0 +1,92 @@
# app/modules/billing/services/store_platform_sync.py
"""
Keeps store_platforms in sync with merchant subscriptions.
When a subscription is created, reactivated, or deleted, this service
ensures all stores belonging to that merchant get corresponding
StorePlatform entries created or updated.
"""
import logging
from sqlalchemy.orm import Session
from app.modules.tenancy.models import Store, StorePlatform
logger = logging.getLogger(__name__)
class StorePlatformSync:
"""Syncs StorePlatform entries when merchant subscriptions change."""
def sync_store_platforms_for_merchant(
self,
db: Session,
merchant_id: int,
platform_id: int,
is_active: bool,
tier_id: int | None = None,
) -> None:
"""
Upsert StorePlatform for every store belonging to a merchant.
- Existing entry → update is_active (and tier_id if provided)
- Missing + is_active=True → create (set is_primary if store has none)
- Missing + is_active=False → no-op
"""
stores = (
db.query(Store)
.filter(Store.merchant_id == merchant_id)
.all()
)
if not stores:
return
for store in stores:
existing = (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == store.id,
StorePlatform.platform_id == platform_id,
)
.first()
)
if existing:
existing.is_active = is_active
if tier_id is not None:
existing.tier_id = tier_id
logger.debug(
f"Updated StorePlatform store_id={store.id} "
f"platform_id={platform_id} is_active={is_active}"
)
elif is_active:
# Check if store already has a primary platform
has_primary = (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == store.id,
StorePlatform.is_primary.is_(True),
)
.first()
) is not None
sp = StorePlatform(
store_id=store.id,
platform_id=platform_id,
is_active=True,
is_primary=not has_primary,
tier_id=tier_id,
)
db.add(sp)
logger.info(
f"Created StorePlatform store_id={store.id} "
f"platform_id={platform_id} is_primary={not has_primary}"
)
db.flush()
# Singleton instance
store_platform_sync = StorePlatformSync()

View File

@@ -82,17 +82,20 @@ class SubscriptionService:
# Tier Information # Tier Information
# ========================================================================= # =========================================================================
def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier | None: def get_tier_by_code(
"""Get subscription tier by code.""" self, db: Session, tier_code: str, platform_id: int | None = None
return ( ) -> SubscriptionTier | None:
db.query(SubscriptionTier) """Get subscription tier by code, optionally scoped to a platform."""
.filter(SubscriptionTier.code == tier_code) query = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code)
.first() if platform_id is not None:
) query = query.filter(SubscriptionTier.platform_id == platform_id)
return query.first()
def get_tier_id(self, db: Session, tier_code: str) -> int | None: def get_tier_id(
self, db: Session, tier_code: str, platform_id: int | None = None
) -> int | None:
"""Get tier ID from tier code. Returns None if tier not found.""" """Get tier ID from tier code. Returns None if tier not found."""
tier = self.get_tier_by_code(db, tier_code) tier = self.get_tier_by_code(db, tier_code, platform_id=platform_id)
return tier.id if tier else None return tier.id if tier else None
def get_all_tiers( def get_all_tiers(
@@ -254,7 +257,7 @@ class SubscriptionService:
trial_ends_at = None trial_ends_at = None
status = SubscriptionStatus.ACTIVE.value status = SubscriptionStatus.ACTIVE.value
tier_id = self.get_tier_id(db, tier_code) tier_id = self.get_tier_id(db, tier_code, platform_id=platform_id)
subscription = MerchantSubscription( subscription = MerchantSubscription(
merchant_id=merchant_id, merchant_id=merchant_id,
@@ -271,6 +274,15 @@ class SubscriptionService:
db.flush() db.flush()
db.refresh(subscription) db.refresh(subscription)
# Sync store_platforms for all merchant stores
from app.modules.billing.services.store_platform_sync_service import (
store_platform_sync,
)
store_platform_sync.sync_store_platforms_for_merchant(
db, merchant_id, platform_id, is_active=True, tier_id=subscription.tier_id
)
logger.info( logger.info(
f"Created subscription for merchant {merchant_id} on platform {platform_id} " f"Created subscription for merchant {merchant_id} on platform {platform_id} "
f"(tier={tier_code}, status={status})" f"(tier={tier_code}, status={status})"
@@ -305,7 +317,7 @@ class SubscriptionService:
subscription = self.get_subscription_or_raise(db, merchant_id, platform_id) subscription = self.get_subscription_or_raise(db, merchant_id, platform_id)
old_tier_id = subscription.tier_id old_tier_id = subscription.tier_id
new_tier = self.get_tier_by_code(db, new_tier_code) new_tier = self.get_tier_by_code(db, new_tier_code, platform_id=platform_id)
if not new_tier: if not new_tier:
raise ValueError(f"Tier '{new_tier_code}' not found") raise ValueError(f"Tier '{new_tier_code}' not found")
@@ -366,6 +378,15 @@ class SubscriptionService:
db.flush() db.flush()
db.refresh(subscription) db.refresh(subscription)
# Sync store_platforms for all merchant stores
from app.modules.billing.services.store_platform_sync_service import (
store_platform_sync,
)
store_platform_sync.sync_store_platforms_for_merchant(
db, merchant_id, platform_id, is_active=True
)
logger.info( logger.info(
f"Reactivated subscription for merchant {merchant_id} " f"Reactivated subscription for merchant {merchant_id} "
f"on platform {platform_id}" f"on platform {platform_id}"

View File

@@ -507,3 +507,56 @@ class TestAdminBillingHistory:
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["total"] == 0 assert response.json()["total"] == 0
# ============================================================================
# Store Subscription Convenience Endpoint
# ============================================================================
class TestAdminStoreSubscription:
"""Tests for GET /api/v1/admin/subscriptions/store/{store_id}."""
def test_get_subscriptions_for_store(
self, client, super_admin_headers, rt_subscription, rt_store
):
"""Returns subscriptions when store has a merchant with subscriptions."""
response = client.get(
f"{BASE}/store/{rt_store.id}",
headers=super_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert "subscriptions" in data
assert len(data["subscriptions"]) >= 1
def test_get_subscriptions_for_nonexistent_store(
self, client, super_admin_headers
):
"""Returns 404 for non-existent store ID."""
response = client.get(
f"{BASE}/store/999999",
headers=super_admin_headers,
)
assert response.status_code == 404
@pytest.fixture
def rt_store(db, rt_merchant):
"""Create a store for route tests."""
from app.modules.tenancy.models import Store
store = Store(
merchant_id=rt_merchant.id,
store_code=f"RT_{uuid.uuid4().hex[:6].upper()}",
name="Route Test Store",
subdomain=f"rt-{uuid.uuid4().hex[:8]}",
is_active=True,
is_verified=True,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
db.add(store)
db.commit()
db.refresh(store)
return store

View File

@@ -497,7 +497,7 @@ class TestAdminGetStats:
@pytest.fixture @pytest.fixture
def admin_billing_tiers(db): def admin_billing_tiers(db, test_platform):
"""Create essential, professional, business tiers for admin tests.""" """Create essential, professional, business tiers for admin tests."""
tiers = [ tiers = [
SubscriptionTier( SubscriptionTier(
@@ -508,6 +508,7 @@ def admin_billing_tiers(db):
display_order=1, display_order=1,
is_active=True, is_active=True,
is_public=True, is_public=True,
platform_id=test_platform.id,
), ),
SubscriptionTier( SubscriptionTier(
code="professional", code="professional",
@@ -517,6 +518,7 @@ def admin_billing_tiers(db):
display_order=2, display_order=2,
is_active=True, is_active=True,
is_public=True, is_public=True,
platform_id=test_platform.id,
), ),
SubscriptionTier( SubscriptionTier(
code="business", code="business",
@@ -526,6 +528,7 @@ def admin_billing_tiers(db):
display_order=3, display_order=3,
is_active=True, is_active=True,
is_public=True, is_public=True,
platform_id=test_platform.id,
), ),
] ]
db.add_all(tiers) db.add_all(tiers)

View File

@@ -603,7 +603,7 @@ class TestBillingServiceUpcomingInvoice:
@pytest.fixture @pytest.fixture
def bs_tier_essential(db): def bs_tier_essential(db, test_platform):
"""Create essential subscription tier.""" """Create essential subscription tier."""
tier = SubscriptionTier( tier = SubscriptionTier(
code="essential", code="essential",
@@ -614,6 +614,7 @@ def bs_tier_essential(db):
display_order=1, display_order=1,
is_active=True, is_active=True,
is_public=True, is_public=True,
platform_id=test_platform.id,
) )
db.add(tier) db.add(tier)
db.commit() db.commit()
@@ -622,7 +623,7 @@ def bs_tier_essential(db):
@pytest.fixture @pytest.fixture
def bs_tiers(db): def bs_tiers(db, test_platform):
"""Create three tiers without Stripe config.""" """Create three tiers without Stripe config."""
tiers = [ tiers = [
SubscriptionTier( SubscriptionTier(
@@ -633,6 +634,7 @@ def bs_tiers(db):
display_order=1, display_order=1,
is_active=True, is_active=True,
is_public=True, is_public=True,
platform_id=test_platform.id,
), ),
SubscriptionTier( SubscriptionTier(
code="professional", code="professional",
@@ -642,6 +644,7 @@ def bs_tiers(db):
display_order=2, display_order=2,
is_active=True, is_active=True,
is_public=True, is_public=True,
platform_id=test_platform.id,
), ),
SubscriptionTier( SubscriptionTier(
code="business", code="business",
@@ -651,6 +654,7 @@ def bs_tiers(db):
display_order=3, display_order=3,
is_active=True, is_active=True,
is_public=True, is_public=True,
platform_id=test_platform.id,
), ),
] ]
db.add_all(tiers) db.add_all(tiers)
@@ -661,7 +665,7 @@ def bs_tiers(db):
@pytest.fixture @pytest.fixture
def bs_tiers_with_stripe(db): def bs_tiers_with_stripe(db, test_platform):
"""Create tiers with Stripe price IDs configured.""" """Create tiers with Stripe price IDs configured."""
tiers = [ tiers = [
SubscriptionTier( SubscriptionTier(
@@ -672,6 +676,7 @@ def bs_tiers_with_stripe(db):
display_order=1, display_order=1,
is_active=True, is_active=True,
is_public=True, is_public=True,
platform_id=test_platform.id,
stripe_product_id="prod_essential", stripe_product_id="prod_essential",
stripe_price_monthly_id="price_ess_monthly", stripe_price_monthly_id="price_ess_monthly",
stripe_price_annual_id="price_ess_annual", stripe_price_annual_id="price_ess_annual",
@@ -684,6 +689,7 @@ def bs_tiers_with_stripe(db):
display_order=2, display_order=2,
is_active=True, is_active=True,
is_public=True, is_public=True,
platform_id=test_platform.id,
stripe_product_id="prod_professional", stripe_product_id="prod_professional",
stripe_price_monthly_id="price_pro_monthly", stripe_price_monthly_id="price_pro_monthly",
stripe_price_annual_id="price_pro_annual", stripe_price_annual_id="price_pro_annual",
@@ -696,6 +702,7 @@ def bs_tiers_with_stripe(db):
display_order=3, display_order=3,
is_active=True, is_active=True,
is_public=True, is_public=True,
platform_id=test_platform.id,
stripe_product_id="prod_business", stripe_product_id="prod_business",
stripe_price_monthly_id="price_biz_monthly", stripe_price_monthly_id="price_biz_monthly",
stripe_price_annual_id="price_biz_annual", stripe_price_annual_id="price_biz_annual",

View File

@@ -0,0 +1,247 @@
# app/modules/billing/tests/unit/test_store_platform_sync.py
"""Unit tests for StorePlatformSync service."""
from datetime import UTC, datetime, timedelta
import pytest
from app.modules.billing.models import (
MerchantSubscription,
SubscriptionStatus,
SubscriptionTier,
)
from app.modules.billing.services.store_platform_sync_service import StorePlatformSync
from app.modules.tenancy.models import StorePlatform
@pytest.mark.unit
@pytest.mark.billing
class TestStorePlatformSyncCreate:
"""Tests for creating StorePlatform entries via sync."""
def setup_method(self):
self.service = StorePlatformSync()
def test_sync_creates_store_platform(self, db, test_store, test_platform):
"""Sync with is_active=True creates a new StorePlatform entry."""
self.service.sync_store_platforms_for_merchant(
db, test_store.merchant_id, test_platform.id, is_active=True
)
sp = (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == test_store.id,
StorePlatform.platform_id == test_platform.id,
)
.first()
)
assert sp is not None
assert sp.is_active is True
def test_sync_sets_primary_when_none(self, db, test_store, test_platform):
"""First platform synced for a store gets is_primary=True."""
self.service.sync_store_platforms_for_merchant(
db, test_store.merchant_id, test_platform.id, is_active=True
)
sp = (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == test_store.id,
StorePlatform.platform_id == test_platform.id,
)
.first()
)
assert sp.is_primary is True
def test_sync_no_primary_override(self, db, test_store, test_platform, another_platform):
"""Second platform synced does not override existing primary."""
# First platform becomes primary
self.service.sync_store_platforms_for_merchant(
db, test_store.merchant_id, test_platform.id, is_active=True
)
# Second platform should not be primary
self.service.sync_store_platforms_for_merchant(
db, test_store.merchant_id, another_platform.id, is_active=True
)
sp1 = (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == test_store.id,
StorePlatform.platform_id == test_platform.id,
)
.first()
)
sp2 = (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == test_store.id,
StorePlatform.platform_id == another_platform.id,
)
.first()
)
assert sp1.is_primary is True
assert sp2.is_primary is False
def test_sync_sets_tier_id(self, db, test_store, test_platform, sync_tier):
"""Sync passes tier_id to newly created StorePlatform."""
self.service.sync_store_platforms_for_merchant(
db, test_store.merchant_id, test_platform.id,
is_active=True, tier_id=sync_tier.id,
)
sp = (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == test_store.id,
StorePlatform.platform_id == test_platform.id,
)
.first()
)
assert sp.tier_id == sync_tier.id
@pytest.mark.unit
@pytest.mark.billing
class TestStorePlatformSyncUpdate:
"""Tests for updating existing StorePlatform entries via sync."""
def setup_method(self):
self.service = StorePlatformSync()
def test_sync_updates_existing_is_active(self, db, test_store, test_platform):
"""Sync updates is_active on existing StorePlatform."""
# Create initial entry
sp = StorePlatform(
store_id=test_store.id,
platform_id=test_platform.id,
is_active=True,
is_primary=True,
)
db.add(sp)
db.flush()
# Deactivate via sync
self.service.sync_store_platforms_for_merchant(
db, test_store.merchant_id, test_platform.id, is_active=False
)
db.refresh(sp)
assert sp.is_active is False
def test_sync_updates_tier_id(self, db, test_store, test_platform, sync_tier):
"""Sync updates tier_id on existing StorePlatform."""
sp = StorePlatform(
store_id=test_store.id,
platform_id=test_platform.id,
is_active=True,
is_primary=True,
)
db.add(sp)
db.flush()
self.service.sync_store_platforms_for_merchant(
db, test_store.merchant_id, test_platform.id,
is_active=True, tier_id=sync_tier.id,
)
db.refresh(sp)
assert sp.tier_id == sync_tier.id
@pytest.mark.unit
@pytest.mark.billing
class TestStorePlatformSyncEdgeCases:
"""Tests for edge cases in sync."""
def setup_method(self):
self.service = StorePlatformSync()
def test_sync_noop_inactive_missing(self, db, test_store, test_platform):
"""Sync with is_active=False for non-existent entry is a no-op."""
self.service.sync_store_platforms_for_merchant(
db, test_store.merchant_id, test_platform.id, is_active=False
)
sp = (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == test_store.id,
StorePlatform.platform_id == test_platform.id,
)
.first()
)
assert sp is None
def test_sync_no_stores(self, db, test_platform):
"""Sync with no stores for merchant is a no-op (no error)."""
self.service.sync_store_platforms_for_merchant(
db, 99999, test_platform.id, is_active=True
)
# No assertion needed — just verifying no exception
def test_sync_multiple_stores(self, db, test_merchant, test_platform):
"""Sync creates entries for all stores of a merchant."""
from app.modules.tenancy.models import Store
store1 = Store(
merchant_id=test_merchant.id,
store_code="SYNC_TEST_1",
name="Sync Store 1",
subdomain="sync-test-1",
is_active=True,
is_verified=True,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
store2 = Store(
merchant_id=test_merchant.id,
store_code="SYNC_TEST_2",
name="Sync Store 2",
subdomain="sync-test-2",
is_active=True,
is_verified=True,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
db.add_all([store1, store2])
db.flush()
self.service.sync_store_platforms_for_merchant(
db, test_merchant.id, test_platform.id, is_active=True
)
count = (
db.query(StorePlatform)
.filter(
StorePlatform.platform_id == test_platform.id,
StorePlatform.store_id.in_([store1.id, store2.id]),
)
.count()
)
assert count == 2
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def sync_tier(db, test_platform):
"""Create a tier for sync tests."""
tier = SubscriptionTier(
platform_id=test_platform.id,
code="essential",
name="Essential",
price_monthly_cents=2900,
display_order=1,
is_active=True,
is_public=True,
)
db.add(tier)
db.commit()
db.refresh(tier)
return tier

View File

@@ -502,7 +502,7 @@ class TestSubscriptionServiceReactivate:
@pytest.fixture @pytest.fixture
def billing_tier_essential(db): def billing_tier_essential(db, test_platform):
"""Create essential subscription tier.""" """Create essential subscription tier."""
tier = SubscriptionTier( tier = SubscriptionTier(
code="essential", code="essential",
@@ -513,6 +513,7 @@ def billing_tier_essential(db):
display_order=1, display_order=1,
is_active=True, is_active=True,
is_public=True, is_public=True,
platform_id=test_platform.id,
) )
db.add(tier) db.add(tier)
db.commit() db.commit()
@@ -521,7 +522,7 @@ def billing_tier_essential(db):
@pytest.fixture @pytest.fixture
def billing_tiers(db): def billing_tiers(db, test_platform):
"""Create essential, professional, and business tiers.""" """Create essential, professional, and business tiers."""
tiers = [ tiers = [
SubscriptionTier( SubscriptionTier(
@@ -532,6 +533,7 @@ def billing_tiers(db):
display_order=1, display_order=1,
is_active=True, is_active=True,
is_public=True, is_public=True,
platform_id=test_platform.id,
), ),
SubscriptionTier( SubscriptionTier(
code="professional", code="professional",
@@ -541,6 +543,7 @@ def billing_tiers(db):
display_order=2, display_order=2,
is_active=True, is_active=True,
is_public=True, is_public=True,
platform_id=test_platform.id,
), ),
SubscriptionTier( SubscriptionTier(
code="business", code="business",
@@ -550,6 +553,7 @@ def billing_tiers(db):
display_order=3, display_order=3,
is_active=True, is_active=True,
is_public=True, is_public=True,
platform_id=test_platform.id,
), ),
] ]
db.add_all(tiers) db.add_all(tiers)

View File

@@ -70,7 +70,7 @@ cart_module = ModuleDefinition(
id="cart", id="cart",
label_key="storefront.actions.cart", label_key="storefront.actions.cart",
icon="shopping-cart", icon="shopping-cart",
route="storefront/cart", route="cart",
order=20, order=20,
), ),
], ],

View File

@@ -135,7 +135,7 @@ catalog_module = ModuleDefinition(
id="products", id="products",
label_key="storefront.nav.products", label_key="storefront.nav.products",
icon="shopping-bag", icon="shopping-bag",
route="storefront/products", route="products",
order=10, order=10,
), ),
], ],

View File

@@ -39,7 +39,9 @@ def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any
""" """
from app.modules.cms.services import content_page_service from app.modules.cms.services import content_page_service
platform_id = platform.id if platform else 1 if not platform:
return {"header_pages": [], "footer_pages": [], "legal_pages": []}
platform_id = platform.id
header_pages = [] header_pages = []
footer_pages = [] footer_pages = []
@@ -73,7 +75,9 @@ def _get_storefront_context(request: Any, db: Any, platform: Any) -> dict[str, A
from app.modules.cms.services import content_page_service from app.modules.cms.services import content_page_service
store = getattr(request.state, "store", None) store = getattr(request.state, "store", None)
platform_id = platform.id if platform else 1 if not platform:
return {"header_pages": [], "footer_pages": [], "legal_pages": []}
platform_id = platform.id
header_pages = [] header_pages = []
footer_pages = [] footer_pages = []

View File

@@ -11,6 +11,7 @@ import logging
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import require_platform
from app.core.database import get_db from app.core.database import get_db
from app.modules.cms.schemas import ( from app.modules.cms.schemas import (
ContentPageListItem, ContentPageListItem,
@@ -29,7 +30,11 @@ logger = logging.getLogger(__name__)
# public - storefront content pages are publicly accessible # public - storefront content pages are publicly accessible
@router.get("/navigation", response_model=list[ContentPageListItem]) @router.get("/navigation", response_model=list[ContentPageListItem])
def get_navigation_pages(request: Request, db: Session = Depends(get_db)): def get_navigation_pages(
request: Request,
platform=Depends(require_platform),
db: Session = Depends(get_db),
):
""" """
Get list of content pages for navigation (footer/header). Get list of content pages for navigation (footer/header).
@@ -37,9 +42,8 @@ def get_navigation_pages(request: Request, db: Session = Depends(get_db)):
Returns store overrides + platform defaults. Returns store overrides + platform defaults.
""" """
store = getattr(request.state, "store", None) store = getattr(request.state, "store", None)
platform = getattr(request.state, "platform", None)
store_id = store.id if store else None store_id = store.id if store else None
platform_id = platform.id if platform else 1 platform_id = platform.id
# Get all published pages for this store # Get all published pages for this store
pages = content_page_service.list_pages_for_store( pages = content_page_service.list_pages_for_store(
@@ -59,7 +63,12 @@ def get_navigation_pages(request: Request, db: Session = Depends(get_db)):
@router.get("/{slug}", response_model=PublicContentPageResponse) @router.get("/{slug}", response_model=PublicContentPageResponse)
def get_content_page(slug: str, request: Request, db: Session = Depends(get_db)): def get_content_page(
slug: str,
request: Request,
platform=Depends(require_platform),
db: Session = Depends(get_db),
):
""" """
Get a specific content page by slug. Get a specific content page by slug.
@@ -67,9 +76,8 @@ def get_content_page(slug: str, request: Request, db: Session = Depends(get_db))
Returns store override if exists, otherwise platform default. Returns store override if exists, otherwise platform default.
""" """
store = getattr(request.state, "store", None) store = getattr(request.state, "store", None)
platform = getattr(request.state, "platform", None)
store_id = store.id if store else None store_id = store.id if store else None
platform_id = platform.id if platform else 1 platform_id = platform.id
page = content_page_service.get_page_for_store_or_raise( page = content_page_service.get_page_for_store_or_raise(
db, db,

View File

@@ -91,8 +91,9 @@ async def homepage(
if store: if store:
logger.debug(f"[HOMEPAGE] Store detected: {store.subdomain}") logger.debug(f"[HOMEPAGE] Store detected: {store.subdomain}")
# Get platform_id (use platform from context or default to 1 for OMS) if not platform:
platform_id = platform.id if platform else 1 raise HTTPException(status_code=400, detail="Platform context required")
platform_id = platform.id
# Try to find store landing page (slug='landing' or 'home') # Try to find store landing page (slug='landing' or 'home')
landing_page = content_page_service.get_page_for_store( landing_page = content_page_service.get_page_for_store(
@@ -133,21 +134,19 @@ async def homepage(
else "unknown" else "unknown"
) )
if access_method == "path": if access_method == "path" and platform:
full_prefix = (
store_context.get("full_prefix", "/store/")
if store_context
else "/store/"
)
return RedirectResponse( return RedirectResponse(
url=f"{full_prefix}{store.subdomain}/storefront/", status_code=302 url=f"/platforms/{platform.code}/storefront/{store.store_code}/",
status_code=302,
) )
# Domain/subdomain - redirect to /storefront/ # Domain/subdomain - root is storefront
return RedirectResponse(url="/storefront/", status_code=302) return RedirectResponse(url="/", status_code=302)
# Scenario 2: Platform marketing site (no store) # Scenario 2: Platform marketing site (no store)
# Load platform homepage from CMS (slug='home') # Load platform homepage from CMS (slug='home')
platform_id = platform.id if platform else 1 if not platform:
raise HTTPException(status_code=400, detail="Platform context required")
platform_id = platform.id
cms_homepage = content_page_service.get_platform_page( cms_homepage = content_page_service.get_platform_page(
db, platform_id=platform_id, slug="home", include_unpublished=False db, platform_id=platform_id, slug="home", include_unpublished=False
@@ -227,9 +226,10 @@ async def content_page(
This is a catch-all route for dynamic content pages managed via the admin CMS. This is a catch-all route for dynamic content pages managed via the admin CMS.
Platform pages have store_id=None and is_platform_page=True. Platform pages have store_id=None and is_platform_page=True.
""" """
# Get platform from middleware (default to OMS platform_id=1)
platform = getattr(request.state, "platform", None) platform = getattr(request.state, "platform", None)
platform_id = platform.id if platform else 1 if not platform:
raise HTTPException(status_code=400, detail="Platform context required")
platform_id = platform.id
# Load platform marketing page from database # Load platform marketing page from database
page = content_page_service.get_platform_page( page = content_page_service.get_platform_page(

View File

@@ -184,7 +184,9 @@ async def store_content_page(
store = getattr(request.state, "store", None) store = getattr(request.state, "store", None)
platform = getattr(request.state, "platform", None) platform = getattr(request.state, "platform", None)
store_id = store.id if store else None store_id = store.id if store else None
platform_id = platform.id if platform else 1 if not platform:
raise HTTPException(status_code=400, detail="Platform context required")
platform_id = platform.id
# Load content page from database (store override → platform default) # Load content page from database (store override → platform default)
page = content_page_service.get_page_for_store( page = content_page_service.get_page_for_store(

View File

@@ -66,7 +66,9 @@ async def generic_content_page(
store = getattr(request.state, "store", None) store = getattr(request.state, "store", None)
platform = getattr(request.state, "platform", None) platform = getattr(request.state, "platform", None)
store_id = store.id if store else None store_id = store.id if store else None
platform_id = platform.id if platform else 1 # Default to OMS if not platform:
raise HTTPException(status_code=400, detail="Platform context required")
platform_id = platform.id
# Load content page from database (store override -> store default) # Load content page from database (store override -> store default)
page = content_page_service.get_page_for_store( page = content_page_service.get_page_for_store(

View File

@@ -14,7 +14,7 @@ import logging
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_merchant_for_current_user from app.api.deps import get_merchant_for_current_user, require_platform
from app.core.database import get_db from app.core.database import get_db
from app.modules.core.schemas.dashboard import MerchantDashboardStatsResponse from app.modules.core.schemas.dashboard import MerchantDashboardStatsResponse
from app.modules.core.services.stats_aggregator import stats_aggregator from app.modules.core.services.stats_aggregator import stats_aggregator
@@ -27,6 +27,7 @@ logger = logging.getLogger(__name__)
def get_merchant_dashboard_stats( def get_merchant_dashboard_stats(
request: Request, request: Request,
merchant=Depends(get_merchant_for_current_user), merchant=Depends(get_merchant_for_current_user),
platform=Depends(require_platform),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
@@ -41,8 +42,7 @@ def get_merchant_dashboard_stats(
Merchant is resolved from the JWT token. Merchant is resolved from the JWT token.
Requires Authorization header (API endpoint). Requires Authorization header (API endpoint).
""" """
platform = getattr(request.state, "platform", None) platform_id = platform.id
platform_id = platform.id if platform else 1
flat = stats_aggregator.get_merchant_stats_flat( flat = stats_aggregator.get_merchant_stats_flat(
db=db, db=db,

View File

@@ -14,7 +14,7 @@ import logging
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_store_api from app.api.deps import get_current_store_api, require_platform
from app.core.database import get_db from app.core.database import get_db
from app.modules.core.schemas.dashboard import ( from app.modules.core.schemas.dashboard import (
StoreCustomerStats, StoreCustomerStats,
@@ -49,6 +49,7 @@ def _extract_metric_value(
def get_store_dashboard_stats( def get_store_dashboard_stats(
request: Request, request: Request,
current_user: UserContext = Depends(get_current_store_api), current_user: UserContext = Depends(get_current_store_api),
platform=Depends(require_platform),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
@@ -74,10 +75,7 @@ def get_store_dashboard_stats(
if not store.is_active: if not store.is_active:
raise StoreNotActiveException(store.store_code) raise StoreNotActiveException(store.store_code)
# Get aggregated metrics from all enabled modules platform_id = platform.id
# Get platform_id from request context (set by PlatformContextMiddleware)
platform = getattr(request.state, "platform", None)
platform_id = platform.id if platform else 1
metrics = stats_aggregator.get_store_dashboard_stats( metrics = stats_aggregator.get_store_dashboard_stats(
db=db, db=db,
store_id=store_id, store_id=store_id,

View File

@@ -332,14 +332,21 @@ def get_storefront_context(
) )
# Calculate base URL for links # Calculate base URL for links
# Dev path-based: /platforms/{code}/storefront/{store_code}/
# Prod subdomain/custom domain: /
base_url = "/" base_url = "/"
if access_method == "path" and store: if access_method == "path" and store:
platform = getattr(request.state, "platform", None)
platform_original_path = getattr(request.state, "platform_original_path", None)
if platform and platform_original_path and platform_original_path.startswith("/platforms/"):
base_url = f"/platforms/{platform.code}/storefront/{store.store_code}/"
else:
full_prefix = ( full_prefix = (
store_context.get("full_prefix", "/store/") store_context.get("full_prefix", "/storefront/")
if store_context if store_context
else "/store/" else "/storefront/"
) )
base_url = f"{full_prefix}{store.subdomain}/" base_url = f"{full_prefix}{store.store_code}/"
# Read subscription info set by StorefrontAccessMiddleware # Read subscription info set by StorefrontAccessMiddleware
subscription = getattr(request.state, "subscription", None) subscription = getattr(request.state, "subscription", None)

View File

@@ -142,28 +142,28 @@ customers_module = ModuleDefinition(
id="dashboard", id="dashboard",
label_key="storefront.account.dashboard", label_key="storefront.account.dashboard",
icon="home", icon="home",
route="storefront/account/dashboard", route="account/dashboard",
order=10, order=10,
), ),
MenuItemDefinition( MenuItemDefinition(
id="profile", id="profile",
label_key="storefront.account.profile", label_key="storefront.account.profile",
icon="user", icon="user",
route="storefront/account/profile", route="account/profile",
order=20, order=20,
), ),
MenuItemDefinition( MenuItemDefinition(
id="addresses", id="addresses",
label_key="storefront.account.addresses", label_key="storefront.account.addresses",
icon="map-pin", icon="map-pin",
route="storefront/account/addresses", route="account/addresses",
order=30, order=30,
), ),
MenuItemDefinition( MenuItemDefinition(
id="settings", id="settings",
label_key="storefront.account.settings", label_key="storefront.account.settings",
icon="cog", icon="cog",
route="storefront/account/settings", route="account/settings",
order=90, order=90,
), ),
], ],

View File

@@ -205,7 +205,7 @@ loyalty_module = ModuleDefinition(
id="loyalty", id="loyalty",
label_key="storefront.account.loyalty", label_key="storefront.account.loyalty",
icon="gift", icon="gift",
route="storefront/account/loyalty", route="account/loyalty",
order=60, order=60,
), ),
], ],

View File

@@ -23,7 +23,7 @@ class TestStorefrontLoyaltyEndpoints:
# Without proper store context, should return 404 or error # Without proper store context, should return 404 or error
response = client.get("/api/v1/storefront/loyalty/program") response = client.get("/api/v1/storefront/loyalty/program")
# Endpoint exists but requires store context # Endpoint exists but requires store context
assert response.status_code in [200, 404, 422, 500] assert response.status_code in [200, 403, 404, 422, 500]
def test_enroll_endpoint_exists(self, client): def test_enroll_endpoint_exists(self, client):
"""Test that enrollment endpoint is registered.""" """Test that enrollment endpoint is registered."""
@@ -35,16 +35,16 @@ class TestStorefrontLoyaltyEndpoints:
}, },
) )
# Endpoint exists but requires store context # Endpoint exists but requires store context
assert response.status_code in [200, 404, 422, 500] assert response.status_code in [200, 403, 404, 422, 500]
def test_card_endpoint_exists(self, client): def test_card_endpoint_exists(self, client):
"""Test that card endpoint is registered.""" """Test that card endpoint is registered."""
response = client.get("/api/v1/storefront/loyalty/card") response = client.get("/api/v1/storefront/loyalty/card")
# Endpoint exists but requires authentication and store context # Endpoint exists but requires authentication and store context
assert response.status_code in [401, 404, 422, 500] assert response.status_code in [401, 403, 404, 422, 500]
def test_transactions_endpoint_exists(self, client): def test_transactions_endpoint_exists(self, client):
"""Test that transactions endpoint is registered.""" """Test that transactions endpoint is registered."""
response = client.get("/api/v1/storefront/loyalty/transactions") response = client.get("/api/v1/storefront/loyalty/transactions")
# Endpoint exists but requires authentication and store context # Endpoint exists but requires authentication and store context
assert response.status_code in [401, 404, 422, 500] assert response.status_code in [401, 403, 404, 422, 500]

View File

@@ -183,7 +183,7 @@ messaging_module = ModuleDefinition(
id="messages", id="messages",
label_key="storefront.account.messages", label_key="storefront.account.messages",
icon="chat-bubble-left-right", icon="chat-bubble-left-right",
route="storefront/account/messages", route="account/messages",
order=50, order=50,
), ),
], ],

View File

@@ -74,7 +74,9 @@ class CapacityForecastService:
# Resource metrics via provider pattern (avoids cross-module imports) # Resource metrics via provider pattern (avoids cross-module imports)
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
platform = db.query(Platform).first() platform = db.query(Platform).first()
platform_id = platform.id if platform else 1 if not platform:
raise ValueError("No platform found in database")
platform_id = platform.id
stats = stats_aggregator.get_admin_stats_flat( stats = stats_aggregator.get_admin_stats_flat(
db, platform_id, db, platform_id,

View File

@@ -147,7 +147,7 @@ orders_module = ModuleDefinition(
id="orders", id="orders",
label_key="storefront.account.orders", label_key="storefront.account.orders",
icon="clipboard-list", icon="clipboard-list",
route="storefront/account/orders", route="account/orders",
order=40, order=40,
), ),
], ],

View File

@@ -72,6 +72,7 @@ def create_store(
merchant_contact_phone=store.merchant.contact_phone, merchant_contact_phone=store.merchant.contact_phone,
merchant_website=store.merchant.website, merchant_website=store.merchant.website,
# Owner info (from merchant) # Owner info (from merchant)
owner_user_id=store.merchant.owner.id,
owner_email=store.merchant.owner.email, owner_email=store.merchant.owner.email,
owner_username=store.merchant.owner.username, owner_username=store.merchant.owner.username,
login_url=f"http://localhost:8000/store/{store.subdomain}/login", login_url=f"http://localhost:8000/store/{store.subdomain}/login",
@@ -143,6 +144,7 @@ def _build_store_detail_response(store) -> StoreDetailResponse:
# Merchant info # Merchant info
merchant_name=store.merchant.name, merchant_name=store.merchant.name,
# Owner details (from merchant) # Owner details (from merchant)
owner_user_id=store.merchant.owner_user_id,
owner_email=store.merchant.owner.email, owner_email=store.merchant.owner.email,
owner_username=store.merchant.owner.username, owner_username=store.merchant.owner.username,
# Resolved contact info with inheritance flags # Resolved contact info with inheritance flags

View File

@@ -78,6 +78,7 @@ def get_store_info(
merchant_contact_phone=store.merchant.contact_phone, merchant_contact_phone=store.merchant.contact_phone,
merchant_website=store.merchant.website, merchant_website=store.merchant.website,
# Owner details (from merchant) # Owner details (from merchant)
owner_user_id=store.merchant.owner_user_id,
owner_email=store.merchant.owner.email, owner_email=store.merchant.owner.email,
owner_username=store.merchant.owner.username, owner_username=store.merchant.owner.username,
) )

View File

@@ -232,6 +232,7 @@ class StoreDetailResponse(StoreResponse):
merchant_name: str = Field(..., description="Name of the parent merchant") merchant_name: str = Field(..., description="Name of the parent merchant")
# Owner info (at merchant level) # Owner info (at merchant level)
owner_user_id: int = Field(..., description="User ID of the merchant owner")
owner_email: str = Field( owner_email: str = Field(
..., description="Email of the merchant owner (for login/authentication)" ..., description="Email of the merchant owner (for login/authentication)"
) )

View File

@@ -271,11 +271,13 @@ class TestStoreDetailResponseSchema:
"owner_username": "owner", "owner_username": "owner",
"contact_email": "contact@techstore.com", "contact_email": "contact@techstore.com",
"contact_email_inherited": False, "contact_email_inherited": False,
"owner_user_id": 42,
} }
response = StoreDetailResponse(**data) response = StoreDetailResponse(**data)
assert response.merchant_name == "Tech Corp" assert response.merchant_name == "Tech Corp"
assert response.owner_email == "owner@techcorp.com" assert response.owner_email == "owner@techcorp.com"
assert response.contact_email_inherited is False assert response.contact_email_inherited is False
assert response.owner_user_id == 42
@pytest.mark.unit @pytest.mark.unit

View File

@@ -63,7 +63,7 @@
{# Store Logo #} {# Store Logo #}
<div class="flex items-center"> <div class="flex items-center">
<a href="{{ base_url }}storefront/" class="flex items-center space-x-3"> <a href="{{ base_url }}" class="flex items-center space-x-3">
{% if theme.branding.logo %} {% if theme.branding.logo %}
{# Show light logo in light mode, dark logo in dark mode #} {# Show light logo in light mode, dark logo in dark mode #}
<img x-show="!dark" <img x-show="!dark"
@@ -96,7 +96,7 @@
{% endfor %} {% endfor %}
{# CMS pages (About, Contact) are already dynamic via header_pages #} {# CMS pages (About, Contact) are already dynamic via header_pages #}
{% for page in header_pages|default([]) %} {% for page in header_pages|default([]) %}
<a href="{{ base_url }}storefront/{{ page.slug }}" class="text-gray-700 dark:text-gray-300 hover:text-primary"> <a href="{{ base_url }}{{ page.slug }}" class="text-gray-700 dark:text-gray-300 hover:text-primary">
{{ page.title }} {{ page.title }}
</a> </a>
{% endfor %} {% endfor %}
@@ -117,7 +117,7 @@
{% if 'cart' in enabled_modules|default([]) %} {% if 'cart' in enabled_modules|default([]) %}
{# Cart #} {# Cart #}
<a href="{{ base_url }}storefront/cart" class="relative p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"> <a href="{{ base_url }}cart" class="relative p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"></path> d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"></path>
@@ -187,7 +187,7 @@
{% endif %} {% endif %}
{# Account #} {# Account #}
<a href="{{ base_url }}storefront/account" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"> <a href="{{ base_url }}account" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path> d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
@@ -228,7 +228,7 @@
</a> </a>
{% endfor %} {% endfor %}
{% for page in header_pages|default([]) %} {% for page in header_pages|default([]) %}
<a href="{{ base_url }}storefront/{{ page.slug }}" @click="closeMobileMenu()" <a href="{{ base_url }}{{ page.slug }}" @click="closeMobileMenu()"
class="block px-3 py-2 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"> class="block px-3 py-2 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
{{ page.title }} {{ page.title }}
</a> </a>
@@ -293,10 +293,10 @@
<h4 class="font-semibold mb-4">Quick Links</h4> <h4 class="font-semibold mb-4">Quick Links</h4>
<ul class="space-y-2"> <ul class="space-y-2">
{% if 'catalog' in enabled_modules|default([]) %} {% if 'catalog' in enabled_modules|default([]) %}
<li><a href="{{ base_url }}storefront/products" class="text-gray-600 hover:text-primary dark:text-gray-400">Products</a></li> <li><a href="{{ base_url }}products" class="text-gray-600 hover:text-primary dark:text-gray-400">Products</a></li>
{% endif %} {% endif %}
{% for page in col1_pages %} {% for page in col1_pages %}
<li><a href="{{ base_url }}storefront/{{ page.slug }}" class="text-gray-600 hover:text-primary dark:text-gray-400">{{ page.title }}</a></li> <li><a href="{{ base_url }}{{ page.slug }}" class="text-gray-600 hover:text-primary dark:text-gray-400">{{ page.title }}</a></li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
@@ -307,7 +307,7 @@
<h4 class="font-semibold mb-4">Information</h4> <h4 class="font-semibold mb-4">Information</h4>
<ul class="space-y-2"> <ul class="space-y-2">
{% for page in col2_pages %} {% for page in col2_pages %}
<li><a href="{{ base_url }}storefront/{{ page.slug }}" class="text-gray-600 hover:text-primary dark:text-gray-400">{{ page.title }}</a></li> <li><a href="{{ base_url }}{{ page.slug }}" class="text-gray-600 hover:text-primary dark:text-gray-400">{{ page.title }}</a></li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
@@ -318,19 +318,19 @@
<h4 class="font-semibold mb-4">Quick Links</h4> <h4 class="font-semibold mb-4">Quick Links</h4>
<ul class="space-y-2"> <ul class="space-y-2">
{% if 'catalog' in enabled_modules|default([]) %} {% if 'catalog' in enabled_modules|default([]) %}
<li><a href="{{ base_url }}storefront/products" class="text-gray-600 hover:text-primary dark:text-gray-400">Products</a></li> <li><a href="{{ base_url }}products" class="text-gray-600 hover:text-primary dark:text-gray-400">Products</a></li>
{% endif %} {% endif %}
<li><a href="{{ base_url }}storefront/about" class="text-gray-600 hover:text-primary dark:text-gray-400">About Us</a></li> <li><a href="{{ base_url }}about" class="text-gray-600 hover:text-primary dark:text-gray-400">About Us</a></li>
<li><a href="{{ base_url }}storefront/contact" class="text-gray-600 hover:text-primary dark:text-gray-400">Contact</a></li> <li><a href="{{ base_url }}contact" class="text-gray-600 hover:text-primary dark:text-gray-400">Contact</a></li>
</ul> </ul>
</div> </div>
<div> <div>
<h4 class="font-semibold mb-4">Information</h4> <h4 class="font-semibold mb-4">Information</h4>
<ul class="space-y-2"> <ul class="space-y-2">
<li><a href="{{ base_url }}storefront/faq" class="text-gray-600 hover:text-primary dark:text-gray-400">FAQ</a></li> <li><a href="{{ base_url }}faq" class="text-gray-600 hover:text-primary dark:text-gray-400">FAQ</a></li>
<li><a href="{{ base_url }}storefront/shipping" class="text-gray-600 hover:text-primary dark:text-gray-400">Shipping</a></li> <li><a href="{{ base_url }}shipping" class="text-gray-600 hover:text-primary dark:text-gray-400">Shipping</a></li>
<li><a href="{{ base_url }}storefront/returns" class="text-gray-600 hover:text-primary dark:text-gray-400">Returns</a></li> <li><a href="{{ base_url }}returns" class="text-gray-600 hover:text-primary dark:text-gray-400">Returns</a></li>
</ul> </ul>
</div> </div>
{% endif %} {% endif %}

View File

@@ -11,12 +11,12 @@
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Go Back Go Back
</a> </a>
<a href="{{ base_url }}storefront/" <a href="{{ base_url }}"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Go to Home Go to Home
</a> </a>
{% endblock %} {% endblock %}
{% block support_link %} {% block support_link %}
Need help? <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">Contact us</a> Need help? <a href="{{ base_url }}contact" class="text-theme-primary font-semibold hover:underline">Contact us</a>
{% endblock %} {% endblock %}

View File

@@ -7,16 +7,16 @@
{% block title %}401 - Authentication Required{% endblock %} {% block title %}401 - Authentication Required{% endblock %}
{% block action_buttons %} {% block action_buttons %}
<a href="{{ base_url }}storefront/account/login" <a href="{{ base_url }}account/login"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Log In Log In
</a> </a>
<a href="{{ base_url }}storefront/account/register" <a href="{{ base_url }}account/register"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Create Account Create Account
</a> </a>
{% endblock %} {% endblock %}
{% block support_link %} {% block support_link %}
Don't have an account? <a href="{{ base_url }}storefront/account/register" class="text-theme-primary font-semibold hover:underline">Sign up now</a> Don't have an account? <a href="{{ base_url }}account/register" class="text-theme-primary font-semibold hover:underline">Sign up now</a>
{% endblock %} {% endblock %}

View File

@@ -7,16 +7,16 @@
{% block title %}403 - Access Restricted{% endblock %} {% block title %}403 - Access Restricted{% endblock %}
{% block action_buttons %} {% block action_buttons %}
<a href="{{ base_url }}storefront/account/login" <a href="{{ base_url }}account/login"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Log In Log In
</a> </a>
<a href="{{ base_url }}storefront/" <a href="{{ base_url }}"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Go to Home Go to Home
</a> </a>
{% endblock %} {% endblock %}
{% block support_link %} {% block support_link %}
Need help accessing your account? <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">Contact support</a> Need help accessing your account? <a href="{{ base_url }}contact" class="text-theme-primary font-semibold hover:underline">Contact support</a>
{% endblock %} {% endblock %}

View File

@@ -7,16 +7,16 @@
{% block title %}404 - Page Not Found{% endblock %} {% block title %}404 - Page Not Found{% endblock %}
{% block action_buttons %} {% block action_buttons %}
<a href="{{ base_url }}storefront/" <a href="{{ base_url }}"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Continue Shopping Continue Shopping
</a> </a>
<a href="{{ base_url }}storefront/products" <a href="{{ base_url }}products"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
View All Products View All Products
</a> </a>
{% endblock %} {% endblock %}
{% block support_link %} {% block support_link %}
Can't find what you're looking for? <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">Contact us</a> and we'll help you find it. Can't find what you're looking for? <a href="{{ base_url }}contact" class="text-theme-primary font-semibold hover:underline">Contact us</a> and we'll help you find it.
{% endblock %} {% endblock %}

View File

@@ -24,12 +24,12 @@
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Go Back and Fix Go Back and Fix
</a> </a>
<a href="{{ base_url }}storefront/" <a href="{{ base_url }}"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Go to Home Go to Home
</a> </a>
{% endblock %} {% endblock %}
{% block support_link %} {% block support_link %}
Having trouble? <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">We're here to help</a> Having trouble? <a href="{{ base_url }}contact" class="text-theme-primary font-semibold hover:underline">We're here to help</a>
{% endblock %} {% endblock %}

View File

@@ -21,12 +21,12 @@
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Try Again Try Again
</a> </a>
<a href="{{ base_url }}storefront/" <a href="{{ base_url }}"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Go to Home Go to Home
</a> </a>
{% endblock %} {% endblock %}
{% block support_link %} {% block support_link %}
Questions? <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">Contact us</a> Questions? <a href="{{ base_url }}contact" class="text-theme-primary font-semibold hover:underline">Contact us</a>
{% endblock %} {% endblock %}

View File

@@ -7,7 +7,7 @@
{% block title %}500 - Something Went Wrong{% endblock %} {% block title %}500 - Something Went Wrong{% endblock %}
{% block action_buttons %} {% block action_buttons %}
<a href="{{ base_url }}storefront/" <a href="{{ base_url }}"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Go to Home Go to Home
</a> </a>
@@ -18,5 +18,5 @@
{% endblock %} {% endblock %}
{% block support_link %} {% block support_link %}
Issue persisting? <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">Let us know</a> and we'll help you out. Issue persisting? <a href="{{ base_url }}contact" class="text-theme-primary font-semibold hover:underline">Let us know</a> and we'll help you out.
{% endblock %} {% endblock %}

View File

@@ -11,12 +11,12 @@
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Try Again Try Again
</a> </a>
<a href="{{ base_url }}storefront/" <a href="{{ base_url }}"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Go to Home Go to Home
</a> </a>
{% endblock %} {% endblock %}
{% block support_link %} {% block support_link %}
If this continues, <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">let us know</a> If this continues, <a href="{{ base_url }}contact" class="text-theme-primary font-semibold hover:underline">let us know</a>
{% endblock %} {% endblock %}

View File

@@ -76,11 +76,11 @@
{# Action Buttons #} {# Action Buttons #}
<div class="flex gap-4 justify-center flex-wrap mt-8"> <div class="flex gap-4 justify-center flex-wrap mt-8">
{% block action_buttons %} {% block action_buttons %}
<a href="{{ base_url }}storefront/" <a href="{{ base_url }}"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Continue Shopping Continue Shopping
</a> </a>
<a href="{{ base_url }}storefront/contact" <a href="{{ base_url }}contact"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Contact Us Contact Us
</a> </a>
@@ -92,7 +92,7 @@
{# Support Link #} {# Support Link #}
<div class="mt-10 pt-8 border-t border-gray-200 text-sm text-gray-500"> <div class="mt-10 pt-8 border-t border-gray-200 text-sm text-gray-500">
{% block support_link %} {% block support_link %}
Need help? <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">Contact our support team</a> Need help? <a href="{{ base_url }}contact" class="text-theme-primary font-semibold hover:underline">Contact our support team</a>
{% endblock %} {% endblock %}
</div> </div>

View File

@@ -7,7 +7,7 @@
{% block title %}{{ status_code }} - {{ status_name }}{% endblock %} {% block title %}{{ status_code }} - {{ status_name }}{% endblock %}
{% block action_buttons %} {% block action_buttons %}
<a href="{{ base_url }}storefront/" <a href="{{ base_url }}"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Continue Shopping Continue Shopping
</a> </a>
@@ -18,5 +18,5 @@
{% endblock %} {% endblock %}
{% block support_link %} {% block support_link %}
Need assistance? <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">Contact us</a> Need assistance? <a href="{{ base_url }}contact" class="text-theme-primary font-semibold hover:underline">Contact us</a>
{% endblock %} {% endblock %}

View File

@@ -24,16 +24,16 @@ The Storefront API provides customer-facing endpoints for browsing products, man
All Storefront API endpoints automatically receive store context from the `StoreContextMiddleware`: All Storefront API endpoints automatically receive store context from the `StoreContextMiddleware`:
1. **Browser makes API call** from storefront page (e.g., `/stores/orion/storefront/products`) 1. **Browser makes API call** from storefront page (e.g., `/platforms/oms/storefront/ORION/products`)
2. **Browser automatically sends Referer header**: `http://localhost:8000/stores/orion/storefront/products` 2. **Browser automatically sends Referer header**: `http://localhost:8000/platforms/oms/storefront/ORION/products`
3. **Middleware extracts store** from Referer path/subdomain/domain 3. **Middleware extracts store** from Referer path/subdomain/domain
4. **Middleware sets** `request.state.store = <Store: orion>` 4. **Middleware sets** `request.state.store = <Store: ORION>`
5. **API endpoint accesses store**: `store = request.state.store` 5. **API endpoint accesses store**: `store = request.state.store`
6. **No store_id needed in URL!** 6. **No store_id needed in URL!**
### Supported Store Detection Methods ### Supported Store Detection Methods
- **Path-based**: `/stores/orion/storefront/products` → extracts `orion` - **Path-based (dev)**: `/platforms/oms/storefront/ORION/products` → extracts `ORION`
- **Subdomain**: `orion.platform.com` → extracts `orion` - **Subdomain**: `orion.platform.com` → extracts `orion`
- **Custom domain**: `customshop.com` → looks up store by domain - **Custom domain**: `customshop.com` → looks up store by domain

View File

@@ -176,7 +176,7 @@ from app.modules.core.services.menu_discovery_service import menu_discovery_serv
sections = menu_discovery_service.get_menu_for_frontend( sections = menu_discovery_service.get_menu_for_frontend(
db=db, db=db,
frontend_type=FrontendType.ADMIN, frontend_type=FrontendType.ADMIN,
platform_id=1, platform_id=platform.id,
user_id=current_user.id, user_id=current_user.id,
is_super_admin=current_user.is_super_admin, is_super_admin=current_user.is_super_admin,
) )
@@ -185,7 +185,7 @@ sections = menu_discovery_service.get_menu_for_frontend(
all_items = menu_discovery_service.get_all_menu_items( all_items = menu_discovery_service.get_all_menu_items(
db=db, db=db,
frontend_type=FrontendType.ADMIN, frontend_type=FrontendType.ADMIN,
platform_id=1, platform_id=platform.id,
) )
``` ```
@@ -285,7 +285,7 @@ from app.modules.enums import FrontendType
# Platform "OMS" hides inventory from admin panel # Platform "OMS" hides inventory from admin panel
AdminMenuConfig( AdminMenuConfig(
frontend_type=FrontendType.ADMIN, frontend_type=FrontendType.ADMIN,
platform_id=1, platform_id=oms_platform.id, # OMS platform
menu_item_id="inventory", menu_item_id="inventory",
is_visible=False is_visible=False
) )
@@ -293,7 +293,7 @@ AdminMenuConfig(
# Platform "OMS" hides letzshop from store dashboard # Platform "OMS" hides letzshop from store dashboard
AdminMenuConfig( AdminMenuConfig(
frontend_type=FrontendType.STORE, frontend_type=FrontendType.STORE,
platform_id=1, platform_id=oms_platform.id, # OMS platform
menu_item_id="letzshop", menu_item_id="letzshop",
is_visible=False is_visible=False
) )

View File

@@ -361,12 +361,12 @@ Platform context flows through middleware and JWT tokens:
┌─────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────┐
│ Route Handler (Dashboard) │ │ Route Handler (Dashboard) │
│ │ │ │
│ # Get platform_id from middleware or JWT token │ # Get platform from require_platform dependency
│ platform = getattr(request.state, "platform", None) │ platform = Depends(require_platform) # Raises 400 if missing
│ platform_id = platform.id if platform else 1 │ platform_id = platform.id
│ │ │ │
│ # Or from JWT for API routes │ │ # Or from JWT for API routes │
│ platform_id = current_user.token_platform_id or 1 │ platform_id = current_user.token_platform_id
└─────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────┘
``` ```
@@ -392,13 +392,11 @@ Platform context flows through middleware and JWT tokens:
def get_store_dashboard_stats( def get_store_dashboard_stats(
request: Request, request: Request,
current_user: UserContext = Depends(get_current_store_api), current_user: UserContext = Depends(get_current_store_api),
platform=Depends(require_platform),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
store_id = current_user.token_store_id store_id = current_user.token_store_id
platform_id = platform.id
# Get platform from middleware
platform = getattr(request.state, "platform", None)
platform_id = platform.id if platform else 1
# Get aggregated metrics from all enabled modules # Get aggregated metrics from all enabled modules
metrics = stats_aggregator.get_store_dashboard_stats( metrics = stats_aggregator.get_store_dashboard_stats(

View File

@@ -572,7 +572,7 @@ def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any
"""Provide CMS context for platform/marketing pages.""" """Provide CMS context for platform/marketing pages."""
from app.modules.cms.services import content_page_service from app.modules.cms.services import content_page_service
platform_id = platform.id if platform else 1 platform_id = platform.id if platform else None
header_pages = content_page_service.list_platform_pages( header_pages = content_page_service.list_platform_pages(
db, platform_id=platform_id, header_only=True, include_unpublished=False db, platform_id=platform_id, header_only=True, include_unpublished=False

View File

@@ -38,13 +38,13 @@ Content pages follow a three-tier inheritance model:
When a customer visits a store page (e.g., `/stores/shopname/about`): When a customer visits a store page (e.g., `/stores/shopname/about`):
``` ```
Customer visits: /stores/shopname/about Customer visits: /platforms/oms/storefront/shopname/about
┌─────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────┐
│ Step 1: Check Store Override │ │ Step 1: Check Store Override │
│ SELECT * FROM content_pages │ │ SELECT * FROM content_pages │
│ WHERE platform_id=1 AND store_id=123 AND slug='about' │ WHERE platform_id=:platform_id AND store_id=123 AND slug='about' │
│ Found? → Return store's custom "About" page │ │ Found? → Return store's custom "About" page │
└─────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────┘
│ Not found │ Not found
@@ -52,7 +52,7 @@ Customer visits: /stores/shopname/about
┌─────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────┐
│ Step 2: Check Store Default │ │ Step 2: Check Store Default │
│ SELECT * FROM content_pages │ │ SELECT * FROM content_pages │
│ WHERE platform_id=1 AND store_id IS NULL │ WHERE platform_id=:platform_id AND store_id IS NULL │
│ AND is_platform_page=FALSE AND slug='about' │ │ AND is_platform_page=FALSE AND slug='about' │
│ Found? → Return platform's default "About" template │ │ Found? → Return platform's default "About" template │
└─────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────┘
@@ -152,7 +152,7 @@ Request: GET /platforms/oms/stores/shopname/about
│ Route Handler (shop_pages.py) │ │ Route Handler (shop_pages.py) │
│ - Gets platform_id from request.state.platform │ │ - Gets platform_id from request.state.platform │
│ - Calls content_page_service.get_page_for_store( │ │ - Calls content_page_service.get_page_for_store( │
│ platform_id=1, store_id=123, slug='about' │ platform_id=platform.id, store_id=123, slug='about' │
│ ) │ │ ) │
│ - Service handles three-tier resolution │ │ - Service handles three-tier resolution │
└─────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────┘
@@ -169,7 +169,7 @@ Request: GET /about
┌─────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────┐
│ PlatformContextMiddleware │ │ PlatformContextMiddleware │
│ - No /platforms/ prefix detected │ │ - No /platforms/ prefix detected │
│ - Uses DEFAULT_PLATFORM_CODE = 'main' │ │ - Uses MAIN_PLATFORM_CODE = 'main'
│ - Sets request.state.platform = Platform(code='main') │ │ - Sets request.state.platform = Platform(code='main') │
│ - Path unchanged: /about │ │ - Path unchanged: /about │
└─────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────┘

View File

@@ -6,33 +6,38 @@
There are three ways depending on the deployment mode: There are three ways depending on the deployment mode:
**⚠️ Important:** This guide describes **customer-facing storefront routes**. For store dashboard/management routes, see [Store Frontend Architecture](../../frontend/store/architecture.md). The storefront uses `/stores/{code}/storefront/*` (plural) in path-based mode, while the store dashboard uses `/store/{code}/*` (singular). **⚠️ Important:** This guide describes **customer-facing storefront routes**. For store dashboard/management routes, see [Store Frontend Architecture](../../frontend/store/architecture.md). The storefront uses `/platforms/{platform_code}/storefront/{store_code}/*` in dev path-based mode, while the store dashboard uses `/platforms/{platform_code}/store/{store_code}/*`. In production, the domain IS the storefront (root path `/`), and staff access is at `/store/`.
### 1. **SUBDOMAIN MODE** (Production - Recommended) ### 1. **SUBDOMAIN MODE** (Production - Recommended)
``` ```
https://STORE_SUBDOMAIN.platform.com/storefront/products https://STORE_SUBDOMAIN.platform-domain.lu/
https://STORE_SUBDOMAIN.platform-domain.lu/products
https://STORE_SUBDOMAIN.platform-domain.lu/cart
Example: Example:
https://acme.orion.lu/storefront/products https://acme.omsflow.lu/
https://techpro.orion.lu/storefront/categories/electronics https://acme.omsflow.lu/products
https://techpro.rewardflow.lu/account/dashboard
``` ```
### 2. **CUSTOM DOMAIN MODE** (Production - Premium) ### 2. **CUSTOM DOMAIN MODE** (Production - Premium)
``` ```
https://STORE_CUSTOM_DOMAIN/storefront/products https://STORE_CUSTOM_DOMAIN/
https://STORE_CUSTOM_DOMAIN/products
Example: Example:
https://store.acmecorp.com/storefront/products https://store.acmecorp.com/
https://shop.techpro.io/storefront/cart https://shop.techpro.io/cart
``` ```
### 3. **PATH-BASED MODE** (Development Only) ### 3. **PATH-BASED MODE** (Development Only)
``` ```
http://localhost:PORT/platforms/PLATFORM_CODE/stores/STORE_CODE/storefront/products http://localhost:PORT/platforms/PLATFORM_CODE/storefront/STORE_CODE/
http://localhost:PORT/platforms/PLATFORM_CODE/storefront/STORE_CODE/products
Example: Example:
http://localhost:8000/platforms/oms/stores/acme/storefront/products http://localhost:8000/platforms/oms/storefront/ACME/products
http://localhost:8000/platforms/loyalty/stores/techpro/storefront/checkout http://localhost:8000/platforms/loyalty/storefront/TECHPRO/cart
``` ```
--- ---
@@ -51,7 +56,7 @@ Orion supports multiple platforms (OMS, Loyalty, Site Builder), each with its ow
| `/about` | Main marketing site about page | | `/about` | Main marketing site about page |
| `/platforms/oms/` | OMS platform homepage | | `/platforms/oms/` | OMS platform homepage |
| `/platforms/oms/pricing` | OMS platform pricing page | | `/platforms/oms/pricing` | OMS platform pricing page |
| `/platforms/oms/stores/{code}/storefront/` | Store storefront on OMS | | `/platforms/oms/storefront/{code}/` | Store storefront on OMS |
| `/platforms/oms/admin/` | Admin panel for OMS platform | | `/platforms/oms/admin/` | Admin panel for OMS platform |
| `/platforms/oms/store/{code}/` | Store dashboard on OMS | | `/platforms/oms/store/{code}/` | Store dashboard on OMS |
| `/platforms/loyalty/` | Loyalty platform homepage | | `/platforms/loyalty/` | Loyalty platform homepage |
@@ -67,10 +72,11 @@ Orion supports multiple platforms (OMS, Loyalty, Site Builder), each with its ow
| `omsflow.lu/pricing` | OMS platform pricing page | | `omsflow.lu/pricing` | OMS platform pricing page |
| `omsflow.lu/admin/` | Admin panel for OMS platform | | `omsflow.lu/admin/` | Admin panel for OMS platform |
| `omsflow.lu/store/{code}/` | Store dashboard on OMS | | `omsflow.lu/store/{code}/` | Store dashboard on OMS |
| `https://mybakery.lu/storefront/` | Store storefront (store's custom domain) | | `mybakery.omsflow.lu/` | Store storefront (subdomain) |
| `https://mybakery.lu/` | Store storefront (custom domain) |
| `rewardflow.lu/` | Loyalty platform homepage | | `rewardflow.lu/` | Loyalty platform homepage |
**Note:** In production, stores configure their own custom domains for storefronts. The platform domain (e.g., `omsflow.lu`) is used for admin and store dashboards, while storefronts use store-owned domains. **Note:** In production, storefronts are accessed via subdomain (`store.omsflow.lu`) or custom domain (`mybakery.lu`). The root path `/` IS the storefront — the `PlatformContextMiddleware` internally rewrites it to `/storefront/`. Staff dashboards are at `/store/` on the same domain.
### Quick Reference by Platform ### Quick Reference by Platform
@@ -80,13 +86,14 @@ Dev:
Platform: http://localhost:8000/platforms/oms/ Platform: http://localhost:8000/platforms/oms/
Admin: http://localhost:8000/platforms/oms/admin/ Admin: http://localhost:8000/platforms/oms/admin/
Store: http://localhost:8000/platforms/oms/store/{store_code}/ Store: http://localhost:8000/platforms/oms/store/{store_code}/
Storefront: http://localhost:8000/platforms/oms/stores/{store_code}/storefront/ Storefront: http://localhost:8000/platforms/oms/storefront/{store_code}/
Prod: Prod:
Platform: https://omsflow.lu/ Platform: https://omsflow.lu/
Admin: https://omsflow.lu/admin/ Admin: https://omsflow.lu/admin/
Store: https://omsflow.lu/store/{store_code}/ Store: https://{store}.omsflow.lu/store/
Storefront: https://mybakery.lu/storefront/ (store's custom domain) Storefront: https://{store}.omsflow.lu/ (subdomain)
Storefront: https://mybakery.lu/ (custom domain)
``` ```
#### For "loyalty" Platform #### For "loyalty" Platform
@@ -95,13 +102,14 @@ Dev:
Platform: http://localhost:8000/platforms/loyalty/ Platform: http://localhost:8000/platforms/loyalty/
Admin: http://localhost:8000/platforms/loyalty/admin/ Admin: http://localhost:8000/platforms/loyalty/admin/
Store: http://localhost:8000/platforms/loyalty/store/{store_code}/ Store: http://localhost:8000/platforms/loyalty/store/{store_code}/
Storefront: http://localhost:8000/platforms/loyalty/stores/{store_code}/storefront/ Storefront: http://localhost:8000/platforms/loyalty/storefront/{store_code}/
Prod: Prod:
Platform: https://rewardflow.lu/ Platform: https://rewardflow.lu/
Admin: https://rewardflow.lu/admin/ Admin: https://rewardflow.lu/admin/
Store: https://rewardflow.lu/store/{store_code}/ Store: https://{store}.rewardflow.lu/store/
Storefront: https://myrewards.lu/storefront/ (store's custom domain) Storefront: https://{store}.rewardflow.lu/ (subdomain)
Storefront: https://myrewards.lu/ (custom domain)
``` ```
### Platform Routing Logic ### Platform Routing Logic
@@ -151,24 +159,24 @@ Request arrives
### 1. SUBDOMAIN MODE (Production - Recommended) ### 1. SUBDOMAIN MODE (Production - Recommended)
**URL Pattern:** `https://STORE_SUBDOMAIN.platform.com/storefront/...` **URL Pattern:** `https://STORE_SUBDOMAIN.platform-domain/` (root path = storefront)
**Example:** **Example:**
- Store subdomain: `acme` - Store subdomain: `acme`
- Platform domain: `orion.lu` - Platform domain: `omsflow.lu`
- Customer Storefront URL: `https://acme.orion.lu/storefront/products` - Customer Storefront URL: `https://acme.omsflow.lu/`
- Product Detail: `https://acme.orion.lu/storefront/products/123` - Product Catalog: `https://acme.omsflow.lu/products`
- Staff Dashboard: `https://acme.omsflow.lu/store/dashboard`
**How It Works:** **How It Works:**
1. Customer visits `https://acme.orion.lu/storefront/products` 1. Customer visits `https://acme.omsflow.lu/products`
2. `store_context_middleware` detects subdomain `"acme"` 2. `PlatformContextMiddleware` detects subdomain `"acme"`, resolves platform from root domain `omsflow.lu`
3. Queries: `SELECT * FROM stores WHERE subdomain = 'acme'` 3. Middleware rewrites path: `/products``/storefront/products` (internal)
4. Finds Store with ID=1 (ACME Store) 4. `store_context_middleware` detects subdomain, queries: `SELECT * FROM stores WHERE subdomain = 'acme'`
5. Sets `request.state.store = Store(ACME Store)` 5. Sets `request.state.store = Store(ACME Store)`
6. `context_middleware` detects it's a STOREFRONT request 6. `frontend_type_middleware` detects STOREFRONT from `/storefront` path prefix
7. `theme_context_middleware` loads ACME's theme 7. `theme_context_middleware` loads ACME's theme
8. Routes to `storefront_pages.py``storefront_products_page()` 8. Routes to storefront handler, renders with ACME's theme and products
9. Renders template with ACME's colors, logo, and products
**Advantages:** **Advantages:**
- Single SSL certificate for all stores (*.orion.lu) - Single SSL certificate for all stores (*.orion.lu)
@@ -179,12 +187,12 @@ Request arrives
### 2. CUSTOM DOMAIN MODE (Production - Premium) ### 2. CUSTOM DOMAIN MODE (Production - Premium)
**URL Pattern:** `https://CUSTOM_DOMAIN/storefront/...` **URL Pattern:** `https://CUSTOM_DOMAIN/` (root path = storefront)
**Example:** **Example:**
- Store name: "ACME Store" - Store name: "ACME Store"
- Custom domain: `store.acme-corp.com` - Custom domain: `store.acme-corp.com`
- Customer Storefront URL: `https://store.acme-corp.com/storefront/products` - Customer Storefront URL: `https://store.acme-corp.com/products`
**Database Setup:** **Database Setup:**
```sql ```sql
@@ -198,13 +206,12 @@ id | store_id | domain | is_active | is_verified
``` ```
**How It Works:** **How It Works:**
1. Customer visits `https://store.acme-corp.com/storefront/products` 1. Customer visits `https://store.acme-corp.com/products`
2. `store_context_middleware` detects custom domain (not *.orion.lu, not localhost) 2. `PlatformContextMiddleware` detects custom domain, resolves platform via `StoreDomain` lookup
3. Normalizes domain to `"store.acme-corp.com"` 3. Middleware rewrites path: `/products` `/storefront/products` (internal)
4. Queries: `SELECT * FROM store_domains WHERE domain = 'store.acme-corp.com'` 4. `store_context_middleware` detects custom domain, queries `store_domains` table
5. Finds `StoreDomain` with `store_id = 1` 5. Finds `StoreDomain` with `store_id = 1`, joins to get `Store(ACME Store)`
6. Joins to get `Store(ACME Store)` 6. Rest is same as subdomain mode...
7. Rest is same as subdomain mode...
**Advantages:** **Advantages:**
- Professional branding with store's own domain - Professional branding with store's own domain
@@ -219,20 +226,19 @@ id | store_id | domain | is_active | is_verified
### 3. PATH-BASED MODE (Development Only) ### 3. PATH-BASED MODE (Development Only)
**URL Pattern:** `http://localhost:PORT/platforms/PLATFORM_CODE/stores/STORE_CODE/storefront/...` **URL Pattern:** `http://localhost:PORT/platforms/PLATFORM_CODE/storefront/STORE_CODE/...`
**Example:** **Example:**
- Development: `http://localhost:8000/platforms/oms/stores/acme/storefront/products` - Development: `http://localhost:8000/platforms/oms/storefront/ACME/products`
- With port: `http://localhost:8000/platforms/loyalty/stores/acme/storefront/products/123` - With port: `http://localhost:8000/platforms/loyalty/storefront/ACME/cart`
**How It Works:** **How It Works:**
1. Developer visits `http://localhost:8000/platforms/oms/stores/acme/storefront/products` 1. Developer visits `http://localhost:8000/platforms/oms/storefront/ACME/products`
2. Platform middleware detects `/platforms/oms/` prefix, sets platform context 2. `PlatformContextMiddleware` detects `/platforms/oms/` prefix, sets platform context, strips prefix
3. `store_context_middleware` detects path-based routing pattern `/stores/acme/...` 3. `store_context_middleware` detects `/storefront/ACME/...` pattern, extracts store code `"ACME"`
4. Extracts store code `"acme"` from the path 4. Looks up Store: `SELECT * FROM stores WHERE store_code = 'ACME'`
5. Looks up Store: `SELECT * FROM stores WHERE subdomain = 'acme'` 5. Sets `request.state.store = Store(ACME)`
6. Sets `request.state.store = Store(acme)` 6. Routes to storefront pages
7. Routes to storefront pages
**Advantages:** **Advantages:**
- Perfect for local development - Perfect for local development
@@ -249,34 +255,37 @@ id | store_id | domain | is_active | is_verified
### Subdomain/Custom Domain (PRODUCTION) ### Subdomain/Custom Domain (PRODUCTION)
``` ```
https://acme.orion.lu/storefront/ → Homepage https://acme.omsflow.lu/ → Homepage
https://acme.orion.lu/storefront/products → Product Catalog https://acme.omsflow.lu/products → Product Catalog
https://acme.orion.lu/storefront/products/123 → Product Detail https://acme.omsflow.lu/products/123 → Product Detail
https://acme.orion.lu/storefront/categories/electronics → Category Page https://acme.omsflow.lu/categories/electronics → Category Page
https://acme.orion.lu/storefront/cart → Shopping Cart https://acme.omsflow.lu/cart → Shopping Cart
https://acme.orion.lu/storefront/checkout → Checkout https://acme.omsflow.lu/checkout → Checkout
https://acme.orion.lu/storefront/search?q=laptop → Search Results https://acme.omsflow.lu/search?q=laptop → Search Results
https://acme.orion.lu/storefront/account/login → Customer Login https://acme.omsflow.lu/account/login → Customer Login
https://acme.orion.lu/storefront/account/dashboard → Account Dashboard (Auth Required) https://acme.omsflow.lu/account/dashboard → Account Dashboard (Auth Required)
https://acme.orion.lu/storefront/account/orders → Order History (Auth Required) https://acme.omsflow.lu/account/orders → Order History (Auth Required)
https://acme.orion.lu/storefront/account/profile → Profile (Auth Required) https://acme.omsflow.lu/store/dashboard → Staff Dashboard (Auth Required)
``` ```
Note: In production, the root path `/` is the storefront. The `PlatformContextMiddleware`
internally rewrites paths to `/storefront/` for route matching. Staff access is at `/store/`.
### Path-Based (DEVELOPMENT) ### Path-Based (DEVELOPMENT)
``` ```
http://localhost:8000/platforms/oms/stores/acme/storefront/ → Homepage http://localhost:8000/platforms/oms/storefront/ACME/ → Homepage
http://localhost:8000/platforms/oms/stores/acme/storefront/products → Products http://localhost:8000/platforms/oms/storefront/ACME/products → Products
http://localhost:8000/platforms/oms/stores/acme/storefront/products/123 → Product Detail http://localhost:8000/platforms/oms/storefront/ACME/products/123 → Product Detail
http://localhost:8000/platforms/oms/stores/acme/storefront/cart → Cart http://localhost:8000/platforms/oms/storefront/ACME/cart → Cart
http://localhost:8000/platforms/oms/stores/acme/storefront/checkout → Checkout http://localhost:8000/platforms/oms/storefront/ACME/checkout → Checkout
http://localhost:8000/platforms/oms/stores/acme/storefront/account/login → Login http://localhost:8000/platforms/oms/storefront/ACME/account/login → Login
``` ```
### API Endpoints (Same for All Modes) ### API Endpoints (Same for All Modes)
``` ```
GET /api/v1/storefront/stores/1/products → Get store products GET /api/v1/storefront/products → Get store products (store from middleware)
GET /api/v1/storefront/stores/1/products/123 → Get product details GET /api/v1/storefront/products/123 → Get product details
POST /api/v1/storefront/stores/1/products/{id}/reviews → Add product review POST /api/v1/storefront/products/{id}/reviews → Add product review
``` ```
--- ---
@@ -304,8 +313,8 @@ POST /api/v1/storefront/stores/1/products/{id}/reviews → Add product review
### Example: No Cross-Store Leakage ### Example: No Cross-Store Leakage
```python ```python
# Customer on acme.orion.lu tries to access TechPro's products # Customer on acme.omsflow.lu tries to access TechPro's products
# They make API call to /api/v1/storefront/stores/2/products # Store context is set to ACME by middleware — all queries scoped to ACME
# Backend checks: # Backend checks:
store = get_store_from_request(request) # Returns Store(id=1, name="ACME") store = get_store_from_request(request) # Returns Store(id=1, name="ACME")
@@ -458,35 +467,36 @@ In Jinja2 template:
## Path-Based Routing Implementation ## Path-Based Routing Implementation
**Current Solution: Double Router Mounting** **Current Solution: Double Router Mounting + Path Rewriting**
The application handles path-based routing by registering storefront routes **twice** with different prefixes: The application handles routing by registering storefront routes **twice** with different prefixes:
```python ```python
# In main.py # In main.py
app.include_router(storefront_pages.router, prefix="/storefront") app.include_router(storefront_pages.router, prefix="/storefront")
app.include_router(storefront_pages.router, prefix="/stores/{store_code}/storefront") app.include_router(storefront_pages.router, prefix="/storefront/{store_code}")
``` ```
**How This Works:** **How This Works:**
1. **For Subdomain/Custom Domain Mode:** 1. **For Subdomain/Custom Domain Mode (Production):**
- URL: `https://acme.orion.lu/storefront/products` - URL: `https://acme.omsflow.lu/products`
- `PlatformContextMiddleware` detects subdomain, rewrites path: `/products``/storefront/products`
- Matches: First router with `/storefront` prefix - Matches: First router with `/storefront` prefix
- Route: `@router.get("/products")` → Full path: `/storefront/products` - Route: `@router.get("/products")` → Full path: `/storefront/products`
2. **For Path-Based Development Mode:** 2. **For Path-Based Development Mode:**
- URL: `http://localhost:8000/platforms/oms/stores/acme/storefront/products` - URL: `http://localhost:8000/platforms/oms/storefront/ACME/products`
- Platform middleware strips `/platforms/oms/` prefix, sets platform context - Platform middleware strips `/platforms/oms/` prefix, sets platform context
- Matches: Second router with `/stores/{store_code}/storefront` prefix - Matches: Second router with `/storefront/{store_code}` prefix
- Route: `@router.get("/products")` → Full path: `/stores/{store_code}/storefront/products` - Route: `@router.get("/products")` → Full path: `/storefront/{store_code}/products`
- Bonus: `store_code` available as path parameter! - Bonus: `store_code` available as path parameter!
**Benefits:** **Benefits:**
-No middleware complexity or path manipulation -Clean separation: `/storefront/` = customer, `/store/` = staff
-FastAPI native routing -Production URLs are clean (root path = storefront)
-Explicit and maintainable -No `/storefront/` prefix visible to production customers
-Store code accessible via path parameter when needed -Internal path rewriting handled by ASGI middleware
- ✅ Both deployment modes supported cleanly - ✅ Both deployment modes supported cleanly
--- ---
@@ -511,9 +521,9 @@ Set-Cookie: customer_token=eyJ...; Path=/storefront; HttpOnly; SameSite=Lax
| Mode | URL | Use Case | SSL | DNS | | Mode | URL | Use Case | SSL | DNS |
|------|-----|----------|-----|-----| |------|-----|----------|-----|-----|
| Subdomain | `store.platform.com/storefront` | Production (standard) | *.platform.com | Add subdomains | | Subdomain | `store.platform.com/` | Production (standard) | *.platform.com | Add subdomains |
| Custom Domain | `store-domain.com/storefront` | Production (premium) | Per store | Store configures | | Custom Domain | `store-domain.com/` | Production (premium) | Per store | Store configures |
| Path-Based | `localhost:8000/platforms/{p}/stores/{v}/storefront` | Development only | None | None | | Path-Based | `localhost:8000/platforms/{p}/storefront/{v}/` | Development only | None | None |
--- ---

View File

@@ -190,7 +190,7 @@ if settings.environment == "development":
Development (using /platforms/ prefix): Development (using /platforms/ prefix):
- [x] `localhost:9999/platforms/oms/` → OMS homepage - [x] `localhost:9999/platforms/oms/` → OMS homepage
- [x] `localhost:9999/platforms/oms/pricing` → OMS pricing page - [x] `localhost:9999/platforms/oms/pricing` → OMS pricing page
- [x] `localhost:9999/platforms/oms/stores/{code}/` → Store storefront - [x] `localhost:9999/platforms/oms/storefront/{code}/` → Store storefront
- [x] `localhost:9999/platforms/loyalty/` → Loyalty homepage - [x] `localhost:9999/platforms/loyalty/` → Loyalty homepage
--- ---
@@ -246,7 +246,7 @@ Inserts Loyalty platform with:
| Task | File | Status | | Task | File | Status |
|------|------|--------| |------|------|--------|
| Update middleware URL detection | `middleware/platform_context.py` | ✅ | | Update middleware URL detection | `middleware/platform_context.py` | ✅ |
| Change DEFAULT_PLATFORM_CODE | `middleware/platform_context.py` | ✅ | | Change MAIN_PLATFORM_CODE | `middleware/platform_context.py` | ✅ |
| Remove hardcoded /oms, /loyalty routes | `main.py` | ✅ | | Remove hardcoded /oms, /loyalty routes | `main.py` | ✅ |
| Update platform_pages.py homepage | `app/routes/platform_pages.py` | ✅ | | Update platform_pages.py homepage | `app/routes/platform_pages.py` | ✅ |
| Add 'main' platform migration | `alembic/versions/z6g7h8i9j0k1_...py` | ✅ | | Add 'main' platform migration | `alembic/versions/z6g7h8i9j0k1_...py` | ✅ |
@@ -324,19 +324,19 @@ Included in `docs/architecture/multi-platform-cms.md`:
### Three-Tier Content Resolution ### Three-Tier Content Resolution
``` ```
Customer visits: omsflow.lu/stores/orion/about Customer visits: orion.omsflow.lu/about
┌─────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────┐
│ Tier 1: Store Override │ │ Tier 1: Store Override │
│ WHERE platform_id=1 AND store_id=123 AND slug='about' │ WHERE platform_id=:pid AND store_id=123 AND slug='about' │
│ Found? → Return store's custom page │ │ Found? → Return store's custom page │
└─────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────┘
│ Not found │ Not found
┌─────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────┐
│ Tier 2: Store Default │ │ Tier 2: Store Default │
│ WHERE platform_id=1 AND store_id IS NULL │ WHERE platform_id=:pid AND store_id IS NULL │
│ AND is_platform_page=FALSE AND slug='about' │ │ AND is_platform_page=FALSE AND slug='about' │
│ Found? → Return platform default │ │ Found? → Return platform default │
└─────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────┘
@@ -383,8 +383,8 @@ curl -s localhost:9999/platforms/loyalty/ | grep -o "<title>.*</title>"
# 5. Verify middleware detection # 5. Verify middleware detection
python -c " python -c "
from middleware.platform_context import PlatformContextMiddleware, DEFAULT_PLATFORM_CODE from middleware.platform_context import PlatformContextMiddleware, MAIN_PLATFORM_CODE
print(f'DEFAULT_PLATFORM_CODE: {DEFAULT_PLATFORM_CODE}') print(f'MAIN_PLATFORM_CODE: {MAIN_PLATFORM_CODE}')
# Expected: main # Expected: main
" "
``` ```

View File

@@ -258,7 +258,7 @@ graph TD
5. **LanguageMiddleware** resolves language based on frontend type 5. **LanguageMiddleware** resolves language based on frontend type
6. **ThemeContextMiddleware** loads store theme based on context 6. **ThemeContextMiddleware** loads store theme based on context
**Note:** Path-based routing (e.g., `/stores/{code}/storefront/*`) is handled by double router mounting in `main.py`, not by middleware. **Note:** Path-based routing (e.g., `/platforms/{platform_code}/storefront/{store_code}/*`) is handled by double router mounting in `main.py`, not by middleware. In production (subdomain/custom domain), `PlatformContextMiddleware` rewrites the path to prepend `/storefront/` internally.
--- ---

View File

@@ -300,6 +300,59 @@ the server filesystem.
--- ---
## Loyalty Module
Configuration for the loyalty module (stamp/points programs, wallet integration).
All variables use the `LOYALTY_` prefix and are managed by `app/modules/loyalty/config.py`.
### Anti-Fraud Defaults
| Variable | Description | Default | Required |
|---|---|---|---|
| `LOYALTY_DEFAULT_COOLDOWN_MINUTES` | Minimum minutes between stamps for the same card | `15` | No |
| `LOYALTY_MAX_DAILY_STAMPS` | Maximum stamps per card per day | `5` | No |
| `LOYALTY_PIN_MAX_FAILED_ATTEMPTS` | Failed PIN attempts before lockout | `5` | No |
| `LOYALTY_PIN_LOCKOUT_MINUTES` | Duration of PIN lockout in minutes | `30` | No |
### Points
| Variable | Description | Default | Required |
|---|---|---|---|
| `LOYALTY_DEFAULT_POINTS_PER_EURO` | Points earned per euro spent | `10` | No |
### Google Wallet
!!! info "Required for Google Wallet passes"
Both variables must be set for loyalty cards to appear in Google Wallet.
See [Hetzner Step 25](hetzner-server-setup.md#step-25-google-wallet-integration) for setup guide.
| Variable | Description | Default | Required |
|---|---|---|---|
| `LOYALTY_GOOGLE_ISSUER_ID` | Google Wallet Issuer ID (numeric string from [Pay & Wallet Console](https://pay.google.com/business/console)) | `None` | Yes (for Google Wallet) |
| `LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON` | Path to the Google service account JSON key file | `None` | Yes (for Google Wallet) |
### Apple Wallet
!!! info "Required for Apple Wallet passes"
All five variables must be set for `.pkpass` generation.
Requires an Apple Developer account ($99/year).
| Variable | Description | Default | Required |
|---|---|---|---|
| `LOYALTY_APPLE_PASS_TYPE_ID` | Pass type identifier (e.g., `pass.com.example.loyalty`) | `None` | Yes (for Apple Wallet) |
| `LOYALTY_APPLE_TEAM_ID` | Apple Developer Team ID | `None` | Yes (for Apple Wallet) |
| `LOYALTY_APPLE_WWDR_CERT_PATH` | Path to Apple WWDR intermediate certificate | `None` | Yes (for Apple Wallet) |
| `LOYALTY_APPLE_SIGNER_CERT_PATH` | Path to pass signing certificate (`.pem`) | `None` | Yes (for Apple Wallet) |
| `LOYALTY_APPLE_SIGNER_KEY_PATH` | Path to pass signing private key (`.pem`) | `None` | Yes (for Apple Wallet) |
### QR Code
| Variable | Description | Default | Required |
|---|---|---|---|
| `LOYALTY_QR_CODE_SIZE` | QR code image size in pixels | `300` | No |
---
## Cloudflare CDN / Proxy ## Cloudflare CDN / Proxy
| Variable | Description | Default | Required | | Variable | Description | Default | Required |
@@ -335,6 +388,8 @@ marked **critical** will trigger a startup warning if left at their default valu
- [x] **Email (Mailgun):** `MAILGUN_API_KEY`, `MAILGUN_DOMAIN` — transactional only, no marketing features - [x] **Email (Mailgun):** `MAILGUN_API_KEY`, `MAILGUN_DOMAIN` — transactional only, no marketing features
- [x] **Email (SES):** `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` — cheapest at scale - [x] **Email (SES):** `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` — cheapest at scale
- [x] **R2 Storage:** `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` - [x] **R2 Storage:** `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`
- [x] **Google Wallet:** `LOYALTY_GOOGLE_ISSUER_ID`, `LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON`
- [ ] **Apple Wallet:** `LOYALTY_APPLE_PASS_TYPE_ID`, `LOYALTY_APPLE_TEAM_ID`, `LOYALTY_APPLE_WWDR_CERT_PATH`, `LOYALTY_APPLE_SIGNER_CERT_PATH`, `LOYALTY_APPLE_SIGNER_KEY_PATH`
### Example `.env` file (production) ### Example `.env` file (production)
@@ -374,4 +429,8 @@ ENABLE_METRICS=True
SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0
SENTRY_ENVIRONMENT=production SENTRY_ENVIRONMENT=production
CLOUDFLARE_ENABLED=True CLOUDFLARE_ENABLED=True
# Google Wallet (Loyalty)
LOYALTY_GOOGLE_ISSUER_ID=3388000000023089598
LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/app/google-wallet-sa.json
``` ```

View File

@@ -102,6 +102,26 @@ Complete step-by-step guide for deploying Orion on a Hetzner Cloud VPS.
**Steps 124 fully complete.** Enterprise infrastructure hardening done. **Steps 124 fully complete.** Enterprise infrastructure hardening done.
!!! success "Progress — 2026-02-24"
**Completed:**
- **Step 25: Google Wallet Integration** — Google Cloud project "Orion" created, Wallet API enabled, service account configured
- Google Pay Merchant ID: `BCR2DN5TW2CNXDAG`
- Google Wallet Issuer ID: `3388000000023089598`
- Service account: `wallet-service@orion-488322.iam.gserviceaccount.com` (admin role in Pay & Wallet Console)
- Service account JSON key generated
- Dependencies added to `requirements.txt`: `google-auth>=2.0.0`, `PyJWT>=2.0.0` (commit `d36783a`)
- Loyalty env vars added to `.env.example` and `docs/deployment/environment.md`
**Next steps:**
- [ ] Upload service account JSON to Hetzner server
- [ ] Set `LOYALTY_GOOGLE_ISSUER_ID` and `LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON` in production `.env`
- [ ] Restart app and test end-to-end: enroll → add pass → stamp → verify pass updates
- [ ] Wire "Add to Google Wallet" button into storefront enrollment success page
- [ ] Submit for Google production approval when ready
- [ ] Apple Wallet setup (APNs push, certificates, pass images)
!!! success "Progress — 2026-02-16" !!! success "Progress — 2026-02-16"
**Completed:** **Completed:**
@@ -1753,6 +1773,196 @@ This document has been updated with Steps 1924. Additional documentation chan
--- ---
## Step 25: Google Wallet Integration
Enable loyalty card passes in Google Wallet so customers can add their loyalty card to their Android phone.
### Prerequisites
- Google account (personal Gmail is fine)
- Loyalty module deployed and working
### 25.1 Google Pay & Wallet Console
Register as a Google Wallet Issuer:
1. Go to [pay.google.com/business/console](https://pay.google.com/business/console)
2. Enter your business name (e.g., "Letzshop" or your company name) — this is for Google's review, customers don't see it on passes
3. Note your **Issuer ID** from the Google Wallet API section
!!! info "Issuer ID"
The Issuer ID is a long numeric string (e.g., `3388000000023089598`). You'll find it under Google Wallet API → Manage in the Pay & Wallet Console.
### 25.2 Google Cloud Project
1. Go to [console.cloud.google.com](https://console.cloud.google.com)
2. Create a new project (e.g., "Orion")
3. Enable the **Google Wallet API**:
- Navigate to "APIs & Services" → "Library"
- Search for "Google Wallet API" and enable it
### 25.3 Service Account
Create a service account for API access:
1. Go to "APIs & Services" → "Credentials" → "Create Credentials"
2. Select **Google Wallet API** as the API
3. Select **Application data** (not user data — your backend calls the API directly)
4. Name the service account (e.g., `wallet-service`)
5. Click "Done"
Download the JSON key:
1. Go to "IAM & Admin" → "Service Accounts"
2. Click on the service account you created
3. Go to **Keys** tab → **Add Key** → **Create new key** → **JSON**
4. Save the downloaded `.json` file securely
### 25.4 Link Service Account to Issuer
1. Go back to [pay.google.com/business/console](https://pay.google.com/business/console)
2. In the **left sidebar**, click **Users** (not inside the Wallet API section)
3. Invite the service account email (e.g., `wallet-service@orion-488322.iam.gserviceaccount.com`)
4. Assign **Admin** role
5. Verify it appears in the users list
!!! warning "Common mistake"
The "Users" link is in the **left sidebar** of the Pay & Wallet Console, not inside the "Google Wallet API" → "Manage" section. The Manage page has "Setup test accounts" which is a different feature.
### 25.5 Deploy to Server
Upload the service account JSON key to the Hetzner server:
```bash
# From your local machine
scp /path/to/orion-488322-xxxxx.json samir@91.99.65.229:~/apps/orion/google-wallet-sa.json
```
Add the environment variables to the production `.env`:
```bash
ssh samir@91.99.65.229
cd ~/apps/orion
nano .env
```
Add:
```bash
# Google Wallet (Loyalty Module)
LOYALTY_GOOGLE_ISSUER_ID=3388000000023089598
LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/app/google-wallet-sa.json
```
!!! note "Docker path"
The path must be relative to the Docker container's filesystem. If the file is in `~/apps/orion/`, it maps to `/app/` inside the container (check your `docker-compose.yml` volumes).
Mount the JSON file in `docker-compose.yml` if not already covered by the app volume:
```yaml
services:
api:
volumes:
- ./google-wallet-sa.json:/app/google-wallet-sa.json:ro
```
Restart the application:
```bash
docker compose --profile full up -d --build
```
### 25.6 Verify Configuration
Check the API health and wallet service status:
```bash
# Check the app logs for wallet service initialization
docker compose --profile full logs api | grep -i "wallet\|loyalty"
# Test via API — create a program and enroll a customer, then check the response
# for google_object_id and google Wallet URL fields
curl -s https://api.wizard.lu/health | python3 -m json.tool
```
### 25.7 Testing Google Wallet Passes
Google provides a **demo mode** — passes work in test without full production approval:
1. Console admins and developers (your Google account) can always test passes
2. For additional testers, add their Gmail addresses in Pay & Wallet Console → Google Wallet API → Manage → **Setup test accounts**
3. Use `walletobjects.sandbox` scope for initial testing (the code uses `wallet_object.issuer` which covers both)
**End-to-end test flow:**
1. Create a loyalty program via the store panel
2. Enroll a customer (via store or storefront self-enrollment)
3. The API returns a Google Wallet save URL
4. Open the URL on an Android device — the pass is added to Google Wallet
5. Add a stamp or points — the pass in Google Wallet auto-updates
### 25.8 Local Development Setup
You can test the full Google Wallet integration from your local machine:
```bash
# In your local .env (or export directly)
export LOYALTY_GOOGLE_ISSUER_ID=3388000000023089598
export LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/path/to/orion-488322-xxxxx.json
```
The `GoogleWalletService` calls Google's REST API directly over HTTPS — no special network configuration needed. The same service account JSON works on both local and server environments.
**Local testing checklist:**
- [x] Service account JSON downloaded and path set in env
- [x] `LOYALTY_GOOGLE_ISSUER_ID` set in env
- [ ] Start the app locally: `uvicorn app.main:app --reload`
- [ ] Create a loyalty program → verify `google_class_id` is set
- [ ] Enroll a customer → verify `google_object_id` is set
- [ ] Call `get_save_url()` → open the URL on Android to add pass
- [ ] Add stamps → verify pass updates in Google Wallet
### 25.9 How It Works (Architecture)
```
┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐
│ Merchant │────▶│ Orion API │────▶│ Google Wallet API │
│ creates │ │ │ │ │
│ program │ │ create_class │ │ POST /loyaltyClass │
└─────────────┘ └──────────────┘ └─────────────────────┘
┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐
│ Customer │────▶│ Orion API │────▶│ Google Wallet API │
│ enrolls │ │ │ │ │
│ │ │create_object │ │ POST /loyaltyObject │
│ │◀────│ save_url │ │ │
│ │ └──────────────┘ └─────────────────────┘
│ taps "Add │
│ to Wallet" │────▶ Google Wallet app adds pass automatically
└─────────────┘
┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐
│ Staff adds │────▶│ Orion API │────▶│ Google Wallet API │
│ stamp │ │ │ │ │
│ │ │update_object │ │ PATCH /loyaltyObject│
└─────────────┘ └──────────────┘ └─────────────────────┘
Pass auto-updates on
customer's phone
```
No push notifications needed — Google syncs object changes automatically.
### 25.10 Next Steps
After Google Wallet is verified working:
1. **Wire "Add to Google Wallet" button** into the storefront enrollment success page and card dashboard
2. **Submit for Google production approval** — required before non-test users can add passes
3. **Apple Wallet** — separate setup requiring Apple Developer account, APNs certificates, and pass signing certificates (see [Loyalty Module docs](../modules/loyalty.md#apple-wallet))
---
## Domain & Port Reference ## Domain & Port Reference
| Service | Internal Port | External Port | Domain (via Caddy) | | Service | Internal Port | External Port | Domain (via Caddy) |

57
main.py
View File

@@ -22,7 +22,7 @@ from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
import sentry_sdk import sentry_sdk
from fastapi import Depends, FastAPI, HTTPException, Request, Response from fastapi import Depends, FastAPI, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -435,13 +435,13 @@ for route_info in store_page_routes:
# STOREFRONT PAGES (Customer Shop) # STOREFRONT PAGES (Customer Shop)
# ============================================================================= # =============================================================================
# Customer shop pages - Register at TWO prefixes: # Customer shop pages - Register at TWO prefixes:
# 1. /storefront/* (for subdomain/custom domain modes) # 1. /storefront/* (for prod: subdomain/custom domain, after path rewrite by middleware)
# 2. /stores/{code}/storefront/* (for path-based development mode) # 2. /storefront/{store_code}/* (for dev: path-based, after /platforms/{code}/ strip)
logger.info("Auto-discovering storefront page routes...") logger.info("Auto-discovering storefront page routes...")
storefront_page_routes = get_storefront_page_routes() storefront_page_routes = get_storefront_page_routes()
logger.info(f" Found {len(storefront_page_routes)} storefront page route modules") logger.info(f" Found {len(storefront_page_routes)} storefront page route modules")
# Register at /storefront/* (direct access) # Register at /storefront/* (prod mode — middleware rewrites /products → /storefront/products)
logger.info(" Registering storefront routes at /storefront/*") logger.info(" Registering storefront routes at /storefront/*")
for route_info in storefront_page_routes: for route_info in storefront_page_routes:
prefix = f"/storefront{route_info.custom_prefix}" if route_info.custom_prefix else "/storefront" prefix = f"/storefront{route_info.custom_prefix}" if route_info.custom_prefix else "/storefront"
@@ -453,10 +453,10 @@ for route_info in storefront_page_routes:
include_in_schema=False, include_in_schema=False,
) )
# Register at /stores/{code}/storefront/* (path-based development mode) # Register at /storefront/{store_code}/* (dev mode — /platforms/oms/storefront/WIZATECH/...)
logger.info(" Registering storefront routes at /stores/{code}/storefront/*") logger.info(" Registering storefront routes at /storefront/{store_code}/*")
for route_info in storefront_page_routes: for route_info in storefront_page_routes:
prefix = f"/stores/{{store_code}}/storefront{route_info.custom_prefix}" if route_info.custom_prefix else "/stores/{store_code}/storefront" prefix = f"/storefront/{{store_code}}{route_info.custom_prefix}" if route_info.custom_prefix else "/storefront/{store_code}"
app.include_router( app.include_router(
route_info.router, route_info.router,
prefix=prefix, prefix=prefix,
@@ -465,49 +465,6 @@ for route_info in storefront_page_routes:
) )
# Add handler for /stores/{store_code}/ root path
@app.get(
"/stores/{store_code}/", response_class=HTMLResponse, include_in_schema=False
)
async def store_root_path(
store_code: str, request: Request, db: Session = Depends(get_db)
):
"""Handle store root path (e.g., /stores/orion/)"""
# Store should already be in request.state from middleware
store = getattr(request.state, "store", None)
platform = getattr(request.state, "platform", None)
if not store:
raise HTTPException(status_code=404, detail=f"Store '{store_code}' not found")
from app.modules.cms.services import content_page_service
from app.modules.core.utils.page_context import get_storefront_context
# Get platform_id (use platform from context or default to 1 for OMS)
platform_id = platform.id if platform else 1
# Try to find landing page (with three-tier resolution)
landing_page = content_page_service.get_page_for_store(
db, platform_id=platform_id, slug="landing", store_id=store.id, include_unpublished=False
)
if not landing_page:
landing_page = content_page_service.get_page_for_store(
db, platform_id=platform_id, slug="home", store_id=store.id, include_unpublished=False
)
if landing_page:
# Render landing page with selected template
template_name = landing_page.template or "default"
template_path = f"store/landing-{template_name}.html"
return templates.TemplateResponse(
template_path, get_storefront_context(request, db=db, page=landing_page)
)
# No landing page - redirect to shop
return RedirectResponse(url=f"/stores/{store_code}/storefront/", status_code=302)
# ============================================================================ # ============================================================================
# PLATFORM ROUTING (via PlatformContextMiddleware) # PLATFORM ROUTING (via PlatformContextMiddleware)
# #

View File

@@ -29,8 +29,11 @@ from app.modules.tenancy.models import Platform
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Default platform code for main marketing site # Platform code for the main marketing site (localhost without /platforms/ prefix)
DEFAULT_PLATFORM_CODE = "main" MAIN_PLATFORM_CODE = "main"
# Hosts treated as local development (including Starlette TestClient's "testserver")
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "testserver"}
class PlatformContextManager: class PlatformContextManager:
@@ -68,7 +71,7 @@ class PlatformContextManager:
# Method 1: Domain-based detection (production) # Method 1: Domain-based detection (production)
# Check if the host matches a known platform domain # Check if the host matches a known platform domain
# This will be resolved in get_platform_from_context by DB lookup # This will be resolved in get_platform_from_context by DB lookup
if host_without_port and host_without_port not in ["localhost", "127.0.0.1"]: if host_without_port and host_without_port not in _LOCAL_HOSTS:
# Could be a platform domain or a store subdomain/custom domain # Could be a platform domain or a store subdomain/custom domain
# Check if it's a known platform domain pattern # Check if it's a known platform domain pattern
# For now, assume non-localhost hosts that aren't subdomains are platform domains # For now, assume non-localhost hosts that aren't subdomains are platform domains
@@ -108,11 +111,11 @@ class PlatformContextManager:
# Method 3: Default platform for localhost without /platforms/ prefix # Method 3: Default platform for localhost without /platforms/ prefix
# This serves the main marketing site # This serves the main marketing site
# Store routes require explicit platform via /platforms/{code}/store/... # Store routes require explicit platform via /platforms/{code}/store/...
if host_without_port in ["localhost", "127.0.0.1"]: if host_without_port in _LOCAL_HOSTS:
if path.startswith(("/store/", "/stores/")): if path.startswith(("/store/", "/stores/")):
return None # No platform — handlers will show appropriate error return None # No platform — handlers will show appropriate error
return { return {
"path_prefix": DEFAULT_PLATFORM_CODE, "path_prefix": MAIN_PLATFORM_CODE,
"detection_method": "default", "detection_method": "default",
"host": host, "host": host,
"original_path": path, "original_path": path,
@@ -136,8 +139,8 @@ class PlatformContextManager:
platform = None platform = None
# Method 1: Domain-based lookup # Method 1: Domain-based lookup (also handles subdomain detection)
if context.get("detection_method") == "domain": if context.get("detection_method") in ("domain", "subdomain"):
domain = context.get("domain") domain = context.get("domain")
if domain: if domain:
# Try Platform.domain first # Try Platform.domain first
@@ -168,6 +171,8 @@ class PlatformContextManager:
.first() .first()
) )
if platform: if platform:
# Mark as store domain so __call__ knows to rewrite path
context["is_store_domain"] = True
logger.debug( logger.debug(
f"[PLATFORM] Platform found via store domain: {domain}{platform.name}" f"[PLATFORM] Platform found via store domain: {domain}{platform.name}"
) )
@@ -194,6 +199,8 @@ class PlatformContextManager:
.first() .first()
) )
if platform: if platform:
# Mark as store domain so __call__ knows to rewrite path
context["is_store_domain"] = True
logger.debug( logger.debug(
f"[PLATFORM] Platform found via merchant domain: {domain}{platform.name}" f"[PLATFORM] Platform found via merchant domain: {domain}{platform.name}"
) )
@@ -226,7 +233,7 @@ class PlatformContextManager:
if context.get("detection_method") == "default": if context.get("detection_method") == "default":
platform = ( platform = (
db.query(Platform) db.query(Platform)
.filter(Platform.code == DEFAULT_PLATFORM_CODE) .filter(Platform.code == MAIN_PLATFORM_CODE)
.filter(Platform.is_active.is_(True)) .filter(Platform.is_active.is_(True))
.first() .first()
) )
@@ -366,12 +373,33 @@ class PlatformContextMiddleware:
# REWRITE THE PATH for routing # REWRITE THE PATH for routing
# This is the key: FastAPI will route based on this rewritten path # This is the key: FastAPI will route based on this rewritten path
if platform_context.get("detection_method") == "path": detection_method = platform_context.get("detection_method")
if detection_method == "path":
# Dev mode: strip /platforms/{code}/ prefix
scope["path"] = clean_path scope["path"] = clean_path
# Also update raw_path if present
if "raw_path" in scope: if "raw_path" in scope:
scope["raw_path"] = clean_path.encode("utf-8") scope["raw_path"] = clean_path.encode("utf-8")
# Prod mode: subdomain or custom store domain
# Prepend /storefront/ so routes registered at /storefront/ prefix match
is_storefront_domain = (
detection_method == "subdomain"
or platform_context.get("is_store_domain", False)
)
if is_storefront_domain:
_RESERVED = (
"/store/", "/admin/", "/api/", "/static/",
"/storefront/", "/health", "/docs", "/redoc",
"/media/", "/assets/",
)
if not any(clean_path.startswith(p) for p in _RESERVED):
new_path = "/storefront" + clean_path
scope["path"] = new_path
scope["state"]["platform_clean_path"] = new_path
if "raw_path" in scope:
scope["raw_path"] = new_path.encode("utf-8")
logger.debug( logger.debug(
f"[PLATFORM] Detected: {platform.code}, " f"[PLATFORM] Detected: {platform.code}, "
f"original={path}, routed={scope['path']}" f"original={path}, routed={scope['path']}"
@@ -406,7 +434,7 @@ class PlatformContextMiddleware:
host_without_port = host.split(":")[0] if ":" in host else host host_without_port = host.split(":")[0] if ":" in host else host
# Method 1: Domain-based (production) # Method 1: Domain-based (production)
if host_without_port and host_without_port not in ["localhost", "127.0.0.1"]: if host_without_port and host_without_port not in _LOCAL_HOSTS:
if "." in host_without_port: if "." in host_without_port:
parts = host_without_port.split(".") parts = host_without_port.split(".")
if len(parts) == 2: # Root domain like omsflow.lu if len(parts) == 2: # Root domain like omsflow.lu
@@ -415,7 +443,17 @@ class PlatformContextMiddleware:
"detection_method": "domain", "detection_method": "domain",
"host": host, "host": host,
"original_path": path, "original_path": path,
"clean_path": path, # No path rewrite for domain-based "clean_path": path,
}
if len(parts) >= 3: # Subdomain like wizatech.omsflow.lu
root_domain = ".".join(parts[-2:])
return {
"domain": root_domain,
"subdomain": parts[0],
"detection_method": "subdomain",
"host": host,
"original_path": path,
"clean_path": path,
} }
# Method 2: Path-based (development) - ONLY for /platforms/ prefix # Method 2: Path-based (development) - ONLY for /platforms/ prefix
@@ -436,12 +474,12 @@ class PlatformContextMiddleware:
} }
# Method 3: Default for localhost - serves main marketing site # Method 3: Default for localhost - serves main marketing site
# Store routes require explicit platform via /platforms/{code}/store/... # Store/storefront routes require explicit platform via /platforms/{code}/...
if host_without_port in ["localhost", "127.0.0.1"]: if host_without_port in _LOCAL_HOSTS:
if path.startswith(("/store/", "/stores/")): if path.startswith(("/store/", "/stores/", "/storefront/")):
return None # No platform — handlers will show appropriate error return None # No platform — require /platforms/{code}/ prefix
return { return {
"path_prefix": DEFAULT_PLATFORM_CODE, "path_prefix": MAIN_PLATFORM_CODE,
"detection_method": "default", "detection_method": "default",
"host": host, "host": host,
"original_path": path, "original_path": path,

View File

@@ -6,7 +6,7 @@ Detects store from host/domain/path and injects into request.state.
Handles three routing modes: Handles three routing modes:
1. Custom domains (customdomain1.com → Store 1) 1. Custom domains (customdomain1.com → Store 1)
2. Subdomains (store1.platform.com → Store 1) 2. Subdomains (store1.platform.com → Store 1)
3. Path-based (/store/store1/ or /stores/store1/ → Store 1) 3. Path-based (/store/store1/, /stores/store1/, or /storefront/store1/ → Store 1)
Also extracts clean_path for nested routing patterns. Also extracts clean_path for nested routing patterns.
@@ -89,11 +89,12 @@ class StoreContextManager:
"host": host, "host": host,
} }
# Method 3: Path-based detection (/store/storename/ or /stores/storename/) # Method 3: Path-based detection (/store/storename/, /stores/storename/, /storefront/storename/)
# Support BOTH patterns for flexibility if path.startswith(("/store/", "/stores/", "/storefront/")):
if path.startswith(("/store/", "/stores/")):
# Determine which pattern # Determine which pattern
if path.startswith("/stores/"): if path.startswith("/storefront/"):
prefix_len = len("/storefront/")
elif path.startswith("/stores/"):
prefix_len = len("/stores/") prefix_len = len("/stores/")
else: else:
prefix_len = len("/store/") prefix_len = len("/store/")
@@ -105,7 +106,7 @@ class StoreContextManager:
"subdomain": store_code, "subdomain": store_code,
"detection_method": "path", "detection_method": "path",
"path_prefix": path[: prefix_len + len(store_code)], "path_prefix": path[: prefix_len + len(store_code)],
"full_prefix": path[:prefix_len], # /store/ or /stores/ "full_prefix": path[:prefix_len], # /store/, /stores/, or /storefront/
"host": host, "host": host,
} }
@@ -269,12 +270,26 @@ class StoreContextManager:
}, },
) )
# Method 1: Path-based detection from referer path # Method 1: Path-based detection from referer path (local hosts only)
# /platforms/oms/storefront/WIZATECH/products → WIZATECH
# /stores/orion/storefront/products → orion # /stores/orion/storefront/products → orion
if referer_path.startswith(("/stores/", "/store/")): # /storefront/WIZATECH/products → WIZATECH
prefix = ( # Note: For subdomain/custom domain hosts, the store code is NOT in the path
"/stores/" if referer_path.startswith("/stores/") else "/store/" # (e.g., orion.platform.com/storefront/products — "products" is a page, not a store)
) is_local_referer = referer_host in ("localhost", "127.0.0.1", "testserver")
if is_local_referer and referer_path.startswith("/platforms/"):
# Strip /platforms/{code}/ to get clean path
after_platforms = referer_path[11:] # Remove "/platforms/"
parts = after_platforms.split("/", 1)
referer_path = "/" + parts[1] if len(parts) > 1 and parts[1] else "/"
if is_local_referer and referer_path.startswith(("/stores/", "/store/", "/storefront/")):
if referer_path.startswith("/storefront/"):
prefix = "/storefront/"
elif referer_path.startswith("/stores/"):
prefix = "/stores/"
else:
prefix = "/store/"
path_parts = referer_path[len(prefix):].split("/") path_parts = referer_path[len(prefix):].split("/")
if len(path_parts) >= 1 and path_parts[0]: if len(path_parts) >= 1 and path_parts[0]:
store_code = path_parts[0] store_code = path_parts[0]
@@ -283,15 +298,13 @@ class StoreContextManager:
f"[STORE] Extracted store from Referer path: {store_code}", f"[STORE] Extracted store from Referer path: {store_code}",
extra={"store_code": store_code, "method": "referer_path"}, extra={"store_code": store_code, "method": "referer_path"},
) )
# Use "path" as detection_method to be consistent with direct path detection
# This allows cookie path logic to work the same way
return { return {
"subdomain": store_code, "subdomain": store_code,
"detection_method": "path", # Consistent with direct path detection "detection_method": "path",
"path_prefix": referer_path[ "path_prefix": referer_path[
: prefix_len + len(store_code) : prefix_len + len(store_code)
], # /store/store1 ],
"full_prefix": prefix, # /store/ or /stores/ "full_prefix": prefix,
"host": referer_host, "host": referer_host,
"referer": referer, "referer": referer,
} }

View File

@@ -129,23 +129,22 @@ class StorefrontAccessMiddleware(BaseHTTPMiddleware):
return await call_next(request) return await call_next(request)
def _get_subscription(self, db, store, request): def _get_subscription(self, db, store, request):
"""Resolve subscription, handling multi-platform stores correctly.""" """Resolve subscription for the detected platform. No fallback."""
from app.modules.billing.services.subscription_service import ( from app.modules.billing.services.subscription_service import (
subscription_service, subscription_service,
) )
platform = getattr(request.state, "platform", None) platform = getattr(request.state, "platform", None)
# If we have a detected platform, check subscription for THAT platform if not platform:
if platform: logger.warning(
sub = subscription_service.get_merchant_subscription( f"[STOREFRONT_ACCESS] No platform context for store '{store.subdomain}'"
)
return None
return subscription_service.get_merchant_subscription(
db, store.merchant_id, platform.id db, store.merchant_id, platform.id
) )
if sub:
return sub
# Fallback: use store's primary platform (via StorePlatform)
return subscription_service.get_subscription_for_store(db, store.id)
def _render_unavailable( def _render_unavailable(
self, request: Request, reason: str, store=None self, request: Request, reason: str, store=None

View File

@@ -418,7 +418,7 @@ def create_admin_settings(db: Session) -> int:
def create_subscription_tiers(db: Session, platform: Platform) -> int: def create_subscription_tiers(db: Session, platform: Platform) -> int:
"""Create default subscription tiers for the OMS platform.""" """Create default subscription tiers for a platform."""
tier_defs = [ tier_defs = [
{ {
@@ -458,11 +458,14 @@ def create_subscription_tiers(db: Session, platform: Platform) -> int:
tiers_created = 0 tiers_created = 0
for tdef in tier_defs: for tdef in tier_defs:
existing = db.execute( existing = db.execute(
select(SubscriptionTier).where(SubscriptionTier.code == tdef["code"]) select(SubscriptionTier).where(
SubscriptionTier.code == tdef["code"],
SubscriptionTier.platform_id == platform.id,
)
).scalar_one_or_none() ).scalar_one_or_none()
if existing: if existing:
print_warning(f"Tier already exists: {existing.name} ({existing.code})") print_warning(f"Tier already exists: {existing.name} ({existing.code}) for {platform.name}")
continue continue
tier = SubscriptionTier( tier = SubscriptionTier(
@@ -620,13 +623,10 @@ def initialize_production(db: Session, auth_manager: AuthManager):
print_step(5, "Creating admin settings...") print_step(5, "Creating admin settings...")
create_admin_settings(db) create_admin_settings(db)
# Step 6: Seed subscription tiers # Step 6: Seed subscription tiers for all platforms
print_step(6, "Seeding subscription tiers...") print_step(6, "Seeding subscription tiers...")
oms_platform = next((p for p in platforms if p.code == "oms"), None) for platform in platforms:
if oms_platform: create_subscription_tiers(db, platform)
create_subscription_tiers(db, oms_platform)
else:
print_warning("OMS platform not found, skipping tier seeding")
# Step 7: Create platform module records # Step 7: Create platform module records
print_step(7, "Creating platform module records...") print_step(7, "Creating platform module records...")

View File

@@ -68,12 +68,14 @@ from app.modules.orders.models import Order, OrderItem
# cross-module string relationships (e.g. Store→StoreEmailTemplate, # cross-module string relationships (e.g. Store→StoreEmailTemplate,
# Platform→SubscriptionTier, Product→Inventory). # Platform→SubscriptionTier, Product→Inventory).
# Core modules # Core modules
from app.modules.billing.models.merchant_subscription import MerchantSubscription
from app.modules.tenancy.models import ( from app.modules.tenancy.models import (
Merchant, Merchant,
PlatformAlert, PlatformAlert,
Role, Role,
Store, Store,
StoreDomain, StoreDomain,
StorePlatform,
StoreUser, StoreUser,
User, User,
) )
@@ -207,6 +209,17 @@ DEMO_STORES = [
}, },
] ]
# Demo subscriptions (linked to merchants by index)
DEMO_SUBSCRIPTIONS = [
# WizaCorp: OMS (professional, active) + Loyalty (essential, trial)
{"merchant_index": 0, "platform_code": "oms", "tier_code": "professional", "trial_days": 0},
{"merchant_index": 0, "platform_code": "loyalty", "tier_code": "essential", "trial_days": 14},
# Fashion Group: Loyalty only (essential, trial)
{"merchant_index": 1, "platform_code": "loyalty", "tier_code": "essential", "trial_days": 14},
# BookWorld: OMS (business, active)
{"merchant_index": 2, "platform_code": "oms", "tier_code": "business", "trial_days": 0},
]
# Demo team members (linked to merchants by index, assigned to stores by store_code) # Demo team members (linked to merchants by index, assigned to stores by store_code)
DEMO_TEAM_MEMBERS = [ DEMO_TEAM_MEMBERS = [
# WizaCorp team # WizaCorp team
@@ -656,6 +669,76 @@ def create_demo_merchants(db: Session, auth_manager: AuthManager) -> list[Mercha
return merchants return merchants
def create_demo_subscriptions(db: Session, merchants: list[Merchant]) -> None:
"""Create demo merchant subscriptions."""
from app.modules.billing.models.subscription import SubscriptionTier
from app.modules.tenancy.models import Platform
sub_count = 1 if SEED_MODE == "minimal" else len(DEMO_SUBSCRIPTIONS)
subs_to_create = DEMO_SUBSCRIPTIONS[:sub_count]
for sub_data in subs_to_create:
merchant_index = sub_data["merchant_index"]
if merchant_index >= len(merchants):
print_error(f"Invalid merchant_index {merchant_index} for subscription")
continue
merchant = merchants[merchant_index]
# Look up platform by code
platform = db.execute(
select(Platform).where(Platform.code == sub_data["platform_code"])
).scalar_one_or_none()
if not platform:
print_warning(f"Platform '{sub_data['platform_code']}' not found, skipping")
continue
# Check if subscription already exists
existing = db.execute(
select(MerchantSubscription).where(
MerchantSubscription.merchant_id == merchant.id,
MerchantSubscription.platform_id == platform.id,
)
).scalar_one_or_none()
if existing:
print_warning(
f"Subscription already exists: {merchant.name} on {platform.name}"
)
continue
# Look up tier by code + platform
tier = db.execute(
select(SubscriptionTier).where(
SubscriptionTier.code == sub_data["tier_code"],
SubscriptionTier.platform_id == platform.id,
)
).scalar_one_or_none()
if not tier:
print_warning(
f"Tier '{sub_data['tier_code']}' not found for {platform.name}, skipping"
)
continue
from app.modules.billing.services.subscription_service import subscription_service
subscription = subscription_service.create_merchant_subscription(
db,
merchant_id=merchant.id,
platform_id=platform.id,
tier_code=sub_data["tier_code"],
trial_days=sub_data.get("trial_days", 14),
)
print_success(
f"Created subscription: {merchant.name}{platform.name} "
f"({tier.name}, {subscription.status})"
)
db.flush()
def create_demo_stores( def create_demo_stores(
db: Session, merchants: list[Merchant], auth_manager: AuthManager db: Session, merchants: list[Merchant], auth_manager: AuthManager
) -> list[Store]: ) -> list[Store]:
@@ -703,6 +786,29 @@ def create_demo_stores(
db.add(store) # noqa: PERF006 db.add(store) # noqa: PERF006
db.flush() db.flush()
# Link store to merchant's subscribed platforms
merchant_subs = db.execute(
select(MerchantSubscription.platform_id).where(
MerchantSubscription.merchant_id == merchant.id
)
).all()
for i, (platform_id,) in enumerate(merchant_subs):
sp = StorePlatform(
store_id=store.id,
platform_id=platform_id,
is_active=True,
is_primary=(i == 0),
)
db.add(sp)
if merchant_subs:
db.flush()
print_success(
f" Linked to {len(merchant_subs)} platform(s): "
f"{[pid for (pid,) in merchant_subs]}"
)
# Owner relationship is via Merchant.owner_user_id — no StoreUser needed # Owner relationship is via Merchant.owner_user_id — no StoreUser needed
# Create store theme # Create store theme
@@ -1060,28 +1166,32 @@ def seed_demo_data(db: Session, auth_manager: AuthManager):
print_step(4, "Creating demo merchants...") print_step(4, "Creating demo merchants...")
merchants = create_demo_merchants(db, auth_manager) merchants = create_demo_merchants(db, auth_manager)
# Step 5: Create stores # Step 5: Create merchant subscriptions (before stores, so StorePlatform linking works)
print_step(5, "Creating demo stores...") print_step(5, "Creating demo subscriptions...")
create_demo_subscriptions(db, merchants)
# Step 6: Create stores
print_step(6, "Creating demo stores...")
stores = create_demo_stores(db, merchants, auth_manager) stores = create_demo_stores(db, merchants, auth_manager)
# Step 6: Create team members # Step 7: Create team members
print_step(6, "Creating demo team members...") print_step(7, "Creating demo team members...")
create_demo_team_members(db, stores, auth_manager) create_demo_team_members(db, stores, auth_manager)
# Step 7: Create customers # Step 8: Create customers
print_step(7, "Creating demo customers...") print_step(8, "Creating demo customers...")
for store in stores: for store in stores:
create_demo_customers( create_demo_customers(
db, store, auth_manager, count=settings.seed_customers_per_store db, store, auth_manager, count=settings.seed_customers_per_store
) )
# Step 8: Create products # Step 9: Create products
print_step(8, "Creating demo products...") print_step(9, "Creating demo products...")
for store in stores: for store in stores:
create_demo_products(db, store, count=settings.seed_products_per_store) create_demo_products(db, store, count=settings.seed_products_per_store)
# Step 9: Create store content pages # Step 10: Create store content pages
print_step(9, "Creating store content page overrides...") print_step(10, "Creating store content page overrides...")
create_demo_store_content_pages(db, stores) create_demo_store_content_pages(db, stores)
# Commit all changes # Commit all changes
@@ -1199,26 +1309,46 @@ def print_summary(db: Session):
print(" (Replace {subdomain} with store subdomain, e.g., wizatech)") print(" (Replace {subdomain} with store subdomain, e.g., wizatech)")
print() print()
print("\n🏪 Shop Access (Development):") # Build store → platform code mapping from store_platforms
from app.modules.tenancy.models import Platform
store_platform_rows = db.execute(
select(StorePlatform.store_id, Platform.code).join(
Platform, Platform.id == StorePlatform.platform_id
).where(StorePlatform.is_active == True).order_by( # noqa: E712
StorePlatform.store_id, StorePlatform.is_primary.desc()
)
).all()
store_platform_map: dict[int, list[str]] = {}
for store_id, platform_code in store_platform_rows:
store_platform_map.setdefault(store_id, []).append(platform_code)
port = settings.api_port
base = f"http://localhost:{port}"
print("\n🏪 Store Access (Development):")
print("" * 70) print("" * 70)
for store in stores: for store in stores:
print(f" {store.name}:") platform_codes = store_platform_map.get(store.id, [])
print( print(f" {store.name} ({store.store_code}):")
f" Path-based: http://localhost:8000/stores/{store.store_code}/shop/" if platform_codes:
) for pc in platform_codes:
print(f" Subdomain: http://{store.subdomain}.localhost:8000/") # noqa: SEC034 print(f" [{pc}] Storefront: {base}/platforms/{pc}/storefront/{store.store_code}/")
print(f" [{pc}] Dashboard: {base}/platforms/{pc}/store/{store.store_code}/")
print(f" [{pc}] Login: {base}/platforms/{pc}/store/{store.store_code}/login")
else:
print(" (!) No platform assigned")
print() print()
print("⚠️ ALL DEMO CREDENTIALS ARE INSECURE - For development only!") print("⚠️ ALL DEMO CREDENTIALS ARE INSECURE - For development only!")
port = settings.api_port
print("\n🚀 NEXT STEPS:") print("\n🚀 NEXT STEPS:")
print(" 1. Start development: make dev") print(" 1. Start development: make dev")
print(f" 2. Admin panel: http://localhost:{port}/admin/login") print(f" 2. Admin panel: {base}/admin/login")
print(f" 3. Merchant panel: http://localhost:{port}/merchants/login") print(f" 3. Merchant panel: {base}/merchants/login")
print(f" 4. Store panel: http://localhost:{port}/store/WIZATECH/login") print(f" 4. Store panel: {base}/platforms/oms/store/WIZATECH/login")
print(f" 5. Storefront: http://localhost:{port}/stores/WIZATECH/shop/") print(f" 5. Storefront: {base}/platforms/oms/storefront/WIZATECH/")
print(f" 6. Customer login: http://localhost:{port}/stores/WIZATECH/shop/account") print(f" 6. Customer login: {base}/platforms/oms/storefront/WIZATECH/account")
# ============================================================================= # =============================================================================

View File

@@ -138,13 +138,15 @@ def collect_dev_urls(platforms, stores, store_domains, store_platform_map):
url = _store_dev_login_url(v.store_code) url = _store_dev_login_url(v.store_code)
urls.append((f"Store Login: {v.name}", url, [200])) urls.append((f"Store Login: {v.name}", url, [200]))
# Storefronts # Storefronts (platform-aware)
for v in stores: for v in stores:
if not v.is_active: if not v.is_active:
continue continue
platform_codes = store_platform_map.get(v.id, [])
for pc in platform_codes:
urls.append(( urls.append((
f"Storefront: {v.name}", f"Storefront [{pc}]: {v.name}",
f"{DEV_BASE}/stores/{v.store_code}/storefront/", f"{DEV_BASE}/platforms/{pc}/storefront/{v.store_code}/",
[200, 302], [200, 302],
)) ))
@@ -225,7 +227,9 @@ def print_dev_urls(platforms, stores, store_domains, store_platform_map):
tag = f" [{status_badge(v.is_active)}]" if not v.is_active else "" tag = f" [{status_badge(v.is_active)}]" if not v.is_active else ""
code = v.store_code code = v.store_code
print(f" {v.name} ({code}){tag}") print(f" {v.name} ({code}){tag}")
print(f" Shop: {DEV_BASE}/stores/{code}/storefront/") pcs = store_platform_map.get(v.id, [])
for pc in pcs:
print(f" Shop [{pc}]: {DEV_BASE}/platforms/{pc}/storefront/{code}/")
print(f" API: {DEV_BASE}/api/v1/storefront/{code}/") print(f" API: {DEV_BASE}/api/v1/storefront/{code}/")

View File

@@ -65,10 +65,11 @@ class TestFrontendDetectorStore:
result = FrontendDetector.detect(host="omsflow.lu", path="/store/dashboard/analytics") result = FrontendDetector.detect(host="omsflow.lu", path="/store/dashboard/analytics")
assert result == FrontendType.STORE assert result == FrontendType.STORE
def test_stores_plural_not_store_dashboard(self): def test_stores_plural_is_not_storefront(self):
"""Test that /stores/ path is NOT store dashboard (it's storefront).""" """Test that /stores/ path is NOT storefront (old pattern removed)."""
result = FrontendDetector.detect(host="localhost", path="/stores/orion/storefront") result = FrontendDetector.detect(host="localhost", path="/stores/orion/storefront")
assert result == FrontendType.STOREFRONT # /stores/ is no longer a storefront signal; /storefront/ is the canonical prefix
assert result != FrontendType.STOREFRONT
@pytest.mark.unit @pytest.mark.unit
@@ -85,9 +86,9 @@ class TestFrontendDetectorStorefront:
result = FrontendDetector.detect(host="localhost", path="/api/v1/storefront/cart") result = FrontendDetector.detect(host="localhost", path="/api/v1/storefront/cart")
assert result == FrontendType.STOREFRONT assert result == FrontendType.STOREFRONT
def test_detect_storefront_from_stores_path(self): def test_detect_storefront_from_storefront_path(self):
"""Test storefront detection from /stores/ path (path-based store access).""" """Test storefront detection from /storefront/ path (path-based store access)."""
result = FrontendDetector.detect(host="localhost", path="/stores/orion/products") result = FrontendDetector.detect(host="localhost", path="/storefront/orion/products")
assert result == FrontendType.STOREFRONT assert result == FrontendType.STOREFRONT
def test_detect_storefront_from_store_subdomain(self): def test_detect_storefront_from_store_subdomain(self):

View File

@@ -24,7 +24,7 @@ from sqlalchemy.orm import Session
from app.core.frontend_detector import FrontendDetector from app.core.frontend_detector import FrontendDetector
from middleware.platform_context import ( from middleware.platform_context import (
DEFAULT_PLATFORM_CODE, MAIN_PLATFORM_CODE,
PlatformContextManager, PlatformContextManager,
PlatformContextMiddleware, PlatformContextMiddleware,
get_current_platform, get_current_platform,
@@ -174,7 +174,7 @@ class TestPlatformContextManager:
assert context is not None assert context is not None
assert context["detection_method"] == "default" assert context["detection_method"] == "default"
assert context["path_prefix"] == DEFAULT_PLATFORM_CODE assert context["path_prefix"] == MAIN_PLATFORM_CODE
assert context["clean_path"] == "/about" # No path rewrite for main site assert context["clean_path"] == "/about" # No path rewrite for main site
def test_detect_default_platform_127_0_0_1(self): def test_detect_default_platform_127_0_0_1(self):
@@ -187,7 +187,7 @@ class TestPlatformContextManager:
assert context is not None assert context is not None
assert context["detection_method"] == "default" assert context["detection_method"] == "default"
assert context["path_prefix"] == DEFAULT_PLATFORM_CODE assert context["path_prefix"] == MAIN_PLATFORM_CODE
def test_detect_default_platform_root_path(self): def test_detect_default_platform_root_path(self):
"""Test default platform detection for root path.""" """Test default platform detection for root path."""
@@ -797,8 +797,8 @@ class TestHelperFunctions:
assert "Platform not found" in exc_info.value.detail assert "Platform not found" in exc_info.value.detail
def test_default_platform_code_is_main(self): def test_default_platform_code_is_main(self):
"""Test that DEFAULT_PLATFORM_CODE is 'main'.""" """Test that MAIN_PLATFORM_CODE is 'main'."""
assert DEFAULT_PLATFORM_CODE == "main" assert MAIN_PLATFORM_CODE == "main"
@pytest.mark.unit @pytest.mark.unit

View File

@@ -430,51 +430,40 @@ class TestGetSubscription:
) )
assert result is expected_sub assert result is expected_sub
def test_falls_back_to_store_primary_platform(self): def test_no_fallback_when_merchant_subscription_none(self):
"""Test fallback to get_subscription_for_store when platform sub is None.""" """Test no fallback when get_merchant_subscription returns None."""
middleware = StorefrontAccessMiddleware(app=None) middleware = StorefrontAccessMiddleware(app=None)
store = _make_store(store_id=5, merchant_id=10) store = _make_store(store_id=5, merchant_id=10)
platform = _make_platform(platform_id=2) platform = _make_platform(platform_id=2)
request = _make_request(store=store, platform=platform) request = _make_request(store=store, platform=platform)
mock_db = MagicMock() mock_db = MagicMock()
fallback_sub = _make_subscription(tier_code="starter")
with patch( with patch(
"app.modules.billing.services.subscription_service.subscription_service" "app.modules.billing.services.subscription_service.subscription_service"
) as mock_svc: ) as mock_svc:
mock_svc.get_merchant_subscription.return_value = None mock_svc.get_merchant_subscription.return_value = None
mock_svc.get_subscription_for_store.return_value = fallback_sub
result = middleware._get_subscription(mock_db, store, request) result = middleware._get_subscription(mock_db, store, request)
mock_svc.get_merchant_subscription.assert_called_once_with( mock_svc.get_merchant_subscription.assert_called_once_with(
mock_db, 10, 2 mock_db, 10, 2
) )
mock_svc.get_subscription_for_store.assert_called_once_with( assert result is None
mock_db, 5
)
assert result is fallback_sub
def test_no_platform_uses_store_fallback(self): def test_no_platform_returns_none(self):
"""Test when no platform is detected, falls back to store-based lookup.""" """Test when no platform is detected, returns None (no fallback)."""
middleware = StorefrontAccessMiddleware(app=None) middleware = StorefrontAccessMiddleware(app=None)
store = _make_store(store_id=7) store = _make_store(store_id=7)
request = _make_request(store=store, platform=None) request = _make_request(store=store, platform=None)
mock_db = MagicMock() mock_db = MagicMock()
fallback_sub = _make_subscription()
with patch( with patch(
"app.modules.billing.services.subscription_service.subscription_service" "app.modules.billing.services.subscription_service.subscription_service"
) as mock_svc: ) as mock_svc:
mock_svc.get_subscription_for_store.return_value = fallback_sub
result = middleware._get_subscription(mock_db, store, request) result = middleware._get_subscription(mock_db, store, request)
mock_svc.get_merchant_subscription.assert_not_called() mock_svc.get_merchant_subscription.assert_not_called()
mock_svc.get_subscription_for_store.assert_called_once_with( assert result is None
mock_db, 7
)
assert result is fallback_sub
# ============================================================================= # =============================================================================