Skip to main content
Technical ManualLooking for step-by-step instructions? In the User Manual: Business Profile

Foundation (CrestApps.Almohaseb)

ModuleAlmohaseb (CrestApps.Almohaseb)
Feature NameAlmohaseb Foundation
Feature IDCrestApps.Almohaseb
EnabledBy dependency only (hidden in Tools > Features)
LibrariesCrestApps.Almohaseb.Core (namespace CrestApps.Almohaseb.Core), CrestApps.Almohaseb.Abstractions (root namespace CrestApps.Almohaseb)

The foundation is the base every Al-Mohaseb feature depends on. It adds:

  • the business profile site settings group (Settings > Business profile) and IBusinessProfileService;
  • the business calendar (IBusinessCalendar): business dates, business-day windows and fiscal periods in the business's time zone;
  • sensitive-data protection (ISensitiveDataProtector) and masking for identifiers such as the EIN;
  • document numbering (IDocumentNumberService): gap-free named sequences for invoices, receipts, journal entries and checks;
  • the foundation permissions and the Business profile deployment step.

The primitives in CrestApps.Almohaseb.Abstractions (Money, FiscalCalendar, EmployerIdentificationNumber, ...) have no Orchard Core dependency and can be used from any project.

Enabling the foundation​

CrestApps.Almohaseb is declared with EnabledByDependencyOnly = true, so it never appears as a choice in Tools > Features. It is turned on by a managed feature that depends on it, today Accounting (CrestApps.Almohaseb.Ledger, see Ledger).

A recipe cannot enable a dependency-only feature directly. Enable the managed feature instead; the setup recipes almohaseb-business-cstore and almohaseb-business-service enable CrestApps.Almohaseb.Ledger, which pulls in the foundation. See Recipes and the Feature reference.

Home page​

A business site has no public pages, so the root of the site opens the admin. The foundation depends on OrchardCore.HomeRoute (and on OrchardCore.Contents, for the content names below, and CrestApps.OrchardCore.Resources, for the date-range picker), and HomeRouteMigrations sets the site's home route to HomeController.Index (area CrestApps.Almohaseb), which redirects to the admin prefix; someone not signed in lands on the sign-in page. A site that already has a home route keeps it.

Content type and part names​

Every Al-Mohaseb content type and part has a technical name that starts with Almohaseb (AlmohasebContentNames.Prefix): AlmohasebVendor, AlmohasebVendorPart, AlmohasebSalesCategory, AlmohasebTrip and so on. A site can enable CrestApps Commerce, Marketplace and other modules next to Al-Mohaseb, and their types and parts (CrestApps Marketplace has its own VendorPart) would otherwise share names with ours. The prefix is technical only: users see the display names (Vendor, Sales category), and each part has the display name of its type.

  • Name a new type Almohaseb{Name} and a new part class Almohaseb{Name}Part, and give both a display name in the migration (WithDisplayName). ContentNameTests fails when a ContentPart class, or a content type constant (*ContentType, *WidgetType, a ContentTypes class), does not start with the prefix.
  • Index tables keep their names (VendorPartIndex, SalesCategoryPartIndex): they are Al-Mohaseb's own and none collides.
  • Code reads a type or part through its constant or nameof(...), never a string.
  • Orchard Core's admin content list shows each row's type as its technical name. The foundation overrides that badge (ContentsType_SummaryAdmin) to show the type's display name, for every type, so a list shows Sales category and not AlmohasebSalesCategory; the badge still filters the list by its type.

Renaming a site set up before the prefix​

ContentNamesMigrations (registered by ContentNamesStartup) runs once on each tenant and calls ContentNameRenamer.RenameAsync(AlmohasebContentNames.LegacyTypes, AlmohasebContentNames.LegacyParts). On a site set up after the prefix there is nothing to rename. On an older site it renames in place:

  1. Parts. Each old part definition that exists is stored under its new name with its fields and settings; a part without a display name takes the display name of its type (VendorPart and the Vendor type's own part become Vendor).
  2. Types. Every type definition, Al-Mohaseb's and any other type a site attached an Al-Mohaseb part to, is stored with its new name and its parts pointing to the new part definitions. A part attached under its own name gets the new name as its key; one attached under another name (Billing of type VendorPart) keeps its key. The old type and part definitions are then deleted.
  3. Content items. Every document of a renamed type (all versions, found through ContentItemIndex) gets the new ContentType and the renamed part keys, and is saved in batches of 100, then saved again. YesSql deletes a document's rows of an index only when the index's filter takes the document as it was stored, and a part index filters on its part (Has<AlmohasebVendorPart>()), so the first save keeps the old rows beside the new ones; the second, with the new names stored, replaces them. UpdateFrom1Async (RewriteIndexesAsync) does that second save for a site renamed before it existed.
  4. Permissions. When Roles is on, each role's content permissions for a renamed type (EditOwn_Vendor, ListContent_SalesCategory, ...) are renamed (EditOwn_AlmohasebVendor).

A name that does not exist, or whose new name already exists, is skipped, so the rename can run again safely. The log line Renamed {Types} content types and {Parts} parts, and {Items} content items. reports what it did.

Date filters​

BusinessDateModelBinder (CrestApps.Almohaseb.Mvc), added to MvcOptions.ModelBinderProviders by the foundation, binds a DateOnly or DateOnly? from a date (2026-10-21) or a date and time (2026-10-21T23:59, its date is kept), so the CrestApps date-range picker of every list posts to actions that take dates. An empty value binds null; anything else is a model error.

Business profile​

The business profile is a site settings object, BusinessProfileSettings (CrestApps.Almohaseb.Core.Models), stored on the tenant's ISite document under the property name BusinessProfileSettings. The admin editor is BusinessProfileSettingsDisplayDriver, a SiteDisplayDriver<BusinessProfileSettings> on the settings group almohaseb-business-profile (AlmohasebConstants.SettingsGroups.BusinessProfile), served at /Admin/Settings/almohaseb-business-profile.

The group is mapped to Manage the business profile with AddSiteSettingsPermission, and the driver also returns no editor (and ignores updates) for users without that permission. How users fill in the profile is described in the User Manual: Business profile.

PropertyTypeNotes
OwnerNamestringRequired by the editor. Trimmed.
BusinessNamestringLegal name. Required. Trimmed.
DoingBusinessAsstringDBA. Required. Trimmed.
PhonestringRequired. Normalised to 000-000-0000 by UsPhoneNumber unless IsForeignPhone is set.
IsForeignPhoneboolWhen true, Phone is stored as entered (trimmed).
Street, CitystringRequired. Trimmed.
StatestringTwo-letter USPS code, validated by UsStates.IsValid and stored upper-case.
PostalCodestring12345 or 12345-6789, normalised by UsPostalCode.Normalize.
ProtectedEmployerIdentificationNumberstringThe EIN digits, protected by ISensitiveDataProtector. Never store clear text here; write it through IBusinessProfileService.SetEmployerIdentificationNumber.
MaskedEmployerIdentificationNumberstringThe display mask (*****6789), kept so lists and headers never unprotect the EIN.
TaxRegistrationNumberstringState tax registration number ("TR number"). Optional.
IncorporationDateDateOnly?Set only when the business is incorporated.
FiscalYearStartMonthint1-12. Default 1 (January).
BusinessDayStartsAtTimeOnlyLocal time at which the business day starts. Default 00:00.

The editor never sends the EIN back to the browser. A blank EIN on save keeps the stored value; a non-blank one must parse as an EmployerIdentificationNumber, and is then protected and masked. The EIN is required only while no protected value is stored.

Setting the profile from a recipe​

The profile is set with the standard Settings recipe step. The business setup recipes set the calendar values:

{
"name": "Settings",
"BusinessProfileSettings": {
"FiscalYearStartMonth": 1,
"BusinessDayStartsAt": "04:00:00"
}
}

almohaseb-business-cstore uses 04:00:00; almohaseb-business-service uses 00:00:00. Do not put an EIN in a recipe: ProtectedEmployerIdentificationNumber must be produced by the tenant's own data-protection key ring.

IBusinessProfileService​

IBusinessProfileService (scoped, CrestApps.Almohaseb.Core.Services) reads the profile and handles its protected field.

MemberWhat it does
GetAsync()Returns the profile from the cached site settings. Treat the instance as read-only.
GetEmployerIdentificationNumberAsync()Unprotects and parses the EIN for e-files and forms. Returns null when no EIN is stored, or when the stored value cannot be unprotected (another key ring, tampering). Callers that show the result to a user must check View sensitive identifiers in clear text first.
SetEmployerIdentificationNumber(settings, ein)Protects the EIN onto a profile instance and sets its mask. null clears both. The caller saves the site settings.

Business calendar​

IBusinessCalendar (scoped) answers every date question in the business's own time zone and business-day convention, so no module computes a business date from the server clock.

MemberWhat it does
GetTimeZoneAsync()The site time zone (Settings > General > Default Time Zone), else the system time zone from IClock, else UTC. An ID the host does not know falls back to UTC with a warning in the log.
GetTodayAsync()The current business date, from IClock.UtcNow.
GetBusinessDateAsync(DateTime utc)The business date that contains a UTC instant. A local time before BusinessDayStartsAt belongs to the previous date.
GetWindowAsync(DateOnly date)The BusinessDayWindow (UTC start inclusive, end exclusive) of a business date.
GetFiscalPeriodAsync(DateOnly date)The FiscalPeriod containing a date, using FiscalYearStartMonth.

The window is DST-safe: on a spring-forward night a day start that falls in the skipped hour moves to the first valid minute, and an ambiguous fall-back time uses its first (daylight) occurrence. Business days are therefore 23 or 25 hours long on transition days, and consecutive windows never overlap or leave a gap.

Sensitive-data protection and masking​

ISensitiveDataProtector (singleton) protects identifiers such as EINs, SSNs and bank account numbers at rest with the tenant's ASP.NET Core data-protection key ring.

  • Protect(purpose, plaintext) returns the protected string.
  • TryUnprotect(purpose, protectedValue, out plaintext) returns false (never throws) when the value is empty, tampered with, or protected with another key ring or purpose.

Each field uses its own purpose string, so a value protected for one field cannot be unprotected as another. The business EIN uses AlmohasebConstants.ProtectionPurposes.EmployerIdentificationNumber (CrestApps.Almohaseb.BusinessProfile.EmployerIdentificationNumber).

For display, store a mask next to the protected value with SensitiveValueMask.Mask, which keeps the last four characters (*****6789 for an EIN). No admin screen shows the business EIN in clear text today.

Moving a profile between tenants

The protected EIN can only be unprotected with the key ring of the tenant that protected it. After a profile is imported into another tenant, the screen still shows the imported mask, but GetEmployerIdentificationNumberAsync returns null there. Enter the EIN again on the target tenant.

Document numbering​

IDocumentNumberService (scoped, CrestApps.Almohaseb.Core.Numbering) issues numbers from named sequences.

MemberWhat it does
NextAsync(sequence)Issues the next number and returns a DocumentNumber (Sequence, Value, Formatted).
PeekAsync(sequence)Returns the number the next document will receive, without issuing it or taking the lock, for display.
SetNextAsync(sequence, next)Sets the next number (at least 1), for example the first check of a new checkbook. The chosen number is used once; numbering then continues from it.

Registering a sequence​

Register sequences in a module's Startup with AddDocumentSequence, which adds a DocumentSequenceDefinition to DocumentNumberingOptions:

services.AddDocumentSequence("invoice", sequence =>
{
sequence.Prefix = "INV-";
sequence.MinimumDigits = 5;
});

// A family: every "check:{bankAccountId}" sequence uses this definition.
services.AddDocumentSequence("check:", sequence => sequence.FirstNumber = 1001);
DocumentSequenceDefinitionDefaultMeaning
Prefix""Text before the number, such as INV-.
FirstNumber1The first number, and the first of each year when the sequence resets yearly. Values below 1 are treated as 1.
MinimumDigits0Pads the number with leading zeros to this many digits.
ResetEachYearfalseStarts over at FirstNumber in each fiscal year.
IncludeYearfalseWrites the fiscal year into the number: {Prefix}{year}-{digits}, for example ST-2026-0007.

A name that ends with : defines a family: DocumentNumberingOptions.Get resolves check:123 to the check: definition when there is no exact match. An unregistered name gets a plain sequence that starts at 1. The fiscal year comes from IBusinessCalendar (today's business date and the profile's fiscal year start).

Concurrency and gaps​

The state of every sequence of a tenant is one YesSql document, DocumentSequences, saved with optimistic concurrency in the caller's session. A number is therefore only taken when the caller's unit of work commits; a rolled-back document gives its number back, so sequences have no gaps.

The first NextAsync or SetNextAsync call of a unit of work takes the distributed lock CrestApps.Almohaseb.DocumentNumbers (10-second wait, 1-minute expiry) and holds it until the shell scope has committed, so issuers in a tenant run one after another. If the lock cannot be taken in time, the call throws TimeoutException with Another process is issuing a document number. Try again in a moment. Outside a shell scope, the service saves and releases the lock immediately.

Primitives reference​

These types live in CrestApps.Almohaseb.Abstractions, namespace CrestApps.Almohaseb.

TypeKindWhat it is
Moneyreadonly record structAn Amount (decimal) in a Currency (three-letter ISO 4217 code, upper-cased; the default value is zero USD). + and - between different currencies throw InvalidOperationException; * multiplies by a decimal. Round(policy) rounds to two decimals. Helpers: Money.Usd(amount), Money.Zero(currency), IsZero. ToString() gives 123.45 USD.
RoundingPolicyenumHalfAwayFromZero (default; 2.345 becomes 2.35, the convention of tax forms and payroll) or HalfEven (banker's rounding; 2.345 becomes 2.34).
AmountInWordsstatic classForCheck(decimal) writes a non-negative amount as printed on a check: ONE HUNDRED AND 25/100, ONE THOUSAND, TWO HUNDRED THIRTY-FOUR AND 50/100, ZERO AND NO CENTS. Rounds half away from zero to cents. Throws for negative amounts and amounts of a quadrillion or more.
FiscalCalendarclassBuilt from the fiscal year's start month (1-12). GetPeriod(date) returns the monthly FiscalPeriod; GetFiscalYearRange(fiscalYear) returns its first and last day. A fiscal year is named by the calendar year it ends in.
FiscalPeriodrecordFiscalYear, Number (1-12 within the fiscal year), Start, End, a sortable Key (202611 for period 11 of 2026) and Contains(date).
BusinessDaystatic classGetBusinessDate(utc, timeZone, dayStartsAt) and GetWindow(date, timeZone, dayStartsAt): the DST-safe conversions used by IBusinessCalendar.
BusinessDayWindowrecordDate, StartUtc (inclusive), EndUtc (exclusive) and Contains(utc).
JurisdictionCoderecordA taxing jurisdiction: country (US), state (US-MI) or locality (US-MI-DETROIT). Parse/TryParse (case-insensitive, stored upper-case), Parent, IsWithin(other).
EmployerIdentificationNumberrecordA validated EIN. Parse/TryParse accept 12-3456789 or 123456789 and reject the 00 prefix. Digits gives the nine digits for e-files; ToString() gives 12-3456789.
SensitiveValueMaskstatic classMask(value, maskCharacter = '*') drops separators and keeps the last VisibleDigits (4) characters; a value of four characters or fewer is masked completely.
UsPostalCodestatic classIsValid(value) and Normalize(value): returns 12345 or 12345-6789 (from 5 or 9 digits, with or without the dash), or null.
UsPhoneNumberstatic classTryNormalize(value, out normalized): ten digits, optionally prefixed by country code 1, with any separators, to 000-000-0000.
UsStatesstatic classAll: the 50 states and the District of Columbia as USPS code and name pairs, ordered by name. IsValid(code) is case-insensitive.

Permissions​

PermissionKeyDefault roles
Manage the business profileManageAlmohasebBusinessProfileAdministrator
View sensitive identifiers in clear textViewAlmohasebSensitiveDataAdministrator

Both are defined as static fields of AlmohasebPermissions in CrestApps.Almohaseb.Core. Manage the business profile guards Settings > Business profile. View sensitive identifiers in clear text is the permission a screen must check before it reveals a protected identifier; no current screen reveals one.

Deployment​

When OrchardCore.Deployment is enabled, deployment plans get a Business profile step (Exports the business profile.), added with AddSiteSettingsPropertyDeploymentStep<BusinessProfileSettings, ...>. It exports BusinessProfileSettings as a Settings recipe step. The EIN travels in its protected form, so it can only be read on a tenant that shares the source tenant's key ring (see the warning above).

Configuration​

The foundation binds no appsettings.json section of its own. Its behaviour comes from the business profile site settings and the site time zone. Sequences are configured in code with AddDocumentSequence. See Configuration for the sections other modules read.

Money columns on SQLite​

DecimalColumnSupport.Register() (CrestApps.Almohaseb.Core.Data), called by the foundation's startup, registers a Dapper type handler for decimal. SQLite keeps a NUMERIC value as INTEGER when it is whole and as REAL otherwise, and Dapper, which YesSql uses to read index rows, builds its reader from the first row's type, so reading a column that holds both (200 and 106.66) failed with "Unable to cast Int64 to Double". The handler converts whatever the provider returns; SQL Server and PostgreSQL return decimals, which pass through. It is process-wide and registered once.

See also​