Accounting Chart Templates
Phases 5A / 5A.1 (catalog + import), 5B.1 / 5B.1.1 (apply + entitlement), 5B.2 (mappings), 5D
(activity-specific defaults). Before this work, every tenant received one identical, hardcoded
161-account chart (ChartofAccounts.json, applied by ApplicationDbSeeder) — no multi-template concept
existed at all.
Schema (SaaS DB)
| Table | Purpose |
|---|---|
Saas.AccountingChartTemplates | One row per template — Code (unique), Name/FName, BusinessActivityId FK, Version, IsDefault, IsActive |
Saas.AccountingChartTemplateAccounts | Mirrors dbo.Accounts field-for-field: AccountCode/ParentAccountCode, Name/FName, Level, AccountType/GLFinalReport/AccountBalanceType (local enum mirrors), 7 *Mandatory flags |
Saas.AccountingChartTemplateMappings | MappingKey (open string) + AccountCode — resolved to a real tenant destination field at apply time, see §"Mapping resolution" below |
Unique filtered indexes: Code (WHERE Is_Deleted=0); (BusinessActivityId, IsDefault) WHERE
IsDefault=1 AND Is_Deleted=0 (at most one default template per activity); (TemplateId, AccountCode);
(TemplateId, MappingKey).
All three tables are soft-deleted (ISoftDelete) — matching every catalog table introduced before the
Product Catalog family, unlike BusinessActivity which is
hard-delete-only.
JSON and Excel import
Two front doors onto the same save/validation pipeline (AccountingChartTemplateService,
AccountingChartTemplateValidation) — no parallel logic, no save logic implemented twice:
- JSON import (Phase 5A):
POST Import/Preview/Import, payload matches the SaaS schema directly. - Excel import (Phase 5A.1):
POST Import/Excel/Preview/Import/Excel(multipart form + aTemplateGET that downloads a blank.xlsx). Three sheets — Templates, Accounts (required), Mappings (optional) — header-name-matched case-insensitively, column order irrelevant, unrecognized column is a warning not an error, blank rows skipped silently. Parser uses ClosedXML 0.105.0 (MIT-licensed — chosen over EPPlus, which requires a commercial license at v5+, and over NPOI).
Overwrite semantics (both import paths, identical):
Overwrite | Existing Code | Behavior |
|---|---|---|
false (default) | exists | Left untouched, skipped |
true | exists | Editable fields replaced |
| either | doesn't exist | Inserted |
Rows already in the database but absent from the import payload are never removed — "update what's provided," never a destructive sync. Preview never saves regardless of validity. On commit, if the Excel parser finds any format-level error, nothing is saved at all — partial-file saves are not possible; a JSON/Excel payload with multiple templates does allow each template to succeed/fail independently of the others.
Apply accounts to tenant dbo.Accounts
Phase 5B.1. The explicit, admin-triggered "last mile" — copies a chosen template's accounts into a
tenant's real dbo.Accounts. Accounts only this step — no mappings, no FinancialSettings, no
InventoryAccountCategory, no cost centers, no onboarding hook.
| Endpoint | Host | Auth |
|---|---|---|
POST api/Saas/AccountingChartTemplates/{id}/ApplyToTenantAccounts/Preview | MultiTenancyApi | Permissions.AccountingChartTemplates.Apply |
POST api/Saas/AccountingChartTemplates/{id}/ApplyToTenantAccounts | MultiTenancyApi | same |
POST api/internal/accounting-chart-templates/apply-accounts | Shumoul.Api | [AllowAnonymous] + X-Shumoul-Internal-Key |
Insert-missing-only, idempotency key = (TenantId, Account.Code):
| Situation | Result |
|---|---|
| Code doesn't exist | Inserted (WouldInsert in Preview) |
| Code exists, active | SkippedExisting — never updated |
| Code exists, soft-deleted | Failed — never resurrected |
| Duplicate Code already ambiguous in tenant DB | Whole apply blocked before any write |
ParentAccountCode unresolvable, or a circular parent chain | Whole apply blocked |
Force exists on the request contract but is reserved/unused for this flow — there is no
overwrite-a-blank-field behavior for accounts apply the way there is for mapping/inventory-category/
cost-center apply. CreatedBy resolves to the tenant's own admin user. Governed by
Features.AccountManagement (see §"Entitlement" below).
Mapping resolution
Phase 5B.2. Resolves a template's AccountingChartTemplateMapping rows into a tenant's existing
dbo.Accounts, filling only confirmed real destination fields — never guesses, never creates an
Account/CostCenter/InventoryAccountCategory row, never writes dbo.Accounts itself.
| MappingKey | Destination | Status |
|---|---|---|
Inventory | InventoryAccountCategory.InventoryId | Supported |
CostOfGoodsSold | .CostId | Supported |
SalesRevenue | .RevenueId | Supported |
SalesReturn | .SalesReturnId | Supported |
CashOnHand, BankAccount, AccountsReceivable, AccountsPayable, VatInput, VatOutput, CapitalAccount, RetainedEarnings | — | Unsupported — no confirmed tenant-wide settings field exists; the closest real fields are per-register/per-customer/per-supplier, not tenant-wide |
The one-category-row rule: exactly one non-deleted InventoryAccountCategory row → apply target;
zero rows → Unsupported (this specific gap is what Phase 5B.3
closed); more than one row → Unsupported, never guessed which one.
Force=false (default): never overwrites an existing non-null field — only fills currently-empty
fields (SkippedAlreadySet when already set). Force=true: overwrites, but only for a supported,
resolved destination — never for an unsupported MappingKey, regardless of Force. A missing
AccountCode in dbo.Accounts reports MissingAccount for that one mapping without blocking the rest
of the batch.
Endpoints (Permissions.AccountingChartTemplates.ApplyMappings — a separate permission from
.Apply, since mapping-apply touches more sensitive settings than plain accounts-apply):
POST api/Saas/AccountingChartTemplates/{id}/ApplyMappingsToTenant/Preview
POST api/Saas/AccountingChartTemplates/{id}/ApplyMappingsToTenant
POST api/internal/accounting-chart-templates/apply-mappings (Shumoul.Api, same internal key)
Features.AccountManagement entitlement
Phase 5B.1.1. Chart-accounts apply, mapping apply, inventory account category apply,
and cost center apply are all gated by the same real,
admin-CRUD-seeded feature — Features.AccountManagement — resolved via
ISubscriptionFeatureEntitlementService.IsFeatureAllowedAsync(tenantId, "Features.AccountManagement").
This replaced an earlier, explicitly-temporary proxy (checking the FinancialSettings settings-group
grant instead of a real feature). See Settings Groups and Entitlements
for how features and settings groups relate.
Activity-specific Saudi/US-style default chart templates
Phase 5D (2026-07-16). Replaced the original 32-account _Basic_COA starter templates as the
default with richer, Saudi-market-friendly, US-style charts (72–81 accounts) for all 5 business
activities:
| Business Activity | New default (Version 1) | Original (kept, IsDefault=false) |
|---|---|---|
| RestaurantCafe | RestaurantCafe_Saudi_USStyle_COA_v1 | RestaurantCafe_Basic_COA |
| Supermarket | Supermarket_Saudi_USStyle_COA_v1 | Supermarket_Basic_COA |
| RetailStore | RetailStore_Saudi_USStyle_COA_v1 | RetailStore_Basic_COA |
| Services | Services_Saudi_USStyle_COA_v1 | Services_Basic_COA |
| WholesaleDistribution | WholesaleDistribution_Saudi_USStyle_COA_v1 | WholesaleDistribution_Basic_COA |
The originals are kept, never deleted — IsActive=true, IsDefault=false — as a historical/
comparison reference; zero data loss.
Account-number-range convention (explicit, documented starting with this phase):
1xxx Assets (Current Assets, Fixed Assets)
2xxx Liabilities (Accounts Payable, VAT Payable, Accrued Expenses)
3xxx Equity (Capital, Retained Earnings, Current Year Profit/Loss)
4xxx Revenue (Sales Revenue, Returns, Discounts + activity-specific)
5xxx Cost of Sales (COGS, Purchase Discounts/Returns + activity-specific)
6xxx Operating Expenses (Payroll, Occupancy, Selling, G&A)
7xxx Other Income / Expenses (minimal)
66 accounts are common to every activity (Assets 18, Liabilities 10, Equity 6, Revenue 7, Cost of
Sales 5, Operating Expenses 15, Other 5); the rest are activity-specific. Account codes may legitimately
repeat across different templates (e.g. 11301 is "Food Inventory" in RestaurantCafe but "Merchandise
Inventory" in RetailStore) — uniqueness is scoped to (TemplateId, AccountCode), not global.
A companion {Activity}_Saudi_USStyle_Inventory_Accounting_v1 Inventory Account Category Template
was seeded per activity, linked to the new chart template.
Every template's description explicitly states it is a starter default, not certified tax, accounting, or legal advice — "review with a licensed accountant before production use."
This phase also introduced the template-demotion seeder rule every template family in this
initiative now follows: when a newly-inserted seed template is IsDefault=true, any other existing
template for the same BusinessActivityId that's also IsDefault=true is demoted (IsDefault=false)
in the same seed run — never touches a different field, never touches a different activity.
