Skip to main content
Version: Latest

20. Business Onboarding Survey Options

This chapter documents every question, answer option, and recommendation rule the onboarding survey currently ships with, verified directly against source (OnboardingSurveySeeder.cs, OnboardingConstants.cs, OnboardingRecommendationService.cs, SettingsPatchService.cs, InternalOnboardingSettingsPatchService.cs — all in the source-of-truth repos listed in the README). Nothing in this chapter is invented; every question key, label, option, and rule below has a direct line back to a specific file and line referenced inline.

20.1 Survey identity

FieldValue
Survey codedefault-business-setup (OnboardingConstants.SurveyCode)
Survey version1 (OnboardingConstants.SurveyVersion)
Name (AR)تهيئة نشاطك
Name (EN)Business Setup
Seeded byOnboardingSurveySeeder (Shumoul.Framework.MultiTenancy.Api/Onboarding/OnboardingSurveySeeder.cs), an ICustomSeeder that runs on every host startup and is idempotent — it does not duplicate rows on restart, and (as of the Phase 8 fix in §20.7) it now also updates an existing rule's patch content if the source changed, instead of only inserting new rows

20.2 Full question list

The survey has 23 questions across 5 steps. StepNo groups questions for a multi-step wizard UI; IsRequired marks the two questions the flow cannot be completed without; VisibleWhen conditionally shows a question only when an earlier answer matches.

#KeyStepTypeRequiredVisibleWhenLabel (AR)Label (EN)
1activityType1SingleChoiceYesما هو نشاطك الأساسي؟What is your main business activity?
2branchesCount1SingleChoiceNoكم عدد الفروع؟How many branches do you have?
3usersCount1SingleChoiceNoكم عدد المستخدمين المتوقعين؟How many users do you expect?
4hasPos2BooleanYesهل تستخدم الكاشير / نقاط البيع؟Do you use POS / cashier?
5paymentMethods2MultiChoiceNoما طرق الدفع التي تستقبلها؟Which payment methods do you accept?
6requireCustomerBeforeClosing2SingleChoiceNoهل تريد تسجيل بيانات العميل قبل إغلاق الفاتورة؟Do you want to collect customer information before closing invoices?
7preventOutOfStockSales2BooleanNoهل تريد منع بيع المنتجات غير المتوفرة في المخزون؟Do you want to prevent selling out-of-stock products?
8hasTables3BooleanNoactivityType = RestaurantCafeهل لديك طاولات داخلية؟Do you have dine-in tables?
9hasKitchen3BooleanNoactivityType = RestaurantCafeهل لديك مطبخ أو تحضير طلبات؟Do you have a kitchen or order preparation area?
10usesModifiers3BooleanNoactivityType = RestaurantCafeهل تستخدم الإضافات والمعدلات؟ مثل الحجم أو الصوص أو الإضافاتDo you use modifiers such as size, sauce, or add-ons?
11showCalories3BooleanNoactivityType = RestaurantCafeهل تريد دعم السعرات الحرارية؟Do you want to support calories?
12hasInventory4BooleanNoهل تتابع المخزون؟Do you track inventory?
13hasExpiryProducts4BooleanNoهل لديك منتجات لها تاريخ صلاحية؟Do you have products with expiry dates?
14usesBatchNumber4BooleanNoهل تستخدم رقم تشغيلة / Batch؟Do you use batch numbers?
15usesSerialNumbers4BooleanNoهل تبيع منتجات برقم تسلسلي؟Do you sell products with serial numbers?
16usesScale4BooleanNoهل تبيع بالوزن أو الميزان؟Do you sell by weight or scale?
17usesMultiUnits4BooleanNoهل المنتج له أكثر من وحدة؟ مثل حبة، كرتون، باكيتDo products have multiple units, such as piece, carton, or pack?
18usesVat5BooleanYesهل منشأتك مسجلة في ضريبة القيمة المضافة؟Is your business registered for VAT?
19pricesIncludeVat5BooleanNousesVat = trueهل أسعار البيع شاملة الضريبة؟Are your sales prices VAT-inclusive?
20needsCostCenters5SingleChoiceNoهل تحتاج مراكز تكلفة؟Do you need cost centers?
21needsProjects5BooleanNoهل تحتاج ربط العمليات بالمشاريع؟Do you need to link transactions to projects?
22hasSalesmen5BooleanNoهل لديك مناديب مبيعات؟Do you have sales representatives?
23hasMarketers5BooleanNoهل لديك مسوقين أو عمولات تسويق؟Do you have marketers or marketing commissions?

Boolean questions have no OptionsJson — they render as a yes/no toggle in the client, answered with JSON true/false. SingleChoice/MultiChoice questions carry an explicit option list — see below.

20.3 Answer options in detail

Only the 6 questions that are SingleChoice/MultiChoice carry an explicit option list. Every option below is a literal (Value, LabelAr, LabelEn) tuple from OnboardingSurveySeeder.BuildQuestions().

activityType — drives profile selection

Option valueLabel (AR)Label (EN)Business meaning
RestaurantCafeمطعم / كوفيRestaurant / CafeSelects the RestaurantCafe profile — tables, kitchen routing, modifiers, calories, loyalty
Supermarketسوبرماركت / بقالةSupermarket / GrocerySelects the Supermarket profile — expiry/batch/scale/multi-unit tracking, no customer-required sales
RetailStoreمتجر تجزئةRetail StoreSelects the RetailStore profile — multi-images, tags, multi-unit, loyalty
Servicesخدمات بدون مخزونServices without InventorySelects the Services profile — no inventory tracking, booking enabled, sales tax only (no purchase tax)
WholesaleDistributionجملة وتوزيعWholesale & DistributionSelects the WholesaleDistribution profile — batch/expiry/packaging, invoice-level discounts

This is the only question the recommendation engine treats specially: OnboardingRecommendationService. DetermineProfile matches the answer directly against OnboardingConstants.Profiles.All and returns 95% confidence on a match, or defaults to RetailStore at 80% confidence if unanswered/unrecognized.

branchesCount / usersCount — collected, not yet used by any rule

QuestionOptions (value / AR / EN)
branchesCountOne (فرع واحد / One) · TwoToFive (من 2 إلى 5 فروع / 2 to 5 branches) · MoreThanFive (أكثر من 5 فروع / More than 5 branches)
usersCountOneToThree (من 1 إلى 3 / 1 to 3) · FourToTen (من 4 إلى 10 / 4 to 10) · MoreThanTen (أكثر من 10 / More than 10)

Both are answered and stored in TenantOnboardingSessions.AnswersJson, but no recommendation rule currently reads either key — they do not affect the settings patch, summary, warnings, or next actions today. They exist for future rules and for reporting/analytics, not because this is a gap to fix.

paymentMethods — collected, not yet used by any rule

ValueAREN
CashنقديCash
CardبطاقةCard
CreditCustomerآجل للعميلCredit Customer

Same status as branchesCount/usersCount — multi-select, stored, but no rule keys on it yet.

requireCustomerBeforeClosing — directly drives a rule

ValueARENRecommendation effect
NoلاNoNo global rule fires (the absence of Always/OnlyReturnsAndCoupons means neither of the two conditional rules below match)
AlwaysدائماًAlwaysCashierAppsSettings.Require_Customer_Info_Before_Closing = true, Require_Mobile_Number = true
OnlyReturnsAndCouponsفقط للمرتجعات والكوبوناتOnly for Returns and CouponsCashierAppsSettings.Require_Customer_Info_Before_Closing = false, Return_Require_Customer_Info = true, Coupon_Require_Customer_Info = true

needsCostCenters — the one question mapped to two settings groups at once

ValueARENRecommendation effect
NoلاNoCostCenterSettings all-false + FinancialSettings all-false (cost center optional everywhere)
SimpleبسيطةSimpleCostCenterSettings enabled-but-optional on sales/purchase/journal entries + FinancialSettings mandatory flags false
MandatoryإلزاميةMandatoryCostCenterSettings enabled-and-mandatory everywhere + FinancialSettings mandatory flags true (sales invoice, payment voucher, receipt voucher, journal entry)

The FinancialSettings half of this mapping was added in Phase 8 — see §20.7.

20.4 Recommendation rules — how a patch is built

OnboardingRecommendationService.BuildRecommendation always applies one base profile rule (picked by activityType, or RetailStore by default) and then layers every matching global rule on top, ordered by Priority (base rules: 100–104; global rules: 200, 210, 220, … in declaration order), with later (higher-priority) rules overwriting earlier ones property-by-property. The merged result is passed through SettingsPatchService.Validate (the whitelist — see Chapter 11) before being returned as SettingsPatch in the recommendation response.

20.4.1 Base profile rules (one applies, by activityType)

ProfileSettings groups touchedNotable choices
RestaurantCafeCashierAppsSettings, ProductSettings, DiscountSettings, TaxSettings, LoyaltySettingsTables + kitchen routing on, modifiers/ingredients/calories on, loyalty enabled
SupermarketCashierAppsSettings, ProductSettings, DiscountSettings, TaxSettingsExpiry/batch/scale/multi-unit/packaging on, Enable_Customer_Sales = false, no loyalty
RetailStoreCashierAppsSettings, ProductSettings, DiscountSettings, TaxSettings, LoyaltySettingsMulti-images/tags/multi-unit on, loyalty enabled
ServicesCashierAppsSettings, ProductSettings, TaxSettingsNo expiry/batch/serial/scale/multi-unit, booking enabled, purchase tax off
WholesaleDistributionCashierAppsSettings, ProductSettings, DiscountSettings, TaxSettingsBatch/expiry/packaging on, invoice-level + line-level discounts, prices exclude tax

LoyaltySettings on RestaurantCafe/RetailStore and every FinancialSettings/NotificationSettings reference below were added in Phase 8 — see §20.7 for exactly what changed and why.

20.4.2 Global conditional rules (any number can apply, layered on top of the base rule)

ProfileCode = null on these rules means they apply regardless of which profile was selected, whenever their condition matches the session's answers.

Condition (answer)Settings group(s) affectedWhat it sets
usesVat = falseTaxSettingsDisables sales tax, purchase tax, and price-includes-tax
pricesIncludeVat = trueTaxSettingsSales_Price_Include_Tax = true
pricesIncludeVat = falseTaxSettingsSales_Price_Include_Tax = false
hasTables = falseCashierAppsSettingsDisables the tables system and its guest-count/required flags
hasKitchen = falseCashierAppsSettings, ProductSettingsDisables kitchen auto-routing and kitchen prep time
usesModifiers = trueProductSettingsEnables modifiers and ingredients
showCalories = trueProductSettingsEnables calories
hasExpiryProducts = trueProductSettingsEnables expiry date tracking
usesBatchNumber = trueProductSettingsEnables batch numbers
usesSerialNumbers = trueProductSettingsEnables serial numbers
usesScale = trueProductSettingsEnables scale/weight selling
usesMultiUnits = trueProductSettingsEnables multi-units and packaging info
preventOutOfStockSales = trueCashierAppsSettingsPrevent_Selling_Out_of_Stock_Products = true
requireCustomerBeforeClosing = AlwaysCashierAppsSettingsRequires customer info + mobile number before closing
requireCustomerBeforeClosing = OnlyReturnsAndCouponsCashierAppsSettingsRequires customer info only for returns/coupons
needsCostCenters = NoCostCenterSettings, FinancialSettingsCost center fully optional, everywhere
needsCostCenters = SimpleCostCenterSettings, FinancialSettingsCost center enabled but optional
needsCostCenters = MandatoryCostCenterSettings, FinancialSettingsCost center enabled and mandatory everywhere
needsProjects = trueProjectSettingsEnables project linkage on sales/purchase invoices and payment/receipt vouchers
hasSalesmen = trueSalesmanSettingsEnables salesman-on-invoice + auto commission calculation
hasMarketers = trueMarketerSettingsEnables marketer-on-invoice + auto commission calculation
hasInventory = trueNotificationSettingsEnables 5 stock-related admin notifications (see §20.7)

That is 22 global rules + 5 base profile rules = 27 total recommendation rules as of this writing, all defined in OnboardingSurveySeeder.SeedRulesAsync.

20.4.3 Recommendation summary lines

OnboardingRecommendationService.BuildSummary adds one bilingual summary line per settings group present in the final patch (not per rule) — e.g. any patch containing LoyaltySettings gets the line "تم تفعيل نظام نقاط الولاء" / "Loyalty rewards have been enabled", regardless of which rule contributed it.

20.5 Supported settings groups (11 total)

The recommendation engine can only ever reference the settings groups the patch engine whitelist knows how to apply — see Chapter 11, §11.2 for the authoritative, up-to-date table of all 11 groups (CashierAppsSettings, ProductSettings, DiscountSettings, TaxSettings, CostCenterSettings, ProjectSettings, SalesmanSettings, MarketerSettings, FinancialSettings, LoyaltySettings, NotificationSettings).

20.6 Localization of survey text

Fixed — no longer hardcoded. Every question label, answer-option label, profile display name, survey name, step title, recommendation summary line, and POS-related warning sentence documented in this chapter is now sourced from two localization resource files, not C# string literals:

  • ArabicShumoul.Framework.MultiTenancy.Api/Onboarding/Localization/ar.json
  • EnglishShumoul.Framework.MultiTenancy.Api/Onboarding/Localization/en.json

Both are embedded resources inside the Shumoul.Framework.MultiTenancy.Api assembly (<EmbeddedResource> in the project's .csproj), read by a small internal reader, OnboardingLocalization (Onboarding/OnboardingLocalization.cs), via OnboardingLocalization.Ar(key) / .En(key). OnboardingSurveySeeder, OnboardingService's step-title lookup, and OnboardingRecommendationService's summary/warning builders all call this reader instead of embedding string literals — see the "Files changed" list below for exactly what changed.

Why not the app-wide IStringLocalizer (the ar.json/en.json under Shumoul.Api/Localization/ used for OnboardingService's own validation/success messages)? That mechanism (Shumoul.Framework.Infrastructure.Localization.JsonStringLocalizer) resolves exactly one culture per call, driven by the ambient request/thread culture — a good fit for per-request strings, but a poor fit for populating TitleAr+TitleEn, LabelAr+LabelEn, and OnboardingSummaryLineDto.Ar+.En, all of which need both languages simultaneously — most notably at survey-seed time, when there is no HTTP request or ambient culture at all. OnboardingLocalization fills that specific gap with a minimal, read-only, embedded-resource dictionary reader — not a second localization system, just a second narrow reader over the same JSON key→value convention already used everywhere else in this codebase.

Key naming convention:

ConceptKey patternExample
Survey nameOnboarding.Survey.Name
Question labelOnboarding.Question.{PascalKey}.LabelOnboarding.Question.ActivityType.Label
Answer option labelOnboarding.Option.{PascalKey}.{OptionValue}.LabelOnboarding.Option.ActivityType.RestaurantCafe.Label
Profile display nameOnboarding.Profile.{Code}.NameOnboarding.Profile.RetailStore.Name
Step titleOnboarding.Step.{n}.Title / Onboarding.Step.Fallback.TitleOnboarding.Step.1.Title
Recommendation summary lineOnboarding.Summary.{Name}Onboarding.Summary.LoyaltyRewardsEnabled
POS-related warningOnboarding.Warning.{Name}Onboarding.Warning.WarehouseNotSet

{PascalKey} is the question's C# constant name from OnboardingConstants.Questions (e.g. ActivityType, HasPos), derived automatically at seed time from the runtime Key value — not a separate value that can drift out of sync with it.

Database contract and API response shape are unchanged. OnboardingQuestion.TitleAr/TitleEn/ OptionsJson[].LabelAr/LabelEn, OnboardingProfile.NameAr/NameEn, and OnboardingSummaryLineDto.Ar/En are all still populated exactly as before — only where the values come from changed, from a literal to OnboardingLocalization.

The database is the source of truth for survey content once a row exists (corrected 2026-07-13 — see §20.6.1 below for what changed and why). Survey questions, answer options, and profiles are planned to become editable from future admin screens, so the seeder must never silently overwrite a value an admin (or a previous seed run) already set. On every run:

  • A missing question/option/profile/survey is inserted with the full localized default — unchanged.
  • An existing row is matched by its stable, non-text key — OnboardingQuestion.Key, OnboardingQuestionOptionDto.Value (within the question's OptionsJson), OnboardingProfile.Code, or the survey's Code+Versionnever by TitleAr/LabelEn/NameAr/etc.
  • For that existing row, the seeder only fills a currently-blank TitleAr/TitleEn/option label/profile name from the resource default. A field that already has a value — whether an admin edit or last release's seeded default — is left untouched, even if the localization resource's value has since changed.
  • StepNo, IsRequired, DisplayOrder, and VisibleWhenJson are still never touched on an existing question, exactly as before.

OnboardingRule.SettingsPatchJson/Priority are the one deliberate exception: they are system/business logic owned by this seeder's source code, not admin-editable display text, so an existing rule's patch content is still kept in sync with source on every run (e.g. how Phase 8 added FinancialSettings to an already-seeded rule) — see §20.4.

Files changed to move this text (2026-07-12):

  • Shumoul.Framework.MultiTenancy.Api/Onboarding/Localization/ar.json, en.json — new, embedded resources holding every key in the table above.
  • Shumoul.Framework.MultiTenancy.Api/Onboarding/OnboardingLocalization.cs — new, the reader.
  • Shumoul.Framework.MultiTenancy.Api/Onboarding/OnboardingSurveySeeder.cs — rewritten to resolve question/option/profile/survey text via OnboardingLocalization (this file's original "refresh existing rows on every reseed" behavior was corrected the next day — see §20.6.1 — to only fill a blank field instead of always overwriting).
  • Shumoul.Framework.MultiTenancy.Api/Onboarding/OnboardingService.csOnboardingStepTitles' 5 hardcoded step-title pairs (plus its "Step {n}" fallback) now resolve from OnboardingLocalization.
  • Shumoul.Framework.MultiTenancy.Api/Onboarding/OnboardingRecommendationService.csBuildSummary's 9 summary lines and BuildWarningsAndNextActions's 3 POS-related warning sentences now resolve from OnboardingLocalization (the 3 warnings resolve .En only, matching their pre-existing English-only behavior exactly — no new Arabic output was introduced for a list that never had it).
  • No question key, answer-option key, recommendation rule, settings-patch behavior, or apply behavior changed — verified by a full regression pass (23 questions, all choice-question options, 27 rules, all 11 supported settings groups) plus new tests proving text now comes from the resources and that reseeding never duplicates rows.

20.6.1 Correction: database is the source of truth (2026-07-13)

What was wrong. The 2026-07-12 pass above made the seeder always overwrite an existing question's TitleAr/TitleEn/OptionsJson (and a profile's NameAr/NameEn, and the survey's NameAr/NameEn) with the current localization resource value whenever it differed — framed at the time as "so a translation fix ships to already-seeded rows without a manual migration." That is unsafe once survey content is planned to be admin-editable: an admin correcting a question's Arabic wording (or a future admin screen doing the same) would have had that edit silently discarded on the next host restart or package upgrade, the moment the seeder ran again.

What changed. The seeder's match keys did not change — OnboardingQuestion.Key, OnboardingQuestionOptionDto.Value, OnboardingProfile.Code, and the survey's Code+Version were already stable and non-text, so no identity logic needed correcting. What changed is what happens after a match: SeedSurveyAsync/SeedQuestionsAsync/SeedProfilesAsync now call a shared FillIfEmpty(current, fallback) helper per field instead of unconditionally assigning the resource value. A field is only ever set when it is currently null/empty — a non-empty value (admin-edited or previously seeded) is left alone, even if the resource default has since changed.

Answer options needed a small merge algorithm, not just a field check, because a question's options are stored together as one OptionsJson blob (not one row per option): MergeOptionsJson in OnboardingSurveySeeder.cs walks the code-declared default option list, matches each one against the existing stored list by Value (never by LabelAr/LabelEn), fills a blank label from the default, preserves a non-empty label untouched, inserts an entirely new option (a Value the DB doesn't have yet) at its declared position, and carries forward any stored option whose Value the current code doesn't define at all (so a hypothetical future admin-added option is never silently dropped by a package upgrade).

Unique indexes were already correct — verified, not added. The EF model (TenantDbContext.OnConfiguring's modelBuilder configuration) already enforces:

TableUnique indexMatches this chapter's stable key
OnboardingSurveyDefinitions(Code, Version)Survey Code+Version
OnboardingQuestions(SurveyDefinitionId, Key)Question Key
OnboardingProfilesCodeProfile Code

Answer options have no table/unique-index of their own — they are the JSON array inside OnboardingQuestion.OptionsJson, so uniqueness-by-Value is enforced by MergeOptionsJson in code, not by the database schema. OnboardingRules had no unique index on its own match key, (ProfileCode, ConditionJson), at the time this section was written — ConditionJson is nvarchar(max), which SQL Server cannot include in an index without either a computed hash column or a dedicated key column, either of which requires a migration, and no migration was created in this pass without explicit approval. This gap was closed the same day by adding OnboardingRule.RuleKey — see §20.6.2 below.

Files changed:

  • Shumoul.Framework.MultiTenancy.Api/Onboarding/OnboardingSurveySeeder.csSeedSurveyAsync, SeedQuestionsAsync, SeedProfilesAsync now fill-blank-only instead of always-overwrite; new FillIfEmpty/MergeOptionsJson helpers. SeedRulesAsync is unchanged — SettingsPatchJson/Priority are system logic, not admin-editable content, so they still sync from source on every run.
  • Shumoul.Framework.MultiTenancy.Test/Onboarding/OnboardingSurveySeederShould.cs — the two tests that asserted the old always-overwrite behavior were replaced with tests asserting the opposite (an edited field survives reseed); new tests cover fill-only-if-blank, no duplicate options when labels change, and inserting a newly-added question without disturbing an already-edited one.
  • No question key, answer-option key, recommendation rule, settings-patch behavior, apply behavior, or API response shape changed.

20.6.2 Add stable RuleKey for onboarding recommendation rules (2026-07-13)

What was missing. Every other seeded onboarding record (survey, question, profile) is matched on reseed by a stable, non-text, indexed key — but OnboardingRule was still matched by the composite (ProfileCode, ConditionJson) pair. ConditionJson is nvarchar(max), so SQL Server cannot back a real unique index on it, and its exact JSON text (key order, whitespace) could in principle drift even when the logical rule is unchanged, which would silently produce a duplicate row instead of an update.

What changed. Added OnboardingRule.RuleKey — a stable, human-readable, non-text string identity, e.g. "Profile.RestaurantCafe" for a base profile rule or "HasInventory.True" for a global conditional rule. The naming convention is deliberately literal, not descriptive: a base rule is Profile.{ProfileCode}; a global rule is {QuestionKeyPascalCase}.{AnswerValue} — the exact trigger condition the rule fires on, never a description of the settings it applies (several global rules bundle two or three unrelated toggles at once, so an "EnableX"-style label would misrepresent what the rule actually does). All 27 current rules and their RuleKey:

RuleKeyProfileCodeConditionJson
Profile.RestaurantCafeRestaurantCafe
Profile.SupermarketSupermarket
Profile.RetailStoreRetailStore
Profile.ServicesServices
Profile.WholesaleDistributionWholesaleDistribution
UsesVat.False{"usesVat":false}
PricesIncludeVat.True / .False{"pricesIncludeVat":true|false}
HasTables.False{"hasTables":false}
HasKitchen.False{"hasKitchen":false}
UsesModifiers.True{"usesModifiers":true}
ShowCalories.True{"showCalories":true}
HasExpiryProducts.True{"hasExpiryProducts":true}
UsesBatchNumber.True{"usesBatchNumber":true}
UsesSerialNumbers.True{"usesSerialNumbers":true}
UsesScale.True{"usesScale":true}
UsesMultiUnits.True{"usesMultiUnits":true}
PreventOutOfStockSales.True{"preventOutOfStockSales":true}
RequireCustomerBeforeClosing.Always / .OnlyReturnsAndCoupons{"requireCustomerBeforeClosing":"Always"|"OnlyReturnsAndCoupons"}
NeedsCostCenters.No / .Simple / .Mandatory{"needsCostCenters":"No"|"Simple"|"Mandatory"}
NeedsProjects.True{"needsProjects":true}
HasSalesmen.True{"hasSalesmen":true}
HasMarketers.True{"hasMarketers":true}
HasInventory.True{"hasInventory":true}

Migration: Add_OnboardingRule_RuleKey (Shumoul.Framework.MultiTenancy.Api/Migrations/) — adds OnboardingRules.RuleKey nvarchar(450) NULL and a filtered unique index, IX_OnboardingRules_RuleKey ON OnboardingRules(RuleKey) WHERE RuleKey IS NOT NULL (nullable + filtered so existing pre-migration rows, which have RuleKey = NULL until the seeder backfills them, don't collide on the single-NULL constraint SQL Server would otherwise apply to a plain unique index). Purely additive — no column dropped, no data touched, no TenantId/PK change.

Seeder matching — before vs. after. Before: SeedRulesAsync looked up an existing row by (ProfileCode, ConditionJson) only. After: it looks up by RuleKey first; a row found that way has its ConditionJson/SettingsPatchJson/Priority re-synced from source if they differ (system logic, same as before — never admin content). If no row matches by RuleKey, it falls back once to the old (ProfileCode, ConditionJson) lookup among rows that still have RuleKey == null — a match there is a pre-migration row, which gets its RuleKey backfilled (and is then only ever found by RuleKey on every subsequent run). This legacy fallback exists purely for backward compatibility with already-seeded databases, not as an ongoing identity mechanism. A legacy row that matches neither path (an obsolete rule no longer in source) is left untouched and logged as a warning for manual review — never deleted.

Backfill result (verified against the real dev database): all 27 pre-existing rows backfilled cleanly, one-to-one, onto their existing row IDs (no new rows created, no duplicates) — confirmed via a direct SQL count (27 rows, 27 distinct RuleKey values) and by re-running the seeder twice more with the count unchanged.

Files changed:

  • Shumoul.Framework.MultiTenancy.Api/Entities/Onboarding/OnboardingRule.cs — new RuleKey property.
  • Shumoul.Framework.MultiTenancy.Api/Tenants/TenantDbContext.cs — new unique index on RuleKey.
  • Shumoul.Framework.MultiTenancy.Api/Migrations/20260712183624_Add_OnboardingRule_RuleKey.cs — the migration.
  • Shumoul.Framework.MultiTenancy.Api/Onboarding/OnboardingSurveySeeder.cs — every seeded rule definition now carries a RuleKey; SeedRulesAsync matches by RuleKey first, falls back to the legacy (ProfileCode, ConditionJson) lookup once for backfill, and logs (never deletes) any row that matches neither.
  • Shumoul.Framework.MultiTenancy.Test/Onboarding/OnboardingSurveySeederShould.cs — new tests: every rule has a non-empty, unique RuleKey; a duplicate RuleKey is rejected at the database level (DbUpdateException); a ConditionJson formatting change doesn't create a duplicate when RuleKey is unchanged; a pre-migration row (simulated by clearing RuleKey) is backfilled onto the same row; the full RuleKey set is identical across repeated reseeds.
  • No question key, answer-option key, recommendation logic, settings-patch behavior, or apply behavior changed — ProfileCode/ConditionJson remain exactly what the recommendation engine evaluates against; RuleKey is purely an identity column the seeder uses internally, never returned in any API response.

RuleKey stability rule for future changes: once released, a RuleKey must never be renamed or reused for a different rule — the seeder (and any future admin tooling) treats it as permanent identity, the same way OnboardingQuestion.Key and OnboardingProfile.Code already are. Retiring a rule means deactivating it (IsActive = false), not deleting or renaming its row.

Technical validation/warning/error messages (2026-07-12)

A follow-up pass moved the remaining hardcoded strings in the settings-patch validation path — as opposed to the survey/summary content path above — into localization resources too: SettingsPatchService.Validate's whitelist warnings, HttpOnboardingTenantSettingsApplier's bridge-disabled/ apply-failed messages, InternalOnboardingSettingsPatchService.ApplyAsync's errors/warnings, and OnboardingInternalController's "TenantId and SessionId are required." validation message.

These live in two different resource locations, by design, matching where each class actually runs:

  • Shumoul.Framework.MultiTenancy.Api/Onboarding/Localization/ar.json/en.json (the same embedded OnboardingLocalization reader from above) — for SettingsPatchService and HttpOnboardingTenantSettingsApplier, since both live in the MultiTenancyApi package and must resolve correctly regardless of which host loads it.
  • Shumoul.Api/Localization/ar.json/en.json (the app-wide IStringLocalizer<T> — the same mechanism OnboardingService's own validation/success messages already used) — for InternalOnboardingSettingsPatchService and OnboardingInternalController, since both live in Shumoul.Infrastructure/Shumoul.Api themselves, where that mechanism is already the established, request-culture-aware convention (ZooBaseController exposes _localizer — an IStringLocalizer<Controller> — to every controller for exactly this purpose).

All new keys use {0}/{1}-style placeholders (e.g. "Property {0} does not exist on {1} and was ignored."), substituted via string.Format — the same convention JsonStringLocalizer and OnboardingLocalization already use elsewhere, never manual string concatenation. The three HttpOnboardingTenantSettingsApplier/ SettingsPatchService messages resolve .En only (matching their pre-existing English-only behavior, since neither class has request-culture context); Shumoul.Api-side messages resolve via the ambient request culture like every other IStringLocalizer message in that host. The errors/warnings response fields remain plain string[]/List<string> — no API contract change.

Known, intentionally out-of-scope gap: SettingsPatchService.cs and InternalOnboardingSettingsPatchService.cs's validation logic — which groups/properties are whitelisted, when a value is rejected — was not touched, only where each rejection message's text comes from. All 11 supported settings groups remain exactly as documented in §20.5.

20.7 Latest Update — Phase 8

Date: 2026-07-11. Scope: completed onboarding recommendation + apply support for FinancialSettings, LoyaltySettings, NotificationSettings — the three settings groups the platform's AppSettingViewModel already exposed (Financial, Loyalty, Notification properties) but which onboarding could not yet recommend or apply. Full technical detail, E2E verification steps, and test results are in docs/testing/NEW_SUBSCRIBER_E2E_TEST_REPORT.md (Shumoul.BackEnd/Shumoul repo, "Phase 8" section) — this section summarizes what changed for documentation purposes.

Exact gaps found and fixed (all three groups had the identical two gaps, nothing else):

  1. SettingsPatchService.Whitelist (Shumoul.Framework.MultiTenancy.Api/Onboarding/ SettingsPatchService.cs) — none of the three groups were in the whitelist dictionary at all (not even as Supported: false). Fixed by adding all three with curated safe property subsets: FinancialSettings gets 15 of its ~80 properties (system-level toggles + voucher-level cost-center mandatory flags — no Guid-typed properties, consistent with the existing EntityReferencePropertyNames convention); LoyaltySettings and NotificationSettings are small classes with no Guid properties, so every property on each is whitelisted (12 and 15 respectively).
  2. InternalOnboardingSettingsPatchService.ApplyAsync's switch (Shumoul.Infrastructure/Services/ InternalOnboardingSettingsPatchService.cs) — none of the three groups had a case, so they fell through to _ => null and were reported as ignored even if they had passed the (nonexistent) whitelist. Fixed by adding nameof(FinancialSettings) => await ApplyGroupAsync<FinancialSettings>(...) and the equivalent for the other two, reusing the exact same generic reflection-based ApplyGroupAsync<T> helper every other group already uses — no new code path was introduced.

Recommendation rules updated (OnboardingSurveySeeder.SeedRulesAsync) — reusing only existing survey questions/answers, no new questions were invented:

  • LoyaltySettings (EnableSystem, Display_Bonus_On_New_Order) added to the RestaurantCafe and RetailStore base profile rules.
  • FinancialSettings mandatory-cost-center flags added alongside the existing CostCenterSettings toggles on all three needsCostCenters (No/Simple/Mandatory) global rules.
  • NotificationSettings (5 inventory-related admin notification toggles) added as a new global rule gated on the existing hasInventory = true answer.
  • OnboardingRecommendationService.BuildSummary given matching bilingual summary lines for the three groups.

Apply flow verified end-to-end against tenant 122169: recommendation correctly included all three new groups given activityType=RetailStore, hasInventory=true, needsCostCenters=Mandatory; apply returned 200 OK; TenantOnboardingSessions.Status transitioned Recommended → Applied; a TenantOnboardingApplyLogs row was created with the full applied patch; and a direct database spot-check of dbo.AppSettings in the tenant DB confirmed every new property matched the applied values exactly. Repeat login afterward did not re-force onboarding (GET onboarding/status returned isRequired:false).

Also fixed, discovered during this phase's E2E run (not part of the original 3-group gap, but required for the phase's own changes to actually take effect):

  • Seeder idempotency bugSeedRulesAsync previously only ever inserted a rule row if none existed for its (ProfileCode, ConditionJson) key; it never updated an existing row's SettingsPatchJson. Any environment that had already seeded the RetailStore base rule or the needsCostCenters global rules before Phase 8 would silently keep the old patch content forever, even after the seeder source code changed. Fixed by having the seeder update SettingsPatchJson/Priority on an existing matching rule when they differ from source, instead of skipping it.
  • Apply bridge timeoutAppSettingService.GetAppSetting<T>/SetAppSetting<T> do one database round-trip per reflected property (no batching). FinancialSettings (~80 properties) is the first onboarding-apply group large enough to make this visible: applying it alone took 60–80 seconds in the dev environment, exceeding OnboardingApplyBridgeSettings.TimeoutSeconds's 60-second default. Fixed by raising the default to 120 seconds. The underlying per-property round-trip pattern itself was left as-is — see §20.8.

20.8 Known Remaining Gaps / Not Blockers

None of the items below block first-login onboarding today — every one of the 11 supported settings groups recommends and applies correctly end-to-end. They are documented so nobody re-discovers them as surprises.

  • AppSettingService.GetAppSetting<T>/SetAppSetting<T> perform one DB round-trip per reflected property, with no batching. This is a pre-existing characteristic of the shared settings-persistence service (not introduced by onboarding), and is a real performance candidate for any settings class as large as FinancialSettings (~80 properties). It was mitigated for onboarding by raising the apply bridge's timeout to 120 seconds (see §20.7), not by changing AppSettingService itself — that service is widely used elsewhere and out of scope for this feature.
  • SalesInvoiceTemplateSettings and PrinterSettings remain unwired in the onboarding whitelist and apply switch — see Chapter 11, §11.3. PrinterSettings is persisted through a separate per-device mechanism entirely, not through IAppSettingService, so it may never be a candidate for this patch engine. SalesInvoiceTemplateSettings is a plain unwired gap, same status as before Phase 8.
  • AppSettingsController.SetLoyaltyAsync takes FinancialSettings as its request parameter type instead of LoyaltySettings. This is a pre-existing bug in that controller action, found while inspecting AppSettingsController during Phase 8 — it is unrelated to onboarding apply, since the onboarding path calls IAppSettingService.SetAppSetting<LoyaltySettings> directly and never goes through that controller action. It affects a normal CRUD settings endpoint, not the first-login flow, and was left unfixed as out of scope for this feature.
  • OnboardingRule has no unique database index on its match keyresolved 2026-07-13, see §20.6.2. RuleKey is now the match key, backed by a real unique index.

20.9 Developer Guidance

20.9.1 When adding a new onboarding option

  1. Add a new question constant to OnboardingConstants.Questions (and a new profile constant to OnboardingConstants.Profiles if it's a new business-activity type).
  2. Add the localization keys firstOnboarding.Question.{PascalKey}.Label (and Onboarding.Option.{PascalKey}.{Value}.Label per choice option) to both Onboarding/Localization/ar.json and en.json (see §20.6 for the key convention). Then add the question to OnboardingSurveySeeder.BuildQuestions() via Q(key, type, step, required, optionValues) — no titleAr/titleEn/label parameters are passed in-line; the seeder derives the localization key from key automatically and resolves it from the resources you just added. A question referencing a key missing from either resource file falls back to showing the raw key string instead of failing the seed — so a missing translation is easy to spot in a review, but won't crash onboarding. These localization values are seed defaults only — per §20.6.1, they populate the question on first insert and fill a currently-blank field on reseed, but never overwrite a non-empty TitleAr/TitleEn/option label already in the database (e.g. a value edited from a future admin screen). To change already-seeded, already-edited content for a live business, edit the database/admin screen directly — editing ar.json/en.json only changes what a new installation seeds and what fills a field that is still blank. The new question's Key (and each option's Value) is what identifies the row on every future reseed — never invent a key that collides with an existing one, and never rely on the Arabic/English text to tell two questions/options apart.
  3. Add a recommendation rule in OnboardingSurveySeeder.SeedRulesAsync if the new answer should affect a settings patch — either a new Global(ruleKey, condition, patch) call, or an addition to an existing base profile rule's SettingsPatchJson. Choose the RuleKey first, deliberately — it is the row's permanent identity (see §20.6.2): follow the existing {QuestionKeyPascalCase}.{AnswerValue} convention for a new global rule, never derive it from Arabic/English text, never generate it randomly, and never reuse or rename an existing RuleKey once released.
  4. If the rule references a settings group not already whitelisted, follow §20.9.2 first — a rule referencing an unwhitelisted group is silently ignored with a warning, not an error.
  5. If the new patch content should show up in the recommendation's human-readable summary, add an Onboarding.Summary.{Name} key to both resource files, then a matching line in OnboardingRecommendationService.BuildSummary via the existing SummaryLine(key) helper — never inline a new Ar/En string literal there.
  6. Add tests: a SettingsPatchServiceShould case (MultiTenancyApi) if a new group/property was whitelisted, and an InternalOnboardingSettingsPatchServiceTests case (BackEnd) proving the apply path writes the expected value.
  7. Update this chapter (question table, options table, and/or rule table) so the documentation stays a faithful mirror of the seeder — do not let source and docs drift.

20.9.2 When adding a new settings group

  1. Confirm the settings class already exists and is exposed through IAppSettingService.GetAppSetting<T>/ SetAppSetting<T>, and that AppSettingViewModel already has a property for it (if not, that is a separate, larger change outside onboarding's scope — see Shumoul.Application/DTOs/Setup/AppSettings/ AppSettingViewModel.cs).
  2. Add the group to SettingsPatchService.Whitelist (Shumoul.Framework.MultiTenancy.Api/Onboarding/ SettingsPatchService.cs) with a curated property list — never whitelist Guid/Guid? properties (they belong in EntityReferencePropertyNames/the apply service's Guid-skip logic instead), and prefer a safe subset over the full class if the class is large, exactly as FinancialSettings did in Phase 8.
  3. Add a case to the switch in InternalOnboardingSettingsPatchService.ApplyAsync (Shumoul.Infrastructure/Services/InternalOnboardingSettingsPatchService.cs): nameof(YourSettings) => await ApplyGroupAsync<YourSettings>(group.Value) — no other code changes are needed, since ApplyGroupAsync<T> is fully generic.
  4. Confirm AppSettingViewModel/IAppSettingService genuinely support the group before wiring it — do not whitelist a group that has no real read/write path, or it will validate successfully but fail (or silently no-op) when applied.
  5. No new localization keys are needed for the group's own validation messages — the "unsupported group", "property does not exist", and "incompatible value" warnings in both SettingsPatchService.cs and InternalOnboardingSettingsPatchService.cs are already generic and {0}/{1}-parameterized by group/ property name (see §20.6). Only add a new key if you introduce a genuinely new kind of validation message — and if so, add it to the resource file matching where the message is raised (Onboarding/Localization/ar.json/en.json for MultiTenancyApi classes, Shumoul.Api/Localization/ar.json/en.json for BackEnd classes) — never as a hardcoded C# literal.
  6. Add an E2E test exercising recommendation + apply for the new group (see §20.7 for the pattern used for FinancialSettings/LoyaltySettings/NotificationSettings).
  7. Update Chapter 11, §11.2 and this chapter's §20.5 with the new group.