Appearance
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
| Id | Name | AppId (Module) | IsTrackingFeature | HasLimit | IsMenuGated |
|---|---|---|---|---|---|
| 1 | Limit Accounts | 1 (Accounting) | false | true | false |
| 2 | Limit Cost Center | 1 (Accounting) | true | true | false |
| 3 | Limit Payment In | 4 (Finance) | false | true | false |
| 4 | Limit Payment Out | 4 (Finance) | false | true | false |
| 5 | Limit Treasuries | 4 (Finance) | true | true | false |
| 6 | Limit Banks | 4 (Finance) | true | true | false |
| 7 | Limit Invoices | 5 (Sales) | false | true | false |
| 8 | Limit Customers | 5 (Sales) | false | true | false |
| 9 | Limit Invoices | 6 (Purchase) | false | true | false |
| 10 | Limit Vendor | 6 (Purchase) | false | true | false |
| 11 | Limit Items | 7 (Inventory) | false | true | false |
| 12 | Limit Warehouse | 7 (Inventory) | true | true | false |
| 13 | LandedCost | 7 (Inventory) | true | false | true |
| 14 | MultiCurrency | 3 (Accounting) | true | false | false |
Source:
Src/BusinessOwners/BusinessOwners.Apis/SeedData/SystemFeature.json
IsTrackingFeature vs HasLimit
| Flag | Meaning |
|---|---|
HasLimit = true | A numeric cap is enforced (e.g. max 5 warehouses). BoFeatureSettings.Limit is the ceiling. |
IsTrackingFeature = true | Every 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 true | Most limit features: cap enforced + individual slot tracked. |
IsTrackingFeature = true, HasLimit = false | Feature 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
SystemFeaturetypes — one is a DB entity in BusinessOwners, the other is an enum in the shared NuGet package used everywhere else.
| Form | Location | Used For |
|---|---|---|
SystemFeature entity class | BusinessOwners.Domain/Entities/SystemPackages/SystemFeature.cs | EF Core persistence in the admin DB (SystemFeature table) |
SystemFeature enum | Microtec.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.cscsharp
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.csPre-allocated slot created at provisioning time. One row = one licensed unit of a tracking feature.
| Column | Type | Meaning |
|---|---|---|
Feature | SystemFeature (enum/FK) | Which feature this slot belongs to |
ResourceId | Guid | Links back to the BoLicenseResource |
FeatureId | string? | ERP entity ID occupying this slot. null = available |
FeatureNameAr | string? | Arabic name of the occupying entity |
FeatureNameEn | string? | English name of the occupying entity |
TenantInfo | value object | TenantId, SubdomainId, Subdomain |
IsActive | bool | Soft-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 = nullTenant DB (AppsPortal)
BoFeatureSettings
Src/AppsPortal/Accounting/AppsPortal.Domain/Entities/GeneralSettings/BoFeatureSettings.csOne row per feature per tenant. The live counter checked on every insert.
| Column | Type | Meaning |
|---|---|---|
Feature | SystemFeature | Which feature |
Limit | int | Max allowed (comes from license quantity at provisioning) |
Used | int | Current count of entities created |
FeatureAr / FeatureEn | string? | 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 resourcesThese 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.BoQuotaCreatedQueueThe consumer:
- Resolves the tenant's connection string via
ITenantProvider - For each feature in
BoQuotaCreatedIntegrationEvent.BoFeatures:- If
BoFeatureSettingsalready exists for this feature → incrementsLimit += FeatureCount(license upgrade) - If not → creates a new
BoFeatureSettingswithLimit = FeatureCount,Used = 0
- If
- Invalidates the
features:disabled:{subdomain}Redis cache - Special case — if
MultiCurrencyis 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.csValidateFeatureLimitAsync 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 → proceedIf 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.AssignedFeatureQueuecsharp
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.RevokeFeatureQueuecsharp
// 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
| Entity | Handler | Feature Checked |
|---|---|---|
| Cost Center | AddCostCenterCommandHandler.cs | SystemFeature.LimitCostCenter (Id: 2) |
| Bank | AddBankCommandHandler.cs | SystemFeature.LimitBanks (Id: 6) |
| Treasury | AddTreasuryCommandHandler.cs | SystemFeature.LimitTreasuries (Id: 5) |
| Warehouse | QuickAddWareHouseCommandHandler.cs | SystemFeature.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
| Entity | Handler | Action |
|---|---|---|
| Cost Center | DeleteCostCenterCommandHandler.cs | RevokeErpFeatureAsync(LimitCostCenter) |
| Bank | DeleteBankCommandHandler.cs | RevokeErpFeatureAsync(LimitBanks) |
| Treasury | DeleteTreasuryCommandHandler.cs | RevokeErpFeatureAsync(LimitTreasuries) |
| Warehouse | DeleteWareHouseCommandHandler.cs | RevokeErpFeatureAsync(LimitWarehouse) |
FeatureServiceMap (Menu-Gated Features)
Src/AppsPortal/Accounting/AppsPortal.Application/Core/FeatureAccess/FeatureServiceMap.csFeatureServiceMap 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
Usedcounter inBoFeatureSettingsis 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
| File | Purpose |
|---|---|
Src/BusinessOwners/BusinessOwners.Domain/Entities/SystemPackages/SystemFeature.cs | Entity with IsTrackingFeature flag |
Src/BusinessOwners/BusinessOwners.Apis/SeedData/SystemFeature.json | Feature seed data — source of truth for which features are tracked |
Src/BusinessOwners/BusinessOwners.Domain/Entities/BoLicenses/BoFeatureResource.cs | Per-slot tracking entity in admin DB |
Src/AppsPortal/Accounting/AppsPortal.Domain/Entities/GeneralSettings/BoFeatureSettings.cs | Limit counter entity in tenant DB |
Src/AppsPortal/Accounting/AppsPortal.Application/Core/BoFeatureSetting/Services/IFeatureSettingService.cs | Service interface |
Src/AppsPortal/Accounting/AppsPortal.Application/Core/BoFeatureSetting/Services/FeatureSettingService.cs | Limit enforcement implementation |
Src/BusinessOwners/BusinessOwners.Application/Core/BoFeature/Consumers/AssignedFeatureConsumer.cs | Marks slot as occupied on insert |
Src/BusinessOwners/BusinessOwners.Application/Core/BoFeature/Consumers/RevokeFeatureConsumer.cs | Releases slot on delete |
Src/BusinessOwners/BusinessOwners.Application/AdminPortal/BusinessOwners/BusinessOwnerQuotaService.cs | Provisioning: creates slots |
Src/BusinessOwners/BusinessOwners.Application/AdminPortal/TenantProvisioning/StepsPersistence/CreateRealmPersistenceHandler.cs | Provisioning: step that seeds BoFeatureResource |
Src/AppsPortal/Accounting/AppsPortal.Application/Core/BoLicenseSetting/Consumers/BoQuotaCreatedConsumer.cs | Seeds BoFeatureSettings when quota event arrives from BusinessOwners |
Src/AppsPortal/Accounting/AppsPortal.Application/Core/FeatureAccess/FeatureServiceMap.cs | Singleton that maps IsMenuGated feature IDs → Services enum values for JWT exclusion |
Src/BusinessOwners/BusinessOwners.Infrastructure/Migrations/20260215150157_AddBoFeatureResource.cs | DB migration adding IsTrackingFeature column and BoFeatureResource table |