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.
This commit is contained in:
Anso
2026-04-24 16:26:36 -04:00
committed by GitHub
parent a502da54ee
commit d6b744e8e6
5 changed files with 143 additions and 59 deletions
+31 -3
View File
@@ -38,9 +38,16 @@ function setLicenseState(overrides: Record<string, string>) {
}
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');
});
});
+5 -17
View File
@@ -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');
+14 -2
View File
@@ -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.
<Tip>
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.
</Tip>
## Upgrading your plan
+13
View File
@@ -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.
@@ -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 (
<div className="space-y-6">
@@ -162,12 +174,42 @@ export function LicenseSection() {
</div>
)}
{/* Upgrade Cards: show for community, trial, or active Skipper users */}
{showTrialCta && (
<div className="border border-glass-border rounded-lg p-4 space-y-3 bg-glass">
<div className="flex items-center gap-2">
<ShipWheel className="w-4 h-4 text-blue-500" />
<span className="font-medium text-sm">Try Admiral free for 14 days</span>
</div>
<p className="text-xs text-muted-foreground">
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.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Button
size="sm"
onClick={() => window.open(ADMIRAL_MONTHLY_CHECKOUT_URL, '_blank')}
>
Start monthly trial
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => window.open(ADMIRAL_ANNUAL_CHECKOUT_URL, '_blank')}
>
Start annual trial
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
After checkout, paste the license key from your email into the activate field below.
</p>
</div>
)}
{showUpgradeCards && (
<div className="space-y-3">
<Label className="text-base">Upgrade your plan</Label>
<div className={`grid gap-3 ${showSkipperCard ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'}`}>
{/* Skipper Card - only for Community users and trial users */}
<div className={`grid gap-3 ${showSkipperCard && showAdmiralUpgradeCard ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'}`}>
{showSkipperCard && (
<div className="relative border border-glass-border rounded-lg p-4 space-y-3 bg-glass flex flex-col">
<div className="flex items-center gap-2">
@@ -187,7 +229,7 @@ export function LicenseSection() {
<Button
size="sm"
className="w-full mt-auto"
onClick={() => window.open('https://saelix.lemonsqueezy.com/checkout/buy/f75bfb65-443a-46a0-abb1-981e0ff4b382', '_blank')}
onClick={() => window.open(SKIPPER_CHECKOUT_URL, '_blank')}
>
<Zap className="w-4 h-4 mr-2" />
Get Skipper
@@ -196,39 +238,40 @@ export function LicenseSection() {
</div>
)}
{/* Admiral Card */}
<div className="border border-glass-border rounded-lg p-4 space-y-3 bg-glass flex flex-col">
<div className="flex items-center gap-2">
<ShipWheel className="w-4 h-4 text-blue-500" />
<span className="font-medium text-sm">Admiral</span>
{showAdmiralUpgradeCard && (
<div className="border border-glass-border rounded-lg p-4 space-y-3 bg-glass flex flex-col">
<div className="flex items-center gap-2">
<ShipWheel className="w-4 h-4 text-blue-500" />
<span className="font-medium text-sm">Admiral</span>
</div>
<p className="text-xs text-muted-foreground">For teams managing shared infrastructure.</p>
<ul className="space-y-1.5">
{[
...(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) => (
<li key={f} className="flex items-center gap-2 text-xs text-muted-foreground">
<Check className="w-3 h-3 shrink-0 text-success" />
{f}
</li>
))}
</ul>
<Button
size="sm"
variant={showSkipperCard ? 'outline' : 'default'}
className="w-full mt-auto"
onClick={() => window.open(ADMIRAL_MONTHLY_CHECKOUT_URL, '_blank')}
>
<Zap className="w-4 h-4 mr-2" />
Get Admiral
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
</Button>
</div>
<p className="text-xs text-muted-foreground">For teams managing shared infrastructure.</p>
<ul className="space-y-1.5">
{[
...(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) => (
<li key={f} className="flex items-center gap-2 text-xs text-muted-foreground">
<Check className="w-3 h-3 shrink-0 text-success" />
{f}
</li>
))}
</ul>
<Button
size="sm"
variant={showSkipperCard ? 'outline' : 'default'}
className="w-full mt-auto"
onClick={() => window.open('https://saelix.lemonsqueezy.com/checkout/buy/b049b824-176a-408d-a9d3-9365c979a61f', '_blank')}
>
<Zap className="w-4 h-4 mr-2" />
Get Admiral
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
</Button>
</div>
)}
</div>
</div>
)}