fix(subscriptions): fix subscription UI and API after store→merchant migration

Store detail page now shows all platform subscriptions instead of always
"No Subscription Found". Subscriptions listing page renamed from Store
to Merchant throughout (template, JS, menu, i18n) with Platform column
added. Tiers API supports platform_id filtering.

Merchant detail page no longer hardcodes 'oms' platform — loads all
platforms, shows subscription cards per platform with labels, and the
Create Subscription modal includes a platform selector with
platform-filtered tiers. Create button always accessible in Quick Actions.

Edit modal on /admin/subscriptions loads tiers from API filtered by
platform instead of hardcoded options, sends tier_code (not tier) to
match PATCH schema, and shows platform context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 19:17:51 +01:00
parent 0984ff7d17
commit 0b37274140
14 changed files with 414 additions and 326 deletions

View File

@@ -171,7 +171,7 @@ billing_module = ModuleDefinition(
),
MenuItemDefinition(
id="subscriptions",
label_key="billing.menu.store_subscriptions",
label_key="billing.menu.merchant_subscriptions",
icon="credit-card",
route="/admin/subscriptions",
order=20,

View File

@@ -128,6 +128,7 @@
"billing_subscriptions": "Abrechnung & Abonnements",
"subscription_tiers": "Abo-Stufen",
"store_subscriptions": "Shop-Abonnements",
"merchant_subscriptions": "Händler-Abonnements",
"billing_history": "Abrechnungsverlauf",
"sales_orders": "Verkäufe & Bestellungen",
"invoices": "Rechnungen",

View File

@@ -128,6 +128,7 @@
"billing_subscriptions": "Billing & Subscriptions",
"subscription_tiers": "Subscription Tiers",
"store_subscriptions": "Store Subscriptions",
"merchant_subscriptions": "Merchant Subscriptions",
"billing_history": "Billing History",
"sales_orders": "Sales & Orders",
"invoices": "Invoices",

View File

@@ -128,6 +128,7 @@
"billing_subscriptions": "Facturation et Abonnements",
"subscription_tiers": "Niveaux d'abonnement",
"store_subscriptions": "Abonnements des magasins",
"merchant_subscriptions": "Abonnements des marchands",
"billing_history": "Historique de facturation",
"sales_orders": "Ventes et Commandes",
"invoices": "Factures",

View File

@@ -128,6 +128,7 @@
"billing_subscriptions": "Ofrechnung & Abonnementer",
"subscription_tiers": "Abo-Stufen",
"store_subscriptions": "Buttek-Abonnementer",
"merchant_subscriptions": "Händler-Abonnementer",
"billing_history": "Ofrechnungsverlaf",
"sales_orders": "Verkaf & Bestellungen",
"invoices": "Rechnungen",

View File

@@ -16,6 +16,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, require_module_access
from app.core.database import get_db
from app.exceptions import ResourceNotFoundException
from app.modules.billing.services import admin_subscription_service, subscription_service
from app.modules.enums import FrontendType
from app.modules.billing.schemas import (
@@ -51,11 +52,12 @@ admin_router = APIRouter(
@admin_router.get("/tiers", response_model=SubscriptionTierListResponse)
def list_subscription_tiers(
include_inactive: bool = Query(False, description="Include inactive tiers"),
platform_id: int | None = Query(None, description="Filter tiers by platform"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""List all subscription tiers."""
tiers = admin_subscription_service.get_tiers(db, include_inactive=include_inactive)
tiers = admin_subscription_service.get_tiers(db, include_inactive=include_inactive, platform_id=platform_id)
return SubscriptionTierListResponse(
tiers=[SubscriptionTierResponse.model_validate(t) for t in tiers],
@@ -133,15 +135,18 @@ def list_merchant_subscriptions(
db, page=page, per_page=per_page, status=status, tier=tier, search=search
)
from app.modules.tenancy.models import Platform
subscriptions = []
for sub, merchant in data["results"]:
sub_resp = MerchantSubscriptionAdminResponse.model_validate(sub)
tier_name = sub.tier.name if sub.tier else None
platform = db.query(Platform).filter(Platform.id == sub.platform_id).first()
subscriptions.append(
MerchantSubscriptionWithMerchant(
**sub_resp.model_dump(),
merchant_name=merchant.name,
platform_name="", # Platform name can be resolved if needed
platform_name=platform.name if platform else "",
tier_name=tier_name,
)
)
@@ -244,34 +249,29 @@ def get_subscription_for_store(
db: Session = Depends(get_db),
):
"""
Get subscription + feature usage for a store (resolves to merchant).
Get subscriptions + feature usage for a store (resolves to merchant).
Convenience endpoint for the admin store detail page. Resolves
store -> merchant -> subscription internally and returns subscription
info with feature usage metrics.
store -> merchant -> all platform subscriptions and returns a list
of subscription entries with feature usage metrics.
"""
from app.modules.billing.services.feature_service import feature_service
from app.modules.billing.schemas.subscription import FeatureSummaryResponse
from app.modules.tenancy.models import Platform
# Resolve store to merchant
merchant_id, platform_id = feature_service._get_merchant_for_store(db, store_id)
if merchant_id is None or platform_id is None:
raise HTTPException(status_code=404, detail="Store not found or has no merchant association")
# Resolve store to merchant + all platform IDs
merchant_id, platform_ids = feature_service._get_merchant_and_platforms_for_store(db, store_id)
if merchant_id is None or not platform_ids:
raise HTTPException(status_code=404, detail="Store not found or has no platform association")
# Get subscription
results = []
for pid in platform_ids:
try:
sub, merchant = admin_subscription_service.get_subscription(
db, merchant_id, platform_id
)
except Exception:
return {
"subscription": None,
"tier": None,
"features": [],
}
sub, merchant = admin_subscription_service.get_subscription(db, merchant_id, pid)
except ResourceNotFoundException:
continue
# Get feature summary
features_summary = feature_service.get_merchant_features_summary(db, merchant_id, platform_id)
features_summary = feature_service.get_merchant_features_summary(db, merchant_id, pid)
# Build tier info
tier_info = None
@@ -296,11 +296,17 @@ def get_subscription_for_store(
"is_approaching_limit": (fs.percent_used or 0) >= 80,
})
return {
# Resolve platform name
platform = db.query(Platform).filter(Platform.id == pid).first()
results.append({
"subscription": MerchantSubscriptionAdminResponse.model_validate(sub).model_dump(),
"tier": tier_info,
"features": usage_metrics,
}
"platform_name": platform.name if platform else "",
})
return {"subscriptions": results}
# ============================================================================

View File

@@ -40,14 +40,20 @@ class AdminSubscriptionService:
# =========================================================================
def get_tiers(
self, db: Session, include_inactive: bool = False
self, db: Session, include_inactive: bool = False, platform_id: int | None = None
) -> list[SubscriptionTier]:
"""Get all subscription tiers."""
"""Get all subscription tiers, optionally filtered by platform."""
query = db.query(SubscriptionTier)
if not include_inactive:
query = query.filter(SubscriptionTier.is_active == True) # noqa: E712
if platform_id is not None:
query = query.filter(
(SubscriptionTier.platform_id == platform_id)
| (SubscriptionTier.platform_id.is_(None))
)
return query.order_by(SubscriptionTier.display_order).all()
def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier:

View File

@@ -115,27 +115,48 @@ class FeatureService:
Returns:
Tuple of (merchant_id, platform_id), either may be None
"""
from app.modules.tenancy.models import Store
from app.modules.tenancy.models import Store, StorePlatform
store = db.query(Store).filter(Store.id == store_id).first()
if not store:
return None, None
merchant_id = store.merchant_id
# Get platform_id from store's platform association
platform_id = getattr(store, "platform_id", None)
if platform_id is None:
# Try StorePlatform junction
from app.modules.tenancy.models import StorePlatform
# Get primary platform_id from StorePlatform junction
sp = (
db.query(StorePlatform.platform_id)
.filter(StorePlatform.store_id == store_id)
.filter(StorePlatform.store_id == store_id, StorePlatform.is_active == True) # noqa: E712
.order_by(StorePlatform.is_primary.desc())
.first()
)
platform_id = sp[0] if sp else None
return merchant_id, platform_id
def _get_merchant_and_platforms_for_store(
self, db: Session, store_id: int
) -> tuple[int | None, list[int]]:
"""
Resolve store_id to (merchant_id, [platform_ids]).
Returns all active platform IDs for the store's merchant,
ordered with the primary platform first.
"""
from app.modules.tenancy.models import Store, StorePlatform
store = db.query(Store).filter(Store.id == store_id).first()
if not store:
return None, []
platform_ids = [
sp[0]
for sp in db.query(StorePlatform.platform_id)
.filter(StorePlatform.store_id == store_id, StorePlatform.is_active == True) # noqa: E712
.order_by(StorePlatform.is_primary.desc())
.all()
]
return store.merchant_id, platform_ids
def _get_subscription(
self, db: Session, merchant_id: int, platform_id: int
) -> MerchantSubscription | None:

View File

@@ -39,17 +39,21 @@ function adminSubscriptions() {
},
// Sorting
sortBy: 'store_name',
sortBy: 'merchant_name',
sortOrder: 'asc',
// Modal state
showModal: false,
editingSub: null,
formData: {
tier: '',
tier_code: '',
status: ''
},
// Tiers for edit modal
editTiers: [],
loadingTiers: false,
// Feature overrides
featureOverrides: [],
quantitativeFeatures: [],
@@ -208,15 +212,34 @@ function adminSubscriptions() {
async openEditModal(sub) {
this.editingSub = sub;
this.formData = {
tier: sub.tier,
tier_code: sub.tier,
status: sub.status
};
this.featureOverrides = [];
this.quantitativeFeatures = [];
this.showModal = true;
// Load feature catalog and merchant overrides
await this.loadFeatureOverrides(sub.merchant_id);
// Load tiers filtered by platform and feature overrides in parallel
await Promise.all([
this.loadEditTiers(sub.platform_id),
this.loadFeatureOverrides(sub.merchant_id),
]);
},
async loadEditTiers(platformId) {
this.loadingTiers = true;
try {
const url = platformId
? `/admin/subscriptions/tiers?platform_id=${platformId}`
: '/admin/subscriptions/tiers';
const response = await apiClient.get(url);
this.editTiers = response.tiers || [];
subsLog.info('Loaded tiers for edit modal:', this.editTiers.length);
} catch (error) {
subsLog.error('Failed to load tiers:', error);
} finally {
this.loadingTiers = false;
}
},
closeModal() {
@@ -312,7 +335,7 @@ function adminSubscriptions() {
// Save feature overrides
await this.saveFeatureOverrides(this.editingSub.merchant_id);
this.successMessage = `Subscription for "${this.editingSub.store_name || this.editingSub.merchant_name}" updated`;
this.successMessage = `Subscription for "${this.editingSub.merchant_name}" updated`;
this.closeModal();
await this.loadSubscriptions();

View File

@@ -5,12 +5,12 @@
{% from 'shared/macros/tables.html' import table_wrapper, table_header_custom, th_sortable %}
{% from 'shared/macros/pagination.html' import pagination %}
{% block title %}Store Subscriptions{% endblock %}
{% block title %}Merchant Subscriptions{% endblock %}
{% block alpine_data %}adminSubscriptions(){% endblock %}
{% block content %}
{{ page_header_refresh('Store Subscriptions') }}
{{ page_header_refresh('Merchant Subscriptions') }}
{{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }}
@@ -94,7 +94,7 @@
type="text"
x-model="filters.search"
@input.debounce.300ms="loadSubscriptions()"
placeholder="Search store name..."
placeholder="Search merchant name..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
</div>
@@ -140,7 +140,8 @@
{% call table_wrapper() %}
<table class="w-full whitespace-nowrap">
{% call table_header_custom() %}
{{ th_sortable('store_name', 'Store', 'sortBy', 'sortOrder') }}
{{ th_sortable('merchant_name', 'Merchant', 'sortBy', 'sortOrder') }}
<th class="px-4 py-3">Platform</th>
{{ th_sortable('tier', 'Tier', 'sortBy', 'sortOrder') }}
{{ th_sortable('status', 'Status', 'sortBy', 'sortOrder') }}
<th class="px-4 py-3 text-center">Features</th>
@@ -150,7 +151,7 @@
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="loading">
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<td colspan="7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('refresh', 'inline w-6 h-6 animate-spin mr-2')"></span>
Loading subscriptions...
</td>
@@ -158,7 +159,7 @@
</template>
<template x-if="!loading && subscriptions.length === 0">
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<td colspan="7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
No subscriptions found.
</td>
</tr>
@@ -168,11 +169,13 @@
<td class="px-4 py-3">
<div class="flex items-center">
<div>
<p class="font-semibold text-gray-900 dark:text-gray-100" x-text="sub.store_name"></p>
<p class="text-xs text-gray-500" x-text="sub.store_code"></p>
<p class="font-semibold text-gray-900 dark:text-gray-100" x-text="sub.merchant_name"></p>
</div>
</div>
</td>
<td class="px-4 py-3">
<span class="text-sm text-gray-600 dark:text-gray-400" x-text="sub.platform_name || '-'"></span>
</td>
<td class="px-4 py-3">
<span class="px-2 py-1 text-xs font-medium rounded-full"
:class="{
@@ -204,7 +207,7 @@
<button @click="openEditModal(sub)" class="p-2 text-gray-500 hover:text-purple-600 dark:hover:text-purple-400" title="Edit">
<span x-html="$icon('pencil', 'w-4 h-4')"></span>
</button>
<a :href="'/admin/stores/' + sub.store_code" class="p-2 text-gray-500 hover:text-blue-600 dark:hover:text-blue-400" title="View Store">
<a :href="'/admin/merchants/' + sub.merchant_id" class="p-2 text-gray-500 hover:text-blue-600 dark:hover:text-blue-400" title="View Merchant">
<span x-html="$icon('external-link', 'w-4 h-4')"></span>
</a>
</div>
@@ -224,20 +227,21 @@
<div x-show="showModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center overflow-auto bg-black bg-opacity-50">
<div class="relative w-full max-w-lg p-6 mx-4 bg-white rounded-lg shadow-xl dark:bg-gray-800" @click.away="closeModal()">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Edit Subscription</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4" x-text="'Store: ' + (editingSub?.store_name || '')"></p>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1" x-text="'Merchant: ' + (editingSub?.merchant_name || '')"></p>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4" x-show="editingSub?.platform_name" x-text="'Platform: ' + (editingSub?.platform_name || '')"></p>
<div class="space-y-4">
<!-- Tier -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tier</label>
<select
x-model="formData.tier"
x-model="formData.tier_code"
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
:disabled="loadingTiers"
>
<option value="essential">Essential</option>
<option value="professional">Professional</option>
<option value="business">Business</option>
<option value="enterprise">Enterprise</option>
<template x-for="t in editTiers" :key="t.code">
<option :value="t.code" x-text="t.name"></option>
</template>
</select>
</div>

View File

@@ -17,13 +17,12 @@ function adminMerchantDetail() {
merchantId: null,
// Subscription state
subscription: null,
subscriptionTier: null,
usageMetrics: [],
platforms: [],
subscriptions: [],
tiers: [],
platformId: null,
tiersForPlatformId: null,
showCreateSubscriptionModal: false,
createForm: { tier_code: 'essential', status: 'trial', trial_days: 14, is_annual: false },
createForm: { platform_id: null, tier_code: 'essential', status: 'trial', trial_days: 14, is_annual: false },
creatingSubscription: false,
// Initialize
@@ -49,9 +48,7 @@ function adminMerchantDetail() {
merchantDetailLog.info('Viewing merchant:', this.merchantId);
await this.loadMerchant();
await this.loadPlatforms();
if (this.platformId) {
await this.loadSubscription();
}
await this.loadSubscriptions();
} else {
merchantDetailLog.error('No merchant ID in URL');
this.error = 'Invalid merchant URL';
@@ -98,64 +95,61 @@ function adminMerchantDetail() {
}
},
// Load platforms and find OMS platform ID
// Load all available platforms
async loadPlatforms() {
try {
const response = await apiClient.get('/admin/platforms');
const platforms = response.platforms || [];
const oms = platforms.find(p => p.code === 'oms');
if (oms) {
this.platformId = oms.id;
merchantDetailLog.info('OMS platform resolved:', this.platformId);
} else {
merchantDetailLog.warn('OMS platform not found');
}
this.platforms = (response.platforms || []).map(p => ({ id: p.id, name: p.name, code: p.code }));
merchantDetailLog.info('Platforms loaded:', this.platforms.length);
} catch (error) {
merchantDetailLog.warn('Failed to load platforms:', error.message);
}
},
// Load subscription for this merchant
async loadSubscription() {
if (!this.merchantId || !this.platformId) return;
// Load subscriptions for all platforms
async loadSubscriptions() {
if (!this.merchantId || this.platforms.length === 0) return;
merchantDetailLog.info('Loading subscription for merchant:', this.merchantId);
merchantDetailLog.info('Loading subscriptions for merchant:', this.merchantId);
this.subscriptions = [];
for (const platform of this.platforms) {
try {
const url = `/admin/subscriptions/merchants/${this.merchantId}/platforms/${this.platformId}`;
window.LogConfig.logApiCall('GET', url, null, 'request');
const url = `/admin/subscriptions/merchants/${this.merchantId}/platforms/${platform.id}`;
const response = await apiClient.get(url);
window.LogConfig.logApiCall('GET', url, response, 'response');
const sub = response.subscription || response;
this.subscription = response.subscription || response;
this.subscriptionTier = response.tier || null;
this.usageMetrics = response.features || [];
merchantDetailLog.info('Subscription loaded:', {
tier: this.subscription?.tier,
status: this.subscription?.status,
features_count: this.usageMetrics.length
this.subscriptions.push({
subscription: sub,
tier: response.tier || null,
features: response.features || [],
platform_id: platform.id,
platform_name: platform.name,
});
} catch (error) {
if (error.status === 404) {
merchantDetailLog.info('No subscription found for merchant');
this.subscription = null;
this.usageMetrics = [];
} else {
merchantDetailLog.warn('Failed to load subscription:', error.message);
if (error.status !== 404) {
merchantDetailLog.warn(`Failed to load subscription for platform ${platform.name}:`, error.message);
}
}
}
merchantDetailLog.info('Subscriptions loaded:', {
count: this.subscriptions.length,
platforms: this.subscriptions.map(e => e.platform_name)
});
},
// Load available subscription tiers
async loadTiers() {
if (this.tiers.length > 0) return;
// Load available subscription tiers for a platform
async loadTiers(platformId) {
if (this.tiers.length > 0 && this.tiersForPlatformId === platformId) return;
try {
const response = await apiClient.get('/admin/subscriptions/tiers');
const url = platformId
? `/admin/subscriptions/tiers?platform_id=${platformId}`
: '/admin/subscriptions/tiers';
const response = await apiClient.get(url);
this.tiers = response.tiers || [];
this.tiersForPlatformId = platformId;
merchantDetailLog.info('Loaded tiers:', this.tiers.length);
} catch (error) {
merchantDetailLog.warn('Failed to load tiers:', error.message);
@@ -164,23 +158,32 @@ function adminMerchantDetail() {
// Open create subscription modal
async openCreateSubscriptionModal() {
await this.loadTiers();
this.createForm = { tier_code: 'essential', status: 'trial', trial_days: 14, is_annual: false };
const firstPlatformId = this.platforms.length > 0 ? this.platforms[0].id : null;
this.createForm = { platform_id: firstPlatformId, tier_code: 'essential', status: 'trial', trial_days: 14, is_annual: false };
await this.loadTiers(firstPlatformId);
this.showCreateSubscriptionModal = true;
},
// Reload tiers when platform changes in create modal
async onCreatePlatformChange() {
this.tiers = [];
this.tiersForPlatformId = null;
await this.loadTiers(this.createForm.platform_id);
},
// Create subscription for this merchant
async createSubscription() {
if (!this.merchantId || !this.platformId) return;
if (!this.merchantId || !this.createForm.platform_id) return;
this.creatingSubscription = true;
merchantDetailLog.info('Creating subscription for merchant:', this.merchantId);
const platformId = this.createForm.platform_id;
merchantDetailLog.info('Creating subscription for merchant:', this.merchantId, 'platform:', platformId);
try {
const url = `/admin/subscriptions/merchants/${this.merchantId}/platforms/${this.platformId}`;
const url = `/admin/subscriptions/merchants/${this.merchantId}/platforms/${platformId}`;
const payload = {
merchant_id: parseInt(this.merchantId),
platform_id: this.platformId,
platform_id: platformId,
tier_code: this.createForm.tier_code,
status: this.createForm.status,
trial_days: this.createForm.status === 'trial' ? parseInt(this.createForm.trial_days) : 0,
@@ -195,7 +198,7 @@ function adminMerchantDetail() {
Utils.showToast('Subscription created successfully', 'success');
merchantDetailLog.info('Subscription created');
await this.loadSubscription();
await this.loadSubscriptions();
} catch (error) {
window.LogConfig.logError(error, 'Create Subscription');
@@ -276,9 +279,7 @@ function adminMerchantDetail() {
async refresh() {
merchantDetailLog.info('=== MERCHANT REFRESH TRIGGERED ===');
await this.loadMerchant();
if (this.platformId) {
await this.loadSubscription();
}
await this.loadSubscriptions();
Utils.showToast(I18n.t('tenancy.messages.merchant_details_refreshed'), 'success');
merchantDetailLog.info('=== MERCHANT REFRESH COMPLETE ===');
}

View File

@@ -13,9 +13,7 @@ function adminStoreDetail() {
// Store detail page specific state
currentPage: 'store-detail',
store: null,
subscription: null,
subscriptionTier: null,
usageMetrics: [],
subscriptions: [],
loading: false,
error: null,
storeCode: null,
@@ -44,7 +42,7 @@ function adminStoreDetail() {
await this.loadStore();
// Load subscription after store is loaded
if (this.store?.id) {
await this.loadSubscription();
await this.loadSubscriptions();
}
} else {
detailLog.error('No store code in URL');
@@ -102,14 +100,14 @@ function adminStoreDetail() {
return formatted;
},
// Load subscription data for this store via convenience endpoint
async loadSubscription() {
// Load subscriptions data for this store via convenience endpoint
async loadSubscriptions() {
if (!this.store?.id) {
detailLog.warn('Cannot load subscription: no store ID');
detailLog.warn('Cannot load subscriptions: no store ID');
return;
}
detailLog.info('Loading subscription for store:', this.store.id);
detailLog.info('Loading subscriptions for store:', this.store.id);
try {
const url = `/admin/subscriptions/store/${this.store.id}`;
@@ -118,24 +116,20 @@ function adminStoreDetail() {
const response = await apiClient.get(url);
window.LogConfig.logApiCall('GET', url, response, 'response');
this.subscription = response.subscription;
this.subscriptionTier = response.tier;
this.usageMetrics = response.features || [];
this.subscriptions = response.subscriptions || [];
detailLog.info('Subscription loaded:', {
tier: this.subscription?.tier,
status: this.subscription?.status,
features_count: this.usageMetrics.length
detailLog.info('Subscriptions loaded:', {
count: this.subscriptions.length,
platforms: this.subscriptions.map(e => e.platform_name)
});
} catch (error) {
// 404 means no subscription exists - that's OK
if (error.status === 404) {
detailLog.info('No subscription found for store');
this.subscription = null;
this.usageMetrics = [];
detailLog.info('No subscriptions found for store');
this.subscriptions = [];
} else {
detailLog.warn('Failed to load subscription:', error.message);
detailLog.warn('Failed to load subscriptions:', error.message);
}
}
},

View File

@@ -32,6 +32,13 @@
<span x-html="$icon('edit', 'w-4 h-4 mr-2')"></span>
Edit Merchant
</a>
<button
@click="openCreateSubscriptionModal()"
x-show="platforms.length > 0"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-green-600 border border-transparent rounded-lg hover:bg-green-700 focus:outline-none focus:shadow-outline-green">
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
Create Subscription
</button>
<button
@click="deleteMerchant()"
:disabled="merchant?.store_count > 0"
@@ -194,11 +201,15 @@
</div>
</div>
<!-- Subscription Card -->
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800" x-show="subscription">
<!-- Subscription Cards -->
<template x-for="entry in subscriptions" :key="entry.subscription.id">
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
Subscription
<span x-show="entry.platform_name" class="text-sm font-normal text-gray-500 dark:text-gray-400">
<span x-text="entry.platform_name"></span>
</span>
</h3>
</div>
@@ -208,27 +219,27 @@
<span class="text-sm text-gray-600 dark:text-gray-400">Tier:</span>
<span class="px-2.5 py-0.5 text-sm font-medium rounded-full"
:class="{
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300': subscription?.tier === 'essential',
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': subscription?.tier === 'professional',
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300': subscription?.tier === 'business',
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300': subscription?.tier === 'enterprise'
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300': entry.subscription?.tier === 'essential',
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': entry.subscription?.tier === 'professional',
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300': entry.subscription?.tier === 'business',
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300': entry.subscription?.tier === 'enterprise'
}"
x-text="subscription?.tier ? subscription.tier.charAt(0).toUpperCase() + subscription.tier.slice(1) : '-'">
x-text="entry.subscription?.tier ? entry.subscription.tier.charAt(0).toUpperCase() + entry.subscription.tier.slice(1) : '-'">
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600 dark:text-gray-400">Status:</span>
<span class="px-2.5 py-0.5 text-sm font-medium rounded-full"
:class="{
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300': subscription?.status === 'active',
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': subscription?.status === 'trial',
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300': subscription?.status === 'past_due',
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300': subscription?.status === 'cancelled' || subscription?.status === 'expired'
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300': entry.subscription?.status === 'active',
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': entry.subscription?.status === 'trial',
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300': entry.subscription?.status === 'past_due',
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300': entry.subscription?.status === 'cancelled' || entry.subscription?.status === 'expired'
}"
x-text="subscription?.status ? subscription.status.replace('_', ' ').charAt(0).toUpperCase() + subscription.status.slice(1) : '-'">
x-text="entry.subscription?.status ? entry.subscription.status.replace('_', ' ').charAt(0).toUpperCase() + entry.subscription.status.slice(1) : '-'">
</span>
</div>
<template x-if="subscription?.is_annual">
<template x-if="entry.subscription?.is_annual">
<span class="px-2.5 py-0.5 text-xs font-medium text-purple-800 bg-purple-100 rounded-full dark:bg-purple-900 dark:text-purple-300">
Annual
</span>
@@ -239,21 +250,21 @@
<div class="flex flex-wrap gap-4 mb-4 text-sm">
<div>
<span class="text-gray-600 dark:text-gray-400">Period:</span>
<span class="ml-1 text-gray-700 dark:text-gray-300" x-text="formatDate(subscription?.period_start)"></span>
<span class="ml-1 text-gray-700 dark:text-gray-300" x-text="formatDate(entry.subscription?.period_start)"></span>
<span class="text-gray-400">&rarr;</span>
<span class="text-gray-700 dark:text-gray-300" x-text="formatDate(subscription?.period_end)"></span>
<span class="text-gray-700 dark:text-gray-300" x-text="formatDate(entry.subscription?.period_end)"></span>
</div>
<template x-if="subscription?.trial_ends_at">
<template x-if="entry.subscription?.trial_ends_at">
<div>
<span class="text-gray-600 dark:text-gray-400">Trial ends:</span>
<span class="ml-1 text-gray-700 dark:text-gray-300" x-text="formatDate(subscription?.trial_ends_at)"></span>
<span class="ml-1 text-gray-700 dark:text-gray-300" x-text="formatDate(entry.subscription?.trial_ends_at)"></span>
</div>
</template>
</div>
<!-- Usage Meters -->
<div class="grid gap-4 md:grid-cols-3">
<template x-for="metric in usageMetrics" :key="metric.name">
<template x-for="metric in entry.features" :key="metric.name">
<div class="p-3 bg-gray-50 rounded-lg dark:bg-gray-700">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 uppercase" x-text="metric.name"></span>
@@ -272,16 +283,17 @@
</div>
</div>
</template>
<template x-if="usageMetrics.length === 0">
<template x-if="entry.features.length === 0">
<div class="p-3 bg-gray-50 rounded-lg dark:bg-gray-700 md:col-span-3">
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">No usage data available</p>
</div>
</template>
</div>
</div>
</template>
<!-- No Subscription Notice -->
<div class="px-4 py-3 mb-6 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800" x-show="!subscription && !loading && platformId">
<div class="px-4 py-3 mb-6 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800" x-show="subscriptions.length === 0 && !loading && platforms.length > 0">
<div class="flex items-center gap-3">
<span x-html="$icon('exclamation', 'w-5 h-5 text-yellow-600 dark:text-yellow-400')"></span>
<div>
@@ -302,6 +314,18 @@
<div class="relative z-10 w-full max-w-md p-6 bg-white rounded-lg shadow-xl dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Create Subscription</h3>
<!-- Platform Selector -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Platform</label>
<select x-model="createForm.platform_id"
@change="onCreatePlatformChange()"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500">
<template x-for="p in platforms" :key="p.id">
<option :value="p.id" x-text="p.name"></option>
</template>
</select>
</div>
<!-- Tier Selector -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Subscription Tier</label>

View File

@@ -106,11 +106,15 @@
</div>
</div>
<!-- Subscription Card -->
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800" x-show="subscription">
<!-- Subscription Cards -->
<template x-for="entry in subscriptions" :key="entry.subscription.id">
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
Subscription
<span x-show="entry.platform_name" class="text-sm font-normal text-gray-500 dark:text-gray-400">
<span x-text="entry.platform_name"></span>
</span>
</h3>
<a
:href="'/admin/merchants/' + store?.merchant_id"
@@ -126,27 +130,27 @@
<span class="text-sm text-gray-600 dark:text-gray-400">Tier:</span>
<span class="px-2.5 py-0.5 text-sm font-medium rounded-full"
:class="{
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300': subscription?.tier === 'essential',
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': subscription?.tier === 'professional',
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300': subscription?.tier === 'business',
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300': subscription?.tier === 'enterprise'
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300': entry.subscription?.tier === 'essential',
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': entry.subscription?.tier === 'professional',
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300': entry.subscription?.tier === 'business',
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300': entry.subscription?.tier === 'enterprise'
}"
x-text="subscription?.tier ? subscription.tier.charAt(0).toUpperCase() + subscription.tier.slice(1) : '-'">
x-text="entry.subscription?.tier ? entry.subscription.tier.charAt(0).toUpperCase() + entry.subscription.tier.slice(1) : '-'">
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600 dark:text-gray-400">Status:</span>
<span class="px-2.5 py-0.5 text-sm font-medium rounded-full"
:class="{
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300': subscription?.status === 'active',
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': subscription?.status === 'trial',
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300': subscription?.status === 'past_due',
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300': subscription?.status === 'cancelled' || subscription?.status === 'expired'
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300': entry.subscription?.status === 'active',
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': entry.subscription?.status === 'trial',
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300': entry.subscription?.status === 'past_due',
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300': entry.subscription?.status === 'cancelled' || entry.subscription?.status === 'expired'
}"
x-text="subscription?.status ? subscription.status.replace('_', ' ').charAt(0).toUpperCase() + subscription.status.slice(1) : '-'">
x-text="entry.subscription?.status ? entry.subscription.status.replace('_', ' ').charAt(0).toUpperCase() + entry.subscription.status.slice(1) : '-'">
</span>
</div>
<template x-if="subscription?.is_annual">
<template x-if="entry.subscription?.is_annual">
<span class="px-2.5 py-0.5 text-xs font-medium text-purple-800 bg-purple-100 rounded-full dark:bg-purple-900 dark:text-purple-300">
Annual
</span>
@@ -157,21 +161,21 @@
<div class="flex flex-wrap gap-4 mb-4 text-sm">
<div>
<span class="text-gray-600 dark:text-gray-400">Period:</span>
<span class="ml-1 text-gray-700 dark:text-gray-300" x-text="formatDate(subscription?.period_start)"></span>
<span class="text-gray-400"></span>
<span class="text-gray-700 dark:text-gray-300" x-text="formatDate(subscription?.period_end)"></span>
<span class="ml-1 text-gray-700 dark:text-gray-300" x-text="formatDate(entry.subscription?.period_start)"></span>
<span class="text-gray-400">&rarr;</span>
<span class="text-gray-700 dark:text-gray-300" x-text="formatDate(entry.subscription?.period_end)"></span>
</div>
<template x-if="subscription?.trial_ends_at">
<template x-if="entry.subscription?.trial_ends_at">
<div>
<span class="text-gray-600 dark:text-gray-400">Trial ends:</span>
<span class="ml-1 text-gray-700 dark:text-gray-300" x-text="formatDate(subscription?.trial_ends_at)"></span>
<span class="ml-1 text-gray-700 dark:text-gray-300" x-text="formatDate(entry.subscription?.trial_ends_at)"></span>
</div>
</template>
</div>
<!-- Usage Meters -->
<div class="grid gap-4 md:grid-cols-3">
<template x-for="metric in usageMetrics" :key="metric.name">
<template x-for="metric in entry.features" :key="metric.name">
<div class="p-3 bg-gray-50 rounded-lg dark:bg-gray-700">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 uppercase" x-text="metric.name"></span>
@@ -190,16 +194,17 @@
</div>
</div>
</template>
<template x-if="usageMetrics.length === 0">
<template x-if="entry.features.length === 0">
<div class="p-3 bg-gray-50 rounded-lg dark:bg-gray-700 md:col-span-3">
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">No usage data available</p>
</div>
</template>
</div>
</div>
</template>
<!-- No Subscription Notice -->
<div class="px-4 py-3 mb-6 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800" x-show="!subscription && !loading">
<div class="px-4 py-3 mb-6 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800" x-show="subscriptions.length === 0 && !loading">
<div class="flex items-center gap-3">
<span x-html="$icon('exclamation', 'w-5 h-5 text-yellow-600 dark:text-yellow-400')"></span>
<div>