Business Modules
Overview
The business-modules module manages the many-to-many relationship between a business and the global module catalog. Each record indicates that a specific feature module (e.g., POS, Restaurant, Inventory) is associated with a business, and whether it is currently active.
When a new business is created — or when its industry_segment / business_type is updated — the system automatically syncs the module set to match the business profile via BusinessModulesService.syncModulesForBusinessProfile. Modules an admin has explicitly toggled (is_manual_override = true) are never touched by the sync.
Domain Concepts
| Concept | Description |
|---|---|
| BusinessModule | Join record linking a business to a module. Carries isActive to enable/disable the feature. |
| Module catalog | Global list of available feature modules, seeded at deploy time from packages/backend/database/src/seeds/data/global/module.data.ts. |
| isActive | Soft enable/disable flag. Hard deletes are also supported but uncommon. |
| isManualOverride | When true, an admin explicitly created or disabled this module — the profile-sync algorithm skips this row entirely, preserving admin intent across profile updates. |
| Profile-driven defaults | The resolver resolveBusinessModuleKeys() in packages/global/constants/default-business-modules.ts is the single source of truth. It maps industry_segment + business_type to a de-duplicated set of module keys. See the Resolver rules section. |
Resolver rules
resolveBusinessModuleKeys({ industrySegment, businessType }) lives in packages/global/constants/default-business-modules.ts and is the single source of truth — used by the sync service, the create-event handler, and the backfill script.
Core modules (always on)
settingsModule, billingModule, goodsAndServicesModule, adminModule, accountsReceivableModule, accountsPayableModule, customersModule, reportsModule, communicationModule, dataImportModule
Segment rules
| Module | Enabled when industry_segment is… |
|---|---|
inventoryModule, purchaseModule | any segment except services |
restaurantModule | food_and_beverage only |
serviceBookingsModule | food_and_beverage, services, vehicles_and_dealerships |
productionModule | food_and_beverage, retail_and_goods, health_and_specialty |
projectsModule | services, vehicles_and_dealerships, other |
payrollModule | all segments — except when business_type = mobile_food_vendor |
posModule | food_and_beverage, retail_and_goods, health_and_specialty, sports_and_outdoors, vehicles_and_dealerships |
Business-type overrides (additive)
business_type | Adds |
|---|---|
jewelry_accessories | jewelryModule |
Extending the resolver
To add a new type-specific module:
- Add the module seed row to
packages/backend/database/src/seeds/data/global/module.data.ts. - Run the migration and regenerate types:
pnpm run generate:types. - Add the rule to
resolveBusinessModuleKeys(). - Optionally add a
pwaMenu.jsonentry so the sidebar renders it. - Run
pnpm --filter @flowpos-workspace/global testto verify the resolver matrix.
Sync algorithm
BusinessModulesService.syncModulesForBusinessProfile runs inside a single DB transaction and is idempotent — safe to re-run or double-fire.
- Resolve desired module UUIDs from
moduleSeedDatausingresolveBusinessModuleKeys. - Load all existing
business_modulerows for the business (including inactive ones). - For each desired module: insert if missing, or re-enable if auto-managed and currently inactive.
- For each auto-managed active module not in the desired set: disable it.
- Rows with
isManualOverride = trueare never touched — they preserve explicit admin choices.
Sync triggers
| Event | Code path | Guard |
|---|---|---|
| Business created | handleBusinessCreateEvent (@OnEvent) | — |
| Onboarding step 1 saved | OnboardingOrchestratorService.upsertOnboardingBusiness | update path only |
| Business profile edited | BusinessesService.updateBusiness | only when businessType or industrySegment changed |
Manual override marking
Any admin action that creates or disables a module via the API sets isManualOverride = true on that row, protecting it from future sync operations.
Backfill script
Run once per environment after the is_manual_override migration to align existing businesses with their profiles:
# Dry-run (no writes, logs what would change)
pnpm --filter @flowpos-workspace/backend-scripts run backfill:business-modules:dry-run
# Apply to a single business
pnpm --filter @flowpos-workspace/backend-scripts run backfill:business-modules -- --business-id=<uuid>
# Apply to all businesses
pnpm --filter @flowpos-workspace/backend-scripts run backfill:business-modules
The script imports resolveBusinessModuleKeys directly — no SQL CASE duplication.
Architecture
The module follows strict Hexagonal Architecture:
interfaces/ ← HTTP adapter (controller, DTOs, query)
application/ ← Use cases (service)
domain/ ← Port interface + injection token (framework-agnostic)
infrastructure/ ← Kysely repository (DB adapter)
Dependency flow
BusinessModulesController
↓
BusinessModulesService
↓ (injected via BUSINESS_MODULES_REPOSITORY token)
IBusinessModulesRepository ← BusinessModulesRepository (Kysely)
The service depends on the IBusinessModulesRepository interface (domain port), not the concrete Kysely implementation. The binding is configured in BusinessModulesModule using a NestJS custom provider with the BUSINESS_MODULES_REPOSITORY symbol token.
Key design decisions
- Interface-based injection — The service is decoupled from Kysely via
@Inject(BUSINESS_MODULES_REPOSITORY). This enables adapter swaps and testing with mocks. - Domain purity — The domain port contains no Kysely types (
Transaction,SimplifySingleResult). Return types use plain TypeScript (T | undefinedinstead ofSimplifySingleResult<T>). - Audit fields from auth token —
createdByandupdatedByare resolved from the bearer token via the@UserId()decorator. They are not accepted in the request body, preventing client impersonation. SortableBusinessModuleKeyis defined in the domain layer (not the interface layer) to keep the port free of UI concerns.createdAt/updatedAtare server-managed; they are not accepted in the create/update DTOs.
Main Use Cases
List active business modules
GET /business-modules?businessId=<uuid>&size=50
Returns all active (isActive=true) module associations for a business. The businessId filter is required in practice; without it the query returns all records across all businesses.
Create a business module association
POST /business-modules
Manually associates a module with a business. Most modules are seeded automatically on business creation — this endpoint is primarily for admin or migration use. The createdBy field is set from the authenticated user's token.
Body:
{
"businessId": "550e8400-e29b-41d4-a716-446655440001",
"moduleId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
Get a business module by ID
GET /business-modules/:id
Returns a single business module record. Returns 404 if not found.
Update a business module
PATCH /business-modules/:id
Partially updates any field on the association (e.g., toggling isActive). The updatedBy field is set from the authenticated user's token.
Body (example — toggle off):
{ "isActive": false }
Disable a business module (soft delete)
PATCH /business-modules/disable-module/:id?businessId=<uuid>
Sets isActive = false. The acting user is resolved from the auth token. Returns the updated record including the related module entity.
Prefer this over hard delete when the module may need to be re-enabled later.
Delete a business module (hard delete)
DELETE /business-modules/:id
Permanently removes the association. Returns 204 No Content. Cannot be undone.
API Endpoints
| Method | Path | Status | Description |
|---|---|---|---|
POST | /business-modules | 201 | Create a business module association |
GET | /business-modules | 200 | List active modules for a business (paginated) |
GET | /business-modules/:id | 200 | Get by ID |
PATCH | /business-modules/:id | 200 | Update (partial) |
PATCH | /business-modules/disable-module/:id | 200 | Soft-disable (sets isActive=false) |
DELETE | /business-modules/:id | 204 | Hard delete |
All endpoints require a valid Firebase bearer token (Authorization: Bearer <token> or flowpos-id-token cookie).
Bruno API Collection
Requests are in api-client/flowpos/collections/business-modules/.
| File | Operation |
|---|---|
list business modules.yml | GET /business-modules |
create business module.yml | POST /business-modules |
get business module by id.yml | GET /business-modules/:id |
update business module.yml | PATCH /business-modules/:id |
disable business module.yml | PATCH /business-modules/disable-module/:id |
delete business module.yml | DELETE /business-modules/:id |
Related
- Businesses module — emits
business.createon new business creation - Module seed data