انتقل إلى المحتوى الرئيسي

LedgerJournalName Aggregate

File: /docs/domain/general-ledger/aggregates/ledger-journal-name.aggregate.md
Module: GeneralLedger
Namespace: GeneralLedger.Domain.Aggregates.LedgerJournalNameAggregate


Purpose

The LedgerJournalName aggregate serves as a configuration template that defines the behavior, validation rules, and default settings for ledger journals within the General Ledger module. It acts as a policy object that encapsulates journal processing rules, ensuring consistency in transaction entry, improving data quality, and enforcing financial controls across different journal types.

When a user creates a LedgerJournalHeader, they select a LedgerJournalName, which determines:

  • The posting strategy to be applied (via JournalType)
  • How voucher numbers are generated and managed
  • Default offset account behavior for efficient data entry
  • Allowed account types and transaction patterns

This design enables organizations to configure multiple journal templates optimized for specific business processes (e.g., daily general entries, customer payments, vendor payments, payroll) without requiring code changes.


Business Context

In enterprise accounting systems, journals are not one-size-fits-all. Different business processes require different entry patterns, controls, and automation:

  • Daily General Journals: Flexible entry with balanced lines, each line pair getting its own voucher
  • Payment Journals: Streamlined entry with a fixed bank account offset, single voucher per payment
  • Subledger Journals: System-generated entries from sales/purchase invoices with specific posting logic
  • Payroll Journals: Batch entries where the entire journal represents one logical transaction

LedgerJournalName provides the configuration layer that enables these different patterns while maintaining data integrity and audit requirements.


Aggregate Structure

Root Entity: LedgerJournalName

  • Identity: Guid Id (inherited from AggregateRootBase)
  • Core Properties:
    • Name (string, unique): User-friendly identifier displayed in journal creation UI
    • Description (string): Detailed explanation of the journal's intended use
  • Behavioral Configuration:
    • JournalTypeId (int): Foreign key to JournalType enumeration
    • JournalType (navigation): Determines the posting strategy implementation
    • VoucherSeriesId (Guid): Foreign key to NumberSequence for voucher numbering
    • VoucherSeries (navigation): Number sequence configuration for vouchers
    • VoucherGenerationStrategy (enum): Controls voucher allocation behavior
  • Default Account Configuration:
    • DefaultOffsetAccountId (Guid?): Suggested offset account for new lines
    • DefaultOffsetAccountType (enum?): Type of the offset account (Ledger, Customer, Vendor, etc.)
    • IsFixedOffsetAccount (bool): Enforces the offset account (prevents user changes)

Key Enumerations

JournalType Enum

Defines the primary purpose and posting strategy for journals using this name:

  • Daily (0): General-purpose entries, uses GeneralJournalPostingStrategy
  • CustomerPayment (1): Customer payment processing, uses CustomerPaymentPostingStrategy
  • VendorPayment (2): Vendor payment processing, uses VendorPaymentPostingStrategy
  • Payroll (3): Payroll transaction processing, uses PayrollPostingStrategy
  • TaxSettlement (4): Tax settlement posting, uses TaxSettlementPostingStrategy

Each JournalType is associated with a specific posting strategy implementation, enabling polymorphic posting behavior.

VoucherGenerationStrategy Enum

Controls how voucher numbers are allocated within a journal batch:

public enum VoucherGenerationStrategy
{
/// <summary>
/// Reuses the last voucher until balanced, then generates new voucher.
/// Ideal for multi-line entries where each balanced set forms one transaction.
/// Example: Debit Cash 1000, Credit Revenue 1000 (Voucher V001)
/// Debit Expense 500, Credit Cash 500 (Voucher V002)
/// </summary>
InConnectionWithBalance = 0,

/// <summary>
/// User must manually enter voucher number on each line.
/// Provides maximum control but requires more data entry effort.
/// Used when voucher numbers must follow external references.
/// </summary>
Manual = 1,

/// <summary>
/// Single voucher for the entire journal batch.
/// Ideal for opening balances or batch imports where all lines
/// represent one logical transaction.
/// Example: All opening balance entries use Voucher OB-2025-001
/// </summary>
OneVoucherNumberOnly = 2
}

Business Rules & Invariants

Core Validation Rules

  1. Unique Name Requirement

    • Rule: Name must be unique across all LedgerJournalName entities
    • Enforcement: Database unique constraint + domain validation
    • Rationale: Users select journals by name, duplicates cause confusion
  2. Journal Type Assignment

    • Rule: Every LedgerJournalName must have a JournalType assigned
    • Enforcement: Required field validation in domain model
    • Rationale: JournalType determines posting strategy, cannot be null
  3. Voucher Series Configuration

    • Rule: Every LedgerJournalName must reference a valid NumberSequence
    • Enforcement: Foreign key constraint + domain validation
    • Rationale: All journals require voucher number generation capability
  4. Fixed Offset Account Integrity

    • Rule: If IsFixedOffsetAccount = true, then DefaultOffsetAccountId must be provided
    • Enforcement: Domain method SetFixedOffsetAccount() validates both properties together
    • Validation Logic:
      if (IsFixedOffsetAccount && !DefaultOffsetAccountId.HasValue)
      {
      throw new DomainException(
      "Cannot set fixed offset account without specifying the account");
      }
    • Rationale: A fixed offset without specifying which account makes no business sense
  5. Voucher Strategy Consistency

    • Rule: VoucherGenerationStrategy must be set to a valid enum value
    • Default: InConnectionWithBalance (most common scenario)
    • Rationale: Controls critical behavior in journal line processing

Deletion Constraints

A LedgerJournalName cannot be deleted if:

  1. Active Journal References: Any LedgerJournalHeader entities reference this LedgerJournalName

    • Check: Query LedgerJournalHeader table for LedgerJournalNameId
    • Impact: Could orphan historical journals and break audit trail
  2. System-Required Journals: Certain journal names may be marked as system-required

    • Examples: "Tax Settlement Posting", "System Generated Adjustments"
    • Enforcement: IsSystemRequired flag (if implemented)

Deletion Process:

public async Task<Result> DeleteLedgerJournalNameAsync(Guid id)
{
var journalName = await _repository.GetByIdAsync(id);

// Check for journal references
var hasJournals = await _repository.HasJournalReferencesAsync(id);
if (hasJournals)
{
return Result.Failure(
"Cannot delete journal name as it is used by existing journals");
}

_repository.Remove(journalName);
await _unitOfWork.SaveChangesAsync();

return Result.Success();
}

State Management

Lifecycle States

State Properties

The LedgerJournalName aggregate tracks the following key state:

PropertyTypePurposeConstraints
NamestringDisplay name for UI selectionUnique, required, max 100 chars
DescriptionstringDetailed usage explanationOptional, max 500 chars
JournalTypeIdintLinks to posting strategyRequired, valid JournalType enum
VoucherSeriesIdGuidNumber sequence for vouchersRequired, must exist in NumberSequence
VoucherGenerationStrategyenumVoucher allocation behaviorRequired, defaults to InConnectionWithBalance
DefaultOffsetAccountIdGuid?Suggested offset accountOptional unless IsFixedOffsetAccount = true
DefaultOffsetAccountTypeenum?Type of offset accountOptional, must align with DefaultOffsetAccountId
IsFixedOffsetAccountboolLocks offset account for controlDefaults to false

Configuration Change Impact

Low-Risk Changes (can be made anytime):

  • Update Description
  • Change DefaultOffsetAccountId (if not fixed) - affects only new journals

Medium-Risk Changes (require careful consideration):

  • Change VoucherGenerationStrategy - affects how new journals allocate vouchers
  • Toggle IsFixedOffsetAccount - changes data entry behavior

High-Risk Changes (generally prohibited after use):

  • Change JournalType - would alter posting strategy for future journals
  • Change VoucherSeriesId - could create numbering conflicts

Best Practice: Create a new LedgerJournalName instead of modifying one that's actively used.


Domain Events

Current State

The LedgerJournalName aggregate currently does not publish domain events. Its state is consumed synchronously by command handlers and services during journal creation and processing.

Future Considerations

As the system evolves, consider implementing the following events:

LedgerJournalNameCreatedDomainEvent

public class LedgerJournalNameCreatedDomainEvent : IDomainEvent
{
public Guid LedgerJournalNameId { get; }
public string Name { get; }
public int JournalTypeId { get; }

// Use Case: Cache invalidation, audit logging, UI refresh
}

LedgerJournalNameConfigurationChangedDomainEvent

public class LedgerJournalNameConfigurationChangedDomainEvent : IDomainEvent
{
public Guid LedgerJournalNameId { get; }
public Dictionary<string, object> ChangedProperties { get; }

// Use Case: Impact analysis, notification to active users, audit trail
}

LedgerJournalNameDeletedDomainEvent

public class LedgerJournalNameDeletedDomainEvent : IDomainEvent
{
public Guid LedgerJournalNameId { get; }
public string Name { get; }

// Use Case: Cleanup caches, notify administrators, audit logging
}

Rationale for Future Events:

  • Configuration changes can impact active users
  • Audit requirements may necessitate event-based tracking
  • Integration with notification systems for process owners
  • Cache invalidation in distributed scenarios

Repository Contract

ILedgerJournalNameRepository

Purpose: Provides data access operations for LedgerJournalName aggregate with specialized queries for validation and configuration retrieval.

Core Query Methods

public interface ILedgerJournalNameRepository : IRepository<LedgerJournalName>
{
/// <summary>
/// Retrieves all journal names, optionally including related entities.
/// Used in: Journal name selection dropdowns, configuration screens
/// </summary>
Task<List<LedgerJournalName>> GetAllAsync(
bool includeJournalType = false,
bool includeVoucherSeries = false);

/// <summary>
/// Gets a journal name by ID with optional eager loading.
/// Used in: Journal creation, configuration editing
/// </summary>
Task<LedgerJournalName?> GetByIdAsync(
Guid id,
bool includeJournalType = false,
bool includeVoucherSeries = false);

/// <summary>
/// Finds journal name by unique name.
/// Used in: Name uniqueness validation
/// </summary>
Task<LedgerJournalName?> GetByNameAsync(string name);

/// <summary>
/// Gets all journal names for a specific journal type.
/// Used in: Type-specific journal selection, reporting
/// </summary>
Task<List<LedgerJournalName>> GetByJournalTypeAsync(int journalTypeId);

/// <summary>
/// Checks if a journal name has any associated journals.
/// Used in: Deletion validation
/// </summary>
Task<bool> HasJournalReferencesAsync(Guid journalNameId);

/// <summary>
/// Validates name uniqueness excluding a specific ID.
/// Used in: Update operations to allow keeping the same name
/// </summary>
Task<bool> IsNameUniqueAsync(string name, Guid? excludeId = null);
}

Command Methods

/// <summary>
/// Adds a new journal name to the repository.
/// Validation: Name uniqueness, required fields
/// </summary>
void Add(LedgerJournalName journalName);

/// <summary>
/// Updates an existing journal name.
/// Validation: Name uniqueness, consistency checks
/// </summary>
void Update(LedgerJournalName journalName);

/// <summary>
/// Removes a journal name from the repository.
/// Pre-condition: No journal references exist
/// </summary>
void Remove(LedgerJournalName journalName);

Aggregate Relationships

1. Relationship with LedgerJournalHeader

Nature: One-to-Many (One LedgerJournalName → Many LedgerJournalHeaders)

Interaction Pattern:

// Journal creation inherits configuration from LedgerJournalName
var journal = new LedgerJournalHeader(
journalBatchNumber: "GJ-2025-001",
ledgerJournalNameId: journalName.Id,
journalTypeId: journalName.JournalTypeId, // Inherited
currencyId: accountingCurrency.Id,
voucherSeriesId: journalName.VoucherSeriesId // Inherited
);

Key Points:

  • LedgerJournalHeader stores LedgerJournalNameId as foreign key
  • Configuration values (JournalType, VoucherSeries) are copied at creation time
  • Changes to LedgerJournalName do not retroactively affect existing journals
  • This immutability preserves audit trail integrity

2. Relationship with JournalType (Enumeration)

Nature: Many-to-One Reference (Many LedgerJournalNames → One JournalType value)

Purpose: Determines which posting strategy implementation is used during journal posting.

Posting Strategy Polymorphism:

// In JournalPostingService
IPostingStrategy strategy = _journalType switch
{
JournalType.Daily => new GeneralJournalPostingStrategy(),
JournalType.CustomerPayment => new CustomerPaymentPostingStrategy(),
JournalType.VendorPayment => new VendorPaymentPostingStrategy(),
JournalType.Payroll => new PayrollPostingStrategy(),
JournalType.TaxSettlement => new TaxSettlementPostingStrategy(),
_ => throw new DomainException($"Unsupported journal type: {_journalType}")
};

await strategy.PostAsync(journal, ledger);

3. Relationship with NumberSequence

Nature: Many-to-One (Many LedgerJournalNames → One NumberSequence)

Purpose: Controls voucher number generation for journals created with this name.

Integration Points:

  • LedgerJournalName stores VoucherSeriesId foreign key
  • During journal line creation, the system requests the next number from NumberSequence
  • VoucherGenerationStrategy determines when to request new numbers:
    • InConnectionWithBalance: New number when lines balance
    • Manual: No automatic generation
    • OneVoucherNumberOnly: Single number for entire journal

Example Usage:

// In AddLedgerJournalTransactionCommandHandler
string voucherNumber = _voucherGenerationStrategy switch
{
VoucherGenerationStrategy.InConnectionWithBalance =>
await _numberSequenceService.GetNextNumberAsync(journal.VoucherSeriesId),
VoucherGenerationStrategy.Manual =>
command.ManualVoucherNumber, // User-provided
VoucherGenerationStrategy.OneVoucherNumberOnly =>
journal.JournalBatchVoucherNumber, // Same for all lines
_ => throw new DomainException("Invalid voucher strategy")
};

4. Relationship with DimensionCombination (Indirect)

Nature: Indirect reference through DefaultOffsetAccountId

Purpose: When DefaultOffsetAccountId is set, it points to a DimensionCombination that represents the default offset account structure.

Workflow:

  1. User configures LedgerJournalName with a default offset account (e.g., "Bank - Main Account")
  2. System resolves this to a full DimensionCombination (MainAccount + Dimensions)
  3. DefaultOffsetAccountId stores the resulting DimensionCombination.Id
  4. When a user creates a journal line, the system auto-populates the offset account using this combination

Example:

// Configuration time
var bankAccount = await _dimensionService.ResolveAccountAsync("1010-CORP-US");
journalName.SetDefaultOffsetAccount(bankAccount.Id, AccountType.Ledger, isFixed: true);

// Usage time (in journal line creation)
if (journalName.DefaultOffsetAccountId.HasValue)
{
line.SetOffsetAccount(
journalName.DefaultOffsetAccountId.Value,
journalName.DefaultOffsetAccountType.Value
);

if (journalName.IsFixedOffsetAccount)
{
line.LockOffsetAccount(); // User cannot change
}
}

Domain Services

Services That Consume LedgerJournalName

While LedgerJournalName itself is primarily a configuration entity, it is consumed by several domain services:

1. JournalCreationService

Purpose: Orchestrates the creation of new journal headers.

Usage of LedgerJournalName:

public async Task<LedgerJournalHeader> CreateJournalAsync(
Guid ledgerJournalNameId,
Guid currencyId)
{
var journalName = await _journalNameRepository.GetByIdAsync(
ledgerJournalNameId,
includeJournalType: true,
includeVoucherSeries: true);

var journalBatchNumber = await _numberSequenceService
.GetNextNumberAsync(ModuleFeature.LedgerJournalNumber);

var journal = new LedgerJournalHeader(
journalBatchNumber,
journalName.Id,
journalName.JournalTypeId,
currencyId,
journalName.VoucherSeriesId
);

// Apply default dimension if configured
if (journalName.DefaultDimensionId.HasValue)
{
journal.SetDefaultDimension(journalName.DefaultDimensionId.Value);
}

return journal;
}

2. VoucherGenerationService

Purpose: Generates or validates voucher numbers based on the configured strategy.

Strategy Implementation:

public async Task<string> GenerateVoucherNumberAsync(
LedgerJournalHeader journal,
LedgerJournalName journalName,
bool isBalanced)
{
return journalName.VoucherGenerationStrategy switch
{
VoucherGenerationStrategy.InConnectionWithBalance =>
isBalanced
? await _numberSequence.GetNextNumberAsync(journal.VoucherSeriesId)
: journal.LastVoucherNumber, // Reuse until balanced

VoucherGenerationStrategy.OneVoucherNumberOnly =>
journal.JournalBatchVoucherNumber ??=
await _numberSequence.GetNextNumberAsync(journal.VoucherSeriesId),

VoucherGenerationStrategy.Manual =>
throw new DomainException(
"Manual voucher strategy requires user to provide voucher number"),

_ => throw new DomainException("Invalid voucher generation strategy")
};
}

3. JournalPostingService

Purpose: Posts journals to the general ledger.

Uses JournalType from LedgerJournalName:

public async Task PostJournalAsync(LedgerJournalHeader journal)
{
var journalName = await _journalNameRepository.GetByIdAsync(
journal.LedgerJournalNameId,
includeJournalType: true);

// Select posting strategy based on journal type
var strategy = _strategyFactory.CreateStrategy(journalName.JournalType);

await strategy.ExecutePostingAsync(journal, _ledger);
}

Usage Examples

Example 1: Creating a Daily Journal Configuration

// Application Layer - CreateLedgerJournalNameCommand
public async Task<Result<Guid>> Handle(
CreateLedgerJournalNameCommand command,
CancellationToken cancellationToken)
{
// 1. Validate name uniqueness
var exists = await _repository.IsNameUniqueAsync(command.Name);
if (exists)
{
return Result<Guid>.Failure("Journal name already exists");
}

// 2. Create aggregate
var journalName = new LedgerJournalName(
name: command.Name,
description: command.Description,
journalTypeId: JournalType.Daily.Id,
voucherSeriesId: command.VoucherSeriesId
);

// 3. Configure voucher strategy
journalName.SetVoucherGenerationStrategy(
VoucherGenerationStrategy.InConnectionWithBalance);

// 4. No default offset account (flexible entry)

// 5. Persist
_repository.Add(journalName);
await _unitOfWork.SaveChangesAsync(cancellationToken);

return Result<Guid>.Success(journalName.Id);
}

Example 2: Creating a Bank Payment Journal Configuration

public async Task<Result<Guid>> Handle(
CreateBankPaymentJournalCommand command,
CancellationToken cancellationToken)
{
// 1. Resolve the bank account to a DimensionCombination
var bankAccount = await _dimensionService.ResolveAccountAsync("1010-CORP-US");

// 2. Create journal name
var journalName = new LedgerJournalName(
name: "Bank Payments - Main Account",
description: "Customer and vendor payments from main bank account",
journalTypeId: JournalType.VendorPayment.Id,
voucherSeriesId: command.VoucherSeriesId
);

// 3. Set ONE VOUCHER per payment (common in payment processing)
journalName.SetVoucherGenerationStrategy(
VoucherGenerationStrategy.OneVoucherNumberOnly);

// 4. Set FIXED offset account (always the bank)
journalName.SetFixedOffsetAccount(
accountId: bankAccount.Id,
accountType: AccountType.Ledger,
isFixed: true // User cannot change
);

// 5. Persist
_repository.Add(journalName);
await _unitOfWork.SaveChangesAsync(cancellationToken);

return Result<Guid>.Success(journalName.Id);
}

Example 3: Using Journal Name During Transaction Entry

// In AddLedgerJournalTransactionCommandHandler
public async Task<Result<Guid>> Handle(
AddLedgerJournalTransactionCommand command,
CancellationToken cancellationToken)
{
// 1. Get journal with its configuration
var journal = await _journalRepository.GetByIdAsync(
command.JournalHeaderId,
includeJournalName: true);

var journalName = journal.JournalName;

// 2. Generate or validate voucher number
string voucherNumber = await _voucherService.GenerateVoucherNumberAsync(
journal,
journalName,
isBalanced: journal.IsBalanced);

// 3. Create journal line
var line = new LedgerJournalLine(
lineNumber: journal.NextLineNumber,
transactionDate: command.TransactionDate,
accountId: command.AccountId,
accountType: command.AccountType,
debitAmount: command.DebitAmount,
creditAmount: command.CreditAmount,
description: command.Description,
voucherNumber: voucherNumber
);

// 4. Apply default offset account if configured
if (journalName.DefaultOffsetAccountId.HasValue)
{
line.SetOffsetAccount(
journalName.DefaultOffsetAccountId.Value,
journalName.DefaultOffsetAccountType.Value
);

// 5. Lock offset account if fixed
if (journalName.IsFixedOffsetAccount)
{
line.LockOffsetAccount();
}
}

// 6. Add line to journal
journal.AddLine(line);

// 7. Persist
await _unitOfWork.SaveChangesAsync(cancellationToken);

return Result<Guid>.Success(line.Id);
}

Validation Rules

Creation Validation

public class CreateLedgerJournalNameValidator : AbstractValidator<CreateLedgerJournalNameCommand>
{
public CreateLedgerJournalNameValidator(ILedgerJournalNameRepository repository)
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Journal name is required")
.MaximumLength(100).WithMessage("Name cannot exceed 100 characters")
.MustAsync(async (name, ct) =>
await repository.IsNameUniqueAsync(name))
.WithMessage("Journal name already exists");

RuleFor(x => x.Description)
.MaximumLength(500).WithMessage("Description cannot exceed 500 characters");

RuleFor(x => x.JournalTypeId)
.Must(BeValidJournalType).WithMessage("Invalid journal type");

RuleFor(x => x.VoucherSeriesId)
.NotEmpty().WithMessage("Voucher series is required");

RuleFor(x => x)
.Must(ValidateFixedOffsetConfiguration)
.WithMessage("Fixed offset account requires specifying the account ID")
.When(x => x.IsFixedOffsetAccount);
}

private bool BeValidJournalType(int journalTypeId)
{
return Enumeration.TryFromValue<JournalType>(journalTypeId, out _);
}

private bool ValidateFixedOffsetConfiguration(CreateLedgerJournalNameCommand command)
{
if (command.IsFixedOffsetAccount && !command.DefaultOffsetAccountId.HasValue)
{
return false; // Fixed offset requires account ID
}
return true;
}
}

Update Validation

public class UpdateLedgerJournalNameValidator : AbstractValidator<UpdateLedgerJournalNameCommand>
{
public UpdateLedgerJournalNameValidator(ILedgerJournalNameRepository repository)
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(100)
.MustAsync(async (cmd, name, ct) =>
await repository.IsNameUniqueAsync(name, cmd.Id))
.WithMessage("Journal name already exists");

// Additional rules similar to creation validation

// Prevent changing critical properties if journals exist
RuleFor(x => x)
.MustAsync(async (cmd, ct) =>
!await repository.HasJournalReferencesAsync(cmd.Id) ||
!cmd.IsJournalTypeChanged)
.WithMessage("Cannot change journal type when journals exist");
}
}

Performance Considerations

1. Caching Strategy

Since LedgerJournalName is frequently accessed but rarely modified, implement caching:

public class CachedLedgerJournalNameRepository : ILedgerJournalNameRepository
{
private readonly ILedgerJournalNameRepository _inner;
private readonly IMemoryCache _cache;
private const string CacheKeyPrefix = "JournalName:";
private const int CacheMinutes = 60;

public async Task<LedgerJournalName?> GetByIdAsync(Guid id, bool includeRelated = false)
{
var cacheKey = $"{CacheKeyPrefix}{id}:{includeRelated}";

return await _cache.GetOrCreateAsync(cacheKey, async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(CacheMinutes);
return await _inner.GetByIdAsync(id, includeRelated);
});
}

public async Task<List<LedgerJournalName>> GetAllAsync(bool includeRelated = false)
{
var cacheKey = $"{CacheKeyPrefix}All:{includeRelated}";

return await _cache.GetOrCreateAsync(cacheKey, async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(CacheMinutes);
return await _inner.GetAllAsync(includeRelated);
});
}

// Invalidate cache on modifications
public void Update(LedgerJournalName journalName)
{
_cache.Remove($"{CacheKeyPrefix}{journalName.Id}:true");
_cache.Remove($"{CacheKeyPrefix}{journalName.Id}:false");
_cache.Remove($"{CacheKeyPrefix}All:true");
_cache.Remove($"{CacheKeyPrefix}All:false");

_inner.Update(journalName);
}
}

2. Query Optimization

Use projection when only basic properties are needed:

// Heavy: Loads entire aggregate with relationships
var journalNames = await _repository.GetAllAsync(includeJournalType: true, includeVoucherSeries: true);

// Lightweight: Projection for dropdown lists
var journalNameList = await _context.LedgerJournalNames
.Select(jn => new LedgerJournalNameListItem
{
Id = jn.Id,
Name = jn.Name,
JournalTypeName = jn.JournalType.Name
})
.ToListAsync();

Common Configuration Patterns

Pattern 1: Flexible General Journal

Use Case: Daily adjusting entries, miscellaneous transactions

Name: "Daily General Journal"
JournalType: Daily
VoucherStrategy: InConnectionWithBalance
DefaultOffsetAccount: None
IsFixedOffsetAccount: false

Behavior: User enters balanced sets of entries, each balanced set gets its own voucher.

Pattern 2: Bank Payment Journal

Use Case: Paying vendors from a specific bank account

Name: "Bank Payments - Chase Checking"
JournalType: VendorPayment
VoucherStrategy: OneVoucherNumberOnly
DefaultOffsetAccount: DimensionCombination for "Cash-Chase-Checking"
IsFixedOffsetAccount: true

Behavior: Every line in the journal offsets the same bank account. One voucher for the entire payment batch.

Pattern 3: Opening Balance Journal

Use Case: Migrating opening balances from legacy system

Name: "Opening Balances 2025"
JournalType: Daily
VoucherStrategy: OneVoucherNumberOnly
DefaultOffsetAccount: None
IsFixedOffsetAccount: false

Behavior: All opening balance entries share a single voucher number (e.g., "OB-2025-001").

Pattern 4: Payroll Journal

Use Case: Monthly payroll posting from HR system

Name: "Payroll Monthly"
JournalType: Payroll
VoucherStrategy: OneVoucherNumberOnly
DefaultOffsetAccount: DimensionCombination for "Payroll Clearing Account"
IsFixedOffsetAccount: false // May need different clearing accounts

Behavior: Entire payroll run uses one voucher. Specific clearing account suggested but can be overridden.


Testing Considerations

Unit Tests (Domain Layer)

[Fact]
public void SetFixedOffsetAccount_WhenAccountIdIsNull_ShouldThrowException()
{
// Arrange
var journalName = LedgerJournalNameFactory.CreateValid();

// Act & Assert
var exception = Assert.Throws<DomainException>(() =>
journalName.SetFixedOffsetAccount(null, AccountType.Ledger, isFixed: true));

Assert.Contains("Cannot set fixed offset account without specifying the account",
exception.Message);
}

[Fact]
public void Create_WithValidProperties_ShouldSucceed()
{
// Arrange & Act
var journalName = new LedgerJournalName(
name: "Test Journal",
description: "Test Description",
journalTypeId: JournalType.Daily.Id,
voucherSeriesId: Guid.NewGuid()
);

// Assert
Assert.NotNull(journalName);
Assert.Equal("Test Journal", journalName.Name);
Assert.Equal(JournalType.Daily.Id, journalName.JournalTypeId);
}

Integration Tests (Application Layer)

[Fact]
public async Task CreateLedgerJournalName_WithDuplicateName_ShouldFail()
{
// Arrange
await SeedJournalName("Existing Journal");

var command = new CreateLedgerJournalNameCommand
{
Name = "Existing Journal",
Description = "Duplicate",
JournalTypeId = JournalType.Daily.Id,
VoucherSeriesId = Guid.NewGuid()
};

// Act
var result = await _handler.Handle(command, CancellationToken.None);

// Assert
Assert.True(result.IsFailure);
Assert.Contains("Journal name already exists", result.Error);
}

Domain Documentation

API Documentation

Conceptual Documentation

Cross-Module References


Last Updated: 2025-01-15 | Version: 1.0 | Status: Active Development