mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-27 20:29:10 +00:00
fix(licensing): resolve Admiral variant detection and lifetime license handling (#376)
* fix(licensing): resolve Admiral variant detection and lifetime license handling The Lemon Squeezy variant name for Admiral licenses contains "Admiral" (not "Team"), but getVariant() only checked for "team" and "personal". This caused Admiral licenses to be misidentified as Skipper, locking all Admiral-exclusive features. - Map "admiral" variant names to internal "team" value, "skipper" to "personal" - Add isLifetime field to LicenseInfo API response - Hide "Manage Subscription" button for lifetime licenses (no billing portal) - Show "Duration: Lifetime" instead of empty renewal date - Hide upgrade cards for active Admiral users - Add 23 unit tests covering variant resolution, tier computation, and lifetime detection - Add troubleshooting entries for wrong tier label, locked features, and billing portal errors * fix(licensing): address code review findings - Fix nested ternary in LicenseSection JSX; restore conditional rendering to avoid showing an empty "N/A" row for non-subscription states - Clean up test file: use shared svc variable, remove redundant comments, add trialDaysRemaining assertions, rename describe block
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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<string, string>) {
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/licensing/license-active.png" alt="License settings showing an active Admiral subscription with Manage Subscription button" />
|
||||
<img src="/images/licensing/license-admiral-active.png" alt="License settings showing an active Admiral subscription with Manage Subscription button" />
|
||||
</Frame>
|
||||
|
||||
### 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.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
@@ -95,10 +96,10 @@ export function LicenseSection() {
|
||||
<span className="font-mono text-xs">{license.maskedKey}</span>
|
||||
</div>
|
||||
)}
|
||||
{license.validUntil && (
|
||||
{(license.isLifetime || license.validUntil) && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Renews</span>
|
||||
<span>{new Date(license.validUntil).toLocaleDateString()}</span>
|
||||
<span className="text-muted-foreground">{license.isLifetime ? 'Duration' : 'Renews'}</span>
|
||||
<span>{license.isLifetime ? 'Lifetime' : new Date(license.validUntil!).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -122,20 +123,22 @@ export function LicenseSection() {
|
||||
{/* Manage Subscription (active paid license) */}
|
||||
{license?.status === 'active' && (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openBillingPortal}
|
||||
disabled={billingLoading}
|
||||
>
|
||||
{billingLoading ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<CreditCard className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Manage Subscription
|
||||
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
|
||||
</Button>
|
||||
{!license.isLifetime && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openBillingPortal}
|
||||
disabled={billingLoading}
|
||||
>
|
||||
{billingLoading ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<CreditCard className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Manage Subscription
|
||||
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Deactivating will revert to Community features.
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface LicenseInfo {
|
||||
trialDaysRemaining: number | null;
|
||||
instanceId: string;
|
||||
portalUrl: string | null;
|
||||
isLifetime: boolean;
|
||||
}
|
||||
|
||||
interface LicenseContextType {
|
||||
|
||||
Reference in New Issue
Block a user