Busflow Docs

Internal documentation portal

Skip to content

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)

StepActionDetails
1Create Nhost userVia Nhost Admin SDK. Email + password handled by Nhost Auth. Returns nhost_user_id.
2Insert operatorsStatus: ONBOARDING. Core fields: name, legal_name, country, default_locale, default_currency.
3Link Nhost userStep 1 created the auth.users row. No separate Busflow users table exists. The Nhost user ID serves as FK in all downstream tables.
4Insert user_tenant_assignmentsdefault_role = MANAGER for the signup user → new operator.
5Seed notification_templatesClone default templates (booking confirmation, pre-trip reminder, delay broadcast) with tenant_id = new_operator.id.
6Insert 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)

StepTriggerAction
7TenantProvisioned eventCreate Mollie sub-merchant (if payment integration enabled). On success → update operator_integrations.status = CONNECTED.
8TenantProvisioned eventGenerate Data Processing Agreement (DPA) document.
9First manager login (Nhost last_seen)Transition operator status: ONBOARDINGACTIVE.

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 ONBOARDING status is fully functional for backoffice use. Async failures do not block operator access.

Hasura Action Contracts

ProvisionTenant

PropertyTypeRequiredDescription
Input
operator_nameStringCompany trading name
legal_nameStringRegistered legal company name
countryStringISO 3166-1 alpha-2
admin_emailStringSignup user email (passed to Nhost Auth for user creation)
admin_passwordStringSignup user password (passed to Nhost Auth — never stored by Busflow)
default_localeStringDefault: de-DE
default_currencyStringDefault: EUR
vat_idStringOptional at signup. Required before first invoice generation (enforced at application layer).
legal_formStringFK to legal_forms.id. Optional.
Output
operator_idUUIDCreated operator
user_idUUIDCreated manager user (= Nhost user ID)
sessionObjectNhost session object ({ accessToken, refreshToken, accessTokenExpiresIn })
Errors
EMAIL_ALREADY_EXISTSadmin_email matches an existing Nhost user. Message does not reveal whether the email belongs to the same or a different company.
INVALID_COUNTRYcountry is not a valid ISO 3166-1 alpha-2 code.
VALIDATION_ERRORGeneric 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

PropertyTypeRequiredDescription
Input
operator_idUUIDThe tenant to suspend
reasonStringSuspension reason (logged in change_events)
Output
successBoolean
Errors
OPERATOR_NOT_FOUNDNo operator with this ID
INVALID_STATUSOperator is not in ACTIVE status
INSUFFICIENT_PERMISSIONSCaller 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

PropertyTypeRequiredDescription
Input
operator_idUUIDThe tenant to reactivate
Output
successBoolean
Errors
OPERATOR_NOT_FOUNDNo operator with this ID
INVALID_STATUSOperator is not in SUSPENDED status
INSUFFICIENT_PERMISSIONSCaller 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

PropertyTypeRequiredDescription
Input
operator_idUUIDThe tenant to churn
reasonStringChurn reason: VOLUNTARY_CANCELLATION, GDPR_DELETION, NON_PAYMENT
Output
successBoolean
cancelled_departuresIntNumber of future departures cancelled
affected_bookingsIntNumber of bookings entering cancellation
Errors
OPERATOR_NOT_FOUNDNo operator with this ID
INVALID_STATUSOperator is not in ACTIVE or SUSPENDED status
INSUFFICIENT_PERMISSIONSCaller is not busflow_staff
IN_PROGRESS_DEPARTURESOperator 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:

mermaid
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 --> [*]
#TransitionTriggerMechanismAuthorizationSide Effects
1ONBOARDING → ACTIVEFirst manager loginHasura 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.
2ACTIVE → SUSPENDEDNon-payment or manager actionHasura Action SuspendTenant (see contract above)busflow_staff onlySee SuspendTenant contract.
3SUSPENDED → ACTIVEPayment resolved or manager actionHasura Action ReactivateTenant (see contract above)busflow_staff onlySee ReactivateTenant contract.
4ACTIVE → CHURNEDVoluntary cancellation or GDPR deletionHasura Action ChurnTenant (see contract above)busflow_staff only (MVP)See ChurnTenant contract.
5SUSPENDED → CHURNEDExtended non-payment (auto: 90 days) or manualHasura 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 CaseDecisionMechanism
1Duplicate 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.
2Concurrent 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.
3Template seeding failureEntire 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).
4Async Mollie failureOperator 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.
5Operator churns with active bookingsThe 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.
6Operator suspended with scheduled departuresDepartures 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.

ColumnTypeConstraintsDescription
idUUIDPrimary KeyUnique integration record
tenant_idUUIDFK (operators.id), Not NullOwning tenant
integration_typeVARCHARNot NullMOLLIE, DPA, DATEV (extensible enum)
statusVARCHARNot Null, Default: PENDINGPENDING, CONNECTED, FAILED, DISCONNECTED
external_idVARCHARNullableExternal system identifier (e.g., Mollie merchant ID)
configJSONBNullableIntegration-specific configuration. Shape varies by integration_type (e.g., Mollie: { onboarding_url, profile_id }, DATEV: { consultant_number, client_number }).
error_messageTEXTNullableLast error message (for FAILED status)
connected_atTIMESTAMPTZNullableWhen status transitioned to CONNECTED
created_atTIMESTAMPTZDefault: now()Record creation
updated_atTIMESTAMPTZDefault: 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_integrations table 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
  • ChurnTenant saga 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_integrations table adds a schema migration

Trade-offs:

  • ONBOARDINGACTIVE transition 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 → CHURNED auto-transition (90 days) is a business policy baked into a scheduled trigger. Configurable threshold recommended for production.

Internal documentation — Busflow