ReferenceEntitlement Matrix

Entitlement Matrix

This page summarises the one mechanism that decides what a business can reach: entitlement keys and who grants them.

There are no plans, tiers, or bundles. The core product is free forever; paid capabilities are bought individually from the catalog, and each product joins the entitlement set through the keys it grants (grants).

Sources of truth: the free core in backend/src/modules/entitlements/free-baseline.const.ts, the catalog and its grants in backend/src/modules/marketplace/alacarte-catalog.const.ts, and the valid key vocabulary in backend/src/modules/entitlements/entitlement-keys.const.ts.

Key namespace and the fold

The entitlement set is the fold of grant rows coming from several sources. The combine rule depends on the key prefix:

PrefixTypeCombineNotes
feature.*booleanORIf any source grants true, the feature is on.
limit.*numberSUM-1 is the “unlimited” sentinel and dominates the sum.
integration.*string[]UNIONUnion of vendor ids.

There are three grant sources: free:baseline (the free core), addon:<code>:<id> (one source per owned product), and override:admin (superadmin intervention; for limit.* / integration.* it can replace the running value via the { __replace: … } wrapper).

Credits (credit.PHOTO, credit.VIDEO, credit.MODEL3D, credit.SMS) are not entitlements. Catalog validation rejects credit.* as a grant key; credit packs are sold as kind: "credit" with creditKind / creditUnits, and the balance is read live at spend time (a 30s-cached number in front of a real vendor charge would be a money bug).

The free core (free:baseline)

Projected for every tenant, unconditionally, forever, with no licence required.

KeyValueMeaning
feature.posAccesstruePOS / sales screen
feature.kdsIntegrationtrueKitchen display (KDS)
feature.customBrandingtrueCustom brand and domain
feature.multiLocationtrueBranch hub, branch picker, switcher UI
limit.maxUsers-1Unlimited
limit.maxTables-1Unlimited
limit.maxProducts-1Unlimited
limit.maxCategories-1Unlimited
limit.maxMonthlyOrders-1Unlimited
limit.maxBranches1The first branch is free; the only priced cap

The split on branches is deliberate: the multi-branch UI is free (feature.multiLocation); what is paid for is the second branch itself (limit.maxBranches). That is exactly why extra_branch grants both keys.

The rest of the free core (menu management, tables and floor plan, QR menu, order management, cash drawer, basic reports, team and roles, customers, the device and branch panel) is not tied to any entitlement key at all — those routes carry no @RequireEntitlement. Entitlement gates are opt-in per route; a route without the decorator denies nobody.

Catalog products and what they grant

Prices are TRY, VAT-inclusive, and stored in the catalog as cents (priceCents): 490000 = ₺4,900.00. annual lines are day-prorated to the account anniversary — see the Licensing & Billing API.

Licence

CodePriceGrantsRequires licence
license_annual₺4,900/yrfeature.license, feature.prioritySupport, integration.fiscal += efatura

Modules

CodePriceGrantsRequires licence
advanced_reports₺1,290/yrfeature.advancedReports
module_inventory₺3,900/yrfeature.inventoryTracking
module_reservations₺990/yrfeature.reservationSystem
module_personnel₺990/yrfeature.personnelManagement
module_personnel_card_shift₺4,000 one-timefeature.cardShift
module_ai_studio₺1,990/yrfeature.aiContentGeneration
api_access₺2,490/yrfeature.apiAccess
module_external_display₺1,990/yrfeature.externalDisplay
priority_supportfeature.prioritySupportarchived v3.6.7 — folded into license_annual

Integrations

CodePriceGrantsRequires licence
delivery_platforms₺2,499/yrintegration.delivery += yemeksepeti, getir, trendyol_yemek, migros; feature.deliveryIntegration
fiscal_efaturaintegration.fiscal += efaturaarchived v3.6.7 — folded into license_annual
fiscal_hugin₺2,990/yrintegration.fiscal += hugin
caller_id_integration₺1,490/yrintegration.caller += generic
sms_integration₺990/yrintegration.sms += *

Delivery is now a single product: delivery_platforms writes all four vendor ids into the integration.delivery key at once. UNION folding is still real, but it’s now only observed in the fiscal_* family (the licence’s ["efatura"] merges with fiscal_hugin’s ["hugin"]). The delivery gate was already domain-wide (@RequiresIntegration("delivery"), provider-agnostic) — the single package just aligns what’s sold with what’s actually delivered.

Capacity

CodePriceGrantsRequires licence
extra_branch₺3,990/yr eachlimit.maxBranches +1, feature.multiLocation

maxQuantity: 100. Bought by the unit: the projector multiplies the limit.* value by the row’s quantity, and the engine sums that on top of the free baseline’s 1.

Credits (one-time, no expiry)

CodePriceBalanceDependency
credit_ai_photo_100₺690100 PHOTOmodule_ai_studio
credit_ai_video_20₺89020 VIDEOmodule_ai_studio
credit_ai_3d_10₺79010 MODEL3Dmodule_ai_studio
credit_sms_500₺490500 SMSsms_integration

Credit packs grant no entitlement (grants: {}); they top up a balance that is valid until consumed. A dependency can be satisfied by an active product the tenant already owns or by a sibling line in the same cart.

Service

CodePriceGrantsRequires licence
onsite_install_full₺7,500 (one-time)— (grants: {})
⚠️

onsite_install_full is a service line: it is paid and invoiced but grants no entitlement. It is one-time and gets no period-end window (currentPeriodEnd: null).

Licence suppression

While the licence is not live, the grants of every requiresLicense: true product are withheld. The projector still writes the ownership source (so the owned-products list stays complete) but with an empty grant array.

  • No business data is deleted: stock, reservations, personnel, generated AI media and chargedCents all stay put. Paying the licence back re-lights everything on the next projection.
  • The cart-level LICENSE_REQUIRED check stops the sale; this is what stops access — which is what matters when a business renews some lines of its anniversary invoice but not the licence itself.
  • A past_due row keeps granting for 7 days (ADDON_GRACE_DAYS); the entitlement drops when the sweeper flips the row to expired. active rows get the same 7-day horizon, so nobody is locked out for the hours between the engine’s 5-minute expiry sweep and the daily sweeper on anniversary night.

Reading entitlements

MethodPathReturns
GET/api/v1/entitlements/meThe folded set: features, limits, integrations, computedAt
GET/api/v1/me/licensingLicence state, owned products, credit balances, renewal, offers and purchasability
GET/api/v1/catalog/pricingThe public price list (published catalog)

/v1/entitlements/me and /v1/me/licensing are tenant-level and do not expect X-Branch-Id.

⚠️

The legacy GET /api/subscriptions/effective-features route is still mounted but depends on the plan rails: it throws 404 when the tenant has no currentPlan. The v3.3.0 migration set currentPlanId to NULL for every tenant and signup no longer stamps one — so for an ordinary business this endpoint returns 404. New integrations should use /v1/entitlements/me (or /v1/me/licensing, which returns everything in one call).

Entitlement denial (403)

A gated route returns a 403 carrying the missing entitlement and the product that unlocks it:

{
  "statusCode": 403,
  "message": "Bu özellik hesabınızda etkin değil.",
  "error": "Entitlement Required",
  "errorCode": "ENTITLEMENT_REQUIRED",
  "timestamp": "2026-08-13T08:14:05.123Z",
  "path": "/api/reports/advanced",
  "requestId": "1786…-a1b2c3d4e",
  "actionable": {
    "requirement": { "type": "feature", "key": "feature.advancedReports" },
    "offer": {
      "code": "advanced_reports",
      "name": "Advanced Reports & Analytics",
      "kind": "module",
      "annualPriceCents": 129000,
      "proratedCents": 74123,
      "currency": "TRY",
      "periodEnd": "2027-03-10T00:00:00.000Z"
    },
    "licenseRequired": false,
    "reason": "not_owned"
  }
}

The remediation payload travels under actionable, through the global error filter’s allowlist (only requirement, offer, licenseRequired, reason):

  • requirement.type: feature | limit | integration. A limit denial also carries usage and cap.
  • offer: the cheapest product that provides the key, priced for this tenant today. The price shown and the price charged at checkout come from the same catalog read.
  • licenseRequired: true means the real blocker is the missing licence, not the product — the offer switches to the licence.
  • reason: not_owned (never bought) or lapsed (expired), which is what lets a client choose between “Buy” and “Renew”.