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
| Field | Value |
|---|---|
| Survey code | default-business-setup (OnboardingConstants.SurveyCode) |
| Survey version | 1 (OnboardingConstants.SurveyVersion) |
| Name (AR) | تهيئة نشاطك |
| Name (EN) | Business Setup |
| Seeded by | OnboardingSurveySeeder (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.
| # | Key | Step | Type | Required | VisibleWhen | Label (AR) | Label (EN) |
|---|---|---|---|---|---|---|---|
| 1 | activityType | 1 | SingleChoice | Yes | — | ما هو نشاطك الأساسي؟ | What is your main business activity? |
| 2 | branchesCount | 1 | SingleChoice | No | — | كم عدد الفروع؟ | How many branches do you have? |
| 3 | usersCount | 1 | SingleChoice | No | — | كم عدد المستخدمين المتوقعين؟ | How many users do you expect? |
| 4 | hasPos | 2 | Boolean | Yes | — | هل تستخدم الكاشير / نقاط البيع؟ | Do you use POS / cashier? |
| 5 | paymentMethods | 2 | MultiChoice | No | — | ما طرق الدفع التي تستقبلها؟ | Which payment methods do you accept? |
| 6 | requireCustomerBeforeClosing | 2 | SingleChoice | No | — | هل تريد تسجيل بيانات العميل قبل إغلاق الفاتورة؟ | Do you want to collect customer information before closing invoices? |
| 7 | preventOutOfStockSales | 2 | Boolean | No | — | هل تريد منع بيع المنتجات غير المتوفرة في المخزون؟ | Do you want to prevent selling out-of-stock products? |
| 8 | hasTables | 3 | Boolean | No | activityType = RestaurantCafe | هل لديك طاولات داخلية؟ | Do you have dine-in tables? |
| 9 | hasKitchen | 3 | Boolean | No | activityType = RestaurantCafe | هل لديك مطبخ أو تحضير طلبات؟ | Do you have a kitchen or order preparation area? |
| 10 | usesModifiers | 3 | Boolean | No | activityType = RestaurantCafe | هل تستخدم الإضافات والمعدلات؟ مثل الحجم أو الصوص أو الإضافات | Do you use modifiers such as size, sauce, or add-ons? |
| 11 | showCalories | 3 | Boolean | No | activityType = RestaurantCafe | هل تريد دعم السعرات الحرارية؟ | Do you want to support calories? |
| 12 | hasInventory | 4 | Boolean | No | — | هل تتابع المخزون؟ | Do you track inventory? |
| 13 | hasExpiryProducts | 4 | Boolean | No | — | هل لديك منتجات لها تاريخ صلاحية؟ | Do you have products with expiry dates? |
| 14 | usesBatchNumber | 4 | Boolean | No | — | هل تستخدم رقم تشغيلة / Batch؟ | Do you use batch numbers? |
| 15 | usesSerialNumbers | 4 | Boolean | No | — | هل تبيع منتجات برقم تسلسلي؟ | Do you sell products with serial numbers? |
| 16 | usesScale | 4 | Boolean | No | — | هل تبيع بالوزن أو الميزان؟ | Do you sell by weight or scale? |
| 17 | usesMultiUnits | 4 | Boolean | No | — | هل المنتج له أكثر من وحدة؟ مثل حبة، كرتون، باكيت | Do products have multiple units, such as piece, carton, or pack? |
| 18 | usesVat | 5 | Boolean | Yes | — | هل منشأتك مسجلة في ضريبة القيمة المضافة؟ | Is your business registered for VAT? |
| 19 | pricesIncludeVat | 5 | Boolean | No | usesVat = true | هل أسعار البيع شاملة الضريبة؟ | Are your sales prices VAT-inclusive? |
| 20 | needsCostCenters | 5 | SingleChoice | No | — | هل تحتاج مراكز تكلفة؟ | Do you need cost centers? |
| 21 | needsProjects | 5 | Boolean | No | — | هل تحتاج ربط العمليات بالمشاريع؟ | Do you need to link transactions to projects? |
| 22 | hasSalesmen | 5 | Boolean | No | — | هل لديك مناديب مبيعات؟ | Do you have sales representatives? |
| 23 | hasMarketers | 5 | Boolean | No | — | هل لديك مسوقين أو عمولات تسويق؟ | 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 value | Label (AR) | Label (EN) | Business meaning |
|---|---|---|---|
RestaurantCafe | مطعم / كوفي | Restaurant / Cafe | Selects the RestaurantCafe profile — tables, kitchen routing, modifiers, calories, loyalty |
Supermarket | سوبرماركت / بقالة | Supermarket / Grocery | Selects the Supermarket profile — expiry/batch/scale/multi-unit tracking, no customer-required sales |
RetailStore | متجر تجزئة | Retail Store | Selects the RetailStore profile — multi-images, tags, multi-unit, loyalty |
Services | خدمات بدون مخزون | Services without Inventory | Selects the Services profile — no inventory tracking, booking enabled, sales tax only (no purchase tax) |
WholesaleDistribution | جملة وتوزيع | Wholesale & Distribution | Selects 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
| Question | Options (value / AR / EN) |
|---|---|
branchesCount | One (فرع واحد / One) · TwoToFive (من 2 إلى 5 فروع / 2 to 5 branches) · MoreThanFive (أكثر من 5 فروع / More than 5 branches) |
usersCount | OneToThree (من 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
| Value | AR | EN |
|---|---|---|
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
| Value | AR | EN | Recommendation effect |
|---|---|---|---|
No | لا | No | No global rule fires (the absence of Always/OnlyReturnsAndCoupons means neither of the two conditional rules below match) |
Always | دائماً | Always | CashierAppsSettings.Require_Customer_Info_Before_Closing = true, Require_Mobile_Number = true |
OnlyReturnsAndCoupons | فقط للمرتجعات والكوبونات | Only for Returns and Coupons | CashierAppsSettings.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
| Value | AR | EN | Recommendation effect |
|---|---|---|---|
No | لا | No | CostCenterSettings all-false + FinancialSettings all-false (cost center optional everywhere) |
Simple | بسيطة | Simple | CostCenterSettings enabled-but-optional on sales/purchase/journal entries + FinancialSettings mandatory flags false |
Mandatory | إلزامية | Mandatory | CostCenterSettings 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)
| Profile | Settings groups touched | Notable choices |
|---|---|---|
RestaurantCafe | CashierAppsSettings, ProductSettings, DiscountSettings, TaxSettings, LoyaltySettings | Tables + kitchen routing on, modifiers/ingredients/calories on, loyalty enabled |
Supermarket | CashierAppsSettings, ProductSettings, DiscountSettings, TaxSettings | Expiry/batch/scale/multi-unit/packaging on, Enable_Customer_Sales = false, no loyalty |
RetailStore | CashierAppsSettings, ProductSettings, DiscountSettings, TaxSettings, LoyaltySettings | Multi-images/tags/multi-unit on, loyalty enabled |
Services | CashierAppsSettings, ProductSettings, TaxSettings | No expiry/batch/serial/scale/multi-unit, booking enabled, purchase tax off |
WholesaleDistribution | CashierAppsSettings, ProductSettings, DiscountSettings, TaxSettings | Batch/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) affected | What it sets |
|---|---|---|
usesVat = false | TaxSettings | Disables sales tax, purchase tax, and price-includes-tax |
pricesIncludeVat = true | TaxSettings | Sales_Price_Include_Tax = true |
pricesIncludeVat = false | TaxSettings | Sales_Price_Include_Tax = false |
hasTables = false | CashierAppsSettings | Disables the tables system and its guest-count/required flags |
hasKitchen = false | CashierAppsSettings, ProductSettings | Disables kitchen auto-routing and kitchen prep time |
usesModifiers = true | ProductSettings | Enables modifiers and ingredients |
showCalories = true | ProductSettings | Enables calories |
hasExpiryProducts = true | ProductSettings | Enables expiry date tracking |
usesBatchNumber = true | ProductSettings | Enables batch numbers |
usesSerialNumbers = true | ProductSettings | Enables serial numbers |
usesScale = true | ProductSettings | Enables scale/weight selling |
usesMultiUnits = true | ProductSettings | Enables multi-units and packaging info |
preventOutOfStockSales = true | CashierAppsSettings | Prevent_Selling_Out_of_Stock_Products = true |
requireCustomerBeforeClosing = Always | CashierAppsSettings | Requires customer info + mobile number before closing |
requireCustomerBeforeClosing = OnlyReturnsAndCoupons | CashierAppsSettings | Requires customer info only for returns/coupons |
needsCostCenters = No | CostCenterSettings, FinancialSettings | Cost center fully optional, everywhere |
needsCostCenters = Simple | CostCenterSettings, FinancialSettings | Cost center enabled but optional |
needsCostCenters = Mandatory | CostCenterSettings, FinancialSettings | Cost center enabled and mandatory everywhere |
needsProjects = true | ProjectSettings | Enables project linkage on sales/purchase invoices and payment/receipt vouchers |
hasSalesmen = true | SalesmanSettings | Enables salesman-on-invoice + auto commission calculation |
hasMarketers = true | MarketerSettings | Enables marketer-on-invoice + auto commission calculation |
hasInventory = true | NotificationSettings | Enables 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:
- Arabic —
Shumoul.Framework.MultiTenancy.Api/Onboarding/Localization/ar.json - English —
Shumoul.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:
| Concept | Key pattern | Example |
|---|---|---|
| Survey name | Onboarding.Survey.Name | — |
| Question label | Onboarding.Question.{PascalKey}.Label | Onboarding.Question.ActivityType.Label |
| Answer option label | Onboarding.Option.{PascalKey}.{OptionValue}.Label | Onboarding.Option.ActivityType.RestaurantCafe.Label |
| Profile display name | Onboarding.Profile.{Code}.Name | Onboarding.Profile.RetailStore.Name |
| Step title | Onboarding.Step.{n}.Title / Onboarding.Step.Fallback.Title | Onboarding.Step.1.Title |
| Recommendation summary line | Onboarding.Summary.{Name} | Onboarding.Summary.LoyaltyRewardsEnabled |
| POS-related warning | Onboarding.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'sOptionsJson),OnboardingProfile.Code, or the survey'sCode+Version— never byTitleAr/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, andVisibleWhenJsonare 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 viaOnboardingLocalization(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.cs—OnboardingStepTitles' 5 hardcoded step-title pairs (plus its "Step {n}" fallback) now resolve fromOnboardingLocalization.Shumoul.Framework.MultiTenancy.Api/Onboarding/OnboardingRecommendationService.cs—BuildSummary's 9 summary lines andBuildWarningsAndNextActions's 3 POS-related warning sentences now resolve fromOnboardingLocalization(the 3 warnings resolve.Enonly, 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:
| Table | Unique index | Matches this chapter's stable key |
|---|---|---|
OnboardingSurveyDefinitions | (Code, Version) | Survey Code+Version |
OnboardingQuestions | (SurveyDefinitionId, Key) | Question Key |
OnboardingProfiles | Code | Profile 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.cs—SeedSurveyAsync,SeedQuestionsAsync,SeedProfilesAsyncnow fill-blank-only instead of always-overwrite; newFillIfEmpty/MergeOptionsJsonhelpers.SeedRulesAsyncis unchanged —SettingsPatchJson/Priorityare 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:
| RuleKey | ProfileCode | ConditionJson |
|---|---|---|
Profile.RestaurantCafe | RestaurantCafe | — |
Profile.Supermarket | Supermarket | — |
Profile.RetailStore | RetailStore | — |
Profile.Services | Services | — |
Profile.WholesaleDistribution | WholesaleDistribution | — |
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— newRuleKeyproperty.Shumoul.Framework.MultiTenancy.Api/Tenants/TenantDbContext.cs— new unique index onRuleKey.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 aRuleKey;SeedRulesAsyncmatches byRuleKeyfirst, 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, uniqueRuleKey; a duplicateRuleKeyis rejected at the database level (DbUpdateException); aConditionJsonformatting change doesn't create a duplicate whenRuleKeyis unchanged; a pre-migration row (simulated by clearingRuleKey) is backfilled onto the same row; the fullRuleKeyset is identical across repeated reseeds.- No question key, answer-option key, recommendation logic, settings-patch behavior, or apply behavior
changed —
ProfileCode/ConditionJsonremain exactly what the recommendation engine evaluates against;RuleKeyis 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 embeddedOnboardingLocalizationreader from above) — forSettingsPatchServiceandHttpOnboardingTenantSettingsApplier, 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-wideIStringLocalizer<T>— the same mechanismOnboardingService's own validation/success messages already used) — forInternalOnboardingSettingsPatchServiceandOnboardingInternalController, since both live inShumoul.Infrastructure/Shumoul.Apithemselves, where that mechanism is already the established, request-culture-aware convention (ZooBaseControllerexposes_localizer— anIStringLocalizer<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):
SettingsPatchService.Whitelist(Shumoul.Framework.MultiTenancy.Api/Onboarding/ SettingsPatchService.cs) — none of the three groups were in the whitelist dictionary at all (not even asSupported: false). Fixed by adding all three with curated safe property subsets:FinancialSettingsgets 15 of its ~80 properties (system-level toggles + voucher-level cost-center mandatory flags — noGuid-typed properties, consistent with the existingEntityReferencePropertyNamesconvention);LoyaltySettingsandNotificationSettingsare small classes with noGuidproperties, so every property on each is whitelisted (12 and 15 respectively).InternalOnboardingSettingsPatchService.ApplyAsync'sswitch(Shumoul.Infrastructure/Services/ InternalOnboardingSettingsPatchService.cs) — none of the three groups had a case, so they fell through to_ => nulland were reported as ignored even if they had passed the (nonexistent) whitelist. Fixed by addingnameof(FinancialSettings) => await ApplyGroupAsync<FinancialSettings>(...)and the equivalent for the other two, reusing the exact same generic reflection-basedApplyGroupAsync<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 theRestaurantCafeandRetailStorebase profile rules.FinancialSettingsmandatory-cost-center flags added alongside the existingCostCenterSettingstoggles on all threeneedsCostCenters(No/Simple/Mandatory) global rules.NotificationSettings(5 inventory-related admin notification toggles) added as a new global rule gated on the existinghasInventory = trueanswer.OnboardingRecommendationService.BuildSummarygiven 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 bug —
SeedRulesAsyncpreviously only ever inserted a rule row if none existed for its(ProfileCode, ConditionJson)key; it never updated an existing row'sSettingsPatchJson. Any environment that had already seeded theRetailStorebase rule or theneedsCostCentersglobal rules before Phase 8 would silently keep the old patch content forever, even after the seeder source code changed. Fixed by having the seeder updateSettingsPatchJson/Priorityon an existing matching rule when they differ from source, instead of skipping it. - Apply bridge timeout —
AppSettingService.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, exceedingOnboardingApplyBridgeSettings.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 asFinancialSettings(~80 properties). It was mitigated for onboarding by raising the apply bridge's timeout to 120 seconds (see §20.7), not by changingAppSettingServiceitself — that service is widely used elsewhere and out of scope for this feature.SalesInvoiceTemplateSettingsandPrinterSettingsremain unwired in the onboarding whitelist and apply switch — see Chapter 11, §11.3.PrinterSettingsis persisted through a separate per-device mechanism entirely, not throughIAppSettingService, so it may never be a candidate for this patch engine.SalesInvoiceTemplateSettingsis a plain unwired gap, same status as before Phase 8.AppSettingsController.SetLoyaltyAsynctakesFinancialSettingsas its request parameter type instead ofLoyaltySettings. This is a pre-existing bug in that controller action, found while inspectingAppSettingsControllerduring Phase 8 — it is unrelated to onboarding apply, since the onboarding path callsIAppSettingService.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.— resolved 2026-07-13, see §20.6.2.OnboardingRulehas no unique database index on its match keyRuleKeyis now the match key, backed by a real unique index.
20.9 Developer Guidance
20.9.1 When adding a new onboarding option
- Add a new question constant to
OnboardingConstants.Questions(and a new profile constant toOnboardingConstants.Profilesif it's a new business-activity type). - Add the localization keys first —
Onboarding.Question.{PascalKey}.Label(andOnboarding.Option.{PascalKey}.{Value}.Labelper choice option) to bothOnboarding/Localization/ar.jsonanden.json(see §20.6 for the key convention). Then add the question toOnboardingSurveySeeder.BuildQuestions()viaQ(key, type, step, required, optionValues)— notitleAr/titleEn/label parameters are passed in-line; the seeder derives the localization key fromkeyautomatically 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-emptyTitleAr/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 — editingar.json/en.jsononly changes what a new installation seeds and what fills a field that is still blank. The new question'sKey(and each option'sValue) 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. - Add a recommendation rule in
OnboardingSurveySeeder.SeedRulesAsyncif the new answer should affect a settings patch — either a newGlobal(ruleKey, condition, patch)call, or an addition to an existing base profile rule'sSettingsPatchJson. Choose theRuleKeyfirst, 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 existingRuleKeyonce released. - 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.
- 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 inOnboardingRecommendationService.BuildSummaryvia the existingSummaryLine(key)helper — never inline a newAr/Enstring literal there. - Add tests: a
SettingsPatchServiceShouldcase (MultiTenancyApi) if a new group/property was whitelisted, and anInternalOnboardingSettingsPatchServiceTestscase (BackEnd) proving the apply path writes the expected value. - 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
- Confirm the settings class already exists and is exposed through
IAppSettingService.GetAppSetting<T>/SetAppSetting<T>, and thatAppSettingViewModelalready has a property for it (if not, that is a separate, larger change outside onboarding's scope — seeShumoul.Application/DTOs/Setup/AppSettings/ AppSettingViewModel.cs). - Add the group to
SettingsPatchService.Whitelist(Shumoul.Framework.MultiTenancy.Api/Onboarding/ SettingsPatchService.cs) with a curated property list — never whitelistGuid/Guid?properties (they belong inEntityReferencePropertyNames/the apply service's Guid-skip logic instead), and prefer a safe subset over the full class if the class is large, exactly asFinancialSettingsdid in Phase 8. - Add a case to the
switchinInternalOnboardingSettingsPatchService.ApplyAsync(Shumoul.Infrastructure/Services/InternalOnboardingSettingsPatchService.cs):nameof(YourSettings) => await ApplyGroupAsync<YourSettings>(group.Value)— no other code changes are needed, sinceApplyGroupAsync<T>is fully generic. - Confirm
AppSettingViewModel/IAppSettingServicegenuinely 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. - 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.csandInternalOnboardingSettingsPatchService.csare 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.jsonfor MultiTenancyApi classes,Shumoul.Api/Localization/ar.json/en.jsonfor BackEnd classes) — never as a hardcoded C# literal. - Add an E2E test exercising recommendation + apply for the new group (see §20.7
for the pattern used for
FinancialSettings/LoyaltySettings/NotificationSettings). - Update Chapter 11, §11.2 and this chapter's §20.5 with the new group.
