fix(billing): hide billing portal for lifetime licenses (#427)

Lifetime licenses have no recurring subscription, so the Lemon Squeezy
customer portal cannot generate a URL. The Manage Subscription button in
Settings already had the isLifetime guard, but the Billing button in the
profile dropdown did not, causing a confusing "No billing portal
available" error.

- Add !license.isLifetime guard to UserProfileDropdown (matches
  LicenseSection pattern)
- Move lifetime detection into getBillingPortalUrl() so the service owns
  all billing eligibility logic
- Change return type to { url } | { error } discriminated union for
  clear error propagation
This commit is contained in:
Anso
2026-04-08 09:51:27 -04:00
committed by GitHub
parent f6d2199978
commit be7eda85f1
5 changed files with 20 additions and 13 deletions
+4 -4
View File
@@ -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<void> => {
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' });
+13 -7
View File
@@ -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<string | null> {
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.' };
}
}