Skip to main content

Entitlement Simplification — Target Architecture

Status: Proposed (design only — no code yet) Date: 2026-06-06 Owner: Jack Nelson Supersedes the commercial/config model in: 2026-05-30-peak-rearchitecture.md, 2026-06-02-peak-rearchitecture-epic-execution-plan.md Builds on: PR #1573 (made the current system work; this makes it simple)


1. Why

The current entitlement + feature system is technically working (post-#1573) but commercially over-modeled and operationally rotten:

  • 7 products × 5 statuses (peakpos, peaksimple, peakterminal, peakpro, peakai, peakgateway, peakmarketing × available/trialing/active/suspended/cancelled).
  • Two parallel stored-config surfaces that humans hand-edit and that silently drift: the PeakPOS config (feature_suite_org_configurations.configuration_modules, 12 module flags) and the Feature Suite config (feature_suite_tenants.configuration_modules, 19 module flags + preset).
  • A "feature level / package" vocabulary (package:peakpos+feature-suite, package:none) layered on top of "product access," so the same idea is named three ways.
  • Manual provisioning steps, config that can drift from the sold plan, and a resolver whose moduleDenied gate reads hand-maintained state.

The owner's model is far simpler and is the target below.

2. Target model (locked)

An entitlement is the commercial truth, and it is just:

PeakPOS: tier × vertical ← required base (the SKU)
+ PeakAI: tier ← optional addon
+ PeakMarketing: tier ← optional addon
+ PeakKitchen: tier ← optional addon
PeakGateway: (flat, always-on) ← the floor; nothing works without it

Module visibility = f(tier, vertical, addons) ← DERIVED, never hand-configured
Per-module overrides = NONE ← config pages deleted entirely
Resolver = invisible plumbing: modules(tier,vertical,addons) ∩ user IAM
Support UI = pick tier · pick vertical · toggle addons(+tier). That is the whole surface.

Principles

  1. Entitlement is the single input. Tier, vertical, and addons are the only things a human ever sets.
  2. Modules are derived, not stored. f(tier, vertical, addons) → enabled modules is a static, version-controlled table. There is no per-org module state to drift, no config page to maintain, no manual provisioning.
  3. The resolver survives only as plumbing. It still computes enabledModules ∩ userIAM per request, but it is never a user-facing concept, page, or vocabulary.
  4. Gateway is the floor. Always present, never tiered (for v1). If we ever charge for "online" capability, that is a separate addon (gated) or usage billing (metered) — explicitly deferred, see §8.
  5. One vocabulary. "Plan" = {tier, vertical} + addons. Retire "product access," "feature level / package," and "feature suite" as user-facing terms.

Tiers (per product)

PeakPOS tiers are device/form-factor surfaces, not a nested feature ladder. Full canonical definitions + capability matrix: docs/private-docs/docs/peak-commercial-model.md §2 (the source of truth). Summary:

  • tier_peakterminal — "Peak Terminal" (Nexgo, card-present only): payments, receipts, reports, dual pricing, tips (suggested + adjust), static/bypassable tax. No EBT. No inventory/scheduling/invoices.
  • tier_peaksimple — "Peak Simple" (Android/iPad tablet + Tap to Pay): everything in Terminal plus inventory, scheduling, invoices, full tax — all Peak POS features except EBT.
  • tier_peakpos — "Peak POS" (2-screen tablet + separate reader, default): everything in Simple plus EBT (the only tier with EBT/PIN debit).

It is a clean capability ladder: Terminal ⊂ Simple ⊂ POS — Simple adds back-office (inventory/scheduling/invoices), POS adds EBT (capstone, needs the separate PIN-capable reader). Different hardware per rung. (SDK PeakPosProductTier.surfaceSubtitle predates this and omits the EBT distinction — reconcile during implementation; see commercial-model doc §8.)

  • PeakAI / PeakMarketing / PeakKitchen: each absent, or present at one of its own tiers (these are capability tiers, unlike the PeakPOS device tiers).

Verticals

Reuse the existing SUPPORTED_FEATURE_MODES: convenience_retail (default), standard_retail, vape_shop, liquor_store, beauty_salon, grocery, qsr_foodservice, service, hybrid.

3. Current state (ground truth, with refs)

ConceptTodayWhere
Productspeakpos, peaksimple, peakterminal, peakpro(=feature-suite), peakai, peakgateway, peakmarketingProductProvisioningService.kt:329-337, AccountProductService.kt:226
Statusesavailable/trialing/active/suspended/cancelledAccountProductService.kt:187-191; DDL AccountProduct.sq:16
Tierfirst-class but separate product-ish: tier_peakpos/tier_peaksimple/tier_peakterminal in merchant-api TierServiceTierService.kt:14; mapping ProductProvisioningService.posProductForExistingTier():67-75, tierForProduct():304-306
Vertical (Feature Mode)feature_mode_key, default convenience_retail, 9 modesPeakPosConfigurationService.kt:506-517; stored in feature_suite_org_configurations / feature_suite_store_configurations
Feature Level / packagederived join package:<segments> / package:noneFeatureResolutionService.kt:106-114, 270-275
PeakPOS module config (stored)12 flags JSONB: organization, staff, catalog, inventory, sales, customers, operations, compliance, integrations, marketing, kitchen, cashPeakPosConfigurationService.kt:369-384; MerchantIamPermissions.kt:6-17
Feature Suite module config (stored)preset + 19 flags JSONB: accounts, countinghouse, appointments, documentBuilder, tax, reporting, aiAssistant, serviceCatalog, resources, checkins, serviceTickets, engagement, reviews, loyalty, giftCards, marketing, memberships, customerPortal, serviceReportingFeatureSuiteConfigurationService.kt:340-360; presets 308-338
Resolver gatesproductDeniedfeature_level_denial; moduleDeniedorg/store_configuration_denial; permissionDenieduser_iam_denial; plus migration_fallback_unavailableFeatureResolutionService.kt:190-244, 452-458
Contract version2026-06-24.feature-resolution.v2FeatureResolutionService.kt:464

The good news: both axes (tier, vertical) already exist. The expensive, rot-prone thing is the stored module config — which the target deletes and replaces with a derived table.

4. Delta: current → target

#ChangeRisk
D1Collapse peakpos/peaksimple/peakterminal (3 products) → 1 peakpos product + tier attributeMedium — billing + existing rows
D2Delete peakpro/feature-suite as a product; "Full" becomes top PeakPOS tierMedium — billing
D3Delete stored module config (both configuration_modules JSONB sets + the PeakPOS/Feature-Suite config pages) → modules = f(tier, vertical, addons)High — the core change
D4moduleDenied gate reads the derived table, not stored configHigh
D5Make peakgateway an implicit always-on floor, not a grantable productLow
D6peakai already entitlement-driven (#1573); make peakmarketing + new peakkitchen follow the same {present, tier} shapeLow–Medium
D7Retire "product access" + "feature level/package" + "feature suite" vocabulary; Support UI = tier · vertical · addonsLow (UX)
D8Backfill: derive each existing org's {tier, vertical} from current entitlements/config; snapshot pre-migration config for rollbackMedium

5. The heart: f(tier, vertical, addons) → modules

Replace the two stored configuration_modules JSONB blobs with one pure, version-controlled resolution function. Proposed shape:

enabledModules(tier, vertical, addons) =
tierFloor(tier) // always-on for that POS tier
∪ ( verticalModules(vertical) // what this industry wants
∩ tierCeiling(tier) ) // capped by what the tier allows
∪ addonModules(addons) // AI / Marketing / Kitchen unlock their own modules
  • tierFloor — modules the device tier provides; a clean ladder Terminal ⊂ Simple ⊂ POS (see commercial-model doc §2):
    • terminal (Nexgo, payment-only) → organization, staff, sales, cash, operations, reporting, tax (+ payment capabilities: dual pricing, tips; static/bypassable tax; no EBT)
    • simple (tablet + TTP) → terminal ∪ {catalog, customers, inventory, appointments(scheduling), accounts, documentBuilder(invoices), countinghouse} (adds back-office; full tax; no EBT)
    • full (Peak POS, 2-screen + reader) → simple ∪ {serviceReporting} + EBT/PIN-debit capability (capstone)
  • verticalModules — industry module set. Examples:
    • convenience_retail / liquor_store / vape_shopinventory, compliance (age-gate), countinghouse
    • beauty_salon / serviceappointments, resources, serviceCatalog, serviceTickets, checkins, customerPortal
    • qsr_foodservice → kitchen surfaces (only realized if PeakKitchen addon present), tables
    • groceryinventory, countinghouse, reporting
  • tierCeiling — the device cap, linear along the ladder: terminal caps at the payment-only surface (no back-office, no EBT); simple adds back-office but still no EBT (TTP hardware); full caps at "all" and is the only tier exposing the EBT/PIN-debit capability (requires the separate reader).
  • addonModules — addon → modules it unlocks:
    • PeakAIaiAssistant (already entitlement-driven, ignores config)
    • PeakKitchenkitchen / KDS surfaces
    • PeakMarketingmarketing, loyalty, giftCards, engagement, reviews, memberships, customerPortal

The full 3-tier × 9-vertical matrix (each cell a module set) is product-owned data, authored once, code-reviewed, and unit-tested. It lives next to the resolver (a Kotlin table / resource), not in the database.

Resolver after the change

  • productDenied → "is the POS entitled / tier high enough / is this addon present" (no stored status matrix beyond {product → tier}).
  • moduleDenieddefinition.moduleKey !in enabledModules(tier, vertical, addons)derived, no JSONB reads.
  • permissionDenied → unchanged (user IAM).
  • Drop migration_fallback_unavailable once config tables are gone.
  • Bump CONTRACT_VERSION and overlap one release (Android + support read both shapes during rollout).

6. Migration path (reviewable slices)

  1. Derived table + resolver read-path (no deletes). Author f(tier,vertical,addons); make the resolver compute it and compare against current stored config behind a feature flag / shadow-log divergences. No user-visible change.
  2. Collapse POS products → tier attribute (D1). Introduce peakpos.tier ∈ {simple,terminal,full}; map peaksimple/peakterminal rows into peakpos@tier. Keep old keys as read aliases one release.
  3. Fold FeatureSuite into tier (D2). peakpro active → peakpos@full. Delete peakpro as a sellable product; keep package:feature-suite emission for one contract version.
  4. Gateway floor (D5) + addon shape for Marketing/Kitchen (D6).
  5. Flip resolver moduleDenied to derived (D4); delete config pages + stored configuration_modules (D3). Snapshot tables first for rollback.
  6. Support UI to tier·vertical·addons; retire old vocabulary (D7). (Mockup accompanies this doc.)
  7. Backfill (D8) + Android authoritative-resolver re-check + epic closure.
  8. Former-PeakPro parity (#1577) — see §6a; runs in parallel (it ports surfaces, this re-arch governs gating). Each ported surface must land behind the correct matrix module key so the new resolver governs it.

Per CLAUDE.md: commit as separate product-lane branches; all automated/third-party execution (billing sync, exports, notifications) stays in tx-bundler — this change is config/derivation only and adds none.

6a. Former-PeakPro parity workstream (#1577)

Issue #1577 closes the last pixel-for-pixel gaps from the retired PeakPro portal. It is surface work that must be wired to the module keys this re-architecture defines — port each page behind its matrix module so the new f(tier,vertical) resolver governs visibility (never an ad-hoc nav flag). Each item maps to a module/vertical from the commercial-model matrix (§3.1 of peak-commercial-model.md):

#1577 itemRecover from 2bfc32ed1^Lands behind module keyVerticals that light it upAutomation → tx-bundler?
PDF template/block engine (largest)…/src/pdf/workspace.document_builder (Invoices)standard_retail, beauty_salon, service, hybridInteractive preview/download = client-side; batch/email PDF delivery → tx-bundler
Credit-note detail (/credit-notes/:id)CreditNoteDetailPage.tsxworkspace.accounts (Invoices)invoicing verticalsNo
Organization settings (/organization)OrganizationPage.tsxcore organization (always-on)allNo
Jurisdictions + Exemptions viewsJurisdictionsPage.tsx, ExemptionsPage.tsxworkspace.tax (tabs on AccountTaxPage)age/tax-sensitive (conv, vape, liquor, grocery)No
Check-ins page (/check-ins)(PeakPro stub — build fresh)workspace.checkinsbeauty_salon, service, hybrid (per §3.1)No

Notes:

  • Check-ins is fully backend-ready: account_appointments.status includes checked_in; resolver feature workspace.checkins; IAM service_workflow.checkins.{read,write,admin}; AccountServiceWorkflowController exposes the route. Build the real day-view + one-tap check-in (confirmed → checked_in) + complete/no-show, gated featureKey:'workspace.checkins' / requiredAnyPermissions:['service_workflow.checkins.read',…], mutation needs …write. This is the first new surface that exercises a vertical-selected matrix module end-to-end — good Phase-1 validation that the derived model lights up the right pages.
  • Sequencing: #1577 surfaces can ship independently of the billing-collapse phases, but wire them to module keys (not bespoke flags) so Phase 5 (derived moduleDenied) governs them with no rework.
  • Acceptance (from #1577): every former-PeakPro page is present-with-parity or explicitly recorded obsolete (no silent gaps); retail :test+:lint green; migrated-route browser smoke; automated PDF delivery via tx-bundler.

7. Support UI target (what the mockup shows)

One "Plan" editor per org:

  • Tier — segmented control: Simple · Terminal · Full
  • Vertical — dropdown of the 9 feature modes
  • Addons — PeakAI / PeakMarketing / PeakKitchen, each Off or a tier
  • Live preview — derived module list (enabledModules(...)) updates instantly; read-only, proves what the customer will see
  • Gateway — shown as an always-on floor chip, not editable

No "product access" matrix. No config page. No "feature level / package" string. No manual provisioning button.

8. Deferred / open

  • Online billing for Gateway — possibility noted, not in v1. When needed, decide per-capability: gate (model as a tiered addon, resolver-gated) vs meter (always-on, usage-billed in Gateway/tx-bundler, not an entitlement). The Gateway floor stays flat regardless.
  • Exact 3×9 module matrix values — product-owned; this doc defines the structure, not every cell.
  • Tier = device tier on a capability ladder. Per product-owner definitions (commercial-model doc §2): Terminal ⊂ Simple ⊂ POS on different hardware — Simple adds back-office, POS adds EBT (the only EBT tier). The SDK PeakPosProductTier.surfaceSubtitle predates this and omits the EBT distinction — reconcile during implementation.

9. Risks

  • Billing-shaped changes (D1/D2): collapsing products and deleting peakpro touches how plans are sold — coordinate before flipping.
  • Deleting stored config (D3) is irreversible-ish: snapshot + shadow-compare (slice 1) before any delete; keep rollback.
  • Contract bump: verify no other consumer (Android, customer portal) breaks on removed workspace.*/package shapes before deletion.
  • Vertical defaults: existing orgs with no explicit vertical default to convenience_retail; backfill must not silently change a salon's surface — derive vertical from current module config where possible.