Licensing & Feature-Gating for Module Authors
Flavor products ship under a pay-from-day-1 licensing model with two shipping tiers:
| Tier | Covers |
|---|---|
| Starter | Core eCommerce, products, payments, premium shipping, marketplace feeds, and all theme storefront modules |
| Business | Everything in Starter plus the native ERP suite (Invoicing, Inventory, Purchasing, Accounting, CRM, HR) |
There is no free or trial tier — a valid license is required for licensed features to run. This page shows how a module author checks tier before exposing a feature, on both sides of the ecosystem.
License state is resolved by Flavor Core, not by the theme or plugin. Both products extend a single
shared base gate (Flavor_Core_License_Gate_Base) that reads the canonical license data via
flavor_core_get_license_data() and applies staleness, expiry (F80), grace (F50), and fingerprint checks.
You never read license rows yourself — you call the helpers below.
The tier model
Tiers are ordered by a shared hierarchy constant (Flavor_Core_License_Gate_Base::TIER_LEVELS):
none (0) → starter (1) → business (2)
A check passes when the site's effective tier is greater than or equal to the tier a feature requires.
"Effective tier" is the tier after degradation — an expired, stale, or fingerprint-failed license
degrades to none even if the raw stored tier still says business. Always gate on the effective
tier (the helpers below already do).
The
TIER_LEVELSconstant also defines higher internal levels abovebusiness, but onlystarterandbusinessship as customer product tiers. Do not gate module features against anything abovebusiness.
Plugin side (WP eCommerce Core)
wpec_can_access() — the feature check
The one call you need to gate a plugin feature by tier:
if ( wpec_can_access( 'erp_invoicing' ) ) {
// Business-tier feature is available — run it.
}
wpec_can_access( string $featureId ): bool — defined in wp-ecommerce-core/includes/helpers.php. It
resolves the current effective tier and compares it against the tier the feature requires in the manifest.
It also fires a read-only observability action (see below).
The feature manifest
Feature IDs map to a minimum tier in LicenseGate's hardcoded manifest
(wp-ecommerce-core/src/Licensing/LicenseGate.php). The shipped baseline:
| Feature ID | Required tier |
|---|---|
core_ecommerce, products, basic_payments, premium_payments, premium_shipping, marketplace_feeds | starter |
erp_invoicing, erp_inventory, erp_purchasing, erp_accounting, erp_crm, erp_hr | business |
The hardcoded map is the fallback ground truth. When Flavor Hub has published a signed manifest, it is overlaid on top of this baseline (it can re-tier a feature, but a feature missing from a stale server manifest keeps its hardcoded tier — it is never silently un-gated). Read the active manifest with:
$manifest = \WPECommerce\Core\Licensing\LicenseGate::getFeatureManifest(); // [feature_id => tier]
Reading tier directly
Get the gate singleton with wpec_license_gate() (returns \WPECommerce\Core\Licensing\LicenseGate):
$gate = wpec_license_gate();
$gate->getEffectiveTier(); // 'none' | 'starter' | 'business' (post-degradation) — gate on this
$gate->getRawTier(); // raw stored tier before degradation — for diagnostics only, never for access
$gate->isLicensed(); // bool — active, non-degraded license (true during an active grace window)
wpec_can_access() fails open for feature IDs that are not in the manifest — an unknown key is treated
as free and returns true. If your module introduces a brand-new capability that is not one of the manifest
IDs above, do not invent a new ID and pass it to wpec_can_access() (it will never gate). Instead check
the tier directly against the level you require:
$gate = wpec_license_gate();
if ( $gate->tierMeetsMinimum( $gate->getEffectiveTier(), 'business' ) ) {
// Effective tier is business or higher.
}
tierMeetsMinimum( string $current, string $minimum ): bool is the same primitive the manifest checks use.
Enforcing tier in a REST controller
WordPress REST permission_callback accepts true, false, or a WP_Error. A WP_Error produces a
403 and passes its data into the response body — use it to tell the client which tier is required. These
are the real idioms shipped in the plugin; follow them rather than inventing a new pattern.
1. Capability-only gate (settings that don't require a paid tier) —
SettingsApiController::canManageSettings():
public function canManageSettings(): bool
{
return current_user_can( 'manage_options' )
|| current_user_can( 'wpec_manage_settings' );
}
wpec_manage_settings is the plugin's own capability, granted to eCommerce shop-manager roles so a
non-admin manager can reach eCommerce settings without full manage_options.
2. License-gated ERP gate — the LicenseGatedErp trait
(wp-ecommerce-core/src/Licensing/Traits/LicenseGatedErp.php). Compose it into an ERP controller and use
its permission callbacks:
use WPECommerce\Core\Licensing\Traits\LicenseGatedErp;
class MyErpController
{
use LicenseGatedErp;
public function register(): void
{
register_rest_route( WPEC_REST_NAMESPACE, '/erp/invoices', [
'methods' => 'GET',
'callback' => [ $this, 'list' ],
// WP cap + license tier + ERP role, in one callback:
'permission_callback' => $this->requireErpPermission( 'erp_invoicing', 'invoicing.view' ),
] );
}
}
The trait layers three checks, so you never have to reassemble them:
canAccessErpFeature( string $featureId ): bool|\WP_Error— WP capability (access_erpormanage_options) and license tier. On a tier miss it returnsnew \WP_Error( 'license_required', …, [ 'status' => 403, 'required_feature' => …, 'required_tier' => …, 'current_tier' => … ] ).checkErpPermission( string $featureId, string $permission ): bool|\WP_Error— the above plus the current user's ERP role (RBAC). Returnserp_role_forbiddenon a role miss.requireErpPermission( $featureId, $permission ): callable— a factory returning a readypermission_callbackclosure (used above).requireErpAdmin()anderpModulePermissionCheck()are the admin-only and module-level variants.
If you only need the tier gate (no ERP RBAC), call canAccessErpFeature() directly as the
permission_callback. To build the same license_required error shape yourself for a non-ERP route:
if ( ! wpec_can_access( 'premium_payments' ) ) {
$gate = wpec_license_gate();
$requiredTier = $gate::getFeatureManifest()['premium_payments'] ?? 'starter';
return new \WP_Error(
'license_required',
sprintf( 'This feature requires a %s license or higher.', ucfirst( $requiredTier ) ),
[ 'status' => 403, 'required_tier' => $requiredTier, 'current_tier' => $gate->getEffectiveTier() ]
);
}
Observability (not a bypass point)
Every wpec_can_access() call fires a read-only action after resolving:
do_action( 'wpec_license_gate_checked', bool $result, string $featureId, string $tier );
Use it for logging or telemetry. It cannot change the verdict — the result is already decided when the action runs.
wpec_license_gate filter does not exist — do not use itThere is a helper function named wpec_license_gate() (returns the gate singleton, shown above). There is
no wpec_license_gate filter. An earlier filter by that name was removed as a tamper vector (F81); the
gate no longer consults it, and a boot-time sentinel (LicenseGate::detectTamper()) logs a warning if
anything registers against it. Never attempt to short-circuit access with add_filter( 'wpec_license_gate', … ).
Theme side (Module System 2.0 authors)
Theme modules extend Flavor\Modules\AbstractModule (see
Creating a Module). Two contract methods relate to licensing, and
they do different things — this distinction matters:
requiresParentLicense() — the boot gate (enforced)
public static function requiresParentLicense(): bool { return true; } // default
This is the runtime license gate for a module. When it returns true (the default), ModuleManager
skips booting the module unless the theme has a valid license. Enforcement lives in
ModuleManager::initActiveModules():
if ( ! empty( $module['requires_parent_license'] ) ) {
if ( function_exists( 'flavor_is_licensed' ) && ! flavor_is_licensed() ) {
// module is skipped and surfaced in getGatedByLicense()
continue;
}
}
Skipped modules are exposed to the admin UI via ModuleManager::getGatedByLicense() so they render with a
"license required" badge. Override to return false only for a built-in module that must run even when
the host license is invalid.
Note: the check is binary — is the theme licensed at all — via
flavor_is_licensed(). It does not compare tiers. Per-tier module access is a separate concern (below).
minTier() — metadata, not a runtime gate
public static function minTier(): string { return 'starter'; } // default
minTier() is a marketplace-ready metadata field. It flows into AbstractModule::metadata() (as
min_tier) and is carried into the component registry and Hub upload — it describes the module's intended
tier. It is not consulted at boot; a module is not blocked by its own minTier(). Do not rely on it as
an access check.
The runtime per-tier access decision for a theme module is made by the license gate's category map, not
by minTier().
Checking tier access at runtime
The theme exposes three global helpers (all in flavor-starter/inc/core/helpers.php):
flavor_is_licensed(); // bool — theme has any valid, non-degraded license
flavor_license_tier(); // string — 'none' | 'starter' | 'business' (effective)
flavor_can_access_module( string $moduleId ); // bool — is THIS module allowed under the current tier
flavor_can_access_module() resolves the module ID to a feature category and checks that category's
required tier. The category map lives in Flavor_License_Gate (flavor-starter/inc/licensing-gate.php):
storefront (eshop_*) modules require starter; developer modules (custom-css, custom-js) require
business; unmapped modules default to the premium_modules category (starter). A module not in the map
is still gated at starter — nothing is free by default under the pay-from-day-1 model.
Gate a premium capability inside your module's boot():
public function boot(): void
{
// Always-available part of the module can register here.
if ( ! flavor_can_access_module( static::id() ) ) {
return; // tier does not cover this module — skip the premium wiring
}
// Premium hooks / shortcodes / assets go here.
}
To fetch the gate object directly (nullable — it returns null before the licensing layer has loaded), use
flavor_license_gate():
$gate = flavor_license_gate();
if ( $gate !== null && $gate->getEffectiveTier() === 'business' ) {
// business-only path
}
Cross-product helpers (Flavor Core)
When you need license state independent of which product you are in (for example a plugin module that also wants to know the theme's status), Flavor Core exposes canonical helpers. These are the single source both gates read from:
flavor_core_is_licensed( string $product = '' ): bool; // '' = any licensed product active
flavor_core_get_tier( string $product = 'plugin' ): string; // raw stored tier
flavor_core_get_license_data( string $product = 'plugin' ): array; // full license record
$product accepts 'plugin' or 'theme'. flavor_core_is_licensed() runs the full hardened check
(staleness, expiry, fingerprint) — it is not a loose "row exists" test.
Quick reference
| Need | Plugin | Theme |
|---|---|---|
| Is there any valid license? | wpec_license_gate()->isLicensed() | flavor_is_licensed() |
| Effective tier string | wpec_license_gate()->getEffectiveTier() | flavor_license_tier() |
| Gate a manifest feature | wpec_can_access('erp_invoicing') | flavor_can_access_module($id) |
| Gate a NON-manifest / custom tier need | wpec_license_gate()->tierMeetsMinimum($current, 'business') | flavor_license_gate()?->tierMeetsMinimum(...) |
| REST permission (tier) | canAccessErpFeature($featureId) | — |
| REST permission (tier + ERP role) | requireErpPermission($featureId, $perm) | — |
| Boot-time module license gate | — | requiresParentLicense() (via ModuleManager) |
| Cross-product state | flavor_core_is_licensed($product) | flavor_core_is_licensed($product) |
Rules to keep it correct
- Gate on the effective tier, never the raw tier.
getEffectiveTier()/flavor_license_tier()already account for expiry, staleness, and grace;getRawTier()/flavor_core_get_tier()do not. - Do not invent manifest feature IDs.
wpec_can_access()fails open for unknown IDs — usetierMeetsMinimum()for a custom tier requirement. - Do not register against
wpec_license_gate— it is not a filter; it is logged as a tamper attempt. minTier()is metadata,requiresParentLicense()is the enforcement — on the theme side, per-tier access is decided byflavor_can_access_module(), not by the module's ownminTier().- Never read license rows directly. Everything routes through the helpers so degradation and fingerprint logic stay in one place.
See also
- Creating a Module — the
AbstractModulecontract - Module Development Best Practices
- Module Settings API
- Data Sovereignty — why licensing state lives in Core-owned tables
- Plugin Hooks · Theme Hooks