Skip to main content

Licensing & Feature-Gating for Module Authors

Flavor products ship under a pay-from-day-1 licensing model with two shipping tiers:

TierCovers
StarterCore eCommerce, products, payments, premium shipping, marketplace feeds, and all theme storefront modules
BusinessEverything 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.

Where license state actually lives

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_LEVELS constant also defines higher internal levels above business, but only starter and business ship as customer product tiers. Do not gate module features against anything above business.


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 IDRequired tier
core_ecommerce, products, basic_payments, premium_payments, premium_shipping, marketplace_feedsstarter
erp_invoicing, erp_inventory, erp_purchasing, erp_accounting, erp_crm, erp_hrbusiness

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)
Gating your OWN (non-manifest) feature ID

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_erp or manage_options) and license tier. On a tier miss it returns new \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). Returns erp_role_forbidden on a role miss.
  • requireErpPermission( $featureId, $permission ): callable — a factory returning a ready permission_callback closure (used above). requireErpAdmin() and erpModulePermissionCheck() 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.

danger
The wpec_license_gate filter does not exist — do not use it

There 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

NeedPluginTheme
Is there any valid license?wpec_license_gate()->isLicensed()flavor_is_licensed()
Effective tier stringwpec_license_gate()->getEffectiveTier()flavor_license_tier()
Gate a manifest featurewpec_can_access('erp_invoicing')flavor_can_access_module($id)
Gate a NON-manifest / custom tier needwpec_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 gaterequiresParentLicense() (via ModuleManager)
Cross-product stateflavor_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 — use tierMeetsMinimum() 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 by flavor_can_access_module(), not by the module's own minTier().
  • Never read license rows directly. Everything routes through the helpers so degradation and fingerprint logic stay in one place.

See also