Skip to content
bitzorcas
中EN

Concept

Immutable Timekeeping & Billing Audit Trails

In-depth architecture of BitzOrcas.Platform.Timekeeping: consultant time tracking, tiered hourly rate matrices, invoicing locks, and tamper-proof audit trails.

Last updated

Immutable Timekeeping & Billing Audit Trails

In professional services including law firms, accounting practices, and management consultancies, recorded billable time represents enterprise capital. Clients require minute-level precision in service billing records alongside strict immutability once invoiced.

BitzOrcas.Platform.Timekeeping delivers an enterprise-grade time tracking subsystem featuring tiered hourly billing matrices, state machine lifecycle management, invoice locking safeguards, and immutable audit trails.


1. Time Entry Lifecycle State Machine

Every TimeEntry progresses through verified lifecycle phases, becoming permanently read-only once associated with a finalized invoice:

Consultant logs time (Draftentry)Submit for weekly reviewManager/Partner approvedRejected (Returned forrevision)Finalized on invoice(Permanently locked)Written off / Bad debtdiscount

Draft

Submitted

Approved

Billed

WrittenOff


2. Core Aggregate Root: TimeEntry.cs

The timekeeping aggregate root resides in BitzOrcas.Platform.Timekeeping.Contracts (or corresponding Domain namespace), inherits TenantAggregateRoot<string>, uses adapter-neutral [BitzTable] and [BitzColumn] metadata, and encapsulates state mutation:

using System;
using System.ComponentModel;
using BitzOrcas.Domain.Contracts;
using BitzOrcas.Domain.Entities;
using BitzOrcas.Domain.Results;
using BitzOrcas.Domain.Tenancy;
using BitzOrcas.Persistence.Metadata;
using BitzOrcas.Platform.Timekeeping.Contracts;
namespace BitzOrcas.Platform.Timekeeping.Domain;
/// <summary>
/// Time entry lifecycle state enumeration
/// </summary>
public enum TimeEntryStatus
{
Draft = 1,
Submitted = 2,
Approved = 3,
Billed = 4,
WrittenOff = 5
}
/// <summary>
/// Professional billable time entry aggregate root
/// </summary>
/// <remarks>
/// Declares adapter-neutral persistence metadata; snowflake identity, tenancy, auditing, and soft delete are inherited.
/// </remarks>
[BitzTable("TimekeepingEntry", IsTenant = true, IsSoftDelete = true, Description = "Professional time entry record")]
public sealed class TimeEntry : TenantAggregateRoot<string>
{
/// <summary>
/// Matter or contract project ID
/// </summary>
[BitzColumn(Length = 64, IsRequired = true)]
public string MatterId { get; private set; } = string.Empty;
/// <summary>
/// Professional staff member user ID
/// </summary>
[BitzColumn(Length = 64, IsRequired = true)]
public string StaffUserId { get; private set; } = string.Empty;
/// <summary>
/// Date when legal/consulting work was performed
/// </summary>
[BitzColumn(IsRequired = true)]
public DateOnly WorkDate { get; private set; }
/// <summary>
/// Billable hours logged (supporting 0.1h / 6-minute increments)
/// </summary>
[BitzColumn(Precision = 8, Scale = 2, IsRequired = true)]
public decimal Hours { get; private set; }
/// <summary>
/// Applicable hourly billing rate
/// </summary>
[BitzColumn(Precision = 18, Scale = 2, IsRequired = true)]
public decimal HourlyRate { get; private set; }
/// <summary>
/// Calculated fee = Hours * HourlyRate
/// </summary>
[BitzColumn(Precision = 18, Scale = 2, IsRequired = true)]
public decimal TotalFee { get; private set; }
/// <summary>
/// Detailed work narrative description
/// </summary>
[BitzColumn(Length = 2000, IsRequired = true)]
public string Narrative { get; private set; } = string.Empty;
/// <summary>
/// Current entry lifecycle state
/// </summary>
[BitzColumn(IsRequired = true)]
public TimeEntryStatus Status { get; private set; } = TimeEntryStatus.Draft;
/// <summary>
/// Associated invoice tracking number (populated when in Billed status)
/// </summary>
[BitzColumn(Length = 64, IsRequired = false)]
public string? InvoiceNumber { get; private set; }
/// <summary>
/// Empty constructor for ORM materialization only
/// </summary>
[Obsolete("For ORM materialization only. Use Create.", error: true)]
[EditorBrowsable(EditorBrowsableState.Never)]
public TimeEntry()
: base("0")
{
}
private TimeEntry(
string id,
string tenantId,
string matterId,
string staffUserId,
DateOnly workDate,
decimal hours,
decimal hourlyRate,
string narrative)
: base(id)
{
TenantId = tenantId;
MatterId = matterId;
StaffUserId = staffUserId;
WorkDate = workDate;
Hours = hours;
HourlyRate = hourlyRate;
TotalFee = hours * hourlyRate;
Narrative = narrative;
Status = TimeEntryStatus.Draft;
}
/// <summary>
/// Factory creating a validated TimeEntry aggregate root
/// </summary>
public static Result<TimeEntry> Create(
string? tenantId,
string matterId,
string staffUserId,
DateOnly workDate,
decimal hours,
decimal hourlyRate,
string narrative)
{
if (string.IsNullOrWhiteSpace(tenantId) || !TenancyDefaults.IsValid(tenantId))
{
return Result<TimeEntry>.Failure(TimekeepingErrors.TenantRequired);
}
if (string.IsNullOrWhiteSpace(matterId))
{
return Result<TimeEntry>.Failure(TimekeepingErrors.MatterIdRequired);
}
if (string.IsNullOrWhiteSpace(staffUserId))
{
return Result<TimeEntry>.Failure(TimekeepingErrors.StaffUserIdRequired);
}
if (hours <= 0 || hours > 24)
{
return Result<TimeEntry>.Failure(TimekeepingErrors.HoursInvalid);
}
if (hourlyRate < 0)
{
return Result<TimeEntry>.Failure(TimekeepingErrors.HourlyRateInvalid);
}
if (string.IsNullOrWhiteSpace(narrative))
{
return Result<TimeEntry>.Failure(TimekeepingErrors.NarrativeRequired);
}
var entry = new TimeEntry(
"0",
tenantId,
matterId.Trim(),
staffUserId.Trim(),
workDate,
hours,
hourlyRate,
narrative.Trim());
return Result<TimeEntry>.Success(entry);
}
/// <summary>
/// Updates logged hours and narrative (strictly guarded against billed status)
/// </summary>
public Result Update(decimal newHours, string newNarrative)
{
// Core invariant lock: once billed, modifications are strictly forbidden
if (Status == TimeEntryStatus.Billed)
{
return Result.Failure(TimekeepingErrors.AlreadyBilledImmutable);
}
if (newHours <= 0 || newHours > 24)
{
return Result.Failure(TimekeepingErrors.HoursInvalid);
}
if (string.IsNullOrWhiteSpace(newNarrative))
{
return Result.Failure(TimekeepingErrors.NarrativeRequired);
}
Hours = newHours;
Narrative = newNarrative.Trim();
TotalFee = Hours * HourlyRate;
return Result.Success();
}
/// <summary>
/// Locks entry and binds finalized invoice number
/// </summary>
public Result MarkAsBilled(string invoiceNumber)
{
if (Status == TimeEntryStatus.Billed)
{
return Result.Failure(TimekeepingErrors.AlreadyBilledImmutable);
}
if (string.IsNullOrWhiteSpace(invoiceNumber))
{
return Result.Failure(TimekeepingErrors.InvoiceNumberRequired);
}
Status = TimeEntryStatus.Billed;
InvoiceNumber = invoiceNumber.Trim();
return Result.Success();
}
}

3. Immutability Lock Guard

To eliminate the moral hazard of post-invoicing record alteration, the update handler retrieves the aggregate via the ORM-neutral narrow command port ICommandRepository<TimeEntry, string> and delegates validation to domain methods:

using BitzOrcas.Domain.Abstractions;
using BitzOrcas.Domain.Results;
using BitzOrcas.Platform.Timekeeping.Contracts;
using BitzOrcas.Platform.Timekeeping.Domain;
using Mediator;
namespace BitzOrcas.Platform.Timekeeping.Application.Commands;
/// <summary>
/// Command contract for updating an existing time entry
/// </summary>
public sealed record UpdateTimeEntryCommand(
string Id,
decimal Hours,
string Narrative
) : ICommand<Result>;
/// <summary>
/// Command handler orchestrating time entry update
/// </summary>
public sealed class UpdateTimeEntryCommandHandler(
ICommandRepository<TimeEntry, string> repository) : ICommandHandler<UpdateTimeEntryCommand, Result>
{
public async ValueTask<Result> Handle(
UpdateTimeEntryCommand command,
CancellationToken cancellationToken)
{
// 1. Load aggregate root from narrow command repository
var entryResult = await repository.FindAsync(command.Id, cancellationToken);
if (entryResult.IsFailure)
{
return Result.Failure(TimekeepingErrors.EntryNotFound);
}
var entry = entryResult.GetValueOrThrow();
// 2. State invariant guards and fee recalculations are encapsulated inside the aggregate
var updateResult = entry.Update(command.Hours, command.Narrative);
if (updateResult.IsFailure)
{
return updateResult;
}
// 3. Persist changes through pipeline transaction governance
var saveResult = await repository.SaveAsync(entry, cancellationToken);
return saveResult.IsFailure ? Result.Failure(saveResult.Error) : Result.Success();
}
}

Stable Module Error Catalog: TimekeepingErrors.cs

namespace BitzOrcas.Platform.Timekeeping.Contracts;
using BitzOrcas.Domain.Results;
/// <summary>
/// Strongly typed error catalog for Timekeeping module
/// </summary>
public static class TimekeepingErrors
{
public static readonly Error EntryNotFound = Error.NotFound("Timekeeping.Entry.NotFound");
public static readonly Error AlreadyBilledImmutable = Error.Conflict("Timekeeping.Entry.AlreadyBilledImmutable");
public static readonly Error HoursInvalid = Error.Validation("Timekeeping.Entry.HoursInvalid");
public static readonly Error HourlyRateInvalid = Error.Validation("Timekeeping.Entry.HourlyRateInvalid");
public static readonly Error NarrativeRequired = Error.Validation("Timekeeping.Entry.NarrativeRequired");
public static readonly Error MatterIdRequired = Error.Validation("Timekeeping.Entry.MatterIdRequired");
public static readonly Error StaffUserIdRequired = Error.Validation("Timekeeping.Entry.StaffUserIdRequired");
public static readonly Error TenantRequired = Error.Unauthorized("Timekeeping.Tenant.Required");
public static readonly Error InvoiceNumberRequired = Error.Validation("Timekeeping.Entry.InvoiceNumberRequired");
}

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%