Compare commits
3 Commits
d36783a7f1
...
05d31a7fc5
| Author | SHA1 | Date | |
|---|---|---|---|
| 05d31a7fc5 | |||
| 272b62fbd3 | |||
| 32acc76b49 |
28
.env.example
28
.env.example
@@ -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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -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",)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
92
app/modules/billing/services/store_platform_sync_service.py
Normal file
92
app/modules/billing/services/store_platform_sync_service.py
Normal 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()
|
||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 │
|
||||||
└─────────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
"
|
"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -102,6 +102,26 @@ Complete step-by-step guide for deploying Orion on a Hetzner Cloud VPS.
|
|||||||
|
|
||||||
**Steps 1–24 fully complete.** Enterprise infrastructure hardening done.
|
**Steps 1–24 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 19–24. 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
57
main.py
@@ -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)
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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...")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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}/")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user