feat: add SQL query tool, platform debug, loyalty settings, and multi-module improvements
Some checks failed
Some checks failed
- Add admin SQL query tool with saved queries, schema explorer presets, and collapsible category sections (dev_tools module) - Add platform debug tool for admin diagnostics - Add loyalty settings page with owner-only access control - Fix loyalty settings owner check (use currentUser instead of window.__userData) - Replace HTTPException with AuthorizationException in loyalty routes - Expand loyalty module with PIN service, Apple Wallet, program management - Improve store login with platform detection and multi-platform support - Update billing feature gates and subscription services - Add store platform sync improvements and remove is_primary column - Add unit tests for loyalty (PIN, points, stamps, program services) - Update i18n translations across dev_tools locales Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -103,9 +103,12 @@ class RequireFeature:
|
||||
) -> None:
|
||||
"""Check if store's merchant has access to any of the required features."""
|
||||
store_id = current_user.token_store_id
|
||||
platform_id = current_user.token_platform_id
|
||||
|
||||
for feature_code in self.feature_codes:
|
||||
if feature_service.has_feature_for_store(db, store_id, feature_code):
|
||||
if feature_service.has_feature_for_store(
|
||||
db, store_id, feature_code, platform_id=platform_id
|
||||
):
|
||||
return
|
||||
|
||||
# None of the features are available
|
||||
@@ -136,7 +139,8 @@ class RequireWithinLimit:
|
||||
store_id = current_user.token_store_id
|
||||
|
||||
allowed, message = feature_service.check_resource_limit(
|
||||
db, self.feature_code, store_id=store_id
|
||||
db, self.feature_code, store_id=store_id,
|
||||
platform_id=current_user.token_platform_id,
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
@@ -176,9 +180,12 @@ def require_feature(*feature_codes: str) -> Callable:
|
||||
)
|
||||
|
||||
store_id = current_user.token_store_id
|
||||
platform_id = current_user.token_platform_id
|
||||
|
||||
for feature_code in feature_codes:
|
||||
if feature_service.has_feature_for_store(db, store_id, feature_code):
|
||||
if feature_service.has_feature_for_store(
|
||||
db, store_id, feature_code, platform_id=platform_id
|
||||
):
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
raise FeatureNotAvailableError(feature_code=feature_codes[0])
|
||||
@@ -195,9 +202,12 @@ def require_feature(*feature_codes: str) -> Callable:
|
||||
)
|
||||
|
||||
store_id = current_user.token_store_id
|
||||
platform_id = current_user.token_platform_id
|
||||
|
||||
for feature_code in feature_codes:
|
||||
if feature_service.has_feature_for_store(db, store_id, feature_code):
|
||||
if feature_service.has_feature_for_store(
|
||||
db, store_id, feature_code, platform_id=platform_id
|
||||
):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
raise FeatureNotAvailableError(feature_code=feature_codes[0])
|
||||
|
||||
@@ -44,7 +44,7 @@ def upgrade() -> None:
|
||||
sa.Column("platform_id", sa.Integer(), sa.ForeignKey("platforms.id", ondelete="CASCADE"), nullable=False, index=True, comment="Reference to the platform"),
|
||||
sa.Column("tier_id", sa.Integer(), sa.ForeignKey("subscription_tiers.id", ondelete="SET NULL"), nullable=True, index=True, comment="Platform-specific subscription tier"),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true", comment="Whether the store is active on this platform"),
|
||||
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="false", comment="Whether this is the store's primary platform"),
|
||||
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="false", comment="Whether this is the store's primary platform"), # Removed in migration remove_is_primary_001
|
||||
sa.Column("custom_subdomain", sa.String(100), nullable=True, comment="Platform-specific subdomain (if different from main subdomain)"),
|
||||
sa.Column("settings", sa.JSON(), nullable=True, server_default="{}", comment="Platform-specific store settings"),
|
||||
sa.Column("joined_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, comment="When the store joined this platform"),
|
||||
@@ -53,7 +53,7 @@ def upgrade() -> None:
|
||||
sa.UniqueConstraint("store_id", "platform_id", name="uq_store_platform"),
|
||||
)
|
||||
op.create_index("idx_store_platform_active", "store_platforms", ["store_id", "platform_id", "is_active"])
|
||||
op.create_index("idx_store_platform_primary", "store_platforms", ["store_id", "is_primary"])
|
||||
op.create_index("idx_store_platform_primary", "store_platforms", ["store_id", "is_primary"]) # Removed in migration remove_is_primary_001
|
||||
|
||||
# --- tier_feature_limits ---
|
||||
op.create_table(
|
||||
|
||||
@@ -46,7 +46,7 @@ def get_subscription_status(
|
||||
):
|
||||
"""Get current subscription status."""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
subscription, tier = billing_service.get_subscription_with_tier(db, merchant_id, platform_id)
|
||||
|
||||
@@ -83,7 +83,7 @@ def get_available_tiers(
|
||||
):
|
||||
"""Get available subscription tiers for upgrade/downgrade."""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
subscription = subscription_service.get_or_create_subscription(db, merchant_id, platform_id)
|
||||
current_tier_id = subscription.tier_id
|
||||
@@ -105,7 +105,7 @@ def get_invoices(
|
||||
):
|
||||
"""Get invoice history."""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
invoices, total = billing_service.get_invoices(db, merchant_id, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ def create_checkout_session(
|
||||
):
|
||||
"""Create a Stripe checkout session for subscription."""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
store_code = subscription_service.get_store_code(db, store_id)
|
||||
|
||||
@@ -84,7 +84,7 @@ def create_portal_session(
|
||||
):
|
||||
"""Create a Stripe customer portal session."""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
store_code = subscription_service.get_store_code(db, store_id)
|
||||
return_url = f"https://{settings.platform_domain}/store/{store_code}/billing"
|
||||
@@ -102,7 +102,7 @@ def cancel_subscription(
|
||||
):
|
||||
"""Cancel subscription."""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
result = billing_service.cancel_subscription(
|
||||
db=db,
|
||||
@@ -126,7 +126,7 @@ def reactivate_subscription(
|
||||
):
|
||||
"""Reactivate a cancelled subscription."""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
result = billing_service.reactivate_subscription(db, merchant_id, platform_id)
|
||||
db.commit()
|
||||
@@ -141,7 +141,7 @@ def get_upcoming_invoice(
|
||||
):
|
||||
"""Preview the upcoming invoice."""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
result = billing_service.get_upcoming_invoice(db, merchant_id, platform_id)
|
||||
|
||||
@@ -161,7 +161,7 @@ def change_tier(
|
||||
):
|
||||
"""Change subscription tier (upgrade/downgrade)."""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
result = billing_service.change_tier(
|
||||
db=db,
|
||||
|
||||
@@ -95,7 +95,7 @@ def get_available_features(
|
||||
List of feature codes the store has access to
|
||||
"""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
# Get available feature codes
|
||||
feature_codes = feature_service.get_merchant_feature_codes(db, merchant_id, platform_id)
|
||||
@@ -134,7 +134,7 @@ def get_features(
|
||||
List of features with metadata and availability
|
||||
"""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
# Get all declarations and available codes
|
||||
all_declarations = feature_aggregator.get_all_declarations()
|
||||
@@ -197,7 +197,7 @@ def get_features_grouped(
|
||||
Useful for rendering feature comparison tables or settings pages.
|
||||
"""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
# Get declarations grouped by category and available codes
|
||||
by_category = feature_aggregator.get_declarations_by_category()
|
||||
@@ -246,7 +246,9 @@ def check_feature(
|
||||
has_feature and feature_code
|
||||
"""
|
||||
store_id = current_user.token_store_id
|
||||
has = feature_service.has_feature_for_store(db, store_id, feature_code)
|
||||
has = feature_service.has_feature_for_store(
|
||||
db, store_id, feature_code, platform_id=current_user.token_platform_id
|
||||
)
|
||||
|
||||
return StoreFeatureCheckResponse(has_feature=has, feature_code=feature_code)
|
||||
|
||||
@@ -270,7 +272,7 @@ def get_feature_detail(
|
||||
Feature details with upgrade info if locked
|
||||
"""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
# Get feature declaration
|
||||
decl = feature_aggregator.get_declaration(feature_code)
|
||||
|
||||
@@ -108,10 +108,17 @@ class FeatureService:
|
||||
# Store -> Merchant Resolution
|
||||
# =========================================================================
|
||||
|
||||
def _get_merchant_for_store(self, db: Session, store_id: int) -> tuple[int | None, int | None]:
|
||||
def _get_merchant_for_store(
|
||||
self, db: Session, store_id: int, platform_id: int | None = None
|
||||
) -> tuple[int | None, int | None]:
|
||||
"""
|
||||
Resolve store_id to (merchant_id, platform_id).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
store_id: Store ID
|
||||
platform_id: Platform ID from JWT. When provided, skips DB lookup.
|
||||
|
||||
Returns:
|
||||
Tuple of (merchant_id, platform_id), either may be None
|
||||
"""
|
||||
@@ -123,7 +130,8 @@ class FeatureService:
|
||||
return None, None
|
||||
|
||||
merchant_id = store.merchant_id
|
||||
platform_id = platform_service.get_primary_platform_id_for_store(db, store_id)
|
||||
if platform_id is None:
|
||||
platform_id = platform_service.get_first_active_platform_id_for_store(db, store_id)
|
||||
|
||||
return merchant_id, platform_id
|
||||
|
||||
@@ -204,28 +212,29 @@ class FeatureService:
|
||||
return subscription.tier.has_feature(feature_code)
|
||||
|
||||
def has_feature_for_store(
|
||||
self, db: Session, store_id: int, feature_code: str
|
||||
self, db: Session, store_id: int, feature_code: str,
|
||||
platform_id: int | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Convenience method that resolves the store -> merchant -> platform
|
||||
hierarchy and checks whether the merchant has access to a feature.
|
||||
|
||||
Looks up the store's merchant_id and platform_id, then delegates
|
||||
to has_feature().
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
store_id: The store ID to resolve.
|
||||
feature_code: The feature code to check.
|
||||
platform_id: Platform ID from JWT. When provided, skips DB lookup.
|
||||
|
||||
Returns:
|
||||
True if the resolved merchant has access to the feature,
|
||||
False if the store/merchant cannot be resolved or lacks access.
|
||||
"""
|
||||
merchant_id, platform_id = self._get_merchant_for_store(db, store_id)
|
||||
if merchant_id is None or platform_id is None:
|
||||
merchant_id, resolved_platform_id = self._get_merchant_for_store(
|
||||
db, store_id, platform_id=platform_id
|
||||
)
|
||||
if merchant_id is None or resolved_platform_id is None:
|
||||
return False
|
||||
return self.has_feature(db, merchant_id, platform_id, feature_code)
|
||||
return self.has_feature(db, merchant_id, resolved_platform_id, feature_code)
|
||||
|
||||
def get_merchant_feature_codes(
|
||||
self, db: Session, merchant_id: int, platform_id: int
|
||||
@@ -317,7 +326,7 @@ class FeatureService:
|
||||
feature_code: Feature code (e.g., "products_limit")
|
||||
store_id: Store ID (if checking per-store)
|
||||
merchant_id: Merchant ID (if already known)
|
||||
platform_id: Platform ID (if already known)
|
||||
platform_id: Platform ID (if already known, e.g. from JWT)
|
||||
|
||||
Returns:
|
||||
(allowed, error_message) tuple
|
||||
@@ -326,7 +335,9 @@ class FeatureService:
|
||||
|
||||
# Resolve store -> merchant if needed
|
||||
if merchant_id is None and store_id is not None:
|
||||
merchant_id, platform_id = self._get_merchant_for_store(db, store_id)
|
||||
merchant_id, platform_id = self._get_merchant_for_store(
|
||||
db, store_id, platform_id=platform_id
|
||||
)
|
||||
|
||||
if merchant_id is None or platform_id is None:
|
||||
return False, "No subscription found"
|
||||
|
||||
@@ -32,7 +32,7 @@ class StorePlatformSync:
|
||||
Upsert StorePlatform for every store belonging to a merchant.
|
||||
|
||||
- Existing entry → update is_active (and tier_id if provided)
|
||||
- Missing + is_active=True → create (set is_primary if store has none)
|
||||
- Missing + is_active=True → create
|
||||
- Missing + is_active=False → no-op
|
||||
"""
|
||||
stores = store_service.get_stores_by_merchant_id(db, merchant_id)
|
||||
|
||||
@@ -311,6 +311,7 @@ class StripeService:
|
||||
trial_days: int | None = None,
|
||||
quantity: int = 1,
|
||||
metadata: dict | None = None,
|
||||
platform_id: int | None = None,
|
||||
) -> stripe.checkout.Session:
|
||||
"""
|
||||
Create a Stripe Checkout session for subscription signup.
|
||||
@@ -324,6 +325,7 @@ class StripeService:
|
||||
trial_days: Optional trial period
|
||||
quantity: Number of items (default 1)
|
||||
metadata: Additional metadata to store
|
||||
platform_id: Platform ID (from JWT or caller). Falls back to DB lookup.
|
||||
|
||||
Returns:
|
||||
Stripe Checkout Session object
|
||||
@@ -334,7 +336,8 @@ class StripeService:
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
from app.modules.tenancy.services.team_service import team_service
|
||||
|
||||
platform_id = platform_service.get_primary_platform_id_for_store(db, store.id)
|
||||
if platform_id is None:
|
||||
platform_id = platform_service.get_first_active_platform_id_for_store(db, store.id)
|
||||
subscription = None
|
||||
if store.merchant_id and platform_id:
|
||||
subscription = (
|
||||
|
||||
@@ -47,9 +47,16 @@ class SubscriptionService:
|
||||
# Store Resolution
|
||||
# =========================================================================
|
||||
|
||||
def resolve_store_to_merchant(self, db: Session, store_id: int) -> tuple[int, int]:
|
||||
def resolve_store_to_merchant(
|
||||
self, db: Session, store_id: int, platform_id: int | None = None
|
||||
) -> tuple[int, int]:
|
||||
"""Resolve store_id to (merchant_id, platform_id).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
store_id: Store ID
|
||||
platform_id: Platform ID from JWT token. When provided, skips DB lookup.
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If store not found or has no platform
|
||||
"""
|
||||
@@ -59,7 +66,8 @@ class SubscriptionService:
|
||||
store = store_service.get_store_by_id_optional(db, store_id)
|
||||
if not store or not store.merchant_id:
|
||||
raise ResourceNotFoundException("Store", str(store_id))
|
||||
platform_id = platform_service.get_primary_platform_id_for_store(db, store_id)
|
||||
if platform_id is None:
|
||||
platform_id = platform_service.get_first_active_platform_id_for_store(db, store_id)
|
||||
if not platform_id:
|
||||
raise ResourceNotFoundException("StorePlatform", f"store_id={store_id}")
|
||||
return store.merchant_id, platform_id
|
||||
@@ -185,7 +193,7 @@ class SubscriptionService:
|
||||
if merchant_id is None:
|
||||
return None
|
||||
|
||||
platform_id = platform_service.get_primary_platform_id_for_store(db, store_id)
|
||||
platform_id = platform_service.get_first_active_platform_id_for_store(db, store_id)
|
||||
if platform_id is None:
|
||||
return None
|
||||
|
||||
|
||||
@@ -39,52 +39,6 @@ class TestStorePlatformSyncCreate:
|
||||
assert sp is not None
|
||||
assert sp.is_active is True
|
||||
|
||||
def test_sync_sets_primary_when_none(self, db, test_store, test_platform):
|
||||
"""First platform synced for a store gets is_primary=True."""
|
||||
self.service.sync_store_platforms_for_merchant(
|
||||
db, test_store.merchant_id, test_platform.id, is_active=True
|
||||
)
|
||||
|
||||
sp = (
|
||||
db.query(StorePlatform)
|
||||
.filter(
|
||||
StorePlatform.store_id == test_store.id,
|
||||
StorePlatform.platform_id == test_platform.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
assert sp.is_primary is True
|
||||
|
||||
def test_sync_no_primary_override(self, db, test_store, test_platform, another_platform):
|
||||
"""Second platform synced does not override existing primary."""
|
||||
# First platform becomes primary
|
||||
self.service.sync_store_platforms_for_merchant(
|
||||
db, test_store.merchant_id, test_platform.id, is_active=True
|
||||
)
|
||||
# Second platform should not be primary
|
||||
self.service.sync_store_platforms_for_merchant(
|
||||
db, test_store.merchant_id, another_platform.id, is_active=True
|
||||
)
|
||||
|
||||
sp1 = (
|
||||
db.query(StorePlatform)
|
||||
.filter(
|
||||
StorePlatform.store_id == test_store.id,
|
||||
StorePlatform.platform_id == test_platform.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
sp2 = (
|
||||
db.query(StorePlatform)
|
||||
.filter(
|
||||
StorePlatform.store_id == test_store.id,
|
||||
StorePlatform.platform_id == another_platform.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
assert sp1.is_primary is True
|
||||
assert sp2.is_primary is False
|
||||
|
||||
def test_sync_sets_tier_id(self, db, test_store, test_platform, sync_tier):
|
||||
"""Sync passes tier_id to newly created StorePlatform."""
|
||||
self.service.sync_store_platforms_for_merchant(
|
||||
@@ -118,7 +72,6 @@ class TestStorePlatformSyncUpdate:
|
||||
store_id=test_store.id,
|
||||
platform_id=test_platform.id,
|
||||
is_active=True,
|
||||
is_primary=True,
|
||||
)
|
||||
db.add(sp)
|
||||
db.flush()
|
||||
@@ -137,7 +90,6 @@ class TestStorePlatformSyncUpdate:
|
||||
store_id=test_store.id,
|
||||
platform_id=test_platform.id,
|
||||
is_active=True,
|
||||
is_primary=True,
|
||||
)
|
||||
db.add(sp)
|
||||
db.flush()
|
||||
|
||||
Reference in New Issue
Block a user