Skip to main content
Version: Latest

Tenant Starter Kit Orchestration

Phase 5E composed the four accounting apply flows into one orchestration. Phase 6C added a second, fully independent chain for the Product Catalog. TenantStarterKitService never writes a tenant database itself and never duplicates any apply flow's own logic — every step is a direct call into that flow's existing ITenant*ApplyService.

API

TenantStarterKitsController, route api/Saas/TenantStarterKits:

EndpointVerbPermission
PreviewPOSTTenancyPermissions.TenantStarterKits.Preview
ApplyPOSTTenancyPermissions.TenantStarterKits.Apply
Tenants/{tenantId}/DefaultPlanGETTenancyPermissions.TenantStarterKits.View

TenancyPermissions.TenantStarterKits deliberately has only 3 members (View, Preview, Apply) — no Create/Edit/Delete/Activate/Deactivate, since this is an orchestration surface over existing templates, not a CRUD entity in its own right.

DefaultPlan is a convenience read-only GET wrapper around Preview — it builds a PreviewTenantStarterKitRequest with every template Id left unresolved (so the service picks the BusinessActivity's defaults) and every Include flag at its default, for the common "show me what the default starter kit would look like" case.

Two independent chains, one plan

Accounting chain: AccountingChartAccountsAccountingChartMappingsTenantOperationalDefaultsCashBankAccountingLinkInventoryAccountCategoryCostCenters.

StarterKit Operational Defaults Orchestration phaseTenantOperationalDefaults (the same ITenantOperationalDefaultsService documented in Tenant Operational Defaults) is now its own StarterKit step, positioned deliberately before CashBankAccountingLink. It has zero technical dependency on the accounting chain in either direction (it only ever creates flat Warehouse/CashAccount/BankAccount/SalesInvoiceTemplate records) and is never gated by Features.AccountManagement — the underlying service already resolves entitlement per kind internally (e.g. CashAccount via Features.PointofSalesApp OR Features.AccountManagement), so gating the whole step on one coarse feature check would incorrectly strip a POS-only tenant's Warehouse/CashAccount. It is positioned here purely so CashBankAccountingLink's own live existence check sees a freshly-created default CashAccount/BankAccount already committed to the tenant database by the time it runs, in the same Apply — this is what closes the "operator must manually sequence Tenant Operational Defaults before re-running Cash/Bank GL-Currency Linking" gap.

CashBankAccountingLink (added by the Cash/Bank GL-Currency Linking phase) links the tenant's default CashAccount/BankAccount to the correct GL account and currency via the applied template's CashOnHand/BankAccount mappings. It shares AccountingChartMappings' hard technical dependency on AccountingChartAccounts, but is deliberately not hard-gated on TenantOperationalDefaults having succeeded this run — the underlying service always re-checks live whether a default CashAccount/BankAccount already exists (from this same run, just committed, or an earlier one), so it still succeeds correctly when TenantOperationalDefaults was excluded this pass or already ran in a prior Apply. If truly nothing exists, every link item reports a non-fatal MissingDependency, never a Blocked step — and the step's Warnings are enriched with a clear note pointing at TenantOperationalDefaults's own outcome this run (see §"Dependency messaging" below). AccountingChartMappings being excluded/skipped this run has no bearing on CashBankAccountingLink either — it resolves the SaaS template's CashOnHand/BankAccount mapping rows directly (the same static seed data AccountingChartMappings itself reads), not through any tenant-side artifact that step would have created.

Mappings, TenantOperationalDefaults (independently — see above), CashBankAccountingLink, and InventoryAccountCategory all ultimately depend on Accounts having actually succeeded this run for their own reasons (Mappings/CashBankAccountingLink/InventoryAccountCategory resolve AccountCode values against real tenant dbo.Accounts) — if Accounts is Failed or Blocked, Mappings, CashBankAccountingLink, and InventoryAccountCategory are unconditionally Skipped (TenantOperationalDefaults is not, since it has no such dependency). CostCenters has no such technical dependency but is conservatively held back too by default (fail-fast) unless ContinueOnNonCriticalFailure is explicitly set.

Because CashBankAccountingLink is never hard-blocked on TenantOperationalDefaults, a per-item MissingDependency can otherwise look confusing on its own. The orchestrator adds clarifying Warnings (never changes Status/Reason) in two cases:

  • Preview limitation: a read-only Preview can never see a record a real Apply hasn't written yet. If TenantOperationalDefaults' own Preview says it would create (or already found) the needed CashAccount/BankAccount, the warning explains that this MissingDependency is a Preview-only artifact and will resolve automatically on a real Apply. This is a deliberately conservative, documented choice — exact future-state simulation across two independent services is not attempted.
  • Unmet dependency this run: if TenantOperationalDefaults was excluded, blocked, or failed this run and a MissingDependency is present, the warning names that step and its outcome directly, instead of leaving the caller to infer it from the raw per-item message alone.

Product chain (Phase 6C): ProductCatalogPrerequisitesProductCatalogCategoriesProductCatalogItems. Categories hard-depends on Prerequisites; Items hard-depends on Categories.

The two chains never depend on each other in either direction — a product-step failure never skips or blocks any accounting step, and vice versa. They only share one TenantStarterKitPlanResult/TenantStarterKitApplyLog for reporting convenience.

A step excluded by request never gates a later step

IncludeXxx = false on any step is treated as a deliberate exclusion, not a failure — a later step is never skipped on its behalf. This is what makes a safe partial re-run possible: apply accounts once, then re-run with IncludeAccountingChart = false to only apply the remaining steps, since every underlying apply flow is itself insert-missing/fill-blank-safe.

Why the product chain needed one extra rule the accounting chain never did

The accounting chain is only two levels deep at any dependency point (nothing depends on Mappings). The product chain is three levels deep (Prerequisites → Categories → Items). When Prerequisites fails, Categories is Skipped (not Failed) with Dependency = ProductCatalogPrerequisites set — and Items must still treat that as blocking, even though Categories' own status string is Skipped. The actual rule implemented: a step blocks what depends on it when it is Blocked/Failed, or Skipped with a non-null Dependency (a dependency-driven skip) — never when Skipped with no Dependency (an explicit-exclusion skip, which stays partial-re-run-safe). This was caught by a failing unit test during development, before it ever reached a live environment.

Entitlement — two different defaults, by design

Accounting chainTenantOperationalDefaultsProduct chain
Feature keyFeatures.AccountManagement(none — resolved per-kind internally)Features.ProductsManagement
Missing entitlement, no explicit requestBlocked (counts as a plan failure)Always attempted — never pre-emptively gatedSkipped (never counts against OverallStatus)
Explicit Include...=true without entitlementBlockedAlways attemptedBlocked
Explicit Include...=falseExcludedExcludedExcluded

Accounting is assumed near-universal for every tenant, so a missing entitlement is treated as an attempted-and-refused failure. Products are optional — most tenants may not sell products at all — so a missing entitlement is a soft skip: a non-product tenant's plan still reads as fully Succeeded. This asymmetry is deliberate, not an oversight.

TenantOperationalDefaults is a third, distinct pattern: the orchestrator never pre-checks Features.AccountManagement (or any single feature) for this step, because ITenantOperationalDefaultsService already resolves entitlement per kind internally (Warehouse/ CashAccount/SalesInvoiceTemplate via Features.PointofSalesApp OR Features.AccountManagement; BankAccount via Features.AccountManagement only — see Tenant Operational Defaults, Chapter 3). A tenant entitled to none of those kinds still gets the step attempted (reported via the underlying service's own EntitlementSkippedCount/Warnings, not an orchestrator-level Blocked) — only when every kind is unentitled does the underlying service itself report Succeeded=false, which then surfaces as this step's Failed status.

CostCenters additionally requires the CostCenterSettings settings-group entitlement (see Settings Groups and Entitlements).

needsCostCenters behavior

Read via the tenant's latest onboarding answer (see the survey question reference):

AnswerDefault IncludeCostCentersBehavior
NoExcludedCostCenters step Skipped by default; admin can still force it on via explicit IncludeCostCenters=true, with a warning
SimpleIncludedCost centers usable, not mandatory in documents
MandatoryIncludedCost centers usable and required in supported documents (mirrors FinancialSettings' own mandatory-cost-center flags, set by the same onboarding answer — see Settings Groups and Entitlements)
Missing answerExcludedExcluded by default, with a warning that no onboarding answer was found

An explicit IncludeCostCenters value on the request always wins over this onboarding-derived default in either direction.

needsCostCenters is an explicit onboarding survey question — it is never inferred from BusinessActivity. A RestaurantCafe and a WholesaleDistribution tenant can each answer any of the three values independently.

Template resolution

Every step resolves its own template the same way: an explicitly-supplied template Id (validated to belong to the resolved BusinessActivity and be active), or — if none is supplied — the one template marked IsDefault=true for that BusinessActivity (and, for InventoryAccountCategory, also for the selected chart template). BusinessActivityCode, if not supplied directly, can be inferred from any one explicitly-supplied template Id across either chain — a caller can trigger a product-only plan by supplying only a product template Id, with no accounting template Id at all.

Cross-step passthrough: when the Prerequisites step runs in the same pass, its resolved ProductDepartment Id is forwarded as the Categories step's TargetProductDepartmentId — avoiding Categories' own ambiguous "first active department" fallback whenever both steps run together. See Product Catalog — Starter Kit Integration.

Preview vs. Apply

  • Preview: resolves the plan and calls every included step's own Preview method. Never writes any tenant database, never writes the orchestration log.
  • Apply: same plan resolution; calls every included step's own commit method in order, stopping dependent steps on a critical failure per the rules above, and always writes one TenantStarterKitApplyLog row summarizing the outcome (currently 9 total step slots per row — 6 accounting (including TenantOperationalDefaults and CashBankAccountingLink) + 3 product — whether or not each one was included/executed).

OverallStatus (Succeeded / PartiallySucceeded / Failed / Blocked) is computed only from steps that were actually attempted (Succeeded/Planned/Failed/Blocked) — a Skipped step (by exclusion or by entitlement default) never counts.

Idempotency

No new idempotency mechanism at the orchestration level — it relies entirely on each underlying apply flow's own insert-missing/fill-blank-safe behavior. A second Apply call is always safe and produces a second, distinct log row (log rows are never merged).

Live verification — StarterKit Operational Defaults Orchestration

Verified against the real, authenticated api/Saas/TenantStarterKits/Preview/Apply endpoints (root tenant admin login, Shumoul.Api hosting both stacks in one process), targeting tenant 555001 — the designated safe write-verification tenant, already carrying an applied accounting chart template and operational defaults from earlier phases. No new tenant was registered and no contact data was used — this phase's verification only calls existing root-admin orchestration endpoints for an existing tenant.

  1. Step order, live: Preview returned exactly AccountingChartAccounts → AccountingChartMappings → TenantOperationalDefaults → CashBankAccountingLink → InventoryAccountCategory → CostCenters → ProductCatalogPrerequisites → ProductCatalogCategories → ProductCatalogItems — the required 9-step order, confirmed directly from a live TenantStarterKitPlanResult.Steps list.
  2. No forced dependency on the accounting chain: with the accounting apply bridge left disabled (its documented, production-safe default), AccountingChartAccounts reported Failed (bridge not configured) while TenantOperationalDefaults was still attempted and also correctly reported Failed for its own, unrelated reason (its own bridge also disabled) — with Dependency: null, proving the orchestrator never gates this step on AccountingChartAccounts.
  3. The actual fix, live: with both relevant bridges enabled (dev-only, loopback, matching the established pattern from Business Onboarding, Chapter 21), Preview and Apply both returned OverallStatus: Succeeded for all 9 steps. CashBankAccountingLink reported MissingDependency=0 for both CashOnHand and BankAccount — the exact outcome this phase set out to guarantee.
  4. Real writes + idempotency: Apply created 1 new TenantOperationalDefaults record (a Warehouse this tenant didn't have yet — Created=1, SkippedExisting=3); a second, immediate Apply reported Created=0, SkippedExisting=4 — zero duplicates. Direct row-count queries confirmed exactly one CashAccount/BankAccount/CashAccountCurrency/BankAccountCurrency/ Warehouse/SalesInvoiceTemplate row before and after both Apply calls, and zero rows in GeneralLedgerJournalEntries, PaymentVouchers, ReceiptVouchers, StockItemTransactions, AccountOpeningBalances, and StockOpeningBalances — no financial or stock side effects of any kind.
  5. Exclusion scenario: Preview with includeOperationalDefaults=false showed TenantOperationalDefaults: Skipped (excluded by request) while CashBankAccountingLink still resolved cleanly (MissingDependency=0) — because the required CashAccount/BankAccount already existed from the earlier Apply calls. This is the "only run if required defaults already exist" behavior permitted by design, not a hard block.
  6. Apply log: each Apply call wrote exactly one new TenantStarterKitApplyLog row (TotalSteps=9, matching the new step count) — no merged or duplicate rows across the two consecutive Apply calls.

A genuinely "Starter/POS, no accounting entitlement" package-tier scenario was not separately live-verified this session — every existing dev tenant's TenantSubscription.PackageId was found to point at a stale/replaced SubscriptionPackages row (a pre-existing dev-data staleness issue, unrelated to this phase, discovered during investigation), so no currently-subscribed tenant resolves a real package/entitlement today. This does not affect the orchestration logic itself — the Features.AccountManagement-gated Blocked paths and TenantOperationalDefaults' own per-kind entitlement resolution are both covered deterministically by unit tests in TenantStarterKitServiceTests (Shumoul.Saas.MultiTenancyApi repo, Shumoul.Framework.MultiTenancy.Test/StarterKits/), which mock entitlement explicitly for both the entitled and not-entitled cases.