feat(license): add Lite SKU; remove user-facing free trial

Two coupled changes:

1. Lite tier
   - New Tier.LITE in src/license/schema.py.
   - FEATURES_BY_TIER[Tier.LITE] = {Deduplicator, Text Cleaner,
     Format Standardizer}. The three universally-useful tools that
     cover the most common bookkeeping / RevOps / Klaviyo prep
     workflows. Other six tools require Core.
   - i18n: license.tier_lite, license.feature_locked_title,
     license.feature_locked_body, license.upgrade_link,
     license.status_locked (en + es).
   - Per-tool feature gate at every GUI tool page
     (require_feature_or_render_upgrade) and every tool CLI
     (guard(feature=...)). A locked tool renders an upgrade
     prompt + Manage-license button (GUI) or exits with code 2
     (CLI).
   - Home grid: tool cards the user's tier doesn't unlock get a
     red 🔒 Locked badge in place of green Ready.

2. Trial removed
   - Activation form's "Start 1-year trial" button removed.
   - license_cli's `trial` subcommand removed.
   - activation.trial_button / activation.trial_help i18n keys
     dropped (pack parity test stays green).
   - Tier.TRIAL stays in the enum (back-compat with any field-
     tested trial licenses); LicenseManager._mint stays internal
     for tests and the seller's key generator.
   - Decision logged in DECISIONS §9b: a 1-year all-features
     trial undercuts paid Lite; paid-only keeps tier economics
     clean.

Tests (+29 net): +17 Lite-tier unit/guard tests + 13 Lite-tier
GUI tests + 1 trial-absent assertion - 2 trial CLI tests - 1
trial GUI button test. Total: 1995 → 2024.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 17:19:30 +00:00
parent e612c751a8
commit d32b58e61a
33 changed files with 621 additions and 153 deletions

View File

@@ -30,9 +30,21 @@ def _is_help_invocation() -> bool:
return any(arg in _HELP_FLAGS for arg in sys.argv[1:])
def guard() -> None:
"""Block startup if no valid license. No-op when license is valid,
when called with ``--help``, or under ``DATATOOLS_DEV_MODE``."""
def guard(feature: str | None = None) -> None:
"""Block startup if no valid license.
*feature* — when supplied, also requires that the active license's
tier unlocks the named feature flag (e.g.
``"03_format_standardizer"``). A Lite-tier user running
``cli_format`` would pass the global validity check but fail the
feature check; we surface a clear "upgrade your tier" message
rather than letting them hit a runtime error halfway through a
job.
No-op when license is valid (and the feature is unlocked), when
called with ``--help`` / ``-h`` / ``--version``, or under
``DATATOOLS_DEV_MODE=1``.
"""
if _is_help_invocation():
return
# Lazy import so a broken license module doesn't fail ``--help``.
@@ -40,6 +52,7 @@ def guard() -> None:
ExpiredLicenseError,
InvalidLicenseError,
LicenseError,
UnsupportedFeatureError,
get_manager,
)
@@ -48,15 +61,33 @@ def guard() -> None:
return
try:
if mgr.is_valid():
return
if not mgr.is_valid():
state = mgr.current_state()
_exit_with_message(state)
if feature is not None:
try:
mgr.require_feature(feature)
except UnsupportedFeatureError as e:
_exit_with_feature_message(feature, str(e))
return
except LicenseError:
# ``is_valid()`` swallows errors and returns False, but be
# paranoid: fall through to the state-based diagnostic.
pass
state = mgr.current_state()
_exit_with_message(state)
state = mgr.current_state()
_exit_with_message(state)
def _exit_with_feature_message(feature: str, detail: str) -> NoReturn:
"""Print the upgrade-tier diagnostic and exit. Mirrors the
GUI's ``require_feature_or_render_upgrade`` UX in CLI form."""
msg = (
f"This command requires the {feature!r} feature, which is not "
f"included in your current license tier.\n"
f"Detail: {detail}\n"
"Run ``python -m src.license_cli status`` to see your tier, "
"then activate an upgrade blob with "
"``python -m src.license_cli renew <blob>``."
)
print(f"Error: {msg}", file=sys.stderr)
raise SystemExit(2)
def _exit_with_message(state) -> NoReturn: