(motir-core) The custom-domain entitlement — the `custom_domains` kind, `maxCustomDomains` per tier, and the org-locked cap assert in entitlementsService
Opened by Zhu Yue ·
The tier gate the story reads instead of hard-coding a plan — a new entitlement kind, its per-tier cap, and the org-locked assert the lifecycle calls before it creates a custom domain. The story's own criterion: "the capability reads the tier gate rather than hardcoding a plan, and the gate's name is cited". This card is that gate; the ADR Q8 names it.
What ships
lib/billing/entitlements.ts—EntitlementKindgains'custom_domains';PmEntitlementsgainsmaxCustomDomains: number | null;PM_ENTITLEMENTSgains the value on everyPmTierrow with the ADR's provisional numbers (free: 0·scaledand the paid rows as Q8 states ·enterprise/meta:null), each row commented as seed policy owned bybilling-tiering.md.PM_ENTITLEMENTSisRecord<PmTier, PmEntitlements>, so a missing row is a compile error — that is the totality this leans on.lib/services/entitlementsService.ts—assertCanAddCustomDomain(organizationId, tx): inert off-cloud (isCloudBilling()false ⇒ return, as every sibling cap does);lockOrgRowOrRefuse(organizationId, tx)FIRST — a cap that cannot serialize is not a cap (MOTIR-3710's lesson, in the file's own header); resolve the tier throughpmTierForOrg(the one chokepoint — the meta org's exemption arrives for free); count the org'scustom_domainrows under the lock through a newpublicAddressRepository.countCustomDomainsForOrganization(organizationId, tx)(added here to the store's repository — one Prisma op, joinsproject.workspace.organizationId); throwEntitlementExceededError('custom_domains', …)at the cap. The caller must run it inside the same transaction as the create it guards — say so in the JSDoc exactly as the siblings do.- Totality downstream: every
Record<EntitlementKind, …>in the tree gains the member — grep for the type and list the hits in the PR (the upgrade-prompt copy map the billing UI keys offEntitlementExceededError.entitlementis the one that matters: 8.1.7 / 8.1.8's prompt must render a sentence forcustom_domains, inenandzh). - Tests: the new row in the table-driven
PM_ENTITLEMENTStest;assertCanAddCustomDomainoff-cloud is a no-op; at the cap it throws withentitlement: 'custom_domains'; a real-concurrency test — two adds atcap − 1under a warm pool produce exactly one success (the warm-pool TOCTOU the service header describes); the meta org is never capped.
Boundary
No lifecycle logic, no route, no UI (the lifecycle calls this; part 2 of the pane renders the refusal). The NUMBERS are not this card's to argue — they are the ADR's provisional values and billing-tiering.md's to change.
Acceptance criteria
EntitlementKindincludes'custom_domains',PmEntitlementsincludesmaxCustomDomains, and everyPmTierrow ofPM_ENTITLEMENTScarries the ADR Q8 value;pnpm typecheckis what proves totality.assertCanAddCustomDomainlocks the org row, counts under the lock, and throwsEntitlementExceededErrorwithentitlement === 'custom_domains'at the cap; off-cloud it performs no read.- Two concurrent adds at
cap − 1yield one success and oneEntitlementExceededErrorunder a real connection pool. - Every
Record<EntitlementKind, …>in the tree compiles with the new member, and the upgrade prompt has anenand azhsentence for it. - No file outside
motir-coreis touched; the only repository method added is the count.
Context refs
motir-core/lib/billing/entitlements.ts—PmTier,EntitlementKind,PmEntitlements,PM_ENTITLEMENTS,pmTierForOrgmotir-core/lib/services/entitlementsService.ts—lockOrgRowOrRefuse, the count-cap pattern, the MOTIR-3710 / MOTIR-3717 headermotir-core/lib/billing/errors.ts—EntitlementExceededError;lib/dto/billing.ts— the kinds the UI mapsmotir-core/docs/decisions/billing-tiering.md§4 — the caps and who owns their values- the decision — Q8; the store — the repository the count is added to
Discussion
No comments yet.
Adding to this discussion signs you in on app.motir.co and brings you back to this request.