From d6b744e8e6a55d85478b2b47f750c5098f68c59c Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 24 Apr 2026 16:26:36 -0400 Subject: [PATCH] feat(license): replace local auto-trial with Lemon Squeezy hosted trial flow (#755) Fresh installs land on the Community tier. The 14-day Admiral trial is now issued by Lemon Squeezy via their hosted checkout: the user enters email + card, receives a license key by email, and pastes it into the existing Settings > License activation field. Backend changes: - LicenseService.initialize() no longer auto-creates a license_status='trial' row on first boot. It now only ensures an instance_id exists and starts periodic validation. - Drop the TRIAL_DURATION_DAYS constant. - Drop the status='trial' early-return in getVariant() so LS-issued trials resolve through the normal variant metadata path (variant_name / product_name). - Trial branches in getTier() and getLicenseInfo() are retained for future work that may detect trial state from Lemon Squeezy metadata; they are currently unreachable via the Sencho code paths. Frontend changes: - Settings > License surfaces a new "Try Admiral free for 14 days" CTA block with Start monthly trial and Start annual trial buttons that open Lemon Squeezy hosted checkout. The CTA is visible only when the user has no paid access and is not already on a trial. - Reserve the Admiral upgrade card for the Skipper-active upgrade path so unlicensed users see one Admiral path (the trial CTA) instead of two. - Pull the inline Lemon Squeezy checkout URLs into named module constants so the Skipper, Admiral monthly, and Admiral annual endpoints are defined in one place. Test changes: - license-service.test.ts covers the no-auto-trial startup path and updates the trial-variant test to reflect the metadata-driven resolution. - afterAll in the initialize() describe block calls destroy() so the 72-hour validation interval does not leak into sibling test files. Docs: - Rewrite the Free trial section in features/licensing.mdx to document the new LS checkout flow (email + card required, auto-converts on day 14 unless cancelled). - Add an operations/troubleshooting entry for cases where the trial license key email does not arrive. --- backend/src/__tests__/license-service.test.ts | 34 ++++- backend/src/services/LicenseService.ts | 22 +--- docs/features/licensing.mdx | 16 ++- docs/operations/troubleshooting.mdx | 13 ++ .../components/settings/LicenseSection.tsx | 117 ++++++++++++------ 5 files changed, 143 insertions(+), 59 deletions(-) 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} -
  • - ))} -
- -
+ )}
)}