feat: add SQL query tool, platform debug, loyalty settings, and multi-module improvements
Some checks failed
CI / ruff (push) Successful in 14s
CI / pytest (push) Failing after 50m12s
CI / validate (push) Successful in 25s
CI / dependency-scanning (push) Successful in 32s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

- 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:
2026-03-10 20:08:07 +01:00
parent a77a8a3a98
commit 319900623a
77 changed files with 5341 additions and 401 deletions

View File

@@ -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])

View File

@@ -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(

View File

@@ -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)

View File

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

View File

@@ -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)

View File

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

View File

@@ -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)

View File

@@ -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 = (

View File

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

View File

@@ -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()