diff --git a/backend/src/__tests__/license-service.test.ts b/backend/src/__tests__/license-service.test.ts index fa36736d..6cfee8e5 100644 --- a/backend/src/__tests__/license-service.test.ts +++ b/backend/src/__tests__/license-service.test.ts @@ -38,9 +38,16 @@ function setLicenseState(overrides: Record) { } describe('LicenseService.getVariant()', () => { - it('returns "skipper" for trial licenses', () => { + it('returns null for trial status with no stored variant metadata', () => { + // Trial status without LS-issued variant metadata resolves to null; a real + // LS-issued trial would carry variant_name and resolve through the normal path. setLicenseState({ license_status: 'trial' }); - expect(svc.getVariant()).toBe('skipper'); + expect(svc.getVariant()).toBeNull(); + }); + + it('returns "admiral" for trial with LS-stored Admiral variant metadata', () => { + setLicenseState({ license_status: 'trial', license_variant_name: 'Admiral Monthly', license_product_name: 'Sencho Admiral' }); + expect(svc.getVariant()).toBe('admiral'); }); it('returns null when no variant name is stored', () => { @@ -119,7 +126,6 @@ describe('LicenseService.getVariant()', () => { 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'); }); @@ -265,3 +271,25 @@ describe('LicenseService.getLicenseInfo() - full scenarios', () => { expect(info.customerName).toBe('Another User'); }); }); + +describe('LicenseService.initialize()', () => { + // initialize() starts a 72h validation interval; tear it down so vitest's worker + // does not inherit the timer into sibling test files. + afterAll(() => { + svc.destroy(); + }); + + it('does not auto-start a trial on first boot', () => { + const db = DatabaseService.getInstance(); + db.setSystemState('instance_id', ''); + db.setSystemState('license_status', ''); + db.setSystemState('license_valid_until', ''); + + svc.initialize(); + + expect(db.getSystemState('instance_id')).toBeTruthy(); + expect(db.getSystemState('license_status')).toBe(''); + expect(db.getSystemState('license_valid_until')).toBe(''); + expect(svc.getTier()).toBe('community'); + }); +}); diff --git a/backend/src/services/LicenseService.ts b/backend/src/services/LicenseService.ts index 489d5ac5..a2ecdea0 100644 --- a/backend/src/services/LicenseService.ts +++ b/backend/src/services/LicenseService.ts @@ -123,7 +123,6 @@ interface LemonSqueezyValidationResponse { const LEMON_SQUEEZY_API = 'https://api.lemonsqueezy.com/v1/licenses'; const VALIDATION_INTERVAL_MS = 72 * 60 * 60 * 1000; // 72 hours const OFFLINE_GRACE_DAYS = 30; -const TRIAL_DURATION_DAYS = 14; export class LicenseService { private static instance: LicenseService; @@ -140,8 +139,9 @@ export class LicenseService { /** * Initialize the license service on startup. - * Ensures an instance ID exists and starts the 14-day trial on first boot. - * Also starts periodic validation for active licenses. + * Ensures an instance ID exists and starts periodic validation for active licenses. + * Fresh installs land on Community; trials are issued by Lemon Squeezy via the + * hosted checkout (email + card required) and activated locally by pasting the key. */ public initialize(): void { const db = DatabaseService.getInstance(); @@ -151,16 +151,6 @@ export class LicenseService { db.setSystemState('instance_id', crypto.randomUUID()); } - // Start 14-day trial on first boot (no license_status means fresh install) - const currentStatus = db.getSystemState('license_status'); - if (!currentStatus) { - const trialEnd = new Date(); - trialEnd.setDate(trialEnd.getDate() + TRIAL_DURATION_DAYS); - db.setSystemState('license_status', 'trial'); - db.setSystemState('license_valid_until', trialEnd.toISOString()); - console.log(`[License] 14-day Skipper trial started. Expires: ${trialEnd.toISOString()}`); - } - this.startPeriodicValidation(); } @@ -235,7 +225,8 @@ export class LicenseService { /** * Get the license variant (skipper or admiral) from stored metadata. - * Trial licenses default to "skipper"; Admiral features require an Admiral license. + * Trial and active licenses both resolve via Lemon Squeezy metadata stored by activate(); + * trial-granted variant is whatever Lemon Squeezy returned for the trial variant. * * Self-healing: on every call, cross-checks the stored variant_type against what * resolveVariantType() produces from the current product/variant names. If they @@ -244,9 +235,6 @@ export class LicenseService { */ public getVariant(): LicenseVariant { const db = DatabaseService.getInstance(); - const status = db.getSystemState('license_status'); - if (status === 'trial') return 'skipper'; - const variantName = db.getSystemState('license_variant_name'); const productName = db.getSystemState('license_product_name') || undefined; const storedType = db.getSystemState('license_variant_type'); diff --git a/docs/features/licensing.mdx b/docs/features/licensing.mdx index 9e2a9731..5d786f14 100644 --- a/docs/features/licensing.mdx +++ b/docs/features/licensing.mdx @@ -48,9 +48,21 @@ Lifetime pricing is an early-adopter offer available for a limited time only. ## Free trial -Every new Sencho installation starts with a **14-day Skipper trial**. No license key or credit card is required. Skipper features like fleet management, webhooks, atomic deployments, and auto-update policies are unlocked during the trial so you can evaluate them with your real infrastructure. Admiral-exclusive features (SSO, audit log, host console, scoped RBAC, unlimited accounts) require an Admiral license. +Sencho offers a **14-day Admiral trial** on monthly and annual Admiral plans so you can evaluate the flagship features (Host Console, Scheduled Operations, LDAP / Active Directory, audit log, unlimited accounts) with your real infrastructure before committing. -When the trial expires, Sencho automatically reverts to the Community tier. No data is lost. +To start a trial: + +1. In the Sencho dashboard, go to **Settings > License**, or visit the [pricing page](https://sencho.io/pricing). +2. Click **Start monthly trial** or **Start annual trial**. +3. Complete the Lemon Squeezy checkout. A valid card is required for verification; you are not charged until the trial ends. +4. Lemon Squeezy will email your license key within a few minutes. +5. Paste the key into the **Have a license key?** field on the License page and click **Activate**. + +When your trial ends, Lemon Squeezy automatically charges the card you provided and your plan continues as a paid Admiral subscription. To avoid being charged, cancel from the **Manage Subscription** button (or from the Lemon Squeezy receipt email) any time before day 14. + + + Fresh installs land on the **Community** tier until you activate a license key. All Community features (unlimited nodes, compose editor, global logs, alerts, Custom OIDC, and more) are available immediately. + ## Upgrading your plan diff --git a/docs/operations/troubleshooting.mdx b/docs/operations/troubleshooting.mdx index a7fddcb5..cb018b57 100644 --- a/docs/operations/troubleshooting.mdx +++ b/docs/operations/troubleshooting.mdx @@ -166,6 +166,19 @@ To re-test connectivity after making changes, open **Profile > Settings > Nodes* --- +## I didn't receive my trial license key after checkout + +**Symptom:** You completed the Lemon Squeezy trial checkout but no license key email arrived. + +**Checks in order:** + +1. **Check your spam / junk folder.** Lemon Squeezy emails come from `no-reply@lemonsqueezy.com`; some providers flag them aggressively. +2. **Verify the email address you used at checkout.** If you made a typo, the key was sent to the wrong address. Contact `licensing@sencho.io` with your order ID (from the checkout receipt) and we can resend or issue a new key. +3. **Wait a few minutes.** Delivery is usually within 60 seconds but can take up to 10 minutes during peak load. +4. **Still missing after 15 minutes?** Email `licensing@sencho.io` with the transaction ID from your card statement and we will resend the key. + +--- + ## Paid features are still locked after activation **Symptom:** Features remain locked even though you activated a valid license key. diff --git a/frontend/src/components/settings/LicenseSection.tsx b/frontend/src/components/settings/LicenseSection.tsx index cf8dc911..a4bc24fd 100644 --- a/frontend/src/components/settings/LicenseSection.tsx +++ b/frontend/src/components/settings/LicenseSection.tsx @@ -12,8 +12,16 @@ import { } from 'lucide-react'; import { apiFetch } from '@/lib/api'; +// Lemon Squeezy hosted checkout URLs. Admiral monthly and annual include a built-in +// 14-day trial (email + card required on LS checkout); users receive a license key +// by email and paste it into the activate input below. +const SKIPPER_CHECKOUT_URL = 'https://saelix.lemonsqueezy.com/checkout/buy/f75bfb65-443a-46a0-abb1-981e0ff4b382'; +const ADMIRAL_MONTHLY_CHECKOUT_URL = 'https://saelix.lemonsqueezy.com/checkout/buy/b049b824-176a-408d-a9d3-9365c979a61f'; +const ADMIRAL_ANNUAL_CHECKOUT_URL = 'https://saelix.lemonsqueezy.com/checkout/buy/3e595568-92d3-4edd-90f1-25650248dfa9'; + function getTierDisplayName(tier?: string, variant?: string | null, status?: string): string { if (tier === 'paid' && variant === 'admiral' && status === 'active') return 'Sencho Admiral'; + if (tier === 'paid' && variant === 'admiral' && status === 'trial') return 'Sencho Admiral (Trial)'; if (tier === 'paid') return 'Sencho Skipper'; return 'Sencho Community'; } @@ -44,7 +52,11 @@ export function LicenseSection() { const isAdmiral = isPaid && license?.variant === 'admiral' && license?.status === 'active'; const showSkipperCard = !isPaid || license?.status === 'trial'; - const showUpgradeCards = !isAdmiral && (showSkipperCard || (license?.variant === 'skipper' && license?.status === 'active')); + // Trial CTA owns the Admiral path for unlicensed users; the Admiral upgrade card is reserved for the + // Skipper-active upgrade path. Otherwise both would link to the same LS checkout for community users. + const showTrialCta = license?.status !== 'active' && license?.status !== 'trial'; + const showAdmiralUpgradeCard = !isAdmiral && !showTrialCta; + const showUpgradeCards = showSkipperCard || showAdmiralUpgradeCard; return (
@@ -162,12 +174,42 @@ export function LicenseSection() {
)} - {/* Upgrade Cards: show for community, trial, or active Skipper users */} + {showTrialCta && ( +
+
+ + Try Admiral free for 14 days +
+

+ Admiral unlocks Host Console, Scheduled Operations, LDAP / Active Directory, audit log, API tokens, and unlimited accounts. Starting a trial opens Lemon Squeezy checkout, which requires a card for verification; you can cancel any time before day 14. +

+
+ + +
+

+ After checkout, paste the license key from your email into the activate field below. +

+
+ )} + {showUpgradeCards && (
-
- {/* Skipper Card - only for Community users and trial users */} +
{showSkipperCard && (
@@ -187,7 +229,7 @@ export function LicenseSection() {
)} - {/* Admiral Card */} -
-
- - Admiral + {showAdmiralUpgradeCard && ( +
+
+ + Admiral +
+

For teams managing shared infrastructure.

+
    + {[ + ...(license?.variant === 'skipper' ? ['Everything in Skipper'] : ['Everything in Community']), + 'Unlimited accounts & scoped RBAC', + ...(license?.variant !== 'skipper' ? ['Fleet View, webhooks & labels', 'Atomic deployments & backups'] : []), + 'LDAP/AD, audit log & host console', + 'API tokens & private registries', + 'Scheduled operations', + ].map((f) => ( +
  • + + {f} +
  • + ))} +
+
-

For teams managing shared infrastructure.

-
    - {[ - ...(license?.variant === 'skipper' ? ['Everything in Skipper'] : ['Everything in Community']), - 'Unlimited accounts & scoped RBAC', - ...(license?.variant !== 'skipper' ? ['Fleet View, webhooks & labels', 'Atomic deployments & backups'] : []), - 'LDAP/AD, audit log & host console', - 'API tokens & private registries', - 'Scheduled operations', - ].map((f) => ( -
  • - - {f} -
  • - ))} -
- -
+ )}
)}