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');