Appearance
Plans Domain
The Plans module models a plan as a single aggregate split across multiple persistence tables.
Aggregate Sections
Identity
nameinternalAlias(unique)isActivedescriptionisPublicisRecommendedcolor
Pricing
model:SUBSCRIPTIONorONE_TIMEcurrency:MXNorUSDplanPricesetupPricegatewayFeePayer:BUSINESS,CUSTOMER, orAGENCYtaxMode:EXCLUSIVEorINCLUSIVEautoTaxsubscriptionConfig: nullable JSON object that stores monthly, quarterly, semiannual, and annual discount settings when the model is subscription-based; each frequency includesisEnabled, which must be true for that frequency to be selectable when an agency creates a platform-plan subscription (ONE_TIMEbilling only applies whenmodelisONE_TIME)
Resource Limits
The aggregate stores five normalized resource entries and maps them back to a stable object in the API contract:
usersbranchesstorageaiQueriesapiRequests
Each resource stores:
quantity— canonical unit depends onresourceKey:STORAGE→ bytes (persisted asbigintto allow TB/PB quotas)USERS,BRANCHES,AI_QUERIES,API_REQUESTS→ integer count-1is reserved as the "unlimited" sentinel for any resource key
limitType:HARDorSOFTalertThresholdfrom0to100
Why bytes for storage? Keeping the entire storage pipeline in one unit (
StorageStats.totalSize,Document.size, plan quota, upload guards) removes the class of bugs where a byte-sized value is compared against a GB-sized limit. The conversion used to happen ad hoc in application handlers; it now only happens at the human input boundary (admin UI / plan creation payload).
Persistence Shape
The module keeps the data split into three tables inside the plans schema:
plans.plans: main identity row and soft-delete columnplans.plan_pricing: one-to-one pricing row with nullablesubscription_config jsonbplans.plan_resource_limits: one row per resource key
This shape keeps unique and filterable fields relational while using JSONB only where nested subscription-frequency configuration is genuinely document-like.