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:
| Endpoint | Verb | Permission |
|---|---|---|
Preview | POST | TenancyPermissions.TenantStarterKits.Preview |
Apply | POST | TenancyPermissions.TenantStarterKits.Apply |
Tenants/{tenantId}/DefaultPlan | GET | TenancyPermissions.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: AccountingChartAccounts → AccountingChartMappings → TenantOperationalDefaults
→ CashBankAccountingLink → InventoryAccountCategory → CostCenters.
StarterKit Operational Defaults Orchestration phase — TenantOperationalDefaults (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.
Dependency messaging for CashBankAccountingLink
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
Previewcan never see a record a realApplyhasn't written yet. IfTenantOperationalDefaults' own Preview says it would create (or already found) the neededCashAccount/BankAccount, the warning explains that thisMissingDependencyis a Preview-only artifact and will resolve automatically on a realApply. This is a deliberately conservative, documented choice — exact future-state simulation across two independent services is not attempted. - Unmet dependency this run: if
TenantOperationalDefaultswas excluded, blocked, or failed this run and aMissingDependencyis 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): ProductCatalogPrerequisites → ProductCatalogCategories →
ProductCatalogItems. 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 chain | TenantOperationalDefaults | Product chain | |
|---|---|---|---|
| Feature key | Features.AccountManagement | (none — resolved per-kind internally) | Features.ProductsManagement |
| Missing entitlement, no explicit request | Blocked (counts as a plan failure) | Always attempted — never pre-emptively gated | Skipped (never counts against OverallStatus) |
Explicit Include...=true without entitlement | Blocked | Always attempted | Blocked |
Explicit Include...=false | Excluded | Excluded | Excluded |
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):
| Answer | Default IncludeCostCenters | Behavior |
|---|---|---|
No | Excluded | CostCenters step Skipped by default; admin can still force it on via explicit IncludeCostCenters=true, with a warning |
Simple | Included | Cost centers usable, not mandatory in documents |
Mandatory | Included | Cost 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 answer | Excluded | Excluded 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
TenantStarterKitApplyLogrow summarizing the outcome (currently 9 total step slots per row — 6 accounting (includingTenantOperationalDefaultsandCashBankAccountingLink) + 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.
- Step order, live:
Previewreturned exactlyAccountingChartAccounts → AccountingChartMappings → TenantOperationalDefaults → CashBankAccountingLink → InventoryAccountCategory → CostCenters → ProductCatalogPrerequisites → ProductCatalogCategories → ProductCatalogItems— the required 9-step order, confirmed directly from a liveTenantStarterKitPlanResult.Stepslist. - No forced dependency on the accounting chain: with the accounting apply bridge left disabled
(its documented, production-safe default),
AccountingChartAccountsreportedFailed(bridge not configured) whileTenantOperationalDefaultswas still attempted and also correctly reportedFailedfor its own, unrelated reason (its own bridge also disabled) — withDependency: null, proving the orchestrator never gates this step onAccountingChartAccounts. - The actual fix, live: with both relevant bridges enabled (dev-only, loopback, matching the
established pattern from Business Onboarding, Chapter 21),
PreviewandApplyboth returnedOverallStatus: Succeededfor all 9 steps.CashBankAccountingLinkreportedMissingDependency=0for bothCashOnHandandBankAccount— the exact outcome this phase set out to guarantee. - Real writes + idempotency:
Applycreated 1 newTenantOperationalDefaultsrecord (aWarehousethis tenant didn't have yet —Created=1,SkippedExisting=3); a second, immediateApplyreportedCreated=0,SkippedExisting=4— zero duplicates. Direct row-count queries confirmed exactly oneCashAccount/BankAccount/CashAccountCurrency/BankAccountCurrency/Warehouse/SalesInvoiceTemplaterow before and after both Apply calls, and zero rows inGeneralLedgerJournalEntries,PaymentVouchers,ReceiptVouchers,StockItemTransactions,AccountOpeningBalances, andStockOpeningBalances— no financial or stock side effects of any kind. - Exclusion scenario:
PreviewwithincludeOperationalDefaults=falseshowedTenantOperationalDefaults: Skipped(excluded by request) whileCashBankAccountingLinkstill resolved cleanly (MissingDependency=0) — because the requiredCashAccount/BankAccountalready existed from the earlier Apply calls. This is the "only run if required defaults already exist" behavior permitted by design, not a hard block. - Apply log: each
Applycall wrote exactly one newTenantStarterKitApplyLogrow (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.
