Foundation (CrestApps.Almohaseb)
| Module | Almohaseb (CrestApps.Almohaseb) |
| Feature Name | Almohaseb Foundation |
| Feature ID | CrestApps.Almohaseb |
| Enabled | By dependency only (hidden in Tools > Features) |
| Libraries | CrestApps.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 classAlmohaseb{Name}Part, and give both a display name in the migration (WithDisplayName).ContentNameTestsfails when aContentPartclass, or a content type constant (*ContentType,*WidgetType, aContentTypesclass), 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 notAlmohasebSalesCategory; 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:
- 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 (
VendorPartand theVendortype's own part become Vendor). - 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 (
Billingof typeVendorPart) keeps its key. The old type and part definitions are then deleted. - Content items. Every document of a renamed type (all versions, found through
ContentItemIndex) gets the newContentTypeand 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. - 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.
| Property | Type | Notes |
|---|---|---|
OwnerName | string | Required by the editor. Trimmed. |
BusinessName | string | Legal name. Required. Trimmed. |
DoingBusinessAs | string | DBA. Required. Trimmed. |
Phone | string | Required. Normalised to 000-000-0000 by UsPhoneNumber unless IsForeignPhone is set. |
IsForeignPhone | bool | When true, Phone is stored as entered (trimmed). |
Street, City | string | Required. Trimmed. |
State | string | Two-letter USPS code, validated by UsStates.IsValid and stored upper-case. |
PostalCode | string | 12345 or 12345-6789, normalised by UsPostalCode.Normalize. |
ProtectedEmployerIdentificationNumber | string | The EIN digits, protected by ISensitiveDataProtector. Never store clear text here; write it through IBusinessProfileService.SetEmployerIdentificationNumber. |
MaskedEmployerIdentificationNumber | string | The display mask (*****6789), kept so lists and headers never unprotect the EIN. |
TaxRegistrationNumber | string | State tax registration number ("TR number"). Optional. |
IncorporationDate | DateOnly? | Set only when the business is incorporated. |
FiscalYearStartMonth | int | 1-12. Default 1 (January). |
BusinessDayStartsAt | TimeOnly | Local 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.
| Member | What 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.
| Member | What 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)returnsfalse(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.
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.
| Member | What 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);
DocumentSequenceDefinition | Default | Meaning |
|---|---|---|
Prefix | "" | Text before the number, such as INV-. |
FirstNumber | 1 | The first number, and the first of each year when the sequence resets yearly. Values below 1 are treated as 1. |
MinimumDigits | 0 | Pads the number with leading zeros to this many digits. |
ResetEachYear | false | Starts over at FirstNumber in each fiscal year. |
IncludeYear | false | Writes 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.
| Type | Kind | What it is |
|---|---|---|
Money | readonly record struct | An 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. |
RoundingPolicy | enum | HalfAwayFromZero (default; 2.345 becomes 2.35, the convention of tax forms and payroll) or HalfEven (banker's rounding; 2.345 becomes 2.34). |
AmountInWords | static class | ForCheck(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. |
FiscalCalendar | class | Built 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. |
FiscalPeriod | record | FiscalYear, Number (1-12 within the fiscal year), Start, End, a sortable Key (202611 for period 11 of 2026) and Contains(date). |
BusinessDay | static class | GetBusinessDate(utc, timeZone, dayStartsAt) and GetWindow(date, timeZone, dayStartsAt): the DST-safe conversions used by IBusinessCalendar. |
BusinessDayWindow | record | Date, StartUtc (inclusive), EndUtc (exclusive) and Contains(utc). |
JurisdictionCode | record | A taxing jurisdiction: country (US), state (US-MI) or locality (US-MI-DETROIT). Parse/TryParse (case-insensitive, stored upper-case), Parent, IsWithin(other). |
EmployerIdentificationNumber | record | A 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. |
SensitiveValueMask | static class | Mask(value, maskCharacter = '*') drops separators and keeps the last VisibleDigits (4) characters; a value of four characters or fewer is masked completely. |
UsPostalCode | static class | IsValid(value) and Normalize(value): returns 12345 or 12345-6789 (from 5 or 9 digits, with or without the dash), or null. |
UsPhoneNumber | static class | TryNormalize(value, out normalized): ten digits, optionally prefixed by country code 1, with any separators, to 000-000-0000. |
UsStates | static class | All: the 50 states and the District of Columbia as USPS code and name pairs, ordered by name. IsValid(code) is case-insensitive. |
Permissions
| Permission | Key | Default roles |
|---|---|---|
| Manage the business profile | ManageAlmohasebBusinessProfile | Administrator |
| View sensitive identifiers in clear text | ViewAlmohasebSensitiveData | Administrator |
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.