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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user