Story CP-007: Module Gating Backend (Interceptor + BUDMGT Rule)¶
Module: control-plane Slice: Slice 7a from architecture (split from original CP-007) Brand context: [BOTH] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite Priority: 7 Depends on: CP-001, CP-002 Estimated complexity: M
Objective¶
Implement the core backend revenue-gating mechanism that ensures modules work only for paying tenants. This slice creates the TenantModule entity, the module status tracking, the ModuleGatingInterceptor that enforces module access, the ModuleGatingService, and the BUDMGT auto-inclusion rule. No frontend in this slice — navigation integration and upsell cards are in CP-021.
Backend Scope¶
Entities & Records¶
TenantModule entity (platform DB):
| Field | Type | Constraints |
|---|---|---|
| id | UUID v7 | PK |
| tenant_id | UUID | NOT NULL, FK to Tenant |
| module_code | String | NOT NULL |
| status | ModuleStatus enum | NOT NULL |
| activated_date | Instant | Nullable |
| deactivated_date | Instant | Nullable |
ModuleStatus enum: ACTIVE, INACTIVE, TRIAL
Sellable module codes: HRCORE, EMPMGT, ABSMGT, QRCONTR, TIMACT, EXPMGT, COMMIT, PAYROL, HSEMGT, PERFOB, GPEC, LTRAIN, ATSMGT, ADVDOC, CRMMGT
Non-sellable (auto-included): BUDMGT
Always active (not gated): CP (Control Plane), ORGDATA (Organization & Master Data)
DTOs:
public record TenantModuleResponse(
String moduleCode, String moduleName, ModuleStatus status,
@Nullable Instant activatedDate, @Nullable Instant deactivatedDate,
boolean isSellable, @Nullable String description, @Nullable String price
) {}
public record ModuleCatalogResponse(
List<TenantModuleResponse> subscribedModules,
List<ModuleCatalogEntry> availableModules
) {}
public record ModuleCatalogEntry(
String moduleCode, String moduleName, String description,
String iconName, boolean available, @Nullable String comingSoonDate,
@Nullable String priceForSegment
) {}
Repository Layer¶
TenantModuleRepository.java— Platform-scoped:findByTenantId(UUID) → List<TenantModule>findByTenantIdAndModuleCode(UUID, String) → Optional<TenantModule>save(TenantModule) → TenantModule
Service Layer¶
ModuleGatingService.java:
- isModuleActive(UUID tenantId, String moduleCode) → boolean — Checks TenantModule status. Cached in Redis (TTL: 5 min) with Caffeine fallback (TTL: 1 min).
- getModulesForTenant(UUID tenantId) → List<TenantModuleResponse> — Lists all modules with status
- activateModule(UUID tenantId, String moduleCode) → void — Sets status=ACTIVE. Publishes TenantModuleActivated event. Triggers BUDMGT auto-inclusion check.
- deactivateModule(UUID tenantId, String moduleCode) → void — Sets status=INACTIVE. Publishes TenantModuleDeactivated event. Triggers BUDMGT auto-inclusion check. Data remains readable.
- checkBudgetAutoInclusion(UUID tenantId) → void — IF EXPMGT OR COMMIT is ACTIVE → BUDMGT=ACTIVE. IF neither → BUDMGT=INACTIVE. Recorded in audit with reason "Auto-inclusion rule".
ModuleGatingInterceptor.java (Spring HandlerInterceptor):
- Inspects request path to determine module code (path prefix mapping: /api/v1/absences/** → ABSMGT, etc.)
- Calls ModuleGatingService.isModuleActive() for the current tenant
- IF module inactive → HTTP 403:
{
"error": "MODULE_NOT_SUBSCRIBED",
"module": "PAYROL",
"message": "Module non souscrit. Contactez votre administrateur pour activer ce module."
}
API Endpoints¶
| Method | Path | Request Body | Response | Auth |
|---|---|---|---|---|
| GET | /api/v1/tenant/modules | — | List |
Any authenticated |
Validation Rules¶
- Module code must be a valid sellable module code
- Cannot deactivate CP or ORGDATA (always active)
- BUDMGT cannot be manually activated/deactivated (auto-inclusion only)
Multi-Tenant Considerations¶
- TenantModule is in platform DB but scoped by tenant_id
- Module check cache: Redis key =
module:{tenantId}:{moduleCode}, Caffeine fallback - Cache invalidation on module status change (event-driven)
Frontend Scope¶
No frontend in this slice. Navigation integration and upsell cards are in CP-021.
Pages & Routes¶
N/A — Backend-only slice.
UI States (ALL REQUIRED)¶
N/A — Backend-only slice.
Responsive Behavior¶
N/A
Interactions¶
N/A
Acceptance Criteria¶
AC-052: Module gating blocks inactive module API
Given my tenant has ABSMGT=ACTIVE and PAYROL=INACTIVE
When I call POST /api/v1/payroll/payslips
Then I receive HTTP 403 with {"error": "MODULE_NOT_SUBSCRIBED", "module": "PAYROL"}
AC-053: Active module allows API access
Given my tenant has ABSMGT=ACTIVE
When I call GET /api/v1/absences/leave-requests
Then the request proceeds normally (not blocked by gating)
AC-054: BUDMGT auto-inclusion on EXPMGT activation
Given my tenant has EXPMGT=INACTIVE and BUDMGT=INACTIVE
When EXPMGT is activated
Then BUDMGT is automatically set to ACTIVE
And an audit entry records reason "Auto-inclusion rule"
AC-055: BUDMGT auto-exclusion
Given my tenant has EXPMGT=ACTIVE, COMMIT=INACTIVE, BUDMGT=ACTIVE
When EXPMGT is deactivated
Then BUDMGT is automatically set to INACTIVE
OHADA & Regulatory Rules¶
- No direct OHADA regulatory requirements for module gating.
- Business rule: deactivated modules' data remains readable — tenant cannot lose access to historical data they've already created. This is important for AUDCIF Art. 24 compliance (financial data in deactivated Payroll module must remain accessible for 10 years).
Standards & Conventions¶
docs/standards/java-spring-guidelines.md— §Spring Interceptors, §Redis caching, §Caffeine fallbackdocs/standards/api-guidelines.md— §403 error format for module gating
Testing Requirements¶
Unit Tests¶
- ModuleGatingService: isModuleActive for ACTIVE, INACTIVE, TRIAL
- ModuleGatingService: BUDMGT auto-inclusion logic
- ModuleGatingInterceptor: path-to-module mapping
Integration Tests¶
- Full flow: request to inactive module → 403
- Full flow: request to active module → success
- BUDMGT auto-inclusion on EXPMGT activation
- Cache: module status change → cache invalidated → next request uses new status
- Tenant isolation: module config for tenant A doesn't affect tenant B
What QA Will Validate¶
- Module gating actually blocks access to inactive modules (API-level testing)
Out of Scope¶
- Suspended tenant write denial — CP-020
- Module catalog API — CP-020
- Navigation integration + upsell cards (frontend) — CP-021
- Module activation/deactivation UI — that's in CP-009 (Billing/Subscription management)
- Per-module suspension — data model supports it (V2 enforcement)
- Module dependency warnings on deactivation — CP-009
- Admin module override — CP-012
Definition of Done¶
- [ ] All backend endpoints implemented and returning correct responses
- [ ] All frontend pages render correctly at 360px, 768px, 1024px — N/A (backend-only)
- [ ] All 5 UI states implemented — N/A (backend-only)
- [ ] French micro-copy matches design spec exactly — N/A (backend-only)
- [ ] All acceptance criteria pass manually
- [ ] Unit tests written and passing
- [ ] Integration tests written and passing
- [ ] Multi-tenant isolation verified (no cross-tenant data leaks)
- [ ] Offline write+sync works (Papillon) — N/A
- [ ] No TypeScript
any— N/A (backend-only) - [ ] No Java raw types — all generics explicit
- [ ] API responses follow standard envelope format
- [ ] Audit trail entries created for every data mutation