diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a4b2e54..5ecdb9d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * **console:** fix remote node Host Console failing with "Connection error" / 502. The gateway's console-token request to the remote node was missing license tier headers, causing the remote's Admiral license gate to reject the request. Both the HTTP fetch and the WS upgrade handler now correctly propagate proxy tier headers for console_session tokens. +* **billing:** hide the Billing button in the profile dropdown for lifetime licenses. Previously it was shown but always failed with "No billing portal available" because lifetime licenses have no recurring subscription. The Manage Subscription button in Settings already had this guard; now both entry points are consistent. * **auto-update:** resolve failure when executing auto-update policies on remote Distributed API nodes. Previously, the scheduler tried to access the remote Docker daemon directly, which is not supported. Now the update execution is proxied to the remote Sencho instance via HTTP, matching the existing Distributed API architecture. * **schedules:** auto-update policies no longer appear in the Scheduled Operations list. Each view now fetches only its relevant task type via server-side action filtering. * **console:** fix remote node Console returning 502 by injecting proxy tier headers into console-token fetch and WS upgrade handler. diff --git a/backend/src/index.ts b/backend/src/index.ts index 823db1a8..3bc2edd6 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1073,12 +1073,12 @@ app.post('/api/license/validate', async (_req: Request, res: Response): Promise< app.get('/api/license/billing-portal', async (_req: Request, res: Response): Promise => { try { - const url = await LicenseService.getInstance().getBillingPortalUrl(); - if (!url) { - res.status(404).json({ error: 'No billing portal available. Ensure you have an active license.' }); + const result = await LicenseService.getInstance().getBillingPortalUrl(); + if ('error' in result) { + res.status(404).json({ error: result.error }); return; } - res.json({ url }); + res.json({ url: result.url }); } catch (error) { console.error('[License] Billing portal error:', error); res.status(500).json({ error: 'Failed to retrieve billing portal URL' }); diff --git a/backend/src/services/LicenseService.ts b/backend/src/services/LicenseService.ts index e378d46d..489d5ac5 100644 --- a/backend/src/services/LicenseService.ts +++ b/backend/src/services/LicenseService.ts @@ -506,20 +506,26 @@ export class LicenseService { * Returns a pre-signed Lemon Squeezy Customer Portal URL (valid 24hrs). * Caches the URL for 12 hours to reduce external API calls. */ - public async getBillingPortalUrl(): Promise { + public async getBillingPortalUrl(): Promise<{ url: string } | { error: string }> { const db = DatabaseService.getInstance(); const status = db.getSystemState('license_status'); const licenseKey = db.getSystemState('license_key'); if (status !== 'active' || !licenseKey) { - return null; + return { error: 'No billing portal available. Ensure you have an active license.' }; + } + + // Lifetime licenses have no recurring subscription to manage + const validUntil = db.getSystemState('license_valid_until'); + if (!validUntil) { + return { error: 'Billing portal is not available for lifetime licenses.' }; } // Check cache (12hr TTL) const cachedUrl = db.getSystemState('billing_portal_url'); const cachedExpires = db.getSystemState('billing_portal_expires'); if (cachedUrl && cachedExpires && Date.now() < parseInt(cachedExpires, 10)) { - return cachedUrl; + return { url: cachedUrl }; } try { @@ -531,7 +537,7 @@ export class LicenseService { const url = response.data?.url; if (!url) { - return null; + return { error: 'No billing portal available. Ensure you have an active license.' }; } // Cache for 12 hours @@ -539,12 +545,12 @@ export class LicenseService { db.setSystemState('billing_portal_url', url); db.setSystemState('billing_portal_expires', String(Date.now() + ttl)); - return url; + return { url }; } catch (err) { console.warn('[License] Failed to fetch billing portal URL:', (err as Error).message); // Return stale cache if available - if (cachedUrl) return cachedUrl; - return null; + if (cachedUrl) return { url: cachedUrl }; + return { error: 'Failed to retrieve billing portal URL.' }; } } diff --git a/docs/features/licensing.mdx b/docs/features/licensing.mdx index 25dd7188..751a779a 100644 --- a/docs/features/licensing.mdx +++ b/docs/features/licensing.mdx @@ -72,7 +72,7 @@ Active licenses are re-validated every **72 hours** against the Lemon Squeezy AP Once your license is active, you can manage your subscription in two ways: - **From the License page:** Go to **Settings > License** and click **Manage Subscription**. This opens the Lemon Squeezy billing portal where you can update your payment method, view invoices, cancel, or switch plans. -- **From the profile menu:** Click your profile icon in the top bar and select **Billing** for quick access to the same portal. +- **From the profile menu:** Click your profile icon in the top bar and select **Billing** for quick access to the same portal. This option is hidden for lifetime licenses. License settings showing an active Admiral subscription with Manage Subscription button diff --git a/frontend/src/components/UserProfileDropdown.tsx b/frontend/src/components/UserProfileDropdown.tsx index 773d0ac1..8a380a8b 100644 --- a/frontend/src/components/UserProfileDropdown.tsx +++ b/frontend/src/components/UserProfileDropdown.tsx @@ -77,7 +77,7 @@ export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserPro Settings - {license?.status === 'active' && ( + {license?.status === 'active' && !license?.isLifetime && (