Enterprise edition
Billing — Deep Reference
At a glance
Section titled “At a glance”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.
Availability & licensing
Section titled “Availability & licensing”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.
Public API surface
Section titled “Public API surface”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.
| Symbol | Parameters | Default behavior | Returns | Throws or fails with | Notes |
|---|---|---|---|---|---|
SaaSPlan (enum) | — | String-backed plan tiers: standard, advanced, high_control | — | Does not throw | label() returns the display name |
PlanDefinition::__construct | SaaSPlan $plan, float $includedCuQuota, list<CapabilityCode> $capabilities, non-empty-string $priceTier, bool $intelligencePackIncluded, bool $privacyPackIncluded | Immutable plan value object; stores inputs as given | New instance | Does not throw | final readonly; promoted public properties |
PlanDefinition::includesCapability | CapabilityCode $capability | Strict identity membership check | bool | Does not throw | — |
PlanRegistry::__construct | list<PlanDefinition> $definitions | Indexes definitions by tier; the last definition per tier wins | New registry | Does not throw | For tests and white-label plan sets |
PlanRegistry::get | SaaSPlan $plan | Canonical plan lookup | PlanDefinition | InvalidArgumentException when the plan is not registered | — |
PlanRegistry::has | SaaSPlan $plan | Registration probe | bool | Does 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 Packs | PlanRegistry | Does not throw | Use unless contractual terms require custom definitions |
OveragePolicy (enum) | — | hard_stop, soft_stop, budget_alert | — | Does not throw | httpStatusCode() maps 402 / 429 / 200; isBlocking() is true for hard and soft stop only |
QuotaManager::__construct | PlanRegistry $planRegistry, OveragePolicy $overagePolicy | Binds the registry to one policy | New instance | Does not throw | — |
QuotaManager::checkQuota | TenantContext $tenant, SaaSPlan $plan, float $currentCu | Returns silently at or under quota, or under a non-blocking policy | void | QuotaExceededException on strict overage under a blocking policy; InvalidArgumentException from the registry on an unregistered plan | resetsAt = first day of next month, midnight UTC |
QuotaManager::remainingQuota | SaaSPlan $plan, float $currentCu | Pure read; never blocks | float | Registry InvalidArgumentException | Negative in overage |
QuotaManager::usagePercentage | SaaSPlan $plan, float $currentCu | Pure read; never blocks | float | Registry InvalidArgumentException | 0.0 when the included quota is non-positive; above 1.0 in overage |
OverageCalculator::calculate | PlanDefinition $plan, float $currentCu | Computes an immutable overage snapshot | OverageResult | Does not throw | final readonly, stateless |
OverageResult | includedCu, usedCu, overageCu, usageRatio, isOverage | Immutable calculation result | — | Does not throw | overageCu = max(0, used - included); isOverage requires strict overage |
BillingAlertType (enum) | — | quota_warning_80, quota_warning_100, budget_exceeded, monthly_cap_reached | — | Does not throw | threshold() 0.8 / 1.0 / 1.0 / 1.0; severity() warning / critical / critical / critical |
BillingAlertService::__construct | AlertStateRepositoryInterface $alertState | Binds the deduplication store | New instance | Does not throw | — |
BillingAlertService::evaluate | TenantContext $tenant, SaaSPlan $plan, PlanDefinition $planDef, float $currentCu | Fires not-yet-fired alerts in ascending threshold order and records them | list<BillingAlertType> | InvalidArgumentException on plan/definition mismatch | Dedup key: tenant, type, UTC YYYY-MM period |
BillingAlertService::clearAlerts | TenantContext $tenant | Clears the tenant’s fired-state for the current UTC period | void | Repository-defined failures propagate | Re-arms alerts within the same period |
AlertStateRepositoryInterface | hasAlertFired(), markAlertFired(), clearForPeriod() | Durable alert-deduplication persistence contract | Per method | Implementation-defined | Operator owns durability across replicas |
InMemoryAlertStateRepository | — | Array-backed fired-state | Per interface | Does not throw | Single-request lifecycles and tests only |
QuotaExceededException | Readonly currentCu, limitCu, resetsAt, tenantId, isSaaS | Deployment-mode-aware quota denial | — | Is the throwable | httpStatusCode() 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_development | — | Does not throw | Substrate. enforcesQuota() is true only for Saas; opt-out is always explicit |
QuotaEnforcementGuard::__construct | DeploymentMode, PlanResolverInterface, QuotaManager, UsageCounterStoreInterface | Assembles the live quota gate | New instance | Does not throw | Substrate. final readonly |
QuotaEnforcementGuard::enforce | ?TenantContext $tenant, non-empty-string $featureKey, float $amount = 1.0 | Fail-closed quota gate with atomic reservation | QuotaDecision (allowed outcomes only) | See the denial taxonomy below | Substrate. Mount after tenant authentication, before the billable handler |
PlanResolverInterface::resolve | TenantContext $tenant | Resolves a tenant to its plan and per-feature policies | ResolvedPlan | NoPlanForTenantException | Substrate. A default-plan fallback for unknown tenants is a defect |
RegistryPlanResolver | array<non-empty-string, ResolvedPlan> $plansByTenant | Map-backed resolver | ResolvedPlan | NoPlanForTenantException for unmapped tenants | Substrate. Fail-closed by construction |
ResolvedPlan::policyFor | non-empty-string $featureKey | Policy lookup on the resolved plan | ?QuotaPolicy | Does not throw | Substrate. null means unknown feature; the guard denies it |
QuotaPolicy | non-empty-string $featureKey, float $limit, OveragePolicy $overagePolicy | Per-feature limit and breach policy | — | Does not throw | Substrate. UNLIMITED = -1.0; a 0.0 limit is zero allowance, not unlimited; isUnlimited(), isBlocking() |
QuotaDecision | Statics bypassed(), unlimited(), consumed() | Allowed-outcome value object | QuotaDecision | Does not throw | Substrate. isAllowed() is always true; every denial throws instead |
UsageCounter | Row snapshot: tenant, feature, period bounds, used, limit, updatedAt | Immutable usage row | — | Does not throw | Substrate. remaining() may be negative; wouldExceed() is strict |
UsageCounterStoreInterface::get | Tenant, feature, period bounds, float $limit | Reads the usage row, creating it with used = 0 when absent | UsageCounter | UsageStoreUnavailableException | Substrate. Never returns a falsy value on backend failure |
UsageCounterStoreInterface::tryConsume | Tenant, feature, period bounds, float $amount, float $limit | Atomic compare-and-set reservation within the limit | ?UsageCounter (null when the reservation would breach the limit) | UsageStoreUnavailableException | Substrate. Must be a single atomic operation against the backing store |
InMemoryUsageCounterStore | — | In-process reference implementation of the store contract | Per interface | Per interface | Substrate. Single process only; documents the atomicity invariant |
QuotaEnforcementException (abstract) | — | Base type of every substrate denial | — | Is the throwable family | Substrate. Each subtype declares httpStatusCode() |
public function checkQuota(TenantContext $tenant, SaaSPlan $plan, float $currentCu): voidpublic function evaluate( TenantContext $tenant, SaaSPlan $plan, PlanDefinition $planDef, float $currentCu,): arraypublic function enforce(?TenantContext $tenant, string $featureKey, float $amount = 1.0): QuotaDecisionpublic function tryConsume( string $tenantId, string $featureKey, DateTimeImmutable $periodStart, DateTimeImmutable $periodEnd, float $amount, float $limit,): ?UsageCounter;Denial taxonomy of QuotaEnforcementGuard::enforce
| Exception | HTTP status | Raised when |
|---|---|---|
MissingTenantContextException | 401 | SaaS mode with no authenticated tenant context |
NoPlanForTenantException | 402 | The resolver finds no plan assigned to the tenant |
UnknownFeatureException | 402 | The resolved plan defines no policy for the feature key |
UsageStoreUnavailableException | 503 | The usage store cannot be read or atomically updated; also raised for a non-positive $amount |
QuotaExceededException | 402 (SaaS) / 403 (on-prem) | A blocking policy’s quota is exceeded, or a concurrent reservation consumed the last headroom |
Behavior contract
Section titled “Behavior contract”- 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()andusagePercentage()are pure reads and never block. Remaining quota goes negative in overage; the usage percentage exceeds1.0in 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-MMperiod. 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.
QuotaEnforcementGuardis 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-SaaSDeploymentMode.- 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 receivesQuotaExceededExceptioneven 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.
QuotaExceededExceptionis deployment-mode-aware: SaaS denials map to HTTP 402 with spec codeSPEC-BILLING-003and are marked retryable; on-prem denials map to HTTP 403 withSPEC-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.
Edge cases & failure modes
Section titled “Edge cases & failure modes”- Non-positive included quota.
usagePercentage(),evaluate(), andOverageCalculator::calculate()all yield a0.0usage 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
OverageResultor the alert stream. - Exactly at the limit.
checkQuota()atcurrentCu == includedCuQuotapasses.BudgetExceededrequires strict overage.UsageCounter::wouldExceed()is strict as well. MonthlyCapReached. The enum declares this fourth alert type, butBillingAlertService::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.
PlanRegistryindexes by tier value; the last definition for a tier silently replaces earlier ones. Construct registries from a deduplicated list. - Zero-allowance versus unlimited. A
QuotaPolicylimit of0.0means every consumption in the period is overage. Only the negativeUNLIMITEDsentinel disables metering;isUnlimited()never blocks. - Non-positive reservation amount.
enforce()denies a non-positive$amountfail-closed withUsageStoreUnavailableException(503). This is a caller defect, not a store outage. - Store outage. Any read or reservation failure surfaces as
UsageStoreUnavailableExceptionand denies. The guard never allows unmetered work while the meter is down. - In-memory implementations.
InMemoryAlertStateRepositoryandInMemoryUsageCounterStoreare 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.
Conformance
Section titled “Conformance”| Claim | Standard | Clause |
|---|---|---|
| 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.
Development notes
Section titled “Development notes”- Compose the model from
PlanRegistry::defaultRegistry(), oneOveragePolicy, and aQuotaManager; addBillingAlertServicewith a durableAlertStateRepositoryInterfaceimplementation for alerting. - Mount
QuotaEnforcementGuardin the request pipeline after tenant authentication and before the billable handler. CatchQuotaEnforcementExceptionand the billingQuotaExceededExceptionat the edge and maphttpStatusCode()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().
Publication boundary
Section titled “Publication boundary”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.