ADR-003: Tenant Provisioning & Operator Lifecycle
Status: Proposed — pending architect approval Impacts:
schema-backoffice.md,domain-driven-design.md,workflow-orchestration.md
Context
The Operator entity is the root tenant, but no document specifies how the system creates a new tenant. Signup, manager user creation, default resource seeding, and third-party integration setup (Mollie, DPA) appear across multiple docs but no single provisioning contract consolidates them. Additionally, the operator lifecycle (ONBOARDING → ACTIVE → SUSPENDED → CHURNED) has no transition specifications, authorization rules, or edge state handling. A coding agent has no spec to implement operator onboarding or lifecycle management.
Decision
Provisioning Flow
Tenant provisioning uses an atomic NestJS command handler (ProvisionTenant) invoked via a Hasura Action. Nhost Auth manages authentication — the command handler creates the Nhost user and the Busflow domain records in a single flow.
Synchronous (within transaction)
| Step | Action | Details |
|---|---|---|
| 1 | Create Nhost user | Via Nhost Admin SDK. Email + password handled by Nhost Auth. Returns nhost_user_id. |
| 2 | Insert operators | Status: ONBOARDING. Core fields: name, legal_name, country, default_locale, default_currency. |
| 3 | Link Nhost user | Step 1 created the auth.users row. No separate Busflow users table exists. The Nhost user ID serves as FK in all downstream tables. |
| 4 | Insert user_tenant_assignments | default_role = MANAGER for the signup user → new operator. |
| 5 | Seed notification_templates | Clone default templates (booking confirmation, pre-trip reminder, delay broadcast) with tenant_id = new_operator.id. |
| 6 | Insert operator_integrations | { tenant_id, integration_type: MOLLIE, status: PENDING }. |
NOTE
Channel accounts are NOT seeded during provisioning. Channel registration (WhatsApp, Email, SMS) requires operator action — the system cannot automate Meta Business Verification and SES domain verification at signup. The operator completes channel setup post-provisioning via the Workspace Channel Setup wizard. See channel-provisioning-protocol.md.
Asynchronous (post-commit, event-driven)
| Step | Trigger | Action |
|---|---|---|
| 7 | TenantProvisioned event | Create Mollie sub-merchant (if payment integration enabled). On success → update operator_integrations.status = CONNECTED. |
| 8 | TenantProvisioned event | Generate Data Processing Agreement (DPA) document. |
| 9 | First manager login (Nhost last_seen) | Transition operator status: ONBOARDING → ACTIVE. |
Error Handling
- Steps 1–6 run within a single database transaction (Nhost user creation uses compensating logic on rollback — delete Nhost user if domain inserts fail). The operator never reaches a partial state.
- Steps 7–8 are idempotent and retryable. The system logs failures and retries via BullMQ dead-letter queue (3 retries, exponential backoff). After exhaustion → alert on Busflow support dashboard.
- A tenant in
ONBOARDINGstatus is fully functional for backoffice use. Async failures do not block operator access.
Hasura Action Contracts
ProvisionTenant
| Property | Type | Required | Description |
|---|---|---|---|
| Input | |||
operator_name | String | ✅ | Company trading name |
legal_name | String | ✅ | Registered legal company name |
country | String | ✅ | ISO 3166-1 alpha-2 |
admin_email | String | ✅ | Signup user email (passed to Nhost Auth for user creation) |
admin_password | String | ✅ | Signup user password (passed to Nhost Auth — never stored by Busflow) |
default_locale | String | Default: de-DE | |
default_currency | String | Default: EUR | |
vat_id | String | Optional at signup. Required before first invoice generation (enforced at application layer). | |
legal_form | String | FK to legal_forms.id. Optional. | |
| Output | |||
operator_id | UUID | ✅ | Created operator |
user_id | UUID | ✅ | Created manager user (= Nhost user ID) |
session | Object | ✅ | Nhost session object ({ accessToken, refreshToken, accessTokenExpiresIn }) |
| Errors | |||
EMAIL_ALREADY_EXISTS | admin_email matches an existing Nhost user. Message does not reveal whether the email belongs to the same or a different company. | ||
INVALID_COUNTRY | country is not a valid ISO 3166-1 alpha-2 code. | ||
VALIDATION_ERROR | Generic input validation failure. Includes field and message. |
NOTE
The response includes a Nhost session so the operator lands directly in the workspace after signup — no separate login step required. Subsequent logins go through the standard Nhost Auth flow.
SuspendTenant
| Property | Type | Required | Description |
|---|---|---|---|
| Input | |||
operator_id | UUID | ✅ | The tenant to suspend |
reason | String | ✅ | Suspension reason (logged in change_events) |
| Output | |||
success | Boolean | ✅ | |
| Errors | |||
OPERATOR_NOT_FOUND | No operator with this ID | ||
INVALID_STATUS | Operator is not in ACTIVE status | ||
INSUFFICIENT_PERMISSIONS | Caller is not busflow_staff |
Side effects: Emit TenantSuspended { operator_id, reason } → Commerce hides all PUBLISHED TourOfferings for this tenant. Block new bookings. Existing bookings remain valid. Notify all tenant admins via email. Log in change_events.
ReactivateTenant
| Property | Type | Required | Description |
|---|---|---|---|
| Input | |||
operator_id | UUID | ✅ | The tenant to reactivate |
| Output | |||
success | Boolean | ✅ | |
| Errors | |||
OPERATOR_NOT_FOUND | No operator with this ID | ||
INVALID_STATUS | Operator is not in SUSPENDED status | ||
INSUFFICIENT_PERMISSIONS | Caller is not busflow_staff |
Side effects: Emit TenantReactivated { operator_id } → Commerce re-publishes previously hidden TourOfferings. Notify all tenant admins. Log in change_events.
ChurnTenant
| Property | Type | Required | Description |
|---|---|---|---|
| Input | |||
operator_id | UUID | ✅ | The tenant to churn |
reason | String | ✅ | Churn reason: VOLUNTARY_CANCELLATION, GDPR_DELETION, NON_PAYMENT |
| Output | |||
success | Boolean | ✅ | |
cancelled_departures | Int | ✅ | Number of future departures cancelled |
affected_bookings | Int | ✅ | Number of bookings entering cancellation |
| Errors | |||
OPERATOR_NOT_FOUND | No operator with this ID | ||
INVALID_STATUS | Operator is not in ACTIVE or SUSPENDED status | ||
INSUFFICIENT_PERMISSIONS | Caller is not busflow_staff | ||
IN_PROGRESS_DEPARTURES | Operator has departures with start_date = CURRENT_DATE. Returned as warning — the system defers the churn until these complete. Returns { departure_ids: UUID[] }. |
Saga: (1) Cancel all future TourDepartures with status ∈ {DRAFT, READY, PUBLISHED} and start_date > CURRENT_DATE. (2) Emit TenantChurned { operator_id, reason } → Commerce cancels affected bookings (status → CANCELLED), initiates Mollie refunds via storno workflow (invoice_cancellations + payments.payment_type = REFUND). (3) Deactivate all tenant users (status → DEACTIVATED). (4) Schedule GDPR data retention countdown (see gdpr-strategy.md). (5) Log in change_events.
Lifecycle Transitions
Operator status state machine:
stateDiagram-v2
[*] --> ONBOARDING : ProvisionTenant
ONBOARDING --> ACTIVE : First login (Nhost last_seen)
ACTIVE --> SUSPENDED : SuspendTenant
SUSPENDED --> ACTIVE : ReactivateTenant
ACTIVE --> CHURNED : ChurnTenant
SUSPENDED --> CHURNED : ChurnTenant / auto (90 days)
CHURNED --> [*]| # | Transition | Trigger | Mechanism | Authorization | Side Effects |
|---|---|---|---|---|---|
| 1 | ONBOARDING → ACTIVE | First manager login | Hasura Event Trigger on Nhost auth.users.last_seen. When last_seen changes from NULL to a timestamp, the NestJS handler resolves the user's tenant via user_tenant_assignments and checks operators.status = ONBOARDING. If true → the handler transitions the operator to ACTIVE. | System (automatic) | Emit TenantActivated { operator_id }. Log in change_events. |
| 2 | ACTIVE → SUSPENDED | Non-payment or manager action | Hasura Action SuspendTenant (see contract above) | busflow_staff only | See SuspendTenant contract. |
| 3 | SUSPENDED → ACTIVE | Payment resolved or manager action | Hasura Action ReactivateTenant (see contract above) | busflow_staff only | See ReactivateTenant contract. |
| 4 | ACTIVE → CHURNED | Voluntary cancellation or GDPR deletion | Hasura Action ChurnTenant (see contract above) | busflow_staff only (MVP) | See ChurnTenant contract. |
| 5 | SUSPENDED → CHURNED | Extended non-payment (auto: 90 days) or manual | Hasura Scheduled Trigger (auto) or ChurnTenant Action (manual) | System (auto) / busflow_staff (manual) | Same as transition #4. |
WARNING
In-progress departures (departure date = today, passengers may already have boarded) complete normally — churn/suspension takes effect after completion. The saga must filter tour_departures.start_date > CURRENT_DATE when cancelling.
Edge State Handling
| # | Edge Case | Decision | Mechanism |
|---|---|---|---|
| 1 | Duplicate signup (same email) | Reject with EMAIL_ALREADY_EXISTS. Do not reveal whether the email belongs to the same or a different company. | Nhost Auth rejects duplicate emails. NestJS catches and wraps as EMAIL_ALREADY_EXISTS. |
| 2 | Concurrent provisioning (two signups, same company name) | Allow. Company names are not unique — two operators can have the same trading name. Uniqueness applies to users.email, not operators.name. Email uniqueness blocks the same person from signing up twice. | No additional constraint. |
| 3 | Template seeding failure | Entire provisioning rolls back (including compensating Nhost user deletion). Operator is never created in a partial state. | Covered by the transaction boundary + Nhost compensation (steps 1–6). |
| 4 | Async Mollie failure | Operator is fully functional for backoffice use without Mollie. UI shows "Payment setup incomplete" banner (reads operator_integrations.status = PENDING/FAILED). The operator cannot publish TourDepartures to Commerce until Mollie connects. BullMQ retries (3 attempts, exponential backoff). After exhaustion → alert Busflow support dashboard + set status = FAILED. | BullMQ dead-letter queue + support alert. Tracked via operator_integrations table. |
| 5 | Operator churns with active bookings | The system cancels future departures. Commerce cancels bookings on future departures with Mollie refunds via storno workflow. Completed departures remain for accounting/GDPR. In-progress departures complete normally — churn takes effect after. | ChurnTenant saga (see contract above). Must filter start_date > CURRENT_DATE. |
| 6 | Operator suspended with scheduled departures | Departures hidden from Commerce (not cancelled). No new bookings. Existing bookings remain valid — passengers can still board. If suspension not resolved before departure date, departure proceeds (suspended from sales, not operations). | TenantSuspended event → Commerce hides TourOfferings. Operations unaffected. |
operator_integrations Table
Third-party integration status tracking per tenant. Replaces a single-purpose mollie_status column with a generic, extensible pattern.
| Column | Type | Constraints | Description |
|---|---|---|---|
id | UUID | Primary Key | Unique integration record |
tenant_id | UUID | FK (operators.id), Not Null | Owning tenant |
integration_type | VARCHAR | Not Null | MOLLIE, DPA, DATEV (extensible enum) |
status | VARCHAR | Not Null, Default: PENDING | PENDING, CONNECTED, FAILED, DISCONNECTED |
external_id | VARCHAR | Nullable | External system identifier (e.g., Mollie merchant ID) |
config | JSONB | Nullable | Integration-specific configuration. Shape varies by integration_type (e.g., Mollie: { onboarding_url, profile_id }, DATEV: { consultant_number, client_number }). |
error_message | TEXT | Nullable | Last error message (for FAILED status) |
connected_at | TIMESTAMPTZ | Nullable | When status transitioned to CONNECTED |
created_at | TIMESTAMPTZ | Default: now() | Record creation |
updated_at | TIMESTAMPTZ | Default: now() | Last update |
Unique constraint: (tenant_id, integration_type) — one record per integration per tenant.
NOTE
The ProvisionTenant flow seeds a MOLLIE row with status = PENDING. The async handler updates to CONNECTED on success or FAILED on exhaustion. The UI checks operator_integrations WHERE integration_type = 'MOLLIE' AND status = 'CONNECTED' to gate publishing to Commerce.
Consequences
Positive:
- Single entry point for tenant creation — no scattered INSERT logic
- Default resources seeded atomically — no empty-template edge cases
- Async integration failures don't block the operator
- All lifecycle transitions have explicit trigger, authorization, and side effects
- Edge cases documented with concrete decisions
operator_integrationstable is extensible for future integrations (DATEV export, SMS providers, etc.)- Authentication delegated to Nhost — no custom auth logic in NestJS
Negative:
- Requires NestJS command handlers — cannot use direct Hasura mutations
ChurnTenantsaga has cross-context side effects (Commerce booking cancellation) requiring event-driven coordination- Nhost user creation requires compensation logic on rollback (delete Nhost user if domain inserts fail)
- New
operator_integrationstable adds a schema migration
Trade-offs:
ONBOARDING→ACTIVEtransition on first login is pragmatic but means the status is not driven by a business event (e.g., "completed setup wizard"). Acceptable for MVP.- Churn is
busflow_staff-only in MVP. Self-service cancellation deferred to post-launch. SUSPENDED → CHURNEDauto-transition (90 days) is a business policy baked into a scheduled trigger. Configurable threshold recommended for production.