Ledger (Accounting)
| Module | Almohaseb Ledger (CrestApps.Almohaseb.Ledger) |
| Feature Name | Accounting |
| Feature ID | CrestApps.Almohaseb.Ledger |
| Library | CrestApps.Almohaseb.Ledger.Core (namespace CrestApps.Almohaseb.Ledger) |
The Ledger module is the double-entry general ledger behind the Al-Mohaseb books. Its feature, Accounting, is the managed feature a business tenant turns on; it pulls in the hidden foundation, jobs and admin UI features. The module provides:
- the chart of accounts: the
Accountdocument, the store and service that enforce the chart's rules, and the protected system accounts; - the journal: the immutable
JournalEntrydocument, drafts, posting, numbering and reversals; - the posting engine:
IPostingRule<TSource>, through which every other module turns its documents into journal entries; - balances: SQL aggregates over the posted journal lines, and the trial balance;
- the Books admin screens: Chart of accounts, Journal entries and Trial balance.
How users work with these screens is described in the User Manual: Chart of accounts, Journal entries and Trial balance.
Features and dependencies
| Feature | ID | Enabled |
|---|---|---|
| Accounting | CrestApps.Almohaseb.Ledger | By the user, or by a recipe |
| Almohaseb Foundation | CrestApps.Almohaseb | By dependency only. See Foundation. |
| Almohaseb Jobs | CrestApps.Almohaseb.Jobs | By dependency only. See Jobs. |
| Almohaseb Admin UI | CrestApps.Almohaseb.Admin.UI | By dependency only. See Admin UI. |
The three dependencies are EnabledByDependencyOnly, so a recipe enables CrestApps.Almohaseb.Ledger and gets them all. The setup recipes almohaseb-business-cstore and almohaseb-business-service do exactly that. See Recipes and the Feature reference.
Data
YesSql collection
Ledger documents live in their own YesSql collection, Ledger (LedgerConstants.CollectionName), registered through StoreCollectionOptions. A large ledger therefore never grows the shared Document table. With the default table naming the tables are Ledger_Document, Ledger_AccountIndex, Ledger_JournalEntryIndex and Ledger_JournalLineIndex.
Account
Account (CrestApps.Almohaseb.Ledger.Models) derives from the CrestApps CatalogItem, so it has a 26-character ItemId.
| Property | Type | Notes |
|---|---|---|
ItemId | string | The account id. |
Number | string | Optional. 1-20 characters of letters, digits, dots and dashes, starting with a letter or digit. Unique across the chart (case-insensitive). Stored trimmed; blank becomes null. |
Name | string | Required, at most 100 characters (ChartOfAccountsRules.MaxNameLength). Unique within its type (case-insensitive). Stored trimmed. |
Type | AccountType | See the table below. |
ParentId | string | The parent account, for subaccounts. The parent must exist, have the same type, and not be the account itself or one of its descendants. |
Description | string | Optional. Blank becomes null. |
IsActive | bool | Default true. Inactive accounts keep their history but are not offered in new transactions. |
SystemKey | string | The key of a system account, or null for an account the business created. IsSystem is true when it is set. |
IsFixedAmount | bool | The expense is the same every period. |
FixedAmount | decimal? | The usual amount of a fixed expense; not negative. Cleared when IsFixedAmount is false. |
CreatedUtc, ModifiedUtc | DateTime | Set from IClock. |
Account types
AccountType decides the normal balance and the statement an account appears on. AccountTypeExtensions exposes both as GetNormalBalance() (NormalBalance.Debit or Credit) and GetStatement() (FinancialStatement.BalanceSheet or ProfitAndLoss).
| Value | Name shown | Normal balance | Statement |
|---|---|---|---|
Asset (0) | Asset | Debit | Balance sheet |
Liability (1) | Liability | Credit | Balance sheet |
Equity (2) | Equity | Credit | Balance sheet |
Income (3) | Income | Credit | Profit and loss |
CostOfGoodsSold (4) | Cost of goods sold | Debit | Profit and loss |
Expense (5) | Expense | Debit | Profit and loss |
OtherIncome (6) | Other income | Credit | Profit and loss |
OtherExpense (7) | Other expense | Debit | Profit and loss |
AccountIndex
AccountIndex is a map index (CatalogItemIndex) in the Ledger collection, filled by AccountIndexProvider.
| Column | Type | Length |
|---|---|---|
ItemId | string | 26 |
Number | string | 20 |
Name | string | 100 |
Type | int | |
ParentId | string | 26 |
IsActive | bool | |
SystemKey | string | 64 |
Database indexes: IDX_AccountIndex_ItemId (ItemId), IDX_AccountIndex_Chart (Type, Number, Name, the chart order), IDX_AccountIndex_SystemKey (SystemKey, for finding system accounts) and IDX_AccountIndex_ParentId (ParentId, for subaccounts).
JournalEntry
JournalEntry (CrestApps.Almohaseb.Ledger.Models) is a CatalogItem in the Ledger collection. Once Status is Posted the entry never changes, except that ReversedByEntryId is set when it is reversed.
| Property | Type | Notes |
|---|---|---|
ItemId | string | The entry id. |
Number | string | Given at posting from the journal-entry document sequence (prefix JE-), so posted numbers have no gaps. null for drafts. |
Date | DateOnly | The business date. |
PeriodKey | int | FiscalPeriod.Key of the date (year * 100 + period), from IBusinessCalendar. |
Memo | string | Trimmed; blank becomes null. |
Status | JournalEntryStatus | Draft (0) or Posted (1). |
Kind | JournalEntryKind | Manual (0), Source (1, written by a posting rule), Reversal (2) or Recurring (3, created from a recurring entry template). |
SourceType, SourceId, SourceRevision | string | For source entries: the rule's source type, the document id and the posted revision. Reversals copy them from the entry they reverse. |
ReversesEntryId, ReversedByEntryId | string | The links between an entry and its reversal. |
Lines | List<JournalLine> | LineNumber (from 1), AccountId, Debit, Credit, Memo, LocationId. |
CreatedUtc, PostedUtc, PostedBy | PostedBy is the user name, or null for automatic postings. | |
Total | decimal | Computed: the sum of the debits. |
Every posted entry obeys JournalRules.Validate: at least two lines; each line has a debit or a credit (not both, not negative, at most two decimals) on an existing, active account; the debits equal the credits; the date is set; and the date is open according to IPostingPeriodGuard. A reversal may use accounts that have become inactive since the original was posted.
JournalEntryIndex and JournalLineIndex
JournalEntryIndexProvider fills two map indexes:
JournalEntryIndex, one row per entry:ItemId,Number,Date(midnight),PeriodKey,Status,Kind,SourceType(128),SourceId(64),ReversesEntryId,ReversedByEntryId,Total(decimal 19,2),Memo(first 255 characters) andCreatedUtc. Database indexes:IDX_JournalEntryIndex_ItemId,IDX_JournalEntryIndex_Source(SourceType,SourceId,Status) andIDX_JournalEntryIndex_Date(Date,Number).JournalLineIndex, one row per line:EntryId,LineNumber,AccountId,Date,PeriodKey,Debit,Credit(decimal 19,2),IsPostedandLocationId. Database indexes:IDX_JournalLineIndex_Balance(IsPosted,AccountId,Date) andIDX_JournalLineIndex_Period(IsPosted,PeriodKey,AccountId).
Balances are aggregates over JournalLineIndex; there are no running balances to keep in step.
LedgerPeriod and PeriodBalanceSnapshot
Periods are the months of the fiscal year (FiscalCalendar, from BusinessProfileSettings.FiscalYearStartMonth). A period that was never closed has no document.
LedgerPeriod(idperiod-{key}):PeriodKey,FiscalYear,Number,Start,End,IsClosedandHistory, a list ofLedgerPeriodEvent(Closed, Utc, User, Reason).PeriodBalanceSnapshot(idsnapshot-{key}):PeriodKey,End,CreatedUtcandAccounts, the cumulativeAccountActivityof every account from the beginning of the books throughEnd. It is written when the period closes and deleted when it reopens.
Both are indexed in LedgerPeriodIndex (ItemId, PeriodKey, FiscalYear, End, IsClosed, IsSnapshot) by LedgerPeriodIndexProvider and PeriodBalanceSnapshotIndexProvider. Database indexes: IDX_LedgerPeriodIndex_Latest (IsSnapshot, IsClosed, End), IDX_LedgerPeriodIndex_Year (FiscalYear, IsSnapshot) and IDX_LedgerPeriodIndex_ItemId.
OpeningBalances
OpeningBalances (one document per tenant, id opening-balances) holds Date, Lines (OpeningBalanceLine: AccountId, Debit, Credit), Revision, ModifiedUtc and ModifiedBy. It is a source document posted by OpeningBalancesPostingRule (source type Ledger.OpeningBalances); see Opening balances.
RecurringEntry
RecurringEntry (CrestApps.Almohaseb.Ledger.Models) is a template in the Ledger collection: Name (100), Memo, Lines (JournalLine), Schedule (RecurrenceSchedule: Frequency Weekly/Monthly/Quarterly/Yearly, Interval 1 to 99, StartDate, optional EndDate and MaxOccurrences 1 to 999), Action (CreateDraft or Post), IsActive, NextDate (null once the schedule has ended), LastDate, OccurrenceCount, History (the last 24 RecurringOccurrence(Date, EntryId, Number, Errors, CreatedUtc)), CreatedUtc, ModifiedUtc and ModifiedBy.
RecurringEntryIndex holds ItemId, Name, NextDate (nullable), IsActive, Action and Frequency. Database indexes: IDX_RecurringEntryIndex_Due (IsActive, NextDate) and IDX_RecurringEntryIndex_ItemId.
Payment methods
The ways the business receives and pays money are the business's own, so they are the AlmohasebPaymentMethod content type (creatable, listable, securable) with AlmohasebPaymentMethodPart: Name, Role (PaymentMethodRole: Cash, Check, Card, OnAccount, Other — what code acts on, replacing the legacy configured cheque and credit ids, which crashed when missing), AccountId (optional), AffectsCash (the legacy "Affects Amounts") and IsActive. The business recipes seed Cash, Check, Credit card and On account (pay-cash, pay-check, pay-card, pay-account). Books > Payment methods (id almohasebPaymentMethods) is the content list.
IPaymentMethodLookup (ContentPaymentMethodLookup in the module) gives PaymentMethodInfo records; PaymentMethodAccounts.Resolve(method, side) gives the account money moves through: the method's own account, Accounts Receivable or Payable for OnAccount (by PaymentSide), otherwise Undeposited Funds; no method means cash. Because of the content type, the Accounting feature depends on OrchardCore.Contents.
Migrations
LedgerMigrations creates the account index (version 1), seeds the system accounts with IChartOfAccountsService.EnsureSystemAccountsAsync (version 2), creates the journal entry and journal line indexes (version 3), creates the period index (version 4), creates the recurring entry index (version 5), adds the system accounts introduced later, such as Amounts Collected for Others (version 6), defines the payment method content type (version 7), and adds the Fixed Assets system account (version 8). They run when the feature is enabled, and on an existing tenant when the shell next starts. The table definitions are the public static methods CreateAccountIndexAsync, CreateJournalIndexesAsync, CreatePeriodIndexAsync and CreateRecurringEntryIndexAsync.
System accounts
Every business has protected system accounts that postings find by key (SystemAccounts), never by name. Their type cannot change, they cannot be made inactive, and they cannot be deleted; they can be renamed, renumbered, described and given a parent of the same type.
| Key | Name | Type | Number |
|---|---|---|---|
AccountsReceivable | Accounts Receivable | Asset | 1200 |
UndepositedFunds | Undeposited Funds | Asset | 1300 |
FixedAssets | Fixed Assets | Asset | 1700 |
AccumulatedDepreciation | Accumulated Depreciation | Asset | 1790 |
AccountsPayable | Accounts Payable | Liability | 2000 |
SalesTaxPayable | Sales Tax Payable | Liability | 2200 |
PayrollLiabilities | Payroll Liabilities | Liability | 2300 |
CollectedForOthers | Amounts Collected for Others | Liability | 2400 |
OpeningBalanceEquity | Opening Balance Equity | Equity | 3000 |
RetainedEarnings | Retained Earnings | Equity | 3900 |
Sales | Sales | Income | 4000 |
Purchases | Purchases | CostOfGoodsSold | 5000 |
GrossWages | Gross Wages | Expense | 6100 |
PayrollTaxes | Payroll Taxes | Expense | 6110 |
ContractorExpense | Contractor | Expense | 6120 |
DepreciationExpense | Depreciation Expenses | Expense | 6200 |
SalesTaxExpense | Sales Tax | Expense | 6300 |
GainOnAssetSale | Profit From Asset Sale | OtherIncome | 7000 |
LossOnAssetSale | Loss From Asset Sale | OtherExpense | 8000 |
EnsureSystemAccountsAsync creates only the missing ones and is safe to run any number of times. When the chart already has an account with a system account's number, the system account is created without a number; when it already has an account of the same type and name, the system account's name gets the suffix (system).
Services
| Service | Lifetime | What it does |
|---|---|---|
IAccountStore / AccountStore | Scoped | A CrestApps DocumentCatalog<Account, AccountIndex> over the Ledger collection, with concurrency checks. Adds FindBySystemKeyAsync, ListAllAsync (the whole chart, ordered by type, number and name), HasChildrenAsync and Query() (an IQuery<Account, AccountIndex> for the admin list filters). |
IChartOfAccountsService / ChartOfAccountsService | Scoped | Changes the chart under its rules. CreateAsync (new accounts are never system accounts), UpdateAsync (keeps the id, system key and creation time), DeleteAsync (returns false for system accounts and parents) and EnsureSystemAccountsAsync. Create and update return an AccountChangeResult with Succeeded and a list of AccountValidationError (Field, Code). |
ChartOfAccountsRules | Static | The rules themselves: Validate(candidate, existing, chart) and CanDelete(account, hasChildren). |
IJournalEntryStore / JournalEntryStore | Scoped | A DocumentCatalog<JournalEntry, JournalEntryIndex> with concurrency checks. Adds FindCurrentBySourceAsync(sourceType, sourceId) (the posted, unreversed source entry of a document), ListCurrentSourceIdsAsync(sourceType, prefix) (the source ids of a family of entries, such as one asset's depreciation months) and Query(). |
IJournalService / JournalService | Scoped | SaveDraftAsync, PostAsync(entry, postedBy) (a new entry or an existing draft), ReverseAsync(entryId, date, memo, postedBy) and DeleteDraftAsync. Returns JournalResult (Entry, Errors, Succeeded). |
IPostingService / PostingService | Scoped | PostAsync<TSource>(source): runs the document's posting rule. See Posting engine. |
ILedgerBalanceService / LedgerBalanceService | Scoped | GetActivityAsync(from, to): the posted debits and credits of every account with lines in the range, as AccountActivity records. See Balances. |
ILedgerPeriodService / LedgerPeriodService | Scoped | GetClosedThroughAsync, ListAsync(fiscalYear) (PeriodStatus per month), CloseAsync(date, user, reason) and ReopenAsync(periodKey, user, reason). Returns PeriodChangeResult (Periods, Error). See Periods. |
IPostingPeriodGuard / ClosedPeriodPostingGuard | Scoped | Decides whether a date is open for posting: a date is open when it is after the latest closed period. |
IOpeningBalanceService / OpeningBalanceService | Scoped | GetAsync and SaveAsync(date, lines, user). See Opening balances. |
PeriodRules | Static | PeriodsToClose, IsClosed(date, closedThrough) and BalanceOpeningBalances(lines). |
IRecurringEntryStore / RecurringEntryStore | Scoped | A DocumentCatalog<RecurringEntry, RecurringEntryIndex> with concurrency checks. Adds ListDueAsync(date, count), CountDueAsync(date) and Query(). |
IRecurringEntryService / RecurringEntryService | Scoped | SaveAsync(candidate, user), SetActiveAsync, DeleteAsync and RunDueAsync(today, maxTemplates). See Recurring entries. |
RecurrenceRules | Static | Validate(schedule), GetOccurrence(schedule, index) and GetNextDate(schedule, after, occurrenceCount). |
JournalRules | Static | Validate(entry, chart) and Reverse(entry, date, memo). |
Always change accounts through IChartOfAccountsService, not the store: the service normalises the values and validates them against the whole chart. A chart holds hundreds of accounts at most, so validation reads the full chart with ListAllAsync.
Validation codes
AccountValidationCode | Message shown in the admin |
|---|---|
NameRequired | The name is required. |
NameTooLong | The name can have at most 100 characters. |
DuplicateName | Another account of this type has this name. |
InvalidNumber | The number can have letters, digits, dots and dashes, at most 20 characters. |
DuplicateNumber | Another account has this number. |
ParentNotFound | The parent account does not exist. |
ParentTypeMismatch | A subaccount must have the type of its parent. |
ParentCycle | An account cannot be a subaccount of itself or of its own subaccounts. |
SystemTypeLocked | The type of a system account cannot change. |
SystemAccountRequired | A system account is used by postings and cannot be made inactive. |
NegativeFixedAmount | The fixed amount cannot be negative. |
The messages are mapped from codes in AccountDisplay.GetMessage, so other callers can localise them the same way. Journal errors are JournalValidationError (Code, LineNumber) and are mapped by JournalDisplay.GetMessage, which prefixes the line number.
Posting engine
Other modules never write journal entries directly. A module that owns a document (a daily sale, a bill, a paycheck) implements IPostingRule<TSource> and registers it; the ledger runs it and never knows the document type.
public sealed class DailySalePostingRule : IPostingRule<DailySale>
{
public string SourceType => "Sales.DailySale";
public string GetSourceId(DailySale sale) => sale.ItemId;
public Task<PostingDraft> BuildAsync(DailySale sale, CancellationToken cancellationToken)
{
if (sale.IsVoided)
{
// Nothing to post: an entry posted earlier is reversed.
return Task.FromResult<PostingDraft>(null);
}
return Task.FromResult(new PostingDraft
{
SourceId = sale.ItemId,
SourceRevision = sale.ModifiedUtc.Ticks.ToString(CultureInfo.InvariantCulture),
Date = sale.BusinessDate,
Memo = $"Daily sales {sale.BusinessDate:d}",
Lines =
[
PostingLine.DebitLine(LedgerAccountRef.System(SystemAccounts.UndepositedFunds), sale.Total),
PostingLine.CreditLine(LedgerAccountRef.ById(sale.IncomeAccountId), sale.NetSales),
PostingLine.CreditLine(LedgerAccountRef.System(SystemAccounts.SalesTaxPayable), sale.Tax),
],
});
}
}
// In the owning feature's Startup (the feature depends on CrestApps.Almohaseb.Ledger):
services.AddPostingRule<DailySale, DailySalePostingRule>();
// When the document is saved:
var result = await postingService.PostAsync(sale);
IPostingService.PostAsync is idempotent:
| The document... | Outcome | What happens |
|---|---|---|
| was never posted | Posted | A Source entry is posted. |
was posted with the same SourceRevision | Unchanged | Nothing. |
| was posted with another revision | Reposted | The current entry is reversed and a new entry is posted. The reversal is dated on the current entry's date while that date is open; once its period is closed, it is dated on the new entry's date, so a change to an old document lands in an open period. |
has nothing to post (the rule returned null or only zero lines) and was posted | Unposted | The current entry is reversed. |
| has nothing to post and was never posted | Nothing | Nothing. |
| would produce an invalid entry | Rejected | Nothing is written; Errors says why. |
Unposted always reverses on the current entry's date, so a document in a closed period cannot be voided until the period is reopened. Entries of kind Source cannot be reversed from the journal's details page or its Reverse action; they follow their document.
Lines with no amount are dropped, so a rule can emit every category without checking for zero. LedgerAccountRef.System(key) resolves a system account by key; LedgerAccountRef.ById(id) uses an account the business chose. The new entry is validated completely (accounts, balance, period) before the previous entry is reversed, so a rejected re-post writes nothing. The changes are saved with the caller's session, together with the document.
Balances
ILedgerBalanceService.GetActivityAsync(from, to) flushes the session, then runs one aggregate in the session's transaction:
SELECT AccountId, SUM(Debit), SUM(Credit) FROM Ledger_JournalLineIndex
WHERE IsPosted = 1 AND Date <= @to [AND Date >= @from]
GROUP BY AccountId
When from is null (balances from the beginning of the books), the service first loads the latest PeriodBalanceSnapshot whose End is on or before to, and aggregates only the lines after it. Nothing can post into a closed period, so a snapshot cannot go stale, and the cost of a balance stays proportional to the open periods rather than to the whole history.
Table and column names come from the YesSql dialect, table prefix and schema. SQLite sums NUMERIC columns as REAL, so the service rounds each sum to cents; SQL Server and PostgreSQL sum exactly. AccountActivity.GetBalance(type) turns the result into a balance in the account's normal-balance direction. The trial balance shows, for every account, the net of its activity up to a date in the debit or the credit column.
Financial statements
FinancialStatementBuilder (CrestApps.Almohaseb.Ledger.Statements) is the one statement engine: the legacy P&L, its details, its comparison and the dashboard each computed different totals (RPT-01 to RPT-03). It is pure and takes the accounts, the StatementPeriods (one per amount column) and the AccountActivity of each.
BuildProfitAndLoss(accounts, periods, activity, summary)returns aProfitAndLossStatementwith oneStatementSectionper type (Income, CostOfGoodsSold, Expense, OtherIncome, OtherExpense) andGrossProfit(income − cost of sales),OperatingProfit(− expenses) andNetProfit(+ other income − other expenses) per period;GetMarginis a percentage of income to one decimal. Amounts are in each type's normal-balance direction.BuildBalanceSheet(accounts, periods, activity, priorYearsActivity, summary)returns Asset, Liability and Equity sections. The books are not closed at year end, so equity adds two computed lines:PriorYearsEarnings(credits − debits of the P&L accounts through the day before the fiscal year started; left out when zero) andNetIncome(the rest, through the date).LiabilitiesAndEquityequals the asset total whenever the activity balances; an FsCheck property checks it, and thatNetIncomeis the P&L net profit of the year.- Sections are account trees: a root is an account whose parent is missing or of another type; subaccounts (same type) are rolled into their parent, listed under it with
Depth + 1, and aParentOwnline carries what was posted to the parent itself so the lines add up. Accounts with no amount in any column are left out, and every column has the same lines.summarylists the roots only. Lines are ordered by number, then name.
IFinancialStatementService (FinancialStatementService) reads IAccountStore.ListAllAsync and one ILedgerBalanceService.GetActivityAsync per column. GetPeriods(from, to, comparison) makes the columns: PreviousPeriod is the same number of months before a whole-month range, otherwise the same number of days; PreviousYear the same dates a year earlier; Monthly one column per month, clipped to the range, at most 24. The balance sheet compares with the previous month end, the same date a year earlier, or every month end of the fiscal year (from IBusinessCalendar.GetFiscalPeriodAsync).
Reports
ReportsStartup ([RequireFeatures("CrestApps.OrchardCore.Reports")]) registers ProfitAndLossReport (almohaseb-profit-and-loss) and BalanceSheetReport, TrialBalanceReport (almohaseb-trial-balance, ViewAlmohasebJournal: each account's debit or credit balance on the last day of the range) (almohaseb-balance-sheet) with AddReport, in the Financial statements category, both needing ViewAlmohasebFinancialStatements. StatementReportFilterDisplayDriver adds Compare with (AlmohasebComparison, a StatementComparison) and Summary (AlmohasebSummary) under the date range, for these two reports only. The date range's UTC bounds become business dates through ILocalClock; the balance sheet uses the last day. Each report returns a metrics section and one table: a label column, a column per period and a Change column for two periods; section headings and totals are Subtotal rows and the last result a GrandTotal. Empty sections are left out, profit lines never are.
Report cells are strings in CrestApps Reports today: amounts are formatted #,##0.00;(#,##0.00) and account trees are indented with non-breaking spaces until the upstream typed numeric cells (U10) and account-tree rows (U11) exist. The statements are listed only in the Reports menu, under Financial statements. CSV export comes with Reports, Excel with CrestApps.OrchardCore.Reports.OpenXml; both business recipes enable the two.
Periods
LedgerPeriodService.CloseAsync(date, user, reason) closes the period that contains date, and every open period before it from the first period with a posted entry (or from the period after the latest closed one), so the books never have an open period before a closed one. For each period, in order, it saves the LedgerPeriod with a close event, then a PeriodBalanceSnapshot; each snapshot is computed from the previous one. The close is refused with AlreadyClosed when the period is already closed, and with DraftsInPeriod when a draft journal entry is dated in the periods. The admin also refuses to close a period that has not ended yet.
ReopenAsync(periodKey, user, reason) reopens only the latest closed period (NotLatestClosed, NotClosed), only with a reason (ReasonRequired), and deletes its snapshot.
The closed-through date is the End of the latest closed LedgerPeriod. Every date on or before it is closed, including months before the first activity that have no LedgerPeriod of their own; PeriodStatus.IsClosed reports them as closed too. PeriodDisplay.GetMessage maps PeriodValidationCode to the admin messages.
Opening balances
IOpeningBalanceService.SaveAsync(date, lines, user) merges the lines by account (amounts entered twice are added), drops empty lines, increases Revision and calls IPostingService.PostAsync. The document is saved only when the posting succeeds, so a rejected posting (an unknown or inactive account, a closed date) changes nothing. OpeningBalancesPostingRule posts one line per account and a balancing line on SystemAccounts.OpeningBalanceEquity for the difference (PeriodRules.BalanceOpeningBalances); with no lines it returns null, so saving an empty list unposts the previous entry.
Recurring entries
RecurrenceRules.GetOccurrence(schedule, n) counts every date from StartDate (StartDate + n weeks or months), never from the previous date, so a schedule on the 30th returns to the 30th after February. When StartDate is the last day of its month, every date is the last day of its month. GetNextDate(schedule, after, count) is the first occurrence after after, or null past EndDate or once count reaches MaxOccurrences. FsCheck properties check that occurrences increase and that GetNextDate walks them in order.
RecurringEntryService.SaveAsync drops empty lines, validates the name, the schedule (RecurringEntryValidationCode) and the lines (JournalRules.Validate on the start date; RecurringEntryResult.LineErrors), and sets NextDate to GetNextDate(schedule, LastDate, OccurrenceCount), so a changed schedule continues after the last entry created.
RunDueAsync(today, maxTemplates) takes the active templates with NextDate <= today, earliest first, and for each creates every due date (at most 60 per run, so a template years behind catches up over several runs). Each date becomes a journal entry of kind Recurring with source type Ledger.Recurring and source id {template id}:{yyyyMMdd}; a date whose entry already exists is recorded and not created again. With Action = Post the entry goes through IJournalService.PostAsync (postedBy is null); when that fails, it is saved as a draft and the occurrence keeps the JournalValidationCodes. The template and its entries are saved in the same session. A draft created by a template keeps its kind and source link when it is later edited and posted from the journal.
RecurringEntriesBackgroundTask runs every 15 minutes (*/15 * * * *). It only counts the due templates for today's business date and, when there are any, enqueues the CrestApps.Almohaseb.Ledger.RecurringEntries job with the idempotency key due, so at most one is active. RecurringEntriesJobHandler calls RunDueAsync with the batch size (20 templates) per batch and completes when nothing is due; entries that could not be posted count as warnings. Create them now enqueues the same job at interactive priority.
Permissions
| Permission | Key | Default roles |
|---|---|---|
| Manage the chart of accounts | ManageAlmohasebChartOfAccounts | Administrator |
| View the chart of accounts | ViewAlmohasebChartOfAccounts | Administrator |
| Enter, post and reverse journal entries | ManageAlmohasebJournal | Administrator |
| View journal entries and the trial balance | ViewAlmohasebJournal | Administrator |
| Close and reopen periods and enter opening balances | CloseAlmohasebPeriods | Administrator |
| View the profit and loss and the balance sheet | ViewAlmohasebFinancialStatements | Administrator |
Manage the chart of accounts implies View the chart of accounts, Enter, post and reverse journal entries implies View journal entries and the trial balance, which implies View the profit and loss and the balance sheet. Both are static fields of LedgerPermissions in CrestApps.Almohaseb.Ledger.Core.
Admin menu and routes
The module adds a top-level Books group (icon fa-book, menu id almohasebBooks) at the content position, with Chart of accounts (id almohasebChartOfAccounts, shown with View the chart of accounts), Journal entries (id almohasebJournal) and Recurring entries (id almohasebRecurring), Periods (id almohasebPeriods) and Opening balances (id almohasebOpeningBalances), all shown with View journal entries and the trial balance.
AccountsController is an [Admin] MVC controller. Every route starts with a literal segment, so an account id is never mistaken for an action.
| Route (under the admin prefix) | Route name | Methods | Permission |
|---|---|---|---|
Almohaseb/Accounts | AlmohasebAccountsIndex | GET (list), POST (apply filter) | View the chart of accounts |
Almohaseb/Accounts/Create | AlmohasebAccountsCreate | GET, POST | Manage the chart of accounts |
Almohaseb/Accounts/Edit/{id} | AlmohasebAccountsEdit | GET, POST | Manage the chart of accounts |
Almohaseb/Accounts/Delete/{id} | AlmohasebAccountsDelete | POST | Manage the chart of accounts |
Almohaseb/Journal | AlmohasebJournalIndex | GET (list), POST (apply filter) | View journal entries and the trial balance |
Almohaseb/Journal/Create | AlmohasebJournalCreate | GET | Enter, post and reverse journal entries |
Almohaseb/Journal/Edit/{id} | AlmohasebJournalEdit | GET (drafts; posted entries redirect to the details) | Enter, post and reverse journal entries |
Almohaseb/Journal/Save | AlmohasebJournalSave | POST (submitAction=post posts; anything else saves a draft) | Enter, post and reverse journal entries |
Almohaseb/Journal/Details/{id} | AlmohasebJournalDetails | GET | View journal entries and the trial balance |
Almohaseb/Journal/Reverse/{id} | AlmohasebJournalReverse | POST (reversalDate, reversalMemo) | Enter, post and reverse journal entries |
Almohaseb/Journal/Delete/{id} | AlmohasebJournalDelete | POST (drafts only) | Enter, post and reverse journal entries |
Almohaseb/TrialBalance | AlmohasebTrialBalance | GET, redirects to the almohaseb-trial-balance report | View journal entries and the trial balance |
Almohaseb/Recurring | AlmohasebRecurringIndex | GET (list), POST (apply filter) | View journal entries and the trial balance |
Almohaseb/Recurring/Create | AlmohasebRecurringCreate | GET | Enter, post and reverse journal entries |
Almohaseb/Recurring/Edit/{id} | AlmohasebRecurringEdit | GET | Enter, post and reverse journal entries |
Almohaseb/Recurring/Save | AlmohasebRecurringSave | POST | Enter, post and reverse journal entries |
Almohaseb/Recurring/SetActive/{id} | AlmohasebRecurringSetActive | POST (active) | Enter, post and reverse journal entries |
Almohaseb/Recurring/Delete/{id} | AlmohasebRecurringDelete | POST | Enter, post and reverse journal entries |
Almohaseb/Recurring/RunNow | AlmohasebRecurringRunNow | POST (enqueues the job) | Enter, post and reverse journal entries |
Almohaseb/Periods | AlmohasebPeriods | GET (year, the fiscal year; defaults to today's) | View journal entries and the trial balance |
Almohaseb/Periods/Close | AlmohasebPeriodsClose | POST (date, reason, year) | Close and reopen periods and enter opening balances |
Almohaseb/Periods/Reopen | AlmohasebPeriodsReopen | POST (periodKey, reason, year) | Close and reopen periods and enter opening balances |
Almohaseb/OpeningBalances | AlmohasebOpeningBalances | GET (view); POST (save and post) | View journal entries and the trial balance; Close and reopen periods and enter opening balances to save |
Each action authorizes with IAuthorizationService and returns Forbid() on failure; the menu only hides the item. The list renders through AdminList with List and Grid layouts (Grid columns: Number, Account, Type, Status, Actions), and the editor is built by AccountDisplayDriver. The type and active flag of a system account are rendered read-only and are not posted.
List filters
The chart's search box uses the shared admin list filters (see Admin UI). AccountsAdminListFilterProvider registers these terms with AddAdminListFilterProvider<Account, AccountsAdminListFilterProvider>():
| Term | Values | Query |
|---|---|---|
text (default) | Any text | Name or Number contains the value. Supports negation. |
type | asset, liability, equity, income, cost-of-goods-sold (also cogs), expense, other-income, other-expense | Type equals the value. Maps to and from the type drop-down. |
status | active, inactive | IsActive. |
system | true/yes, false/no | SystemKey is or is not null. |
sort | type (default), number, name | The order. Without a sort term the list is ordered by type, number and name. |
The filter is posted to the index action and redirected as ?q=..., so a filtered list can be bookmarked.
JournalAdminListFilterProvider adds the journal's terms with AddAdminListFilterProvider<JournalEntry, JournalAdminListFilterProvider>():
| Term | Values | Query |
|---|---|---|
text (default) | Any text | Memo or Number contains the value. Supports negation. |
date | The date term syntax, with today from IBusinessCalendar | JournalEntryIndex.Date in the range. |
amount | The amount term syntax | Total in the range. |
account | An account number, or an account's exact name | Joins JournalLineIndex on AccountId. An unknown account matches nothing. An entry with several lines on the account is returned once. |
status | draft, posted (posted and not reversed), reversed | Status, ReversedByEntryId. |
kind | manual, source, reversal | Kind. |
source | Text | SourceType contains the value. |
sort | newest (default), oldest, amount | The order. |
RecurringAdminListFilterProvider adds the terms of Books > Recurring entries: text (default, Name contains), status (active, paused, ended, due with today from IBusinessCalendar), frequency, action (post, draft) and sort (name, next).
The journal entry, recurring entry and opening balance editors use the Admin UI editable grid for their lines.
Recipes and deployment
The module adds no recipe step or deployment step of its own. Enable it with the Feature recipe step and the ID CrestApps.Almohaseb.Ledger; the system accounts are seeded by the migration, not by a recipe.
Configuration
The Ledger module binds no appsettings.json section. See Configuration.
Extension points
-
Posting documents: implement
IPostingRule<TSource>, register it withAddPostingRule<TSource, TRule>(), and callIPostingService.PostAsyncwhen the document is saved. See Posting engine. -
Closing periods:
ClosedPeriodPostingGuardis the registeredIPostingPeriodGuard. Replace it only to add stricter rules (for example a lock date for filed returns), and keep refusing every date on or beforeILedgerPeriodService.GetClosedThroughAsync, because the balance snapshots rely on it. -
Journal search terms: register another
IAdminListFilterProvider<JournalEntry>. -
List filter terms: register another
IAdminListFilterProvider<Account>withAddAdminListFilterProvider<Account, TProvider>()to add search terms to Books > Chart of accounts. Filter onAccountIndexcolumns so the predicate stays SQL-translatable. -
Creating accounts from code: use
IChartOfAccountsService.CreateAsync, and checkAccountChangeResult.Succeeded. -
Finding system accounts: use
IAccountStore.FindBySystemKeyAsyncwith aSystemAccountskey constant. Never look an account up by name, because users can rename it. -
Account semantics: use
AccountType.GetNormalBalance()andGetStatement()rather than switching on the type yourself.