Skip to content
getnextpdf.com

Enterprise edition

Billing — Deep Reference

This page is the deep reference for the billing surface of NextPDF Enterprise. The surface has two layers. The billing model in NextPDF\Enterprise\Billing defines plan tiers, quotas, overage policies, and deduplicated usage alerts. The enforcement substrate in NextPDF\Enterprise\Billing\Substrate places that model on the live request path, fail-closed and concurrency-safe. Entry points are PlanRegistry, QuotaManager, OverageCalculator, BillingAlertService, and QuotaEnforcementGuard. For the workflow-level guide, see the Billing capability page.

This capability ships in NextPDF Enterprise (nextpdf/enterprise) and activates with an Enterprise-tier license envelope. A deployment without that entitlement does not load the capability’s classes. Compare editions and get a license.

Billing is a base Enterprise capability with no separate per-feature flag; it is available once the Enterprise package is installed next to the Core package. NextPDF Core (Apache-2.0) and NextPDF Pro have no plan, quota, or overage model; this surface has no lower-tier equivalent. Plan inclusions, quotas, and commercial terms are governed by the license agreement, not by runtime enforcement; this reference is not a legal or contractual opinion.

All symbols live under NextPDF\Enterprise\Billing. Rows marked substrate live under NextPDF\Enterprise\Billing\Substrate. TenantContext is the authenticated tenant type from NextPDF\Enterprise\SaaS.

SymbolParametersDefault behaviorReturnsThrows or fails withNotes
SaaSPlan (enum)String-backed plan tiers: standard, advanced, high_controlDoes not throwlabel() returns the display name
PlanDefinition::__constructSaaSPlan $plan, float $includedCuQuota, list<CapabilityCode> $capabilities, non-empty-string $priceTier, bool $intelligencePackIncluded, bool $privacyPackIncludedImmutable plan value object; stores inputs as givenNew instanceDoes not throwfinal readonly; promoted public properties
PlanDefinition::includesCapabilityCapabilityCode $capabilityStrict identity membership checkboolDoes not throw
PlanRegistry::__constructlist<PlanDefinition> $definitionsIndexes definitions by tier; the last definition per tier winsNew registryDoes not throwFor tests and white-label plan sets
PlanRegistry::getSaaSPlan $planCanonical plan lookupPlanDefinitionInvalidArgumentException when the plan is not registered
PlanRegistry::hasSaaSPlan $planRegistration probeboolDoes not throw
PlanRegistry::defaultRegistry (static)Production defaults: Standard 1,000 CU; Advanced 5,000 CU plus Intelligence Pack; High Control 20,000 CU plus Intelligence and Privacy PacksPlanRegistryDoes not throwUse unless contractual terms require custom definitions
OveragePolicy (enum)hard_stop, soft_stop, budget_alertDoes not throwhttpStatusCode() maps 402 / 429 / 200; isBlocking() is true for hard and soft stop only
QuotaManager::__constructPlanRegistry $planRegistry, OveragePolicy $overagePolicyBinds the registry to one policyNew instanceDoes not throw
QuotaManager::checkQuotaTenantContext $tenant, SaaSPlan $plan, float $currentCuReturns silently at or under quota, or under a non-blocking policyvoidQuotaExceededException on strict overage under a blocking policy; InvalidArgumentException from the registry on an unregistered planresetsAt = first day of next month, midnight UTC
QuotaManager::remainingQuotaSaaSPlan $plan, float $currentCuPure read; never blocksfloatRegistry InvalidArgumentExceptionNegative in overage
QuotaManager::usagePercentageSaaSPlan $plan, float $currentCuPure read; never blocksfloatRegistry InvalidArgumentException0.0 when the included quota is non-positive; above 1.0 in overage
OverageCalculator::calculatePlanDefinition $plan, float $currentCuComputes an immutable overage snapshotOverageResultDoes not throwfinal readonly, stateless
OverageResultincludedCu, usedCu, overageCu, usageRatio, isOverageImmutable calculation resultDoes not throwoverageCu = max(0, used - included); isOverage requires strict overage
BillingAlertType (enum)quota_warning_80, quota_warning_100, budget_exceeded, monthly_cap_reachedDoes not throwthreshold() 0.8 / 1.0 / 1.0 / 1.0; severity() warning / critical / critical / critical
BillingAlertService::__constructAlertStateRepositoryInterface $alertStateBinds the deduplication storeNew instanceDoes not throw
BillingAlertService::evaluateTenantContext $tenant, SaaSPlan $plan, PlanDefinition $planDef, float $currentCuFires not-yet-fired alerts in ascending threshold order and records themlist<BillingAlertType>InvalidArgumentException on plan/definition mismatchDedup key: tenant, type, UTC YYYY-MM period
BillingAlertService::clearAlertsTenantContext $tenantClears the tenant’s fired-state for the current UTC periodvoidRepository-defined failures propagateRe-arms alerts within the same period
AlertStateRepositoryInterfacehasAlertFired(), markAlertFired(), clearForPeriod()Durable alert-deduplication persistence contractPer methodImplementation-definedOperator owns durability across replicas
InMemoryAlertStateRepositoryArray-backed fired-statePer interfaceDoes not throwSingle-request lifecycles and tests only
QuotaExceededExceptionReadonly currentCu, limitCu, resetsAt, tenantId, isSaaSDeployment-mode-aware quota denialIs the throwablehttpStatusCode() 402 SaaS / 403 on-prem; specCode() SPEC-BILLING-003 / SPEC-LIC-001; toErrorEnvelope() yields a structured error body
DeploymentMode (enum)saas, self_hosted_oss, local_developmentDoes not throwSubstrate. enforcesQuota() is true only for Saas; opt-out is always explicit
QuotaEnforcementGuard::__constructDeploymentMode, PlanResolverInterface, QuotaManager, UsageCounterStoreInterfaceAssembles the live quota gateNew instanceDoes not throwSubstrate. final readonly
QuotaEnforcementGuard::enforce?TenantContext $tenant, non-empty-string $featureKey, float $amount = 1.0Fail-closed quota gate with atomic reservationQuotaDecision (allowed outcomes only)See the denial taxonomy belowSubstrate. Mount after tenant authentication, before the billable handler
PlanResolverInterface::resolveTenantContext $tenantResolves a tenant to its plan and per-feature policiesResolvedPlanNoPlanForTenantExceptionSubstrate. A default-plan fallback for unknown tenants is a defect
RegistryPlanResolverarray<non-empty-string, ResolvedPlan> $plansByTenantMap-backed resolverResolvedPlanNoPlanForTenantException for unmapped tenantsSubstrate. Fail-closed by construction
ResolvedPlan::policyFornon-empty-string $featureKeyPolicy lookup on the resolved plan?QuotaPolicyDoes not throwSubstrate. null means unknown feature; the guard denies it
QuotaPolicynon-empty-string $featureKey, float $limit, OveragePolicy $overagePolicyPer-feature limit and breach policyDoes not throwSubstrate. UNLIMITED = -1.0; a 0.0 limit is zero allowance, not unlimited; isUnlimited(), isBlocking()
QuotaDecisionStatics bypassed(), unlimited(), consumed()Allowed-outcome value objectQuotaDecisionDoes not throwSubstrate. isAllowed() is always true; every denial throws instead
UsageCounterRow snapshot: tenant, feature, period bounds, used, limit, updatedAtImmutable usage rowDoes not throwSubstrate. remaining() may be negative; wouldExceed() is strict
UsageCounterStoreInterface::getTenant, feature, period bounds, float $limitReads the usage row, creating it with used = 0 when absentUsageCounterUsageStoreUnavailableExceptionSubstrate. Never returns a falsy value on backend failure
UsageCounterStoreInterface::tryConsumeTenant, feature, period bounds, float $amount, float $limitAtomic compare-and-set reservation within the limit?UsageCounter (null when the reservation would breach the limit)UsageStoreUnavailableExceptionSubstrate. Must be a single atomic operation against the backing store
InMemoryUsageCounterStoreIn-process reference implementation of the store contractPer interfacePer interfaceSubstrate. Single process only; documents the atomicity invariant
QuotaEnforcementException (abstract)Base type of every substrate denialIs the throwable familySubstrate. Each subtype declares httpStatusCode()
public function checkQuota(TenantContext $tenant, SaaSPlan $plan, float $currentCu): void
public function evaluate(
TenantContext $tenant,
SaaSPlan $plan,
PlanDefinition $planDef,
float $currentCu,
): array
public function enforce(?TenantContext $tenant, string $featureKey, float $amount = 1.0): QuotaDecision
public function tryConsume(
string $tenantId,
string $featureKey,
DateTimeImmutable $periodStart,
DateTimeImmutable $periodEnd,
float $amount,
float $limit,
): ?UsageCounter;

Denial taxonomy of QuotaEnforcementGuard::enforce

ExceptionHTTP statusRaised when
MissingTenantContextException401SaaS mode with no authenticated tenant context
NoPlanForTenantException402The resolver finds no plan assigned to the tenant
UnknownFeatureException402The resolved plan defines no policy for the feature key
UsageStoreUnavailableException503The usage store cannot be read or atomically updated; also raised for a non-positive $amount
QuotaExceededException402 (SaaS) / 403 (on-prem)A blocking policy’s quota is exceeded, or a concurrent reservation consumed the last headroom
  • The default registry ships three tiers (Standard / Advanced / High Control) with increasing CU quotas and capability sets. An unregistered plan request fails with an explicit InvalidArgumentException.
  • QuotaManager::checkQuota() raises only when both conditions hold: the policy is blocking, and current usage is strictly above the included quota. A budget-alert policy never raises; overage is signalled through alerts.
  • remainingQuota() and usagePercentage() are pure reads and never block. Remaining quota goes negative in overage; the usage percentage exceeds 1.0 in overage.
  • Alerts evaluate in ascending threshold order: 80% warning, 100% warning (critical), then budget-exceeded (critical). Budget-exceeded is gated on strict overage; usage at exactly 100% fires the 100% warning, not budget-exceeded.
  • Each alert type fires at most once per tenant per billing period. The fired-state is recorded through AlertStateRepositoryInterface, so deduplication is as durable as the chosen implementation.
  • The deduplication key embeds the UTC YYYY-MM period. A new calendar month therefore re-arms every alert type automatically; no clear call is required for rollover re-arming. clearAlerts() clears the current period, which re-arms alerts mid-period, for example after a plan upgrade.
  • A plan-mismatch guard in evaluate() rejects a call where the supplied plan and plan definition disagree, protecting against a definition from a different tier than the tenant’s plan.
  • All period arithmetic is anchored to UTC. The quota-exceeded reset instant is the first day of the next calendar month at midnight UTC; a soft-stop response should advertise it as the retry horizon.
  • QuotaEnforcementGuard is fail-closed in SaaS mode. Missing tenant, missing plan, unknown feature, store outage, and quota breach all deny; nothing falls through to an implicit allow. Non-SaaS deployments opt out only by constructing the guard with a non-SaaS DeploymentMode.
  • Blocking policies reserve usage through UsageCounterStoreInterface::tryConsume, an atomic compare-and-set. Concurrent requests cannot collectively push usage past the limit; the loser of the race receives QuotaExceededException even though the pre-check passed.
  • Under a budget-alert policy the guard records consumption best-effort and never denies; a reservation past the soft ceiling still records the row at the limit.
  • QuotaExceededException is deployment-mode-aware: SaaS denials map to HTTP 402 with spec code SPEC-BILLING-003 and are marked retryable; on-prem denials map to HTTP 403 with SPEC-LIC-001.
  • The library does not emit HTTP responses itself. The declared status codes are the contract for the edge layer, which maps a thrown denial to a response and must not invoke the billable handler.
  • Non-positive included quota. usagePercentage(), evaluate(), and OverageCalculator::calculate() all yield a 0.0 usage ratio instead of dividing by zero. Threshold alerts then never fire from ratio alone.
  • Budget-alert plus large overage. The manager and the guard both return allowed outcomes. Do not treat the absence of an exception as proof of being within quota; consult OverageResult or the alert stream.
  • Exactly at the limit. checkQuota() at currentCu == includedCuQuota passes. BudgetExceeded requires strict overage. UsageCounter::wouldExceed() is strict as well.
  • MonthlyCapReached. The enum declares this fourth alert type, but BillingAlertService::evaluate() never emits it; its candidate list covers only the three threshold alerts. It is reserved for cap-tracking emitters outside this module.
  • Duplicate tier definitions. PlanRegistry indexes by tier value; the last definition for a tier silently replaces earlier ones. Construct registries from a deduplicated list.
  • Zero-allowance versus unlimited. A QuotaPolicy limit of 0.0 means every consumption in the period is overage. Only the negative UNLIMITED sentinel disables metering; isUnlimited() never blocks.
  • Non-positive reservation amount. enforce() denies a non-positive $amount fail-closed with UsageStoreUnavailableException (503). This is a caller defect, not a store outage.
  • Store outage. Any read or reservation failure surfaces as UsageStoreUnavailableException and denies. The guard never allows unmetered work while the meter is down.
  • In-memory implementations. InMemoryAlertStateRepository and InMemoryUsageCounterStore are correct within one PHP process only. Multi-replica deployments must supply implementations backed by a datastore with real atomicity; a read-then-write store is a defect that permits over-quota under load.
  • FIPS mode. Billing performs no cryptographic operations of its own and has no FIPS-specific behavior. Tenant identity it consumes must originate from an authenticated context whose FIPS posture is documented with the SaaS surface.
ClaimStandardClause
The 402 status code is reserved for future use; it carries no normative request semantics of its own.RFC 9110§15.5.3
429 indicates the client has sent too many requests in a given amount of time (“rate limiting”).RFC 6585§4
Retry-After indicates how long the user agent ought to wait before making a follow-up request.RFC 9110§10.2.3

All clauses are paraphrased; NextPDF does not reproduce normative text. The 402 / 429 / 200 mapping declared by OveragePolicy::httpStatusCode() and the guard’s 401 / 402 / 503 denial codes are a product convention aligned with the clauses above: RFC 9110 reserves 402, so its payment-denial use here is the common industry convention, not an IETF-defined semantic. The soft-stop retry horizon (resetsAt) is the value an edge layer should surface as Retry-After guidance. Emitting actual HTTP responses, headers, and caching behavior is the responsibility of the hosting application.

  • Compose the model from PlanRegistry::defaultRegistry(), one OveragePolicy, and a QuotaManager; add BillingAlertService with a durable AlertStateRepositoryInterface implementation for alerting.
  • Mount QuotaEnforcementGuard in the request pipeline after tenant authentication and before the billable handler. Catch QuotaEnforcementException and the billing QuotaExceededException at the edge and map httpStatusCode() to the response.
  • Plan definitions in this module are the single source of truth for billing; do not maintain a parallel billing definition elsewhere in your deployment.
  • The in-memory implementations make the whole surface unit-testable without I/O. Recommended boundary tests: usage exactly at the quota, one unit above, ratio thresholds at 0.8 and 1.0, the plan-mismatch guard, the CAS race (two reservations against the last unit of headroom), and store-outage denial.
  • The core model classes carry @since 2.2.0; the substrate carries @since 2.3.0. The current package line is 3.1.0.
  • The operator owns the alert-state repository and usage-store implementations, their durability across replicas, and any mid-period alert re-arming through clearAlerts().

This page documents externally observable behavior and the supported public API surface only. Internal namespace paths, helper classes, mechanism tables, runbook filenames, and ticket prefixes are out of scope.