Skip to content

Feature Limit Tracking (IsTrackingFeature = true)

Overview

The Feature Limit Tracking system enforces per-tenant numeric limits on specific ERP entities (Cost Centers, Banks, Treasuries, Warehouses, etc.) sold as part of a subscription package. It spans two solutions:

  • Microtec.BusinessOwner.sln — Defines limits during tenant provisioning and tracks each assigned entity slot (BoFeatureResource).
  • Microtec.Platforms.sln — Enforces limits at insert time and notifies BusinessOwners when slots are consumed or released.

When IsTrackingFeature = true, the system does more than a simple counter check — it tracks every individual entity assignment against a pre-allocated pool of resource slots, giving the admin portal full visibility into what is using each licensed slot.


Feature Flags Quick Reference

IdNameAppId (Module)IsTrackingFeatureHasLimitIsMenuGated
1Limit Accounts1 (Accounting)falsetruefalse
2Limit Cost Center1 (Accounting)truetruefalse
3Limit Payment In4 (Finance)falsetruefalse
4Limit Payment Out4 (Finance)falsetruefalse
5Limit Treasuries4 (Finance)truetruefalse
6Limit Banks4 (Finance)truetruefalse
7Limit Invoices5 (Sales)falsetruefalse
8Limit Customers5 (Sales)falsetruefalse
9Limit Invoices6 (Purchase)falsetruefalse
10Limit Vendor6 (Purchase)falsetruefalse
11Limit Items7 (Inventory)falsetruefalse
12Limit Warehouse7 (Inventory)truetruefalse
13LandedCost7 (Inventory)truefalsetrue
14MultiCurrency3 (Accounting)truefalsefalse

Source: Src/BusinessOwners/BusinessOwners.Apis/SeedData/SystemFeature.json

IsTrackingFeature vs HasLimit

FlagMeaning
HasLimit = trueA numeric cap is enforced (e.g. max 5 warehouses). BoFeatureSettings.Limit is the ceiling.
IsTrackingFeature = trueEvery individual entity inserted is traced back to a pre-allocated BoFeatureResource slot in the admin DB. The admin portal can see exactly which slot belongs to which entity.
Both can be trueMost limit features: cap enforced + individual slot tracked.
IsTrackingFeature = true, HasLimit = falseFeature toggle style: LandedCost, MultiCurrency — tracked as assigned/unassigned but no numeric cap.

Architecture

┌──────────────────────────────────────────────────────────────────────┐
│              Microtec.BusinessOwner.sln (Admin Portal)               │
│                                                                      │
│  SystemFeature.json ──► SystemFeature entity                         │
│    IsTrackingFeature = true                                          │
│                                                                      │
│  Tenant Provisioning                                                 │
│  BoLicense with BoLicenseResources                                   │
│    │                                                                 │
│    └─► For each unit of a tracking feature:                          │
│            Create BoFeatureResource (FeatureId = null = available)   │
│                                                                      │
│  ◄──────── ErpFeatureIntegrationEvent (RabbitMQ) ───────────────     │
│  AssignedFeatureConsumer                                             │
│    └─► Find BoFeatureResource where FeatureId == null                │
│    └─► UpdateFeature(entityId, nameAr, nameEn) → slot occupied       │
│                                                                      │
│  ◄──────── RevokeErpFeatureIntegrationEvent (RabbitMQ) ─────────     │
│  RevokeFeatureConsumer                                               │
│    └─► Find BoFeatureResource where FeatureId == entityId            │
│    └─► RevokeFeature() → slot released (FeatureId = null again)      │
└──────────────────────────────────────────────────────────────────────┘
                              ▲ │
              Integration     │ │  Integration
              Events          │ │  Events
              (RabbitMQ)      │ ▼
┌──────────────────────────────────────────────────────────────────────┐
│                Microtec.Platforms.sln (AppsPortal / ERP)             │
│                                                                      │
│  Provisioning Consumer                                               │
│    └─► Creates BoFeatureSettings { Limit = N, Used = 0 }             │
│        (one record per tracking feature per tenant)                  │
│                                                                      │
│  On INSERT (e.g. AddCostCenterCommandHandler)                        │
│    1. ValidateFeatureLimitAsync()                                    │
│         └─► BoFeatureSettings.IsLimitReached()? → throw 422          │
│    2. Entity saved to tenant DB                                      │
│    3. boFeatureSetting.IncrementUsedCount()                          │
│    4. SaveChangesAsync()                                             │
│    5. PublishAddErpFeatureAsync() → ErpFeatureIntegrationEvent       │
│                                                                      │
│  On DELETE (e.g. DeleteCostCenterCommandHandler)                     │
│    1. RevokeErpFeatureAsync()                                        │
│         └─► boFeatureSetting.DecrementUsedCount()                   │
│         └─► SaveChangesAsync()                                       │
│         └─► Publish RevokeErpFeatureIntegrationEvent                 │
└──────────────────────────────────────────────────────────────────────┘

Data Model

SystemFeature — Two Forms

There are two different SystemFeature types — one is a DB entity in BusinessOwners, the other is an enum in the shared NuGet package used everywhere else.

FormLocationUsed For
SystemFeature entity classBusinessOwners.Domain/Entities/SystemPackages/SystemFeature.csEF Core persistence in the admin DB (SystemFeature table)
SystemFeature enumMicrotec.Domain.Enums.BusinessOwners (NuGet)Domain logic — .LimitCostCenter, .LimitBanks, .MultiCurrency etc.

In AppsPortal, every reference to SystemFeature.LimitCostCenter or SystemFeature.MultiCurrency is the enum, stored as int in BoFeatureSettings.Feature and BoFeatureResource.Feature.


BusinessOwners DB (Admin)

SystemFeature (entity)

Src/BusinessOwners/BusinessOwners.Domain/Entities/SystemPackages/SystemFeature.cs
csharp
public class SystemFeature : EntityBase<int>
{
    public string Name { get; set; }
    public int AppId { get; set; }
    public bool IsTrackingFeature { get; set; }   // ← subject of this doc
    public bool HasLimit { get; set; }
    public bool IsMenuGated { get; set; }
}

BoFeatureResource

Src/BusinessOwners/BusinessOwners.Domain/Entities/BoLicenses/BoFeatureResource.cs

Pre-allocated slot created at provisioning time. One row = one licensed unit of a tracking feature.

ColumnTypeMeaning
FeatureSystemFeature (enum/FK)Which feature this slot belongs to
ResourceIdGuidLinks back to the BoLicenseResource
FeatureIdstring?ERP entity ID occupying this slot. null = available
FeatureNameArstring?Arabic name of the occupying entity
FeatureNameEnstring?English name of the occupying entity
TenantInfovalue objectTenantId, SubdomainId, Subdomain
IsActiveboolSoft-delete / active flag

Key methods:

csharp
// Occupies the slot when an ERP entity is created
boFeatureResource.UpdateFeature(string id, string nameAr, string nameEn);

// Releases the slot when an ERP entity is deleted
boFeatureResource.RevokeFeature();  // sets FeatureId = null

Tenant DB (AppsPortal)

BoFeatureSettings

Src/AppsPortal/Accounting/AppsPortal.Domain/Entities/GeneralSettings/BoFeatureSettings.cs

One row per feature per tenant. The live counter checked on every insert.

ColumnTypeMeaning
FeatureSystemFeatureWhich feature
LimitintMax allowed (comes from license quantity at provisioning)
UsedintCurrent count of entities created
FeatureAr / FeatureEnstring?Display names

Key methods:

csharp
bool IsLimitReached()       => Used >= Limit;
void IncrementUsedCount()   => Used++;
void DecrementUsedCount()   => if (Used > 0) Used--;

Flow Details

1. Tenant Provisioning (BusinessOwner.sln → Platforms.sln)

Step A — Admin creates resource slots (BusinessOwner.sln)

During tenant setup, BusinessOwnerQuotaService and CreateRealmPersistenceHandler iterate all system features where IsTrackingFeature = true and create one BoFeatureResource row per licensed unit:

Src/BusinessOwners/BusinessOwners.Application/AdminPortal/BusinessOwners/BusinessOwnerQuotaService.cs
  Lines 179–229  — filter IsTrackingFeature, build BoFeatureIntegrationDto

Src/BusinessOwners/BusinessOwners.Application/AdminPortal/TenantProvisioning/StepsPersistence/CreateRealmPersistenceHandler.cs
  Lines 78–81    — retrieve tracking features
  Lines 306–321  — create BoFeatureResource per unit

Src/BusinessOwners/BusinessOwners.Application/AdminPortal/Subdomains/SubdomainService.cs
  Lines 962–967  — resolve trackable feature IDs
  Lines 1025–1042 — build BoFeatureIntegrationDto from license resources

These provisioning steps publish a BoQuotaCreatedIntegrationEvent to RabbitMQ.

Step B — AppsPortal seeds BoFeatureSettings (Platforms.sln)

Src/AppsPortal/Accounting/AppsPortal.Application/Core/BoLicenseSetting/Consumers/BoQuotaCreatedConsumer.cs
Queue: RabbitMqQueues.BoQuotaCreatedQueue

The consumer:

  1. Resolves the tenant's connection string via ITenantProvider
  2. For each feature in BoQuotaCreatedIntegrationEvent.BoFeatures:
    • If BoFeatureSettings already exists for this feature → increments Limit += FeatureCount (license upgrade)
    • If not → creates a new BoFeatureSettings with Limit = FeatureCount, Used = 0
  3. Invalidates the features:disabled:{subdomain} Redis cache
  4. Special case — if MultiCurrency is newly activated: triggers currency and conversion-rate seeders (TenantCurrencySeeder, CurrencyConversionSeeder)
csharp
// BoQuotaCreatedConsumer.cs (excerpt)
foreach (var incomingFeature in createdQuota.BoFeatures)
{
    if (existingFeatures.TryGetValue(incomingFeature.Feature, out var existing))
    {
        existing.Limit += incomingFeature.FeatureCount;   // upgrade: add slots
        featureRepository.Update(existing, ...);
    }
    else
    {
        var feature = new BoFeatureSettings(
            incomingFeature.Feature,
            incomingFeature.FeatureNameAr,
            incomingFeature.FeatureNameEn,
            incomingFeature.FeatureCount);               // initial provisioning
        featureRepository.Add(feature, ...);
    }
}

2. Insert Enforcement (Platforms.sln)

Every command handler for a tracked entity calls IFeatureSettingService.ValidateFeatureLimitAsync() before saving to the database.

Src/AppsPortal/Accounting/AppsPortal.Application/Core/BoFeatureSetting/Services/IFeatureSettingService.cs
Src/AppsPortal/Accounting/AppsPortal.Application/Core/BoFeatureSetting/Services/FeatureSettingService.cs

ValidateFeatureLimitAsync logic:

csharp
var boFeature = await _featureRepository.GetQueryable()
    .Where(x => x.Subdomain == subdomainName && x.Feature == feature)
    .FirstOrDefaultAsync(cancellationToken);

if (boFeature is null)
    return null;          // feature not purchased → no restriction

if (boFeature.IsLimitReached())
    throw new BusinessRuleException(
        ExceptionCodes.InvalidData,
        localizer["GeneralSettings.LicenseLimitExceeded"]);

return boFeature;         // limit not reached → proceed

If boFeature is null: The tenant has not purchased this feature, so the insert is unrestricted (backwards compatible behavior).


3. Post-Insert: Increment + Publish

After the entity is successfully saved:

csharp
// Command handler pattern (e.g. AddCostCenterCommandHandler.cs:61–72)
if (boFeatureSetting is not null)
    boFeatureSetting.IncrementUsedCount();

await unitOfWork.SaveChangesAsync(cancellationToken);

await featureSettingService.PublishAddErpFeatureAsync(
    boFeatureSetting,
    entity.Id.ToString(),
    entity.Name,
    entity.NameAr,
    cancellationToken);

PublishAddErpFeatureAsync sends an ErpFeatureIntegrationEvent to RabbitMQ queue AssignedFeatureQueue.


4. Post-Insert: BusinessOwners Consumes Assignment

Src/BusinessOwners/BusinessOwners.Application/Core/BoFeature/Consumers/AssignedFeatureConsumer.cs
Queue: RabbitMqQueues.AssignedFeatureQueue
csharp
var availableSlot = await _boFeatureResource.GetQueryable()
    .Where(x =>
        x.TenantInfo.Subdomain == featureResource.SubDomainName &&
        x.Feature == featureResource.Feature &&
        x.FeatureId == null &&      // ← finds the next available slot
        x.IsActive)
    .FirstOrDefaultAsync();

availableSlot.UpdateFeature(featureResource.Id, featureResource.NameAr, featureResource.Name);
await unitOfWork.SaveChangesAsync();

After this, the admin portal can see BoFeatureResource.FeatureId = "cost-center-guid" — the slot is occupied.


5. On Delete: Decrement + Revoke

Src/BusinessOwners/BusinessOwners.Application/Core/BoFeature/Consumers/RevokeFeatureConsumer.cs
Queue: RabbitMqQueues.RevokeFeatureQueue
csharp
// AppsPortal (RevokeErpFeatureAsync in FeatureSettingService.cs:85–116)
bofeatureSetting.DecrementUsedCount();
await unitOfWork.SaveChangesAsync();
await publishEndpoint.Publish(new RevokeErpFeatureIntegrationEvent { Id = entityId, Feature = feature, ... });

// BusinessOwners (RevokeFeatureConsumer)
var slot = await _boFeatureResource.GetQueryable()
    .Where(x => x.FeatureId == featureResource.Id && x.Feature == featureResource.Feature)
    .FirstOrDefaultAsync();

slot.RevokeFeature();   // sets FeatureId = null → slot available again
await unitOfWork.SaveChangesAsync();

Command Handlers That Enforce Limits

Insert Handlers

EntityHandlerFeature Checked
Cost CenterAddCostCenterCommandHandler.csSystemFeature.LimitCostCenter (Id: 2)
BankAddBankCommandHandler.csSystemFeature.LimitBanks (Id: 6)
TreasuryAddTreasuryCommandHandler.csSystemFeature.LimitTreasuries (Id: 5)
WarehouseQuickAddWareHouseCommandHandler.csSystemFeature.LimitWarehouse (Id: 12)

All follow the same 5-step pattern:

1. ValidateFeatureLimitAsync()       ← throws if Used >= Limit
2. Map & build entity
3. Add entity to repo
4. boFeatureSetting.IncrementUsedCount()
5. SaveChanges + PublishAddErpFeatureAsync()

Delete Handlers

EntityHandlerAction
Cost CenterDeleteCostCenterCommandHandler.csRevokeErpFeatureAsync(LimitCostCenter)
BankDeleteBankCommandHandler.csRevokeErpFeatureAsync(LimitBanks)
TreasuryDeleteTreasuryCommandHandler.csRevokeErpFeatureAsync(LimitTreasuries)
WarehouseDeleteWareHouseCommandHandler.csRevokeErpFeatureAsync(LimitWarehouse)

FeatureServiceMap (Menu-Gated Features)

Src/AppsPortal/Accounting/AppsPortal.Application/Core/FeatureAccess/FeatureServiceMap.cs

FeatureServiceMap is a singleton that reads SystemFeature.json at startup. It only processes features where IsMenuGated = true and whose Name maps to a value in the Services enum:

csharp
var map = entries
    .Where(e => e.IsMenuGated && Enum.TryParse<Services>(e.Name, out _))
    .ToDictionary(
        e => e.Id,
        e => (int)Enum.Parse<Services>(e.Name)
    );

This builds FeatureIdToServiceId — used by FeatureSettingService.GetDisabledServiceIdsAsync() to determine which service IDs to exclude from the JWT Permissions claim.

Currently only Id 13 (LandedCost) qualifies (IsMenuGated = true + Name matches Services.LandedCost).


Redis Cache

FeatureSettingService caches disabled service IDs (not the limit counters) under:

Key:  features:disabled:{subdomainName}
TTL:  1 hour
Type: HashSet<int>

Cache is explicitly invalidated after every insert (PublishAddErpFeatureAsync) and delete (RevokeErpFeatureAsync) via ClearFeatureCache(subdomain).

The Used counter in BoFeatureSettings is not cached — it is always read fresh from the tenant DB to prevent stale limit checks.


Adding a New Tracking Feature

Step 1 — Seed (BusinessOwner.sln)

Append to Src/BusinessOwners/BusinessOwners.Apis/SeedData/SystemFeature.json:

json
{
  "Id": 15,
  "Name": "Limit YourEntity",
  "AppId": <moduleAppId>,
  "IsTrackingFeature": true,
  "HasLimit": true
}

Step 2 — Constant (Platforms.sln)

Add to the relevant module constants file (e.g. BoFeatureIds.cs):

csharp
public const int LimitYourEntity = 15;

Ensure the SystemFeature enum value matches if using an enum in domain.

Step 3 — Insert Handler

Call ValidateFeatureLimitAsync first, then increment and publish:

csharp
var boFeatureSetting = await featureSettingService
    .ValidateFeatureLimitAsync(
        securityService.CurrentSubdomainName,
        SystemFeature.LimitYourEntity,
        cancellationToken);

// ... create entity ...

if (boFeatureSetting is not null)
    boFeatureSetting.IncrementUsedCount();

await unitOfWork.SaveChangesAsync(cancellationToken);

await featureSettingService.PublishAddErpFeatureAsync(
    boFeatureSetting,
    entity.Id.ToString(),
    entity.NameEn,
    entity.NameAr,
    cancellationToken);

Step 4 — Delete Handler

csharp
await featureSettingService.RevokeErpFeatureAsync(
    SystemFeature.LimitYourEntity,
    entity.Id.ToString(),
    cancellationToken);

Step 5 — Provisioning (BusinessOwner.sln)

No code change needed — the quota service iterates all features where IsTrackingFeature = true dynamically. It will automatically include the new feature and create BoFeatureResource slots during the next tenant provisioning.


Error Handling

When the limit is reached, ValidateFeatureLimitAsync throws:

csharp
throw new BusinessRuleException(
    ExceptionCodes.InvalidData,
    localizer["GeneralSettings.LicenseLimitExceeded"]);

This produces an HTTP 422 Unprocessable Entity response. No partial state is written — the entity is not saved before the check.

If boFeatureSetting is null (feature not purchased by tenant), the insert proceeds without any limit check. This is intentional for backwards compatibility and for tenants on packages that do not include the feature.


Key Files Reference

FilePurpose
Src/BusinessOwners/BusinessOwners.Domain/Entities/SystemPackages/SystemFeature.csEntity with IsTrackingFeature flag
Src/BusinessOwners/BusinessOwners.Apis/SeedData/SystemFeature.jsonFeature seed data — source of truth for which features are tracked
Src/BusinessOwners/BusinessOwners.Domain/Entities/BoLicenses/BoFeatureResource.csPer-slot tracking entity in admin DB
Src/AppsPortal/Accounting/AppsPortal.Domain/Entities/GeneralSettings/BoFeatureSettings.csLimit counter entity in tenant DB
Src/AppsPortal/Accounting/AppsPortal.Application/Core/BoFeatureSetting/Services/IFeatureSettingService.csService interface
Src/AppsPortal/Accounting/AppsPortal.Application/Core/BoFeatureSetting/Services/FeatureSettingService.csLimit enforcement implementation
Src/BusinessOwners/BusinessOwners.Application/Core/BoFeature/Consumers/AssignedFeatureConsumer.csMarks slot as occupied on insert
Src/BusinessOwners/BusinessOwners.Application/Core/BoFeature/Consumers/RevokeFeatureConsumer.csReleases slot on delete
Src/BusinessOwners/BusinessOwners.Application/AdminPortal/BusinessOwners/BusinessOwnerQuotaService.csProvisioning: creates slots
Src/BusinessOwners/BusinessOwners.Application/AdminPortal/TenantProvisioning/StepsPersistence/CreateRealmPersistenceHandler.csProvisioning: step that seeds BoFeatureResource
Src/AppsPortal/Accounting/AppsPortal.Application/Core/BoLicenseSetting/Consumers/BoQuotaCreatedConsumer.csSeeds BoFeatureSettings when quota event arrives from BusinessOwners
Src/AppsPortal/Accounting/AppsPortal.Application/Core/FeatureAccess/FeatureServiceMap.csSingleton that maps IsMenuGated feature IDs → Services enum values for JWT exclusion
Src/BusinessOwners/BusinessOwners.Infrastructure/Migrations/20260215150157_AddBoFeatureResource.csDB migration adding IsTrackingFeature column and BoFeatureResource table

Internal Documentation — Microtec