mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-22 08:06:42 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user