Skip to content
bitzorcas
中EN

Guide

Billing Entitlements, Features, Quotas, and Usage

Understand plan and Catalog entitlement resolution, the tenant Feature Store, quota decisions, UsageId facts, concurrency, periods, and the missing rating layer.

Last updated

PlatformBilling separates purchased capability from consumed resources through Entitlement, Feature, Quota, and UsageRecord. The current services resolve and enforce hard limits; they are not automatically applied to every business module and do not convert usage into money.

1. Three independent admission layers

required gates pass

Plan.Entitlements

plan OR catalog

Catalog mappings

Entitled

IFeatureStore.IsEnabled

FeatureEnabled

consumer decision

RBAC / ABAC / ReBAC

business use case

The implemented formula is:

Entitled = enabled FeatureCode in Plan
OR enabled Catalog mapping for PlanId + PlanVersion
FeatureEnabled = IFeatureStore.IsEnabledAsync(featureCode, tenantId)

EntitlementResolution returns those booleans separately. It has no EffectiveEnabled property. Consumers normally require Entitled && FeatureEnabled, followed by subject authorization and data scope.

Combine commercial and runtime gates
public static class BillingErrors
{
public static readonly Error EntitlementDenied =
Error.Forbidden("Billing.Entitlement.Denied", "The current plan does not enable this capability.");
}
// Resolve commercial entitlement and the tenant runtime switch as separate facts.
// Keep lookup failures as failures rather than turning them into implicit grants.
var resolution = await resolver.ResolveAsync(
currentUser.User.TenantId,
currentPlan,
"platform.webhooks",
cancellationToken);
if (resolution.IsFailure)
return resolution.Error; // Do not grant access when Catalog lookup fails.
var gate = resolution.Value!;
if (!gate.Entitled || !gate.FeatureEnabled)
return BillingErrors.EntitlementDenied;
// Permission and target-resource scope still run after this gate.

2. Actual cache semantics

The key is:

platform-billing:entitlement:{tenantId}:{planId}:v{planVersion}:{featureCode}

Tenant and plan version prevent direct cross-tenant/version reuse. The cache itself is only a Dictionary in the scoped EntitlementResolver instance. It has no cross-request or cross-node lifetime, TTL, distributed invalidation, or mutation refresh. EnableEntitlementCache=false does not disable it because the resolver does not read PlatformBillingOptions.

If this becomes a distributed cache, invalidation must cover Plan, Catalog mapping, and Feature changes while retaining tenant, PlanVersion, and FeatureCode in the key.

3. Quota decisions

Quota contains MeterCode, Limit, and Unit. QuotaService takes the first matching meter:

  • found: allow when used + requested <= limit; otherwise Reject;
  • absent: allow, report decimal.MaxValue, and use AllowAndRecord.

Unit is metadata only. The service cannot tell 100 GB from 100 bytes. Meter owners must publish immutable normalization and unit rules.

Inspect one quota decision
// Quantity has already been normalized to the meter's documented unit.
// Retain used, requested, and limit values so a rejection remains explainable.
var check = quotaService.Check(plan, meter, "storage.gb", 2.5m);
if (check.IsFailure)
return check.Error;
if (!check.Value!.Allowed)
{
logger.LogWarning(
"Quota rejected: used={Used}, requested={Requested}, limit={Limit}",
check.Value.Used,
check.Value.Requested,
check.Value.Limit);
}

4. RecordUsage path

The command accepts UsageId, MeterCode, and Quantity. TenantId and OccurredAt come from the authenticated context and application clock. The handler loads the subscription and plan, reconstructs the meter from every tenant/meter fact, checks quota, records the fact, and appends missing rows.

Submit retry-safe usage
POST /api/platform-billing/usage HTTP/1.1
Authorization: Bearer <tenant-token>
Content-Type: application/json
{
"usageId": "document-upload:doc-8831:v1",
"meterCode": "storage.gb",
"quantity": 0.125
}

Retries must reuse the same UsageId. A new GUID on each attempt charges each retry. A stable {sourceType}:{sourceId}:{sourceVersion} shape is usually safer.

5. Fact persistence and idempotency scope

UsageMeter is reconstructed by replaying SysPlatformBillingUsageRecord rows ordered by OccurredAt. Saving appends records not already present for that tenant and meter.

Usage fact tableBilling RepositoryRecordUsage HandlerCallerUsage fact tableBilling RepositoryRecordUsage HandlerCallerUsageId + MeterCode + QuantityFindUsageMeter(tenant,meter)list existing factsrowsreconstructed meterquota check + deduplicateSaveUsageMeterappend missing UsageIds

The database key is (TenantId,UsageId), broader than the in-memory meter dictionary. Reusing one UsageId across two meters in a tenant also conflicts. Include MeterCode in the source identifier or guarantee tenant-wide uniqueness.

6. Quota checks are not atomic

The path is read-all → sum → decide → append. Two requests can both see Used=99/Limit=100, each add one, and both pass. Distinct UsageIds bypass the duplicate key, producing 101. The unique constraint prevents duplicate facts, not concurrent oversubscription.

Production hard quotas need a conditional counter, transactional bucket lock, fenced reservation, or a purpose-built usage ledger. Separate hard synchronous rejection from soft allow-and-alert behavior.

Target conditional reservation semantics
-- Illustrative extension; not built into the repository.
UPDATE BillingQuotaBucket
SET Used = Used + @requested, Version = Version + 1
WHERE TenantId = @tenantId
AND MeterCode = @meterCode
AND Period = @period
AND Used + @requested <= Limit;
-- Zero affected rows means a version race or exhausted quota.

7. No billing-period dimension

UsageRecord has no Period. QueryUsage returns the complete history for a meter, and quota uses the all-time sum. Monthly quotas never reset unless callers change the MeterCode or custom cleanup deletes facts. There is no cleanup/archive job or paging, so both writes and reads grow more expensive over time.

A monthly window requires a persisted Period/WindowKey with timezone, boundaries, late-event, correction, and rerating rules. UI filtering alone cannot repair the write-time admission decision.

8. Usage is not rated

GenerateMonthlyInvoice never reads UsageMeter. Usage facts contain no unit price, price tier, currency, plan-price version, free allowance, discount, tax, rounding, closed period, or invoice-line ID. The current product behavior is fixed monthly fee plus an independent hard quota—not usage-based billing.

Introduce immutable RatingResult/InvoiceLine snapshots before adding metered prices. Never recalculate historical invoices using the latest plan price.

9. Operations and release evidence

Useful metrics include accepted/rejected/deduplicated usage, quota-utilization distribution, unknown meters, record latency, fact-row growth, reservation conflicts, entitlement lookup failures/cache hits, and entitlement/Feature mismatches. Avoid raw TenantId and UsageId as unbounded metric labels.

Existing tests prove sequential UsageId deduplication, sequential over-quota rejection, and tenant/version presence in the cache key. Missing tests cover cross-meter UsageId conflicts, both ORMs, concurrent oversubscription, billing windows, late usage, Catalog OR logic, Feature failure, cache mutation, missing quotas, zero limits, large history, HTTP authorization, and cross-tenant isolation.

Back to Billing · Plans and subscriptions · Invoices and idempotency

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%