Skip to main content
Version: Latest

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)

TablePurpose
Saas.AccountingChartTemplatesOne row per template — Code (unique), Name/FName, BusinessActivityId FK, Version, IsDefault, IsActive
Saas.AccountingChartTemplateAccountsMirrors dbo.Accounts field-for-field: AccountCode/ParentAccountCode, Name/FName, Level, AccountType/GLFinalReport/AccountBalanceType (local enum mirrors), 7 *Mandatory flags
Saas.AccountingChartTemplateMappingsMappingKey (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 + a Template GET 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):

OverwriteExisting CodeBehavior
false (default)existsLeft untouched, skipped
trueexistsEditable fields replaced
eitherdoesn't existInserted

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.

EndpointHostAuth
POST api/Saas/AccountingChartTemplates/{id}/ApplyToTenantAccounts/PreviewMultiTenancyApiPermissions.AccountingChartTemplates.Apply
POST api/Saas/AccountingChartTemplates/{id}/ApplyToTenantAccountsMultiTenancyApisame
POST api/internal/accounting-chart-templates/apply-accountsShumoul.Api[AllowAnonymous] + X-Shumoul-Internal-Key

Insert-missing-only, idempotency key = (TenantId, Account.Code):

SituationResult
Code doesn't existInserted (WouldInsert in Preview)
Code exists, activeSkippedExisting — never updated
Code exists, soft-deletedFailed — never resurrected
Duplicate Code already ambiguous in tenant DBWhole apply blocked before any write
ParentAccountCode unresolvable, or a circular parent chainWhole 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.

MappingKeyDestinationStatus
InventoryInventoryAccountCategory.InventoryIdSupported
CostOfGoodsSold.CostIdSupported
SalesRevenue.RevenueIdSupported
SalesReturn.SalesReturnIdSupported
CashOnHand, BankAccount, AccountsReceivable, AccountsPayable, VatInput, VatOutput, CapitalAccount, RetainedEarningsUnsupported — 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 ActivityNew default (Version 1)Original (kept, IsDefault=false)
RestaurantCafeRestaurantCafe_Saudi_USStyle_COA_v1RestaurantCafe_Basic_COA
SupermarketSupermarket_Saudi_USStyle_COA_v1Supermarket_Basic_COA
RetailStoreRetailStore_Saudi_USStyle_COA_v1RetailStore_Basic_COA
ServicesServices_Saudi_USStyle_COA_v1Services_Basic_COA
WholesaleDistributionWholesaleDistribution_Saudi_USStyle_COA_v1WholesaleDistribution_Basic_COA

The originals are kept, never deletedIsActive=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.