13. Package Limit Enforcement
13.1 What this chapter covers
How the five Shumoul Starter package limits (Chapter 7) — Users,
Branches, POS Devices, Sales Invoices/month, Products — went from "seeded and queryable" to "seeded,
queryable, and actually enforced" for tenants creating business records in Shumoul.Api.
13.2 Architecture — where the rule lives vs. where usage is counted
Two databases are involved, and no data was duplicated between them:
- Rule definition —
SubscriptionPackageFeatureRule(RowCounter,EntityName,ApplyPeriod) lives in the central Saas control-plane database, owned byShumoul.Framework.MultiTenancy.Api(Shumoul.Saas.MultiTenancyApi). See Chapter 3. - Usage counting — the actual
SalesInvoice/ApplicationUser/Branch/Device/Productrows live in each tenant's own business database, owned byShumoul.Api(Shumoul.Saas.Api).
Confirmed before implementing: TenantDbContext (the context hosting SubscriptionPackageFeatureRule)
is a single, fixed-connection-string context — it never switches per ambient tenant like
ApplicationDbContext does — so it always resolves to the one central Saas database regardless of
which customer tenant is the current caller. Combined with the fact that Shumoul.Api already shares
one DI container with Shumoul.Framework.MultiTenancy.Api (AddInfrustracture() calls
AddMultiTenancy()), this meant no HTTP bridge was needed — a new BackEnd service can directly
constructor-inject a new, minimal MultiTenancyApi interface, exactly like the pre-existing
TenantCurrentSubscriptionService already does with ITenantSubscriptionService.
Operational service (e.g. ProductService.CreateAsync)
→ ISubscriptionPackageLimitEnforcementService.ThrowIfLimitExceededAsync("Products")
→ ITenantSubscriptionService.GetTenantPackageId(tenantId) [existing, central Saas DB]
→ ISubscriptionPackageLimitService.GetEntityLimitAsync(tenantId, "Products") [NEW, central Saas DB]
→ IRepositoryAsync.Query<Product>().Count(...) [tenant's own business DB]
→ throws ValidationException if current usage + increment > RowCounter
13.3 Why the rule resolution is package-scoped, not feature-scoped
SubscriptionPackageFeatureRule.FeatureId points at SubscriptionPackageFeature.Id — a package+
feature join row — never at SubscriptionFeature.Id directly. The same SubscriptionFeature (e.g.
Features.ProductsManagement) is included in both Shumoul Starter and Shumoul POS, producing two
separate SubscriptionPackageFeature rows, each with its own Id. The new
ISubscriptionPackageLimitService.GetEntityLimitAsync(tenantId, entityName) resolves the tenant's
specific active package first (ITenantSubscriptionService.GetTenantPackageId), then only looks at
SubscriptionPackageFeature rows scoped to that exact PackageId, before matching a rule. This makes
it structurally impossible for one package's limit to leak onto a tenant on a different package — even
one sharing the same underlying feature. Verified by dedicated tests (see
§13.6) including two packages sharing the same feature with different (or no) rules.
13.4 New services
| Service | Repo | Role |
|---|---|---|
ISubscriptionPackageLimitService | MultiTenancyApi | Resolves the effective (RowCounter, ApplyPeriod) for a tenant's current package + EntityName, or null for unlimited. Package-scoped resolution (§13.3). |
ISubscriptionPackageLimitEnforcementService | BackEnd | GetCurrentUsageAsync, CanCreateAsync (non-throwing), ThrowIfLimitExceededAsync (throws ValidationException — the same exception type every other validation failure in this codebase already uses). Counts usage via the tenant-scoped IRepositoryAsync, honoring the existing global soft-delete filter automatically. |
Shumoul.Domain.Constants.SubscriptionLimitEntityNames centralizes the five EntityName string
constants (Users, Branches, Devices, SalesInvoices, Products) — operational services reference
the constant, never a literal string, and no limit value is ever hardcoded anywhere in an operational
service.
13.5 Where enforcement is wired (8 call sites, 5 services)
| Entity | Service | Method(s) | Increment |
|---|---|---|---|
| Sales invoices (Monthly) | SalesInvoiceService | CreateAsync, CreateListAsync, CreateFromQuotationAsync, CreateFromReservationAsync | 1 each, except CreateListAsync which checks the full batch size at once |
| Users (total) | UserService | CreateAsync | 1 |
| Branches (total) | BranchService | CreateAsync | 1 |
| POS Devices (total) | DeviceService | CreateAsync | 1 |
| Products (total) | ProductService | CreateAsync | 1 |
Every call site places the check immediately before _repository.BeginTransaction(), after existing
entity-reference validation — mirroring the codebase's own established Errors.Add(...) → throw new ValidationException(Errors) idiom exactly, so a blocked create fails in the same shape as any other
validation error a client already knows how to handle.
Counting rules per entity, exactly as decided:
- Monthly (
SalesInvoices): counted from the first day of the current calendar month (server time) — there is no separate tenant-billing-month concept anywhere in this codebase today, so calendar month is the documented convention. - WhenSubscribing (all other four): counted as a total across all non-deleted rows, no date filter.
- The global soft-delete query filter (
ISoftDelete) already excludes deleted rows for every one of these entities automatically — no extraIs_Deletedpredicate was added. - Updating an existing row is never counted as a new create — the check only ever counts rows that already exist; it plays no role in update flows at all.
- The tenant's seeded initial admin user and seeded "Main Branch" (both created during tenant provisioning, before any package-limit check ever runs) correctly count as slot #1 for Users/Branches respectively — confirmed both by unit test and live verification.
ProductCatalogItemTemplateApplyService(the Starter Kit's bulk product-catalog apply path) was deliberately not wired — Starter Kit behavior is out of scope for this task by explicit instruction. This is safe in practice: the RetailStore starter template creates only 2 products, well under any package's product limit (5 for Starter) — confirmed by inspectingProductCatalogTemplateItems.jsondirectly before this decision was made.
13.6 Tests
42 new tests across both repos (7 rule-resolution in MultiTenancyApi + 17 enforcement-decision + 2 fixed pre-existing call sites in BackEnd — see the exact counts in §13.8):
Shumoul.Framework.MultiTenancy.Test.PackageLimits.SubscriptionPackageLimitServiceShould(7 tests) — Starter's 5-product rule resolves correctly; POS (same feature, no rule) resolves unlimited; Finance (feature not included at all) resolves unlimited; two packages sharing a feature never leak a rule across each other; no active subscription resolves unlimited at this layer (the enforcement service is what turns "no subscription" into a hard block — see next); unknown entity name resolves unlimited; an inactive rule row is ignored.Shumoul.Application.Tests.ServicesTests.SubscriptionPackageLimitEnforcementServiceTests(17 tests) — no active subscription throws; no rule for the entity does not throw; the engine enforces whateverRowCounterit is given (never a hardcoded Starter-specific number); Products 4/5 boundary; Users (seeded admin counts as #1, second blocked, first allowed for a fresh tenant); Branches (seeded branch counts as #1, second blocked); Devices 0/1 boundary; Sales Invoices 9/10 monthly boundary, previous-month invoices excluded, bulk-create-list checks the full increment at once; the non-throwingCanCreateAsyncconvenience API.
13.7 User-facing error
{
"code": "Validation_Errors",
"statusCode": 422,
"succeeded": false,
"messages": ["Package limit exceeded: maximum 1 Branches."]
}
This is the exact, existing Shumoul.Framework.Application.Exceptions.ValidationException shape every
other validation failure in this codebase already returns (HTTP 422, Validation_Errors code) — no new
response style was invented. No internal rule/package implementation detail, stack trace, or raw SQL
ever reaches the client.
13.8 Live dev verification
Reused the existing Shumoul Starter test tenant (956470, from the package blueprint phase) —
registered, verified, onboarded already. Verified live via real HTTP calls against the local dev API and
dev database (no production access):
| Limit | Live result |
|---|---|
| Branches | POST api/v1/Branch/Create for a second branch → 422, "Package limit exceeded: maximum 1 Branches." — confirmed the tenant's seeded "Main Branch" already counts as branch #1. |
| Users | POST api/Users/Create for a second user → 422, "Package limit exceeded: maximum 1 Users." — confirmed the seeded admin already counts as user #1. |
| POS Devices | POST api/v1/Device/Create → 422, "Package limit exceeded: maximum 1 Devices." — the tenant already had a device registered from earlier work in this project, so the "first device allowed" half of the boundary was not independently re-demonstrated live here; the exact 0→1 / 1→2 boundary is covered by the enforcement service's own unit tests instead. |
| Products | Not exercised live — ProductEditDto requires several pre-existing reference rows (BusinessTypeId, DepartmentId, CategoryId, UnitOfMeasureId) not readily available for this tenant without substantial additional setup. Covered by the enforcement service's unit tests (4/5 boundary) instead. |
| Sales Invoices | Not exercised live — creating 10 real invoices requires a fully configured POS/sales setup (customer, tax, pricing) and was judged too heavy for this verification pass, per this task's own allowance to substitute focused tests when a live create is too heavy. Covered by the monthly-boundary, previous-month-exclusion, and bulk-create unit tests instead. |
13.9 How to add a new limited entity safely
- Confirm the
EntityNamevalue already exists (or add it) toShumoul.Domain.Constants.SubscriptionLimitEntityNames— never a raw string literal in an operational service. - Add a
casetoSubscriptionPackageLimitEnforcementService's internalCountUsageAsyncswitch, using_repository.Query<TEntity>()(notGetCountAsync<TEntity>if the entity does not derive fromBaseEntity— e.g.ApplicationUserderives fromIdentityUser<Guid>instead). - Call
ISubscriptionPackageLimitEnforcementService.ThrowIfLimitExceededAsync(EntityName)once, immediately before_repository.BeginTransaction(), in every code path that creates a new row of that entity — check for multiple creation paths (bulk create, create-from-X convenience endpoints) the same way this task found 4 separateSalesInvoicecreation methods. - Seed the actual
SubscriptionPackageFeatureRulerow for the package that should have this limit (see Chapter 4 for the seeder pattern — remember the legacySeedHistory-gated seeders never re-run). - Add rule-resolution tests (MultiTenancyApi) and enforcement-decision tests (BackEnd) mirroring §13.6 — especially a same-feature-different-package non-leak test if the entity's feature is shared across packages.
13.10 Production deployment notes
No migration, no schema change. The new services are pure C#/DI additions plus a PackageReference
version bump (Shumoul.Framework.MultiTenancy.Api 1.0.117 → 1.0.118) — both repos build and their full
test suites pass with zero new failures relative to the pre-existing baselines. No production DB access
was used or required for this phase; all verification ran against the dev database.
13.11 Production bug fix — false "No active subscription" on Add/Create (2026-07-20)
Symptom: Add/Create (Device, Product — any of the 8 call sites in §13.5) failed with "No active subscription was found for this tenant" for tenants that demonstrably had one; Edit/Update was unaffected (it never calls this service at all — only Create paths do, per §13.5).
Root cause: ThrowIfLimitExceededAsync/CanCreateAsync gated purely on
ITenantSubscriptionService.GetTenantPackageId(tenantId) returning non-null. That method resolves
TenantSubscription.PlanPackageFK.PackageId via EF Include — if the tenant's Active TenantSubscription
row's PackageId no longer resolves to a real SubscriptionPlanPackage row (a dangling reference — the
same class of data-integrity defect the
Dev/Staging Subscription Remediation tool exists to repair,
just never run against every affected tenant), the Include silently returns a null navigation, and
GetTenantPackageId returns null — indistinguishable, at that method's boundary, from "genuinely no
active subscription at all." The join chain itself (TenantSubscription.PackageId → SubscriptionPlanPackage.Id → SubscriptionPlanPackage.PackageId → SubscriptionPackage.Id) was and remains correct; this was a
false-negative in how a broken link partway through that chain got reported, not a wrong join.
Fix (BackEnd, SubscriptionPackageLimitEnforcementService): both methods now resolve the tenant's
TenantSubscription (via the existing GetTenantActiveSubscription) before attempting package
resolution. A missing subscription row still throws/returns false exactly as before. A subscription row
that exists but whose package can't be resolved is now logged as a data-integrity warning and treated
as "no limit rule for this call" (unenforceable) — consistent with the pre-existing "no rule found ⇒
unlimited" convention already used when a package genuinely has no SubscriptionPackageFeatureRule for
that entity — rather than blocking a tenant with a demonstrably active subscription from a routine Create.
Error text updated to "No active subscription was found for the selected tenant.", matching the wording
used elsewhere in this guide.
No architecture change, no new call sites, no MultiTenancyApi change — the fix is entirely inside the one shared BackEnd resolver every Create call site already funnels through (§13.5), so no screen was patched individually.
