diff --git a/CHANGELOG.md b/CHANGELOG.md index a8c5ed93..f2f39c46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * **licensing:** trial users can now see Skipper/Admiral purchase cards in Settings > License (previously hidden due to a condition bug) +* **licensing:** Admiral licenses (including lifetime) are now correctly identified as the "team" variant; previously, Lemon Squeezy variant names containing "Admiral" were not matched, causing the tier to display as "Skipper" and Admiral features to remain locked +* **licensing:** "Manage Subscription" button is now hidden for lifetime licenses, which have no billing portal by design +* **licensing:** license card now shows "Duration: Lifetime" for lifetime licenses instead of an empty renewal date ### Changed @@ -17,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **licensing:** `ProGate` component renamed to `PaidGate`, `isPro` renamed to `isPaid` throughout the codebase * **licensing:** backend error code `PRO_REQUIRED` renamed to `PAID_REQUIRED`; `requirePro` guard renamed to `requirePaid` * **docs:** all documentation updated to replace "Sencho Pro" references with proper tier names +* **licensing:** `LicenseInfo` API response now includes `isLifetime` boolean field ## [0.38.0](https://github.com/AnsoCode/Sencho/compare/v0.37.0...v0.38.0) (2026-04-04) diff --git a/backend/src/__tests__/license-service.test.ts b/backend/src/__tests__/license-service.test.ts new file mode 100644 index 00000000..60ce42ae --- /dev/null +++ b/backend/src/__tests__/license-service.test.ts @@ -0,0 +1,234 @@ +/** + * Tests for LicenseService: variant resolution, tier computation, lifetime detection, + * and getLicenseInfo() output across all license states. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; + +let tmpDir: string; +let svc: import('../services/LicenseService').LicenseService; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + const licMod = await import('../services/LicenseService'); + svc = licMod.LicenseService.getInstance(); + ({ DatabaseService } = await import('../services/DatabaseService')); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +function setLicenseState(overrides: Record) { + const db = DatabaseService.getInstance(); + const keys = [ + 'license_status', 'license_key', 'license_valid_until', + 'license_last_validated', 'license_customer_name', + 'license_product_name', 'license_variant_name', + 'billing_portal_url', 'billing_portal_expires', + ]; + for (const key of keys) { + db.setSystemState(key, ''); + } + for (const [key, value] of Object.entries(overrides)) { + db.setSystemState(key, value); + } +} + +describe('LicenseService.getVariant()', () => { + it('returns "personal" for trial licenses', () => { + setLicenseState({ license_status: 'trial' }); + expect(svc.getVariant()).toBe('personal'); + }); + + it('returns null when no variant name is stored', () => { + setLicenseState({ license_status: 'active' }); + expect(svc.getVariant()).toBeNull(); + }); + + it('maps "Team" variant name to "team"', () => { + setLicenseState({ license_status: 'active', license_variant_name: 'Team' }); + expect(svc.getVariant()).toBe('team'); + }); + + it('maps "Personal" variant name to "personal"', () => { + setLicenseState({ license_status: 'active', license_variant_name: 'Personal' }); + expect(svc.getVariant()).toBe('personal'); + }); + + it('maps "Admiral" variant name to "team"', () => { + setLicenseState({ license_status: 'active', license_variant_name: 'Admiral' }); + expect(svc.getVariant()).toBe('team'); + }); + + it('maps "Admiral Lifetime" variant name to "team"', () => { + setLicenseState({ license_status: 'active', license_variant_name: 'Admiral Lifetime' }); + expect(svc.getVariant()).toBe('team'); + }); + + it('maps "Skipper" variant name to "personal"', () => { + setLicenseState({ license_status: 'active', license_variant_name: 'Skipper' }); + expect(svc.getVariant()).toBe('personal'); + }); + + it('maps "Skipper Lifetime" variant name to "personal"', () => { + setLicenseState({ license_status: 'active', license_variant_name: 'Skipper Lifetime' }); + expect(svc.getVariant()).toBe('personal'); + }); + + it('defaults unknown variant names to "personal"', () => { + setLicenseState({ license_status: 'active', license_variant_name: 'Unknown Variant' }); + expect(svc.getVariant()).toBe('personal'); + }); +}); + +describe('LicenseService.getTier()', () => { + it('returns "community" when no status is set', () => { + setLicenseState({}); + // initialize() sets trial on first boot; override to test the empty-status path + DatabaseService.getInstance().setSystemState('license_status', ''); + expect(svc.getTier()).toBe('community'); + }); + + it('returns "community" for community status', () => { + setLicenseState({ license_status: 'community' }); + expect(svc.getTier()).toBe('community'); + }); + + it('returns "community" for expired status', () => { + setLicenseState({ license_status: 'expired' }); + expect(svc.getTier()).toBe('community'); + }); + + it('returns "community" for disabled status', () => { + setLicenseState({ license_status: 'disabled' }); + expect(svc.getTier()).toBe('community'); + }); + + it('returns "paid" for active status with valid license', () => { + setLicenseState({ + license_status: 'active', + license_last_validated: Date.now().toString(), + }); + expect(svc.getTier()).toBe('paid'); + }); + + it('returns "paid" for active trial', () => { + const future = new Date(); + future.setDate(future.getDate() + 7); + setLicenseState({ + license_status: 'trial', + license_valid_until: future.toISOString(), + }); + expect(svc.getTier()).toBe('paid'); + }); + + it('returns "community" for expired trial', () => { + const past = new Date(); + past.setDate(past.getDate() - 1); + setLicenseState({ + license_status: 'trial', + license_valid_until: past.toISOString(), + }); + expect(svc.getTier()).toBe('community'); + }); + + it('returns "paid" for lifetime license (no expiry)', () => { + setLicenseState({ + license_status: 'active', + license_key: 'test-key-1234', + license_last_validated: Date.now().toString(), + }); + expect(svc.getTier()).toBe('paid'); + }); +}); + +describe('LicenseService.getLicenseInfo() - isLifetime', () => { + it('sets isLifetime=true for active license with key and no expiry', () => { + setLicenseState({ + license_status: 'active', + license_key: 'test-key-1234', + license_last_validated: Date.now().toString(), + }); + const info = svc.getLicenseInfo(); + expect(info.isLifetime).toBe(true); + expect(info.trialDaysRemaining).toBeNull(); + }); + + it('sets isLifetime=false for active subscription with expiry', () => { + const future = new Date(); + future.setDate(future.getDate() + 30); + setLicenseState({ + license_status: 'active', + license_key: 'test-key-1234', + license_valid_until: future.toISOString(), + license_last_validated: Date.now().toString(), + }); + const info = svc.getLicenseInfo(); + expect(info.isLifetime).toBe(false); + expect(info.trialDaysRemaining).toBeNull(); + }); + + it('sets isLifetime=false for trial licenses', () => { + const future = new Date(); + future.setDate(future.getDate() + 14); + setLicenseState({ + license_status: 'trial', + license_valid_until: future.toISOString(), + }); + const info = svc.getLicenseInfo(); + expect(info.isLifetime).toBe(false); + expect(info.trialDaysRemaining).toBeGreaterThan(0); + }); + + it('sets isLifetime=false for community status', () => { + setLicenseState({ license_status: 'community' }); + const info = svc.getLicenseInfo(); + expect(info.isLifetime).toBe(false); + expect(info.trialDaysRemaining).toBeNull(); + }); +}); + +describe('LicenseService.getLicenseInfo() - full scenarios', () => { + it('returns correct info for an Admiral lifetime license', () => { + setLicenseState({ + license_status: 'active', + license_key: 'ABCD-EFGH-IJKL-MN5D', + license_variant_name: 'Admiral Lifetime', + license_customer_name: 'Test User', + license_product_name: 'Sencho Admiral', + license_last_validated: Date.now().toString(), + }); + const info = svc.getLicenseInfo(); + expect(info.tier).toBe('paid'); + expect(info.status).toBe('active'); + expect(info.variant).toBe('team'); + expect(info.isLifetime).toBe(true); + expect(info.trialDaysRemaining).toBeNull(); + expect(info.customerName).toBe('Test User'); + expect(info.productName).toBe('Sencho Admiral'); + expect(info.maskedKey).toBe('****-****-****-MN5D'); + }); + + it('returns correct info for a Skipper subscription', () => { + const future = new Date(); + future.setDate(future.getDate() + 30); + setLicenseState({ + license_status: 'active', + license_key: 'ABCD-EFGH-IJKL-SK5D', + license_variant_name: 'Skipper Monthly', + license_customer_name: 'Another User', + license_product_name: 'Sencho Skipper', + license_valid_until: future.toISOString(), + license_last_validated: Date.now().toString(), + }); + const info = svc.getLicenseInfo(); + expect(info.tier).toBe('paid'); + expect(info.status).toBe('active'); + expect(info.variant).toBe('personal'); + expect(info.isLifetime).toBe(false); + expect(info.trialDaysRemaining).toBeNull(); + expect(info.customerName).toBe('Another User'); + }); +}); diff --git a/backend/src/services/LicenseService.ts b/backend/src/services/LicenseService.ts index 3f701c6a..986676e8 100644 --- a/backend/src/services/LicenseService.ts +++ b/backend/src/services/LicenseService.ts @@ -33,6 +33,7 @@ export interface LicenseInfo { trialDaysRemaining: number | null; instanceId: string; portalUrl: string | null; + isLifetime: boolean; } /** Seat limits per variant. null = unlimited. */ @@ -194,7 +195,9 @@ export class LicenseService { /** * Get the license variant (personal or team) from stored metadata. - * Trial licenses default to "personal" - Admiral features require an Admiral license. + * Trial licenses default to "personal"; Admiral features require an Admiral license. + * Maps Lemon Squeezy variant names (which may use brand names like "Admiral" + * or "Skipper") to the internal 'team' or 'personal' values. */ public getVariant(): LicenseVariant { const db = DatabaseService.getInstance(); @@ -203,8 +206,8 @@ export class LicenseService { const variantName = db.getSystemState('license_variant_name'); if (!variantName) return null; const lower = variantName.toLowerCase(); - if (lower.includes('team')) return 'team'; - if (lower.includes('personal')) return 'personal'; + if (lower.includes('team') || lower.includes('admiral')) return 'team'; + if (lower.includes('personal') || lower.includes('skipper')) return 'personal'; return 'personal'; // default activated licenses to personal } @@ -233,6 +236,9 @@ export class LicenseService { trialDaysRemaining = Math.max(0, Math.ceil(remaining)); } + // Lifetime license: active with a stored key but no expiry date + const isLifetime = status === 'active' && !!key && !validUntil; + return { tier: this.getTier(), status, @@ -244,6 +250,7 @@ export class LicenseService { trialDaysRemaining, instanceId, portalUrl: db.getSystemState('billing_portal_url') || db.getSystemState('customer_portal_url') || null, + isLifetime, }; } diff --git a/docs/features/licensing.mdx b/docs/features/licensing.mdx index 90e8278d..25dd7188 100644 --- a/docs/features/licensing.mdx +++ b/docs/features/licensing.mdx @@ -75,9 +75,13 @@ Once your license is active, you can manage your subscription in two ways: - **From the profile menu:** Click your profile icon in the top bar and select **Billing** for quick access to the same portal. - License settings showing an active Admiral subscription with Manage Subscription button + License settings showing an active Admiral subscription with Manage Subscription button +### Lifetime licenses + +Lifetime licenses do not have a recurring subscription, so the **Manage Subscription** button is not shown. Instead, the license card displays **Duration: Lifetime** to confirm your license never expires. All other features (deactivation, multi-node enforcement, validation) work the same as subscription licenses. + ## Multi-node license enforcement If you manage multiple servers through Sencho's [multi-node feature](/features/multi-node), you only need a license on your **primary instance**. All remote nodes automatically inherit the primary's license tier for proxied requests. No per-node activation is required. @@ -93,3 +97,5 @@ To transfer your license to a different instance: 3. On your new instance, activate the same license key. Deactivation reverts the current instance to the Community tier immediately. + +Having trouble with your license? See the [Troubleshooting](/operations/troubleshooting#license-tier-shows-the-wrong-name) page for solutions to common licensing issues. diff --git a/docs/images/licensing/license-admiral-active.png b/docs/images/licensing/license-admiral-active.png new file mode 100644 index 00000000..b6e9209b Binary files /dev/null and b/docs/images/licensing/license-admiral-active.png differ diff --git a/docs/operations/troubleshooting.mdx b/docs/operations/troubleshooting.mdx index 6dad324e..4c9169d8 100644 --- a/docs/operations/troubleshooting.mdx +++ b/docs/operations/troubleshooting.mdx @@ -154,6 +154,39 @@ To re-test connectivity after making changes, open **Profile > Settings > Nodes* --- +## License tier shows the wrong name + +**Symptom:** The license card in **Settings > License** shows the wrong tier name (e.g. "Sencho Skipper" when you purchased Admiral). + +**Cause:** Older versions of Sencho could misidentify certain license variant names from the payment provider. + +**Fix:** Update to the latest version of Sencho and restart. The correct tier name will appear automatically in **Settings > License**. + +--- + +## Paid features are still locked after activation + +**Symptom:** Features remain locked even though you activated a valid license key. + +**Checks in order:** + +1. Open **Settings > License** and verify it shows your license as **active** with the correct tier name. +2. If the tier shows "Skipper" but you purchased Admiral, this is the same variant identification issue described above. Update Sencho to the latest version to resolve it. +3. If the tier shows correctly but features are still locked, restart the Sencho container. + +--- + +## "No billing portal available" when clicking Manage Subscription + +**Symptom:** Clicking **Manage Subscription** shows an error about no billing portal being available. + +**Possible causes:** + +- **Lifetime license:** Lifetime licenses do not have a billing portal. This is expected behavior. In the latest version, the **Manage Subscription** button is hidden for lifetime licenses. +- **Network issues:** If your Sencho instance cannot reach the internet, the billing portal URL cannot be fetched. Check your instance's outbound connectivity and try again. + +--- + ## Paid features return 403 on remote nodes **Symptom:** A Skipper or Admiral feature works on the local node but returns a 403 error when you switch to a remote node. diff --git a/frontend/src/components/settings/LicenseSection.tsx b/frontend/src/components/settings/LicenseSection.tsx index 35f7a4aa..1d044907 100644 --- a/frontend/src/components/settings/LicenseSection.tsx +++ b/frontend/src/components/settings/LicenseSection.tsx @@ -42,8 +42,9 @@ export function LicenseSection() { } }; + const isAdmiral = isPaid && license?.variant === 'team' && license?.status === 'active'; const showSkipperCard = !isPaid || license?.status === 'trial'; - const showUpgradeCards = showSkipperCard || (license?.variant === 'personal' && license?.status === 'active'); + const showUpgradeCards = !isAdmiral && (showSkipperCard || (license?.variant === 'personal' && license?.status === 'active')); return (
@@ -95,10 +96,10 @@ export function LicenseSection() { {license.maskedKey}
)} - {license.validUntil && ( + {(license.isLifetime || license.validUntil) && (
- Renews - {new Date(license.validUntil).toLocaleDateString()} + {license.isLifetime ? 'Duration' : 'Renews'} + {license.isLifetime ? 'Lifetime' : new Date(license.validUntil!).toLocaleDateString()}
)} @@ -122,20 +123,22 @@ export function LicenseSection() { {/* Manage Subscription (active paid license) */} {license?.status === 'active' && (
- + {!license.isLifetime && ( + + )}

Deactivating will revert to Community features. diff --git a/frontend/src/context/LicenseContext.tsx b/frontend/src/context/LicenseContext.tsx index 33a22a0e..27d14bef 100644 --- a/frontend/src/context/LicenseContext.tsx +++ b/frontend/src/context/LicenseContext.tsx @@ -17,6 +17,7 @@ export interface LicenseInfo { trialDaysRemaining: number | null; instanceId: string; portalUrl: string | null; + isLifetime: boolean; } interface LicenseContextType {