Skip to main content
Technical ManualLooking for step-by-step instructions? In the User Manual: Chart of AccountsJournal EntriesTrial Balance

Ledger (Accounting)

ModuleAlmohaseb Ledger (CrestApps.Almohaseb.Ledger)
Feature NameAccounting
Feature IDCrestApps.Almohaseb.Ledger
LibraryCrestApps.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 Account document, the store and service that enforce the chart's rules, and the protected system accounts;
  • the journal: the immutable JournalEntry document, 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​

FeatureIDEnabled
AccountingCrestApps.Almohaseb.LedgerBy the user, or by a recipe
Almohaseb FoundationCrestApps.AlmohasebBy dependency only. See Foundation.
Almohaseb JobsCrestApps.Almohaseb.JobsBy dependency only. See Jobs.
Almohaseb Admin UICrestApps.Almohaseb.Admin.UIBy 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.

PropertyTypeNotes
ItemIdstringThe account id.
NumberstringOptional. 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.
NamestringRequired, at most 100 characters (ChartOfAccountsRules.MaxNameLength). Unique within its type (case-insensitive). Stored trimmed.
TypeAccountTypeSee the table below.
ParentIdstringThe parent account, for subaccounts. The parent must exist, have the same type, and not be the account itself or one of its descendants.
DescriptionstringOptional. Blank becomes null.
IsActiveboolDefault true. Inactive accounts keep their history but are not offered in new transactions.
SystemKeystringThe key of a system account, or null for an account the business created. IsSystem is true when it is set.
IsFixedAmountboolThe expense is the same every period.
FixedAmountdecimal?The usual amount of a fixed expense; not negative. Cleared when IsFixedAmount is false.
CreatedUtc, ModifiedUtcDateTimeSet 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).

ValueName shownNormal balanceStatement
Asset (0)AssetDebitBalance sheet
Liability (1)LiabilityCreditBalance sheet
Equity (2)EquityCreditBalance sheet
Income (3)IncomeCreditProfit and loss
CostOfGoodsSold (4)Cost of goods soldDebitProfit and loss
Expense (5)ExpenseDebitProfit and loss
OtherIncome (6)Other incomeCreditProfit and loss
OtherExpense (7)Other expenseDebitProfit and loss

AccountIndex​

AccountIndex is a map index (CatalogItemIndex) in the Ledger collection, filled by AccountIndexProvider.

ColumnTypeLength
ItemIdstring26
Numberstring20
Namestring100
Typeint
ParentIdstring26
IsActivebool
SystemKeystring64

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.

PropertyTypeNotes
ItemIdstringThe entry id.
NumberstringGiven at posting from the journal-entry document sequence (prefix JE-), so posted numbers have no gaps. null for drafts.
DateDateOnlyThe business date.
PeriodKeyintFiscalPeriod.Key of the date (year * 100 + period), from IBusinessCalendar.
MemostringTrimmed; blank becomes null.
StatusJournalEntryStatusDraft (0) or Posted (1).
KindJournalEntryKindManual (0), Source (1, written by a posting rule), Reversal (2) or Recurring (3, created from a recurring entry template).
SourceType, SourceId, SourceRevisionstringFor source entries: the rule's source type, the document id and the posted revision. Reversals copy them from the entry they reverse.
ReversesEntryId, ReversedByEntryIdstringThe links between an entry and its reversal.
LinesList<JournalLine>LineNumber (from 1), AccountId, Debit, Credit, Memo, LocationId.
CreatedUtc, PostedUtc, PostedByPostedBy is the user name, or null for automatic postings.
TotaldecimalComputed: 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) and CreatedUtc. Database indexes: IDX_JournalEntryIndex_ItemId, IDX_JournalEntryIndex_Source (SourceType, SourceId, Status) and IDX_JournalEntryIndex_Date (Date, Number).
  • JournalLineIndex, one row per line: EntryId, LineNumber, AccountId, Date, PeriodKey, Debit, Credit (decimal 19,2), IsPosted and LocationId. Database indexes: IDX_JournalLineIndex_Balance (IsPosted, AccountId, Date) and IDX_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 (id period-{key}): PeriodKey, FiscalYear, Number, Start, End, IsClosed and History, a list of LedgerPeriodEvent(Closed, Utc, User, Reason).
  • PeriodBalanceSnapshot (id snapshot-{key}): PeriodKey, End, CreatedUtc and Accounts, the cumulative AccountActivity of every account from the beginning of the books through End. 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.

KeyNameTypeNumber
AccountsReceivableAccounts ReceivableAsset1200
UndepositedFundsUndeposited FundsAsset1300
FixedAssetsFixed AssetsAsset1700
AccumulatedDepreciationAccumulated DepreciationAsset1790
AccountsPayableAccounts PayableLiability2000
SalesTaxPayableSales Tax PayableLiability2200
PayrollLiabilitiesPayroll LiabilitiesLiability2300
CollectedForOthersAmounts Collected for OthersLiability2400
OpeningBalanceEquityOpening Balance EquityEquity3000
RetainedEarningsRetained EarningsEquity3900
SalesSalesIncome4000
PurchasesPurchasesCostOfGoodsSold5000
GrossWagesGross WagesExpense6100
PayrollTaxesPayroll TaxesExpense6110
ContractorExpenseContractorExpense6120
DepreciationExpenseDepreciation ExpensesExpense6200
SalesTaxExpenseSales TaxExpense6300
GainOnAssetSaleProfit From Asset SaleOtherIncome7000
LossOnAssetSaleLoss From Asset SaleOtherExpense8000

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​

ServiceLifetimeWhat it does
IAccountStore / AccountStoreScopedA 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 / ChartOfAccountsServiceScopedChanges 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).
ChartOfAccountsRulesStaticThe rules themselves: Validate(candidate, existing, chart) and CanDelete(account, hasChildren).
IJournalEntryStore / JournalEntryStoreScopedA 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 / JournalServiceScopedSaveDraftAsync, PostAsync(entry, postedBy) (a new entry or an existing draft), ReverseAsync(entryId, date, memo, postedBy) and DeleteDraftAsync. Returns JournalResult (Entry, Errors, Succeeded).
IPostingService / PostingServiceScopedPostAsync<TSource>(source): runs the document's posting rule. See Posting engine.
ILedgerBalanceService / LedgerBalanceServiceScopedGetActivityAsync(from, to): the posted debits and credits of every account with lines in the range, as AccountActivity records. See Balances.
ILedgerPeriodService / LedgerPeriodServiceScopedGetClosedThroughAsync, ListAsync(fiscalYear) (PeriodStatus per month), CloseAsync(date, user, reason) and ReopenAsync(periodKey, user, reason). Returns PeriodChangeResult (Periods, Error). See Periods.
IPostingPeriodGuard / ClosedPeriodPostingGuardScopedDecides whether a date is open for posting: a date is open when it is after the latest closed period.
IOpeningBalanceService / OpeningBalanceServiceScopedGetAsync and SaveAsync(date, lines, user). See Opening balances.
PeriodRulesStaticPeriodsToClose, IsClosed(date, closedThrough) and BalanceOpeningBalances(lines).
IRecurringEntryStore / RecurringEntryStoreScopedA DocumentCatalog<RecurringEntry, RecurringEntryIndex> with concurrency checks. Adds ListDueAsync(date, count), CountDueAsync(date) and Query().
IRecurringEntryService / RecurringEntryServiceScopedSaveAsync(candidate, user), SetActiveAsync, DeleteAsync and RunDueAsync(today, maxTemplates). See Recurring entries.
RecurrenceRulesStaticValidate(schedule), GetOccurrence(schedule, index) and GetNextDate(schedule, after, occurrenceCount).
JournalRulesStaticValidate(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​

AccountValidationCodeMessage shown in the admin
NameRequiredThe name is required.
NameTooLongThe name can have at most 100 characters.
DuplicateNameAnother account of this type has this name.
InvalidNumberThe number can have letters, digits, dots and dashes, at most 20 characters.
DuplicateNumberAnother account has this number.
ParentNotFoundThe parent account does not exist.
ParentTypeMismatchA subaccount must have the type of its parent.
ParentCycleAn account cannot be a subaccount of itself or of its own subaccounts.
SystemTypeLockedThe type of a system account cannot change.
SystemAccountRequiredA system account is used by postings and cannot be made inactive.
NegativeFixedAmountThe 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...OutcomeWhat happens
was never postedPostedA Source entry is posted.
was posted with the same SourceRevisionUnchangedNothing.
was posted with another revisionRepostedThe 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 postedUnpostedThe current entry is reversed.
has nothing to post and was never postedNothingNothing.
would produce an invalid entryRejectedNothing 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 a ProfitAndLossStatement with one StatementSection per type (Income, CostOfGoodsSold, Expense, OtherIncome, OtherExpense) and GrossProfit (income − cost of sales), OperatingProfit (− expenses) and NetProfit (+ other income − other expenses) per period; GetMargin is 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) and NetIncome (the rest, through the date). LiabilitiesAndEquity equals the asset total whenever the activity balances; an FsCheck property checks it, and that NetIncome is 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 a ParentOwn line 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. summary lists 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​

PermissionKeyDefault roles
Manage the chart of accountsManageAlmohasebChartOfAccountsAdministrator
View the chart of accountsViewAlmohasebChartOfAccountsAdministrator
Enter, post and reverse journal entriesManageAlmohasebJournalAdministrator
View journal entries and the trial balanceViewAlmohasebJournalAdministrator
Close and reopen periods and enter opening balancesCloseAlmohasebPeriodsAdministrator
View the profit and loss and the balance sheetViewAlmohasebFinancialStatementsAdministrator

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 nameMethodsPermission
Almohaseb/AccountsAlmohasebAccountsIndexGET (list), POST (apply filter)View the chart of accounts
Almohaseb/Accounts/CreateAlmohasebAccountsCreateGET, POSTManage the chart of accounts
Almohaseb/Accounts/Edit/{id}AlmohasebAccountsEditGET, POSTManage the chart of accounts
Almohaseb/Accounts/Delete/{id}AlmohasebAccountsDeletePOSTManage the chart of accounts
Almohaseb/JournalAlmohasebJournalIndexGET (list), POST (apply filter)View journal entries and the trial balance
Almohaseb/Journal/CreateAlmohasebJournalCreateGETEnter, post and reverse journal entries
Almohaseb/Journal/Edit/{id}AlmohasebJournalEditGET (drafts; posted entries redirect to the details)Enter, post and reverse journal entries
Almohaseb/Journal/SaveAlmohasebJournalSavePOST (submitAction=post posts; anything else saves a draft)Enter, post and reverse journal entries
Almohaseb/Journal/Details/{id}AlmohasebJournalDetailsGETView journal entries and the trial balance
Almohaseb/Journal/Reverse/{id}AlmohasebJournalReversePOST (reversalDate, reversalMemo)Enter, post and reverse journal entries
Almohaseb/Journal/Delete/{id}AlmohasebJournalDeletePOST (drafts only)Enter, post and reverse journal entries
Almohaseb/TrialBalanceAlmohasebTrialBalanceGET, redirects to the almohaseb-trial-balance reportView journal entries and the trial balance
Almohaseb/RecurringAlmohasebRecurringIndexGET (list), POST (apply filter)View journal entries and the trial balance
Almohaseb/Recurring/CreateAlmohasebRecurringCreateGETEnter, post and reverse journal entries
Almohaseb/Recurring/Edit/{id}AlmohasebRecurringEditGETEnter, post and reverse journal entries
Almohaseb/Recurring/SaveAlmohasebRecurringSavePOSTEnter, post and reverse journal entries
Almohaseb/Recurring/SetActive/{id}AlmohasebRecurringSetActivePOST (active)Enter, post and reverse journal entries
Almohaseb/Recurring/Delete/{id}AlmohasebRecurringDeletePOSTEnter, post and reverse journal entries
Almohaseb/Recurring/RunNowAlmohasebRecurringRunNowPOST (enqueues the job)Enter, post and reverse journal entries
Almohaseb/PeriodsAlmohasebPeriodsGET (year, the fiscal year; defaults to today's)View journal entries and the trial balance
Almohaseb/Periods/CloseAlmohasebPeriodsClosePOST (date, reason, year)Close and reopen periods and enter opening balances
Almohaseb/Periods/ReopenAlmohasebPeriodsReopenPOST (periodKey, reason, year)Close and reopen periods and enter opening balances
Almohaseb/OpeningBalancesAlmohasebOpeningBalancesGET (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>():

TermValuesQuery
text (default)Any textName or Number contains the value. Supports negation.
typeasset, liability, equity, income, cost-of-goods-sold (also cogs), expense, other-income, other-expenseType equals the value. Maps to and from the type drop-down.
statusactive, inactiveIsActive.
systemtrue/yes, false/noSystemKey is or is not null.
sorttype (default), number, nameThe 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>():

TermValuesQuery
text (default)Any textMemo or Number contains the value. Supports negation.
dateThe date term syntax, with today from IBusinessCalendarJournalEntryIndex.Date in the range.
amountThe amount term syntaxTotal in the range.
accountAn account number, or an account's exact nameJoins JournalLineIndex on AccountId. An unknown account matches nothing. An entry with several lines on the account is returned once.
statusdraft, posted (posted and not reversed), reversedStatus, ReversedByEntryId.
kindmanual, source, reversalKind.
sourceTextSourceType contains the value.
sortnewest (default), oldest, amountThe 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 with AddPostingRule<TSource, TRule>(), and call IPostingService.PostAsync when the document is saved. See Posting engine.

  • Closing periods: ClosedPeriodPostingGuard is the registered IPostingPeriodGuard. Replace it only to add stricter rules (for example a lock date for filed returns), and keep refusing every date on or before ILedgerPeriodService.GetClosedThroughAsync, because the balance snapshots rely on it.

  • Journal search terms: register another IAdminListFilterProvider<JournalEntry>.

  • List filter terms: register another IAdminListFilterProvider<Account> with AddAdminListFilterProvider<Account, TProvider>() to add search terms to Books > Chart of accounts. Filter on AccountIndex columns so the predicate stays SQL-translatable.

  • Creating accounts from code: use IChartOfAccountsService.CreateAsync, and check AccountChangeResult.Succeeded.

  • Finding system accounts: use IAccountStore.FindBySystemKeyAsync with a SystemAccounts key constant. Never look an account up by name, because users can rename it.

  • Account semantics: use AccountType.GetNormalBalance() and GetStatement() rather than switching on the type yourself.

See also​