diff --git a/docs/product/data-provider-accounting-brainstorm-2026-07.md b/docs/product/data-provider-accounting-brainstorm-2026-07.md index adf51e7bed..b22c465be1 100644 --- a/docs/product/data-provider-accounting-brainstorm-2026-07.md +++ b/docs/product/data-provider-accounting-brainstorm-2026-07.md @@ -20,24 +20,27 @@ --- -## Status Update (2026-07-13) +## Status Update (2026-07-15) -The `codex/data-provider-accounting-completion` branch completed ideas #1–#5 during the 2026-07-13 -implementation pass. The narratives below are preserved as the point-in-time analysis of -2026-07-05, with dated update notes where the premise has changed. Current branch status: +The `codex/data-provider-accounting-completion` branch completed the first implementation pass for +ideas #1–#6 and #8–#10 by 2026-07-15. The narratives below are preserved as the point-in-time +analysis of 2026-07-05, with dated update notes where the premise has changed. Independent +correctness, durability, and tenant-isolation audits then tightened the completion criteria. The +status table distinguishes source-complete work from final focused and aggregate validation; idea +#7 remains open after its audit found additional server-owned evidence and scope work. | # | Idea | Status | What remains | |---|------|--------|--------------| -| 1 | Streaming unification + honest status | Done (2026-07-13) | A shared `ProviderConnectionSupervisor` now owns complete connection transactions for WebSocket and polling lanes; NYSE/IB subscription replay and polling recovery are implemented, and runtime health routes return `unknown`/`unavailable` when diagnostics do not exist. Focused proof: the supervisor harness passed 5/5, and both the default and IBAPI smoke-stub Infrastructure builds passed. Added NYSE/IB replay/rate and endpoint-honesty tests await execution after shared contention; aggregate CI has not run. | -| 2 | Canonical symbol spine | Done (2026-07-13) | Registry identity is `SecurityId`-aware with provider-scoped aliases; `Legacy`/`Compare`/`Canonical` modes, idempotent migration receipts, mismatch diagnostics, and the browser registry surface are implemented. Focused proof: browser registry tests passed 5/5; Contracts, Storage, and Application builds plus contract-impact, generated-route, and schema checks passed. Added .NET endpoint/collision tests await a serialized rerun; aggregate CI has not run. | -| 3 | Unified data quality + browser dashboard | Done (2026-07-13) | `CompositeDataQualityReadService` combines stored completeness, streaming freshness, and adapter gap integrity, issues stable opaque gap IDs, and resolves exact provider/range remediation through `AutoGapRemediationService`; browser and WPF consume the shared contract. Focused proof: browser quality tests passed 18/18 and the Application, Ui.Shared, and Ui.Services builds passed. Aggregate CI has not run. | -| 4 | Backfill feedback loop | Done (2026-07-13) | Live progress carries range, provider, fallback attempt, and retry through typed contracts to browser and WPF; bounded execution history durably retains typed SLA/remediation evidence. Focused proof: browser view-model tests passed 39/39, rendered screen tests passed 26/26, the Contracts build passed, and the WPF XAML parsed. New durable-history/.NET/WPF tests await execution after shared MSBuild contention; aggregate CI has not run. | -| 5 | Failure & rate-limit hardening | Done (2026-07-13) | The catalog exposes immutable, sanitized registration failures; historical and streaming rate diagnostics use coherent lock-guarded snapshots, and NYSE maps HTTP 429 to typed `RateLimitException`. Browser and WPF show current usage, reset, failure, and retry posture while stating that history is unavailable. Focused ProviderSdk/Infrastructure builds and 24 browser tests passed; added .NET/WPF filters await a serialized rerun. Aggregate CI has not run. | -| 6 | Mark-to-market wiring | Done (2026-07-13) | Trusted historical-provider close marks retain observed-date and confidence evidence and must pass staleness and coverage gates. Persisted, explicitly scoped daily-valuation schedules run through the due-run host into governed workbench drafts for human approval and durable posting; restart hydration feeds marked statements and NAV, the close cockpit exposes a "Daily valuation" lane, and NAV already computes assets − liabilities. Focused `Meridian.Application` and `Meridian.FinancialOperations` builds passed (the latter with one existing analyzer warning), along with generated-route, static, and diff checks. Added scheduler/E2E, cockpit, and stale/low-confidence tests await execution; `Meridian.Ui.Shared` reached the new code before two sibling `BackfillCoordinator` ambiguities stopped its build. Aggregate CI has not run. | -| 7 | Automated journal drafts | Done (2026-07-06) | Corporate-action/dividend producers, management/performance-fee accrual (`FeeScheduleAccrualEventProducer` + `RunFeeAccrualIntakeAsync` + endpoint), and dividend withholding-tax accrual (`WithholdingTaxRate` on the dividend intake lane) all land governed drafts in the workbench queue. Operator/cockpit-triggered; recurring scheduling remains optional follow-on. | -| 8 | Closing entries + retained-earnings roll | Done (2026-07-06) | `AutomatedJournalIntakeRunner.RunPeriodCloseIntakeAsync` projects closing entries from a closed period's trial balance and lands the governed draft in the workbench queue via `/api/ledger/journal-automation/period-close-intake`; open periods are rejected loudly. | -| 9 | One ledger spine | Done (2026-07-08) | `DurableAutomatedJournalPoster` posts approved drafts through `ILedgerJournalStore`; `LedgerJournalStoreHydrationExtensions` hydrates as-of and book/period projections from the durable journal store; `Ledger` keeps balance/posting snapshots for point-in-time reads; tests cover hydration, durable-first posting, as-of snapshots, and the F#/C# enum-ordinal contract. | -| 10 | Fill-to-ledger durability | Done | `LedgerPostingConsumer.Publish` now blocks on channel capacity (`WaitToWriteAsync` loop) instead of dropping fills, with a regression test covering the full-channel case. | +| 1 | Streaming unification + honest status | Implementation complete; validation pending | Provider diagnostics now live at the ProviderSdk contract boundary; NYSE, Robinhood polling, live IB, and direct IB simulation paths report supervised state honestly. Subscription replay, bounded heartbeat teardown, explicit caller cancellation, and `unknown`/`unavailable` endpoint behavior have focused tests. `Meridian.ProviderSdk` builds with zero errors; the Infrastructure variants and focused tests still need the serialized validation pass. | +| 2 | Canonical symbol spine | Implementation complete; validation pending | `SecurityId`-aware provider aliases, comparison/canonical modes, production backfill resolver composition, and atomic persisted migration markers are implemented. Tests cover worker translation, inline/external migration inputs, restart no-op, changed fingerprints, malformed data, cancellation, and reload. Focused builds/tests and aggregate CI remain. | +| 3 | Unified data quality + browser dashboard | Implementation complete; validation pending | Shared stored/streaming/adapter scoring, stable gap identity, exact remediation requests, WPF dependency injection, and a rendered browser Data Quality region are implemented. The browser shows partial/unavailable evidence and accessible disabled-action reasons. Focused browser/.NET/WPF execution and aggregate CI remain. | +| 4 | Backfill feedback loop | Implementation complete; validation pending | Typed progress and retained execution/SLA history flow through shared contracts to browser and WPF. Completed backfills refresh history after final progress with stale-response protection, and the shared API client reads the typed history envelope. Focused browser/.NET/WPF execution and aggregate CI remain. | +| 5 | Failure & rate-limit hardening | Implementation complete; validation pending | Provider catalog failures remain immutable/sanitized; recursive aggregate classification preserves provider attribution and `Retry-After`. Alpha Vantage symbol and corporate-action paths map HTTP 429 and quota payloads to typed rate-limit failures without message-text heuristics. Focused tests and aggregate CI remain. | +| 6 | Mark-to-market wiring | Implementation complete; validation pending | Daily delta carrying values, per-security lineage, fresh tenant-owned position snapshots, Security Master/currency gates, all-member batch lifecycle, isolated same-day corrections, current-run status precedence, and browser configure/run/approve/retry actions are implemented. A two-security correction/restart scenario was added; its focused execution and aggregate CI remain. Legacy unowned snapshots now fail closed and require ownership backfill. | +| 7 | Automated journal drafts | In progress (completion audit) | Recurring schedules, durable restart/CAS/rearm behavior, exact corporate-action currency, immutable draft identity, and evidence policy are present. The latest audit still requires four corrections before completion: prevent rearmed work from inheriting stale posted readiness; resolve capital-account reconciliation from a server-owned source rather than client assertions; send exact tenant/company/fund/book/entity WPF scope; and evaluate delayed-run evidence at the actual execution/review time. | +| 8 | Closing entries + retained-earnings roll | Implementation complete; validation pending | Prepare-only queueing and hard lock are distinct in browser/WPF and server contracts. The backend now performs JIT readiness/version checks, atomic correction-pair persistence, durable reopen intent and exact retry convergence, source-linked SoftClosed reversal, strict tenant/company ownership, and a transactional Postgres temporary-balance guard plus period CAS/close event. Focused tests and aggregate CI remain. | +| 9 | One ledger spine | Implementation complete; validation pending | In-memory as-of indexes now preserve chronological out-of-order/dimensional reads. Durable posting detects global journal/command collisions, aggregate-scoped source/idempotency collisions, validates book context, and compares a canonical full-command fingerprint before treating crash retries as equivalent. `Meridian.Ledger` builds with zero errors; focused Storage/Ledger tests and aggregate CI remain. | +| 10 | Fill-to-ledger durability | Implementation complete; validation pending | Accepted fills are synchronously retained in an execution-owned WAL, acknowledged only after idempotent ledger posting, and replayed after restart. Forced shutdown and per-fill failures retain pending work; OMS fill side effects are resumable, use bounded `WriteAsync`, and expose explicit caller-scoped composition without inventing a global ledger. Focused Execution tests and aggregate CI remain. | ## The Two Headline Findings @@ -326,6 +329,15 @@ idea 9. > compiled the new scheduler, DI, and cockpit code before stopping on two unrelated sibling > `BackfillCoordinator` ambiguities. Aggregate CI has not run. +> **Completion-audit correction (2026-07-15):** the 2026-07-13 foundation did not yet prove a +> postable, non-compounding multi-security batch. The audit reopened this item after finding that +> full cumulative unrealized P&L could be posted again on a later day, aggregate drafts lacked the +> single-security lineage required by the posting guard, configured position lists could become +> stale, and an older posted draft could mask a blocked current run. The status table above tracks +> the delta-carrying, position-freshness, Security Master, batch/correction, tenant-isolation, and +> cockpit-precedence work now in progress; this section must not be read as complete until those +> paths pass their focused end-to-end tests. + ### 7. Automated Journal Drafts in the Close Cockpit `AutomatedJournalDraftProjector` + `AutomatedJournalApproval` model exactly the postings the live diff --git a/src/Meridian.Application/Accounting/DailyMarkToMarketService.cs b/src/Meridian.Application/Accounting/DailyMarkToMarketService.cs index d55f366935..f72cd7d869 100644 --- a/src/Meridian.Application/Accounting/DailyMarkToMarketService.cs +++ b/src/Meridian.Application/Accounting/DailyMarkToMarketService.cs @@ -13,7 +13,8 @@ public sealed record MarkToMarketPosition( decimal Quantity, decimal CostPrice, string? FinancialAccountId = null, - string? InstrumentType = null); + string? InstrumentType = null, + Guid? SecurityId = null); /// /// A resolved mark price with its provenance for valuation evidence. records @@ -25,7 +26,76 @@ public sealed record MarkPriceQuote( string Source, string EvidenceReference, FairValueLevel Level = FairValueLevel.Unclassified, - DateOnly? PriceAsOf = null); + DateOnly? PriceAsOf = null, + DailyPortfolioPriceConfidence Confidence = DailyPortfolioPriceConfidence.High) +{ + /// + /// Compatibility constructor for callers introduced with the confidence-aware mark contract. + /// Fair-value classification remains unclassified until the valuation policy supplies a default. + /// + public MarkPriceQuote( + decimal Price, + string Source, + string EvidenceReference, + DateOnly? ObservedOn, + DailyPortfolioPriceConfidence Confidence = DailyPortfolioPriceConfidence.High) + : this( + Price, + Source, + EvidenceReference, + FairValueLevel.Unclassified, + ObservedOn, + Confidence) + { + } + + public DateOnly? ObservedOn => PriceAsOf; +} + +/// +/// Trust policy applied before provider marks can enter a governed valuation draft. The policy is +/// explicit so legacy callers can continue to use the fund's while +/// production scheduling requires observation dates, freshness, and minimum confidence. +/// +public sealed record MarkPriceQualityPolicy +{ + public static MarkPriceQualityPolicy Standard { get; } = new( + TimeSpan.FromDays(3), + DailyPortfolioPriceConfidence.Medium, + RequireCompleteCoverage: false, + RequireObservedDate: true); + + public MarkPriceQualityPolicy( + TimeSpan maximumAge, + DailyPortfolioPriceConfidence minimumConfidence, + bool RequireCompleteCoverage = true, + bool RequireObservedDate = true) + { + if (maximumAge < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(maximumAge), "Maximum mark age cannot be negative."); + + MaximumAge = maximumAge; + MinimumConfidence = minimumConfidence; + this.RequireCompleteCoverage = RequireCompleteCoverage; + this.RequireObservedDate = RequireObservedDate; + } + + public TimeSpan MaximumAge { get; } + + public DailyPortfolioPriceConfidence MinimumConfidence { get; } + + public bool RequireCompleteCoverage { get; } + + public bool RequireObservedDate { get; } +} + +/// Provider mark rejected by a valuation trust or stale-price policy. +public sealed record MarkPriceRejection( + string Symbol, + string Reason, + DateOnly? ObservedOn = null, + DailyPortfolioPriceConfidence? Confidence = null, + string? EvidenceReference = null); /// /// Supplies mark prices for daily portfolio valuation. Implementations return null when @@ -37,6 +107,93 @@ public interface IMarkPriceSource Task GetMarkPriceAsync(string symbol, DateOnly asOf, CancellationToken ct = default); } +/// +/// Stable lookup key for one security account whose carrying value must be hydrated before a +/// daily mark can be projected. Symbols are normalized so producer and consumer keys compare +/// deterministically across process boundaries. +/// +public sealed record MarkToMarketCarryingValueKey +{ + public MarkToMarketCarryingValueKey(Guid? securityId, string symbol, string? financialAccountId) + { + if (securityId == Guid.Empty) + throw new ArgumentException("Security identifier cannot be empty when supplied.", nameof(securityId)); + if (string.IsNullOrWhiteSpace(symbol)) + throw new ArgumentException("Symbol is required for carrying-value lookup.", nameof(symbol)); + + SecurityId = securityId; + Symbol = symbol.Trim().ToUpperInvariant(); + FinancialAccountId = string.IsNullOrWhiteSpace(financialAccountId) ? null : financialAccountId.Trim(); + } + + public Guid? SecurityId { get; } + + public string Symbol { get; } + + public string? FinancialAccountId { get; } + + public static MarkToMarketCarryingValueKey FromPosition(MarkToMarketPosition position) + { + ArgumentNullException.ThrowIfNull(position); + return new MarkToMarketCarryingValueKey( + position.SecurityId, + position.Symbol, + position.FinancialAccountId); + } +} + +/// +/// Durable carrying-value lookup result. being null explicitly means the +/// securities account is absent; zero means it exists with a zero balance. +/// +public sealed record MarkToMarketCarryingValue +{ + public MarkToMarketCarryingValue( + decimal? amount, + string source, + DateTimeOffset? capturedAtUtc = null, + string? evidenceReference = null) + { + if (string.IsNullOrWhiteSpace(source)) + throw new ArgumentException("Carrying-value source is required.", nameof(source)); + + Amount = amount; + Source = source.Trim(); + CapturedAtUtc = capturedAtUtc?.ToUniversalTime(); + EvidenceReference = string.IsNullOrWhiteSpace(evidenceReference) ? null : evidenceReference.Trim(); + } + + public decimal? Amount { get; } + + public bool AccountExists => Amount.HasValue; + + public string Source { get; } + + public DateTimeOffset? CapturedAtUtc { get; } + + public string? EvidenceReference { get; } +} + +/// Batch scope for one durable carrying-value hydration. +public sealed record MarkToMarketCarryingValueRequest( + string FundId, + string PeriodId, + Guid? LedgerBookId, + DateTimeOffset AsOf, + string BaseCurrency, + IReadOnlyList Positions); + +/// +/// Supplies current durable securities-account carrying values in one scoped read. Implementations +/// must return one result for every requested key; use a null amount to report an absent account. +/// +public interface IMarkToMarketCarryingValueSource +{ + Task> GetCarryingValuesAsync( + MarkToMarketCarryingValueRequest request, + CancellationToken ct = default); +} + /// /// Request to prepare a governed daily mark-to-market draft for a fund's positions. /// @@ -47,7 +204,9 @@ public sealed record DailyMarkToMarketRequest( string BaseCurrency, IReadOnlyList Positions, string Actor, - string Reason); + string Reason, + MarkPriceQualityPolicy? QualityPolicy = null, + Guid? LedgerBookId = null); /// /// Outcome of a daily mark-to-market preparation run. is a @@ -57,10 +216,23 @@ public sealed record DailyMarkToMarketRequest( public sealed record DailyMarkToMarketRun( DailyPortfolioPricingProjection? Projection, AutomatedJournalApproval? Approval, - IReadOnlyList UnpricedSymbols) + IReadOnlyList UnpricedSymbols, + IReadOnlyList? RejectedMarks = null, + IReadOnlyList? Approvals = null) { + public IReadOnlyList RejectedMarks { get; init; } = RejectedMarks ?? []; + + /// All per-security/account drafts produced by the valuation batch. + public IReadOnlyList Approvals { get; init; } = + Approvals ?? (Approval is null ? [] : [Approval]); + /// True when a governed draft was submitted for approval. - public bool HasDraft => Approval is not null; + public bool HasDraft => Approvals.Count > 0; + + public int DraftCount => Approvals.Count; + + /// True when strict completeness policy rejected the whole valuation batch. + public bool IsBlocked => Projection is null && Approval is null && RejectedMarks.Count > 0; /// /// Stale-priced symbols surfaced for review: those blocked by a @@ -81,12 +253,23 @@ public sealed record DailyMarkToMarketRun( public sealed class DailyMarkToMarketService { private readonly IMarkPriceSource _priceSource; + private readonly IMarkToMarketCarryingValueSource _carryingValueSource; private readonly ILogger _log; public DailyMarkToMarketService(IMarkPriceSource priceSource, ILogger? log = null) + : this(priceSource, ExplicitAbsentCarryingValueSource.Instance, log) + { + } + + public DailyMarkToMarketService( + IMarkPriceSource priceSource, + IMarkToMarketCarryingValueSource carryingValueSource, + ILogger? log = null) { ArgumentNullException.ThrowIfNull(priceSource); + ArgumentNullException.ThrowIfNull(carryingValueSource); _priceSource = priceSource; + _carryingValueSource = carryingValueSource; _log = log ?? LoggingSetup.ForContext(); } @@ -98,33 +281,86 @@ public DailyMarkToMarketService(IMarkPriceSource priceSource, ILogger? log = nul public async Task PrepareAsync(DailyMarkToMarketRequest request, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(request); - if (request.Positions.Count == 0) + if (request.Positions is null || request.Positions.Count == 0) throw new ArgumentException("At least one position is required.", nameof(request)); if (string.IsNullOrWhiteSpace(request.Actor)) throw new ArgumentException("Actor is required.", nameof(request)); if (string.IsNullOrWhiteSpace(request.Reason)) throw new ArgumentException("Reason is required.", nameof(request)); + if (request.LedgerBookId == Guid.Empty) + throw new ArgumentException("Ledger book identifier cannot be empty when supplied.", nameof(request)); var asOfDate = DateOnly.FromDateTime(request.AsOf.UtcDateTime); + var qualityPolicy = request.QualityPolicy; var stalePricePolicy = request.Policy.StalePricePolicy; var marks = new List(request.Positions.Count); - var unpriced = new List(); + var rejected = new List(); var stalePriced = new List(); - foreach (var position in request.Positions) + var positionKeys = request.Positions + .Select(MarkToMarketCarryingValueKey.FromPosition) + .ToArray(); + var duplicateKey = positionKeys + .GroupBy(static key => key) + .FirstOrDefault(static group => group.Count() > 1); + if (duplicateKey is not null) + { + throw new ArgumentException( + $"Daily valuation position scope contains duplicate security/account key {duplicateKey.Key.Symbol}/{duplicateKey.Key.FinancialAccountId ?? "unscoped"}.", + nameof(request)); + } + + var carryingValues = await _carryingValueSource.GetCarryingValuesAsync( + new MarkToMarketCarryingValueRequest( + request.Policy.FundId, + request.PeriodId, + request.LedgerBookId, + request.AsOf, + request.BaseCurrency, + request.Positions), + ct).ConfigureAwait(false) + ?? throw new InvalidOperationException("Carrying-value source returned no result set."); + + foreach (var key in positionKeys) + { + if (!carryingValues.TryGetValue(key, out var carryingValue) || carryingValue is null) + { + throw new InvalidOperationException( + $"Carrying-value source omitted requested security/account key {key.Symbol}/{key.FinancialAccountId ?? "unscoped"}."); + } + } + + for (var positionIndex = 0; positionIndex < request.Positions.Count; positionIndex++) { ct.ThrowIfCancellationRequested(); + var position = request.Positions[positionIndex]; + var carryingValue = carryingValues[positionKeys[positionIndex]]; var quote = await _priceSource.GetMarkPriceAsync(position.Symbol, asOfDate, ct).ConfigureAwait(false); if (quote is null) { - unpriced.Add(position.Symbol); + rejected.Add(new MarkPriceRejection(position.Symbol, "No closing mark was available.")); _log.Warning( "No mark price available for {Symbol} as of {AsOfDate}; position excluded from fair-value draft", position.Symbol, asOfDate); continue; } + var rejectionReason = EvaluateMarkQuality(quote, asOfDate, qualityPolicy); + if (rejectionReason is not null) + { + rejected.Add(new MarkPriceRejection( + position.Symbol, + rejectionReason, + quote.PriceAsOf, + quote.Confidence, + quote.EvidenceReference)); + _log.Warning( + "Rejected mark for {Symbol} as of {AsOfDate}: {Reason}", + position.Symbol, asOfDate, rejectionReason); + continue; + } + // A price whose observation date is unknown cannot be assessed for freshness and is // treated as fresh; policies wanting to block unknown-date prices should not supply them. var assessment = quote.PriceAsOf is { } priceAsOf @@ -134,6 +370,12 @@ public async Task PrepareAsync(DailyMarkToMarketRequest re if (assessment is { IsStale: true, Handling: StalePriceHandling.Block }) { stalePriced.Add(position.Symbol); + rejected.Add(new MarkPriceRejection( + position.Symbol, + $"Mark is {assessment.AgeDays} days old; fund stale-price policy allows {stalePricePolicy.MaxAgeDays} days.", + quote.PriceAsOf, + quote.Confidence, + quote.EvidenceReference)); _log.Warning( "Mark price for {Symbol} is stale by {AgeDays}d (policy max {MaxAgeDays}d); blocked from fair-value draft", position.Symbol, assessment.AgeDays, stalePricePolicy.MaxAgeDays); @@ -161,18 +403,44 @@ public async Task PrepareAsync(DailyMarkToMarketRequest re quote.Price, quote.Source, quote.EvidenceReference, - position.FinancialAccountId, - position.InstrumentType, - fairValueLevel, - flagStale)); + FinancialAccountId: position.FinancialAccountId, + InstrumentType: position.InstrumentType, + FairValueLevel: fairValueLevel, + IsStalePriced: flagStale, + PriceObservedOn: quote.PriceAsOf, + Confidence: quote.Confidence, + SecurityId: position.SecurityId, + PriorCarryingValue: carryingValue.Amount, + CarryingValueSource: carryingValue.Source, + CarryingValueCapturedAtUtc: carryingValue.CapturedAtUtc, + CarryingValueEvidenceReference: carryingValue.EvidenceReference)); + } + + var unpriced = rejected + .Select(static item => item.Symbol) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (qualityPolicy?.RequireCompleteCoverage == true && rejected.Count > 0) + { + _log.Warning( + "Daily mark-to-market run for fund {FundId} period {PeriodId} blocked because {RejectedCount} marks failed completeness policy", + request.Policy.FundId, request.PeriodId, rejected.Count); + return new DailyMarkToMarketRun(null, null, unpriced, rejected) + { + StalePricedSymbols = stalePriced.Distinct(StringComparer.OrdinalIgnoreCase).ToArray() + }; } if (marks.Count == 0) { _log.Warning( "Daily mark-to-market run for fund {FundId} period {PeriodId} priced no positions ({UnpricedCount} unpriced, {StaleCount} stale)", - request.Policy.FundId, request.PeriodId, unpriced.Count, stalePriced.Count); - return new DailyMarkToMarketRun(null, null, unpriced) { StalePricedSymbols = stalePriced }; + request.Policy.FundId, request.PeriodId, unpriced.Length, stalePriced.Count); + return new DailyMarkToMarketRun(null, null, unpriced, rejected) + { + StalePricedSymbols = stalePriced.Distinct(StringComparer.OrdinalIgnoreCase).ToArray() + }; } var projection = DailyPortfolioPricingProjector.Project(new DailyPortfolioPricingInput( @@ -182,27 +450,83 @@ public async Task PrepareAsync(DailyMarkToMarketRequest re request.BaseCurrency, marks)); - var draft = DailyPortfolioPricingDraftBuilder.BuildDraft(projection); - if (draft is null) + var drafts = DailyPortfolioPricingDraftBuilder.BuildDrafts(projection); + if (drafts.Count == 0) { _log.Information( - "Daily marks for fund {FundId} period {PeriodId} produced no unrealized movement; nothing to post", + "Daily marks for fund {FundId} period {PeriodId} produced no carrying-value adjustment; nothing to post", request.Policy.FundId, request.PeriodId); - return new DailyMarkToMarketRun(projection, null, unpriced) { StalePricedSymbols = stalePriced }; + return new DailyMarkToMarketRun(projection, null, unpriced, rejected) + { + StalePricedSymbols = stalePriced.Distinct(StringComparer.OrdinalIgnoreCase).ToArray() + }; } - var approval = AutomatedJournalApproval.Submit( - draft, - request.Actor, - DateTimeOffset.UtcNow, - request.Reason, - draft.Metadata.EvidenceReferences.Select(static reference => reference.Uri).ToArray()); + var submittedAtUtc = DateTimeOffset.UtcNow; + var approvals = drafts + .Select(draft => AutomatedJournalApproval.Submit( + draft, + request.Actor, + submittedAtUtc, + request.Reason, + draft.Metadata.EvidenceReferences.Select(static reference => reference.Uri).ToArray())) + .ToArray(); _log.Information( - "Submitted fair-value draft {ApprovalId} for fund {FundId} period {PeriodId}: {LineCount} lines, net unrealized {NetUnrealized} ({UnpricedCount} unpriced)", - approval.ApprovalId, request.Policy.FundId, request.PeriodId, - draft.Lines.Count, projection.NetUnrealizedGainOrLoss, unpriced.Count); + "Submitted {DraftCount} fair-value drafts for fund {FundId} period {PeriodId}: net carrying-value adjustment {MarkAdjustment}, cumulative unrealized {NetUnrealized} ({UnpricedCount} unpriced)", + approvals.Length, request.Policy.FundId, request.PeriodId, + projection.NetMarkAdjustment, projection.NetUnrealizedGainOrLoss, unpriced.Length); - return new DailyMarkToMarketRun(projection, approval, unpriced) { StalePricedSymbols = stalePriced }; + return new DailyMarkToMarketRun(projection, approvals[0], unpriced, rejected, approvals) + { + StalePricedSymbols = stalePriced.Distinct(StringComparer.OrdinalIgnoreCase).ToArray() + }; + } + + private static string? EvaluateMarkQuality( + MarkPriceQuote quote, + DateOnly asOfDate, + MarkPriceQualityPolicy? policy) + { + if (quote.Price <= 0m) + return $"Closing mark price must be positive (was {quote.Price})."; + if (policy is null) + return null; + if (quote.Confidence < policy.MinimumConfidence) + return $"Mark confidence {quote.Confidence} is below required {policy.MinimumConfidence}."; + if (!quote.PriceAsOf.HasValue) + return policy.RequireObservedDate ? "Mark observation date is required." : null; + if (quote.PriceAsOf.Value > asOfDate) + return $"Mark observation date {quote.PriceAsOf:yyyy-MM-dd} is after valuation date {asOfDate:yyyy-MM-dd}."; + + var age = TimeSpan.FromDays(asOfDate.DayNumber - quote.PriceAsOf.Value.DayNumber); + return age > policy.MaximumAge + ? $"Mark is {age.TotalDays:0} days old; maximum allowed age is {policy.MaximumAge.TotalDays:0} days." + : null; + } + + /// + /// Compatibility source for direct/synthetic callers that have no durable ledger. It reports + /// account absence explicitly, causing the projector to use cost basis only for that case. + /// Production composition should inject a ledger-backed source. + /// + private sealed class ExplicitAbsentCarryingValueSource : IMarkToMarketCarryingValueSource + { + public static ExplicitAbsentCarryingValueSource Instance { get; } = new(); + + public Task> GetCarryingValuesAsync( + MarkToMarketCarryingValueRequest request, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + IReadOnlyDictionary result = request.Positions + .Select(MarkToMarketCarryingValueKey.FromPosition) + .ToDictionary( + static key => key, + static _ => new MarkToMarketCarryingValue( + amount: null, + source: "explicit-account-absent:cost-basis-fallback")); + return Task.FromResult(result); + } } } diff --git a/src/Meridian.Application/Composition/Features/BackfillFeatureRegistration.cs b/src/Meridian.Application/Composition/Features/BackfillFeatureRegistration.cs index a460b8d24b..008d94d654 100644 --- a/src/Meridian.Application/Composition/Features/BackfillFeatureRegistration.cs +++ b/src/Meridian.Application/Composition/Features/BackfillFeatureRegistration.cs @@ -7,6 +7,7 @@ using Meridian.Application.UI; using Meridian.DataIntegration.Monitoring.DataQuality; using Meridian.Infrastructure.Adapters.Core; +using Meridian.Infrastructure.Adapters.Core.SymbolResolution; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -24,6 +25,12 @@ internal sealed class BackfillFeatureRegistration : IServiceFeatureRegistration { public IServiceCollection Register(IServiceCollection services, CompositionOptions options) { + // The background-worker stack is created by this factory outside the normal + // BackfillCoordinator path. Resolve it from DI so it receives the same canonical, + // provider-scoped symbol resolver as the coordinator and ProviderFactory. + services.AddSingleton(sp => + new BackfillServiceFactory(symbolResolver: sp.GetService())); + // BackfillCoordinator - uses ProviderRegistry for unified provider discovery and // the canonical-registry symbol resolution spine when registered. services.AddSingleton(sp => diff --git a/src/Meridian.Application/Composition/ServiceCompositionRoot.cs b/src/Meridian.Application/Composition/ServiceCompositionRoot.cs index 34398efdd7..97751de861 100644 --- a/src/Meridian.Application/Composition/ServiceCompositionRoot.cs +++ b/src/Meridian.Application/Composition/ServiceCompositionRoot.cs @@ -276,7 +276,10 @@ public sealed record CompositionOptions /// public static CompositionOptions BackfillOnly => new() { - EnableSymbolManagement = false, + // Backfill is a provider-scoped identity workflow. Keep the canonical symbol spine + // enabled so the worker/coordinator/factory paths cannot silently fall back to raw + // input symbols when provider aliases are required. + EnableSymbolManagement = true, EnableBackfillServices = true, EnableEtlServices = true, EnableMaintenanceServices = false, diff --git a/src/Meridian.Application/SecurityMaster/CanonicalSymbolRegistryMigrationService.cs b/src/Meridian.Application/SecurityMaster/CanonicalSymbolRegistryMigrationService.cs index 116b9bbb6a..2edf7b9cd8 100644 --- a/src/Meridian.Application/SecurityMaster/CanonicalSymbolRegistryMigrationService.cs +++ b/src/Meridian.Application/SecurityMaster/CanonicalSymbolRegistryMigrationService.cs @@ -46,8 +46,10 @@ public async Task StartAsync(CancellationToken cancellationToken) var externalInputs = await LoadExternalInputsAsync(config, cancellationToken).ConfigureAwait(false); var fingerprint = ComputeFingerprint(inlineMappings, externalInputs); - var persistedRegistry = _registryStore.GetRegistry(); - if (persistedRegistry.MigrationMarkers.TryGetValue(MigrationId, out var completedFingerprint) && + var completedFingerprint = await _registryStore + .GetMigrationMarkerAsync(MigrationId, cancellationToken) + .ConfigureAwait(false); + if (completedFingerprint is not null && string.Equals(completedFingerprint, fingerprint, StringComparison.Ordinal)) { return; @@ -82,8 +84,9 @@ await ImportAsync(new CanonicalSymbolDefinition imported++; } - persistedRegistry.MigrationMarkers[MigrationId] = fingerprint; - await _registryStore.SaveRegistryAsync(cancellationToken).ConfigureAwait(false); + await _registryStore + .SetMigrationMarkerAsync(MigrationId, fingerprint, cancellationToken) + .ConfigureAwait(false); _logger.LogInformation( "Imported {Count} legacy symbol mappings into the canonical registry; legacy sources were retained for comparison and rollback.", imported); diff --git a/src/Meridian.Contracts/Api/UiApiClient.cs b/src/Meridian.Contracts/Api/UiApiClient.cs index db9bd1375b..4545e2ad3d 100644 --- a/src/Meridian.Contracts/Api/UiApiClient.cs +++ b/src/Meridian.Contracts/Api/UiApiClient.cs @@ -73,8 +73,8 @@ public void UpdateBaseUrl(string baseUrl) public async Task?> GetBackfillPresetsAsync(CancellationToken ct = default) => await GetAsync>(UiApiRoutes.BackfillPresets, ct).ConfigureAwait(false); - public async Task?> GetBackfillExecutionsAsync(int limit = 50, CancellationToken ct = default) - => await GetAsync>( + public async Task GetBackfillExecutionsAsync(int limit = 50, CancellationToken ct = default) + => await GetAsync( UiApiRoutes.WithQuery(UiApiRoutes.BackfillExecutions, $"limit={limit}"), ct).ConfigureAwait(false); public async Task GetBackfillStatisticsAsync(int? hours = null, CancellationToken ct = default) diff --git a/src/Meridian.Contracts/Api/UiApiRoutes.cs b/src/Meridian.Contracts/Api/UiApiRoutes.cs index e546ee02dd..c292bc9a73 100644 --- a/src/Meridian.Contracts/Api/UiApiRoutes.cs +++ b/src/Meridian.Contracts/Api/UiApiRoutes.cs @@ -797,6 +797,7 @@ public static class UiApiRoutes public const string LedgerCloseManagementTaskSignOffs = "/api/ledger/close-management/task-signoffs"; public const string LedgerCloseManagementEvidenceReview = "/api/ledger/close-management/evidence-review"; public const string LedgerCloseManagementPeriodLock = "/api/ledger/close-management/period-lock"; + public const string LedgerCloseManagementPeriodReopen = "/api/ledger/close-management/period-reopen"; public const string LedgerManualJournalEntryWorkbench = "/api/ledger/journal-entry-workbench"; public const string LedgerPrivateCapitalActivity = "/api/ledger/private-capital/activity"; public const string LedgerPrivateCapitalFundEventRecord = "/api/ledger/private-capital/fund-event-record"; @@ -815,6 +816,7 @@ public static class UiApiRoutes public const string LedgerJournalAutomationDailyMarkToMarketIntake = "/api/ledger/journal-automation/daily-mark-to-market-intake"; public const string LedgerJournalAutomationDailyMarkToMarketSchedules = "/api/ledger/journal-automation/daily-mark-to-market-schedules"; public const string LedgerJournalAutomationDailyMarkToMarketRunDue = "/api/ledger/journal-automation/daily-mark-to-market-run-due"; + public const string LedgerJournalAutomationDailyMarkToMarketBatchLifecycle = "/api/ledger/journal-automation/daily-mark-to-market-batch-lifecycle"; public const string LedgerJournalAutomationMonthlySchedules = "/api/ledger/journal-automation/monthly-schedules"; public const string LedgerJournalAutomationMonthlyRunDue = "/api/ledger/journal-automation/monthly-schedules/run-due"; public const string LedgerReportsTrialBalance = "/api/ledger/reports/trial-balance"; diff --git a/src/Meridian.Contracts/Domain/IPositionSnapshotStore.cs b/src/Meridian.Contracts/Domain/IPositionSnapshotStore.cs index d0af364615..365d8a03ff 100644 --- a/src/Meridian.Contracts/Domain/IPositionSnapshotStore.cs +++ b/src/Meridian.Contracts/Domain/IPositionSnapshotStore.cs @@ -31,6 +31,11 @@ public sealed record PositionRecord( /// Cumulative realised P&L. /// All open positions at snapshot time. /// UTC timestamp of the snapshot. +/// Immutable tenant owner of the snapshot. +/// Immutable company owner of the snapshot. +/// Immutable fund-profile owner of the snapshot. +/// Immutable ledger-book owner of the snapshot. +/// Immutable legal/reporting entity owner of the snapshot. public sealed record AccountSnapshotRecord( string RunId, string AccountId, @@ -41,7 +46,23 @@ public sealed record AccountSnapshotRecord( decimal UnrealisedPnl, decimal RealisedPnl, IReadOnlyList Positions, - DateTimeOffset AsOf); + DateTimeOffset AsOf, + string? TenantId = null, + string? CompanyId = null, + string? FundProfileId = null, + Guid? LedgerBookId = null, + string? EntityId = null); + +/// +/// Immutable accounting-owner scope used to retrieve a position snapshot without crossing a +/// tenant, company, fund, ledger-book, or entity boundary. +/// +public sealed record PositionSnapshotOwnerScope( + string TenantId, + string CompanyId, + string FundProfileId, + Guid LedgerBookId, + string EntityId); /// /// Provides crash-safe persistence for per-account portfolio snapshots. @@ -61,6 +82,16 @@ public interface IPositionSnapshotStore /// Task GetLatestSnapshotAsync(string runId, string accountId, CancellationToken ct = default); + /// + /// Retrieves the latest snapshot owned by the exact accounting scope. Implementations must + /// not fall back to an unowned or differently owned run/account snapshot. + /// + Task GetLatestSnapshotAsync( + string runId, + string accountId, + PositionSnapshotOwnerScope ownerScope, + CancellationToken ct = default); + /// /// Streams all snapshots for the given run / account pair between /// and (both inclusive, UTC). diff --git a/src/Meridian.Contracts/Ledger/AccountingConfigurationCloseReportingDtos.cs b/src/Meridian.Contracts/Ledger/AccountingConfigurationCloseReportingDtos.cs index 78f112efb3..3ef9f2a06c 100644 --- a/src/Meridian.Contracts/Ledger/AccountingConfigurationCloseReportingDtos.cs +++ b/src/Meridian.Contracts/Ledger/AccountingConfigurationCloseReportingDtos.cs @@ -398,7 +398,8 @@ public sealed record LockClosePeriodRequestDto( string? ClosePackageId = null, string? ClosePackageManifestId = null, string? ClosePackageRetainedManifestRoute = null, - OperationsActionOriginDto ActionOrigin = OperationsActionOriginDto.HumanOperator) + OperationsActionOriginDto ActionOrigin = OperationsActionOriginDto.HumanOperator, + bool PrepareClosingEntriesOnly = false) { public IReadOnlyList EvidenceLinks { get; init; } = EvidenceLinks ?? []; @@ -513,7 +514,8 @@ public sealed record ClosePeriodPlanDto( ClosePeriodPlanConfigurationDto? Configuration = null, IReadOnlyList? EvidenceReviews = null, IReadOnlyList? OperatingCoverage = null, - ClosePostingGateDto? ClosingEntriesGate = null) + ClosePostingGateDto? ClosingEntriesGate = null, + long WorkflowVersion = 0) { public IReadOnlyList ValidationIssues { get; init; } = ValidationIssues ?? []; @@ -986,6 +988,14 @@ Task> ListAsync( string? companyId = null); Task SaveAsync(ManualJournalEntryDraftDto draft, CancellationToken ct = default); + + /// + /// Persists a related set of journal drafts as one workbench mutation. Implementations must + /// make the complete set visible together or leave the retained set unchanged. + /// + Task SaveBatchAsync( + IReadOnlyList drafts, + CancellationToken ct = default); } public interface IAccountingConfigurationStore diff --git a/src/Meridian.Contracts/Ledger/AccountingConfigurationDtos.cs b/src/Meridian.Contracts/Ledger/AccountingConfigurationDtos.cs index 2775e623b1..af0257f605 100644 --- a/src/Meridian.Contracts/Ledger/AccountingConfigurationDtos.cs +++ b/src/Meridian.Contracts/Ledger/AccountingConfigurationDtos.cs @@ -752,7 +752,8 @@ public sealed record TreasuryLedgerContextDto( string? CapitalAccountId = null, string? InvestorId = null, string? PaymentIntentId = null, - string? SettlementReference = null); + string? SettlementReference = null, + string? BatchCorrelationId = null); public sealed record ManualJournalEntryDraftDto( Guid JournalEntryId, diff --git a/src/Meridian.Contracts/Workstation/AutomatedJournalScheduleDtos.cs b/src/Meridian.Contracts/Workstation/AutomatedJournalScheduleDtos.cs index beef96584f..b9d8a7a3ff 100644 --- a/src/Meridian.Contracts/Workstation/AutomatedJournalScheduleDtos.cs +++ b/src/Meridian.Contracts/Workstation/AutomatedJournalScheduleDtos.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Meridian.Contracts.Ledger; namespace Meridian.Contracts.Workstation; @@ -16,6 +17,32 @@ public enum AutomatedJournalScheduleStateDto Failed = 7 } +/// +/// Reviewed capital-account tie-out for the NAV and high-water-mark inputs used by one +/// monthly fee-accrual cycle. The scheduler verifies the retained values, variance, +/// confidence, reviewer, source version, and evidence before it can create a draft. +/// +public sealed record AutomatedJournalCapitalAccountReconciliationDto( + string ReconciliationId, + string PeriodId, + string Currency, + decimal ReconciledBeginningNav, + decimal ReconciledEndingNavBeforeFees, + decimal ReconciledHighWaterMark, + decimal CapitalAccountOpeningBalance, + decimal CapitalAccountEndingBalanceBeforeFees, + decimal CapitalAccountHighWaterMark, + decimal MaximumVarianceTolerance, + decimal ConfidenceScore, + bool IsReconciled, + string SourceVersion, + string ReviewedBy, + DateTimeOffset ReviewedAtUtc, + IReadOnlyList? EvidenceLinks = null) +{ + public IReadOnlyList EvidenceLinks { get; init; } = EvidenceLinks ?? []; +} + /// /// Close-cockpit projection for the explicitly scoped monthly automated-journal work. /// Counts are schedule-run counts, not posted-journal counts; every produced item remains @@ -36,7 +63,13 @@ public sealed record AutomatedJournalScheduleStatusDto( string Summary, IReadOnlyList? EvidenceLinks = null, IReadOnlyList? Blockers = null, - IReadOnlyList? JournalEntryIds = null) + IReadOnlyList? JournalEntryIds = null, + decimal? MinimumEvidenceConfidence = null, + AutomatedJournalEvidenceQualityDto? LowestEvidenceQuality = null, + int HumanReviewQueueCount = 0, + string? EntityId = null, + string? TenantId = null, + string? CompanyId = null) { public IReadOnlyList EvidenceLinks { get; init; } = EvidenceLinks ?? []; @@ -54,5 +87,8 @@ Task GetStatusAsync( string? fundProfileId, Guid? ledgerBookId, string? periodId, - CancellationToken ct = default); + CancellationToken ct = default, + string? tenantId = null, + string? companyId = null, + string? entityId = null); } diff --git a/src/Meridian.Contracts/Workstation/DailyValuationScheduleDtos.cs b/src/Meridian.Contracts/Workstation/DailyValuationScheduleDtos.cs index af090a774b..f0598cde4d 100644 --- a/src/Meridian.Contracts/Workstation/DailyValuationScheduleDtos.cs +++ b/src/Meridian.Contracts/Workstation/DailyValuationScheduleDtos.cs @@ -12,7 +12,8 @@ public enum DailyValuationScheduleStateDto DraftReady = 3, NoAdjustment = 4, Blocked = 5, - Failed = 6 + Failed = 6, + Posted = 7 } /// @@ -31,7 +32,48 @@ public sealed record DailyValuationScheduleStatusDto( string Summary, Guid? JournalEntryId, IReadOnlyList EvidenceLinks, - IReadOnlyList Blockers); + IReadOnlyList Blockers, + IReadOnlyList? JournalEntryIds = null, + string? BatchCorrelationId = null, + string? EntityId = null, + string? TenantId = null, + string? CompanyId = null) +{ + /// + /// Every governed draft in the latest valuation batch. remains + /// the first-entry compatibility alias for older clients. + /// + public IReadOnlyList JournalEntryIds { get; init; } = JournalEntryIds ?? []; +} + +/// +/// One human-governed command that submits, approves, and posts every draft in the latest daily +/// valuation batch. The server derives the batch members from the retained schedule; callers +/// cannot replace the journal-entry set. +/// +public sealed record DailyValuationBatchLifecycleRequestDto( + string ScheduleId, + string FundProfileId, + string Actor, + string Notes, + IReadOnlyList? EvidenceLinks = null, + string? TenantId = null, + string? CompanyId = null) +{ + public IReadOnlyList EvidenceLinks { get; init; } = EvidenceLinks ?? []; +} + +/// Result of one governed daily-valuation batch lifecycle command. +public sealed record DailyValuationBatchLifecycleResultDto( + string ScheduleId, + string BatchCorrelationId, + bool IsComplete, + IReadOnlyList JournalEntryIds, + IReadOnlyList PostedJournalEntryIds, + IReadOnlyList? Blockers = null) +{ + public IReadOnlyList Blockers { get; init; } = Blockers ?? []; +} /// /// Read-only scheduler projection consumed by close-cockpit surfaces without taking @@ -43,5 +85,8 @@ Task GetStatusAsync( string? fundProfileId, Guid? ledgerBookId, string? periodId, - CancellationToken ct = default); + CancellationToken ct = default, + string? entityId = null, + string? tenantId = null, + string? companyId = null); } diff --git a/src/Meridian.Contracts/Workstation/OperationsContinuityDtos.cs b/src/Meridian.Contracts/Workstation/OperationsContinuityDtos.cs index cce0ed758f..411b278618 100644 --- a/src/Meridian.Contracts/Workstation/OperationsContinuityDtos.cs +++ b/src/Meridian.Contracts/Workstation/OperationsContinuityDtos.cs @@ -1342,7 +1342,8 @@ public sealed record PrivateCapitalCloseCockpitDto( IReadOnlyList PlannedCapabilities, IReadOnlyList? ApprovalHistory = null, IReadOnlyList? NavSupportPackages = null, - IReadOnlyList? EvidencePackages = null) + IReadOnlyList? EvidencePackages = null, + DailyValuationScheduleStatusDto? DailyValuationStatus = null) { public IReadOnlyList ApprovalHistory { get; init; } = ApprovalHistory ?? []; @@ -1362,5 +1363,7 @@ Task GetCockpitAsync( Guid? fundAccountId = null, string? periodId = null, string? entityId = null, - CancellationToken ct = default); + CancellationToken ct = default, + string? tenantId = null, + string? companyId = null); } diff --git a/src/Meridian.Execution/BrokerageServiceRegistration.cs b/src/Meridian.Execution/BrokerageServiceRegistration.cs index 04d5aa00da..93e1bb5231 100644 --- a/src/Meridian.Execution/BrokerageServiceRegistration.cs +++ b/src/Meridian.Execution/BrokerageServiceRegistration.cs @@ -1,4 +1,6 @@ +using Meridian.Application.SecurityMaster; using Meridian.Execution.Adapters; +using Meridian.Execution.Events; using Meridian.Execution.Interfaces; using Meridian.Execution.Sdk; using Meridian.Execution.Services; @@ -87,7 +89,8 @@ public static IServiceCollection AddBrokerageExecution( portfolioState, brokerageConfiguration: brokerageConfiguration, liveOrderReadinessGate: liveOrderReadinessGate, - options: orderManagementOptions); + options: orderManagementOptions, + tradeEventPublisher: sp.GetService()); }); services.TryAddSingleton(); @@ -95,6 +98,69 @@ public static IServiceCollection AddBrokerageExecution( return services; } + /// + /// Explicitly composes accepted execution fills into one caller-owned ledger scope. + /// This registration deliberately requires ledger and durable-store factories because + /// brokerage execution alone cannot infer the correct accounting book or period. + /// + public static IServiceCollection AddTradeFillLedgerPosting( + this IServiceCollection services, + string postingScope, + Func ledgerFactory, + Func postingStoreFactory, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(postingScope); + ArgumentNullException.ThrowIfNull(ledgerFactory); + ArgumentNullException.ThrowIfNull(postingStoreFactory); + + var normalizedScope = postingScope.Trim(); + var options = new TradeFillLedgerPostingOptions(); + configure?.Invoke(options); + if (options.ChannelCapacity <= 0) + throw new ArgumentOutOfRangeException(nameof(options.ChannelCapacity), "Trade-fill channel capacity must be positive."); + if (options.DrainTimeout is { } drainTimeout + && (drainTimeout <= TimeSpan.Zero || drainTimeout == Timeout.InfiniteTimeSpan)) + { + throw new ArgumentOutOfRangeException(nameof(options.DrainTimeout), "Trade-fill drain timeout must be positive and finite."); + } + if (options.CancellationTimeout is { } cancellationTimeout + && (cancellationTimeout <= TimeSpan.Zero || cancellationTimeout == Timeout.InfiniteTimeSpan)) + { + throw new ArgumentOutOfRangeException(nameof(options.CancellationTimeout), "Trade-fill cancellation timeout must be positive and finite."); + } + if (services.Any(static descriptor => + descriptor.ServiceType == typeof(ITradeEventPublisher) + || descriptor.ServiceType == typeof(ITradeFillPostingStore) + || descriptor.ServiceType == typeof(LedgerPostingConsumer))) + { + throw new InvalidOperationException( + "A trade-fill publisher or posting store is already registered. Compose multiple ledger scopes explicitly instead of allowing one registration to shadow another."); + } + + services.AddSingleton(postingStoreFactory); + services.AddSingleton(sp => + { + var securityGate = sp.GetRequiredService(); + + return new LedgerPostingConsumer( + ledgerFactory(sp), + sp.GetRequiredService>(), + sp.GetRequiredService(), + normalizedScope, + channelCapacity: options.ChannelCapacity, + securityValidationGate: securityGate, + requireSecurityMasterPostingGate: true, + drainTimeout: options.DrainTimeout, + cancellationTimeout: options.CancellationTimeout); + }); + services.AddSingleton(sp => + sp.GetRequiredService()); + + return services; + } + /// /// Registers a specific implementation as a keyed named gateway. /// Use the same when configuring . @@ -154,3 +220,13 @@ private static IBrokerageGateway ResolveBrokerageGateway(IServiceProvider sp, st "Register gateways using AddBrokerageGateway(gatewayId) before calling AddBrokerageExecution()."); } } + +/// Runtime controls for the explicit fill-to-ledger consumer. +public sealed class TradeFillLedgerPostingOptions +{ + public int ChannelCapacity { get; set; } = 10_000; + + public TimeSpan? DrainTimeout { get; set; } + + public TimeSpan? CancellationTimeout { get; set; } +} diff --git a/src/Meridian.Execution/Events/LedgerPostingConsumer.cs b/src/Meridian.Execution/Events/LedgerPostingConsumer.cs index c4e1c86c43..497b5656f1 100644 --- a/src/Meridian.Execution/Events/LedgerPostingConsumer.cs +++ b/src/Meridian.Execution/Events/LedgerPostingConsumer.cs @@ -24,7 +24,10 @@ public sealed class LedgerPostingConsumer : ITradeEventPublisher, IAsyncDisposab private static readonly TimeSpan DefaultCancellationTimeout = TimeSpan.FromSeconds(1); private readonly Ledger.Ledger _ledger; - private readonly Channel _channel; + private readonly Channel _channel; + private readonly ITradeFillPostingStore _postingStore; + private readonly string _postingScope; + private readonly TaskCompletionSource _recoveryLoaded = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly Task _processingTask; private readonly CancellationTokenSource _cts = new(); private readonly ILogger _logger; @@ -36,6 +39,7 @@ public sealed class LedgerPostingConsumer : ITradeEventPublisher, IAsyncDisposab private readonly object _postingBoundarySync = new(); private Task? _disposeTask; private bool _postingDisabled; + private int _disposeStarted; private int _cancellationSourceDisposed; internal Task ProcessingCompletion => _processingTask; @@ -45,6 +49,8 @@ public sealed class LedgerPostingConsumer : ITradeEventPublisher, IAsyncDisposab /// /// The double-entry ledger that journal entries will be posted to. /// Logger for diagnostic output. + /// Durable accepted-fill handoff owned by the same accounting scope. + /// Exact ledger book/period scope represented by the store and ledger. /// /// Maximum number of un-processed events to buffer before additional publishes block /// until the consumer drains capacity (backpressure). @@ -61,6 +67,8 @@ public sealed class LedgerPostingConsumer : ITradeEventPublisher, IAsyncDisposab public LedgerPostingConsumer( Ledger.Ledger ledger, ILogger logger, + ITradeFillPostingStore postingStore, + string postingScope, int channelCapacity = 10_000, ISecurityValidationGateService? securityValidationGate = null, bool requireSecurityMasterPostingGate = true, @@ -69,11 +77,21 @@ public LedgerPostingConsumer( { ArgumentNullException.ThrowIfNull(ledger); ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(postingStore); + ArgumentException.ThrowIfNullOrWhiteSpace(postingScope); if (channelCapacity <= 0) throw new ArgumentOutOfRangeException(nameof(channelCapacity)); + if (!string.Equals(postingStore.PostingScope, postingScope.Trim(), StringComparison.Ordinal)) + { + throw new ArgumentException( + $"Posting store scope '{postingStore.PostingScope}' does not match ledger scope '{postingScope.Trim()}'.", + nameof(postingScope)); + } _ledger = ledger; _logger = logger; + _postingStore = postingStore; + _postingScope = postingScope.Trim(); _securityValidationGate = securityValidationGate; _requireSecurityMasterPostingGate = requireSecurityMasterPostingGate; _drainTimeout = RequirePositiveTimeout(drainTimeout, DefaultDrainTimeout, nameof(drainTimeout)); @@ -88,24 +106,51 @@ public LedgerPostingConsumer( SingleWriter = false, SingleReader = true }; - _channel = Channel.CreateBounded(options); + _channel = Channel.CreateBounded(options); _processingTask = Task.Run(() => ProcessAsync(_cts.Token)); } /// - /// Enqueues a for asynchronous ledger posting. - /// Returns immediately while the channel has capacity; when the channel is full the call - /// blocks until the background consumer frees space, so fills are never dropped. + /// Durably accepts a for asynchronous ledger posting. + /// Returning means the fill can replay after restart. While the channel has capacity the + /// call returns after the WAL append; when full it blocks until the consumer frees space. /// /// - /// Disposal has begun and the event could not be enqueued. + /// Disposal prevented acceptance, or the fill was durably accepted but the live channel + /// closed before enqueue; in the latter case the exception message confirms restart replay. /// public void Publish(TradeExecutedEvent tradeEvent) { ArgumentNullException.ThrowIfNull(tradeEvent); + if (Volatile.Read(ref _disposeStarted) != 0) + { + throw new ChannelClosedException( + $"LedgerPostingConsumer is disposed; fill {tradeEvent.FillId} for {tradeEvent.Symbol} was not accepted."); + } + + // Establish a strict cut between restart replay and live acceptance so a fill cannot + // appear in both the recovered snapshot and the channel during consumer startup. + _recoveryLoaded.Task.GetAwaiter().GetResult(); + if (Volatile.Read(ref _disposeStarted) != 0) + { + throw new ChannelClosedException( + $"LedgerPostingConsumer is disposed; fill {tradeEvent.FillId} for {tradeEvent.Symbol} was not accepted."); + } + + // The synchronous publisher contract intentionally applies storage backpressure here: + // returning means the executed fill is durably replayable even if this process stops. + var acceptance = _postingStore + .AcceptAsync(tradeEvent, CancellationToken.None) + .GetAwaiter() + .GetResult(); + if (!acceptance.ShouldEnqueue) + return; + + var posting = acceptance.Posting + ?? throw new InvalidOperationException("A newly accepted trade fill is missing its durable posting envelope."); // Fast path: capacity available. - if (_channel.Writer.TryWrite(tradeEvent)) + if (_channel.Writer.TryWrite(posting)) return; // Slow path: channel full. Block the publisher until the consumer drains capacity @@ -114,13 +159,13 @@ public void Publish(TradeExecutedEvent tradeEvent) "LedgerPostingConsumer channel is full; applying backpressure for fill {FillId} on {Symbol}", tradeEvent.FillId, tradeEvent.Symbol); - while (!_channel.Writer.TryWrite(tradeEvent)) + while (!_channel.Writer.TryWrite(posting)) { var channelOpen = _channel.Writer.WaitToWriteAsync().AsTask().GetAwaiter().GetResult(); if (!channelOpen) { throw new ChannelClosedException( - $"LedgerPostingConsumer is disposed; fill {tradeEvent.FillId} for {tradeEvent.Symbol} was not enqueued."); + $"LedgerPostingConsumer is disposed; durable fill {tradeEvent.FillId} for {tradeEvent.Symbol} will replay on restart."); } } } @@ -134,6 +179,7 @@ public ValueTask DisposeAsync() { lock (_disposeSync) { + Interlocked.Exchange(ref _disposeStarted, 1); return new ValueTask(_disposeTask ??= DisposeCoreAsync()); } } @@ -272,28 +318,90 @@ private static TimeSpan RequirePositiveTimeout( private async Task ProcessAsync(CancellationToken ct) { - await foreach (var evt in _channel.Reader.ReadAllAsync(ct).ConfigureAwait(false)) + try { - try + var recovered = await _postingStore.LoadPendingAsync(ct).ConfigureAwait(false); + _recoveryLoaded.TrySetResult(); + foreach (var posting in recovered) { - await PostEventAsync(evt, ct).ConfigureAwait(false); + ct.ThrowIfCancellationRequested(); + await ProcessPostingAsync(posting, ct).ConfigureAwait(false); } - catch (OperationCanceledException) when (ct.IsCancellationRequested) + + await foreach (var posting in _channel.Reader.ReadAllAsync(ct).ConfigureAwait(false)) { - throw; + await ProcessPostingAsync(posting, ct).ConfigureAwait(false); } - catch (Exception ex) + } + catch (Exception ex) + { + _recoveryLoaded.TrySetException(ex); + throw; + } + } + + private async Task ProcessPostingAsync(PendingTradeFillPosting posting, CancellationToken ct) + { + var evt = posting.TradeEvent; + try + { + var result = await PostEventAsync(evt, ct).ConfigureAwait(false); + if (!result.Posted) { - _logger.LogError( - ex, - "Failed to post ledger entries for fill {FillId} ({Symbol})", - evt.FillId, evt.Symbol); + await RecordFailureSafelyAsync(evt, result.Failure!, null).ConfigureAwait(false); + return; } + + // Acknowledgement is intentionally not cancellable after ledger mutation. If it + // fails, the pending record survives and replay detects the existing fill journals. + await _postingStore.MarkPostedAsync(evt.FillId, CancellationToken.None).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + await RecordFailureSafelyAsync(evt, ex.Message, ex).ConfigureAwait(false); + } + } + + private async Task RecordFailureSafelyAsync( + TradeExecutedEvent evt, + string failure, + Exception? exception) + { + try + { + await _postingStore.RecordFailureAsync(evt.FillId, failure, CancellationToken.None) + .ConfigureAwait(false); + } + catch (Exception persistenceException) + { + _logger.LogError( + persistenceException, + "Failed to append reconciliation handoff for fill {FillId} ({Symbol}); the original pending WAL record remains unacknowledged", + evt.FillId, + evt.Symbol); } + + _logger.LogError( + exception, + "Failed to post ledger entries for fill {FillId} ({Symbol}); the fill remains pending for replay", + evt.FillId, + evt.Symbol); } - private async Task PostEventAsync(TradeExecutedEvent evt, CancellationToken ct) + private async Task PostEventAsync(TradeExecutedEvent evt, CancellationToken ct) { + lock (_postingBoundarySync) + { + if (_postingDisabled || ct.IsCancellationRequested) + throw new OperationCanceledException("Ledger posting consumer is shutting down.", ct); + if (HasCompletePosting(evt)) + return LedgerPostingAttempt.Success; + } + var securityGate = await EvaluateSecurityMasterPostingGateAsync(evt, ct).ConfigureAwait(false); if (!securityGate.CanPost) { @@ -302,7 +410,7 @@ private async Task PostEventAsync(TradeExecutedEvent evt, CancellationToken ct) evt.FillId, evt.Symbol, securityGate.Reason); - return; + return LedgerPostingAttempt.Failed(securityGate.Reason); } ct.ThrowIfCancellationRequested(); @@ -318,23 +426,30 @@ private async Task PostEventAsync(TradeExecutedEvent evt, CancellationToken ct) ? LedgerAccounts.Cash : LedgerAccounts.CashAccount(accountId); var metadata = BuildPostingMetadata(evt, securityGate); + var existing = _ledger.GetJournalEntries(new LedgerQuery(FillId: evt.FillId)); + var hasTradePosting = existing.Any(entry => PostingMatches(entry, evt, "trade-fill")); + var hasCommissionPosting = evt.Commission <= 0m + || existing.Any(entry => PostingMatches(entry, evt, "trade-commission")); - switch (evt.Side) + if (!hasTradePosting) { - case Sdk.OrderSide.Buy: - PostBuy(evt, cashAccount, accountId, metadata); - break; - - case Sdk.OrderSide.Sell: - PostSell(evt, cashAccount, accountId, metadata); - break; - - default: - _logger.LogWarning("Unhandled order side {Side} for fill {FillId}", evt.Side, evt.FillId); - break; + switch (evt.Side) + { + case Sdk.OrderSide.Buy: + PostBuy(evt, cashAccount, accountId, metadata); + break; + + case Sdk.OrderSide.Sell: + PostSell(evt, cashAccount, accountId, metadata); + break; + + default: + return LedgerPostingAttempt.Failed( + $"Order side '{evt.Side}' is not supported for fill '{evt.FillId:D}'."); + } } - if (evt.Commission > 0m) + if (!hasCommissionPosting) { PostCommission(evt, cashAccount, accountId, metadata); } @@ -343,6 +458,33 @@ private async Task PostEventAsync(TradeExecutedEvent evt, CancellationToken ct) _logger.LogDebug( "Posted ledger entries for fill {FillId}: {Side} {Quantity} {Symbol} @ {Price}", evt.FillId, evt.Side, evt.FilledQuantity, evt.Symbol, evt.FillPrice); + return LedgerPostingAttempt.Success; + } + + private bool HasCompletePosting(TradeExecutedEvent evt) + { + var existing = _ledger.GetJournalEntries(new LedgerQuery(FillId: evt.FillId)); + var hasTradePosting = existing.Any(entry => PostingMatches(entry, evt, "trade-fill")); + var hasCommissionPosting = evt.Commission <= 0m + || existing.Any(entry => PostingMatches(entry, evt, "trade-commission")); + return hasTradePosting && hasCommissionPosting; + } + + private bool PostingMatches(JournalEntry entry, TradeExecutedEvent evt, string activityType) + { + if (!string.Equals(entry.Metadata.ActivityType, activityType, StringComparison.Ordinal) + || !string.Equals(entry.Metadata.Symbol, evt.Symbol, StringComparison.OrdinalIgnoreCase) + || !string.Equals( + entry.Metadata.FinancialAccountId, + evt.FinancialAccountId, + StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return entry.Metadata.Tags is not null + && entry.Metadata.Tags.TryGetValue("ledgerPosting.scope", out var retainedScope) + && string.Equals(retainedScope, _postingScope, StringComparison.Ordinal); } private async Task EvaluateSecurityMasterPostingGateAsync( @@ -385,7 +527,7 @@ private async Task EvaluateSecurityMasterPostin issueCodes); } - private static JournalEntryMetadata BuildPostingMetadata( + private JournalEntryMetadata BuildPostingMetadata( TradeExecutedEvent evt, LedgerPostingSecurityGateResult securityGate) { @@ -396,7 +538,8 @@ private static JournalEntryMetadata BuildPostingMetadata( ["securityMaster.scope"] = securityGate.ValidationScope, ["securityMaster.gate"] = "resolved-approved-mapped", ["securityMaster.issueCodes"] = string.Join(",", securityGate.IssueCodes), - ["source.orderId"] = evt.OrderId + ["source.orderId"] = evt.OrderId, + ["ledgerPosting.scope"] = _postingScope }; return new JournalEntryMetadata( @@ -518,4 +661,11 @@ public static LedgerPostingSecurityGateResult Allowed( public static LedgerPostingSecurityGateResult Blocked(string reason) => new(false, null, "SecurityMasterResolution", [], reason); } + + private sealed record LedgerPostingAttempt(bool Posted, string? Failure) + { + public static LedgerPostingAttempt Success { get; } = new(true, null); + + public static LedgerPostingAttempt Failed(string failure) => new(false, failure); + } } diff --git a/src/Meridian.Execution/Events/TradeExecutedEvent.cs b/src/Meridian.Execution/Events/TradeExecutedEvent.cs index 6bf1a8a3f9..b488713c9d 100644 --- a/src/Meridian.Execution/Events/TradeExecutedEvent.cs +++ b/src/Meridian.Execution/Events/TradeExecutedEvent.cs @@ -3,10 +3,11 @@ namespace Meridian.Execution.Events; /// -/// Domain event raised when an order fill is applied to the portfolio. -/// Published by both paper-trading and live-execution paths so that -/// downstream consumers (e.g. ) can -/// react without the portfolio holding a hard dependency on the ledger. +/// Canonical event for an accepted execution fill. For an OMS-tracked paper order it is published +/// after the portfolio mutation; live fills can carry zero realized P&L when no portfolio +/// accounting context is attached. An order manager publishes it only for tracked orders and when an +/// is explicitly composed for the owning ledger scope; merely +/// enabling paper or live execution does not create an accounting book implicitly. /// /// Unique identifier for the fill that triggered this event. /// The order that produced the fill. @@ -19,7 +20,7 @@ namespace Meridian.Execution.Events; /// Realized P&L produced by this fill (non-zero only when the fill closes or reduces /// an existing position). /// -/// Portfolio cash balance after applying the fill. +/// Portfolio cash balance after applying the fill, or zero when unavailable. /// Wall-clock timestamp of the fill. /// /// Optional brokerage account ID. null when the portfolio operates on a single @@ -30,7 +31,7 @@ public sealed record TradeExecutedEvent( string OrderId, string Symbol, OrderSide Side, - long FilledQuantity, + decimal FilledQuantity, decimal FillPrice, decimal Commission, decimal RealizedPnl, diff --git a/src/Meridian.Execution/Events/TradeFillPostingStore.cs b/src/Meridian.Execution/Events/TradeFillPostingStore.cs new file mode 100644 index 0000000000..65c223ecfa --- /dev/null +++ b/src/Meridian.Execution/Events/TradeFillPostingStore.cs @@ -0,0 +1,431 @@ +using System.Runtime.ExceptionServices; +using System.Text.Json; +using Meridian.Execution.Serialization; +using Meridian.Storage.Archival; +using Microsoft.Extensions.Logging; + +namespace Meridian.Execution.Events; + +/// Configuration for the durable trade-fill-to-ledger handoff. +public sealed record TradeFillPostingStoreOptions(string RootDirectory, string PostingScope) +{ + public string WalDirectory => Path.Combine(RootDirectory, "wal"); +} + +/// +/// One accepted fill that remains pending until its corresponding ledger journals have posted. +/// +public sealed record PendingTradeFillPosting( + long StoreSequence, + string PostingScope, + TradeExecutedEvent TradeEvent, + DateTimeOffset AcceptedAtUtc, + int FailureCount = 0, + string? LastFailure = null, + DateTimeOffset? LastAttemptAtUtc = null); + +/// Result of accepting a fill into the durable handoff. +public sealed record TradeFillPostingAcceptance( + PendingTradeFillPosting? Posting, + bool ShouldEnqueue, + bool WasAlreadyPosted); + +/// +/// Durable pending/acknowledgement boundary for fill-to-ledger processing. +/// A successful accept must survive restart; acknowledgement is allowed only after posting. +/// +public interface ITradeFillPostingStore : IAsyncDisposable +{ + string PostingScope { get; } + + Task AcceptAsync( + TradeExecutedEvent tradeEvent, + CancellationToken ct = default); + + Task> LoadPendingAsync(CancellationToken ct = default); + + Task MarkPostedAsync(Guid fillId, CancellationToken ct = default); + + Task RecordFailureAsync(Guid fillId, string failure, CancellationToken ct = default); +} + +/// +/// Execution-owned fill handoff backed by Meridian's write-ahead log. Pending, failure, and +/// per-fill acknowledgement records are append-only so one failed fill cannot be accidentally +/// committed by a later successful fill. +/// +public sealed class WalTradeFillPostingStore : ITradeFillPostingStore +{ + private const string PendingRecordType = "TradeFillPending"; + private const string FailureRecordType = "TradeFillFailure"; + private const string PostedRecordType = "TradeFillPosted"; + private const int MaximumFailureLength = 4_096; + + private readonly WriteAheadLog _wal; + private readonly ILogger _logger; + private readonly SemaphoreSlim _operationGate = new(1, 1); + private readonly object _initializationSync = new(); + private readonly object _disposeSync = new(); + private readonly Dictionary _pending = []; + private readonly HashSet _posted = []; + private readonly Dictionary _acceptedEvents = []; + private Task? _initializationTask; + private Task? _disposeTask; + private int _disposeStarted; + + public WalTradeFillPostingStore( + TradeFillPostingStoreOptions options, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(options); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + if (string.IsNullOrWhiteSpace(options.RootDirectory)) + throw new ArgumentException("A durable trade-fill store root is required.", nameof(options)); + if (string.IsNullOrWhiteSpace(options.PostingScope)) + throw new ArgumentException("A ledger posting scope is required.", nameof(options)); + + PostingScope = options.PostingScope.Trim(); + _wal = new WriteAheadLog( + options.WalDirectory, + new WalOptions + { + SyncMode = WalSyncMode.EveryWrite, + ArchiveAfterTruncate = false, + MaxWalFileAge = TimeSpan.FromDays(1), + MaxWalFileSizeBytes = 5 * 1024 * 1024, + CorruptionMode = WalCorruptionMode.Halt + }); + } + + public string PostingScope { get; } + + public async Task AcceptAsync( + TradeExecutedEvent tradeEvent, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(tradeEvent); + if (tradeEvent.FillId == Guid.Empty) + throw new ArgumentException("A durable fill id is required.", nameof(tradeEvent)); + + ThrowIfDisposing(); + await EnsureInitializedAsync(ct).ConfigureAwait(false); + await _operationGate.WaitAsync(ct).ConfigureAwait(false); + try + { + ThrowIfDisposing(); + if (_acceptedEvents.TryGetValue(tradeEvent.FillId, out var acceptedEvent) + && acceptedEvent != tradeEvent) + { + throw new InvalidOperationException( + $"Fill '{tradeEvent.FillId:D}' was replayed with different economic content."); + } + if (_posted.Contains(tradeEvent.FillId)) + { + return new TradeFillPostingAcceptance(null, ShouldEnqueue: false, WasAlreadyPosted: true); + } + + if (_pending.TryGetValue(tradeEvent.FillId, out var retained)) + { + return new TradeFillPostingAcceptance(retained, ShouldEnqueue: false, WasAlreadyPosted: false); + } + + var acceptedAtUtc = DateTimeOffset.UtcNow; + var payload = new TradeFillPendingWalPayload(PostingScope, tradeEvent, acceptedAtUtc); + var json = JsonSerializer.Serialize( + payload, + ExecutionJsonContext.Default.TradeFillPendingWalPayload); + var walRecord = await _wal.AppendAsync(json, PendingRecordType, ct).ConfigureAwait(false); + var posting = new PendingTradeFillPosting( + walRecord.Sequence, + PostingScope, + tradeEvent, + acceptedAtUtc); + _pending.Add(tradeEvent.FillId, posting); + _acceptedEvents.Add(tradeEvent.FillId, tradeEvent); + return new TradeFillPostingAcceptance(posting, ShouldEnqueue: true, WasAlreadyPosted: false); + } + finally + { + _operationGate.Release(); + } + } + + public async Task> LoadPendingAsync(CancellationToken ct = default) + { + ThrowIfDisposing(); + await EnsureInitializedAsync(ct).ConfigureAwait(false); + await _operationGate.WaitAsync(ct).ConfigureAwait(false); + try + { + ThrowIfDisposing(); + return _pending.Values + .OrderBy(static posting => posting.StoreSequence) + .ToArray(); + } + finally + { + _operationGate.Release(); + } + } + + public async Task MarkPostedAsync(Guid fillId, CancellationToken ct = default) + { + if (fillId == Guid.Empty) + throw new ArgumentException("A durable fill id is required.", nameof(fillId)); + + ThrowIfDisposing(); + await EnsureInitializedAsync(ct).ConfigureAwait(false); + await _operationGate.WaitAsync(ct).ConfigureAwait(false); + try + { + ThrowIfDisposing(); + if (_posted.Contains(fillId)) + return; + if (!_pending.ContainsKey(fillId)) + throw new InvalidOperationException($"Fill '{fillId:D}' is not pending and cannot be acknowledged."); + + var payload = new TradeFillStatusWalPayload(PostingScope, fillId, DateTimeOffset.UtcNow, null); + var json = JsonSerializer.Serialize( + payload, + ExecutionJsonContext.Default.TradeFillStatusWalPayload); + await _wal.AppendAsync(json, PostedRecordType, ct).ConfigureAwait(false); + _pending.Remove(fillId); + _posted.Add(fillId); + } + finally + { + _operationGate.Release(); + } + } + + public async Task RecordFailureAsync(Guid fillId, string failure, CancellationToken ct = default) + { + if (fillId == Guid.Empty) + throw new ArgumentException("A durable fill id is required.", nameof(fillId)); + ArgumentException.ThrowIfNullOrWhiteSpace(failure); + + ThrowIfDisposing(); + await EnsureInitializedAsync(ct).ConfigureAwait(false); + await _operationGate.WaitAsync(ct).ConfigureAwait(false); + try + { + ThrowIfDisposing(); + if (_posted.Contains(fillId)) + return; + if (!_pending.TryGetValue(fillId, out var posting)) + throw new InvalidOperationException($"Fill '{fillId:D}' is not pending and cannot record a failure."); + + var occurredAtUtc = DateTimeOffset.UtcNow; + var normalizedFailure = failure.Trim(); + if (normalizedFailure.Length > MaximumFailureLength) + normalizedFailure = normalizedFailure[..MaximumFailureLength]; + var payload = new TradeFillStatusWalPayload( + PostingScope, + fillId, + occurredAtUtc, + normalizedFailure); + var json = JsonSerializer.Serialize( + payload, + ExecutionJsonContext.Default.TradeFillStatusWalPayload); + await _wal.AppendAsync(json, FailureRecordType, ct).ConfigureAwait(false); + _pending[fillId] = posting with + { + FailureCount = posting.FailureCount + 1, + LastFailure = normalizedFailure, + LastAttemptAtUtc = occurredAtUtc + }; + } + finally + { + _operationGate.Release(); + } + } + + public ValueTask DisposeAsync() + { + lock (_disposeSync) + { + Interlocked.Exchange(ref _disposeStarted, 1); + return new ValueTask(_disposeTask ??= DisposeCoreAsync()); + } + } + + private async Task EnsureInitializedAsync(CancellationToken ct) + { + Task initializationTask; + lock (_initializationSync) + { + ThrowIfDisposing(); + _initializationTask ??= InitializeCoreAsync(); + initializationTask = _initializationTask; + } + + await initializationTask.WaitAsync(ct).ConfigureAwait(false); + } + + private async Task InitializeCoreAsync() + { + await _wal.InitializeAsync(CancellationToken.None).ConfigureAwait(false); + await foreach (var record in _wal.GetUncommittedRecordsAsync(CancellationToken.None).ConfigureAwait(false)) + { + if (record.RecordType == PendingRecordType) + { + var payload = DeserializePending(record); + EnsureScope(payload.PostingScope); + if (payload.TradeEvent.FillId == Guid.Empty) + throw new InvalidDataException($"Trade-fill WAL record {record.Sequence} has an empty fill id."); + if (_acceptedEvents.TryGetValue(payload.TradeEvent.FillId, out var acceptedEvent) + && acceptedEvent != payload.TradeEvent) + { + throw new InvalidDataException( + $"Trade-fill WAL contains conflicting economics for fill '{payload.TradeEvent.FillId:D}'."); + } + _acceptedEvents.TryAdd(payload.TradeEvent.FillId, payload.TradeEvent); + if (_posted.Contains(payload.TradeEvent.FillId)) + continue; + if (_pending.ContainsKey(payload.TradeEvent.FillId)) + continue; + + _pending.Add(payload.TradeEvent.FillId, new PendingTradeFillPosting( + record.Sequence, + PostingScope, + payload.TradeEvent, + payload.AcceptedAtUtc)); + continue; + } + + if (record.RecordType is FailureRecordType or PostedRecordType) + { + var payload = DeserializeStatus(record); + EnsureScope(payload.PostingScope); + if (payload.FillId == Guid.Empty || !_acceptedEvents.ContainsKey(payload.FillId)) + { + throw new InvalidDataException( + $"Trade-fill WAL status record {record.Sequence} does not reference an accepted fill."); + } + if (record.RecordType == PostedRecordType) + { + if (payload.Failure is not null) + throw new InvalidDataException($"Trade-fill acknowledgement record {record.Sequence} contains failure data."); + _pending.Remove(payload.FillId); + _posted.Add(payload.FillId); + continue; + } + + if (string.IsNullOrWhiteSpace(payload.Failure)) + throw new InvalidDataException($"Trade-fill failure record {record.Sequence} has no failure detail."); + + if (_pending.TryGetValue(payload.FillId, out var posting)) + { + _pending[payload.FillId] = posting with + { + FailureCount = posting.FailureCount + 1, + LastFailure = payload.Failure, + LastAttemptAtUtc = payload.OccurredAtUtc + }; + } + } + } + + _logger.LogInformation( + "Recovered {PendingCount} pending trade fill(s) for ledger posting scope {PostingScope}", + _pending.Count, + PostingScope); + } + + private async Task DisposeCoreAsync() + { + Task? initializationTask; + lock (_initializationSync) + { + initializationTask = _initializationTask; + } + + Exception? initializationFailure = null; + if (initializationTask is not null) + { + try + { + await initializationTask.ConfigureAwait(false); + } + catch (Exception ex) + { + initializationFailure = ex; + } + } + + await _operationGate.WaitAsync(CancellationToken.None).ConfigureAwait(false); + try + { + await _wal.DisposeAsync().ConfigureAwait(false); + } + finally + { + _operationGate.Release(); + _operationGate.Dispose(); + } + + if (initializationFailure is not null) + ExceptionDispatchInfo.Capture(initializationFailure).Throw(); + } + + private TradeFillPendingWalPayload DeserializePending(WalRecord record) + { + var json = record.DeserializePayload() + ?? throw new InvalidDataException($"Trade-fill WAL record {record.Sequence} has no payload."); + try + { + return JsonSerializer.Deserialize( + json, + ExecutionJsonContext.Default.TradeFillPendingWalPayload) + ?? throw new InvalidDataException($"Trade-fill WAL record {record.Sequence} is empty."); + } + catch (JsonException ex) + { + throw new InvalidDataException($"Trade-fill WAL record {record.Sequence} is invalid.", ex); + } + } + + private TradeFillStatusWalPayload DeserializeStatus(WalRecord record) + { + var json = record.DeserializePayload() + ?? throw new InvalidDataException($"Trade-fill WAL record {record.Sequence} has no payload."); + try + { + return JsonSerializer.Deserialize( + json, + ExecutionJsonContext.Default.TradeFillStatusWalPayload) + ?? throw new InvalidDataException($"Trade-fill WAL record {record.Sequence} is empty."); + } + catch (JsonException ex) + { + throw new InvalidDataException($"Trade-fill WAL record {record.Sequence} is invalid.", ex); + } + } + + private void EnsureScope(string retainedScope) + { + if (!string.Equals(retainedScope, PostingScope, StringComparison.Ordinal)) + { + throw new InvalidDataException( + $"Trade-fill WAL scope '{retainedScope}' does not match configured posting scope '{PostingScope}'."); + } + } + + private void ThrowIfDisposing() + { + if (Volatile.Read(ref _disposeStarted) != 0) + throw new ObjectDisposedException(nameof(WalTradeFillPostingStore)); + } +} + +internal sealed record TradeFillPendingWalPayload( + string PostingScope, + TradeExecutedEvent TradeEvent, + DateTimeOffset AcceptedAtUtc); + +internal sealed record TradeFillStatusWalPayload( + string PostingScope, + Guid FillId, + DateTimeOffset OccurredAtUtc, + string? Failure); diff --git a/src/Meridian.Execution/Meridian.Execution.csproj b/src/Meridian.Execution/Meridian.Execution.csproj index 16747c729e..168e352d62 100644 --- a/src/Meridian.Execution/Meridian.Execution.csproj +++ b/src/Meridian.Execution/Meridian.Execution.csproj @@ -15,7 +15,7 @@ - + diff --git a/src/Meridian.Execution/OrderManagementSystem.cs b/src/Meridian.Execution/OrderManagementSystem.cs index 3940e807da..b21ff5127b 100644 --- a/src/Meridian.Execution/OrderManagementSystem.cs +++ b/src/Meridian.Execution/OrderManagementSystem.cs @@ -1,6 +1,10 @@ using System.Collections.Concurrent; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; using System.Threading.Channels; using Meridian.Application.Pipeline; +using Meridian.Execution.Events; using Meridian.Execution.Sdk; using Meridian.Execution.Services; using Microsoft.Extensions.Logging; @@ -33,10 +37,11 @@ public sealed class OrderManagementSystem : IOrderManager, IDisposable private readonly ConcurrentDictionary _orderSessionIds = new(StringComparer.OrdinalIgnoreCase); private readonly CancellationTokenSource _reportPumpCts = new(); private readonly Task _reportPumpTask; - private readonly ConcurrentDictionary _processedFillReports = new(); - private readonly ConcurrentQueue _processedFillReportOrder = new(); + private readonly ITradeEventPublisher? _tradeEventPublisher; + private readonly ConcurrentDictionary _orderFinancialAccountIds = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _fillProcessing = new(); + private readonly ConcurrentQueue _completedFillReportOrder = new(); private int _orderSequence; - private long _droppedExecutionReports; private const int MaxTrackedFillReports = 4096; private static readonly TimeSpan InitialReportStreamRetryDelay = TimeSpan.FromSeconds(1); @@ -53,7 +58,8 @@ public OrderManagementSystem( PaperSessionPersistenceService? sessionPersistence = null, BrokerageConfiguration? brokerageConfiguration = null, ILiveOrderReadinessGate? liveOrderReadinessGate = null, - OrderManagementSystemOptions? options = null) + OrderManagementSystemOptions? options = null, + ITradeEventPublisher? tradeEventPublisher = null) { _gateway = gateway ?? throw new ArgumentNullException(nameof(gateway)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -65,6 +71,7 @@ public OrderManagementSystem( _portfolioState = portfolioState; _sessionPersistence = sessionPersistence; _brokerageConfiguration = brokerageConfiguration; + _tradeEventPublisher = tradeEventPublisher; _options = options ?? new OrderManagementSystemOptions(); _gatewayExecutionMode = gateway is IExecutionGatewayModeProvider modeProvider ? modeProvider.ExecutionMode @@ -735,70 +742,172 @@ private async Task ProcessFillReportAsync( decimal previousFilledQuantity, CancellationToken ct) { - if (!TryMarkFillProcessed(report)) + var orderId = report.ClientOrderId ?? report.OrderId; + if (!_fillProcessing.TryGetValue(report, out var progress)) { - return; + // Gateways report FilledQuantity cumulatively (e.g. IB CumulativeQuantity, + // Alpaca filled_qty) while fill consumers treat each report as a discrete + // trade, so only the increment since the last tracked fill may be forwarded. + var incrementQuantity = report.FilledQuantity - previousFilledQuantity; + if (incrementQuantity <= 0m) + return; + + var fillIncrement = incrementQuantity == report.FilledQuantity + ? report + : report with { FilledQuantity = incrementQuantity }; + progress = _fillProcessing.GetOrAdd( + report, + _ => new FillProcessingProgress( + fillIncrement, + report.FilledQuantity, + !string.IsNullOrWhiteSpace(orderId) && _orders.ContainsKey(orderId))); } - // Gateways report FilledQuantity cumulatively (e.g. IB CumulativeQuantity, - // Alpaca filled_qty) while fill consumers treat each report as a discrete - // trade, so only the increment since the last tracked fill may be forwarded — - // otherwise partial fills are double-applied (5 then 10 becomes 15, not 10). - var incrementQuantity = report.FilledQuantity - previousFilledQuantity; - if (incrementQuantity <= 0m) + await progress.Gate.WaitAsync(ct).ConfigureAwait(false); + try { - return; - } + if (progress.IsComplete) + return; - var fillIncrement = incrementQuantity == report.FilledQuantity - ? report - : report with { FilledQuantity = incrementQuantity }; + var fillIncrement = progress.FillIncrement; - // Only fills for orders this OMS placed may mutate the paper portfolio; - // stream reports for external/untracked orders are still published below - // for observers but must not corrupt tracked positions. - var orderId = report.ClientOrderId ?? report.OrderId; - if (_portfolioState is PaperTradingPortfolio paperPortfolio - && !string.IsNullOrWhiteSpace(orderId) - && _orders.ContainsKey(orderId)) - { - paperPortfolio.ApplyFill(fillIncrement); - } + if (!progress.PortfolioApplied) + { + var realisedPnlBefore = _portfolioState?.RealisedPnl ?? 0m; + + // Only fills for orders this OMS placed may mutate the paper portfolio; + // stream reports for external/untracked orders are still published below. + if (_portfolioState is PaperTradingPortfolio paperPortfolio + && progress.IsTrackedOrder) + { + paperPortfolio.ApplyFill(fillIncrement); + progress.RealizedPnl = paperPortfolio.RealisedPnl - realisedPnlBefore; + } + + progress.NewCash = _portfolioState?.Cash ?? 0m; + progress.PortfolioApplied = true; + } + + if (!progress.TradeEventPublished) + { + if (_tradeEventPublisher is not null && progress.IsTrackedOrder) + { + progress.TradeEvent ??= CreateTradeExecutedEvent( + fillIncrement, + progress.CumulativeFilledQuantity, + progress.RealizedPnl, + progress.NewCash, + ResolveFinancialAccountId(orderId)); + _tradeEventPublisher.Publish(progress.TradeEvent); + } - await RecordSessionFillAsync(sessionId, fillIncrement, ct).ConfigureAwait(false); + progress.TradeEventPublished = true; + } + + if (!progress.SessionRecorded) + { + await RecordSessionFillAsync(sessionId, fillIncrement, ct).ConfigureAwait(false); + progress.SessionRecorded = true; + } - if (!_executionChannel.Writer.TryWrite(fillIncrement)) + if (!progress.ExecutionReportPublished) + { + // FullMode.Wait must be observed asynchronously. TryWrite here silently lost + // accepted fills whenever subscribers lagged behind the configured capacity. + await _executionChannel.Writer.WriteAsync(fillIncrement, ct).ConfigureAwait(false); + progress.ExecutionReportPublished = true; + } + + progress.IsComplete = true; + TrackCompletedFill(report); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) { - var dropped = Interlocked.Increment(ref _droppedExecutionReports); + // Preserve the per-side-effect progress object. An identical gateway replay can + // resume at the failed step without applying portfolio/session/publication twice. _logger.LogError( - "Execution report channel full; dropped fill report for order {OrderId} ({Symbol} {FilledQuantity} @ {FillPrice}); {DroppedCount} dropped in total — ExecutionReports consumers must drain faster", - fillIncrement.OrderId, fillIncrement.Symbol, fillIncrement.FilledQuantity, fillIncrement.FillPrice, dropped); + ex, + "Fill processing paused for order {OrderId} ({Symbol} {FilledQuantity} @ {FillPrice}); a replay will resume the unfinished side effects", + progress.FillIncrement.OrderId, + progress.FillIncrement.Symbol, + progress.FillIncrement.FilledQuantity, + progress.FillIncrement.FillPrice); + } + finally + { + progress.Gate.Release(); } } - /// - /// Marks a fill report as processed; returns when the identical - /// report was already handled via the other path (sync ack vs. report stream). - /// is a record, so value equality identifies the replayed - /// ack; distinct fills always differ in timestamp and cumulative filled quantity. - /// - private bool TryMarkFillProcessed(ExecutionReport report) + private void TrackCompletedFill(ExecutionReport report) { - if (!_processedFillReports.TryAdd(report, 0)) + _completedFillReportOrder.Enqueue(report); + while (_completedFillReportOrder.Count > MaxTrackedFillReports + && _completedFillReportOrder.TryDequeue(out var oldest)) { - return false; + if (_fillProcessing.TryGetValue(oldest, out var progress) && progress.IsComplete) + _fillProcessing.TryRemove(oldest, out _); } + } - _processedFillReportOrder.Enqueue(report); - while (_processedFillReportOrder.Count > MaxTrackedFillReports - && _processedFillReportOrder.TryDequeue(out var oldest)) + private string? ResolveFinancialAccountId(string? orderId) + => !string.IsNullOrWhiteSpace(orderId) + && _orderFinancialAccountIds.TryGetValue(orderId, out var accountId) + ? accountId + : null; + + private static TradeExecutedEvent CreateTradeExecutedEvent( + ExecutionReport fillIncrement, + decimal cumulativeFilledQuantity, + decimal realizedPnl, + decimal newCash, + string? financialAccountId) + { + if (fillIncrement.FillPrice is not { } fillPrice) { - _processedFillReports.TryRemove(oldest, out _); + throw new InvalidOperationException( + $"Fill report '{fillIncrement.OrderId}' for '{fillIncrement.Symbol}' has no execution price."); } - return true; + var canonicalIdentity = string.Join( + "|", + EncodeIdentityPart(fillIncrement.OrderId), + EncodeIdentityPart(fillIncrement.ClientOrderId), + EncodeIdentityPart(fillIncrement.GatewayOrderId), + EncodeIdentityPart(fillIncrement.Symbol), + ((int)fillIncrement.Side).ToString(CultureInfo.InvariantCulture), + fillIncrement.FilledQuantity.ToString(CultureInfo.InvariantCulture), + cumulativeFilledQuantity.ToString(CultureInfo.InvariantCulture), + fillPrice.ToString(CultureInfo.InvariantCulture), + (fillIncrement.Commission ?? 0m).ToString(CultureInfo.InvariantCulture), + fillIncrement.Timestamp.ToUniversalTime().Ticks.ToString(CultureInfo.InvariantCulture), + EncodeIdentityPart(financialAccountId)); + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonicalIdentity)); + var fillId = new Guid(hash.AsSpan(0, 16)); + + return new TradeExecutedEvent( + fillId, + fillIncrement.ClientOrderId ?? fillIncrement.OrderId, + fillIncrement.Symbol, + fillIncrement.Side, + fillIncrement.FilledQuantity, + fillPrice, + fillIncrement.Commission ?? 0m, + realizedPnl, + newCash, + fillIncrement.Timestamp, + financialAccountId); } + private static string EncodeIdentityPart(string? value) + => value is null + ? "-" + : Convert.ToBase64String(Encoding.UTF8.GetBytes(value)); + private static OrderState CreateRejectedState( string orderId, OrderRequest request, @@ -1158,6 +1267,26 @@ OrderStatus.Rejected or { _orders.TryRemove(removableOrderId, out _); _orderSessionIds.TryRemove(removableOrderId, out _); + _orderFinancialAccountIds.TryRemove(removableOrderId, out _); } } + + private sealed class FillProcessingProgress( + ExecutionReport fillIncrement, + decimal cumulativeFilledQuantity, + bool isTrackedOrder) + { + public SemaphoreSlim Gate { get; } = new(1, 1); + public ExecutionReport FillIncrement { get; } = fillIncrement; + public decimal CumulativeFilledQuantity { get; } = cumulativeFilledQuantity; + public bool IsTrackedOrder { get; } = isTrackedOrder; + public TradeExecutedEvent? TradeEvent { get; set; } + public decimal RealizedPnl { get; set; } + public decimal NewCash { get; set; } + public bool PortfolioApplied { get; set; } + public bool TradeEventPublished { get; set; } + public bool SessionRecorded { get; set; } + public bool ExecutionReportPublished { get; set; } + public volatile bool IsComplete; + } } diff --git a/src/Meridian.Execution/README.md b/src/Meridian.Execution/README.md index 517574344f..c0febb2856 100644 --- a/src/Meridian.Execution/README.md +++ b/src/Meridian.Execution/README.md @@ -6,7 +6,7 @@ module_id: SRC-EXECUTION path: src/Meridian.Execution status: active owner_lane: Execution and Fund Accounts -last_reviewed: 2026-07-05 +last_reviewed: 2026-07-15 --- # src/Meridian.Execution @@ -40,9 +40,24 @@ rejection instead of a broker submit. Broker-backed readiness also includes open-order reconciliation: `BrokerageExecutionReconciliationService` compares broker-reported open orders with the OMS open-order ledger, treats missing client order IDs as untraceable breaks, and reports OMS/broker divergence before live operators rely on the gateway. -Ledger posting from trade-fill events is Security Master gated: postings require a configured -validation gate, resolved Security Master identity, non-blocked validation, and journal metadata -that preserves the Security Master ID, fill ID, symbol, and gate evidence for provenance. +Ledger posting from trade-fill events is explicit and Security Master gated. `AddBrokerageExecution` +injects an `ITradeEventPublisher` into the OMS only when one has been composed; the execution host +does not infer an accounting book or period. A book-owning composition root can call +`AddTradeFillLedgerPosting` with a ledger factory, exact posting scope, and caller-owned +`ITradeFillPostingStore`. `WalTradeFillPostingStore` durably accepts each fill before publication +returns, retains per-fill failure/reconciliation records, replays unacknowledged fills after restart, +and acknowledges only after the required trade and commission journals exist. Postings still require +a configured validation gate, resolved Security Master identity, non-blocked validation, and journal +metadata that preserves the Security Master ID, fill ID, symbol, posting scope, and gate evidence for +provenance. `UiServer` intentionally supplies no ledger consumer by default because its paper +portfolios may own session ledgers and the host has no safe global book/period scope. +The book-owning caller must provide the ledger's persistence/hydration lifecycle and must not attach +this publisher to a `PaperTradingPortfolio` that already posts the same fills into that ledger. +For sell accounting, the caller must also attach portfolio state that supplies the fill's realized +P&L, or publish an enriched `TradeExecutedEvent` through the abstraction instead of relying on +the OMS fallback value. +The OMS sends only fills for its own tracked orders to the accounting publisher; untracked broker +stream reports remain observable through `ExecutionReports` but cannot contaminate the configured book. Live execution controls include persisted circuit-breaker state, position limits, and manual overrides. Run-scoped manual overrides are matched against order `runId` metadata, and submitted paper orders that use an override carry the applied override ID, run/strategy/symbol scope, and @@ -62,6 +77,8 @@ state. OMS runtime guardrails are configuration-backed under `Execution:OrderManagement`: `MaxRetainedOrders`, `ExecutionChannelCapacity`, and `CancelAllMaxConcurrency`. Reg T margin rates are configuration-bindable through `Execution:Margin:RegT` while preserving the standard defaults. +Fill-report publication observes bounded-channel `WriteAsync` backpressure, and duplicate gateway +reports resume only unfinished portfolio, durable-accounting, session, or subscriber side effects. ## Diagrams diff --git a/src/Meridian.Execution/Serialization/ExecutionJsonContext.cs b/src/Meridian.Execution/Serialization/ExecutionJsonContext.cs index 6d5da96409..3c048dfb76 100644 --- a/src/Meridian.Execution/Serialization/ExecutionJsonContext.cs +++ b/src/Meridian.Execution/Serialization/ExecutionJsonContext.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Meridian.Execution.Events; using Meridian.Execution.Interfaces; using Meridian.Execution.Margin; using Meridian.Execution.Models; @@ -40,6 +41,9 @@ namespace Meridian.Execution.Serialization; [JsonSerializable(typeof(MarginAccountType))] [JsonSerializable(typeof(ExecutionAuditEntry))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(TradeExecutedEvent))] +[JsonSerializable(typeof(TradeFillPendingWalPayload))] +[JsonSerializable(typeof(TradeFillStatusWalPayload))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(ExecutionCircuitBreakerState))] diff --git a/src/Meridian.FinancialOperations/AccountingClose/AccountingCloseManagementService.cs b/src/Meridian.FinancialOperations/AccountingClose/AccountingCloseManagementService.cs index 6699087c94..83a1610faa 100644 --- a/src/Meridian.FinancialOperations/AccountingClose/AccountingCloseManagementService.cs +++ b/src/Meridian.FinancialOperations/AccountingClose/AccountingCloseManagementService.cs @@ -14,42 +14,105 @@ public interface IAccountingCloseManagementService { Task GetPeriodPlanAsync(Guid workflowId, CancellationToken ct = default); + Task GetPeriodPlanScopedAsync( + Guid workflowId, + string? tenantId, + string? companyId, + CancellationToken ct = default) + => GetPeriodPlanAsync(workflowId, ct); + Task RequestLateAdjustmentAsync( CreateLateAdjustmentRequestDto request, string actor, CancellationToken ct = default); + Task RequestLateAdjustmentScopedAsync( + CreateLateAdjustmentRequestDto request, + string actor, + string? tenantId, + string? companyId, + CancellationToken ct = default) + => RequestLateAdjustmentAsync(request, actor, ct); + Task ReviewLateAdjustmentAsync( ReviewLateAdjustmentRequestDto request, string actor, CancellationToken ct = default); + Task ReviewLateAdjustmentScopedAsync( + ReviewLateAdjustmentRequestDto request, + string actor, + string? tenantId, + string? companyId, + CancellationToken ct = default) + => ReviewLateAdjustmentAsync(request, actor, ct); + Task SignOffCloseTaskAsync( SignOffCloseTaskRequestDto request, string actor, CancellationToken ct = default); + Task SignOffCloseTaskScopedAsync( + SignOffCloseTaskRequestDto request, + string actor, + string? tenantId, + string? companyId, + CancellationToken ct = default) + => SignOffCloseTaskAsync(request, actor, ct); + Task ReviewCloseEvidenceAsync( ReviewCloseEvidenceRequestDto request, string actor, CancellationToken ct = default); + Task ReviewCloseEvidenceScopedAsync( + ReviewCloseEvidenceRequestDto request, + string actor, + string? tenantId, + string? companyId, + CancellationToken ct = default) + => ReviewCloseEvidenceAsync(request, actor, ct); + Task ConfigurePeriodPlanAsync( UpsertClosePeriodPlanConfigurationRequestDto request, string actor, CancellationToken ct = default); + Task ConfigurePeriodPlanScopedAsync( + UpsertClosePeriodPlanConfigurationRequestDto request, + string actor, + string? tenantId, + string? companyId, + CancellationToken ct = default) + => ConfigurePeriodPlanAsync(request, actor, ct); + Task LockClosePeriodAsync( LockClosePeriodRequestDto request, string actor, CancellationToken ct = default); + Task LockClosePeriodScopedAsync( + LockClosePeriodRequestDto request, + string actor, + string? tenantId, + string? companyId, + CancellationToken ct = default) + => LockClosePeriodAsync(request, actor, ct); + Task ReopenClosePeriodAsync( ReopenClosePeriodRequestDto request, string actor, CancellationToken ct = default) => Task.FromException( new NotSupportedException("This accounting close service does not support governed period reopen.")); + + Task ReopenClosePeriodScopedAsync( + ReopenClosePeriodRequestDto request, + string actor, + string? tenantId, + string? companyId, + CancellationToken ct = default) + => ReopenClosePeriodAsync(request, actor, ct); } public sealed class AccountingCloseManagementService : IAccountingCloseManagementService @@ -109,7 +172,16 @@ public AccountingCloseManagementService( _postingWorkbench = postingWorkbench ?? throw new ArgumentNullException(nameof(postingWorkbench)); } - public async Task GetPeriodPlanAsync(Guid workflowId, CancellationToken ct = default) + public Task GetPeriodPlanAsync( + Guid workflowId, + CancellationToken ct = default) + => GetPeriodPlanScopedAsync(workflowId, tenantId: null, companyId: null, ct: ct); + + public async Task GetPeriodPlanScopedAsync( + Guid workflowId, + string? tenantId, + string? companyId, + CancellationToken ct = default) { if (workflowId == Guid.Empty) { @@ -117,13 +189,23 @@ public AccountingCloseManagementService( } var workflow = await _workflowService.GetAsync(workflowId, ct).ConfigureAwait(false); - return workflow is null ? null : await BuildPeriodPlanWithGateAsync(workflow, ct).ConfigureAwait(false); + return workflow is null + ? null + : await BuildPeriodPlanWithGateAsync(workflow, ct, tenantId, companyId).ConfigureAwait(false); } - public async Task RequestLateAdjustmentAsync( + public Task RequestLateAdjustmentAsync( CreateLateAdjustmentRequestDto request, string actor, CancellationToken ct = default) + => RequestLateAdjustmentScopedAsync(request, actor, tenantId: null, companyId: null, ct: ct); + + public async Task RequestLateAdjustmentScopedAsync( + CreateLateAdjustmentRequestDto request, + string actor, + string? tenantId, + string? companyId, + CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(request); EnsureHumanOrigin(request.ActionOrigin, "request late adjustments"); @@ -208,13 +290,21 @@ public AccountingCloseManagementService( _writeGate.Release(); } - return await BuildPeriodPlanWithGateAsync(workflow, ct).ConfigureAwait(false); + return await BuildPeriodPlanWithGateAsync(workflow, ct, tenantId, companyId).ConfigureAwait(false); } - public async Task ReviewLateAdjustmentAsync( + public Task ReviewLateAdjustmentAsync( ReviewLateAdjustmentRequestDto request, string actor, CancellationToken ct = default) + => ReviewLateAdjustmentScopedAsync(request, actor, tenantId: null, companyId: null, ct: ct); + + public async Task ReviewLateAdjustmentScopedAsync( + ReviewLateAdjustmentRequestDto request, + string actor, + string? tenantId, + string? companyId, + CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(request); EnsureHumanOrigin(request.ActionOrigin, "review late adjustments"); @@ -300,13 +390,21 @@ public AccountingCloseManagementService( _writeGate.Release(); } - return await BuildPeriodPlanWithGateAsync(workflow, ct).ConfigureAwait(false); + return await BuildPeriodPlanWithGateAsync(workflow, ct, tenantId, companyId).ConfigureAwait(false); } - public async Task SignOffCloseTaskAsync( + public Task SignOffCloseTaskAsync( SignOffCloseTaskRequestDto request, string actor, CancellationToken ct = default) + => SignOffCloseTaskScopedAsync(request, actor, tenantId: null, companyId: null, ct: ct); + + public async Task SignOffCloseTaskScopedAsync( + SignOffCloseTaskRequestDto request, + string actor, + string? tenantId, + string? companyId, + CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(request); EnsureHumanOrigin(request.ActionOrigin, "sign off close tasks"); @@ -443,12 +541,20 @@ public AccountingCloseManagementService( _writeGate.Release(); } - return await BuildPeriodPlanWithGateAsync(workflow, ct).ConfigureAwait(false); + return await BuildPeriodPlanWithGateAsync(workflow, ct, tenantId, companyId).ConfigureAwait(false); } - public async Task ReviewCloseEvidenceAsync( + public Task ReviewCloseEvidenceAsync( + ReviewCloseEvidenceRequestDto request, + string actor, + CancellationToken ct = default) + => ReviewCloseEvidenceScopedAsync(request, actor, tenantId: null, companyId: null, ct: ct); + + public async Task ReviewCloseEvidenceScopedAsync( ReviewCloseEvidenceRequestDto request, string actor, + string? tenantId, + string? companyId, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(request); @@ -536,12 +642,20 @@ await SaveCloseManagementAsync( _writeGate.Release(); } - return await BuildPeriodPlanWithGateAsync(workflow, ct).ConfigureAwait(false); + return await BuildPeriodPlanWithGateAsync(workflow, ct, tenantId, companyId).ConfigureAwait(false); } - public async Task ConfigurePeriodPlanAsync( + public Task ConfigurePeriodPlanAsync( + UpsertClosePeriodPlanConfigurationRequestDto request, + string actor, + CancellationToken ct = default) + => ConfigurePeriodPlanScopedAsync(request, actor, tenantId: null, companyId: null, ct: ct); + + public async Task ConfigurePeriodPlanScopedAsync( UpsertClosePeriodPlanConfigurationRequestDto request, string actor, + string? tenantId, + string? companyId, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(request); @@ -618,12 +732,20 @@ request.ExpectedConfiguredAtUtc is { } expectedConfiguredAtUtc && _writeGate.Release(); } - return await BuildPeriodPlanWithGateAsync(workflow, ct).ConfigureAwait(false); + return await BuildPeriodPlanWithGateAsync(workflow, ct, tenantId, companyId).ConfigureAwait(false); } - public async Task LockClosePeriodAsync( + public Task LockClosePeriodAsync( + LockClosePeriodRequestDto request, + string actor, + CancellationToken ct = default) + => LockClosePeriodScopedAsync(request, actor, tenantId: null, companyId: null, ct: ct); + + public async Task LockClosePeriodScopedAsync( LockClosePeriodRequestDto request, string actor, + string? tenantId, + string? companyId, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(request); @@ -633,6 +755,9 @@ request.ExpectedConfiguredAtUtc is { } expectedConfiguredAtUtc && throw new ArgumentException("WorkflowId is required.", nameof(request)); } + await _writeGate.WaitAsync(ct).ConfigureAwait(false); + try + { var resolvedActor = string.IsNullOrWhiteSpace(actor) ? RequireText(request.Actor, "Actor") : actor.Trim(); var workflow = await _workflowService.GetAsync(request.WorkflowId, ct).ConfigureAwait(false); if (workflow is null) @@ -643,7 +768,7 @@ request.ExpectedConfiguredAtUtc is { } expectedConfiguredAtUtc && var plan = BuildPeriodPlan(workflow); if (plan.IsPeriodLocked) { - plan = await AttachClosingEntriesGateAsync(plan, workflow, ct).ConfigureAwait(false); + plan = await AttachClosingEntriesGateAsync(plan, workflow, ct, tenantId, companyId).ConfigureAwait(false); return new ClosePeriodLockResultDto( true, plan, @@ -661,7 +786,7 @@ request.ExpectedConfiguredAtUtc is { } expectedConfiguredAtUtc && var issues = BuildClosePeriodLockIssues(request, workflow, plan); if (issues.Count > 0) { - plan = await AttachClosingEntriesGateAsync(plan, workflow, ct).ConfigureAwait(false); + plan = await AttachClosingEntriesGateAsync(plan, workflow, ct, tenantId, companyId).ConfigureAwait(false); return new ClosePeriodLockResultDto(false, plan, null, issues); } @@ -679,7 +804,7 @@ request.ExpectedConfiguredAtUtc is { } expectedConfiguredAtUtc && try { closingGate = await _postingWorkbench.EnsureClosingDraftQueuedAsync( - RequirePostingContext(workflow, plan), + RequirePostingContext(workflow, plan, tenantId, companyId), new AccountingClosePostingCommand( resolvedActor, RequireText(request.Rationale, "Rationale"), @@ -702,6 +827,19 @@ request.ExpectedConfiguredAtUtc is { } expectedConfiguredAtUtc && } plan = AttachClosingEntriesGate(plan, closingGate); + if (request.PrepareClosingEntriesOnly) + { + var preparationIssues = closingGate.State is ClosePostingGateStateDto.Blocked + or ClosePostingGateStateDto.Unavailable + ? new[] { ClosingEntriesIssue(closingGate) } + : Array.Empty(); + return new ClosePeriodLockResultDto( + false, + plan, + null, + preparationIssues); + } + if (!closingGate.IsReadyForLock) { return new ClosePeriodLockResultDto( @@ -711,10 +849,33 @@ request.ExpectedConfiguredAtUtc is { } expectedConfiguredAtUtc && [ClosingEntriesIssue(closingGate)]); } + // Closing-entry preparation can await external stores. Re-read the governed workflow at the + // irreversible mutation boundary so a concurrent sign-off/configuration/version change cannot + // hard-close the ledger against a stale close plan. + var boundaryWorkflow = await _workflowService.GetAsync(request.WorkflowId, ct).ConfigureAwait(false); + if (boundaryWorkflow is null) + { + return null; + } + + var boundaryPlan = BuildPeriodPlan(boundaryWorkflow); + var boundaryIssues = BuildClosePeriodLockIssues(request, boundaryWorkflow, boundaryPlan); + if (boundaryIssues.Count > 0) + { + return new ClosePeriodLockResultDto( + false, + AttachClosingEntriesGate(boundaryPlan, closingGate), + null, + boundaryIssues); + } + + workflow = boundaryWorkflow; + plan = AttachClosingEntriesGate(boundaryPlan, closingGate); + try { await _postingWorkbench.FinalizeHardCloseAsync( - RequirePostingContext(workflow, plan), + RequirePostingContext(workflow, plan, tenantId, companyId), new AccountingClosePostingCommand( resolvedActor, RequireText(request.Rationale, "Rationale"), @@ -758,23 +919,42 @@ [new AccountingConfigurationValidationIssueDto( var updatedPlan = transition.Workflow is null ? plan - : await BuildPeriodPlanWithGateAsync(transition.Workflow, ct).ConfigureAwait(false); + : await BuildPeriodPlanWithGateAsync(transition.Workflow, ct, tenantId, companyId).ConfigureAwait(false); var transitionIssues = transition.Success ? Array.Empty() : transition.Blockers .Select(static blocker => ToValidationIssue(blocker)) + .Append(new AccountingConfigurationValidationIssueDto( + "CloseWorkflowTransitionPendingAfterLedgerHardClose", + AccountingConfigurationValidationSeverityDto.Critical, + "The ledger period is hard-closed, but the workflow close transition did not commit.", + plan.ClosePlanId, + "Refresh the close plan and retry the same close command with the current workflow version; ledger hard close is idempotent.")) .ToArray(); return new ClosePeriodLockResultDto( transition.Success && updatedPlan.IsPeriodLocked, updatedPlan, transition, transitionIssues); + } + finally + { + _writeGate.Release(); + } } - public async Task ReopenClosePeriodAsync( + public Task ReopenClosePeriodAsync( ReopenClosePeriodRequestDto request, string actor, CancellationToken ct = default) + => ReopenClosePeriodScopedAsync(request, actor, tenantId: null, companyId: null, ct: ct); + + public async Task ReopenClosePeriodScopedAsync( + ReopenClosePeriodRequestDto request, + string actor, + string? tenantId, + string? companyId, + CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(request); EnsureHumanOrigin(request.ActionOrigin, "reopen close periods"); @@ -783,6 +963,9 @@ [new AccountingConfigurationValidationIssueDto( throw new ArgumentException("WorkflowId is required.", nameof(request)); } + await _writeGate.WaitAsync(ct).ConfigureAwait(false); + try + { var role = RequireText(request.Role, "Role"); if (!string.Equals(role, "Controller", StringComparison.OrdinalIgnoreCase) && !string.Equals(role, "Fund Controller", StringComparison.OrdinalIgnoreCase)) @@ -809,7 +992,7 @@ [new AccountingConfigurationValidationIssueDto( { return new ClosePeriodReopenResultDto( false, - await AttachClosingEntriesGateAsync(plan, workflow, ct).ConfigureAwait(false), + await AttachClosingEntriesGateAsync(plan, workflow, ct, tenantId, companyId).ConfigureAwait(false), null, null, [new AccountingConfigurationValidationIssueDto( @@ -824,7 +1007,7 @@ [new AccountingConfigurationValidationIssueDto( { return new ClosePeriodReopenResultDto( false, - await AttachClosingEntriesGateAsync(plan, workflow, ct).ConfigureAwait(false), + await AttachClosingEntriesGateAsync(plan, workflow, ct, tenantId, companyId).ConfigureAwait(false), null, null, [new AccountingConfigurationValidationIssueDto( @@ -846,8 +1029,50 @@ [new AccountingConfigurationValidationIssueDto( [ClosingEntriesIssue(unavailable)]); } + // Re-read immediately before the durable ledger reopen. This prevents a stale version from + // reopening the ledger after another close-plan mutation completed while the request waited. + var boundaryWorkflow = await _workflowService.GetAsync(request.WorkflowId, ct).ConfigureAwait(false); + if (boundaryWorkflow is null) + { + return null; + } + + var boundaryPlan = BuildPeriodPlan(boundaryWorkflow); + if (!boundaryPlan.IsPeriodLocked) + { + return new ClosePeriodReopenResultDto( + false, + await AttachClosingEntriesGateAsync(boundaryPlan, boundaryWorkflow, ct, tenantId, companyId).ConfigureAwait(false), + null, + null, + [new AccountingConfigurationValidationIssueDto( + "ClosePeriodNotLocked", + AccountingConfigurationValidationSeverityDto.Critical, + $"Close period '{boundaryPlan.PeriodId}' is no longer locked and cannot enter governed reopen.", + boundaryPlan.ClosePlanId, + "Refresh the close plan before retrying the reopen command.")]); + } + + if (boundaryWorkflow.Version != request.ExpectedWorkflowVersion) + { + return new ClosePeriodReopenResultDto( + false, + await AttachClosingEntriesGateAsync(boundaryPlan, boundaryWorkflow, ct, tenantId, companyId).ConfigureAwait(false), + null, + null, + [new AccountingConfigurationValidationIssueDto( + "ClosePeriodReopenVersionMismatch", + AccountingConfigurationValidationSeverityDto.Critical, + $"Workflow version {boundaryWorkflow.Version} does not match expected version {request.ExpectedWorkflowVersion}.", + boundaryPlan.ClosePlanId, + "Refresh the close plan before reopening the period.")]); + } + + workflow = boundaryWorkflow; + plan = boundaryPlan; + var reversalGate = await _postingWorkbench.ReopenAndQueueClosingReversalsAsync( - RequirePostingContext(workflow, plan), + RequirePostingContext(workflow, plan, tenantId, companyId), new AccountingClosePostingCommand( resolvedActor, RequireText(request.Rationale, "Rationale"), @@ -881,13 +1106,26 @@ [new AccountingConfigurationValidationIssueDto( : AttachClosingEntriesGate(BuildPeriodPlan(transition.Workflow), reversalGate); var reopenIssues = transition.Success ? Array.Empty() - : transition.Blockers.Select(static blocker => ToValidationIssue(blocker)).ToArray(); + : transition.Blockers + .Select(static blocker => ToValidationIssue(blocker)) + .Append(new AccountingConfigurationValidationIssueDto( + "CloseWorkflowReopenPendingAfterLedgerReopen", + AccountingConfigurationValidationSeverityDto.Critical, + "The ledger period is reopened, but the workflow reopen transition did not commit.", + plan.ClosePlanId, + "Refresh the close plan and retry the exact reopen command with the current workflow version; the retained reversal receipt makes ledger reopen idempotent.")) + .ToArray(); return new ClosePeriodReopenResultDto( transition.Success, updatedPlan, transition, reversalGate, reopenIssues); + } + finally + { + _writeGate.Release(); + } } private ClosePeriodPlanDto BuildPeriodPlan(OperationsContinuityWorkflowDto workflow) @@ -942,18 +1180,29 @@ private ClosePeriodPlanDto BuildPeriodPlan(OperationsContinuityWorkflowDto workf BuildCloseCalendar(tasks, isPeriodLocked), planConfiguration, evidenceReviews, - operatingCoverage); + operatingCoverage, + WorkflowVersion: workflow.Version); } private async Task BuildPeriodPlanWithGateAsync( OperationsContinuityWorkflowDto workflow, - CancellationToken ct) - => await AttachClosingEntriesGateAsync(BuildPeriodPlan(workflow), workflow, ct).ConfigureAwait(false); + CancellationToken ct, + string? tenantId = null, + string? companyId = null) + => await AttachClosingEntriesGateAsync( + BuildPeriodPlan(workflow), + workflow, + ct, + tenantId, + companyId) + .ConfigureAwait(false); private async Task AttachClosingEntriesGateAsync( ClosePeriodPlanDto plan, OperationsContinuityWorkflowDto workflow, - CancellationToken ct) + CancellationToken ct, + string? tenantId = null, + string? companyId = null) { if (_postingWorkbench is null) { @@ -964,7 +1213,7 @@ private async Task AttachClosingEntriesGateAsync( try { gate = await _postingWorkbench - .EvaluateAsync(RequirePostingContext(workflow, plan), ct) + .EvaluateAsync(RequirePostingContext(workflow, plan, tenantId, companyId), ct) .ConfigureAwait(false); } catch (Exception ex) when (ex is InvalidOperationException or ArgumentException) @@ -1025,7 +1274,9 @@ private static ClosePostingGateDto UnavailableClosingEntriesGate(ClosePeriodPlan private static AccountingClosePostingContext RequirePostingContext( OperationsContinuityWorkflowDto workflow, - ClosePeriodPlanDto plan) + ClosePeriodPlanDto plan, + string? tenantId = null, + string? companyId = null) { if (workflow.LedgerBookId is not { } ledgerBookId || ledgerBookId == Guid.Empty) { @@ -1035,10 +1286,12 @@ private static AccountingClosePostingContext RequirePostingContext( return new AccountingClosePostingContext( workflow.WorkflowId, - plan.FundProfileId, + workflow.FundAccountId, ledgerBookId, workflow.PeriodId, - plan.MaterialityPolicy.Currency); + plan.MaterialityPolicy.Currency, + tenantId, + companyId); } private static AccountingConfigurationValidationIssueDto ClosingEntriesIssue(ClosePostingGateDto gate) @@ -1796,6 +2049,15 @@ private static IReadOnlyList BuildClo plan.ClosePlanId, "Refresh the workflow and retry period lock with the current version.")); } + else if (request.ExpectedWorkflowVersion != workflow.Version) + { + issues.Add(new AccountingConfigurationValidationIssueDto( + "ClosePeriodLockVersionMismatch", + AccountingConfigurationValidationSeverityDto.Critical, + $"Workflow version {workflow.Version} does not match expected version {request.ExpectedWorkflowVersion}.", + plan.ClosePlanId, + "Refresh the close plan before posting closing entries or locking the period.")); + } var unique = new List(issues.Count); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); diff --git a/src/Meridian.FinancialOperations/AccountingClose/AccountingClosePostingWorkbench.cs b/src/Meridian.FinancialOperations/AccountingClose/AccountingClosePostingWorkbench.cs index 3fff07db2b..2d59ae1b14 100644 --- a/src/Meridian.FinancialOperations/AccountingClose/AccountingClosePostingWorkbench.cs +++ b/src/Meridian.FinancialOperations/AccountingClose/AccountingClosePostingWorkbench.cs @@ -6,10 +6,12 @@ namespace Meridian.FinancialOperations.AccountingClose; /// Ledger/workbench scope for the final period-close posting control. public sealed record AccountingClosePostingContext( Guid WorkflowId, - string FundProfileId, + Guid FundAccountId, Guid LedgerBookId, string PeriodId, - string Currency); + string Currency, + string? TenantId = null, + string? CompanyId = null); /// Human-governed command evidence used when the gate mutates workbench or period state. public sealed record AccountingClosePostingCommand( diff --git a/src/Meridian.FinancialOperations/PrivateCapital/PrivateCapitalCloseCockpitService.cs b/src/Meridian.FinancialOperations/PrivateCapital/PrivateCapitalCloseCockpitService.cs index 65136fa268..702fd76c6f 100644 --- a/src/Meridian.FinancialOperations/PrivateCapital/PrivateCapitalCloseCockpitService.cs +++ b/src/Meridian.FinancialOperations/PrivateCapital/PrivateCapitalCloseCockpitService.cs @@ -60,7 +60,9 @@ public async Task GetCockpitAsync( Guid? fundAccountId = null, string? periodId = null, string? entityId = null, - CancellationToken ct = default) + CancellationToken ct = default, + string? tenantId = null, + string? companyId = null) { ct.ThrowIfCancellationRequested(); @@ -68,25 +70,25 @@ public async Task GetCockpitAsync( _manualJournalEntryWorkbenchService is null ? null : await _manualJournalEntryWorkbenchService - .GetWorkbenchAsync(fundProfileId, ledgerBookId, ct) + .GetWorkbenchAsync(fundProfileId, ledgerBookId, ct, tenantId, companyId) .ConfigureAwait(false); var activity = workbench?.PrivateCapitalActivity ?? (_manualJournalEntryWorkbenchService is null ? null : await _manualJournalEntryWorkbenchService - .GetPrivateCapitalActivityAsync(fundProfileId, ledgerBookId, ct) + .GetPrivateCapitalActivityAsync(fundProfileId, ledgerBookId, ct, tenantId, companyId) .ConfigureAwait(false)); var dailyValuationStatus = _dailyValuationScheduleStatusSource is null ? null : await _dailyValuationScheduleStatusSource - .GetStatusAsync(fundProfileId, ledgerBookId, periodId, ct) + .GetStatusAsync(fundProfileId, ledgerBookId, periodId, ct, entityId, tenantId, companyId) .ConfigureAwait(false); var automatedJournalStatus = _automatedJournalScheduleStatusSource is null ? null : await _automatedJournalScheduleStatusSource - .GetStatusAsync(fundProfileId, ledgerBookId, periodId, ct) + .GetStatusAsync(fundProfileId, ledgerBookId, periodId, ct, tenantId, companyId, entityId) .ConfigureAwait(false); var dailyValuationDrafts = FilterDailyValuationDrafts(workbench, periodId); - var automatedJournalDrafts = FilterAutomatedJournalDrafts(workbench, periodId); + var automatedJournalDrafts = FilterAutomatedJournalDrafts(workbench, periodId, entityId); var workflows = await LoadWorkflowsAsync(fundAccountId, ledgerBookId, periodId, ct).ConfigureAwait(false); var records = FilterFundEventRecords(activity, periodId, entityId); var subledgers = FilterSubledgers(activity, records); @@ -146,7 +148,8 @@ _manualJournalEntryWorkbenchService is null PlannedCapabilities: PlannedCapabilities, ApprovalHistory: approvalHistory, NavSupportPackages: navSupportPackages, - EvidencePackages: evidencePackages); + EvidencePackages: evidencePackages, + DailyValuationStatus: dailyValuationStatus); } private async Task> LoadWorkflowsAsync( @@ -802,46 +805,69 @@ private static PrivateCapitalCloseCockpitLaneDto BuildDailyValuationLane( DailyValuationScheduleStatusDto schedule, IReadOnlyList drafts) { - var latestDraft = drafts - .OrderByDescending(static draft => draft.AccountingDate) - .ThenByDescending(static draft => draft.UpdatedAtUtc) - .FirstOrDefault(); + var scopedIds = schedule.JournalEntryIds.ToHashSet(); + var scopedDrafts = scopedIds.Count == 0 + ? [] + : drafts.Where(draft => scopedIds.Contains(draft.JournalEntryId)).ToArray(); + var missingDraftCount = scopedIds.Count - scopedDrafts.Length; var evidence = schedule.EvidenceLinks - .Concat(latestDraft?.EvidenceLinks.Select((route, index) => new OperationsEvidenceLinkDto( - $"daily-valuation-draft:{latestDraft.JournalEntryId:D}:{index + 1}", + .Concat(scopedDrafts.SelectMany(draft => draft.EvidenceLinks.Select((route, index) => new OperationsEvidenceLinkDto( + $"daily-valuation-draft:{draft.JournalEntryId:D}:{index + 1}", "Daily valuation draft evidence", route, "manual-journal-workbench", - latestDraft.UpdatedAtUtc)) ?? []) + draft.UpdatedAtUtc)))) .DistinctBy(static link => link.EvidenceId, StringComparer.OrdinalIgnoreCase) .ToArray(); - if (latestDraft is not null) + var currentRunBlocked = schedule.State is DailyValuationScheduleStateDto.Blocked or + DailyValuationScheduleStateDto.Failed; + if (currentRunBlocked) { - var isReady = latestDraft.Status is ManualJournalEntryStatusDto.Posted or ManualJournalEntryStatusDto.CloseLocked; - var isBlocked = latestDraft.Status is ManualJournalEntryStatusDto.NeedsFix or ManualJournalEntryStatusDto.Rejected; - var laneStatus = isReady + return Lane( + "daily-valuation", + "Daily valuation", + EvidenceStatusDto.Blocked, + isReady: false, + schedule.Summary, + UiApiRoutes.LedgerJournalAutomationDailyMarkToMarketSchedules, + evidence, + schedule.State == DailyValuationScheduleStateDto.Failed + ? "Repair and rerun the failed daily valuation schedule" + : "Resolve the daily valuation portfolio or mark-quality blockers"); + } + + if (scopedIds.Count > 0) + { + var allPosted = missingDraftCount == 0 && scopedDrafts.All(static draft => + draft.Status is ManualJournalEntryStatusDto.Posted or ManualJournalEntryStatusDto.CloseLocked); + var anyBlocked = scopedDrafts.Any(static draft => + draft.Status is ManualJournalEntryStatusDto.NeedsFix or ManualJournalEntryStatusDto.Rejected) || + missingDraftCount > 0; + var laneStatus = allPosted ? EvidenceStatusDto.Ready - : isBlocked + : anyBlocked ? EvidenceStatusDto.Blocked : EvidenceStatusDto.ReviewRequired; - var summary = isReady - ? $"Daily valuation draft '{latestDraft.JournalEntryId:D}' is {latestDraft.Status} with retained closing-mark evidence." - : isBlocked - ? $"Daily valuation draft '{latestDraft.JournalEntryId:D}' is {latestDraft.Status} and blocks close readiness." - : $"Daily valuation draft '{latestDraft.JournalEntryId:D}' is {latestDraft.Status} and still requires approval or posting."; + var summary = allPosted + ? $"All {scopedDrafts.Length} daily valuation draft(s) are posted with retained closing-mark evidence." + : missingDraftCount > 0 + ? $"Daily valuation batch is missing {missingDraftCount} retained draft(s) and blocks close readiness." + : anyBlocked + ? "One or more daily valuation drafts require repair before close readiness." + : $"Daily valuation batch has {scopedDrafts.Length} draft(s) awaiting governed approval or posting."; return Lane( "daily-valuation", "Daily valuation", laneStatus, - isReady, + allPosted, summary, UiApiRoutes.LedgerManualJournalEntryDrafts, evidence, - isBlocked - ? "Repair or reject the blocked daily valuation draft" - : "Approve and post the governed daily valuation draft"); + anyBlocked + ? "Repair or reject the blocked daily valuation batch" + : "Approve and post the governed daily valuation batch"); } var scheduleReady = schedule.State == DailyValuationScheduleStateDto.NoAdjustment; @@ -859,6 +885,7 @@ private static PrivateCapitalCloseCockpitLaneDto BuildDailyValuationLane( DailyValuationScheduleStateDto.Blocked => "Resolve the daily valuation portfolio or mark-quality blockers", DailyValuationScheduleStateDto.Failed => "Repair and rerun the failed daily valuation schedule", DailyValuationScheduleStateDto.DraftReady => "Open the governed daily valuation draft for approval", + DailyValuationScheduleStateDto.Posted => "Review retained daily valuation posting evidence", _ => "Wait for or run the configured daily valuation schedule" }; @@ -1352,7 +1379,8 @@ private static IReadOnlyList FilterDailyValuationDra private static IReadOnlyList FilterAutomatedJournalDrafts( ManualJournalEntryWorkbenchDto? workbench, - string? periodId) + string? periodId, + string? entityId) { if (workbench is null) { @@ -1360,6 +1388,7 @@ private static IReadOnlyList FilterAutomatedJournalD } var normalizedPeriodId = Normalize(periodId); + var normalizedEntityId = Normalize(entityId); return workbench.Drafts .Where(static draft => draft.TreasuryContext?.IdempotencyKey is { } key && (key.StartsWith("mgmt-fee|", StringComparison.OrdinalIgnoreCase) || @@ -1369,6 +1398,8 @@ private static IReadOnlyList FilterAutomatedJournalD .Where(draft => normalizedPeriodId is null || string.Equals(draft.PeriodId, normalizedPeriodId, StringComparison.OrdinalIgnoreCase) || MatchesPeriod(draft.AccountingDate, normalizedPeriodId)) + .Where(draft => normalizedEntityId is null || + string.Equals(draft.EntityId, normalizedEntityId, StringComparison.OrdinalIgnoreCase)) .ToArray(); } diff --git a/src/Meridian.Infrastructure/Adapters/AlphaVantage/AlphaVantageCorporateActionProvider.cs b/src/Meridian.Infrastructure/Adapters/AlphaVantage/AlphaVantageCorporateActionProvider.cs index 99e5fd2079..76e033fd8b 100644 --- a/src/Meridian.Infrastructure/Adapters/AlphaVantage/AlphaVantageCorporateActionProvider.cs +++ b/src/Meridian.Infrastructure/Adapters/AlphaVantage/AlphaVantageCorporateActionProvider.cs @@ -1,6 +1,8 @@ using System.Globalization; +using System.Net; using System.Text.Json; using System.Text.Json.Serialization; +using Meridian.Core.Exceptions; using Meridian.Infrastructure.Adapters.Core; using Meridian.Infrastructure.Contracts; using Meridian.Infrastructure.DataSources; @@ -74,6 +76,11 @@ public async Task> FetchAsync( try { using var response = await client.GetAsync(url, ct).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.TooManyRequests) + { + throw CreateRateLimitException(response, normalizedSymbol); + } + if (!response.IsSuccessStatusCode) { _logger.LogDebug( @@ -84,11 +91,20 @@ public async Task> FetchAsync( } var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); - if (IsErrorOrRateLimitBody(json)) + if (IsRateLimitBody(json)) + { + throw new RateLimitException( + $"Alpha Vantage corporate-action rate limit exceeded for {normalizedSymbol}.", + provider: ProviderId, + symbol: normalizedSymbol, + retryAfter: TimeSpan.FromMinutes(1)); + } + + if (IsErrorBody(json)) { _logger.LogDebug( - "Alpha Vantage corporate actions returned an error or throttle body for {Ticker}; skipping.", - ticker); + "Alpha Vantage corporate actions returned an error body for {Ticker}; skipping.", + normalizedSymbol); return []; } @@ -115,6 +131,10 @@ public async Task> FetchAsync( return commands; } + catch (RateLimitException) + { + throw; + } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogWarning(ex, "Failed to fetch Alpha Vantage corporate actions for {Ticker}.", ticker); @@ -166,11 +186,28 @@ private static IEnumerable MapToCommands( } } - private static bool IsErrorOrRateLimitBody(string json) - { - return json.Contains("\"Note\"", StringComparison.Ordinal) || - json.Contains("\"Error Message\"", StringComparison.Ordinal) || + private static bool IsRateLimitBody(string json) + => json.Contains("\"Note\"", StringComparison.Ordinal) || json.Contains("Thank you for using Alpha Vantage", StringComparison.Ordinal); + + private static bool IsErrorBody(string json) + => json.Contains("\"Error Message\"", StringComparison.Ordinal); + + private RateLimitException CreateRateLimitException(HttpResponseMessage response, string symbol) + { + var retryAfter = response.Headers.RetryAfter?.Delta; + if (retryAfter is null && response.Headers.RetryAfter?.Date is { } retryAt) + { + var delay = retryAt - DateTimeOffset.UtcNow; + if (delay > TimeSpan.Zero) + retryAfter = delay; + } + + return new RateLimitException( + $"Alpha Vantage corporate-action rate limit exceeded for {symbol}.", + provider: ProviderId, + symbol: symbol, + retryAfter: retryAfter ?? TimeSpan.FromMinutes(1)); } private string? ResolveApiKey() diff --git a/src/Meridian.Infrastructure/Adapters/AlphaVantage/AlphaVantageHistoricalDataProvider.cs b/src/Meridian.Infrastructure/Adapters/AlphaVantage/AlphaVantageHistoricalDataProvider.cs index e90dc0f62c..bc79bacf7b 100644 --- a/src/Meridian.Infrastructure/Adapters/AlphaVantage/AlphaVantageHistoricalDataProvider.cs +++ b/src/Meridian.Infrastructure/Adapters/AlphaVantage/AlphaVantageHistoricalDataProvider.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; +using Meridian.Core.Exceptions; using Meridian.Contracts.Domain.Models; using Meridian.Domain.Models; using Meridian.Infrastructure.Adapters.Core; @@ -134,7 +135,10 @@ public override async Task> GetAdjustedDail if (IsRateLimitResponse(json)) { Log.Warning("Alpha Vantage rate limit hit for {Symbol}. Message in response.", symbol); - throw new HttpRequestException($"Alpha Vantage rate limit exceeded for {symbol}. Please wait before retrying."); + throw new RateLimitException( + $"Alpha Vantage rate limit exceeded for {symbol}. Please wait before retrying.", + provider: Name, + symbol: symbol); } if (json.Contains("\"Error Message\"")) @@ -199,7 +203,10 @@ public async Task> GetIntradayBarsAsync( // Alpha Vantage returns 200 OK with error/rate limit messages in body if (IsRateLimitResponse(json)) { - throw new HttpRequestException($"Alpha Vantage rate limit exceeded for {symbol}"); + throw new RateLimitException( + $"Alpha Vantage rate limit exceeded for {symbol}", + provider: Name, + symbol: symbol); } if (json.Contains("\"Error Message\"")) diff --git a/src/Meridian.Infrastructure/Adapters/AlphaVantage/AlphaVantageSymbolSearchProvider.cs b/src/Meridian.Infrastructure/Adapters/AlphaVantage/AlphaVantageSymbolSearchProvider.cs index 39bc91e20b..a207e94415 100644 --- a/src/Meridian.Infrastructure/Adapters/AlphaVantage/AlphaVantageSymbolSearchProvider.cs +++ b/src/Meridian.Infrastructure/Adapters/AlphaVantage/AlphaVantageSymbolSearchProvider.cs @@ -1,5 +1,7 @@ using System.Globalization; +using System.Net; using System.Text.Json.Serialization; +using Meridian.Core.Exceptions; using Meridian.Core.Subscriptions.Models; using Meridian.Infrastructure.Adapters.Core; using Meridian.Infrastructure.Contracts; @@ -73,7 +75,16 @@ protected override string BuildDetailsUrl(string symbol) protected override IEnumerable DeserializeSearchResults(string json, string query) { - if (IsErrorOrRateLimitBody(json)) + if (IsRateLimitBody(json)) + { + throw new RateLimitException( + $"Alpha Vantage symbol search rate limit exceeded for {query}.", + provider: Name, + symbol: query, + retryAfter: RateLimitWindow); + } + + if (IsErrorBody(json)) { return Enumerable.Empty(); } @@ -124,11 +135,35 @@ protected override IEnumerable DeserializeSearchResults(stri return Task.FromResult(details); } - private static bool IsErrorOrRateLimitBody(string json) + protected override RateLimitException? CreateRateLimitException( + HttpResponseMessage response, + string symbol) + { + if (response.StatusCode != HttpStatusCode.TooManyRequests) + return null; + + var retryAfter = response.Headers.RetryAfter?.Delta; + if (retryAfter is null && response.Headers.RetryAfter?.Date is { } retryAt) + { + var delay = retryAt - DateTimeOffset.UtcNow; + if (delay > TimeSpan.Zero) + retryAfter = delay; + } + + return new RateLimitException( + $"Alpha Vantage symbol search rate limit exceeded for {symbol}.", + provider: Name, + symbol: symbol, + retryAfter: retryAfter ?? RateLimitWindow); + } + + private static bool IsRateLimitBody(string json) => json.Contains("\"Note\"", StringComparison.Ordinal) || - json.Contains("\"Error Message\"", StringComparison.Ordinal) || json.Contains("Thank you for using Alpha Vantage", StringComparison.Ordinal); + private static bool IsErrorBody(string json) + => json.Contains("\"Error Message\"", StringComparison.Ordinal); + private static int ParseProviderScore(string? score) { if (!decimal.TryParse(score, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)) diff --git a/src/Meridian.Infrastructure/Adapters/Core/Backfill/BackfillWorkerService.cs b/src/Meridian.Infrastructure/Adapters/Core/Backfill/BackfillWorkerService.cs index 87d5f043e3..6c7f5ff4bc 100644 --- a/src/Meridian.Infrastructure/Adapters/Core/Backfill/BackfillWorkerService.cs +++ b/src/Meridian.Infrastructure/Adapters/Core/Backfill/BackfillWorkerService.cs @@ -313,16 +313,15 @@ private async Task ProcessRequestAsync(BackfillRequest request, CancellationToke // Typed RateLimitException (thrown directly or wrapped in aggregate/inner chains) // is located here, so a dedicated typed catch would duplicate this path. var rateLimit = FindRateLimitException(ex); - var retryAfter = rateLimit?.RetryAfter ?? TryExtractRetryAfter(ex); - var isRateLimited = rateLimit is not null || - retryAfter.HasValue || - IsHttp429(ex) || - ex.Message.Contains("429") || - ex.Message.Contains("rate limit", StringComparison.OrdinalIgnoreCase); - - if (isRateLimited && request.AssignedProvider != null) + var isRateLimited = IsRateLimited(ex); + var retryAfter = isRateLimited + ? rateLimit?.RetryAfter ?? TryExtractRetryAfter(ex) + : null; + var rateLimitedProvider = ResolveRateLimitedProvider(ex, request.AssignedProvider); + + if (isRateLimited && rateLimitedProvider is not null) { - _requestQueue.RecordProviderRateLimitHit(request.AssignedProvider); + _requestQueue.RecordProviderRateLimitHit(rateLimitedProvider, retryAfter); // Retry with Retry-After or exponential backoff if within retry budget if (retryAttempt < MaxRetryAttemptsPerRequest) @@ -333,7 +332,7 @@ private async Task ProcessRequestAsync(BackfillRequest request, CancellationToke activity?.SetTag("backfill.retry_count", retryAttempt); scopedLog.Information( "Rate limited for {Symbol} via {Provider}, retrying in {Delay}ms via {DelaySource} (attempt {Attempt}/{Max})", - request.Symbol, request.AssignedProvider, delay.TotalMilliseconds, + request.Symbol, rateLimitedProvider, delay.TotalMilliseconds, retryAfter.HasValue ? "provider-specified cooldown" : "calculated exponential backoff", retryAttempt, MaxRetryAttemptsPerRequest); await Task.Delay(delay, ct).ConfigureAwait(false); @@ -342,7 +341,7 @@ private async Task ProcessRequestAsync(BackfillRequest request, CancellationToke scopedLog.Warning( "Rate limit retry budget exhausted for {Symbol} via {Provider} after {Attempts} attempts", - request.Symbol, request.AssignedProvider, retryAttempt); + request.Symbol, rateLimitedProvider, retryAttempt); } MarketDataTracing.RecordError(activity, ex); @@ -389,6 +388,31 @@ private static TimeSpan CalculateBackoff(int attempt, TimeSpan baseDelay, TimeSp return ex.InnerException is { } innerException ? FindRateLimitException(innerException) : null; } + /// + /// Resolves the provider whose budget should be charged for a throttle response. + /// Composite providers can fail over after assignment, so typed provider metadata + /// is authoritative when it is present. + /// + internal static string? ResolveRateLimitedProvider(Exception ex, string? assignedProvider) + { + ArgumentNullException.ThrowIfNull(ex); + var provider = EnumerateExceptionTree(ex) + .OfType() + .Select(static rateLimit => rateLimit.Provider) + .FirstOrDefault(static candidate => !string.IsNullOrWhiteSpace(candidate)); + return string.IsNullOrWhiteSpace(provider) ? assignedProvider : provider; + } + + /// + /// Classifies rate limiting only from typed provider metadata or a preserved HTTP 429 status. + /// Exception-message text is deliberately not treated as an accounting-relevant signal. + /// + internal static bool IsRateLimited(Exception ex) + { + ArgumentNullException.ThrowIfNull(ex); + return FindRateLimitException(ex) is not null || IsHttp429(ex); + } + /// /// Extracts Retry-After delay from an exception chain. /// Supports both delta-seconds ("120") and HTTP-date ("Thu, 01 Dec 2024 16:00:00 GMT") formats @@ -396,10 +420,15 @@ private static TimeSpan CalculateBackoff(int attempt, TimeSpan baseDelay, TimeSp /// internal static TimeSpan? TryExtractRetryAfter(Exception ex) { - // Walk the exception chain looking for HttpRequestException with Retry-After info - var current = ex; - while (current != null) + ArgumentNullException.ThrowIfNull(ex); + + // Walk every aggregate branch rather than AggregateException.InnerException, + // which exposes only the first child and can hide the actual throttled provider. + foreach (var current in EnumerateExceptionTree(ex)) { + if (current is RateLimitException { RetryAfter: { } typedRetryAfter }) + return CapRetryAfter(typedRetryAfter); + if (TryExtractRetryAfterFromExceptionData(current) is { } retryAfterFromData) return retryAfterFromData; @@ -421,13 +450,33 @@ private static TimeSpan CalculateBackoff(int attempt, TimeSpan baseDelay, TimeSp if (retryAfter.HasValue) return retryAfter; } - - current = current.InnerException; } return null; } + private static IEnumerable EnumerateExceptionTree(Exception ex) + { + yield return ex; + + if (ex is AggregateException aggregate) + { + foreach (var inner in aggregate.InnerExceptions) + { + foreach (var descendant in EnumerateExceptionTree(inner)) + yield return descendant; + } + + yield break; + } + + if (ex.InnerException is { } innerException) + { + foreach (var descendant in EnumerateExceptionTree(innerException)) + yield return descendant; + } + } + private static TimeSpan? TryExtractRetryAfterFromExceptionData(Exception ex) { if (ex.Data.Count == 0) @@ -504,19 +553,13 @@ private static TimeSpan CapRetryAfter(TimeSpan delay) private static bool IsHttp429(Exception ex) { - var current = ex; - while (current != null) - { - if (current is HttpRequestException httpRequestException && - httpRequestException.StatusCode == System.Net.HttpStatusCode.TooManyRequests) - { - return true; - } + if (ex is HttpRequestException { StatusCode: System.Net.HttpStatusCode.TooManyRequests }) + return true; - current = current.InnerException; - } + if (ex is AggregateException aggregate) + return aggregate.Flatten().InnerExceptions.Any(IsHttp429); - return false; + return ex.InnerException is not null && IsHttp429(ex.InnerException); } /// diff --git a/src/Meridian.Infrastructure/Adapters/Core/BaseSymbolSearchProvider.cs b/src/Meridian.Infrastructure/Adapters/Core/BaseSymbolSearchProvider.cs index 64428ab6c1..b4ece04418 100644 --- a/src/Meridian.Infrastructure/Adapters/Core/BaseSymbolSearchProvider.cs +++ b/src/Meridian.Infrastructure/Adapters/Core/BaseSymbolSearchProvider.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Meridian.Core.Exceptions; using Meridian.Core.Logging; using Meridian.Core.Subscriptions.Models; using Meridian.Contracts.Domain; @@ -223,6 +224,9 @@ public async Task> SearchAsync( if (!response.IsSuccessStatusCode) { + if (CreateRateLimitException(response, query) is { } rateLimit) + throw rateLimit; + Log.Warning("{Provider} search returned {Status} for query {Query}", Name, response.StatusCode, query); return Array.Empty(); @@ -239,6 +243,10 @@ public async Task> SearchAsync( .Take(limit) .ToList(); } + catch (RateLimitException) + { + throw; + } catch (Exception ex) { Log.Error(ex, "{Provider} search failed for query {Query}", Name, query); @@ -270,6 +278,9 @@ public async Task> SearchAsync( if (!response.IsSuccessStatusCode) { + if (CreateRateLimitException(response, symbolValue) is { } rateLimit) + throw rateLimit; + Log.Debug("{Provider} details returned {Status} for {Symbol}", Name, response.StatusCode, symbol); return null; @@ -278,6 +289,10 @@ public async Task> SearchAsync( var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); return await DeserializeDetailsAsync(json, symbolValue, ct).ConfigureAwait(false); } + catch (RateLimitException) + { + throw; + } catch (Exception ex) { Log.Error(ex, "{Provider} details lookup failed for {Symbol}", Name, symbol); @@ -319,6 +334,15 @@ public async Task> SearchAsync( /// Symbol details or null if not found. protected abstract Task DeserializeDetailsAsync(string json, string symbol, CancellationToken ct); + /// + /// Allows providers that expose actionable throttle metadata to preserve it + /// instead of reducing every non-success response to an empty result. + /// + protected virtual RateLimitException? CreateRateLimitException( + HttpResponseMessage response, + string symbol) + => null; + /// diff --git a/src/Meridian.Infrastructure/Adapters/Core/IProviderConnectionDiagnosticsSource.cs b/src/Meridian.Infrastructure/Adapters/Core/IProviderConnectionDiagnosticsSource.cs deleted file mode 100644 index a93438b85d..0000000000 --- a/src/Meridian.Infrastructure/Adapters/Core/IProviderConnectionDiagnosticsSource.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Meridian.Infrastructure.Resilience; - -namespace Meridian.Infrastructure.Adapters.Core; - -/// -/// Optional provider contract for adapters that can expose safe connection lifecycle diagnostics. -/// -public interface IProviderConnectionDiagnosticsSource -{ - /// - /// Raised when the provider connection lifecycle diagnostics change. - /// - event Action? ConnectionDiagnosticsChanged; - - /// - /// Gets a safe diagnostics snapshot for provider health, logs, and tests. - /// - WebSocketConnectionDiagnostics GetConnectionDiagnosticsSnapshot(); -} diff --git a/src/Meridian.Infrastructure/Adapters/InteractiveBrokers/IBMarketDataClient.cs b/src/Meridian.Infrastructure/Adapters/InteractiveBrokers/IBMarketDataClient.cs index ef80371458..1a433e04a6 100644 --- a/src/Meridian.Infrastructure/Adapters/InteractiveBrokers/IBMarketDataClient.cs +++ b/src/Meridian.Infrastructure/Adapters/InteractiveBrokers/IBMarketDataClient.cs @@ -29,7 +29,6 @@ public sealed class IBMarketDataClient : { private readonly IMarketDataClient _inner; private readonly bool _isSimulation; - private readonly ProviderConnectionSupervisor _simulationSupervisor; private readonly ProviderRateLimitTracker _streamingRateLimits; public IBMarketDataClient( @@ -40,13 +39,6 @@ public IBMarketDataClient( OptionDataCollector? optionCollector = null, IBOptions? options = null) { - _simulationSupervisor = new ProviderConnectionSupervisor( - providerName: "Interactive Brokers (simulation)", - maxReconnectAttempts: 3, - retryBaseDelay: TimeSpan.FromSeconds(1), - maxRetryDelay: TimeSpan.FromSeconds(10)); - _simulationSupervisor.StateChanged += OnSimulationSupervisorStateChanged; - _streamingRateLimits = new ProviderRateLimitTracker(); _streamingRateLimits.RegisterProvider( "ib", @@ -64,13 +56,13 @@ public IBMarketDataClient( options ?? new IBOptions()); liveClient.RateLimitHit += RecordPacingViolation; liveClient.StreamingRequestSent += RecordStreamingRequest; - liveClient.ConnectionDiagnosticsChanged += OnInnerConnectionDiagnosticsChanged; _inner = liveClient; _isSimulation = false; #else _inner = new IBSimulationClient(publisher); _isSimulation = true; #endif + _inner.ConnectionDiagnosticsChanged += OnInnerConnectionDiagnosticsChanged; } /// @@ -136,32 +128,11 @@ public IBMarketDataClient( /// /// /// TWS/Gateway connectivity is a raw TCP socket, not a WebSocket, so - /// is reported. When the inner - /// client exposes richer diagnostics (real IBAPI builds), those win over the facade view. + /// is reported. The inner client + /// owns the lifecycle evidence for both live and simulation builds. /// public WebSocketConnectionDiagnostics GetConnectionDiagnosticsSnapshot() - { - if (_inner is IProviderConnectionDiagnosticsSource innerSource) - return innerSource.GetConnectionDiagnosticsSnapshot(); - - var supervisor = _simulationSupervisor.GetSnapshot(); - return new WebSocketConnectionDiagnostics( - ProviderName: _isSimulation ? "Interactive Brokers (simulation)" : "Interactive Brokers", - LifecycleState: supervisor.LifecycleState, - WebSocketState: System.Net.WebSockets.WebSocketState.None, - IsConnected: supervisor.IsConnected, - IsReconnecting: supervisor.IsReconnecting, - ReconnectAttempts: supervisor.ReconnectAttempts, - LastConnectedAt: supervisor.LastConnectedAt, - LastDisconnectedAt: supervisor.LastDisconnectedAt, - LastHeartbeatReceivedAt: null, - LastMessageReceivedAt: null, - LastReconnectAttemptAt: supervisor.LastReconnectAttemptAt, - LastError: supervisor.LastError, - LastFailureKind: supervisor.LastFailureKind, - ConnectionAge: supervisor.ConnectionAge, - IdleDuration: null); - } + => _inner.GetConnectionDiagnosticsSnapshot(); /// public ProviderRateLimitDiagnosticSnapshot GetRateLimitDiagnosticsSnapshot() @@ -182,24 +153,11 @@ public ProviderRateLimitDiagnosticSnapshot GetRateLimitDiagnosticsSnapshot() status.Reason); } - public async Task ConnectAsync(CancellationToken ct = default) - { - if (_inner is IProviderConnectionDiagnosticsSource) - await _inner.ConnectAsync(ct).ConfigureAwait(false); - else - await _simulationSupervisor.ConnectAsync(_inner.ConnectAsync, ct).ConfigureAwait(false); - } - - public async Task DisconnectAsync(CancellationToken ct = default) - { - if (_inner is IProviderConnectionDiagnosticsSource) - await _inner.DisconnectAsync(ct).ConfigureAwait(false); - else - await _simulationSupervisor.DisconnectAsync(_inner.DisconnectAsync, ct).ConfigureAwait(false); - } + public Task ConnectAsync(CancellationToken ct = default) + => _inner.ConnectAsync(ct); - private void OnSimulationSupervisorStateChanged(ProviderConnectionSupervisorSnapshot _) - => ConnectionDiagnosticsChanged?.Invoke(GetConnectionDiagnosticsSnapshot()); + public Task DisconnectAsync(CancellationToken ct = default) + => _inner.DisconnectAsync(ct); private void OnInnerConnectionDiagnosticsChanged(WebSocketConnectionDiagnostics snapshot) => ConnectionDiagnosticsChanged?.Invoke(snapshot); @@ -242,17 +200,15 @@ public void UnsubscribeTrades(int subscriptionId) public async ValueTask DisposeAsync() { - _simulationSupervisor.StateChanged -= OnSimulationSupervisorStateChanged; + _inner.ConnectionDiagnosticsChanged -= OnInnerConnectionDiagnosticsChanged; #if IBAPI if (_inner is IBMarketDataClientIBApi liveClient) { liveClient.RateLimitHit -= RecordPacingViolation; liveClient.StreamingRequestSent -= RecordStreamingRequest; - liveClient.ConnectionDiagnosticsChanged -= OnInnerConnectionDiagnosticsChanged; } #endif await _inner.DisposeAsync().ConfigureAwait(false); - await _simulationSupervisor.DisposeAsync().ConfigureAwait(false); _streamingRateLimits.Dispose(); } } diff --git a/src/Meridian.Infrastructure/Adapters/InteractiveBrokers/IBSimulationClient.cs b/src/Meridian.Infrastructure/Adapters/InteractiveBrokers/IBSimulationClient.cs index 85de0afb49..17a0d1a8aa 100644 --- a/src/Meridian.Infrastructure/Adapters/InteractiveBrokers/IBSimulationClient.cs +++ b/src/Meridian.Infrastructure/Adapters/InteractiveBrokers/IBSimulationClient.cs @@ -7,6 +7,7 @@ using Meridian.Infrastructure.Adapters.Core; using Meridian.Infrastructure.Contracts; using Meridian.Infrastructure.DataSources; +using Meridian.Infrastructure.Resilience; using Serilog; using DataSourceType = Meridian.Infrastructure.DataSources.DataSourceType; @@ -31,6 +32,7 @@ public sealed class IBSimulationClient : IMarketDataClient private readonly IMarketEventPublisher _publisher; private readonly TimeSpan _autoTickDueTime; private readonly TimeSpan _autoTickPeriod; + private readonly ProviderConnectionSupervisor _connectionSupervisor; private readonly Random _rng = new(); private int _nextTickerId = 10_000; private bool _connected; @@ -61,6 +63,12 @@ public IBSimulationClient( _publisher = publisher ?? throw new ArgumentNullException(nameof(publisher)); _autoTickDueTime = autoTickDueTime ?? TimeSpan.FromSeconds(1); _autoTickPeriod = autoTickPeriod ?? TimeSpan.FromSeconds(1); + _connectionSupervisor = new ProviderConnectionSupervisor( + providerName: ProviderDisplayName, + maxReconnectAttempts: 0, + retryBaseDelay: TimeSpan.Zero, + maxRetryDelay: TimeSpan.Zero); + _connectionSupervisor.StateChanged += OnConnectionSupervisorStateChanged; if (enableAutoTicks) { @@ -105,9 +113,40 @@ public IBSimulationClient( "Do not use for trading decisions." }; + /// + public event Action? ConnectionDiagnosticsChanged; + + /// + public WebSocketConnectionDiagnostics GetConnectionDiagnosticsSnapshot() + { + var supervisor = _connectionSupervisor.GetSnapshot(); + return new WebSocketConnectionDiagnostics( + ProviderName: ProviderDisplayName, + LifecycleState: supervisor.LifecycleState, + WebSocketState: System.Net.WebSockets.WebSocketState.None, + IsConnected: supervisor.IsConnected, + IsReconnecting: supervisor.IsReconnecting, + ReconnectAttempts: supervisor.ReconnectAttempts, + LastConnectedAt: supervisor.LastConnectedAt, + LastDisconnectedAt: supervisor.LastDisconnectedAt, + LastHeartbeatReceivedAt: null, + LastMessageReceivedAt: null, + LastReconnectAttemptAt: supervisor.LastReconnectAttemptAt, + LastError: supervisor.LastError, + LastFailureKind: supervisor.LastFailureKind, + ConnectionAge: supervisor.ConnectionAge, + IdleDuration: null, + ActiveSubscriptions: _tradeSubs.Count + _depthSubs.Count); + } + public Task ConnectAsync(CancellationToken ct = default) + => _connectionSupervisor.ConnectAsync(ConnectTransportAsync, ct); + + private Task ConnectTransportAsync(CancellationToken ct) { + ct.ThrowIfCancellationRequested(); + ObjectDisposedException.ThrowIf(_disposed, this); _connected = true; _log.Information("[IB-SIM] Connected in simulation mode. Generating synthetic market data for subscribed symbols"); _tickTimer?.Change(_autoTickDueTime, _autoTickPeriod); @@ -115,7 +154,11 @@ public Task ConnectAsync(CancellationToken ct = default) } public Task DisconnectAsync(CancellationToken ct = default) + => _connectionSupervisor.DisconnectAsync(DisconnectTransportAsync, ct); + + private Task DisconnectTransportAsync(CancellationToken ct) { + ct.ThrowIfCancellationRequested(); _connected = false; _tickTimer?.Change(Timeout.Infinite, Timeout.Infinite); _log.Information("[IB-SIM] Disconnected. Generated ticks for {TradeCount} trade subscriptions, {DepthCount} depth subscriptions", @@ -123,6 +166,9 @@ public Task DisconnectAsync(CancellationToken ct = default) return Task.CompletedTask; } + private void OnConnectionSupervisorStateChanged(ProviderConnectionSupervisorSnapshot _) + => ConnectionDiagnosticsChanged?.Invoke(GetConnectionDiagnosticsSnapshot()); + public int SubscribeMarketDepth(SymbolConfig cfg) { var id = Interlocked.Increment(ref _nextTickerId); @@ -187,14 +233,17 @@ private void GenerateSimulatedTicks(object? state) } } - public ValueTask DisposeAsync() + public async ValueTask DisposeAsync() { if (_disposed) - return ValueTask.CompletedTask; + return; _disposed = true; + _connected = false; + _tickTimer?.Change(Timeout.Infinite, Timeout.Infinite); + _connectionSupervisor.StateChanged -= OnConnectionSupervisorStateChanged; _tickTimer?.Dispose(); _tradeSubs.Clear(); _depthSubs.Clear(); - return ValueTask.CompletedTask; + await _connectionSupervisor.DisposeAsync().ConfigureAwait(false); } } diff --git a/src/Meridian.Infrastructure/Adapters/OpenFigi/OpenFigiClient.cs b/src/Meridian.Infrastructure/Adapters/OpenFigi/OpenFigiClient.cs index 8498e9cdbe..cd11c3ebc3 100644 --- a/src/Meridian.Infrastructure/Adapters/OpenFigi/OpenFigiClient.cs +++ b/src/Meridian.Infrastructure/Adapters/OpenFigi/OpenFigiClient.cs @@ -1,6 +1,7 @@ using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; +using Meridian.Core.Exceptions; using Meridian.Core.Logging; using Meridian.Core.Subscriptions.Models; using Meridian.Infrastructure.Adapters.Core; @@ -324,7 +325,17 @@ private async Task>> MappingRequestAsyn if ((int)response.StatusCode == 429) { - throw new HttpRequestException("OpenFIGI rate limit exceeded (429)"); + var retryAfter = response.Headers.RetryAfter?.Delta; + if (!retryAfter.HasValue && response.Headers.RetryAfter?.Date is { } retryAt) + { + var delay = retryAt - DateTimeOffset.UtcNow; + retryAfter = delay > TimeSpan.Zero ? delay : null; + } + + throw new RateLimitException( + "OpenFIGI rate limit exceeded (429)", + provider: Name, + retryAfter: retryAfter); } return Enumerable.Repeat>(Array.Empty(), requests.Count).ToList(); diff --git a/src/Meridian.Infrastructure/ConnectionDiagnosticsTypeForwarders.cs b/src/Meridian.Infrastructure/ConnectionDiagnosticsTypeForwarders.cs new file mode 100644 index 0000000000..9853395af4 --- /dev/null +++ b/src/Meridian.Infrastructure/ConnectionDiagnosticsTypeForwarders.cs @@ -0,0 +1,8 @@ +using System.Runtime.CompilerServices; +using Meridian.Infrastructure.Adapters.Core; +using Meridian.Infrastructure.Resilience; + +[assembly: TypeForwardedTo(typeof(IProviderConnectionDiagnosticsSource))] +[assembly: TypeForwardedTo(typeof(ProviderConnectionLifecycleState))] +[assembly: TypeForwardedTo(typeof(ProviderFailureKind))] +[assembly: TypeForwardedTo(typeof(WebSocketConnectionDiagnostics))] diff --git a/src/Meridian.Infrastructure/README.md b/src/Meridian.Infrastructure/README.md index e0007340b9..ec139e327e 100644 --- a/src/Meridian.Infrastructure/README.md +++ b/src/Meridian.Infrastructure/README.md @@ -6,7 +6,7 @@ module_id: SRC-INFRASTRUCTURE path: src/Meridian.Infrastructure status: active owner_lane: Data Confidence and Validation -last_reviewed: 2026-06-05 +last_reviewed: 2026-07-15 --- # src/Meridian.Infrastructure @@ -30,11 +30,20 @@ This layer owns external integration details while depending on lower contracts Use this module for provider implementation, external service integration, and adapter behavior. -WebSocket streaming adapters that derive from `WebSocketProviderBase` expose -`IProviderConnectionDiagnosticsSource` for safe provider-level health snapshots. Consumers should -use that optional seam for connection state, heartbeat time, reconnect status, subscription health -counts, last subscription message time, and last safe error category instead of reaching into -provider-specific transport internals. +Every `IMarketDataClient` now inherits the ProviderSdk-owned +`IProviderConnectionDiagnosticsSource` contract. Adapters without a lifecycle supervisor receive a +conservative compatibility snapshot that never claims a live connection; supervised adapters must +override it with runtime evidence. `WebSocketProviderBase` supplies shared WebSocket state, +heartbeat, reconnect, and safe failure diagnostics; Robinhood supplies the same normalized shape +from `PollingProviderBase`; NYSE and Interactive Brokers retain subscription-health and +transport-specific lifecycle evidence. For polling, raw-socket, simulation, and fallback +diagnostics, `WebSocketState` is `None`. Consumers should use this contract instead of reaching +into provider-specific transport internals. + +The public diagnostics interface, lifecycle/failure enums, and snapshot record retain their +existing namespaces but are owned by ProviderSdk so plugin contracts do not depend on concrete +Infrastructure. Infrastructure publishes type forwarders for adapters compiled against the former +assembly location. Provider registry paths normalize configured provider identifiers before factory lookup, and the registry can hold multiple adapter contracts for one provider family ID. This allows identifiers diff --git a/src/Meridian.Infrastructure/Resilience/ProviderConnectionSupervisor.cs b/src/Meridian.Infrastructure/Resilience/ProviderConnectionSupervisor.cs index 286fdb1ef3..041c183850 100644 --- a/src/Meridian.Infrastructure/Resilience/ProviderConnectionSupervisor.cs +++ b/src/Meridian.Infrastructure/Resilience/ProviderConnectionSupervisor.cs @@ -11,7 +11,9 @@ namespace Meridian.Infrastructure.Resilience; /// public sealed class ProviderConnectionSupervisor : IAsyncDisposable { + private static readonly TimeSpan DefaultDisposeTimeout = TimeSpan.FromSeconds(10); private readonly object _sync = new(); + private readonly object _disposeSync = new(); private readonly SemaphoreSlim _operationGate = new(1, 1); private readonly SemaphoreSlim _disconnectGate = new(1, 1); private readonly string _providerName; @@ -34,6 +36,7 @@ public sealed class ProviderConnectionSupervisor : IAsyncDisposable private DateTimeOffset? _lastReconnectAttemptAt; private string? _lastError; private ProviderFailureKind? _lastFailureKind; + private Task? _disposeTask; /// /// Raised whenever lifecycle or failure diagnostics change. @@ -289,20 +292,29 @@ public async Task DisconnectAsync( { try { - await reconnectTask.ConfigureAwait(false); + await reconnectTask.WaitAsync(ct).ConfigureAwait(false); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (!ct.IsCancellationRequested) { // Expected when disconnect cancels the connection lifetime. } } - await _operationGate.WaitAsync(CancellationToken.None).ConfigureAwait(false); + await _operationGate.WaitAsync(ct).ConfigureAwait(false); try { try { - await disconnectTransaction(ct).ConfigureAwait(false); + var disconnectTask = disconnectTransaction(ct); + try + { + await disconnectTask.WaitAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested && !disconnectTask.IsCompleted) + { + ObserveDeferredDisconnect(disconnectTask); + throw; + } } finally { @@ -387,22 +399,69 @@ public void RecordFailure(Exception error) } /// - public async ValueTask DisposeAsync() + public ValueTask DisposeAsync() { - bool shouldDisconnect; - lock (_sync) - shouldDisconnect = !_disposed; + lock (_disposeSync) + return new ValueTask(_disposeTask ??= DisposeWithTimeoutAsync()); + } - if (!shouldDisconnect) - return; + internal ValueTask DisposeAsync(CancellationToken ct) + { + lock (_disposeSync) + return new ValueTask(_disposeTask ??= DisposeCoreAsync(ct)); + } - await DisconnectAsync(static _ => Task.CompletedTask).ConfigureAwait(false); + private async Task DisposeWithTimeoutAsync() + { + using var shutdownCts = new CancellationTokenSource(DefaultDisposeTimeout); + await DisposeCoreAsync(shutdownCts.Token).ConfigureAwait(false); + } + private async Task DisposeCoreAsync(CancellationToken ct) + { lock (_sync) - _disposed = true; + { + if (_disposed) + return; + } + + try + { + await DisconnectAsync(static _ => Task.CompletedTask, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + _log.Warning( + "Timed out waiting for {Provider} connection operations during supervisor disposal; forcing terminal state", + _providerName); + } + finally + { + CancellationTokenSource? lifetimeCts; + ProviderConnectionSupervisorSnapshot snapshot; + lock (_sync) + { + lifetimeCts = _lifetimeCts; + _disposed = true; + _stopping = true; + _reconnectEnabled = false; + _isReconnecting = false; + _lifecycleState = ProviderConnectionLifecycleState.Disconnected; + _lastDisconnectedAt ??= DateTimeOffset.UtcNow; + snapshot = CreateSnapshotLocked(DateTimeOffset.UtcNow); + } + + try + { + lifetimeCts?.Cancel(); + } + catch (ObjectDisposedException) + { + } + + Publish(snapshot); + } - _operationGate.Dispose(); - _disconnectGate.Dispose(); GC.SuppressFinalize(this); } @@ -630,6 +689,28 @@ private void MarkReconnectFailed() Publish(snapshot); } + private void ObserveDeferredDisconnect(Task disconnectTask) + => _ = ObserveDeferredDisconnectAsync(disconnectTask); + + private async Task ObserveDeferredDisconnectAsync(Task disconnectTask) + { + try + { + await disconnectTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // A cooperative transaction may finish cancellation after the caller's bounded wait. + } + catch (Exception ex) + { + _log.Warning( + ex, + "Deferred {Provider} disconnect transaction failed after the caller stopped waiting", + _providerName); + } + } + private CancellationToken EnsureLifetimeLocked() { if (_lifetimeCts is { IsCancellationRequested: false }) diff --git a/src/Meridian.Infrastructure/Resilience/WebSocketConnectionManager.cs b/src/Meridian.Infrastructure/Resilience/WebSocketConnectionManager.cs index e6cbab597f..4d129b77d2 100644 --- a/src/Meridian.Infrastructure/Resilience/WebSocketConnectionManager.cs +++ b/src/Meridian.Infrastructure/Resilience/WebSocketConnectionManager.cs @@ -26,17 +26,26 @@ namespace Meridian.Infrastructure.Resilience; /// public sealed class WebSocketConnectionManager : IAsyncDisposable { + private static readonly TimeSpan DefaultShutdownTimeout = TimeSpan.FromSeconds(10); private readonly WebSocketConnectionConfig _config; private readonly ResiliencePipeline _resiliencePipeline; private readonly ProviderConnectionSupervisor _supervisor; private readonly ILogger _log; private readonly string _providerName; + private readonly TimeSpan _shutdownTimeout; + private readonly object _disposeSync = new(); private ClientWebSocket? _webSocket; private CancellationTokenSource? _connectionCts; private CancellationTokenSource? _receiveLoopCts; private Task? _receiveTask; private WebSocketHeartbeat? _heartbeat; + private Task? _disposeTask; + + // Test seam for proving bounded cleanup when a heartbeat implementation ignores + // shutdown. Production always uses WebSocketHeartbeat.DisposeAsync directly. + internal Func HeartbeatDisposer { get; set; } + = static heartbeat => heartbeat.DisposeAsync().AsTask(); // Transport activity complements the supervisor's lifecycle diagnostics. private DateTimeOffset? _lastMessageReceivedAt; @@ -133,10 +142,23 @@ public WebSocketConnectionManager( string providerName, WebSocketConnectionConfig? config = null, ILogger? logger = null) + : this(providerName, config, logger, DefaultShutdownTimeout) + { + } + + internal WebSocketConnectionManager( + string providerName, + WebSocketConnectionConfig? config, + ILogger? logger, + TimeSpan shutdownTimeout) { + if (shutdownTimeout <= TimeSpan.Zero || shutdownTimeout == Timeout.InfiniteTimeSpan) + throw new ArgumentOutOfRangeException(nameof(shutdownTimeout)); + _providerName = providerName ?? throw new ArgumentNullException(nameof(providerName)); _config = config ?? WebSocketConnectionConfig.Default; _log = logger ?? LoggingSetup.ForContext(); + _shutdownTimeout = shutdownTimeout; _supervisor = new ProviderConnectionSupervisor( _providerName, _config.MaxReconnectAttempts, @@ -356,13 +378,52 @@ public async Task SendAsync(string message, CancellationToken ct = default) } /// - /// Disconnects from the WebSocket gracefully. + /// Disconnects from the WebSocket gracefully. Calls without a caller cancellation token use + /// the manager shutdown timeout so non-cooperative heartbeat cleanup cannot block forever. /// /// Cancellation token. public async Task DisconnectAsync(CancellationToken ct = default) { _log.Information("Disconnecting from {Provider} WebSocket", _providerName); - await _supervisor.DisconnectAsync(DisconnectTransportAsync, ct).ConfigureAwait(false); + + if (ct.CanBeCanceled) + { + await _supervisor.DisconnectAsync(DisconnectTransportAsync, ct).ConfigureAwait(false); + _log.Information("Disconnected from {Provider} WebSocket", _providerName); + return; + } + + Task? transportCleanupTask = null; + using var shutdownCts = new CancellationTokenSource(_shutdownTimeout); + try + { + await _supervisor.DisconnectAsync( + token => transportCleanupTask = DisconnectTransportAsync(token), + shutdownCts.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (shutdownCts.IsCancellationRequested) + { + if (transportCleanupTask is not null) + { + // The supervisor may stop waiting as soon as its timeout token is cancelled. + // Wait for the transport transaction's non-blocking finally block so its + // detached socket and cancellation sources are released before we return. + await ObserveForcedCleanupAsync(transportCleanupTask, "disconnect transport") + .ConfigureAwait(false); + } + else + { + // Cancellation can occur while a reconnect operation still owns the supervisor + // gate, before the transport transaction starts. + ForceDetachTransport(); + } + + _log.Warning( + "Timed out disconnecting {Provider}; completed forced transport cleanup", + _providerName); + } + _log.Information("Disconnected from {Provider} WebSocket", _providerName); } @@ -373,6 +434,7 @@ private async Task DisconnectTransportAsync(CancellationToken ct) var receiveLoopCts = _receiveLoopCts; var receiveTask = _receiveTask; var webSocket = _webSocket; + Task? heartbeatDisposeTask = null; _heartbeat = null; _connectionCts = null; @@ -380,48 +442,45 @@ private async Task DisconnectTransportAsync(CancellationToken ct) _receiveTask = null; _webSocket = null; - if (heartbeat != null) - { - heartbeat.ConnectionLost -= OnConnectionLostAsync; - await heartbeat.DisposeAsync().ConfigureAwait(false); - } - try { - connectionCts?.Cancel(); - receiveLoopCts?.Cancel(); - } - catch (Exception ex) - { - _log.Debug(ex, "CancellationTokenSource.Cancel failed during {Provider} disconnect", _providerName); - } - - if (webSocket != null) - { - try + if (heartbeat != null) { - if (webSocket.State is WebSocketState.Open or WebSocketState.CloseReceived) - { - await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disconnecting", ct) - .ConfigureAwait(false); - } + heartbeat.ConnectionLost -= OnConnectionLostAsync; + heartbeatDisposeTask = HeartbeatDisposer(heartbeat); + await heartbeatDisposeTask.WaitAsync(ct).ConfigureAwait(false); } - catch (OperationCanceledException) when (ct.IsCancellationRequested) + + try { - _log.Warning("{Provider} WebSocket close was cancelled; forcing transport cleanup", _providerName); + connectionCts?.Cancel(); + receiveLoopCts?.Cancel(); } catch (Exception ex) { - _log.Warning(ex, "Error during {Provider} WebSocket close", _providerName); + _log.Debug(ex, "CancellationTokenSource.Cancel failed during {Provider} disconnect", _providerName); } - finally + + if (webSocket != null) { - webSocket.Dispose(); + try + { + if (webSocket.State is WebSocketState.Open or WebSocketState.CloseReceived) + { + await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disconnecting", ct) + .ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + _log.Warning("{Provider} WebSocket close was cancelled; forcing transport cleanup", _providerName); + } + catch (Exception ex) + { + _log.Warning(ex, "Error during {Provider} WebSocket close", _providerName); + } } - } - try - { if (receiveTask != null) { try @@ -440,14 +499,40 @@ await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disconnecting", _log.Debug(ex, "Receive loop completion error during {Provider} disconnect", _providerName); } } + + ct.ThrowIfCancellationRequested(); } finally { + if (heartbeatDisposeTask is { IsCompleted: false }) + ObserveForcedCleanup(heartbeatDisposeTask, "heartbeat"); + + try + { + connectionCts?.Cancel(); + receiveLoopCts?.Cancel(); + } + catch (Exception ex) + { + _log.Debug(ex, "Cancellation source failed during final {Provider} transport cleanup", _providerName); + } + + try + { + if (ct.IsCancellationRequested) + webSocket?.Abort(); + webSocket?.Dispose(); + } + catch (Exception ex) + { + _log.Debug(ex, "WebSocket failed during final {Provider} transport cleanup", _providerName); + } + receiveLoopCts?.Dispose(); connectionCts?.Dispose(); + if (receiveTask is { IsCompleted: false }) + ObserveForcedCleanup(receiveTask, "receive loop"); } - - ct.ThrowIfCancellationRequested(); } /// @@ -518,14 +603,16 @@ public void RecordPongReceived() } /// - public async ValueTask DisposeAsync() + public ValueTask DisposeAsync() { - // Idempotent per the IAsyncDisposable contract: a second dispose must not - // re-run DisconnectAsync against the supervisor's already-disposed gates. - if (Interlocked.Exchange(ref _disposed, 1) != 0) - return; + lock (_disposeSync) + return new ValueTask(_disposeTask ??= DisposeCoreAsync()); + } - using var shutdownCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + private async Task DisposeCoreAsync() + { + Interlocked.Exchange(ref _disposed, 1); + using var shutdownCts = new CancellationTokenSource(_shutdownTimeout); try { await DisconnectAsync(shutdownCts.Token).ConfigureAwait(false); @@ -539,7 +626,78 @@ public async ValueTask DisposeAsync() finally { _supervisor.StateChanged -= OnSupervisorStateChanged; - await _supervisor.DisposeAsync().ConfigureAwait(false); + try + { + await _supervisor.DisposeAsync(shutdownCts.Token).ConfigureAwait(false); + } + finally + { + ForceDetachTransport(); + } + } + } + + private void ForceDetachTransport() + { + var heartbeat = _heartbeat; + var connectionCts = _connectionCts; + var receiveLoopCts = _receiveLoopCts; + var receiveTask = _receiveTask; + var webSocket = _webSocket; + + _heartbeat = null; + _connectionCts = null; + _receiveLoopCts = null; + _receiveTask = null; + _webSocket = null; + + if (heartbeat is not null) + { + heartbeat.ConnectionLost -= OnConnectionLostAsync; + ObserveForcedCleanup(HeartbeatDisposer(heartbeat), "heartbeat"); + } + + try + { + connectionCts?.Cancel(); + receiveLoopCts?.Cancel(); + } + catch (Exception ex) + { + _log.Debug(ex, "Cancellation source failed during forced {Provider} transport cleanup", _providerName); + } + + try + { + webSocket?.Abort(); + webSocket?.Dispose(); + } + catch (Exception ex) + { + _log.Debug(ex, "WebSocket failed during forced {Provider} transport cleanup", _providerName); + } + + connectionCts?.Dispose(); + receiveLoopCts?.Dispose(); + if (receiveTask is not null) + ObserveForcedCleanup(receiveTask, "receive loop"); + } + + private void ObserveForcedCleanup(Task task, string operation) + => _ = ObserveForcedCleanupAsync(task, operation); + + private async Task ObserveForcedCleanupAsync(Task task, string operation) + { + try + { + await task.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + _log.Debug(ex, "Deferred {Provider} {Operation} cleanup failed", _providerName, operation); } } @@ -815,39 +973,6 @@ private async Task CleanupConnectionAsync(CancellationToken ct = default) } -/// -/// Shared lifecycle states for provider connection supervision. -/// -public enum ProviderConnectionLifecycleState -{ - NotConfigured = 0, - Configured = 1, - Connecting = 2, - Connected = 3, - Degraded = 4, - Reconnecting = 5, - Disconnecting = 6, - Disconnected = 7, - Failed = 8, - Disabled = 9 -} - -/// -/// Normalized provider failure categories used by retry policy, diagnostics, and health surfaces. -/// -public enum ProviderFailureKind -{ - Unknown = 0, - TransientNetworkFailure = 1, - ProviderRateLimit = 2, - AuthenticationOrAuthorizationFailure = 3, - InvalidSubscription = 4, - ProviderOutage = 5, - MalformedProviderResponse = 6, - LocalConfigurationError = 7, - Cancelled = 8 -} - /// /// Classifies provider failures so reconnection loops can distinguish transient errors /// from credential/configuration failures that need operator action. @@ -897,31 +1022,6 @@ private static bool LooksLikeCredentialOrConfigFailure(string? message) } } -/// -/// Safe provider WebSocket diagnostics snapshot. It intentionally excludes URIs, -/// credentials, headers, account IDs, and payload data. -/// -public sealed record WebSocketConnectionDiagnostics( - string ProviderName, - ProviderConnectionLifecycleState LifecycleState, - WebSocketState WebSocketState, - bool IsConnected, - bool IsReconnecting, - int ReconnectAttempts, - DateTimeOffset? LastConnectedAt, - DateTimeOffset? LastDisconnectedAt, - DateTimeOffset? LastHeartbeatReceivedAt, - DateTimeOffset? LastMessageReceivedAt, - DateTimeOffset? LastReconnectAttemptAt, - string? LastError, - ProviderFailureKind? LastFailureKind, - TimeSpan? ConnectionAge, - TimeSpan? IdleDuration, - int ActiveSubscriptions = 0, - int FailedSubscriptions = 0, - int RecoveringSubscriptions = 0, - DateTimeOffset? LastSubscriptionMessageAt = null); - /// /// Represents a gap in data caused by a WebSocket disconnection and reconnection. /// Subscribers should use this to trigger backfill for the missed time window. diff --git a/src/Meridian.Ledger/DailyPortfolioPriceMark.cs b/src/Meridian.Ledger/DailyPortfolioPriceMark.cs index ebde0ac2ba..7c0615069f 100644 --- a/src/Meridian.Ledger/DailyPortfolioPriceMark.cs +++ b/src/Meridian.Ledger/DailyPortfolioPriceMark.cs @@ -26,7 +26,14 @@ public DailyPortfolioPriceMark( string? FinancialAccountId = null, string? InstrumentType = null, FairValueLevel FairValueLevel = FairValueLevel.Unclassified, - bool IsStalePriced = false) + bool IsStalePriced = false, + DateOnly? PriceObservedOn = null, + DailyPortfolioPriceConfidence Confidence = DailyPortfolioPriceConfidence.High, + Guid? SecurityId = null, + decimal? PriorCarryingValue = null, + string? CarryingValueSource = null, + DateTimeOffset? CarryingValueCapturedAtUtc = null, + string? CarryingValueEvidenceReference = null) { if (string.IsNullOrWhiteSpace(Symbol)) throw new ArgumentException("Symbol must not be null or whitespace.", nameof(Symbol)); @@ -40,6 +47,14 @@ public DailyPortfolioPriceMark( throw new ArgumentException("Price source must not be null or whitespace.", nameof(PriceSource)); if (string.IsNullOrWhiteSpace(EvidenceReference)) throw new ArgumentException("Price evidence reference must not be null or whitespace.", nameof(EvidenceReference)); + if (SecurityId == Guid.Empty) + throw new ArgumentException("Security identifier cannot be empty when supplied.", nameof(SecurityId)); + if (PriorCarryingValue.HasValue && string.IsNullOrWhiteSpace(CarryingValueSource)) + { + throw new ArgumentException( + "Carrying-value source is required when a prior carrying value is supplied.", + nameof(CarryingValueSource)); + } this.Symbol = Symbol.Trim().ToUpperInvariant(); this.Quantity = Quantity; @@ -51,6 +66,15 @@ public DailyPortfolioPriceMark( this.InstrumentType = string.IsNullOrWhiteSpace(InstrumentType) ? null : InstrumentType.Trim(); this.FairValueLevel = FairValueLevel; this.IsStalePriced = IsStalePriced; + this.PriceObservedOn = PriceObservedOn; + this.Confidence = Confidence; + this.SecurityId = SecurityId; + this.PriorCarryingValue = PriorCarryingValue; + this.CarryingValueSource = string.IsNullOrWhiteSpace(CarryingValueSource) ? null : CarryingValueSource.Trim(); + this.CarryingValueCapturedAtUtc = CarryingValueCapturedAtUtc?.ToUniversalTime(); + this.CarryingValueEvidenceReference = string.IsNullOrWhiteSpace(CarryingValueEvidenceReference) + ? null + : CarryingValueEvidenceReference.Trim(); } public string Symbol { get; } @@ -74,4 +98,24 @@ public DailyPortfolioPriceMark( /// True when the mark price was older than the valuation policy permitted but retained per policy. public bool IsStalePriced { get; } + + public DateOnly? PriceObservedOn { get; } + + public DailyPortfolioPriceConfidence Confidence { get; } + + /// Canonical Security Master identity used by governed posting controls. + public Guid? SecurityId { get; } + + /// + /// Durable carrying value before this mark. A null value means the carrying-value source + /// explicitly reported that the securities account does not yet exist; zero means the + /// account exists with a zero balance. + /// + public decimal? PriorCarryingValue { get; } + + public string? CarryingValueSource { get; } + + public DateTimeOffset? CarryingValueCapturedAtUtc { get; } + + public string? CarryingValueEvidenceReference { get; } } diff --git a/src/Meridian.Ledger/DailyPortfolioPricingDraftBuilder.cs b/src/Meridian.Ledger/DailyPortfolioPricingDraftBuilder.cs index 8315a15014..20a19a37a9 100644 --- a/src/Meridian.Ledger/DailyPortfolioPricingDraftBuilder.cs +++ b/src/Meridian.Ledger/DailyPortfolioPricingDraftBuilder.cs @@ -1,65 +1,129 @@ using System.Globalization; +using System.Security.Cryptography; +using System.Text; namespace Meridian.Ledger; /// -/// Converts a into a governed -/// so daily fair-value marks flow through +/// Converts a into governed +/// instances so daily fair-value marks flow through /// before posting to the books. /// public static class DailyPortfolioPricingDraftBuilder { /// - /// Builds a balanced fair-value adjustment draft from the projection. - /// Returns null when the marks produced no unrealized movement to post. + /// Builds one balanced draft per security/account scope. Splitting the valuation batch keeps + /// Security Master identity unambiguous for posting controls and gives each corrected mark its + /// own deterministic idempotency key. /// - public static AutomatedJournalDraft? BuildDraft(DailyPortfolioPricingProjection projection) + public static IReadOnlyList BuildDrafts(DailyPortfolioPricingProjection projection) { ArgumentNullException.ThrowIfNull(projection); - if (projection.JournalLines.Count == 0) - return null; if (!projection.IsBalanced) throw new InvalidOperationException("Daily portfolio pricing projection produced unbalanced journal lines."); + var groups = projection.Lines + .Where(static line => line.MarkAdjustment != 0m) + .GroupBy(static line => new DraftScope( + line.SecurityId, + line.Symbol, + line.FinancialAccountId ?? string.Empty)) + .OrderBy(static group => group.Key.SecurityId) + .ThenBy(static group => group.Key.Symbol, StringComparer.Ordinal) + .ThenBy(static group => group.Key.FinancialAccountId, StringComparer.Ordinal) + .ToArray(); + + if (groups.Length == 0) + return []; + + var drafts = new List(groups.Length); + foreach (var group in groups) + { + var lines = group + .OrderBy(static line => line.EvidenceReference, StringComparer.Ordinal) + .ThenBy(static line => line.Quantity) + .ThenBy(static line => line.CostPrice) + .ThenBy(static line => line.MarkPrice) + .ToArray(); + drafts.Add(BuildDraft(projection, group.Key, lines)); + } + + return drafts; + } + + /// + /// Compatibility helper for a single-security projection. Multi-security projections fail + /// closed so a caller cannot silently discard drafts; use instead. + /// + public static AutomatedJournalDraft? BuildDraft(DailyPortfolioPricingProjection projection) + { + var drafts = BuildDrafts(projection); + return drafts.Count switch + { + 0 => null, + 1 => drafts[0], + _ => throw new InvalidOperationException( + "Daily portfolio pricing produced multiple security/account drafts; use BuildDrafts to retain the full batch.") + }; + } + + private static AutomatedJournalDraft BuildDraft( + DailyPortfolioPricingProjection projection, + DraftScope scope, + IReadOnlyList pricingLines) + { var input = projection.Input; var effectiveDate = DateOnly.FromDateTime(input.AsOf.UtcDateTime); + var fingerprint = BuildFingerprint(input, scope, pricingLines); var idempotencyKey = FormattableString.Invariant( - $"fair-value|{input.Policy.FundId}|{input.PeriodId}|{effectiveDate:yyyy-MM-dd}"); + $"fair-value|fund={Escape(input.Policy.FundId)}|period={Escape(input.PeriodId)}|date={effectiveDate:yyyy-MM-dd}|security={Escape(SecurityKey(scope))}|account={Escape(AccountKey(scope))}|fp={fingerprint[..32]}"); - var evidence = projection.Lines - .Select(line => new JournalEvidenceReference( - EvidenceId: FormattableString.Invariant($"fair-value-mark:{line.Symbol}:{input.PeriodId}"), - Uri: line.EvidenceReference, - Kind: "price-mark", - SourceSystem: line.PriceSource, - RetainedAtUtc: input.AsOf, - RetainedBy: input.Policy.ApprovedBy, - SubjectId: line.Symbol, - Description: FormattableString.Invariant( - $"{line.Symbol} marked at {line.MarkPrice} via {line.PriceSource}")).Normalize()) + var journalLines = projection.JournalLines + .Where(line => MatchesScope(line.PricingLine, scope)) + .Select(static line => (line.account, line.debit, line.credit, line.dimensions)) .ToArray(); + var totalDebits = journalLines.Sum(static line => line.debit); + var totalCredits = journalLines.Sum(static line => line.credit); + if (journalLines.Length == 0 || totalDebits != totalCredits) + { + throw new InvalidOperationException( + $"Daily portfolio pricing draft for {scope.Symbol} produced unbalanced journal lines."); + } + var evidence = BuildEvidence(input, scope, pricingLines, fingerprint); + var marketValue = pricingLines.Sum(static line => line.MarketValue); + var costBasis = pricingLines.Sum(static line => line.CostBasis); + var priorCarryingValue = pricingLines.Sum(static line => line.PriorCarryingValue); + var unrealizedGainOrLoss = pricingLines.Sum(static line => line.UnrealizedGainOrLoss); + var markAdjustment = pricingLines.Sum(static line => line.MarkAdjustment); + var securityLabel = scope.SecurityId?.ToString("D") ?? scope.Symbol; + var accountLabel = string.IsNullOrEmpty(scope.FinancialAccountId) + ? "unscoped account" + : $"account {scope.FinancialAccountId}"; var description = FormattableString.Invariant( - $"Daily fair-value marks for fund {input.Policy.FundId}, period {input.PeriodId} as of {effectiveDate:yyyy-MM-dd}"); + $"Daily fair-value mark for {scope.Symbol} ({securityLabel}), {accountLabel}, fund {input.Policy.FundId}, period {input.PeriodId} as of {effectiveDate:yyyy-MM-dd}"); var journalEvent = new AutomatedJournalEvent( AutomatedJournalEventKind.FairValueMarkAdjustment, - input.Policy.FundId, - projection.NetUnrealizedGainOrLoss, + scope.Symbol, + markAdjustment, input.AsOf, + FinancialAccountId: NullIfEmpty(scope.FinancialAccountId), Description: description, + SecurityId: scope.SecurityId, + SourceEventId: $"fair-value-mark:{fingerprint}", EffectiveDate: effectiveDate, IdempotencyKey: idempotencyKey, EvidenceReferences: evidence); var fairValueLevels = string.Join( ",", - projection.Lines + pricingLines .Select(static line => line.FairValueLevel) .Distinct() .OrderBy(static level => level) .Select(static level => level.ToString())); - var stalePricedCount = projection.Lines.Count(static line => line.IsStalePriced); + var stalePricedCount = pricingLines.Count(static line => line.IsStalePriced); var tags = new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -67,22 +131,141 @@ public static class DailyPortfolioPricingDraftBuilder ["valuation.method"] = input.Policy.ValuationMethod, ["valuation.periodId"] = input.PeriodId, ["valuation.baseCurrency"] = input.BaseCurrency, - ["valuation.totalMarketValue"] = projection.TotalMarketValue.ToString(CultureInfo.InvariantCulture), - ["valuation.netUnrealizedGainOrLoss"] = projection.NetUnrealizedGainOrLoss.ToString(CultureInfo.InvariantCulture), + ["valuation.fundId"] = input.Policy.FundId, + ["valuation.symbol"] = scope.Symbol, + ["valuation.securityId"] = scope.SecurityId?.ToString("D") ?? string.Empty, + ["valuation.financialAccountId"] = scope.FinancialAccountId, + ["valuation.totalCostBasis"] = Format(costBasis), + ["valuation.totalMarketValue"] = Format(marketValue), + ["valuation.priorCarryingValue"] = Format(priorCarryingValue), + ["valuation.hasPriorCarryingValue"] = pricingLines.All(static line => line.HasPriorCarryingValue).ToString(CultureInfo.InvariantCulture), + ["valuation.netUnrealizedGainOrLoss"] = Format(unrealizedGainOrLoss), + ["valuation.markAdjustment"] = Format(markAdjustment), + ["valuation.markFingerprintSha256"] = fingerprint, ["valuation.fairValueLevels"] = fairValueLevels, - ["valuation.stalePricedCount"] = stalePricedCount.ToString(CultureInfo.InvariantCulture) + ["valuation.stalePricedCount"] = stalePricedCount.ToString(CultureInfo.InvariantCulture), + ["valuation.carryingValueSources"] = string.Join(",", pricingLines + .Select(static line => line.CarryingValueSource) + .Where(static source => !string.IsNullOrWhiteSpace(source)) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal)) }; var metadata = new JournalEntryMetadata( ActivityType: "fair-value-mark", + Symbol: scope.Symbol, + SecurityId: scope.SecurityId, + FinancialAccountId: NullIfEmpty(scope.FinancialAccountId), EffectiveDate: effectiveDate, IdempotencyKey: idempotencyKey, Tags: tags, EvidenceReferences: evidence); - var lines = projection.JournalLines - .Select(static line => (line.account, line.debit, line.credit, (LedgerLineDimensionSet?)null)) - .ToArray(); - return new AutomatedJournalDraft(journalEvent, description, lines, metadata); + return new AutomatedJournalDraft(journalEvent, description, journalLines, metadata); } + + private static IReadOnlyList BuildEvidence( + DailyPortfolioPricingInput input, + DraftScope scope, + IReadOnlyList lines, + string fingerprint) + { + var evidence = new List(lines.Count * 2); + var index = 0; + foreach (var line in lines) + { + evidence.Add(new JournalEvidenceReference( + EvidenceId: FormattableString.Invariant( + $"fair-value-mark:{SecurityKey(scope)}:{input.PeriodId}:{fingerprint[..16]}:{index++}"), + Uri: line.EvidenceReference, + Kind: "price-mark", + SourceSystem: line.PriceSource, + RetainedAtUtc: input.AsOf, + RetainedBy: input.Policy.ApprovedBy, + SubjectId: scope.SecurityId?.ToString("D") ?? line.Symbol, + Description: FormattableString.Invariant( + $"{line.Symbol} marked at {Format(line.MarkPrice)} via {line.PriceSource}; observed {(line.PriceObservedOn.HasValue ? line.PriceObservedOn.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) : "unknown")}; confidence {line.Confidence}")).Normalize()); + + if (!string.IsNullOrWhiteSpace(line.CarryingValueEvidenceReference)) + { + evidence.Add(new JournalEvidenceReference( + EvidenceId: FormattableString.Invariant( + $"fair-value-carrying:{SecurityKey(scope)}:{input.PeriodId}:{fingerprint[..16]}:{index++}"), + Uri: line.CarryingValueEvidenceReference, + Kind: "prior-carrying-value", + SourceSystem: line.CarryingValueSource ?? "ledger", + RetainedAtUtc: line.CarryingValueCapturedAtUtc ?? input.AsOf, + RetainedBy: input.Policy.ApprovedBy, + SubjectId: scope.SecurityId?.ToString("D") ?? line.Symbol, + Description: FormattableString.Invariant( + $"Prior carrying value {Format(line.PriorCarryingValue)} for {line.Symbol}; account {(line.HasPriorCarryingValue ? "present" : "absent")}")).Normalize()); + } + } + + return evidence; + } + + private static string BuildFingerprint( + DailyPortfolioPricingInput input, + DraftScope scope, + IReadOnlyList lines) + { + var canonical = new StringBuilder(512) + .Append("v1|") + .Append(input.Policy.FundId).Append('|') + .Append(input.Policy.PolicyId).Append('|') + .Append(input.Policy.ValuationMethod).Append('|') + .Append(input.PeriodId).Append('|') + .Append(DateOnly.FromDateTime(input.AsOf.UtcDateTime).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).Append('|') + .Append(input.BaseCurrency).Append('|') + .Append(scope.SecurityId?.ToString("D") ?? "none").Append('|') + .Append(scope.Symbol).Append('|') + .Append(scope.FinancialAccountId); + + foreach (var line in lines) + { + canonical + .Append("||").Append(Format(line.Quantity)) + .Append('|').Append(Format(line.CostPrice)) + .Append('|').Append(Format(line.MarkPrice)) + .Append('|').Append(Format(line.CostBasis)) + .Append('|').Append(Format(line.MarketValue)) + .Append('|').Append(Format(line.PriorCarryingValue)) + .Append('|').Append(line.HasPriorCarryingValue ? "present" : "absent") + .Append('|').Append(Format(line.MarkAdjustment)) + .Append('|').Append(line.PriceSource) + .Append('|').Append(line.EvidenceReference) + .Append('|').Append(line.PriceObservedOn?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? "unknown") + .Append('|').Append(line.Confidence) + .Append('|').Append(line.FairValueLevel) + .Append('|').Append(line.IsStalePriced ? "stale" : "current") + .Append('|').Append(line.CarryingValueSource ?? "unknown") + .Append('|').Append(line.CarryingValueEvidenceReference ?? "none"); + } + + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToString()))) + .ToLowerInvariant(); + } + + private static bool MatchesScope(DailyPortfolioPricingLine line, DraftScope scope) + => line.SecurityId == scope.SecurityId + && string.Equals(line.Symbol, scope.Symbol, StringComparison.Ordinal) + && string.Equals(line.FinancialAccountId ?? string.Empty, scope.FinancialAccountId, StringComparison.Ordinal); + + private static string SecurityKey(DraftScope scope) + => scope.SecurityId?.ToString("D") ?? scope.Symbol; + + private static string AccountKey(DraftScope scope) + => string.IsNullOrEmpty(scope.FinancialAccountId) ? "unscoped" : scope.FinancialAccountId; + + private static string Escape(string value) => Uri.EscapeDataString(value); + + private static string Format(decimal value) => value.ToString("G29", CultureInfo.InvariantCulture); + + private static string? NullIfEmpty(string value) => value.Length == 0 ? null : value; + + private readonly record struct DraftScope( + Guid? SecurityId, + string Symbol, + string FinancialAccountId); } diff --git a/src/Meridian.Ledger/DailyPortfolioPricingLine.cs b/src/Meridian.Ledger/DailyPortfolioPricingLine.cs index 098783d5bd..f85916186d 100644 --- a/src/Meridian.Ledger/DailyPortfolioPricingLine.cs +++ b/src/Meridian.Ledger/DailyPortfolioPricingLine.cs @@ -11,6 +11,9 @@ public sealed record DailyPortfolioPricingLine( decimal CostBasis, decimal MarketValue, decimal UnrealizedGainOrLoss, + decimal PriorCarryingValue, + bool HasPriorCarryingValue, + decimal MarkAdjustment, string PriceSource, string EvidenceReference, string PolicyId, @@ -18,4 +21,10 @@ public sealed record DailyPortfolioPricingLine( string? FinancialAccountId, string? InstrumentType, FairValueLevel FairValueLevel = FairValueLevel.Unclassified, - bool IsStalePriced = false); + bool IsStalePriced = false, + DateOnly? PriceObservedOn = null, + DailyPortfolioPriceConfidence Confidence = DailyPortfolioPriceConfidence.High, + Guid? SecurityId = null, + string? CarryingValueSource = null, + DateTimeOffset? CarryingValueCapturedAtUtc = null, + string? CarryingValueEvidenceReference = null); diff --git a/src/Meridian.Ledger/DailyPortfolioPricingProjection.cs b/src/Meridian.Ledger/DailyPortfolioPricingProjection.cs index daeee7de98..db8d52fa70 100644 --- a/src/Meridian.Ledger/DailyPortfolioPricingProjection.cs +++ b/src/Meridian.Ledger/DailyPortfolioPricingProjection.cs @@ -6,7 +6,7 @@ namespace Meridian.Ledger; public sealed record DailyPortfolioPricingProjection( DailyPortfolioPricingInput Input, IReadOnlyList Lines, - IReadOnlyList<(LedgerAccount account, decimal debit, decimal credit)> JournalLines) + IReadOnlyList JournalLines) { public decimal TotalCostBasis => Lines.Sum(static line => line.CostBasis); @@ -14,9 +14,30 @@ public sealed record DailyPortfolioPricingProjection( public decimal NetUnrealizedGainOrLoss => Lines.Sum(static line => line.UnrealizedGainOrLoss); + /// Prior carrying value used to calculate this run's ledger delta. + public decimal TotalPriorCarryingValue => Lines.Sum(static line => line.PriorCarryingValue); + + /// + /// Net ledger movement required to bring the durable carrying value to the current market + /// value. Unlike , this amount is not cumulative. + /// + public decimal NetMarkAdjustment => Lines.Sum(static line => line.MarkAdjustment); + public decimal TotalDebits => JournalLines.Sum(static line => line.debit); public decimal TotalCredits => JournalLines.Sum(static line => line.credit); public bool IsBalanced => TotalDebits == TotalCredits; } + +/// +/// One balanced-journal component tied to the exact priced security/account line that produced it. +/// The association keeps per-security drafts deterministic even when several securities share an +/// unrealized gain/loss account. +/// +public sealed record DailyPortfolioPricingJournalLine( + DailyPortfolioPricingLine PricingLine, + LedgerAccount account, + decimal debit, + decimal credit, + LedgerLineDimensionSet? dimensions); diff --git a/src/Meridian.Ledger/DailyPortfolioPricingProjector.cs b/src/Meridian.Ledger/DailyPortfolioPricingProjector.cs index 1d3d6268ad..bcbee4602c 100644 --- a/src/Meridian.Ledger/DailyPortfolioPricingProjector.cs +++ b/src/Meridian.Ledger/DailyPortfolioPricingProjector.cs @@ -13,8 +13,12 @@ public static DailyPortfolioPricingProjection Project(DailyPortfolioPricingInput var lines = input.Marks .Select(mark => BuildLine(input, mark)) + .OrderBy(static line => line.SecurityId) + .ThenBy(static line => line.Symbol, StringComparer.Ordinal) + .ThenBy(static line => line.FinancialAccountId, StringComparer.Ordinal) + .ThenBy(static line => line.EvidenceReference, StringComparer.Ordinal) .ToList(); - var journalLines = BuildJournalLines(lines); + var journalLines = BuildJournalLines(input, lines); return new DailyPortfolioPricingProjection(input, lines, journalLines); } @@ -24,6 +28,9 @@ private static DailyPortfolioPricingLine BuildLine(DailyPortfolioPricingInput in var costBasis = RoundCurrency(mark.Quantity * mark.CostPrice); var marketValue = RoundCurrency(mark.Quantity * mark.MarkPrice); var unrealizedGainOrLoss = marketValue - costBasis; + var hasPriorCarryingValue = mark.PriorCarryingValue.HasValue; + var priorCarryingValue = RoundCurrency(mark.PriorCarryingValue ?? costBasis); + var markAdjustment = marketValue - priorCarryingValue; return new DailyPortfolioPricingLine( mark.Symbol, @@ -33,39 +40,55 @@ private static DailyPortfolioPricingLine BuildLine(DailyPortfolioPricingInput in costBasis, marketValue, unrealizedGainOrLoss, + priorCarryingValue, + hasPriorCarryingValue, + markAdjustment, mark.PriceSource, mark.EvidenceReference, input.Policy.PolicyId, input.Policy.ValuationMethod, mark.FinancialAccountId, mark.InstrumentType, - mark.FairValueLevel == FairValueLevel.Unclassified ? input.Policy.DefaultFairValueLevel : mark.FairValueLevel, - mark.IsStalePriced); + FairValueLevel: mark.FairValueLevel == FairValueLevel.Unclassified + ? input.Policy.DefaultFairValueLevel + : mark.FairValueLevel, + IsStalePriced: mark.IsStalePriced, + PriceObservedOn: mark.PriceObservedOn, + Confidence: mark.Confidence, + SecurityId: mark.SecurityId, + CarryingValueSource: mark.CarryingValueSource, + CarryingValueCapturedAtUtc: mark.CarryingValueCapturedAtUtc, + CarryingValueEvidenceReference: mark.CarryingValueEvidenceReference); } - private static IReadOnlyList<(LedgerAccount account, decimal debit, decimal credit)> BuildJournalLines( + private static IReadOnlyList BuildJournalLines( + DailyPortfolioPricingInput input, IReadOnlyList lines) { - var journalLines = new List<(LedgerAccount account, decimal debit, decimal credit)>(); + var journalLines = new List(); foreach (var line in lines) { - if (line.UnrealizedGainOrLoss == 0m) + if (line.MarkAdjustment == 0m) continue; var securitiesAccount = LedgerAccounts.Securities(line.Symbol, line.FinancialAccountId); var scope = line.FinancialAccountId ?? line.Symbol; + var dimensions = new LedgerLineDimensionSet( + FundId: input.Policy.FundId, + InstrumentId: line.SecurityId, + AccountId: line.FinancialAccountId); - if (line.UnrealizedGainOrLoss > 0m) + if (line.MarkAdjustment > 0m) { - journalLines.Add((securitiesAccount, line.UnrealizedGainOrLoss, 0m)); - journalLines.Add((LedgerAccounts.UnrealizedGainFor(scope), 0m, line.UnrealizedGainOrLoss)); + journalLines.Add(new DailyPortfolioPricingJournalLine(line, securitiesAccount, line.MarkAdjustment, 0m, dimensions)); + journalLines.Add(new DailyPortfolioPricingJournalLine(line, LedgerAccounts.UnrealizedGainFor(scope), 0m, line.MarkAdjustment, dimensions)); } else { - var loss = Math.Abs(line.UnrealizedGainOrLoss); - journalLines.Add((LedgerAccounts.UnrealizedLossFor(scope), loss, 0m)); - journalLines.Add((securitiesAccount, 0m, loss)); + var loss = Math.Abs(line.MarkAdjustment); + journalLines.Add(new DailyPortfolioPricingJournalLine(line, LedgerAccounts.UnrealizedLossFor(scope), loss, 0m, dimensions)); + journalLines.Add(new DailyPortfolioPricingJournalLine(line, securitiesAccount, 0m, loss, dimensions)); } } diff --git a/src/Meridian.Ledger/Ledger.cs b/src/Meridian.Ledger/Ledger.cs index 9e8448ad1c..f5d7c4d203 100644 --- a/src/Meridian.Ledger/Ledger.cs +++ b/src/Meridian.Ledger/Ledger.cs @@ -29,6 +29,12 @@ public sealed class Ledger : IReadOnlyLedger private readonly Dictionary> _accountBalanceSnapshots = []; private readonly Dictionary<(LedgerAccount Account, string ScopeKey), DimensionalBalanceSeries> _dimensionalBalanceSnapshots = []; + private readonly Dictionary> + _dimensionalBalanceSeriesIndex = new(StringComparer.Ordinal); + private readonly Dictionary> + _dimensionalPostingIndex = new(StringComparer.Ordinal); + private readonly Dictionary> + _financialAccountPostingIndex = new(StringComparer.OrdinalIgnoreCase); private readonly List _postingCountSnapshots = []; private long _ledgerLineSequence; private long _journalPostingSequence; @@ -56,7 +62,8 @@ public void Post(JournalEntry entry) ValidateJournalEntry(entry); - _journal.Add(entry); + var postingSequence = ++_journalPostingSequence; + InsertJournalEntry(entry); _journalEntryIds.Add(entry.JournalEntryId); foreach (var line in entry.Lines) @@ -69,9 +76,10 @@ public void Post(JournalEntry entry) _accountTotals[line.Account] = totals.Add(line.Debit, line.Credit, entry.Timestamp); var snapshot = AddAccountBalanceSnapshot(line); AddDimensionalBalanceSnapshot(line, snapshot); + AddScopedPostingIndexes(entry.JournalEntryId, line, snapshot.Sequence); } - AddPostingCountSnapshot(entry); + AddPostingCountSnapshot(entry, postingSequence); } /// Returns all individual ledger lines posted to . @@ -350,7 +358,11 @@ private IReadOnlyDictionary BuildTrialBalanceFromDimensi LedgerLineDimensionSet lineDimensions) { var result = new Dictionary(); - foreach (var series in _dimensionalBalanceSnapshots.Values) + var candidateSeries = FindCandidateDimensionSeries(lineDimensions); + if (candidateSeries is null) + return result; + + foreach (var series in candidateSeries) { if (!MatchesFinancialAccount(series.Account, financialAccountId) || !MatchesLineDimensions(series.Dimensions, lineDimensions)) @@ -428,8 +440,9 @@ public LedgerSnapshot SnapshotAsOf( string? financialAccountId = null, LedgerLineDimensionSet? lineDimensions = null) { - var balances = TrialBalanceAsOf(timestamp, financialAccountId, lineDimensions); - if (string.IsNullOrWhiteSpace(financialAccountId) && lineDimensions is null) + var canonicalDimensions = LedgerLineDimensionSetNormalizer.Canonicalize(lineDimensions); + var balances = TrialBalanceAsOf(timestamp, financialAccountId, canonicalDimensions); + if (string.IsNullOrWhiteSpace(financialAccountId) && canonicalDimensions is null) { var index = LastPostingCountSnapshotAtOrBefore(timestamp); return index < 0 @@ -441,23 +454,10 @@ public LedgerSnapshot SnapshotAsOf( _postingCountSnapshots[index].LedgerEntryCount); } - var journalCount = 0; - var ledgerEntryCount = 0; - foreach (var journalEntry in _journal) - { - if (journalEntry.Timestamp > timestamp) - continue; - - var scopedLines = journalEntry.Lines - .Where(line => MatchesFinancialAccount(line.Account, financialAccountId)) - .Where(line => MatchesLineDimensions(line.Dimensions, lineDimensions)) - .ToList(); - if (scopedLines.Count == 0) - continue; - - journalCount++; - ledgerEntryCount += scopedLines.Count; - } + var (journalCount, ledgerEntryCount) = GetScopedPostingCountsAsOf( + timestamp, + financialAccountId, + canonicalDimensions); return new LedgerSnapshot(timestamp, balances, journalCount, ledgerEntryCount); } @@ -598,6 +598,17 @@ private void AddDimensionalBalanceSnapshot( { series = new DimensionalBalanceSeries(line.Account, dimensions, []); _dimensionalBalanceSnapshots[key] = series; + foreach (var field in LedgerLineDimensionSetFields.Enumerate(dimensions)) + { + var indexKey = BuildDimensionIndexKey(field); + if (!_dimensionalBalanceSeriesIndex.TryGetValue(indexKey, out var indexedSeries)) + { + indexedSeries = []; + _dimensionalBalanceSeriesIndex[indexKey] = indexedSeries; + } + + indexedSeries.Add(series); + } } var index = FindAccountBalanceInsertIndex(series.Snapshots, snapshot); @@ -605,6 +616,153 @@ private void AddDimensionalBalanceSnapshot( RecalculateAccountBalanceSnapshots(series.Snapshots, index); } + private IReadOnlyCollection? FindCandidateDimensionSeries( + LedgerLineDimensionSet dimensions) + { + HashSet? smallest = null; + foreach (var field in LedgerLineDimensionSetFields.Enumerate(dimensions)) + { + if (!_dimensionalBalanceSeriesIndex.TryGetValue(BuildDimensionIndexKey(field), out var candidates)) + return null; + + if (smallest is null || candidates.Count < smallest.Count) + smallest = candidates; + } + + return smallest; + } + + private void AddScopedPostingIndexes(Guid journalEntryId, LedgerEntry line, long sequence) + { + var dimensions = LedgerLineDimensionSetNormalizer.Canonicalize(line.Dimensions); + var posting = new ScopedLedgerLinePosting( + line.Timestamp, + sequence, + journalEntryId, + line.Account, + dimensions); + + if (!string.IsNullOrWhiteSpace(line.Account.FinancialAccountId)) + { + var financialAccountId = line.Account.FinancialAccountId.Trim(); + if (!_financialAccountPostingIndex.TryGetValue(financialAccountId, out var postings)) + { + postings = []; + _financialAccountPostingIndex[financialAccountId] = postings; + } + + InsertScopedPosting(postings, posting); + } + + if (dimensions is null) + return; + + foreach (var field in LedgerLineDimensionSetFields.Enumerate(dimensions)) + { + var indexKey = BuildDimensionIndexKey(field); + if (!_dimensionalPostingIndex.TryGetValue(indexKey, out var postings)) + { + postings = []; + _dimensionalPostingIndex[indexKey] = postings; + } + + InsertScopedPosting(postings, posting); + } + } + + private (int JournalCount, int LedgerEntryCount) GetScopedPostingCountsAsOf( + DateTimeOffset timestamp, + string? financialAccountId, + LedgerLineDimensionSet? dimensions) + { + IReadOnlyList? candidates = null; + if (!string.IsNullOrWhiteSpace(financialAccountId)) + { + if (!_financialAccountPostingIndex.TryGetValue(financialAccountId.Trim(), out var accountPostings)) + return (0, 0); + + candidates = accountPostings; + } + + if (dimensions is not null) + { + foreach (var field in LedgerLineDimensionSetFields.Enumerate(dimensions)) + { + if (!_dimensionalPostingIndex.TryGetValue(BuildDimensionIndexKey(field), out var dimensionPostings)) + return (0, 0); + + if (candidates is null || dimensionPostings.Count < candidates.Count) + candidates = dimensionPostings; + } + } + + if (candidates is null) + return (0, 0); + + var journalIds = new HashSet(); + var ledgerEntryCount = 0; + var lastIndex = LastScopedPostingAtOrBefore(candidates, timestamp); + for (var index = 0; index <= lastIndex; index++) + { + var posting = candidates[index]; + if (!MatchesFinancialAccount(posting.Account, financialAccountId) || + !MatchesLineDimensions(posting.Dimensions, dimensions)) + { + continue; + } + + journalIds.Add(posting.JournalEntryId); + ledgerEntryCount++; + } + + return (journalIds.Count, ledgerEntryCount); + } + + private static void InsertScopedPosting( + List postings, + ScopedLedgerLinePosting posting) + { + var low = 0; + var high = postings.Count; + while (low < high) + { + var mid = low + ((high - low) / 2); + if (CompareScopedPostings(postings[mid], posting) <= 0) + low = mid + 1; + else + high = mid; + } + + postings.Insert(low, posting); + } + + private static int LastScopedPostingAtOrBefore( + IReadOnlyList postings, + DateTimeOffset timestamp) + { + var low = 0; + var high = postings.Count - 1; + var result = -1; + while (low <= high) + { + var mid = low + ((high - low) / 2); + if (postings[mid].Timestamp <= timestamp) + { + result = mid; + low = mid + 1; + } + else + { + high = mid - 1; + } + } + + return result; + } + + private static string BuildDimensionIndexKey(LedgerDimensionField field) + => $"{field.Name.ToUpperInvariant()}\u001f{field.Value.ToUpperInvariant()}"; + private static int FindAccountBalanceInsertIndex( IReadOnlyList snapshots, AccountBalanceSnapshot snapshot) @@ -637,11 +795,27 @@ private static void RecalculateAccountBalanceSnapshots(List new() { @@ -839,6 +1019,13 @@ private sealed record DimensionalBalanceSeries( LedgerLineDimensionSet Dimensions, List Snapshots); + private readonly record struct ScopedLedgerLinePosting( + DateTimeOffset Timestamp, + long Sequence, + Guid JournalEntryId, + LedgerAccount Account, + LedgerLineDimensionSet? Dimensions); + private readonly record struct LedgerPostingCountSnapshot( DateTimeOffset Timestamp, long Sequence, diff --git a/src/Meridian.ProviderSdk/ConnectionDiagnosticsContracts.cs b/src/Meridian.ProviderSdk/ConnectionDiagnosticsContracts.cs new file mode 100644 index 0000000000..726a820282 --- /dev/null +++ b/src/Meridian.ProviderSdk/ConnectionDiagnosticsContracts.cs @@ -0,0 +1,65 @@ +using System.Net.WebSockets; + +namespace Meridian.Infrastructure.Resilience; + +/// +/// Shared lifecycle states for provider connection supervision. +/// +public enum ProviderConnectionLifecycleState +{ + NotConfigured = 0, + Configured = 1, + Connecting = 2, + Connected = 3, + Degraded = 4, + Reconnecting = 5, + Disconnecting = 6, + Disconnected = 7, + Failed = 8, + Disabled = 9 +} + +/// +/// Normalized provider failure categories used by retry policy, diagnostics, and health surfaces. +/// +public enum ProviderFailureKind +{ + Unknown = 0, + TransientNetworkFailure = 1, + ProviderRateLimit = 2, + AuthenticationOrAuthorizationFailure = 3, + InvalidSubscription = 4, + ProviderOutage = 5, + MalformedProviderResponse = 6, + LocalConfigurationError = 7, + Cancelled = 8 +} + +/// +/// Safe provider connection diagnostics. It intentionally excludes URIs, credentials, +/// headers, account IDs, and payload data. +/// +/// +/// The retained field is for +/// polling, raw-socket, simulated, and default contract diagnostics. +/// +public sealed record WebSocketConnectionDiagnostics( + string ProviderName, + ProviderConnectionLifecycleState LifecycleState, + WebSocketState WebSocketState, + bool IsConnected, + bool IsReconnecting, + int ReconnectAttempts, + DateTimeOffset? LastConnectedAt, + DateTimeOffset? LastDisconnectedAt, + DateTimeOffset? LastHeartbeatReceivedAt, + DateTimeOffset? LastMessageReceivedAt, + DateTimeOffset? LastReconnectAttemptAt, + string? LastError, + ProviderFailureKind? LastFailureKind, + TimeSpan? ConnectionAge, + TimeSpan? IdleDuration, + int ActiveSubscriptions = 0, + int FailedSubscriptions = 0, + int RecoveringSubscriptions = 0, + DateTimeOffset? LastSubscriptionMessageAt = null); diff --git a/src/Meridian.ProviderSdk/IMarketDataClient.cs b/src/Meridian.ProviderSdk/IMarketDataClient.cs index 0d94b202a0..2f0d60dd0b 100644 --- a/src/Meridian.ProviderSdk/IMarketDataClient.cs +++ b/src/Meridian.ProviderSdk/IMarketDataClient.cs @@ -14,11 +14,17 @@ namespace Meridian.Infrastructure; /// All streaming data providers must implement this interface. /// /// Implements for unified provider discovery -/// and capability reporting across all provider types. +/// and capability reporting across all provider types, plus +/// so runtime health surfaces can always +/// obtain a conservative connection snapshot. Providers with supervised lifecycle state should +/// override the diagnostics defaults with their richer runtime evidence. /// [ImplementsAdr("ADR-001", "Core streaming data provider contract")] [ImplementsAdr("ADR-004", "All async methods support CancellationToken")] -public interface IMarketDataClient : IProviderMetadata, IAsyncDisposable +public interface IMarketDataClient : + IProviderMetadata, + IProviderConnectionDiagnosticsSource, + IAsyncDisposable { bool IsEnabled { get; } diff --git a/src/Meridian.ProviderSdk/IProviderConnectionDiagnosticsSource.cs b/src/Meridian.ProviderSdk/IProviderConnectionDiagnosticsSource.cs new file mode 100644 index 0000000000..da0d9179c6 --- /dev/null +++ b/src/Meridian.ProviderSdk/IProviderConnectionDiagnosticsSource.cs @@ -0,0 +1,63 @@ +using System.Net.WebSockets; +using Meridian.Infrastructure.Resilience; + +namespace Meridian.Infrastructure.Adapters.Core; + +/// +/// Provider contract for safe connection lifecycle diagnostics. +/// +/// +/// inherits this contract, so every +/// streaming adapter has a safe diagnostic surface. Adapters with supervised lifecycle state +/// should override the conservative default members below; other provider families may opt in. +/// +public interface IProviderConnectionDiagnosticsSource +{ + /// + /// Raised when the provider connection lifecycle diagnostics change. The compatibility + /// default is a no-op because adapters without a lifecycle supervisor cannot publish changes. + /// + event Action? ConnectionDiagnosticsChanged + { + add { } + remove { } + } + + /// + /// Gets a safe diagnostics snapshot for provider health, logs, and tests. + /// + /// + /// The compatibility default never claims a live connection. Enabled streaming clients are + /// reported as configured; disabled clients are reported as disabled. Adapters should + /// override this member when they can prove richer runtime state. + /// + WebSocketConnectionDiagnostics GetConnectionDiagnosticsSnapshot() + { + var providerName = this is IProviderMetadata metadata && + !string.IsNullOrWhiteSpace(metadata.ProviderDisplayName) + ? metadata.ProviderDisplayName + : GetType().Name; + var lifecycleState = this is Meridian.Infrastructure.IMarketDataClient client + ? client.IsEnabled + ? ProviderConnectionLifecycleState.Configured + : ProviderConnectionLifecycleState.Disabled + : ProviderConnectionLifecycleState.NotConfigured; + + return new WebSocketConnectionDiagnostics( + ProviderName: providerName, + LifecycleState: lifecycleState, + WebSocketState: WebSocketState.None, + IsConnected: false, + IsReconnecting: false, + ReconnectAttempts: 0, + LastConnectedAt: null, + LastDisconnectedAt: null, + LastHeartbeatReceivedAt: null, + LastMessageReceivedAt: null, + LastReconnectAttemptAt: null, + LastError: null, + LastFailureKind: null, + ConnectionAge: null, + IdleDuration: null); + } +} diff --git a/src/Meridian.ProviderSdk/README.md b/src/Meridian.ProviderSdk/README.md index 74b2315be2..91f64ee36e 100644 --- a/src/Meridian.ProviderSdk/README.md +++ b/src/Meridian.ProviderSdk/README.md @@ -6,7 +6,7 @@ module_id: SRC-PROVIDER-SDK path: src/Meridian.ProviderSdk status: active owner_lane: Data Confidence and Validation -last_reviewed: 2026-06-07 +last_reviewed: 2026-07-15 --- # src/Meridian.ProviderSdk @@ -30,6 +30,13 @@ This layer is the plugin contract for provider adapters. It should expose stable ## Important workflows Use this module when a provider abstraction must be consumed by multiple adapters or higher-level services. +`IMarketDataClient` inherits `IProviderConnectionDiagnosticsSource`, making a safe connection +snapshot a contract-level expectation for every streaming provider. The default implementation is +compatibility-preserving and conservative: enabled adapters report `Configured`, disabled adapters +report `Disabled`, `WebSocketState` is `None`, and no live connection is inferred. Adapters with a +connection supervisor should override the default event and snapshot with proven runtime state. +The retained `WebSocketConnectionDiagnostics` name is transport-compatible; polling and raw-socket +providers use `WebSocketState.None`. `Backfill/BackfillJob.cs` owns the shared backfill job descriptors and `DataGranularity` conversion helpers. `PluginLoaderService` owns non-recursive provider plugin assembly scanning and registration against diff --git a/src/Meridian.Storage/Interfaces/ISymbolRegistryService.cs b/src/Meridian.Storage/Interfaces/ISymbolRegistryService.cs index cdc5c619d2..d17730f54d 100644 --- a/src/Meridian.Storage/Interfaces/ISymbolRegistryService.cs +++ b/src/Meridian.Storage/Interfaces/ISymbolRegistryService.cs @@ -73,6 +73,17 @@ Task AddProviderMappingAsync( /// IEnumerable GetSymbolsByAssetClass(string assetClass); + /// + /// Gets the retained fingerprint for a completed registry migration while holding the + /// registry mutation gate. + /// + Task GetMigrationMarkerAsync(string migrationId, CancellationToken ct = default); + + /// + /// Persists a registry migration fingerprint while holding the registry mutation gate. + /// + Task SetMigrationMarkerAsync(string migrationId, string fingerprint, CancellationToken ct = default); + /// /// Saves the registry to disk. /// diff --git a/src/Meridian.Storage/Ledger/AccountingPostingCommandFingerprintJsonContext.cs b/src/Meridian.Storage/Ledger/AccountingPostingCommandFingerprintJsonContext.cs new file mode 100644 index 0000000000..4d925112fc --- /dev/null +++ b/src/Meridian.Storage/Ledger/AccountingPostingCommandFingerprintJsonContext.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; +using Meridian.Contracts.Ledger; + +namespace Meridian.Storage.Ledger; + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(AccountingPostingCommandDto))] +internal sealed partial class AccountingPostingCommandFingerprintJsonContext : JsonSerializerContext; diff --git a/src/Meridian.Storage/Ledger/AccountingPostingCommandValidator.cs b/src/Meridian.Storage/Ledger/AccountingPostingCommandValidator.cs index 00cd6669c9..895e9ae1b5 100644 --- a/src/Meridian.Storage/Ledger/AccountingPostingCommandValidator.cs +++ b/src/Meridian.Storage/Ledger/AccountingPostingCommandValidator.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text.Json; using Meridian.Contracts.Ledger; using Meridian.Contracts.Workstation; using Meridian.Ledger; @@ -6,6 +8,8 @@ namespace Meridian.Storage.Ledger; public static class AccountingPostingCommandValidator { + internal const string PostingCommandFingerprintTag = "postingCommandFingerprint"; + public static LedgerJournalEntryWrite NormalizeAndValidate(LedgerJournalEntryWrite write) { ArgumentNullException.ThrowIfNull(write); @@ -94,6 +98,7 @@ public static LedgerJournalEntryWrite NormalizeAndValidate(LedgerJournalEntryWri throw new LedgerValidationException("Accounting posting command requires approved or not-required reviewer state before append."); } + ValidateBookContext(write, command); ValidateTypedAssertions(command); var entry = NormalizeEntryMetadata(write.Entry, command); @@ -114,6 +119,46 @@ public static LedgerJournalEntryWrite NormalizeAndValidate(LedgerJournalEntryWri private static bool RequiresApproval(AccountingPostingIntentDto intent) => intent is not AccountingPostingIntentDto.AutomatedDraft; + private static void ValidateBookContext( + LedgerJournalEntryWrite write, + AccountingPostingCommandDto command) + { + if (command.BookContext is not { } context) + return; + + var writeBookId = command.LedgerBookId ?? write.LedgerBookId; + if (writeBookId != context.LedgerBookId) + { + throw new LedgerValidationException( + "Accounting posting command book context ledger book must match the ledger write book."); + } + + if (context.PeriodId != write.PeriodId) + { + throw new LedgerValidationException( + "Accounting posting command book context period must match the ledger write period."); + } + + if (context.AccountingBasis != write.AccountingBasis) + { + throw new LedgerValidationException( + "Accounting posting command book context basis must match the ledger write accounting basis."); + } + + if (!string.Equals( + context.AccountingPolicyId?.Trim(), + write.AccountingPolicyId?.Trim(), + StringComparison.Ordinal) || + !string.Equals( + context.AccountingPolicyVersion?.Trim(), + write.AccountingPolicyVersion?.Trim(), + StringComparison.Ordinal)) + { + throw new LedgerValidationException( + "Accounting posting command book context policy must match the ledger write accounting policy and version."); + } + } + private static void ValidateTypedAssertions(AccountingPostingCommandDto command) { if (command.EconomicEvent is { } economicEvent) @@ -205,7 +250,8 @@ private static IReadOnlyDictionary BuildCommandTags(AccountingPo var tags = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["postingCommandId"] = command.CommandId.ToString("D"), - ["approvalState"] = command.ApprovalState.ToString() + ["approvalState"] = command.ApprovalState.ToString(), + [PostingCommandFingerprintTag] = ComputePostingCommandFingerprint(command) }; AddTag(tags, "approvalId", command.ApprovalId); @@ -230,6 +276,68 @@ private static IReadOnlyDictionary BuildCommandTags(AccountingPo return tags; } + internal static string ComputePostingCommandFingerprint(AccountingPostingCommandDto command) + { + ArgumentNullException.ThrowIfNull(command); + var element = JsonSerializer.SerializeToElement( + command, + AccountingPostingCommandFingerprintJsonContext.Default.AccountingPostingCommandDto); + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + WriteCanonicalJson(writer, element); + } + + var hash = SHA256.HashData(stream.ToArray()); + return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; + } + + private static void WriteCanonicalJson(Utf8JsonWriter writer, JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + writer.WriteStartObject(); + foreach (var property in element + .EnumerateObject() + .OrderBy(static property => property.Name, StringComparer.Ordinal)) + { + writer.WritePropertyName(property.Name); + WriteCanonicalJson(writer, property.Value); + } + + writer.WriteEndObject(); + break; + case JsonValueKind.Array: + writer.WriteStartArray(); + foreach (var item in element.EnumerateArray()) + { + WriteCanonicalJson(writer, item); + } + + writer.WriteEndArray(); + break; + case JsonValueKind.String: + writer.WriteStringValue(element.GetString()); + break; + case JsonValueKind.Number: + writer.WriteRawValue(element.GetRawText(), skipInputValidation: true); + break; + case JsonValueKind.True: + writer.WriteBooleanValue(true); + break; + case JsonValueKind.False: + writer.WriteBooleanValue(false); + break; + case JsonValueKind.Null: + writer.WriteNullValue(); + break; + default: + throw new LedgerValidationException( + $"Accounting posting command contains unsupported JSON value kind '{element.ValueKind}'."); + } + } + private static void AddTag(IDictionary tags, string key, string? value) { if (!string.IsNullOrWhiteSpace(value)) diff --git a/src/Meridian.Storage/Ledger/GovernedLedgerPostingTarget.cs b/src/Meridian.Storage/Ledger/GovernedLedgerPostingTarget.cs index f332124bed..11c3248c04 100644 --- a/src/Meridian.Storage/Ledger/GovernedLedgerPostingTarget.cs +++ b/src/Meridian.Storage/Ledger/GovernedLedgerPostingTarget.cs @@ -1,4 +1,5 @@ using Meridian.Ledger; +using Npgsql; namespace Meridian.Storage.Ledger; @@ -20,8 +21,9 @@ public sealed record GovernedLedgerPostingResult( /// /// Serializes the check-and-append handoff for one process and treats an equivalent -/// retained journal entry as a successful retry. A reused journal id with different -/// accounting content fails closed. +/// retained posting identity as a successful retry. Global journal/command collisions +/// and aggregate-scoped source/idempotency collisions with different accounting content +/// fail closed. /// public sealed class DurableLedgerPostingTarget : IGovernedLedgerPostingTarget, IDisposable { @@ -39,22 +41,36 @@ public async Task PostAsync( { ArgumentNullException.ThrowIfNull(write); ArgumentNullException.ThrowIfNull(write.Entry); + write = NormalizeWrite(AccountingPostingCommandValidator.NormalizeAndValidate(write)); await _writeGate.WaitAsync(ct).ConfigureAwait(false); try { - var retained = await _store - .GetByAggregateAsync(write.AggregateId, ct) + var identity = LedgerPostingIdentity.FromWrite(write); + var collisions = await _store + .FindPostingIdentityCollisionsAsync(identity, ct) .ConfigureAwait(false); - var existing = retained.FirstOrDefault(record => - record.Entry.JournalEntryId == write.Entry.JournalEntryId); - if (existing is not null) + if (collisions.Count > 0) { - EnsureEquivalent(existing, write); - return new GovernedLedgerPostingResult(write.Entry.JournalEntryId, WasAppended: false); + return ResolveRetainedCollision(collisions, write); + } + + try + { + await _store.AppendAsync(write, ct).ConfigureAwait(false); + } + catch (PostgresException exception) + when (exception.SqlState == PostgresErrorCodes.UniqueViolation) + { + collisions = await _store + .FindPostingIdentityCollisionsAsync(identity, ct) + .ConfigureAwait(false); + if (collisions.Count == 0) + throw; + + return ResolveRetainedCollision(collisions, write); } - await _store.AppendAsync(write, ct).ConfigureAwait(false); return new GovernedLedgerPostingResult(write.Entry.JournalEntryId, WasAppended: true); } finally @@ -65,6 +81,23 @@ public async Task PostAsync( public void Dispose() => _writeGate.Dispose(); + private static GovernedLedgerPostingResult ResolveRetainedCollision( + IReadOnlyList collisions, + LedgerJournalEntryWrite requested) + { + foreach (var collision in collisions) + { + EnsureEquivalent(collision, requested); + } + + var retained = collisions + .OrderBy(static record => record.GlobalSequence) + .ThenBy(static record => record.CreatedAt) + .ThenBy(static record => record.Entry.JournalEntryId) + .First(); + return new GovernedLedgerPostingResult(retained.Entry.JournalEntryId, WasAppended: false); + } + private static void EnsureEquivalent( LedgerJournalEntryRecord existing, LedgerJournalEntryWrite requested) @@ -73,15 +106,21 @@ private static void EnsureEquivalent( var candidate = requested.Entry; var equivalent = existing.AggregateId == requested.AggregateId && existing.PeriodId == requested.PeriodId + && existing.CommandId == requested.CommandId + && existing.CorrelationId == requested.CorrelationId && existing.AccountingBasis == requested.AccountingBasis + && string.Equals(existing.AccountingPolicyId, requested.AccountingPolicyId, StringComparison.Ordinal) + && string.Equals(existing.AccountingPolicyVersion, requested.AccountingPolicyVersion, StringComparison.Ordinal) + && string.Equals(existing.RuleId, requested.RuleId, StringComparison.Ordinal) + && string.Equals(existing.RuleVersion, requested.RuleVersion, StringComparison.Ordinal) + && existing.SourceEventId == requested.SourceEventId + && existing.SourceJournalEntryId == requested.SourceJournalEntryId + && existing.PostingKind == requested.PostingKind + && existing.AdjustmentApproval == requested.AdjustmentApproval && retained.Timestamp == candidate.Timestamp && string.Equals(retained.Description, candidate.Description, StringComparison.Ordinal) - && string.Equals( - retained.Metadata.IdempotencyKey, - candidate.Metadata.IdempotencyKey, - StringComparison.OrdinalIgnoreCase) - && retained.Lines.Count == candidate.Lines.Count - && retained.Lines.Zip(candidate.Lines, LinesEquivalent).All(static matches => matches); + && MetadataEquivalent(retained.Metadata, candidate.Metadata) + && JournalLinesEquivalent(retained.Lines, candidate.Lines); if (!equivalent) { @@ -90,8 +129,37 @@ private static void EnsureEquivalent( } } + private static bool JournalLinesEquivalent( + IReadOnlyList retained, + IReadOnlyList candidate) + { + if (retained.Count != candidate.Count) + return false; + + var matched = new bool[candidate.Count]; + foreach (var retainedLine in retained) + { + var matchIndex = -1; + for (var index = 0; index < candidate.Count; index++) + { + if (!matched[index] && LinesEquivalent(retainedLine, candidate[index])) + { + matchIndex = index; + break; + } + } + + if (matchIndex < 0) + return false; + + matched[matchIndex] = true; + } + + return true; + } + private static bool LinesEquivalent(LedgerEntry retained, LedgerEntry candidate) - => retained.EntryId == candidate.EntryId + => retained.Timestamp == candidate.Timestamp && retained.Account.AccountType == candidate.Account.AccountType && string.Equals(retained.Account.Name, candidate.Account.Name, StringComparison.Ordinal) && string.Equals(retained.Account.Symbol, candidate.Account.Symbol, StringComparison.OrdinalIgnoreCase) @@ -100,5 +168,203 @@ private static bool LinesEquivalent(LedgerEntry retained, LedgerEntry candidate) candidate.Account.FinancialAccountId, StringComparison.OrdinalIgnoreCase) && retained.Debit == candidate.Debit - && retained.Credit == candidate.Credit; + && retained.Credit == candidate.Credit + && string.Equals(retained.Description, candidate.Description, StringComparison.Ordinal) + && DimensionsEquivalent(retained.Dimensions, candidate.Dimensions); + + private static LedgerJournalEntryWrite NormalizeWrite(LedgerJournalEntryWrite write) + { + var metadata = write.Entry.Metadata; + if (write.LedgerBookId is { } ledgerBookId && string.IsNullOrWhiteSpace(metadata.LedgerBook)) + { + metadata = metadata with { LedgerBook = ledgerBookId.ToString("D") }; + } + else if (write.LedgerBookId is { } requestedLedgerBookId && + (!Guid.TryParse(metadata.LedgerBook, out var metadataLedgerBookId) || + metadataLedgerBookId != requestedLedgerBookId)) + { + throw new LedgerValidationException( + $"Ledger write ledger book '{requestedLedgerBookId:D}' conflicts with journal metadata ledger book '{metadata.LedgerBook}'."); + } + + var entry = ReferenceEquals(metadata, write.Entry.Metadata) + ? write.Entry + : new JournalEntry( + write.Entry.JournalEntryId, + write.Entry.Timestamp, + write.Entry.Description, + write.Entry.Lines, + metadata); + + return write with + { + Entry = entry, + AccountingPolicyId = RequireText(write.AccountingPolicyId, nameof(write.AccountingPolicyId)), + AccountingPolicyVersion = RequireText(write.AccountingPolicyVersion, nameof(write.AccountingPolicyVersion)), + RuleId = NormalizeOptional(write.RuleId), + RuleVersion = NormalizeOptional(write.RuleVersion) + }; + } + + private static bool MetadataEquivalent(JournalEntryMetadata retained, JournalEntryMetadata candidate) + { + retained = retained.Normalize(); + candidate = candidate.Normalize(); + + return TextEquals(retained.ActivityType, candidate.ActivityType) + && TextEquals(retained.Symbol, candidate.Symbol) + && retained.SecurityId == candidate.SecurityId + && retained.OrderId == candidate.OrderId + && retained.FillId == candidate.FillId + && TextEquals(retained.ProjectId, candidate.ProjectId) + && TextEquals(retained.LedgerBook, candidate.LedgerBook) + && retained.LedgerView == candidate.LedgerView + && TextEquals(retained.ScenarioId, candidate.ScenarioId) + && TextEquals(retained.StrategyId, candidate.StrategyId) + && TextEquals(retained.FinancialAccountId, candidate.FinancialAccountId) + && TextEquals(retained.CounterpartyAccountId, candidate.CounterpartyAccountId) + && TextEquals(retained.Institution, candidate.Institution) + && retained.EffectiveDate == candidate.EffectiveDate + && TextEquals(retained.IdempotencyKey, candidate.IdempotencyKey) + && TextEquals(retained.FundEventId, candidate.FundEventId) + && TextEquals(retained.FundEventType, candidate.FundEventType) + && TextEquals(retained.CapitalAccountId, candidate.CapitalAccountId) + && TextEquals(retained.InvestorId, candidate.InvestorId) + && TextEquals(retained.PaymentIntentId, candidate.PaymentIntentId) + && TextEquals(retained.SettlementReference, candidate.SettlementReference) + && TagsEquivalent(retained.Tags, candidate.Tags) + && EvidenceEquivalent(retained.EvidenceReferences, candidate.EvidenceReferences); + } + + private static bool TagsEquivalent( + IReadOnlyDictionary? retained, + IReadOnlyDictionary? candidate) + { + var ignoreLineDimensionCompatibilityTags = + ContainsTag(retained, AccountingPostingCommandValidator.PostingCommandFingerprintTag) && + ContainsTag(candidate, AccountingPostingCommandValidator.PostingCommandFingerprintTag); + var retainedPairs = (retained ?? new Dictionary()) + .Where(pair => !ignoreLineDimensionCompatibilityTags || !IsLineDimensionCompatibilityTag(pair.Key)) + .ToArray(); + var candidatePairs = (candidate ?? new Dictionary()) + .Where(pair => !ignoreLineDimensionCompatibilityTags || !IsLineDimensionCompatibilityTag(pair.Key)) + .ToArray(); + if (retainedPairs.Length != candidatePairs.Length) + return false; + + foreach (var (key, retainedValue) in retainedPairs) + { + var candidatePair = candidatePairs.FirstOrDefault(pair => + string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase)); + if (candidatePair.Key is null || + !string.Equals(retainedValue, candidatePair.Value, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + private static bool ContainsTag( + IReadOnlyDictionary? tags, + string key) + => tags?.Keys.Any(candidate => string.Equals(candidate, key, StringComparison.OrdinalIgnoreCase)) == true; + + private static bool IsLineDimensionCompatibilityTag(string? key) + => key?.StartsWith("lineDimensions.", StringComparison.OrdinalIgnoreCase) == true; + + private static bool EvidenceEquivalent( + IReadOnlyList retained, + IReadOnlyList candidate) + { + if (retained.Count != candidate.Count) + return false; + + var retainedOrdered = retained + .Select(static evidence => evidence.Normalize()) + .OrderBy(static evidence => evidence.EvidenceId, StringComparer.OrdinalIgnoreCase) + .ThenBy(static evidence => evidence.Uri, StringComparer.Ordinal) + .ThenBy(static evidence => evidence.Kind, StringComparer.OrdinalIgnoreCase) + .ThenBy(static evidence => evidence.SourceSystem, StringComparer.OrdinalIgnoreCase) + .ThenBy(static evidence => evidence.RetainedAtUtc) + .ThenBy(static evidence => evidence.RetainedBy, StringComparer.OrdinalIgnoreCase) + .ThenBy(static evidence => evidence.SubjectId, StringComparer.OrdinalIgnoreCase) + .ThenBy(static evidence => evidence.ContentHash, StringComparer.OrdinalIgnoreCase) + .ThenBy(static evidence => evidence.Description, StringComparer.Ordinal) + .ToArray(); + var candidateOrdered = candidate + .Select(static evidence => evidence.Normalize()) + .OrderBy(static evidence => evidence.EvidenceId, StringComparer.OrdinalIgnoreCase) + .ThenBy(static evidence => evidence.Uri, StringComparer.Ordinal) + .ThenBy(static evidence => evidence.Kind, StringComparer.OrdinalIgnoreCase) + .ThenBy(static evidence => evidence.SourceSystem, StringComparer.OrdinalIgnoreCase) + .ThenBy(static evidence => evidence.RetainedAtUtc) + .ThenBy(static evidence => evidence.RetainedBy, StringComparer.OrdinalIgnoreCase) + .ThenBy(static evidence => evidence.SubjectId, StringComparer.OrdinalIgnoreCase) + .ThenBy(static evidence => evidence.ContentHash, StringComparer.OrdinalIgnoreCase) + .ThenBy(static evidence => evidence.Description, StringComparer.Ordinal) + .ToArray(); + return retainedOrdered.SequenceEqual(candidateOrdered); + } + + private static bool DimensionsEquivalent( + LedgerLineDimensionSet? retained, + LedgerLineDimensionSet? candidate) + { + if (retained is null || candidate is null) + return retained is null && candidate is null; + + return TextEquals(retained.FundId, candidate.FundId) + && TextEquals(retained.EntityId, candidate.EntityId) + && TextEquals(retained.SleeveId, candidate.SleeveId) + && TextEquals(retained.StrategyId, candidate.StrategyId) + && TextEquals(retained.InvestorId, candidate.InvestorId) + && TextEquals(retained.CapitalAccountId, candidate.CapitalAccountId) + && retained.InstrumentId == candidate.InstrumentId + && retained.PositionId == candidate.PositionId + && TextEquals(retained.TaxLotId, candidate.TaxLotId) + && TextEquals(retained.CostCenterId, candidate.CostCenterId) + && TextEquals(retained.CounterpartyId, candidate.CounterpartyId) + && TextEquals(retained.OrganizationId, candidate.OrganizationId) + && TextEquals(retained.PortfolioId, candidate.PortfolioId) + && TextEquals(retained.BookId, candidate.BookId) + && TextEquals(retained.AccountId, candidate.AccountId) + && TextEquals(retained.CustomerId, candidate.CustomerId) + && TextEquals(retained.VendorId, candidate.VendorId) + && TextEquals(retained.ProjectId, candidate.ProjectId) + && StringDictionaryEquivalent(retained.ExternalGlDimensions, candidate.ExternalGlDimensions); + } + + private static bool StringDictionaryEquivalent( + IReadOnlyDictionary retained, + IReadOnlyDictionary candidate) + { + if (retained.Count != candidate.Count) + return false; + + foreach (var (key, retainedValue) in retained) + { + var candidatePair = candidate.FirstOrDefault(pair => + string.Equals(pair.Key?.Trim(), key?.Trim(), StringComparison.OrdinalIgnoreCase)); + if (candidatePair.Key is null || !TextEquals(retainedValue?.Trim(), candidatePair.Value?.Trim())) + return false; + } + + return true; + } + + private static bool TextEquals(string? retained, string? candidate) + => string.Equals(retained, candidate, StringComparison.OrdinalIgnoreCase); + + private static string RequireText(string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value)) + throw new LedgerValidationException($"{parameterName} is required for durable ledger posting."); + + return value.Trim(); + } + + private static string? NormalizeOptional(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } diff --git a/src/Meridian.Storage/Ledger/ILedgerJournalStore.cs b/src/Meridian.Storage/Ledger/ILedgerJournalStore.cs index 50182b5e01..b7087214ee 100644 --- a/src/Meridian.Storage/Ledger/ILedgerJournalStore.cs +++ b/src/Meridian.Storage/Ledger/ILedgerJournalStore.cs @@ -74,7 +74,23 @@ Task> ListOpenTaxLotsAsync( new NotSupportedException("This ledger journal store does not support tax-lot persistence.")); } -public interface ITransactionalLedgerJournalStore : ILedgerJournalStore +public interface IAtomicLedgerPeriodCloseStore +{ + /// + /// Hard-closes a retained period only after rechecking revenue and expense balances while + /// holding the same exclusion boundary used by journal appends. The balance guard, period + /// CAS, and close-event insert must commit or roll back together. + /// + Task SaveHardClosedPeriodAsync( + LedgerAccountingPeriod period, + long expectedVersion, + PeriodCloseEventRecord closeEvent, + CancellationToken ct = default); +} + +public interface ITransactionalLedgerJournalStore : + ILedgerJournalStore, + IAtomicLedgerPeriodCloseStore { Task AppendAsync( NpgsqlConnection connection, @@ -83,6 +99,101 @@ Task AppendAsync( CancellationToken ct = default); } +/// +/// Optional optimized lookup for every retained journal that collides with a proposed posting +/// identity. Journal-entry and command identifiers are global identities; source-event and +/// idempotency identities are scoped to the accounting aggregate. +/// +public interface ILedgerPostingIdentityCollisionLookup +{ + Task> FindPostingIdentityCollisionsAsync( + LedgerPostingIdentity identity, + CancellationToken ct = default); +} + +public sealed record LedgerPostingIdentity( + Guid JournalEntryId, + Guid AggregateId, + Guid? CommandId, + Guid? SourceEventId, + string? IdempotencyKey) +{ + public static LedgerPostingIdentity FromWrite(LedgerJournalEntryWrite write) + { + ArgumentNullException.ThrowIfNull(write); + ArgumentNullException.ThrowIfNull(write.Entry); + + return new LedgerPostingIdentity( + write.Entry.JournalEntryId, + write.AggregateId, + write.CommandId, + write.SourceEventId, + NormalizeOptional(write.Entry.Metadata.IdempotencyKey)); + } + + private static string? NormalizeOptional(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} + +/// +/// Compatibility lookup for journal stores that predate the optimized collision contract. +/// Aggregate reads still enforce all aggregate-scoped identities and any global identity retained +/// by that aggregate. Durable stores should implement +/// so journal-entry and command identities are checked across every aggregate efficiently. +/// +public static class LedgerPostingIdentityCollisionLookupExtensions +{ + public static async Task> FindPostingIdentityCollisionsAsync( + this ILedgerJournalStore store, + LedgerPostingIdentity identity, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(identity); + if (identity.JournalEntryId == Guid.Empty) + throw new ArgumentException("Journal entry id is required.", nameof(identity)); + if (identity.AggregateId == Guid.Empty) + throw new ArgumentException("Aggregate id is required.", nameof(identity)); + + if (store is ILedgerPostingIdentityCollisionLookup optimized) + { + return await optimized + .FindPostingIdentityCollisionsAsync(identity, ct) + .ConfigureAwait(false); + } + + var retained = await store + .GetByAggregateAsync(identity.AggregateId, ct) + .ConfigureAwait(false); + return retained + .Where(record => IsCollision(record, identity)) + .ToArray(); + } + + internal static bool IsCollision( + LedgerJournalEntryRecord record, + LedgerPostingIdentity identity) + { + if (record.Entry.JournalEntryId == identity.JournalEntryId) + return true; + + if (identity.CommandId.HasValue && record.CommandId == identity.CommandId) + return true; + + if (record.AggregateId != identity.AggregateId) + return false; + + if (identity.SourceEventId.HasValue && record.SourceEventId == identity.SourceEventId) + return true; + + return identity.IdempotencyKey is not null + && string.Equals( + record.Entry.Metadata.IdempotencyKey?.Trim(), + identity.IdempotencyKey, + StringComparison.OrdinalIgnoreCase); + } +} + public sealed record LedgerJournalEntryWrite( JournalEntry Entry, Guid AggregateId, diff --git a/src/Meridian.Storage/Ledger/LedgerPeriodPostingGuard.cs b/src/Meridian.Storage/Ledger/LedgerPeriodPostingGuard.cs index 1c0eea84ec..f6c3577c69 100644 --- a/src/Meridian.Storage/Ledger/LedgerPeriodPostingGuard.cs +++ b/src/Meridian.Storage/Ledger/LedgerPeriodPostingGuard.cs @@ -24,21 +24,26 @@ public static void Validate(LedgerJournalEntryWrite entry, LedgerAccountingPerio if (string.Equals(period.Status, "Open", StringComparison.Ordinal)) { - return; - } + if (entry.PostingKind == LedgerPostingKindDto.ClosingEntry) + { + throw new LedgerValidationException( + $"Accounting period '{period.Label}' is open; ClosingEntry postings are accepted only while the period is soft-closed."); + } - // Period-close closing entries are the sanctioned exception to the closed-period posting - // bar. They are produced only by the governed period-close workflow after human approval, - // and they must post into the period being closed to finalize it (zero temporary accounts, - // roll net income to retained earnings). Their posting date is already constrained to the - // period's date range above, so permit them for both soft- and hard-closed periods. - if (entry.PostingKind == LedgerPostingKindDto.ClosingEntry) - { return; } if (string.Equals(period.Status, "SoftClosed", StringComparison.Ordinal)) { + // Period-close closing entries are the sanctioned soft-close exception. They are + // produced only by the governed period-close workflow after human approval and must + // post before hard close so the hard-close transition remains the final mutation + // boundary for the period. + if (entry.PostingKind == LedgerPostingKindDto.ClosingEntry) + { + return; + } + if (entry.PostingKind == LedgerPostingKindDto.Adjustment) { if (entry.AdjustmentApproval is null) @@ -51,7 +56,7 @@ public static void Validate(LedgerJournalEntryWrite entry, LedgerAccountingPerio } throw new LedgerValidationException( - $"Accounting period '{period.Label}' is soft-closed; only Adjustment postings are accepted."); + $"Accounting period '{period.Label}' is soft-closed; only ClosingEntry and approved Adjustment postings are accepted."); } if (string.Equals(period.Status, "HardClosed", StringComparison.Ordinal)) diff --git a/src/Meridian.Storage/Ledger/Migrations/V_ledger_025__global_posting_command_identity.sql b/src/Meridian.Storage/Ledger/Migrations/V_ledger_025__global_posting_command_identity.sql new file mode 100644 index 0000000000..730a21c125 --- /dev/null +++ b/src/Meridian.Storage/Ledger/Migrations/V_ledger_025__global_posting_command_identity.sql @@ -0,0 +1,5 @@ +drop index if exists __SCHEMA__.ux_journal_entries_aggregate_command; + +create unique index if not exists ux_journal_entries_command + on __SCHEMA__.journal_entries (command_id) + where command_id is not null; diff --git a/src/Meridian.Storage/Ledger/PostgresLedgerBookService.cs b/src/Meridian.Storage/Ledger/PostgresLedgerBookService.cs index b26f94b174..8ae5642513 100644 --- a/src/Meridian.Storage/Ledger/PostgresLedgerBookService.cs +++ b/src/Meridian.Storage/Ledger/PostgresLedgerBookService.cs @@ -347,12 +347,6 @@ public async Task ClosePeriodAsync( ValidateTransition(current, targetStatus); - if (string.Equals(targetStatus, HardClosedStatus, StringComparison.Ordinal)) - { - var preLockFinancials = await BuildFinancialsAsync(current, ct).ConfigureAwait(false); - EnsureTemporaryAccountsAreClosed(current, preLockFinancials); - } - var now = DateTimeOffset.UtcNow; var closeEvent = new PeriodCloseEventRecord( EventId: Guid.NewGuid(), @@ -370,9 +364,25 @@ public async Task ClosePeriodAsync( ? now : current.ClosedAt }; - var saved = await _store - .SavePeriodAsync(updated, current.Version, closeEvent, ct) - .ConfigureAwait(false); + LedgerAccountingPeriod saved; + if (string.Equals(targetStatus, HardClosedStatus, StringComparison.Ordinal)) + { + if (_store is not IAtomicLedgerPeriodCloseStore atomicCloseStore) + { + throw new LedgerBookValidationException( + "Hard-close requires a ledger store that can recheck temporary-account balances and persist the period transition atomically."); + } + + saved = await atomicCloseStore + .SaveHardClosedPeriodAsync(updated, current.Version, closeEvent, ct) + .ConfigureAwait(false); + } + else + { + saved = await _store + .SavePeriodAsync(updated, current.Version, closeEvent, ct) + .ConfigureAwait(false); + } var requiredRole = NormalizeOptional(request.RequiredSignoffRole) ?? "Fund Controller"; var toleranceProfile = NormalizeOptional(request.ToleranceProfileId) ?? "standard-recon-tolerance"; @@ -484,7 +494,6 @@ private static void ValidateTransition(LedgerAccountingPeriod period, string tar var isValid = (period.Status, targetStatus) switch { (OpenStatus, SoftClosedStatus) => true, - (OpenStatus, HardClosedStatus) => true, (SoftClosedStatus, HardClosedStatus) => true, _ => false }; @@ -496,31 +505,6 @@ private static void ValidateTransition(LedgerAccountingPeriod period, string tar } } - private static void EnsureTemporaryAccountsAreClosed( - LedgerAccountingPeriod period, - LedgerPeriodFinancials financials) - { - var residuals = financials.TrialBalance - .Where(static row => - row.Balance != 0m && - (string.Equals(row.AccountType, nameof(LedgerAccountType.Revenue), StringComparison.OrdinalIgnoreCase) || - string.Equals(row.AccountType, nameof(LedgerAccountType.Expense), StringComparison.OrdinalIgnoreCase))) - .OrderBy(static row => row.AccountName, StringComparer.OrdinalIgnoreCase) - .ThenBy(static row => row.FinancialAccountId, StringComparer.OrdinalIgnoreCase) - .ToArray(); - if (residuals.Length == 0) - { - return; - } - - var preview = string.Join( - "; ", - residuals.Take(5).Select(static row => - FormattableString.Invariant($"{row.AccountName}={row.Balance}"))); - throw new LedgerBookValidationException( - $"Accounting period '{period.Label}' cannot be hard-closed while {residuals.Length} revenue/expense balance(s) remain non-zero ({preview}). Post and approve the closing-entry draft before period lock."); - } - private static bool HasScopedRestatementEvidence( IReadOnlyList evidence, LedgerAccountingPeriod period, diff --git a/src/Meridian.Storage/Ledger/PostgresLedgerJournalStore.cs b/src/Meridian.Storage/Ledger/PostgresLedgerJournalStore.cs index 03eac00c0a..0c00eaca19 100644 --- a/src/Meridian.Storage/Ledger/PostgresLedgerJournalStore.cs +++ b/src/Meridian.Storage/Ledger/PostgresLedgerJournalStore.cs @@ -9,7 +9,9 @@ namespace Meridian.Storage.Ledger; -public sealed class PostgresLedgerJournalStore : ITransactionalLedgerJournalStore +public sealed class PostgresLedgerJournalStore : + ITransactionalLedgerJournalStore, + ILedgerPostingIdentityCollisionLookup { private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); private readonly LedgerJournalStoreOptions _options; @@ -215,6 +217,59 @@ public async Task> GetByAggregateAsync(G return await ReadJournalEntriesAsync(command, ct).ConfigureAwait(false); } + public async Task> FindPostingIdentityCollisionsAsync( + LedgerPostingIdentity identity, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(identity); + if (identity.JournalEntryId == Guid.Empty) + throw new ArgumentException("Journal entry id is required.", nameof(identity)); + if (identity.AggregateId == Guid.Empty) + throw new ArgumentException("Aggregate id is required.", nameof(identity)); + + // Deliberately do not apply the ambient tenant read filter here. Journal-entry and command + // ids are database-global write identities, so a cross-tenant collision must fail closed + // rather than reaching the unique constraint as an unexplained append failure. + await using var connection = await OpenConnectionAsync(ct).ConfigureAwait(false); + await using var command = CreateJournalEntryReadCommand(connection); + var predicates = new List + { + "je.journal_entry_id = @identity_journal_entry_id" + }; + command.Parameters.AddWithValue("identity_journal_entry_id", identity.JournalEntryId); + + if (identity.CommandId.HasValue) + { + predicates.Add("je.command_id = @identity_command_id"); + command.Parameters.AddWithValue("identity_command_id", identity.CommandId.Value); + } + + var aggregatePredicates = new List(); + if (identity.SourceEventId.HasValue) + { + aggregatePredicates.Add("je.source_event_id = @identity_source_event_id"); + command.Parameters.AddWithValue("identity_source_event_id", identity.SourceEventId.Value); + } + + if (!string.IsNullOrWhiteSpace(identity.IdempotencyKey)) + { + aggregatePredicates.Add( + "lower(btrim(je.metadata ->> 'idempotencyKey')) = lower(@identity_idempotency_key)"); + command.Parameters.AddWithValue("identity_idempotency_key", identity.IdempotencyKey.Trim()); + } + + if (aggregatePredicates.Count > 0) + { + predicates.Add( + $"(je.aggregate_id = @identity_aggregate_id and ({string.Join(" or ", aggregatePredicates)}))"); + command.Parameters.AddWithValue("identity_aggregate_id", identity.AggregateId); + } + + command.CommandText += $" where ({string.Join(" or ", predicates)})"; + command.CommandText += " order by je.global_sequence, jl.line_no;"; + return await ReadJournalEntriesAsync(command, ct).ConfigureAwait(false); + } + public async Task GetPeriodAsync(Guid periodId, CancellationToken ct = default) { await using var connection = await OpenConnectionAsync(ct).ConfigureAwait(false); @@ -294,6 +349,48 @@ public async Task SavePeriodAsync( long expectedVersion, PeriodCloseEventRecord? closeEvent = null, CancellationToken ct = default) + => await SavePeriodCoreAsync( + period, + expectedVersion, + closeEvent, + requireClosedTemporaryAccounts: false, + ct) + .ConfigureAwait(false); + + public async Task SaveHardClosedPeriodAsync( + LedgerAccountingPeriod period, + long expectedVersion, + PeriodCloseEventRecord closeEvent, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(period); + ArgumentNullException.ThrowIfNull(closeEvent); + if (!string.Equals(period.Status, "HardClosed", StringComparison.Ordinal)) + { + throw new ArgumentException("Atomic hard-close persistence requires a HardClosed target period.", nameof(period)); + } + + if (!_options.EnablePeriodLocking) + { + throw new LedgerValidationException( + "Atomic hard-close persistence requires ledger period row locking to be enabled."); + } + + return await SavePeriodCoreAsync( + period, + expectedVersion, + closeEvent, + requireClosedTemporaryAccounts: true, + ct) + .ConfigureAwait(false); + } + + private async Task SavePeriodCoreAsync( + LedgerAccountingPeriod period, + long expectedVersion, + PeriodCloseEventRecord? closeEvent, + bool requireClosedTemporaryAccounts, + CancellationToken ct) { ArgumentNullException.ThrowIfNull(period); @@ -313,19 +410,31 @@ public async Task SavePeriodAsync( } await using var connection = await OpenConnectionAsync(ct).ConfigureAwait(false); - await using var transaction = await connection.BeginTransactionAsync(IsolationLevel.Serializable, ct).ConfigureAwait(false); + // Hard-close relies on the shared period-row lock for exclusion. ReadCommitted gives the + // balance recheck a fresh snapshot after any earlier append that held the row lock commits; + // using one transaction-wide snapshot here could otherwise hide that just-completed append. + var isolationLevel = requireClosedTemporaryAccounts + ? IsolationLevel.ReadCommitted + : IsolationLevel.Serializable; + await using var transaction = await connection.BeginTransactionAsync(isolationLevel, ct).ConfigureAwait(false); var current = await LoadPeriodAsync( connection, transaction, period.PeriodId, - forUpdate: _options.EnablePeriodLocking, + forUpdate: requireClosedTemporaryAccounts || _options.EnablePeriodLocking, ct: ct) .ConfigureAwait(false); LedgerAccountingPeriod saved; if (current is null) { + if (requireClosedTemporaryAccounts) + { + throw new LedgerBookNotFoundException( + $"Ledger period '{period.PeriodId}' was not found for atomic hard-close."); + } + if (expectedVersion != 0) { throw PeriodVersionConflict(period.PeriodId, expectedVersion, actualVersion: 0); @@ -341,6 +450,16 @@ public async Task SavePeriodAsync( throw PeriodVersionConflict(period.PeriodId, expectedVersion, current.Version); } + if (requireClosedTemporaryAccounts) + { + await EnsureTemporaryAccountsClosedAsync( + connection, + transaction, + current, + ct) + .ConfigureAwait(false); + } + saved = period with { Version = expectedVersion + 1 }; var affected = await UpdatePeriodAsync(connection, transaction, saved, expectedVersion, ct).ConfigureAwait(false); if (affected != 1) @@ -1310,6 +1429,57 @@ private async Task InsertCloseEventAsync( await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } + private async Task EnsureTemporaryAccountsClosedAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + LedgerAccountingPeriod period, + CancellationToken ct) + { + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = + $""" + select jl.account_name, + case + when jl.account_type = 'Revenue' then sum(jl.credit) - sum(jl.debit) + else sum(jl.debit) - sum(jl.credit) + end as balance + from {Qualified("journal_entries")} je + join {Qualified("journal_legs")} jl on jl.journal_entry_id = je.journal_entry_id + where je.period_id = @period_id + and jl.account_type in ('Revenue', 'Expense') + group by jl.account_name, + jl.account_type, + jl.symbol, + jl.financial_account_id, + jl.dimensions + having sum(jl.debit) <> sum(jl.credit) + order by jl.account_name, + jl.financial_account_id, + jl.dimensions::text; + """; + command.Parameters.AddWithValue("period_id", period.PeriodId); + + var residuals = new List<(string AccountName, decimal Balance)>(); + await using var reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false); + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + residuals.Add((reader.GetString(0), reader.GetDecimal(1))); + } + + if (residuals.Count == 0) + { + return; + } + + var preview = string.Join( + "; ", + residuals.Take(5).Select(static row => + FormattableString.Invariant($"{row.AccountName}={row.Balance}"))); + throw new LedgerBookValidationException( + $"Accounting period '{period.Label}' cannot be hard-closed while {residuals.Count} revenue/expense balance(s) remain non-zero ({preview}). Post and approve the closing-entry draft before period lock."); + } + private static void AddPeriodParameters(NpgsqlCommand command, LedgerAccountingPeriod period) { command.Parameters.AddWithValue("period_id", period.PeriodId); diff --git a/src/Meridian.Storage/Services/JsonlPositionSnapshotStore.cs b/src/Meridian.Storage/Services/JsonlPositionSnapshotStore.cs index 34b10b93f0..1a1274470b 100644 --- a/src/Meridian.Storage/Services/JsonlPositionSnapshotStore.cs +++ b/src/Meridian.Storage/Services/JsonlPositionSnapshotStore.cs @@ -11,7 +11,10 @@ namespace Meridian.Storage.Services; /// /// JSONL-backed implementation of . /// -/// Path convention: {StorageRoot}/portfolios/{runId}/{accountId}/snapshots.jsonl +/// Owned path convention: +/// {StorageRoot}/portfolios/owned/{tenant}/{company}/{fund}/{book}/{entity}/{runId}/{accountId}/snapshots.jsonl. +/// Legacy unowned snapshots retain {StorageRoot}/portfolios/{runId}/{accountId}/snapshots.jsonl +/// and are never returned by an owned lookup. /// This path falls under StorageOptions.RootPath so the /// automatically picks it up for tiered-storage /// lifecycle enforcement (ADR-002). Each snapshot is a single JSON line appended @@ -45,7 +48,10 @@ public async Task SaveSnapshotAsync(AccountSnapshotRecord snapshot, Cancellation { ArgumentNullException.ThrowIfNull(snapshot); - var path = GetSnapshotPath(snapshot.RunId, snapshot.AccountId); + var ownerScope = GetOwnerScope(snapshot); + var path = ownerScope is null + ? GetSnapshotPath(snapshot.RunId, snapshot.AccountId) + : GetSnapshotPath(snapshot.RunId, snapshot.AccountId, ownerScope); EnsureDirectory(path); var json = JsonSerializer.Serialize(snapshot, SnapshotJsonContext.Default.AccountSnapshotRecord); @@ -94,6 +100,35 @@ public async Task SaveSnapshotAsync(AccountSnapshotRecord snapshot, Cancellation : TryDeserialize(lastLine); } + /// + public async Task GetLatestSnapshotAsync( + string runId, + string accountId, + PositionSnapshotOwnerScope ownerScope, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(runId); + ArgumentException.ThrowIfNullOrWhiteSpace(accountId); + ArgumentNullException.ThrowIfNull(ownerScope); + ValidateOwnerScope(ownerScope); + + var path = GetSnapshotPath(runId, accountId, ownerScope); + if (!File.Exists(path)) + return null; + + await foreach (var line in ReadLinesReverseAsync(path, ct)) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + + var snapshot = TryDeserialize(line); + if (snapshot is not null && IsOwnedBy(snapshot, ownerScope)) + return snapshot; + } + + return null; + } + /// public async IAsyncEnumerable GetSnapshotHistoryAsync( string runId, @@ -134,6 +169,79 @@ private string GetSnapshotPath(string runId, string accountId) return Path.Combine(_rootPath, "portfolios", safeRunId, safeAccountId, "snapshots.jsonl"); } + private string GetSnapshotPath( + string runId, + string accountId, + PositionSnapshotOwnerScope ownerScope) + { + var safeTenantId = SanitisePathSegment(ownerScope.TenantId.Trim()); + var safeCompanyId = SanitisePathSegment(ownerScope.CompanyId.Trim()); + var safeFundProfileId = SanitisePathSegment(ownerScope.FundProfileId.Trim()); + var safeLedgerBookId = ownerScope.LedgerBookId.ToString("N"); + var safeEntityId = SanitisePathSegment(ownerScope.EntityId.Trim()); + var safeRunId = SanitisePathSegment(runId); + var safeAccountId = SanitisePathSegment(accountId); + return Path.Combine( + _rootPath, + "portfolios", + "owned", + safeTenantId, + safeCompanyId, + safeFundProfileId, + safeLedgerBookId, + safeEntityId, + safeRunId, + safeAccountId, + "snapshots.jsonl"); + } + + private static PositionSnapshotOwnerScope? GetOwnerScope(AccountSnapshotRecord snapshot) + { + var ownershipFields = new[] + { + snapshot.TenantId, + snapshot.CompanyId, + snapshot.FundProfileId, + snapshot.EntityId + }; + var populatedCount = ownershipFields.Count(static value => !string.IsNullOrWhiteSpace(value)); + if (populatedCount == 0 && !snapshot.LedgerBookId.HasValue) + return null; + if (populatedCount != ownershipFields.Length || + !snapshot.LedgerBookId.HasValue || + snapshot.LedgerBookId.Value == Guid.Empty) + { + throw new InvalidOperationException( + "Position snapshot ownership must include tenant, company, fund profile, ledger book, and entity together."); + } + + var ownerScope = new PositionSnapshotOwnerScope( + snapshot.TenantId!.Trim(), + snapshot.CompanyId!.Trim(), + snapshot.FundProfileId!.Trim(), + snapshot.LedgerBookId.Value, + snapshot.EntityId!.Trim()); + ValidateOwnerScope(ownerScope); + return ownerScope; + } + + private static void ValidateOwnerScope(PositionSnapshotOwnerScope ownerScope) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ownerScope.TenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(ownerScope.CompanyId); + ArgumentException.ThrowIfNullOrWhiteSpace(ownerScope.FundProfileId); + ArgumentException.ThrowIfNullOrWhiteSpace(ownerScope.EntityId); + if (ownerScope.LedgerBookId == Guid.Empty) + throw new ArgumentException("Position snapshot ledger-book owner is required.", nameof(ownerScope)); + } + + private static bool IsOwnedBy(AccountSnapshotRecord snapshot, PositionSnapshotOwnerScope ownerScope) + => string.Equals(snapshot.TenantId?.Trim(), ownerScope.TenantId.Trim(), StringComparison.OrdinalIgnoreCase) && + string.Equals(snapshot.CompanyId?.Trim(), ownerScope.CompanyId.Trim(), StringComparison.OrdinalIgnoreCase) && + string.Equals(snapshot.FundProfileId?.Trim(), ownerScope.FundProfileId.Trim(), StringComparison.OrdinalIgnoreCase) && + snapshot.LedgerBookId == ownerScope.LedgerBookId && + string.Equals(snapshot.EntityId?.Trim(), ownerScope.EntityId.Trim(), StringComparison.OrdinalIgnoreCase); + private static void EnsureDirectory(string filePath) { var dir = Path.GetDirectoryName(filePath); diff --git a/src/Meridian.Storage/Services/SymbolRegistryService.cs b/src/Meridian.Storage/Services/SymbolRegistryService.cs index 726b1edede..4510564a91 100644 --- a/src/Meridian.Storage/Services/SymbolRegistryService.cs +++ b/src/Meridian.Storage/Services/SymbolRegistryService.cs @@ -380,6 +380,44 @@ public IEnumerable GetSymbolsByAssetClass(string assetClass .OrderBy(s => s.Canonical); } + public async Task GetMigrationMarkerAsync(string migrationId, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(migrationId); + + await _registryLock.WaitAsync(ct); + try + { + return _registry.MigrationMarkers.TryGetValue(migrationId.Trim(), out var fingerprint) + ? fingerprint + : null; + } + finally + { + _registryLock.Release(); + } + } + + public async Task SetMigrationMarkerAsync( + string migrationId, + string fingerprint, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(migrationId); + ArgumentException.ThrowIfNullOrWhiteSpace(fingerprint); + + await _registryLock.WaitAsync(ct); + try + { + _registry.MigrationMarkers[migrationId.Trim()] = fingerprint.Trim(); + _registry.LastUpdatedAt = DateTime.UtcNow; + await SaveRegistryAsync(ct); + } + finally + { + _registryLock.Release(); + } + } + public async Task SaveRegistryAsync(CancellationToken ct = default) { var json = JsonSerializer.Serialize(_registry, MarketDataJsonContext.Default.SymbolRegistry); diff --git a/src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.JournalAutomation.cs b/src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.JournalAutomation.cs index 08d6c0e045..6f3236b779 100644 --- a/src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.JournalAutomation.cs +++ b/src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.JournalAutomation.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Meridian.Contracts.Api; +using Meridian.Contracts.Ledger; using Meridian.Contracts.Workstation; using Meridian.Ui.Shared.Services; using Microsoft.AspNetCore.Builder; @@ -37,11 +38,11 @@ private static void MapJournalAutomationEndpoints(WebApplication app, JsonSerial item.ScheduleId, scheduleId.Trim(), StringComparison.OrdinalIgnoreCase)) - .Where(item => tenantContext.TenantId is null || string.Equals( + .Where(item => string.Equals( item.TenantId, tenantContext.TenantId, StringComparison.OrdinalIgnoreCase)) - .Where(item => tenantContext.CompanyId is null || string.Equals( + .Where(item => string.Equals( item.CompanyId, tenantContext.CompanyId, StringComparison.OrdinalIgnoreCase)) @@ -77,19 +78,94 @@ private static void MapJournalAutomationEndpoints(WebApplication app, JsonSerial return EndpointHelpers.Forbidden(); } + if (existing?.State == AutomatedJournalScheduleStateDto.Running) + { + return Results.Conflict(new + { + error = $"Automated journal schedule '{existing.ScheduleId}' is Running and cannot be reconfigured until its durable claim completes or resumes." + }); + } + + if (existing is not null && request.Version != existing.Version) + { + return Results.Conflict(new + { + error = $"Automated journal schedule '{existing.ScheduleId}' version is stale. Expected {request.Version}, current {existing.Version}." + }); + } + + if (existing is not null && existing.JournalEntryIds.Count > 0) + { + var draftStore = context.RequestServices.GetService(); + if (draftStore is null) + { + return ServiceUnavailable(); + } + + foreach (var journalEntryId in existing.JournalEntryIds) + { + var draft = await draftStore.GetAsync( + existing.FundProfileId, + journalEntryId, + context.RequestAborted, + existing.TenantId, + existing.CompanyId).ConfigureAwait(false); + if (draft is null || draft.Status is ManualJournalEntryStatusDto.Draft or + ManualJournalEntryStatusDto.NeedsFix or + ManualJournalEntryStatusDto.Submitted or + ManualJournalEntryStatusDto.Approved) + { + return Results.Conflict(new + { + error = $"Automated journal schedule '{existing.ScheduleId}' cannot be re-armed while retained draft '{journalEntryId:D}' is pending. Post or reject the current draft before rearming." + }); + } + } + } + + var actor = ResolveMutationActor(context, request.Actor); + var now = (context.RequestServices.GetService() ?? TimeProvider.System) + .GetUtcNow() + .ToUniversalTime(); + var runHistory = existing?.RunHistory ?? []; + if (existing is not null) + { + var summary = $"Monthly {request.Kind} work was deliberately re-armed by {actor} after configuration review."; + var rearm = new AutomatedJournalScheduleRunHistory( + RunKey: FormattableString.Invariant( + $"rearm|{existing.ScheduleId.Trim().ToLowerInvariant()}|v{existing.Version + 1}|{request.PeriodId.Trim().ToLowerInvariant()}"), + ScheduledForUtc: existing.ScheduledForUtc ?? now, + StartedAtUtc: now, + CompletedAtUtc: now, + State: AutomatedJournalScheduleStateDto.Scheduled, + Summary: summary, + PeriodId: request.PeriodId, + PeriodStart: request.PeriodStart, + PeriodEnd: request.PeriodEnd, + HistoryKind: AutomatedJournalScheduleHistoryKind.Rearm, + Actor: actor, + PreviousVersion: existing.Version, + ResultVersion: checked(existing.Version + 1)); + runHistory = runHistory.Append(rearm).ToArray(); + } + var saved = await store.SaveAsync(request with { - Actor = ResolveMutationActor(context, request.Actor), + Actor = actor, + CreatedBy = existing?.CreatedBy ?? existing?.Actor ?? actor, + LastConfiguredBy = actor, TenantId = tenantContext.TenantId, CompanyId = tenantContext.CompanyId, - State = existing?.State ?? AutomatedJournalScheduleStateDto.Scheduled, + State = AutomatedJournalScheduleStateDto.Scheduled, LastRunAtUtc = existing?.LastRunAtUtc, - LastScheduledForUtc = existing?.LastScheduledForUtc, - JournalEntryIds = existing?.JournalEntryIds ?? [], - LastSummary = existing?.LastSummary, - EvidenceLinks = existing?.EvidenceLinks ?? [], - Blockers = existing?.Blockers ?? [], - RunHistory = existing?.RunHistory ?? [] + LastScheduledForUtc = null, + JournalEntryIds = [], + LastSummary = existing is null + ? $"Monthly {request.Kind} work is scheduled." + : $"Monthly {request.Kind} work was deliberately re-armed by {actor} after configuration review.", + EvidenceLinks = [], + Blockers = [], + RunHistory = runHistory }, context.RequestAborted).ConfigureAwait(false); return Results.Json(saved, jsonOptions); } @@ -97,6 +173,10 @@ private static void MapJournalAutomationEndpoints(WebApplication app, JsonSerial { return Results.BadRequest(new { error = ex.Message }); } + catch (AutomatedJournalScheduleConcurrencyException ex) + { + return Results.Conflict(new { error = ex.Message }); + } catch (InvalidOperationException) { return EndpointHelpers.Forbidden(); @@ -106,6 +186,7 @@ private static void MapJournalAutomationEndpoints(WebApplication app, JsonSerial .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status409Conflict) .Produces(StatusCodes.Status501NotImplemented) .RequireFundScopedWriteTenant() .RequireRateLimiting(UiEndpoints.MutationRateLimitPolicy); @@ -124,7 +205,12 @@ private static void MapJournalAutomationEndpoints(WebApplication app, JsonSerial } var timeProvider = context.RequestServices.GetService() ?? TimeProvider.System; - var result = await worker.RunDueAsync(timeProvider.GetUtcNow(), context.RequestAborted).ConfigureAwait(false); + var tenantContext = HttpContextWorkstationTenantContextAccessor.Resolve(context); + var result = await worker.RunDueForScopeAsync( + timeProvider.GetUtcNow(), + tenantContext.TenantId, + tenantContext.CompanyId, + context.RequestAborted).ConfigureAwait(false); return Results.Json(result, jsonOptions); }) .WithName("RunDueLedgerJournalAutomationMonthlySchedules") @@ -149,11 +235,11 @@ private static void MapJournalAutomationEndpoints(WebApplication app, JsonSerial var tenantContext = HttpContextWorkstationTenantContextAccessor.Resolve(context); var schedules = (await source.ListAsync(context.RequestAborted).ConfigureAwait(false)) - .Where(item => tenantContext.TenantId is null || string.Equals( + .Where(item => string.Equals( item.TenantId, tenantContext.TenantId, StringComparison.OrdinalIgnoreCase)) - .Where(item => tenantContext.CompanyId is null || string.Equals( + .Where(item => string.Equals( item.CompanyId, tenantContext.CompanyId, StringComparison.OrdinalIgnoreCase)) @@ -182,17 +268,57 @@ private static void MapJournalAutomationEndpoints(WebApplication app, JsonSerial { var tenantContext = HttpContextWorkstationTenantContextAccessor.Resolve(context); var existing = await source.GetAsync(request.ScheduleId, context.RequestAborted).ConfigureAwait(false); + if (existing is not null && + (!string.Equals(existing.TenantId, tenantContext.TenantId, StringComparison.OrdinalIgnoreCase) || + !string.Equals(existing.CompanyId, tenantContext.CompanyId, StringComparison.OrdinalIgnoreCase))) + { + return EndpointHelpers.Forbidden(); + } + + if (existing is not null && existing.JournalEntryIds.Count > 0) + { + var draftStore = context.RequestServices.GetService(); + if (draftStore is null) + { + return ServiceUnavailable(); + } + + foreach (var journalEntryId in existing.JournalEntryIds) + { + var draft = await draftStore.GetAsync( + existing.FundProfileId, + journalEntryId, + context.RequestAborted, + existing.TenantId, + existing.CompanyId).ConfigureAwait(false); + if (draft is null || draft.Status is ManualJournalEntryStatusDto.Draft or + ManualJournalEntryStatusDto.NeedsFix or + ManualJournalEntryStatusDto.Submitted or + ManualJournalEntryStatusDto.Approved) + { + return Results.Conflict(new + { + error = $"Daily valuation schedule '{existing.ScheduleId}' cannot be reconfigured while retained batch draft '{journalEntryId:D}' is pending. Post or reject the current batch first." + }); + } + } + } + var saved = await source.SaveAsync(request with { Actor = ResolveMutationActor(context, request.Actor), + CreatedBy = existing?.CreatedBy ?? existing?.Actor ?? ResolveMutationActor(context, request.Actor), + LastConfiguredBy = ResolveMutationActor(context, request.Actor), TenantId = tenantContext.TenantId, CompanyId = tenantContext.CompanyId, State = DailyValuationScheduleStateDto.Scheduled, LastRunAtUtc = existing?.LastRunAtUtc, LastScheduledForUtc = null, - JournalEntryId = existing?.JournalEntryId, + JournalEntryId = null, + JournalEntryIds = [], + BatchCorrelationId = null, LastSummary = $"Daily valuation is scheduled for {request.NextRunAtUtc:O}.", - EvidenceLinks = existing?.EvidenceLinks ?? [], + EvidenceLinks = [], Blockers = [] }, context.RequestAborted).ConfigureAwait(false); return Results.Json(saved, jsonOptions); @@ -201,11 +327,16 @@ private static void MapJournalAutomationEndpoints(WebApplication app, JsonSerial { return Results.BadRequest(new { error = ex.Message }); } + catch (InvalidOperationException) + { + return EndpointHelpers.Forbidden(); + } }) .WithName("ConfigureLedgerJournalAutomationDailyMarkToMarketSchedule") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status409Conflict) .Produces(StatusCodes.Status501NotImplemented) .RequireFundScopedWriteTenant() .RequireRateLimiting(UiEndpoints.MutationRateLimitPolicy); @@ -223,7 +354,13 @@ private static void MapJournalAutomationEndpoints(WebApplication app, JsonSerial return ServiceUnavailable(); } - var result = await worker.RunDueAsync(DateTimeOffset.UtcNow, context.RequestAborted).ConfigureAwait(false); + var tenantContext = HttpContextWorkstationTenantContextAccessor.Resolve(context); + var timeProvider = context.RequestServices.GetService() ?? TimeProvider.System; + var result = await worker.RunDueForScopeAsync( + timeProvider.GetUtcNow(), + tenantContext.TenantId, + tenantContext.CompanyId, + context.RequestAborted).ConfigureAwait(false); return Results.Json(result, jsonOptions); }) .WithName("RunDueLedgerJournalAutomationDailyMarkToMarketSchedules") @@ -233,6 +370,54 @@ private static void MapJournalAutomationEndpoints(WebApplication app, JsonSerial .RequireFundScopedWriteTenant() .RequireRateLimiting(UiEndpoints.MutationRateLimitPolicy); + app.MapPost(UiApiRoutes.LedgerJournalAutomationDailyMarkToMarketBatchLifecycle, async ( + DailyValuationBatchLifecycleRequestDto request, + HttpContext context) => + { + if (!HasLedgerMutationPermission(context)) + { + return EndpointHelpers.Forbidden(); + } + + var service = context.RequestServices.GetService(); + if (service is null) + { + return ServiceUnavailable(); + } + + try + { + var tenantContext = HttpContextWorkstationTenantContextAccessor.Resolve(context); + var result = await service.ApproveAndPostAsync(request with + { + Actor = ResolveMutationActor(context, request.Actor), + TenantId = tenantContext.TenantId, + CompanyId = tenantContext.CompanyId + }, context.RequestAborted).ConfigureAwait(false); + return Results.Json(result, jsonOptions); + } + catch (UnauthorizedAccessException) + { + return EndpointHelpers.Forbidden(); + } + catch (ArgumentException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } + catch (InvalidOperationException ex) + { + return Results.Conflict(new { error = ex.Message }); + } + }) + .WithName("ApproveAndPostLedgerJournalAutomationDailyMarkToMarketBatch") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status409Conflict) + .Produces(StatusCodes.Status501NotImplemented) + .RequireFundScopedWriteTenant() + .RequireRateLimiting(UiEndpoints.MutationRateLimitPolicy); + app.MapPost(UiApiRoutes.LedgerJournalAutomationDailyMarkToMarketIntake, async (RunDailyMarkToMarketDraftIntakeRequest request, HttpContext context) => { if (!HasLedgerMutationPermission(context)) diff --git a/src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs b/src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs index 02de958125..3f99c49b94 100644 --- a/src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs +++ b/src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs @@ -3,6 +3,7 @@ using System.Text; using System.Text.Json; using Meridian.Contracts.Api; +using Meridian.Contracts.Tenancy; using Meridian.Contracts.Workstation; using Meridian.FinancialOperations.AccountingClose; using Meridian.FinancialOperations.Ledger; @@ -235,6 +236,14 @@ public static void MapLedgerEndpoints(this WebApplication app, JsonSerializerOpt try { + if (request.CloseKind != LedgerPeriodCloseKindDto.SoftClose) + { + return Results.BadRequest(new + { + error = "The generic ledger-period endpoint supports soft close only. Use the governed close-management period-lock workflow for hard close." + }); + } + var result = await service .ClosePeriodAsync( periodId, @@ -1060,10 +1069,15 @@ request with return ServiceUnavailable(); } - var result = await service.GetPeriodPlanAsync(workflowId, context.RequestAborted).ConfigureAwait(false); - return result is null + var scope = await ResolveCloseWorkflowTenantScopeAsync(context, service, workflowId).ConfigureAwait(false); + if (!scope.IsAccessible) + { + return CloseWorkflowScopeDenied(); + } + + return scope.Plan is null ? Results.NotFound(new { error = $"Close workflow '{workflowId}' was not found." }) - : Results.Json(result, jsonOptions); + : Results.Json(scope.Plan, jsonOptions); }) .WithName("GetLedgerCloseManagementPeriodPlan") .Produces(StatusCodes.Status200OK) @@ -1088,11 +1102,23 @@ request with try { + var scope = await ResolveCloseWorkflowTenantScopeAsync(context, service, request.WorkflowId).ConfigureAwait(false); + if (!scope.IsAccessible) + { + return CloseWorkflowScopeDenied(); + } + if (scope.Plan is null) + { + return Results.NotFound(new { error = $"Close workflow '{request.WorkflowId}' was not found." }); + } + var actor = ResolveMutationActor(context, request.Actor ?? string.Empty); var result = await service - .ConfigurePeriodPlanAsync( + .ConfigurePeriodPlanScopedAsync( request with { Actor = actor }, actor, + scope.TenantContext.TenantId, + scope.TenantContext.CompanyId, context.RequestAborted) .ConfigureAwait(false); return result is null @@ -1138,11 +1164,23 @@ request with try { + var scope = await ResolveCloseWorkflowTenantScopeAsync(context, service, request.WorkflowId).ConfigureAwait(false); + if (!scope.IsAccessible) + { + return CloseWorkflowScopeDenied(); + } + if (scope.Plan is null) + { + return Results.NotFound(new { error = $"Close workflow '{request.WorkflowId}' was not found." }); + } + var actor = ResolveMutationActor(context, request.RequestedBy); var result = await service - .RequestLateAdjustmentAsync( + .RequestLateAdjustmentScopedAsync( request with { RequestedBy = actor }, actor, + scope.TenantContext.TenantId, + scope.TenantContext.CompanyId, context.RequestAborted) .ConfigureAwait(false); return result is null @@ -1188,11 +1226,23 @@ request with try { + var scope = await ResolveCloseWorkflowTenantScopeAsync(context, service, request.WorkflowId).ConfigureAwait(false); + if (!scope.IsAccessible) + { + return CloseWorkflowScopeDenied(); + } + if (scope.Plan is null) + { + return Results.NotFound(new { error = $"Close workflow '{request.WorkflowId}' was not found." }); + } + var actor = ResolveMutationActor(context, request.Actor); var result = await service - .ReviewLateAdjustmentAsync( + .ReviewLateAdjustmentScopedAsync( request with { Actor = actor }, actor, + scope.TenantContext.TenantId, + scope.TenantContext.CompanyId, context.RequestAborted) .ConfigureAwait(false); return result is null @@ -1238,11 +1288,23 @@ request with try { + var scope = await ResolveCloseWorkflowTenantScopeAsync(context, service, request.WorkflowId).ConfigureAwait(false); + if (!scope.IsAccessible) + { + return CloseWorkflowScopeDenied(); + } + if (scope.Plan is null) + { + return Results.NotFound(new { error = $"Close workflow '{request.WorkflowId}' was not found." }); + } + var actor = ResolveMutationActor(context, request.Actor); var result = await service - .SignOffCloseTaskAsync( + .SignOffCloseTaskScopedAsync( request with { Actor = actor }, actor, + scope.TenantContext.TenantId, + scope.TenantContext.CompanyId, context.RequestAborted) .ConfigureAwait(false); return result is null @@ -1288,11 +1350,23 @@ request with try { + var scope = await ResolveCloseWorkflowTenantScopeAsync(context, service, request.WorkflowId).ConfigureAwait(false); + if (!scope.IsAccessible) + { + return CloseWorkflowScopeDenied(); + } + if (scope.Plan is null) + { + return Results.NotFound(new { error = $"Close workflow '{request.WorkflowId}' was not found." }); + } + var actor = ResolveMutationActor(context, request.Actor); var result = await service - .ReviewCloseEvidenceAsync( + .ReviewCloseEvidenceScopedAsync( request with { Actor = actor }, actor, + scope.TenantContext.TenantId, + scope.TenantContext.CompanyId, context.RequestAborted) .ConfigureAwait(false); return result is null @@ -1338,15 +1412,27 @@ request with try { + var scope = await ResolveCloseWorkflowTenantScopeAsync(context, service, request.WorkflowId).ConfigureAwait(false); + if (!scope.IsAccessible) + { + return CloseWorkflowScopeDenied(); + } + if (scope.Plan is null) + { + return Results.NotFound(new { error = $"Close workflow '{request.WorkflowId}' was not found." }); + } + var actor = ResolveMutationActor(context, request.Actor); var result = await service - .LockClosePeriodAsync( + .LockClosePeriodScopedAsync( request with { Actor = actor, ActionOrigin = OperationsActionOriginDto.HumanOperator }, actor, + scope.TenantContext.TenantId, + scope.TenantContext.CompanyId, context.RequestAborted) .ConfigureAwait(false); return result is null @@ -1375,6 +1461,77 @@ request with .RequireFundScopedWriteTenant() .RequireRateLimiting(UiEndpoints.MutationRateLimitPolicy); + app.MapPost(UiApiRoutes.LedgerCloseManagementPeriodReopen, async ( + ReopenClosePeriodRequestDto request, + HttpContext context) => + { + if (!HasLedgerMutationPermission(context) || + !TryResolveControllerRole(context, out var controllerRole)) + { + return EndpointHelpers.Forbidden(); + } + + var service = ResolveAccountingCloseManagementService(context); + if (service is null) + { + return ServiceUnavailable(); + } + + try + { + var scope = await ResolveCloseWorkflowTenantScopeAsync(context, service, request.WorkflowId).ConfigureAwait(false); + if (!scope.IsAccessible) + { + return CloseWorkflowScopeDenied(); + } + if (scope.Plan is null) + { + return Results.NotFound(new { error = $"Close workflow '{request.WorkflowId}' was not found." }); + } + + var actor = ResolveMutationActor(context, request.Actor); + var result = await service + .ReopenClosePeriodScopedAsync( + request with + { + Actor = actor, + Role = controllerRole + }, + actor, + scope.TenantContext.TenantId, + scope.TenantContext.CompanyId, + context.RequestAborted) + .ConfigureAwait(false); + return result is null + ? Results.NotFound(new { error = $"Close workflow '{request.WorkflowId}' was not found." }) + : Results.Json(result, jsonOptions); + } + catch (ArgumentException ex) + { + return Results.ValidationProblem(new Dictionary + { + ["request"] = [ex.Message] + }); + } + catch (NotSupportedException ex) + { + return Results.Problem(ex.Message, statusCode: StatusCodes.Status501NotImplemented); + } + catch (Exception ex) when (ex is InvalidOperationException or LedgerBookServiceException) + { + return Results.Conflict(new { error = ex.Message }); + } + }) + .WithName("ReopenLedgerCloseManagementPeriod") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict) + .Produces(StatusCodes.Status501NotImplemented) + .RequireFundScopedWriteTenant() + .RequireRateLimiting(UiEndpoints.MutationRateLimitPolicy); + app.MapPost(UiApiRoutes.LedgerReportsAccountingPackage, async ( AccountingReportPackageRequestDto request, HttpContext context) => @@ -2231,6 +2388,114 @@ private static bool HasLedgerReadPermission(HttpContext context) UserPermission.AdminMaintenance, UserPermission.ManageDirectLending); + private static async Task ResolveCloseWorkflowTenantScopeAsync( + HttpContext context, + IAccountingCloseManagementService service, + Guid workflowId) + { + var tenant = HttpContextWorkstationTenantContextAccessor.Resolve(context); + var plan = await service + .GetPeriodPlanScopedAsync(workflowId, tenant.TenantId, tenant.CompanyId, context.RequestAborted) + .ConfigureAwait(false); + if (!tenant.HasTenantScope || + string.IsNullOrWhiteSpace(tenant.TenantId) || + string.IsNullOrWhiteSpace(tenant.CompanyId)) + { + return new CloseWorkflowTenantScope(plan, tenant, IsAccessible: false); + } + + if (plan is null) + { + return new CloseWorkflowTenantScope(plan, tenant, IsAccessible: true); + } + + if (plan.LedgerBookId is not { } ledgerBookId || ledgerBookId == Guid.Empty) + { + return new CloseWorkflowTenantScope(plan, tenant, IsAccessible: false); + } + + var ledgerBookService = context.RequestServices.GetService(); + if (ledgerBookService is null) + { + return new CloseWorkflowTenantScope(plan, tenant, IsAccessible: false); + } + + var registry = context.RequestServices.GetService(); + var guard = context.RequestServices.GetService(); + if (registry is null) + { + return new CloseWorkflowTenantScope(plan, tenant, IsAccessible: false); + } + + try + { + var book = await ledgerBookService.GetBookAsync(ledgerBookId, context.RequestAborted).ConfigureAwait(false); + if (book is null || + !Guid.TryParse(plan.FundProfileId, out var fundAccountId) || + fundAccountId == Guid.Empty || + book.FundStructureNodeId != fundAccountId || + !string.Equals(book.BaseCurrency, plan.MaterialityPolicy.Currency, StringComparison.OrdinalIgnoreCase)) + { + return new CloseWorkflowTenantScope(plan, tenant, IsAccessible: false); + } + + var periods = await ledgerBookService + .ListPeriodsAsync(new LedgerPeriodQuery(LedgerBookId: ledgerBookId), context.RequestAborted) + .ConfigureAwait(false); + var hasPeriodId = Guid.TryParse(plan.PeriodId, out var requestedPeriodId); + if (!periods.Any(period => + (hasPeriodId && period.PeriodId == requestedPeriodId) || + string.Equals(period.Label, plan.PeriodId, StringComparison.OrdinalIgnoreCase))) + { + return new CloseWorkflowTenantScope(plan, tenant, IsAccessible: false); + } + + // FundProfileId on the close plan identifies the operations fund account. The + // authoritative tenant owner is the ledger book's fund profile, after proving that the + // book belongs to that exact fund-account node. + var owner = await registry.ResolveAsync(book.FundProfileId, context.RequestAborted).ConfigureAwait(false); + if (owner is null || !CloseWorkflowOwnerMatches(owner, tenant)) + { + return new CloseWorkflowTenantScope(plan, tenant, IsAccessible: false); + } + + if (guard is not null) + { + var decision = await guard + .EvaluateAsync(tenant, book.FundProfileId, context.RequestAborted) + .ConfigureAwait(false); + if (!decision.IsAllowed) + { + return new CloseWorkflowTenantScope(plan, tenant, IsAccessible: false); + } + } + + return new CloseWorkflowTenantScope(plan, tenant, IsAccessible: true); + } + catch (LedgerBookServiceException) + { + return new CloseWorkflowTenantScope(plan, tenant, IsAccessible: false); + } + } + + private static bool CloseWorkflowOwnerMatches( + FundProfileOwnership owner, + WorkstationTenantContext tenant) + => owner.IsHeldBy(tenant.TenantId) && + !string.IsNullOrWhiteSpace(owner.CompanyId) && + !string.IsNullOrWhiteSpace(tenant.CompanyId) && + string.Equals(owner.CompanyId.Trim(), tenant.CompanyId.Trim(), StringComparison.OrdinalIgnoreCase); + + private static IResult CloseWorkflowScopeDenied() + => Results.Problem( + "The requested close workflow is not accessible to the current tenant and company.", + statusCode: StatusCodes.Status403Forbidden); + + private sealed record CloseWorkflowTenantScope( + ClosePeriodPlanDto? Plan, + WorkstationTenantContext TenantContext, + bool IsAccessible); + /// /// Tenant isolation (SEC-005 slice 3) for body-supplied fund scopes on POST read/preview routes the /// query-string filter cannot see. Returns true (allow) @@ -2264,6 +2529,28 @@ private static bool HasLedgerMutationPermission(HttpContext context) UserPermission.AdminMaintenance, UserPermission.ManageDirectLending); + private static bool TryResolveControllerRole(HttpContext context, out string role) + { + if (context.Items.TryGetValue(LoginSessionMiddleware.CurrentUserRoleKey, out var rawRole) && + rawRole is UserRole userRole && + userRole == UserRole.Controller) + { + role = "Controller"; + return true; + } + + var profile = HttpContextWorkstationTenantContextAccessor.Resolve(context).RoleProfileName; + if (string.Equals(profile, "Controller", StringComparison.OrdinalIgnoreCase) || + string.Equals(profile, "Fund Controller", StringComparison.OrdinalIgnoreCase)) + { + role = profile!.Trim(); + return true; + } + + role = string.Empty; + return false; + } + private static bool HasLedgerCertificationPermission(HttpContext context) => EndpointAuthorization.HasPermission(context, UserPermission.AdminMaintenance); diff --git a/src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs b/src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs index b2d309d818..2968c452b4 100644 --- a/src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs +++ b/src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs @@ -741,8 +741,17 @@ public static void MapWorkstationEndpoints(this WebApplication app, JsonSerializ return Results.Problem("Private-capital close cockpit service is not registered.", statusCode: StatusCodes.Status501NotImplemented); } + var tenantContext = HttpContextWorkstationTenantContextAccessor.Resolve(context); var cockpit = await service - .GetCockpitAsync(fundProfileId, ledgerBookId, fundAccountId, periodId, entityId, context.RequestAborted) + .GetCockpitAsync( + fundProfileId, + ledgerBookId, + fundAccountId, + periodId, + entityId, + context.RequestAborted, + tenantContext.TenantId, + tenantContext.CompanyId) .ConfigureAwait(false); return Results.Json(cockpit, jsonOptions); }) diff --git a/src/Meridian.Ui.Shared/Services/AccountingClosePostingWorkbenchBridge.cs b/src/Meridian.Ui.Shared/Services/AccountingClosePostingWorkbenchBridge.cs index 33dcb4aa2d..d2cd9285eb 100644 --- a/src/Meridian.Ui.Shared/Services/AccountingClosePostingWorkbenchBridge.cs +++ b/src/Meridian.Ui.Shared/Services/AccountingClosePostingWorkbenchBridge.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using Meridian.Contracts.Ledger; using Meridian.Contracts.Workstation; using Meridian.FinancialOperations.AccountingClose; @@ -51,14 +53,19 @@ public async Task EvaluateAsync( try { - var period = await ResolvePeriodAsync(context, ct).ConfigureAwait(false); + var scope = await ResolveScopeAsync(context, ct).ConfigureAwait(false); var preview = await _runner - .PreviewPeriodCloseAsync(ToIntakeRequest(context, period.PeriodId, "close-gate-preview"), ct) + .PreviewPeriodCloseAsync(ToIntakeRequest(context, scope, "close-gate-preview"), ct) .ConfigureAwait(false); var workbench = await _workbench - .GetWorkbenchAsync(context.FundProfileId, context.LedgerBookId, ct) + .GetWorkbenchAsync( + scope.FundProfileId, + context.LedgerBookId, + ct, + context.TenantId, + context.CompanyId) .ConfigureAwait(false); - return BuildGate(context, period.PeriodId, preview, workbench.Drafts); + return BuildGate(context, scope.Period.PeriodId, preview, workbench.Drafts); } catch (InvalidOperationException ex) { @@ -86,9 +93,15 @@ public async Task EnsureClosingDraftQueuedAsync( return before; } - var period = await ResolvePeriodAsync(context, ct).ConfigureAwait(false); + var scope = await ResolveScopeAsync(context, ct).ConfigureAwait(false); + if (scope.Period.Status != LedgerPeriodStatusDto.SoftClosed) + { + throw new InvalidOperationException( + $"Ledger period '{scope.Period.Label}' must be soft-closed before a closing-entry draft can be queued."); + } + await _runner.RunPeriodCloseIntakeAsync( - ToIntakeRequest(context, period.PeriodId, command.Actor), + ToIntakeRequest(context, scope, command.Actor), ct) .ConfigureAwait(false); return await EvaluateAsync(context, ct).ConfigureAwait(false); @@ -101,7 +114,9 @@ public async Task FinalizeHardCloseAsync( { ValidateContext(context); ValidateHumanCommand(command, requireController: false); - var period = await ResolvePeriodAsync(context, ct).ConfigureAwait(false); + var scope = await ResolveScopeAsync(context, ct).ConfigureAwait(false); + var ledgerBookService = _ledgerBookService!; + var period = scope.Period; if (period.Status == LedgerPeriodStatusDto.HardClosed) { return period; @@ -143,7 +158,9 @@ public async Task ReopenAndQueueClosingReversalsAsync( { ValidateContext(context); ValidateHumanCommand(command, requireController: true); - var period = await ResolvePeriodAsync(context, ct).ConfigureAwait(false); + var scope = await ResolveScopeAsync(context, ct).ConfigureAwait(false); + var ledgerBookService = _ledgerBookService!; + var period = scope.Period; if (period.Status is not LedgerPeriodStatusDto.HardClosed and not LedgerPeriodStatusDto.SoftClosed) { throw new InvalidOperationException( @@ -151,26 +168,35 @@ public async Task ReopenAndQueueClosingReversalsAsync( } var workbench = await _workbench - .GetWorkbenchAsync(context.FundProfileId, context.LedgerBookId, ct) + .GetWorkbenchAsync( + scope.FundProfileId, + context.LedgerBookId, + ct, + context.TenantId, + context.CompanyId) .ConfigureAwait(false); - var closingBatches = workbench.Drafts + var retainedClosingBatches = workbench.Drafts .Where(draft => draft.EntryType == ManualJournalEntryTypeDto.ClosingEntry && draft.LedgerBookId == context.LedgerBookId && string.Equals(draft.PeriodId, period.PeriodId.ToString("D"), StringComparison.OrdinalIgnoreCase) && - draft.Status is ManualJournalEntryStatusDto.Posted or ManualJournalEntryStatusDto.Reversed) + draft.Status is ManualJournalEntryStatusDto.Posted + or ManualJournalEntryStatusDto.Reversed + or ManualJournalEntryStatusDto.CloseLocked) .OrderByDescending(static draft => draft.PostedAtUtc) .ThenByDescending(static draft => draft.JournalEntryId) .ToArray(); - if (closingBatches.Length == 0) + var closeLocked = retainedClosingBatches.FirstOrDefault(static batch => + batch.Status == ManualJournalEntryStatusDto.CloseLocked); + if (closeLocked is not null) { throw new InvalidOperationException( - $"Period '{context.PeriodId}' has no retained posted closing batch to reverse."); + $"Closing batch '{closeLocked.JournalEntryId:D}' is close-locked and cannot be reversed until the governed restatement workflow releases that lock."); } var reversalDrafts = workbench.Drafts .Where(draft => draft.ReversalOfJournalEntryId.HasValue && - closingBatches.Any(batch => batch.JournalEntryId == draft.ReversalOfJournalEntryId.Value)) + retainedClosingBatches.Any(batch => batch.JournalEntryId == draft.ReversalOfJournalEntryId.Value)) .GroupBy(static draft => draft.ReversalOfJournalEntryId!.Value) .ToDictionary( static group => group.Key, @@ -178,72 +204,139 @@ public async Task ReopenAndQueueClosingReversalsAsync( .OrderByDescending(static draft => draft.UpdatedAtUtc) .ThenByDescending(static draft => draft.JournalEntryId) .First()); - foreach (var batch in closingBatches) + + // A posted reversal fully unwinds an older closing batch and must not be reversed again on + // a later restatement cycle. A Reversed source with a still-pending reversal is the partial + // state of the current reopen attempt and remains active for retry. + var activeClosingBatches = new List(); + foreach (var batch in retainedClosingBatches) { - if (reversalDrafts.ContainsKey(batch.JournalEntryId)) + if (batch.Status == ManualJournalEntryStatusDto.Posted) { + activeClosingBatches.Add(batch); continue; } - if (batch.Status == ManualJournalEntryStatusDto.Reversed) + if (!reversalDrafts.TryGetValue(batch.JournalEntryId, out var retainedReversal)) { throw new InvalidOperationException( $"Closing batch '{batch.JournalEntryId:D}' is marked Reversed without its retained reversal draft; reopen fails closed."); } - var reversed = await _lifecycle.ApplyLifecycleActionAsync( - new JournalEntryLifecycleActionRequestDto( - batch.JournalEntryId, - context.FundProfileId, - JournalEntryLifecycleActionDto.Reverse, - command.Actor, - batch.Version, - command.Reason, - command.CorrelationId, - command.EvidenceLinks, - command.ActionOrigin, - PeriodIsLocked: false, - LedgerBookId: context.LedgerBookId), - ct) - .ConfigureAwait(false); - var generated = reversed.GeneratedJournalEntries.SingleOrDefault(draft => - draft.ReversalOfJournalEntryId == batch.JournalEntryId) - ?? throw new InvalidOperationException( - $"Reversing closing batch '{batch.JournalEntryId:D}' did not retain a source-linked reversal draft."); - reversalDrafts[batch.JournalEntryId] = generated; + if (retainedReversal.Status is not ManualJournalEntryStatusDto.Posted + and not ManualJournalEntryStatusDto.CloseLocked) + { + activeClosingBatches.Add(batch); + } } - if (reversalDrafts.Count != closingBatches.Length) + var retainedActiveReversals = activeClosingBatches + .Where(batch => reversalDrafts.ContainsKey(batch.JournalEntryId)) + .Select(batch => reversalDrafts[batch.JournalEntryId]) + .ToArray(); + if (retainedActiveReversals.Any(draft => !IsSameReopenReplay(draft, command))) { - throw new InvalidOperationException("Not every retained closing batch has a source-linked reversal draft."); + throw new InvalidOperationException( + "Retained reversal drafts do not match this reopen actor, correlation, reason, and evidence; retry is rejected."); } - if (period.Status == LedgerPeriodStatusDto.SoftClosed) + // Retain the exact reopen intent before changing the durable period. If the ledger reopen + // succeeds but reversal creation is interrupted, the SoftClosed period and this receipt + // form an explicit recoverable state: only an exact command replay may continue. + await RetainReopenIntentAsync( + context, + scope.FundProfileId, + period, + command, + ct) + .ConfigureAwait(false); + + if (period.Status == LedgerPeriodStatusDto.HardClosed) { - if (reversalDrafts.Values.Any(draft => !IsSameReopenReplay(draft, command))) + try + { + await RequireLedgerBookService().ReopenPeriodAsync( + period.PeriodId, + new ReopenLedgerPeriodRequest( + command.Actor, + command.Role!, + command.Reason, + command.ApprovalReference!, + command.EvidenceLinks, + command.ActionOrigin), + ct) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) { throw new InvalidOperationException( - "The ledger period is already soft-closed, but retained reversal drafts do not match this reopen correlation/evidence; retry is rejected."); + $"The governed reopen intent for ledger period '{period.Label}' was retained, but the ledger transition did not complete. Retry the exact reopen command to converge safely.", + ex); } } - else - { - await RequireLedgerBookService().ReopenPeriodAsync( - period.PeriodId, - new ReopenLedgerPeriodRequest( - command.Actor, - command.Role!, - command.Reason, - command.ApprovalReference!, - command.EvidenceLinks, - command.ActionOrigin), - ct) - .ConfigureAwait(false); + + try + { + foreach (var batch in activeClosingBatches) + { + if (reversalDrafts.ContainsKey(batch.JournalEntryId)) + { + continue; + } + + var reversed = await _lifecycle.ApplyLifecycleActionAsync( + new JournalEntryLifecycleActionRequestDto( + batch.JournalEntryId, + scope.FundProfileId, + JournalEntryLifecycleActionDto.Reverse, + command.Actor, + batch.Version, + command.Reason, + command.CorrelationId, + command.EvidenceLinks, + command.ActionOrigin, + PeriodIsLocked: false, + LedgerBookId: context.LedgerBookId, + TenantId: context.TenantId, + CompanyId: context.CompanyId), + ct) + .ConfigureAwait(false); + var generated = reversed.GeneratedJournalEntries.SingleOrDefault(draft => + draft.ReversalOfJournalEntryId == batch.JournalEntryId) + ?? throw new InvalidOperationException( + $"Reversing closing batch '{batch.JournalEntryId:D}' did not retain a source-linked reversal draft."); + reversalDrafts[batch.JournalEntryId] = generated; + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Ledger period '{period.Label}' was reopened under a retained governed intent, but closing-entry reversal drafting did not complete. Retry the exact reopen command to converge the retained source and reversal state.", + ex); + } + + var activeReversals = activeClosingBatches + .Select(batch => reversalDrafts.TryGetValue(batch.JournalEntryId, out var reversal) + ? reversal + : throw new InvalidOperationException( + $"Closing batch '{batch.JournalEntryId:D}' has no retained source-linked reversal draft.")) + .ToArray(); + if (activeReversals.Any(draft => !IsSameReopenReplay(draft, command))) + { + throw new InvalidOperationException( + "Retained reversal drafts do not match this reopen actor, correlation, reason, and evidence; retry is rejected."); } var evaluated = await EvaluateAsync(context, ct).ConfigureAwait(false); - var reversalRows = reversalDrafts.Values.ToArray(); - var pendingCount = reversalRows.Count(static draft => + var pendingCount = activeReversals.Count(static draft => draft.Status is not ManualJournalEntryStatusDto.Posted and not ManualJournalEntryStatusDto.CloseLocked); return evaluated with { @@ -251,28 +344,111 @@ await RequireLedgerBookService().ReopenPeriodAsync( IsReadyForLock = false, Detail = pendingCount > 0 ? $"{pendingCount} governed closing-entry reversal draft(s) await independent approval and posting before restatement can be reclosed." - : "All retained closing-entry reversals are posted; apply the restatement and rerun the closing-entry delta before reclose.", + : activeClosingBatches.Count == 0 + ? "No active retained closing batch requires reversal; apply the restatement and rerun the closing-entry gate before reclose." + : "All active closing-entry reversals are posted; apply the restatement and rerun the closing-entry delta before reclose.", EvidenceLinks = command.EvidenceLinks, - ClosingBatchJournalEntryIds = closingBatches.Select(static draft => draft.JournalEntryId).ToArray(), - ReversalDraftJournalEntryIds = reversalRows.Select(static draft => draft.JournalEntryId).ToArray() + ClosingBatchJournalEntryIds = activeClosingBatches.Select(static draft => draft.JournalEntryId).ToArray(), + ReversalDraftJournalEntryIds = activeReversals.Select(static draft => draft.JournalEntryId).ToArray() }; } + private async Task RetainReopenIntentAsync( + AccountingClosePostingContext context, + string fundProfileId, + LedgerPeriodDto period, + AccountingClosePostingCommand command, + CancellationToken ct) + { + if (_workbench is not ManualJournalEntryWorkbenchService receiptStore) + { + throw new InvalidOperationException( + "A governed period reopen requires the durable accounting audit store to retain an exact-command intent receipt."); + } + + var commandHash = BuildReopenCommandHash(context, period.PeriodId, command); + var retention = await receiptStore.RetainCloseReopenReceiptAsync( + fundProfileId, + context.LedgerBookId, + period.PeriodId, + period.Version, + command.Actor, + command.CorrelationId!, + commandHash, + command.EvidenceLinks, + context.TenantId, + context.CompanyId, + allowCreate: period.Status == LedgerPeriodStatusDto.HardClosed, + ct) + .ConfigureAwait(false); + if (retention == CloseReopenReceiptRetention.Conflict) + { + throw new InvalidOperationException( + "The retained governed reopen intent does not match this actor, correlation, reason, approval, and evidence; retry is rejected."); + } + + if (retention == CloseReopenReceiptRetention.Missing) + { + throw new InvalidOperationException( + "The ledger period is already soft-closed without a retained governed reopen intent; retry fails closed."); + } + } + + private static string BuildReopenCommandHash( + AccountingClosePostingContext context, + Guid ledgerPeriodId, + AccountingClosePostingCommand command) + { + var normalizedEvidence = command.EvidenceLinks + .Where(static link => !string.IsNullOrWhiteSpace(link)) + .Select(static link => link.Trim().ToLowerInvariant()) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal); + var canonical = string.Join( + "|", + context.WorkflowId.ToString("D"), + context.FundAccountId.ToString("D"), + context.LedgerBookId.ToString("D"), + ledgerPeriodId.ToString("D"), + context.TenantId?.Trim().ToLowerInvariant() ?? string.Empty, + context.CompanyId?.Trim().ToLowerInvariant() ?? string.Empty, + command.Actor.Trim().ToLowerInvariant(), + command.Role?.Trim().ToLowerInvariant() ?? string.Empty, + command.Reason.Trim(), + command.ApprovalReference?.Trim().ToLowerInvariant() ?? string.Empty, + command.CorrelationId?.Trim().ToLowerInvariant() ?? string.Empty, + string.Join(';', normalizedEvidence)); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant(); + } + private static bool IsSameReopenReplay( ManualJournalEntryDraftDto reversalDraft, AccountingClosePostingCommand command) { - var hasEvidenceMatch = command.EvidenceLinks.Any(requested => - reversalDraft.EvidenceLinks.Any(retained => - string.Equals(requested, retained, StringComparison.OrdinalIgnoreCase))); - if (!hasEvidenceMatch) + if (string.IsNullOrWhiteSpace(command.CorrelationId)) { return false; } - return string.IsNullOrWhiteSpace(command.CorrelationId) || - reversalDraft.LifecycleTransitions.Any(transition => - string.Equals(transition.CorrelationId, command.CorrelationId, StringComparison.OrdinalIgnoreCase)); + var transition = reversalDraft.LifecycleTransitions.LastOrDefault(item => + item.Action == JournalEntryLifecycleActionDto.Reverse && + string.Equals(item.Actor, command.Actor, StringComparison.OrdinalIgnoreCase) && + string.Equals(item.CorrelationId, command.CorrelationId, StringComparison.OrdinalIgnoreCase) && + string.Equals(item.Notes, command.Reason, StringComparison.Ordinal)); + if (transition is null) + { + return false; + } + + var requestedEvidence = command.EvidenceLinks + .Where(static link => !string.IsNullOrWhiteSpace(link)) + .Select(static link => link.Trim()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var retainedEvidence = transition.EvidenceLinks + .Where(static link => !string.IsNullOrWhiteSpace(link)) + .Select(static link => link.Trim()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + return requestedEvidence.SetEquals(retainedEvidence); } private static ClosePostingGateDto BuildGate( @@ -298,6 +474,7 @@ private static ClosePostingGateDto BuildGate( .Where(static draft => draft.ReversalOfJournalEntryId.HasValue && draft.Status is not ManualJournalEntryStatusDto.Posted and not ManualJournalEntryStatusDto.CloseLocked) + .Where(draft => closingBatches.Contains(draft.ReversalOfJournalEntryId!.Value)) .ToArray(); if (pendingReversals.Length > 0) { @@ -388,14 +565,16 @@ private static IReadOnlyList ToBalances(PeriodCloseDraft private static RunPeriodCloseDraftIntakeRequest ToIntakeRequest( AccountingClosePostingContext context, - Guid ledgerPeriodId, + ResolvedPostingScope scope, string actor) => new( - context.FundProfileId, + scope.FundProfileId, context.Currency, actor, - ledgerPeriodId, - context.LedgerBookId); + scope.Period.PeriodId, + context.LedgerBookId, + TenantId: context.TenantId, + CompanyId: context.CompanyId); private static ClosePostingGateDto Blocked(AccountingClosePostingContext context, string detail) => new( @@ -410,29 +589,56 @@ private static ClosePostingGateDto Blocked(AccountingClosePostingContext context private static string GateId(AccountingClosePostingContext context, Guid? ledgerPeriodId) => $"period-close-posting:{context.LedgerBookId:N}:{ledgerPeriodId?.ToString("N") ?? context.PeriodId.Trim()}"; - private async Task ResolvePeriodAsync( + private async Task ResolveScopeAsync( AccountingClosePostingContext context, CancellationToken ct) { - var periods = await RequireLedgerBookService() + var ledgerBookService = _ledgerBookService + ?? throw new InvalidOperationException( + LedgerUnavailableDetail); + var book = await ledgerBookService.GetBookAsync(context.LedgerBookId, ct).ConfigureAwait(false) + ?? throw new InvalidOperationException( + $"Ledger book '{context.LedgerBookId:D}' was not found for the closing-entry gate."); + if (book.FundStructureNodeId != context.FundAccountId) + { + throw new InvalidOperationException( + $"Close workflow '{context.WorkflowId:D}' fund account '{context.FundAccountId:D}' does not own ledger book '{context.LedgerBookId:D}'."); + } + + if (!string.Equals(book.BaseCurrency, context.Currency, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Close workflow currency '{context.Currency}' does not match ledger book '{context.LedgerBookId:D}' base currency '{book.BaseCurrency}'."); + } + + var periods = await ledgerBookService .ListPeriodsAsync(new LedgerPeriodQuery(LedgerBookId: context.LedgerBookId), ct) .ConfigureAwait(false); var hasGuid = Guid.TryParse(context.PeriodId, out var requestedId); - return periods.FirstOrDefault(period => - (hasGuid && period.PeriodId == requestedId) || - string.Equals(period.Label, context.PeriodId, StringComparison.OrdinalIgnoreCase)) - ?? throw new InvalidOperationException( - $"Ledger period '{context.PeriodId}' was not found in book '{context.LedgerBookId:D}'."); + var period = periods.FirstOrDefault(candidate => + (hasGuid && candidate.PeriodId == requestedId) || + string.Equals(candidate.Label, context.PeriodId, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException( + $"Ledger period '{context.PeriodId}' was not found in book '{context.LedgerBookId:D}'."); + return new ResolvedPostingScope(book.FundProfileId, book, period); } private static void ValidateContext(AccountingClosePostingContext context) { ArgumentNullException.ThrowIfNull(context); - ArgumentException.ThrowIfNullOrWhiteSpace(context.FundProfileId); ArgumentException.ThrowIfNullOrWhiteSpace(context.Currency); - if (context.LedgerBookId == Guid.Empty || string.IsNullOrWhiteSpace(context.PeriodId)) + if (context.WorkflowId == Guid.Empty || + context.FundAccountId == Guid.Empty || + context.LedgerBookId == Guid.Empty || + string.IsNullOrWhiteSpace(context.PeriodId)) + { + throw new ArgumentException("Period-close posting context requires workflow, fund account, ledger book, and period ids.", nameof(context)); + } + + if (string.IsNullOrWhiteSpace(context.TenantId) && + !string.IsNullOrWhiteSpace(context.CompanyId)) { - throw new ArgumentException("Period-close posting context requires ledger book and period ids.", nameof(context)); + throw new ArgumentException("Period-close posting context cannot carry company scope without tenant scope.", nameof(context)); } } @@ -463,6 +669,18 @@ private static void ValidateHumanCommand( } ArgumentException.ThrowIfNullOrWhiteSpace(command.ApprovalReference); + ArgumentException.ThrowIfNullOrWhiteSpace(command.CorrelationId); + if (!command.EvidenceLinks.Any(link => + link.Contains(command.ApprovalReference, StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException( + "Closing-entry reversal evidence must reference the governed reopen approval."); + } } } + + private sealed record ResolvedPostingScope( + string FundProfileId, + LedgerBookDto Book, + LedgerPeriodDto Period); } diff --git a/src/Meridian.Ui.Shared/Services/AutomatedJournalDraftIntakeService.cs b/src/Meridian.Ui.Shared/Services/AutomatedJournalDraftIntakeService.cs index f80df80854..255ed1c8a4 100644 --- a/src/Meridian.Ui.Shared/Services/AutomatedJournalDraftIntakeService.cs +++ b/src/Meridian.Ui.Shared/Services/AutomatedJournalDraftIntakeService.cs @@ -5,6 +5,27 @@ namespace Meridian.Ui.Shared.Services; +internal sealed class DailyValuationPendingDraftException : InvalidOperationException +{ + public DailyValuationPendingDraftException( + IReadOnlyList pendingJournalEntryIds, + ManualJournalEntryStatusDto pendingStatus, + string scope, + string? pendingBatchCorrelationId) + : base( + $"Daily valuation correction for {scope} is blocked while {pendingJournalEntryIds.Count} prior same-day draft(s), including '{pendingJournalEntryIds[0]:D}', remain pending ({pendingStatus}); post or reject the pending batch before creating a corrected mark.") + { + PendingJournalEntryIds = pendingJournalEntryIds; + PendingBatchCorrelationId = string.IsNullOrWhiteSpace(pendingBatchCorrelationId) + ? null + : pendingBatchCorrelationId.Trim(); + } + + public IReadOnlyList PendingJournalEntryIds { get; } + + public string? PendingBatchCorrelationId { get; } +} + /// /// Batch of automated economic events (dividends, interest, fees, withholding) to admit /// into the manual journal workbench queue for the named fund profile. @@ -19,7 +40,8 @@ public sealed record AutomatedJournalDraftIntakeRequest( string? EntityId = null, string? TenantId = null, string? CompanyId = null, - IReadOnlyDictionary? EvidenceAssessments = null); + IReadOnlyDictionary? EvidenceAssessments = null, + string? BatchCorrelationId = null); /// /// Batch of prebuilt automated journal drafts (for example period-close closing entries, @@ -36,15 +58,35 @@ public sealed record AutomatedJournalPreparedDraftIntakeRequest( string? EntityId = null, string? TenantId = null, string? CompanyId = null, - IReadOnlyDictionary? EvidenceAssessments = null); + IReadOnlyDictionary? EvidenceAssessments = null, + string? BatchCorrelationId = null); /// /// One event the intake did not turn into a new draft, with the reason it was skipped. /// +public enum AutomatedJournalDraftIntakeDisposition +{ + ProjectionFailed = 0, + ExistingDraftReady = 1, + ExistingDraftNeedsFix = 2, + ExistingDraftRejected = 3, + ExistingDraftGoverned = 4, + ExistingDraftTerminal = 5, + ExistingDraftReassessmentRequired = 6 +} + public sealed record AutomatedJournalDraftIntakeSkip( Guid JournalEntryId, string IdempotencyKey, - string Reason); + string Reason, + AutomatedJournalDraftIntakeDisposition Disposition = AutomatedJournalDraftIntakeDisposition.ProjectionFailed, + ManualJournalEntryStatusDto? ExistingStatus = null, + AutomatedJournalEvidenceAssessmentDto? ExistingEvidenceAssessment = null) +{ + public bool IsReadyDuplicate => Disposition is + AutomatedJournalDraftIntakeDisposition.ExistingDraftReady or + AutomatedJournalDraftIntakeDisposition.ExistingDraftGoverned; +} /// /// Outcome of an automated journal intake run. Created drafts land in the workbench queue @@ -120,7 +162,8 @@ public async Task IntakeAsync( request.EntityId, request.TenantId, request.CompanyId, - request.EvidenceAssessments), + request.EvidenceAssessments, + BatchCorrelationId: null), skipped, ct).ConfigureAwait(false); } @@ -156,23 +199,35 @@ private async Task IntakeCoreAsync( var chartLookup = ChartAccountLookup.Build(workspace.ChartOfAccounts); var created = new List(); + var existingDrafts = await _draftStore + .ListAsync(request.FundProfileId, request.LedgerBookId, ct, request.TenantId, request.CompanyId) + .ConfigureAwait(false); + EnsureNoPendingDailyValuationCorrections(request, existingDrafts); foreach (var draft in request.Drafts) { ct.ThrowIfCancellationRequested(); var idempotencyKey = draft.Event.IdempotencyKey ?? BuildFallbackIdempotencyKey(draft.Event); - var journalEntryId = BuildDeterministicJournalEntryId(request.FundProfileId, idempotencyKey); + var journalEntryId = BuildDeterministicJournalEntryId(request, idempotencyKey); var existing = await _draftStore .GetAsync(request.FundProfileId, journalEntryId, ct, request.TenantId, request.CompanyId) .ConfigureAwait(false); if (existing is not null) { + var incomingAssessment = request.EvidenceAssessments is not null && + request.EvidenceAssessments.TryGetValue(idempotencyKey, out var candidateAssessment) + ? candidateAssessment + : null; + var disposition = ClassifyExistingDraft(existing, incomingAssessment); skipped.Add(new AutomatedJournalDraftIntakeSkip( journalEntryId, idempotencyKey, - $"Draft already exists with status {existing.Status}.")); + BuildExistingDraftReason(existing, disposition), + disposition, + existing.Status, + existing.AutomationEvidenceAssessment)); continue; } @@ -198,7 +253,9 @@ private async Task IntakeCoreAsync( new SaveManualJournalEntryDraftRequest( dto, Actor: request.Actor, - CorrelationId: idempotencyKey, + CorrelationId: string.IsNullOrWhiteSpace(request.BatchCorrelationId) + ? idempotencyKey + : request.BatchCorrelationId, EvidenceLinks: evidenceLinks, LedgerBookId: request.LedgerBookId, TenantId: request.TenantId, @@ -210,6 +267,130 @@ private async Task IntakeCoreAsync( return new AutomatedJournalDraftIntakeResult(created, skipped); } + private static void EnsureNoPendingDailyValuationCorrections( + AutomatedJournalPreparedDraftIntakeRequest request, + IReadOnlyList existingDrafts) + { + foreach (var draft in request.Drafts.Where(static item => + item.Event.Kind == AutomatedJournalEventKind.FairValueMarkAdjustment)) + { + var idempotencyKey = string.IsNullOrWhiteSpace(draft.Metadata.IdempotencyKey) + ? BuildFallbackIdempotencyKey(draft.Event) + : draft.Metadata.IdempotencyKey.Trim(); + var journalEntryId = BuildDeterministicJournalEntryId(request, idempotencyKey); + if (existingDrafts.Any(existing => existing.JournalEntryId == journalEntryId)) + { + continue; + } + + var effectiveDate = draft.Metadata.EffectiveDate ?? + DateOnly.FromDateTime(draft.Event.Timestamp.UtcDateTime); + var pendingOverlap = existingDrafts.FirstOrDefault(existingDraft => + IsPendingDailyValuationDraft(existingDraft) && + existingDraft.AccountingDate == effectiveDate && + string.Equals(existingDraft.PeriodId, request.PeriodId, StringComparison.OrdinalIgnoreCase) && + string.Equals(existingDraft.EntityId, request.EntityId, StringComparison.OrdinalIgnoreCase) && + HasOverlappingValuationScope(existingDraft, draft)); + if (pendingOverlap is not null) + { + var pendingIds = SelectPendingCorrectionBatchIds( + existingDrafts, + pendingOverlap, + draft, + effectiveDate, + request.PeriodId, + request.EntityId); + throw new DailyValuationPendingDraftException( + pendingIds, + pendingOverlap.Status, + DescribeValuationScope(draft), + pendingOverlap.TreasuryContext?.BatchCorrelationId); + } + } + } + + internal static IReadOnlyList SelectPendingCorrectionBatchIds( + IReadOnlyList existingDrafts, + ManualJournalEntryDraftDto pendingOverlap, + AutomatedJournalDraft candidate, + DateOnly effectiveDate, + string? periodId, + string? entityId) + { + var pendingBatchCorrelationId = NormalizeText( + pendingOverlap.TreasuryContext?.BatchCorrelationId); + return existingDrafts + .Where(IsPendingDailyValuationDraft) + .Where(existingDraft => existingDraft.AccountingDate == effectiveDate) + .Where(existingDraft => string.Equals( + existingDraft.PeriodId, + periodId, + StringComparison.OrdinalIgnoreCase)) + .Where(existingDraft => string.Equals( + existingDraft.EntityId, + entityId, + StringComparison.OrdinalIgnoreCase)) + .Where(existingDraft => pendingBatchCorrelationId is not null + ? string.Equals( + NormalizeText(existingDraft.TreasuryContext?.BatchCorrelationId), + pendingBatchCorrelationId, + StringComparison.OrdinalIgnoreCase) + : HasOverlappingValuationScope(existingDraft, candidate)) + .Select(static existingDraft => existingDraft.JournalEntryId) + .Where(static id => id != Guid.Empty) + .Distinct() + .OrderBy(static id => id) + .ToArray(); + } + + private static bool IsPendingDailyValuationDraft(ManualJournalEntryDraftDto draft) + => draft.TreasuryContext?.IdempotencyKey?.StartsWith("fair-value|", StringComparison.OrdinalIgnoreCase) == true && + draft.Status is ManualJournalEntryStatusDto.Draft or + ManualJournalEntryStatusDto.NeedsFix or + ManualJournalEntryStatusDto.Submitted or + ManualJournalEntryStatusDto.Approved; + + private static bool HasOverlappingValuationScope( + ManualJournalEntryDraftDto existing, + AutomatedJournalDraft candidate) + { + var existingScopes = existing.Lines + .Select(static line => new ValuationScopeKey( + line.SecurityId ?? line.Dimensions?.InstrumentId, + NormalizeOptional(line.LedgerAccountSymbol ?? line.SecurityDisplayName), + NormalizeOptional(line.LedgerAccountFinancialAccountId))) + .Where(static key => key.SecurityId.HasValue || key.Symbol is not null) + .ToHashSet(); + var candidateScopes = candidate.Lines + .Select(line => new ValuationScopeKey( + candidate.Event.SecurityId ?? line.dimensions?.InstrumentId, + NormalizeOptional(line.account.Symbol ?? candidate.Event.Symbol), + NormalizeOptional(line.account.FinancialAccountId))) + .Where(static key => key.SecurityId.HasValue || key.Symbol is not null) + .ToHashSet(); + + if (existingScopes.Count == 0 || candidateScopes.Count == 0) + return true; + + return existingScopes.Overlaps(candidateScopes); + } + + private static string DescribeValuationScope(AutomatedJournalDraft draft) + { + var line = draft.Lines.FirstOrDefault(); + var symbol = NormalizeOptional(line.account?.Symbol) ?? NormalizeOptional(draft.Event.Symbol) ?? "unknown security"; + var account = NormalizeOptional(line.account?.FinancialAccountId) ?? "unscoped account"; + return $"{symbol}/{account}"; + } + + private static string? NormalizeOptional(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToUpperInvariant(); + + private static string? NormalizeText(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private sealed record ValuationScopeKey(Guid? SecurityId, string? Symbol, string? FinancialAccountId); + private static ManualJournalEntryDraftDto BuildDraftDto( AutomatedJournalPreparedDraftIntakeRequest request, AutomatedJournalDraft draft, @@ -265,7 +446,8 @@ private static ManualJournalEntryDraftDto BuildDraftDto( EntryType: MapEntryType(draft.Event.Kind), TreasuryContext: new TreasuryLedgerContextDto( EffectiveDate: effectiveDate, - IdempotencyKey: idempotencyKey), + IdempotencyKey: idempotencyKey, + BatchCorrelationId: NormalizeText(request.BatchCorrelationId)), AutomationEvidenceAssessment: evidenceAssessment); } @@ -291,14 +473,84 @@ private static string BuildFallbackIdempotencyKey(AutomatedJournalEvent journalE $"{journalEvent.Kind}|{journalEvent.Symbol.Trim().ToUpperInvariant()}|{journalEvent.Amount}|{effectiveDate:yyyy-MM-dd}|{journalEvent.FinancialAccountId ?? "-"}"); } - private static Guid BuildDeterministicJournalEntryId(string fundProfileId, string idempotencyKey) + private static Guid BuildDeterministicJournalEntryId( + AutomatedJournalPreparedDraftIntakeRequest request, + string idempotencyKey) { var seed = FormattableString.Invariant( - $"automated-journal|{fundProfileId.Trim().ToLowerInvariant()}|{idempotencyKey.Trim().ToLowerInvariant()}"); + $"automated-journal|tenant={NormalizeIdentity(request.TenantId)}|company={NormalizeIdentity(request.CompanyId)}|fund={NormalizeIdentity(request.FundProfileId)}|book={request.LedgerBookId?.ToString("N") ?? "-"}|entity={NormalizeIdentity(request.EntityId)}|currency={NormalizeIdentity(request.Currency)}|event={idempotencyKey.Trim().ToLowerInvariant()}"); var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed)); return new Guid(hash.AsSpan(0, 16)); } + private static AutomatedJournalDraftIntakeDisposition ClassifyExistingDraft( + ManualJournalEntryDraftDto existing, + AutomatedJournalEvidenceAssessmentDto? incomingAssessment) + { + if (existing.Status == ManualJournalEntryStatusDto.Rejected) + return AutomatedJournalDraftIntakeDisposition.ExistingDraftRejected; + if (existing.Status is ManualJournalEntryStatusDto.Reversed or ManualJournalEntryStatusDto.Rebooked) + return AutomatedJournalDraftIntakeDisposition.ExistingDraftTerminal; + if (existing.Status is ManualJournalEntryStatusDto.Submitted or + ManualJournalEntryStatusDto.Approved or + ManualJournalEntryStatusDto.Posted or + ManualJournalEntryStatusDto.CloseLocked) + { + return AutomatedJournalDraftIntakeDisposition.ExistingDraftGoverned; + } + + if (incomingAssessment is not null && + !EvidenceAssessmentsEquivalent(existing.AutomationEvidenceAssessment, incomingAssessment)) + { + return AutomatedJournalDraftIntakeDisposition.ExistingDraftReassessmentRequired; + } + + if (existing.Status == ManualJournalEntryStatusDto.NeedsFix || + existing.AutomationEvidenceAssessment?.RequiresInvestigation == true || + existing.ValidationIssues.Any(static issue => + issue.Severity == AccountingConfigurationValidationSeverityDto.Critical)) + { + return AutomatedJournalDraftIntakeDisposition.ExistingDraftNeedsFix; + } + + return AutomatedJournalDraftIntakeDisposition.ExistingDraftReady; + } + + private static string BuildExistingDraftReason( + ManualJournalEntryDraftDto existing, + AutomatedJournalDraftIntakeDisposition disposition) + => disposition switch + { + AutomatedJournalDraftIntakeDisposition.ExistingDraftReady => + $"Draft already exists with status {existing.Status} and remains ready for human review.", + AutomatedJournalDraftIntakeDisposition.ExistingDraftGoverned => + $"Draft already exists with governed status {existing.Status}; no duplicate was created.", + AutomatedJournalDraftIntakeDisposition.ExistingDraftNeedsFix => + $"Draft already exists with status {existing.Status} and still requires fixes or evidence investigation.", + AutomatedJournalDraftIntakeDisposition.ExistingDraftRejected => + "Draft already exists with status Rejected and cannot be reported as ready by a scheduler retry.", + AutomatedJournalDraftIntakeDisposition.ExistingDraftTerminal => + $"Draft already exists with terminal correction status {existing.Status} and cannot be reported as ready.", + AutomatedJournalDraftIntakeDisposition.ExistingDraftReassessmentRequired => + "Draft already exists with a different immutable automated-evidence assessment; retain the original assessment and route an explicit reassessment before readiness can change.", + _ => $"Draft already exists with status {existing.Status}." + }; + + private static bool EvidenceAssessmentsEquivalent( + AutomatedJournalEvidenceAssessmentDto? existing, + AutomatedJournalEvidenceAssessmentDto incoming) + => existing is not null && + string.Equals(existing.AssessmentCode, incoming.AssessmentCode, StringComparison.Ordinal) && + existing.ConfidenceScore == incoming.ConfidenceScore && + existing.Quality == incoming.Quality && + existing.RequiresInvestigation == incoming.RequiresInvestigation && + string.Equals(existing.Summary, incoming.Summary, StringComparison.Ordinal) && + existing.Reasons.SequenceEqual(incoming.Reasons, StringComparer.Ordinal) && + existing.EvidenceLinks.SequenceEqual(incoming.EvidenceLinks, StringComparer.Ordinal); + + private static string NormalizeIdentity(string? value) + => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim().ToLowerInvariant(); + /// /// Resolves ledger accounts to chart-of-accounts paths: an exact match on /// name + symbol + financial account wins, then a name-only match, then the raw diff --git a/src/Meridian.Ui.Shared/Services/AutomatedJournalEventProducers.cs b/src/Meridian.Ui.Shared/Services/AutomatedJournalEventProducers.cs index 130d7f3bd1..3e9d093bd6 100644 --- a/src/Meridian.Ui.Shared/Services/AutomatedJournalEventProducers.cs +++ b/src/Meridian.Ui.Shared/Services/AutomatedJournalEventProducers.cs @@ -39,6 +39,7 @@ public sealed record DividendAccrualPosition( /// public sealed record CorporateActionDividendRequest( IReadOnlyList Positions, + string Currency, DateOnly WindowStart, DateOnly WindowEnd, DateTimeOffset AsOf, @@ -67,6 +68,8 @@ public async Task ProduceAsync( ArgumentNullException.ThrowIfNull(request); if (request.Positions.Count == 0) throw new ArgumentException("At least one position is required.", nameof(request)); + if (string.IsNullOrWhiteSpace(request.Currency)) + throw new ArgumentException("Dividend accounting currency is required.", nameof(request)); if (request.WindowEnd < request.WindowStart) throw new ArgumentException("Dividend window end must not precede its start.", nameof(request)); if (request.WithholdingTaxRate is < 0m or >= 1m) @@ -74,6 +77,7 @@ public async Task ProduceAsync( if (request.MinimumEvidenceConfidence is < 0m or > 1m) throw new ArgumentOutOfRangeException(nameof(request), "Minimum evidence confidence must be between 0 and 1."); + var expectedCurrency = request.Currency.Trim().ToUpperInvariant(); var events = new List(); var skipped = new List(); var evidenceAssessments = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -123,6 +127,21 @@ state.Effective.DividendPerShare is > 0m && foreach (var effectiveState in effectiveDividends) { var dividend = effectiveState.Effective; + var actionCurrency = string.IsNullOrWhiteSpace(dividend.Currency) + ? null + : dividend.Currency.Trim().ToUpperInvariant(); + if (!string.Equals(actionCurrency, expectedCurrency, StringComparison.Ordinal)) + { + skipped.Add(new AutomatedJournalEventProductionSkip( + symbol, + actionCurrency is null + ? FormattableString.Invariant( + $"Corporate action {dividend.CorpActId:N} has no currency and cannot enter the exact {expectedCurrency} accounting scope.") + : FormattableString.Invariant( + $"Corporate action {dividend.CorpActId:N} currency {actionCurrency} does not match the exact {expectedCurrency} accounting scope."))); + continue; + } + var currencySuffix = dividend.Currency is null ? "" : " " + dividend.Currency; var amount = decimal.Round( position.Quantity * dividend.DividendPerShare!.Value, 2, MidpointRounding.AwayFromZero); diff --git a/src/Meridian.Ui.Shared/Services/AutomatedJournalEvidencePolicy.cs b/src/Meridian.Ui.Shared/Services/AutomatedJournalEvidencePolicy.cs new file mode 100644 index 0000000000..e72c0c890f --- /dev/null +++ b/src/Meridian.Ui.Shared/Services/AutomatedJournalEvidencePolicy.cs @@ -0,0 +1,187 @@ +using Meridian.Contracts.Ledger; +using Meridian.Contracts.Workstation; + +namespace Meridian.Ui.Shared.Services; + +/// +/// Server-owned bounds for evidence that can support an automated accounting draft. Request +/// thresholds may strengthen these controls, but cannot weaken the confidence floor or variance +/// cap selected by the host. +/// +public sealed record AutomatedJournalEvidencePolicy( + decimal MinimumCapitalAccountConfidence, + decimal MaximumCapitalAccountVarianceTolerance) +{ + public static AutomatedJournalEvidencePolicy Default { get; } = new( + MinimumCapitalAccountConfidence: 0.90m, + MaximumCapitalAccountVarianceTolerance: 0.01m); + + public decimal ResolveMinimumCapitalAccountConfidence(decimal requestedMinimum) + { + if (requestedMinimum is < 0m or > 1m) + { + throw new ArgumentOutOfRangeException( + nameof(requestedMinimum), + "Capital-account confidence threshold must be between 0 and 1."); + } + + if (MinimumCapitalAccountConfidence is < 0m or > 1m) + { + throw new InvalidOperationException( + "The server-owned capital-account confidence policy must be between 0 and 1."); + } + + return Math.Max(requestedMinimum, MinimumCapitalAccountConfidence); + } + + public decimal ResolveMaximumCapitalAccountVarianceTolerance(decimal retainedTolerance) + { + if (retainedTolerance < 0m) + { + throw new ArgumentOutOfRangeException( + nameof(retainedTolerance), + "Capital-account reconciliation tolerance cannot be negative."); + } + + if (MaximumCapitalAccountVarianceTolerance < 0m) + { + throw new InvalidOperationException( + "The server-owned capital-account variance tolerance cannot be negative."); + } + + return Math.Min(retainedTolerance, MaximumCapitalAccountVarianceTolerance); + } +} + +internal sealed record AutomatedJournalFeeEvidenceEvaluation( + bool IsReady, + AutomatedJournalScheduleStateDto FailureState, + AutomatedJournalEvidenceAssessmentDto Assessment, + IReadOnlyList Blockers, + IReadOnlyList EvidenceLinks); + +/// +/// The single fee-basis admission gate used by both direct intake and recurring schedules. +/// Missing evidence blocks; reviewed-but-mismatched or low-confidence evidence requires +/// investigation. The evaluator never trusts a caller-supplied threshold below server policy. +/// +internal static class AutomatedJournalFeeEvidenceEvaluator +{ + public static AutomatedJournalFeeEvidenceEvaluation Evaluate( + string periodId, + string currency, + decimal? beginningNav, + decimal? endingNavBeforeFees, + decimal? highWaterMark, + AutomatedJournalCapitalAccountReconciliationDto? reconciliation, + decimal requestedMinimumConfidence, + DateTimeOffset evaluatedAtUtc, + AutomatedJournalEvidencePolicy policy) + { + ArgumentNullException.ThrowIfNull(policy); + var minimumConfidence = policy.ResolveMinimumCapitalAccountConfidence(requestedMinimumConfidence); + var missing = new List(); + var mismatches = new List(); + if (!beginningNav.HasValue) + missing.Add("Beginning NAV is missing for the fee-accrual cycle."); + if (!endingNavBeforeFees.HasValue) + missing.Add("Ending NAV before fees is missing for the fee-accrual cycle."); + if (!highWaterMark.HasValue) + missing.Add("High-water mark is missing for the fee-accrual cycle."); + if (reconciliation is null) + { + missing.Add("Reviewed capital-account reconciliation evidence is missing for the fee-accrual cycle."); + return Build(false, AutomatedJournalScheduleStateDto.Blocked, 0m, [], missing, mismatches, minimumConfidence, policy.MaximumCapitalAccountVarianceTolerance); + } + + if (reconciliation.EvidenceLinks.Count == 0) + missing.Add("Capital-account reconciliation evidence links are missing."); + if (string.IsNullOrWhiteSpace(reconciliation.SourceVersion)) + missing.Add("Capital-account reconciliation source version is missing."); + if (string.IsNullOrWhiteSpace(reconciliation.ReviewedBy)) + missing.Add("Capital-account reconciliation reviewer is missing."); + if (reconciliation.ReviewedAtUtc == default) + missing.Add("Capital-account reconciliation review time is missing."); + else if (reconciliation.ReviewedAtUtc.ToUniversalTime() > evaluatedAtUtc.ToUniversalTime()) + mismatches.Add("Capital-account reconciliation review time is later than the scheduler evaluation time."); + + if (!string.Equals(reconciliation.PeriodId, periodId, StringComparison.OrdinalIgnoreCase)) + mismatches.Add($"Capital-account reconciliation period '{reconciliation.PeriodId}' does not match schedule period '{periodId}'."); + if (!string.Equals(reconciliation.Currency, currency, StringComparison.OrdinalIgnoreCase)) + mismatches.Add($"Capital-account reconciliation currency '{reconciliation.Currency}' does not match schedule currency '{currency}'."); + if (beginningNav.HasValue && beginningNav.Value != reconciliation.ReconciledBeginningNav) + mismatches.Add("Scheduled beginning NAV does not match the reviewed capital-account reconciliation."); + if (endingNavBeforeFees.HasValue && endingNavBeforeFees.Value != reconciliation.ReconciledEndingNavBeforeFees) + mismatches.Add("Scheduled ending NAV before fees does not match the reviewed capital-account reconciliation."); + if (highWaterMark.HasValue && highWaterMark.Value != reconciliation.ReconciledHighWaterMark) + mismatches.Add("Scheduled high-water mark does not match the reviewed capital-account reconciliation."); + + var maximumObservedVariance = new[] + { + decimal.Abs(reconciliation.ReconciledBeginningNav - reconciliation.CapitalAccountOpeningBalance), + decimal.Abs(reconciliation.ReconciledEndingNavBeforeFees - reconciliation.CapitalAccountEndingBalanceBeforeFees), + decimal.Abs(reconciliation.ReconciledHighWaterMark - reconciliation.CapitalAccountHighWaterMark) + }.Max(); + var effectiveVarianceTolerance = policy.ResolveMaximumCapitalAccountVarianceTolerance( + reconciliation.MaximumVarianceTolerance); + if (!reconciliation.IsReconciled) + mismatches.Add("Capital-account reconciliation is not marked reconciled."); + if (maximumObservedVariance > effectiveVarianceTolerance) + { + mismatches.Add(FormattableString.Invariant( + $"Capital-account reconciliation variance {maximumObservedVariance:0.00} exceeds the server-governed tolerance {effectiveVarianceTolerance:0.00}.")); + } + if (reconciliation.ConfidenceScore < minimumConfidence) + { + mismatches.Add(FormattableString.Invariant( + $"Capital-account reconciliation confidence {reconciliation.ConfidenceScore:P0} is below the server-governed {minimumConfidence:P0} threshold.")); + } + + var ready = missing.Count == 0 && mismatches.Count == 0; + return Build( + ready, + missing.Count > 0 ? AutomatedJournalScheduleStateDto.Blocked : AutomatedJournalScheduleStateDto.NeedsInvestigation, + reconciliation.ConfidenceScore, + reconciliation.EvidenceLinks, + missing, + mismatches, + minimumConfidence, + effectiveVarianceTolerance); + } + + private static AutomatedJournalFeeEvidenceEvaluation Build( + bool isReady, + AutomatedJournalScheduleStateDto failureState, + decimal confidence, + IReadOnlyList evidenceLinks, + IReadOnlyList missing, + IReadOnlyList mismatches, + decimal minimumConfidence, + decimal maximumVarianceTolerance) + { + var blockers = missing.Concat(mismatches).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + var quality = confidence >= 0.90m + ? AutomatedJournalEvidenceQualityDto.High + : confidence >= minimumConfidence + ? AutomatedJournalEvidenceQualityDto.Medium + : AutomatedJournalEvidenceQualityDto.Low; + var summary = isReady + ? FormattableString.Invariant( + $"Capital-account reconciliation confidence {confidence:P0} satisfies the server-governed {minimumConfidence:P0} threshold and the fee basis ties within {maximumVarianceTolerance:0.00}.") + : $"Fee-accrual preparation cannot enter approval: {string.Join(" ", blockers)}"; + var assessment = new AutomatedJournalEvidenceAssessmentDto( + "capital-account-reconciliation-confidence", + confidence, + quality, + RequiresInvestigation: !isReady, + summary, + blockers, + evidenceLinks.Select(static link => link.Route).ToArray()); + return new AutomatedJournalFeeEvidenceEvaluation( + isReady, + failureState, + assessment, + blockers, + evidenceLinks); + } +} diff --git a/src/Meridian.Ui.Shared/Services/AutomatedJournalIntakeRunner.cs b/src/Meridian.Ui.Shared/Services/AutomatedJournalIntakeRunner.cs index ed942641ad..828942d104 100644 --- a/src/Meridian.Ui.Shared/Services/AutomatedJournalIntakeRunner.cs +++ b/src/Meridian.Ui.Shared/Services/AutomatedJournalIntakeRunner.cs @@ -1,5 +1,6 @@ using Meridian.Application.Accounting; using Meridian.Contracts.Ledger; +using Meridian.Contracts.Workstation; using Meridian.Ledger; namespace Meridian.Ui.Shared.Services; @@ -44,7 +45,9 @@ public sealed record RunFeeAccrualDraftIntakeRequest( string? CompanyId = null, DateTimeOffset? AsOf = null, IReadOnlyList? EvidenceLinks = null, - DateTimeOffset? EvidenceRetainedAtUtc = null); + DateTimeOffset? EvidenceRetainedAtUtc = null, + AutomatedJournalCapitalAccountReconciliationDto? CapitalAccountReconciliation = null, + decimal MinimumCapitalAccountConfidence = 0.90m); /// /// Request to project period-close closing entries from a closed ledger period's trial @@ -84,26 +87,39 @@ public sealed record RunDailyMarkToMarketDraftIntakeRequest( bool RequireCompleteCoverage = true, string? EntityId = null, string? TenantId = null, - string? CompanyId = null); + string? CompanyId = null, + string? BatchCorrelationId = null); /// /// Outcome of one automated intake run: producer-side skips plus the intake result /// (created drafts and intake-side skips). Empty productions return an empty intake /// rather than an error. /// +public enum AutomatedJournalIntakeReadiness +{ + Ready = 0, + NeedsInvestigation = 1, + Blocked = 2 +} + public sealed record AutomatedJournalIntakeRunResult( IReadOnlyList ProducerSkips, AutomatedJournalDraftIntakeResult Intake, - IReadOnlyDictionary? EvidenceAssessments = null) + IReadOnlyDictionary? EvidenceAssessments = null, + AutomatedJournalIntakeReadiness Readiness = AutomatedJournalIntakeReadiness.Ready, + IReadOnlyList? ReadinessBlockers = null) { public IReadOnlyDictionary EvidenceAssessments { get; init; } = EvidenceAssessments ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyList ReadinessBlockers { get; init; } = ReadinessBlockers ?? []; } /// Valuation evidence and workbench intake outcome for one daily-close run. public sealed record DailyMarkToMarketIntakeRunResult( DailyMarkToMarketRun Valuation, - AutomatedJournalDraftIntakeResult Intake); + AutomatedJournalDraftIntakeResult Intake, + string? BatchCorrelationId = null); internal sealed record PeriodCloseDraftPreview( LedgerPeriodDto Period, @@ -126,21 +142,31 @@ public sealed class AutomatedJournalIntakeRunner private readonly CorporateActionDividendEventProducer? _dividendProducer; private readonly ILedgerBookService? _ledgerBookService; private readonly DailyMarkToMarketService? _dailyMarkToMarketService; + private readonly DailyValuationPositionService? _dailyValuationPositionService; + private readonly AutomatedJournalEvidencePolicy _evidencePolicy; public AutomatedJournalIntakeRunner( AutomatedJournalDraftIntakeService intake, FeeScheduleAccrualEventProducer feeProducer, CorporateActionDividendEventProducer? dividendProducer = null, ILedgerBookService? ledgerBookService = null, - DailyMarkToMarketService? dailyMarkToMarketService = null) + DailyMarkToMarketService? dailyMarkToMarketService = null, + DailyValuationPositionService? dailyValuationPositionService = null, + AutomatedJournalEvidencePolicy? evidencePolicy = null) { _intake = intake ?? throw new ArgumentNullException(nameof(intake)); _feeProducer = feeProducer ?? throw new ArgumentNullException(nameof(feeProducer)); _dividendProducer = dividendProducer; _ledgerBookService = ledgerBookService; _dailyMarkToMarketService = dailyMarkToMarketService; + _dailyValuationPositionService = dailyValuationPositionService; + _evidencePolicy = evidencePolicy ?? AutomatedJournalEvidencePolicy.Default; } + /// Whether this process can execute the provider-backed daily valuation lane. + public bool CanRunDailyMarkToMarket => + _dailyMarkToMarketService is not null && _dailyValuationPositionService is not null; + public async Task RunDividendIntakeAsync( RunDividendDraftIntakeRequest request, CancellationToken ct = default) @@ -155,6 +181,7 @@ public async Task RunDividendIntakeAsync( var production = await _dividendProducer.ProduceAsync( new CorporateActionDividendRequest( request.Positions, + request.Currency, request.WindowStart, request.WindowEnd, request.AsOf ?? DateTimeOffset.UtcNow, @@ -202,6 +229,20 @@ public async Task RunDailyMarkToMarketIntakeAs throw new ArgumentException("Ledger period id is required.", nameof(request)); if (request.MaximumMarkAgeDays < 0) throw new ArgumentOutOfRangeException(nameof(request), "Maximum mark age cannot be negative."); + if (_dailyValuationPositionService is null) + { + throw new InvalidOperationException( + "Daily mark-to-market intake requires canonical Security Master position resolution, which is not configured."); + } + + var positionResolution = await _dailyValuationPositionService + .ResolveAdHocAsync(request.Positions, request.Currency, request.AsOf, ct) + .ConfigureAwait(false); + if (!positionResolution.IsReady) + { + throw new InvalidOperationException( + $"Daily mark-to-market intake is blocked: {string.Join(" ", positionResolution.Blockers)}"); + } // The scheduled valuation's maximum mark age maps onto the ledger stale-price policy: marks // older than the bound are blocked from the fair-value draft and surfaced for review. The @@ -222,44 +263,81 @@ public async Task RunDailyMarkToMarketIntakeAs request.PeriodId.ToString("D"), request.AsOf, request.Currency, - request.Positions, + positionResolution.Positions, request.Actor, - request.Reason), + request.Reason, + new MarkPriceQualityPolicy( + TimeSpan.FromDays(request.MaximumMarkAgeDays), + request.MinimumConfidence, + request.RequireCompleteCoverage, + RequireObservedDate: true), + request.LedgerBookId), ct).ConfigureAwait(false); - if (valuation.Approval is null) + var batchCorrelationId = BuildDailyValuationBatchCorrelationId( + request, + positionResolution.Positions, + valuation.Approvals, + request.BatchCorrelationId); + + if (valuation.Approvals.Count == 0) { - return new DailyMarkToMarketIntakeRunResult(valuation, EmptyIntake); + return new DailyMarkToMarketIntakeRunResult(valuation, EmptyIntake, batchCorrelationId); } var intake = await _intake.IntakeDraftsAsync( new AutomatedJournalPreparedDraftIntakeRequest( request.FundProfileId, request.Currency, - [valuation.Approval.Draft], + valuation.Approvals.Select(static approval => approval.Draft).ToArray(), request.Actor, request.LedgerBookId, request.PeriodId.ToString("D"), request.EntityId, request.TenantId, - request.CompanyId), + request.CompanyId, + BatchCorrelationId: batchCorrelationId), ct).ConfigureAwait(false); - return new DailyMarkToMarketIntakeRunResult(valuation, intake); + return new DailyMarkToMarketIntakeRunResult(valuation, intake, batchCorrelationId); + } + + private static string BuildDailyValuationBatchCorrelationId( + RunDailyMarkToMarketDraftIntakeRequest request, + IReadOnlyList positions, + IReadOnlyList approvals, + string? requestedCorrelationSeed) + { + var draftRevision = approvals.Count == 0 + ? "no-adjustment" + : string.Join('|', approvals + .Select(static approval => approval.Draft.Metadata.IdempotencyKey) + .Where(static key => !string.IsNullOrWhiteSpace(key)) + .Order(StringComparer.Ordinal)); + var seed = FormattableString.Invariant( + $"daily-valuation-batch|{requestedCorrelationSeed?.Trim() ?? "unseeded"}|{request.FundProfileId.Trim().ToLowerInvariant()}|{request.LedgerBookId:N}|{request.PeriodId:N}|{request.AsOf.ToUniversalTime():O}|{DailyValuationPositionService.ComputeStaticPositionHash(positions)}|{draftRevision}"); + return new Guid(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(seed)).AsSpan(0, 16)) + .ToString("D"); } /// /// Projects closing entries from a closed period's trial balance and admits the - /// resulting draft into the workbench queue. The period must already be soft- or - /// hard-closed: closing entries are the accounting consequence of a close decision, - /// not a way to make one. A period with no temporary-account balances returns an - /// empty intake — a correct outcome, not a gap. + /// resulting draft into the workbench queue. Mutating intake is allowed only while the + /// period is soft-closed; hard-closed periods remain available through the read-only preview + /// path but cannot acquire new drafts. A period with no temporary-account balances returns + /// an empty intake — a correct outcome, not a gap. /// public async Task RunPeriodCloseIntakeAsync( RunPeriodCloseDraftIntakeRequest request, CancellationToken ct = default) { var preview = await PreviewPeriodCloseAsync(request, ct).ConfigureAwait(false); + if (preview.Period.Status != LedgerPeriodStatusDto.SoftClosed) + { + throw new InvalidOperationException( + $"Ledger period '{preview.Period.Label}' must be soft-closed before closing-entry drafts can be queued; current status is {preview.Period.Status}."); + } + var draft = preview.Draft; var intake = draft is null ? EmptyIntake @@ -388,6 +466,33 @@ public async Task RunFeeAccrualIntakeAsync( var eventAsOf = request.AsOf ?? DateTimeOffset.UtcNow; var retainedAtUtc = request.EvidenceRetainedAtUtc ?? eventAsOf; + var feeEvidence = AutomatedJournalFeeEvidenceEvaluator.Evaluate( + request.PeriodId, + request.Currency, + request.BeginningNav, + request.EndingNavBeforeFees, + request.HighWaterMark, + request.CapitalAccountReconciliation, + request.MinimumCapitalAccountConfidence, + retainedAtUtc, + _evidencePolicy); + var feeAssessmentKey = FormattableString.Invariant( + $"fee-basis|{request.FundProfileId.Trim().ToLowerInvariant()}|{request.PeriodId.Trim().ToLowerInvariant()}"); + if (!feeEvidence.IsReady) + { + return new AutomatedJournalIntakeRunResult( + ProducerSkips: [], + Intake: EmptyIntake, + EvidenceAssessments: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [feeAssessmentKey] = feeEvidence.Assessment + }, + Readiness: feeEvidence.FailureState == AutomatedJournalScheduleStateDto.Blocked + ? AutomatedJournalIntakeReadiness.Blocked + : AutomatedJournalIntakeReadiness.NeedsInvestigation, + ReadinessBlockers: feeEvidence.Blockers); + } + var production = _feeProducer.Produce(new FeeScheduleAccrualRequest( request.FundProfileId, request.PeriodId, @@ -399,12 +504,20 @@ public async Task RunFeeAccrualIntakeAsync( request.PerformanceFeeRate)); var events = AttachFeeScheduleEvidence( production.Events, - request.EvidenceLinks, + (request.EvidenceLinks ?? []) + .Concat(feeEvidence.EvidenceLinks.Select(static link => link.Route)) + .ToArray(), retainedAtUtc, request.Actor, request.FundProfileId, request.PeriodId); + var evidenceAssessments = events + .Where(static journalEvent => !string.IsNullOrWhiteSpace(journalEvent.IdempotencyKey)) + .ToDictionary( + static journalEvent => journalEvent.IdempotencyKey!, + _ => feeEvidence.Assessment, + StringComparer.OrdinalIgnoreCase); var intake = events.Count == 0 ? EmptyIntake : await _intake.IntakeAsync( @@ -417,10 +530,16 @@ public async Task RunFeeAccrualIntakeAsync( request.PeriodId, request.EntityId, request.TenantId, - request.CompanyId), + request.CompanyId, + evidenceAssessments), ct).ConfigureAwait(false); - return new AutomatedJournalIntakeRunResult(production.Skipped, intake); + return new AutomatedJournalIntakeRunResult( + production.Skipped, + intake, + evidenceAssessments, + AutomatedJournalIntakeReadiness.Ready, + []); } private static IReadOnlyList AttachFeeScheduleEvidence( diff --git a/src/Meridian.Ui.Shared/Services/AutomatedJournalScheduleStore.cs b/src/Meridian.Ui.Shared/Services/AutomatedJournalScheduleStore.cs index ccb10d286a..963e74f072 100644 --- a/src/Meridian.Ui.Shared/Services/AutomatedJournalScheduleStore.cs +++ b/src/Meridian.Ui.Shared/Services/AutomatedJournalScheduleStore.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Meridian.Contracts.Ledger; using Meridian.Contracts.Workstation; using Meridian.Storage.Store; @@ -13,6 +14,14 @@ public enum AutomatedJournalScheduleKind DividendCapture = 1 } +/// Why a durable schedule-history row was appended. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum AutomatedJournalScheduleHistoryKind +{ + Execution = 0, + Rearm = 1 +} + /// Durable audit entry for one deterministic scheduled execution. public sealed record AutomatedJournalScheduleRunHistory( string RunKey, @@ -23,7 +32,16 @@ public sealed record AutomatedJournalScheduleRunHistory( string Summary, IReadOnlyList? JournalEntryIds = null, IReadOnlyList? EvidenceLinks = null, - IReadOnlyList? Blockers = null) + IReadOnlyList? Blockers = null, + string? PeriodId = null, + DateOnly? PeriodStart = null, + DateOnly? PeriodEnd = null, + decimal? EvidenceConfidenceScore = null, + AutomatedJournalEvidenceQualityDto? EvidenceQuality = null, + AutomatedJournalScheduleHistoryKind HistoryKind = AutomatedJournalScheduleHistoryKind.Execution, + string? Actor = null, + long? PreviousVersion = null, + long? ResultVersion = null) { public IReadOnlyList JournalEntryIds { get; init; } = JournalEntryIds ?? []; @@ -33,9 +51,9 @@ public sealed record AutomatedJournalScheduleRunHistory( } /// -/// Persisted configuration for one explicit monthly fund/book/period/entity/currency scope. -/// Work items are intentionally one-period records: NAV, high-water mark, fee terms, and -/// positions are never silently rolled into a later month. +/// Persisted configuration and durable current-cycle cursor for one recurring monthly +/// fund/book/entity/currency scope. Completed cycles remain immutable in ; +/// fee-basis values and their capital-account reconciliation never roll into a later month. /// public sealed record AutomatedJournalScheduleWorkItem( string ScheduleId, @@ -70,7 +88,15 @@ public sealed record AutomatedJournalScheduleWorkItem( string? LastSummary = null, IReadOnlyList? EvidenceLinks = null, IReadOnlyList? Blockers = null, - IReadOnlyList? RunHistory = null) + IReadOnlyList? RunHistory = null, + bool RecurrenceEnabled = true, + decimal MinimumCapitalAccountConfidence = 0.90m, + AutomatedJournalCapitalAccountReconciliationDto? CapitalAccountReconciliation = null, + string? CreatedBy = null, + string? LastConfiguredBy = null, + decimal? LastEvidenceConfidenceScore = null, + AutomatedJournalEvidenceQualityDto? LastEvidenceQuality = null, + long Version = 0) { public IReadOnlyList Positions { get; init; } = Positions ?? []; @@ -95,6 +121,25 @@ Task SaveAsync( CancellationToken ct = default); } +/// Raised when a schedule save loses an optimistic-concurrency race. +public sealed class AutomatedJournalScheduleConcurrencyException : InvalidOperationException +{ + public AutomatedJournalScheduleConcurrencyException(string scheduleId, long expectedVersion, long actualVersion) + : base( + $"Automated journal schedule '{scheduleId}' version is stale. Expected {expectedVersion}, current {actualVersion}.") + { + ScheduleId = scheduleId; + ExpectedVersion = expectedVersion; + ActualVersion = actualVersion; + } + + public string ScheduleId { get; } + + public long ExpectedVersion { get; } + + public long ActualVersion { get; } +} + /// In-memory source for deterministic tests and lightweight composition. public sealed class InMemoryAutomatedJournalScheduleStore : IAutomatedJournalScheduleStore, @@ -139,12 +184,31 @@ public Task SaveAsync( lock (_gate) { if (_items.TryGetValue(normalized.ScheduleId, out var existing) && - !AutomatedJournalScheduleProjection.HasSameOwnership(existing, normalized)) + !AutomatedJournalScheduleProjection.HasSameImmutableIdentity(existing, normalized)) { throw new InvalidOperationException( - $"Automated journal schedule '{normalized.ScheduleId}' belongs to a different tenant or company scope."); + $"Automated journal schedule '{normalized.ScheduleId}' belongs to a different immutable identity scope."); + } + + if (existing is null) + { + if (normalized.Version != 0) + { + throw new AutomatedJournalScheduleConcurrencyException( + normalized.ScheduleId, + normalized.Version, + actualVersion: 0); + } + } + else if (normalized.Version != existing.Version) + { + throw new AutomatedJournalScheduleConcurrencyException( + normalized.ScheduleId, + normalized.Version, + existing.Version); } + normalized = normalized with { Version = checked((existing?.Version ?? 0) + 1) }; _items[normalized.ScheduleId] = normalized; } @@ -155,12 +219,18 @@ public async Task GetStatusAsync( string? fundProfileId, Guid? ledgerBookId, string? periodId, - CancellationToken ct = default) + CancellationToken ct = default, + string? tenantId = null, + string? companyId = null, + string? entityId = null) => AutomatedJournalScheduleProjection.ProjectStatus( await ListAsync(ct).ConfigureAwait(false), fundProfileId, ledgerBookId, - periodId); + periodId, + tenantId, + companyId, + entityId); } /// Atomic JSON-backed source with schedule state and run history in one snapshot. @@ -219,19 +289,39 @@ public async Task SaveAsync( item.ScheduleId, normalized.ScheduleId, StringComparison.OrdinalIgnoreCase)); - if (existing is not null && !AutomatedJournalScheduleProjection.HasSameOwnership(existing, normalized)) + if (existing is not null && !AutomatedJournalScheduleProjection.HasSameImmutableIdentity(existing, normalized)) { throw new InvalidOperationException( - $"Automated journal schedule '{normalized.ScheduleId}' belongs to a different tenant or company scope."); + $"Automated journal schedule '{normalized.ScheduleId}' belongs to a different immutable identity scope."); } + if (existing is null) + { + if (normalized.Version != 0) + { + throw new AutomatedJournalScheduleConcurrencyException( + normalized.ScheduleId, + normalized.Version, + actualVersion: 0); + } + } + else if (normalized.Version != existing.Version) + { + throw new AutomatedJournalScheduleConcurrencyException( + normalized.ScheduleId, + normalized.Version, + existing.Version); + } + + var versioned = normalized with { Version = checked((existing?.Version ?? 0) + 1) }; + var workItems = snapshot.WorkItems .Where(item => !string.Equals(item.ScheduleId, normalized.ScheduleId, StringComparison.OrdinalIgnoreCase)) - .Append(normalized) + .Append(versioned) .OrderBy(static item => item.ScheduledForUtc) .ThenBy(static item => item.ScheduleId, StringComparer.OrdinalIgnoreCase) .ToArray(); - return (new AutomatedJournalScheduleSnapshot(workItems), normalized); + return (new AutomatedJournalScheduleSnapshot(workItems), versioned); }, ct).ConfigureAwait(false); } @@ -240,12 +330,18 @@ public async Task GetStatusAsync( string? fundProfileId, Guid? ledgerBookId, string? periodId, - CancellationToken ct = default) + CancellationToken ct = default, + string? tenantId = null, + string? companyId = null, + string? entityId = null) => AutomatedJournalScheduleProjection.ProjectStatus( await ListAsync(ct).ConfigureAwait(false), fundProfileId, ledgerBookId, - periodId); + periodId, + tenantId, + companyId, + entityId); public sealed record AutomatedJournalScheduleSnapshot( IReadOnlyList WorkItems); @@ -253,11 +349,17 @@ public sealed record AutomatedJournalScheduleSnapshot( internal static class AutomatedJournalScheduleProjection { - public static bool HasSameOwnership( + public static bool HasSameImmutableIdentity( AutomatedJournalScheduleWorkItem left, AutomatedJournalScheduleWorkItem right) => string.Equals(left.TenantId, right.TenantId, StringComparison.OrdinalIgnoreCase) && - string.Equals(left.CompanyId, right.CompanyId, StringComparison.OrdinalIgnoreCase); + string.Equals(left.CompanyId, right.CompanyId, StringComparison.OrdinalIgnoreCase) && + left.Kind == right.Kind && + string.Equals(left.FundProfileId, right.FundProfileId, StringComparison.OrdinalIgnoreCase) && + left.LedgerBookId == right.LedgerBookId && + string.Equals(left.EntityId, right.EntityId, StringComparison.OrdinalIgnoreCase) && + string.Equals(left.Currency, right.Currency, StringComparison.OrdinalIgnoreCase) && + string.Equals(left.CreatedBy ?? left.Actor, right.CreatedBy ?? right.Actor, StringComparison.OrdinalIgnoreCase); public static AutomatedJournalScheduleWorkItem Normalize(AutomatedJournalScheduleWorkItem item) { @@ -279,12 +381,21 @@ public static AutomatedJournalScheduleWorkItem Normalize(AutomatedJournalSchedul throw new ArgumentOutOfRangeException(nameof(item), "Withholding tax rate must be at least 0 and below 1."); if (item.MinimumCorporateActionConfidence is < 0m or > 1m) throw new ArgumentOutOfRangeException(nameof(item), "Corporate-action confidence threshold must be between 0 and 1."); + if (item.MinimumCapitalAccountConfidence is < 0m or > 1m) + throw new ArgumentOutOfRangeException(nameof(item), "Capital-account confidence threshold must be between 0 and 1."); + var periodToken = item.PeriodStart.ToString("yyyy-MM", System.Globalization.CultureInfo.InvariantCulture); + if (item.RecurrenceEnabled && !periodId.Contains(periodToken, StringComparison.Ordinal)) + { + throw new ArgumentException( + "A recurring automated-journal period id must contain its yyyy-MM period token so the durable cursor can advance deterministically.", + nameof(item)); + } if (item.Kind == AutomatedJournalScheduleKind.FeeAccrual) { - RequireNonNegative(item.BeginningNav, "Beginning NAV"); - RequireNonNegative(item.EndingNavBeforeFees, "Ending NAV before fees"); - RequireNonNegative(item.HighWaterMark, "High-water mark"); + ValidateOptionalNonNegative(item.BeginningNav, "Beginning NAV"); + ValidateOptionalNonNegative(item.EndingNavBeforeFees, "Ending NAV before fees"); + ValidateOptionalNonNegative(item.HighWaterMark, "High-water mark"); RequireRate(item.ManagementFeeRate, "Management fee rate"); RequireRate(item.PerformanceFeeRate, "Performance fee rate"); } @@ -299,6 +410,8 @@ public static AutomatedJournalScheduleWorkItem Normalize(AutomatedJournalSchedul Currency = currency, TimeZoneId = timeZoneId, Actor = actor, + CreatedBy = Require(item.CreatedBy ?? actor, "Schedule creator"), + LastConfiguredBy = Require(item.LastConfiguredBy ?? item.CreatedBy ?? actor, "Last configured by"), TenantId = NormalizeOptional(item.TenantId), CompanyId = NormalizeOptional(item.CompanyId), ScheduledForUtc = scheduledForUtc, @@ -308,7 +421,8 @@ public static AutomatedJournalScheduleWorkItem Normalize(AutomatedJournalSchedul EvidenceLinks = item.EvidenceLinks ?? [], Blockers = item.Blockers ?? [], JournalEntryIds = item.JournalEntryIds ?? [], - RunHistory = item.RunHistory ?? [] + RunHistory = item.RunHistory ?? [], + CapitalAccountReconciliation = NormalizeReconciliation(item.CapitalAccountReconciliation) }; } @@ -316,12 +430,22 @@ public static AutomatedJournalScheduleStatusDto ProjectStatus( IReadOnlyList items, string? fundProfileId, Guid? ledgerBookId, - string? periodId) + string? periodId, + string? tenantId, + string? companyId, + string? entityId) { + var normalizedPeriodId = NormalizeOptional(periodId); + var normalizedTenantId = NormalizeOptional(tenantId); + var normalizedCompanyId = NormalizeOptional(companyId); + var normalizedEntityId = NormalizeOptional(entityId); var scoped = items + .Where(item => string.Equals(item.TenantId, normalizedTenantId, StringComparison.OrdinalIgnoreCase)) + .Where(item => string.Equals(item.CompanyId, normalizedCompanyId, StringComparison.OrdinalIgnoreCase)) .Where(item => string.IsNullOrWhiteSpace(fundProfileId) || string.Equals(item.FundProfileId, fundProfileId.Trim(), StringComparison.OrdinalIgnoreCase)) .Where(item => !ledgerBookId.HasValue || item.LedgerBookId == ledgerBookId.Value) - .Where(item => string.IsNullOrWhiteSpace(periodId) || string.Equals(item.PeriodId, periodId.Trim(), StringComparison.OrdinalIgnoreCase)) + .Where(item => normalizedEntityId is null || string.Equals(item.EntityId, normalizedEntityId, StringComparison.OrdinalIgnoreCase)) + .SelectMany(item => ProjectCycles(item, normalizedPeriodId)) .ToArray(); if (scoped.Length == 0) { @@ -338,30 +462,41 @@ public static AutomatedJournalScheduleStatusDto ProjectStatus( BlockedCount: 0, State: AutomatedJournalScheduleStateDto.NotConfigured, Summary: "Monthly fee-accrual and dividend-capture schedules are not configured for this close scope.", - Blockers: ["Configure explicit monthly fee and dividend work items before relying on automated close preparation."]); + Blockers: ["Configure explicit monthly fee and dividend work items before relying on automated close preparation."], + EntityId: normalizedEntityId, + TenantId: normalizedTenantId, + CompanyId: normalizedCompanyId); } var state = SelectAggregateState(scoped); - var investigationCount = scoped.Count(static item => item.State == AutomatedJournalScheduleStateDto.NeedsInvestigation); - var blockedCount = scoped.Count(static item => item.State is AutomatedJournalScheduleStateDto.Blocked or AutomatedJournalScheduleStateDto.Failed); - var evidence = scoped.SelectMany(static item => item.EvidenceLinks) + var investigationCount = scoped.Count(static cycle => cycle.State == AutomatedJournalScheduleStateDto.NeedsInvestigation); + var blockedCount = scoped.Count(static cycle => cycle.State is AutomatedJournalScheduleStateDto.Blocked or AutomatedJournalScheduleStateDto.Failed); + var evidence = scoped.SelectMany(static cycle => cycle.EvidenceLinks) .DistinctBy(static link => $"{link.EvidenceId}|{link.Route}", StringComparer.OrdinalIgnoreCase) .ToArray(); - var blockers = scoped.SelectMany(static item => item.Blockers) + var blockers = scoped.SelectMany(static cycle => cycle.Blockers) .Where(static blocker => !string.IsNullOrWhiteSpace(blocker)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); - var journalEntryIds = scoped.SelectMany(static item => item.JournalEntryIds) + var journalEntryIds = scoped.SelectMany(static cycle => cycle.JournalEntryIds) .Distinct() .ToArray(); - var distinctFunds = scoped.Select(static item => item.FundProfileId).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); - var distinctBooks = scoped.Select(static item => item.LedgerBookId).Distinct().ToArray(); - var distinctPeriods = scoped.Select(static item => item.PeriodId).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + var confidenceScores = scoped + .Where(static cycle => cycle.EvidenceConfidenceScore.HasValue) + .Select(static cycle => cycle.EvidenceConfidenceScore!.Value) + .ToArray(); + var evidenceQualities = scoped + .Where(static cycle => cycle.EvidenceQuality.HasValue) + .Select(static cycle => cycle.EvidenceQuality!.Value) + .ToArray(); + var distinctFunds = scoped.Select(static cycle => cycle.Item.FundProfileId).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + var distinctBooks = scoped.Select(static cycle => cycle.Item.LedgerBookId).Distinct().ToArray(); + var distinctPeriods = scoped.Select(static cycle => cycle.PeriodId).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); var summary = state switch { AutomatedJournalScheduleStateDto.NeedsInvestigation => $"{investigationCount} monthly automated-journal run(s) need evidence investigation.", AutomatedJournalScheduleStateDto.Blocked or AutomatedJournalScheduleStateDto.Failed => $"{blockedCount} monthly automated-journal run(s) are blocked or failed.", - AutomatedJournalScheduleStateDto.DraftReady => $"{scoped.Count(static item => item.State == AutomatedJournalScheduleStateDto.DraftReady)} monthly run(s) produced governed drafts awaiting human approval.", + AutomatedJournalScheduleStateDto.DraftReady => $"{scoped.Count(static cycle => cycle.State == AutomatedJournalScheduleStateDto.DraftReady)} monthly run(s) produced governed drafts awaiting human approval.", AutomatedJournalScheduleStateDto.Running => "Monthly automated-journal work is running.", AutomatedJournalScheduleStateDto.Scheduled => "Monthly fee-accrual and dividend-capture work is scheduled.", _ => "Monthly automated-journal work completed without a required draft." @@ -372,37 +507,107 @@ public static AutomatedJournalScheduleStatusDto ProjectStatus( ledgerBookId ?? (distinctBooks.Length == 1 ? distinctBooks[0] : null), NormalizeOptional(periodId) ?? (distinctPeriods.Length == 1 ? distinctPeriods[0] : null), scoped.Length, - scoped.Count(static item => item.IsEnabled), - scoped.Count(static item => item.Kind == AutomatedJournalScheduleKind.FeeAccrual), - scoped.Count(static item => item.Kind == AutomatedJournalScheduleKind.DividendCapture), - scoped.Count(static item => item.State == AutomatedJournalScheduleStateDto.DraftReady), + scoped.Count(static cycle => cycle.Item.IsEnabled), + scoped.Count(static cycle => cycle.Item.Kind == AutomatedJournalScheduleKind.FeeAccrual), + scoped.Count(static cycle => cycle.Item.Kind == AutomatedJournalScheduleKind.DividendCapture), + scoped.Count(static cycle => cycle.State == AutomatedJournalScheduleStateDto.DraftReady), investigationCount, blockedCount, state, summary, evidence, blockers, - journalEntryIds); + journalEntryIds, + confidenceScores.Length == 0 ? null : confidenceScores.Min(), + evidenceQualities.Length == 0 ? null : evidenceQualities.Min(), + journalEntryIds.Length, + normalizedEntityId, + normalizedTenantId, + normalizedCompanyId); } private static AutomatedJournalScheduleStateDto SelectAggregateState( - IReadOnlyList items) + IReadOnlyList cycles) { - if (items.Any(static item => item.State == AutomatedJournalScheduleStateDto.NeedsInvestigation)) + if (cycles.Any(static cycle => cycle.State == AutomatedJournalScheduleStateDto.NeedsInvestigation)) return AutomatedJournalScheduleStateDto.NeedsInvestigation; - if (items.Any(static item => item.State == AutomatedJournalScheduleStateDto.Failed)) + if (cycles.Any(static cycle => cycle.State == AutomatedJournalScheduleStateDto.Failed)) return AutomatedJournalScheduleStateDto.Failed; - if (items.Any(static item => item.State == AutomatedJournalScheduleStateDto.Blocked)) + if (cycles.Any(static cycle => cycle.State == AutomatedJournalScheduleStateDto.Blocked)) return AutomatedJournalScheduleStateDto.Blocked; - if (items.Any(static item => item.State == AutomatedJournalScheduleStateDto.Running)) + if (cycles.Any(static cycle => cycle.State == AutomatedJournalScheduleStateDto.Running)) return AutomatedJournalScheduleStateDto.Running; - if (items.Any(static item => item.State == AutomatedJournalScheduleStateDto.DraftReady)) + if (cycles.Any(static cycle => cycle.State == AutomatedJournalScheduleStateDto.DraftReady)) return AutomatedJournalScheduleStateDto.DraftReady; - if (items.Any(static item => item.State == AutomatedJournalScheduleStateDto.Scheduled)) + if (cycles.Any(static cycle => cycle.State == AutomatedJournalScheduleStateDto.Scheduled)) return AutomatedJournalScheduleStateDto.Scheduled; return AutomatedJournalScheduleStateDto.NoDraftRequired; } + private static IEnumerable ProjectCycles( + AutomatedJournalScheduleWorkItem item, + string? periodId) + { + if (periodId is null) + { + yield return CurrentCycle(item); + yield break; + } + + var currentMatches = string.Equals(item.PeriodId, periodId, StringComparison.OrdinalIgnoreCase); + var currentIsRearmed = currentMatches && + item.State == AutomatedJournalScheduleStateDto.Scheduled && + item.LastScheduledForUtc != item.ScheduledForUtc; + if (currentIsRearmed) + { + yield return CurrentCycle(item); + yield break; + } + + var history = item.RunHistory + .Where(static entry => entry.HistoryKind == AutomatedJournalScheduleHistoryKind.Execution) + .Where(entry => string.Equals(entry.PeriodId ?? item.PeriodId, periodId, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(static entry => entry.ScheduledForUtc) + .FirstOrDefault(); + if (history is not null) + { + yield return new AutomatedJournalScheduleCycleView( + item, + history.PeriodId ?? periodId, + history.State, + history.EvidenceLinks, + history.Blockers, + history.JournalEntryIds, + history.EvidenceConfidenceScore, + history.EvidenceQuality); + yield break; + } + + if (currentMatches) + yield return CurrentCycle(item); + } + + private static AutomatedJournalScheduleCycleView CurrentCycle(AutomatedJournalScheduleWorkItem item) + => new( + item, + item.PeriodId, + item.State, + item.EvidenceLinks, + item.Blockers, + item.JournalEntryIds, + item.LastEvidenceConfidenceScore, + item.LastEvidenceQuality); + + private sealed record AutomatedJournalScheduleCycleView( + AutomatedJournalScheduleWorkItem Item, + string PeriodId, + AutomatedJournalScheduleStateDto State, + IReadOnlyList EvidenceLinks, + IReadOnlyList Blockers, + IReadOnlyList JournalEntryIds, + decimal? EvidenceConfidenceScore, + AutomatedJournalEvidenceQualityDto? EvidenceQuality); + private static DateTimeOffset ResolveDueAtUtc(DateOnly dueDate, TimeOnly dueTime, string timeZoneId) { TimeZoneInfo zone; @@ -435,11 +640,42 @@ private static string Require(string? value, string label) private static string? NormalizeOptional(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - private static void RequireNonNegative(decimal? value, string label) + private static AutomatedJournalCapitalAccountReconciliationDto? NormalizeReconciliation( + AutomatedJournalCapitalAccountReconciliationDto? reconciliation) { - if (!value.HasValue) - throw new ArgumentException($"{label} is required for fee-accrual schedules."); - if (value.Value < 0m) + if (reconciliation is null) + return null; + if (reconciliation.ConfidenceScore is < 0m or > 1m) + throw new ArgumentOutOfRangeException(nameof(reconciliation), "Capital-account reconciliation confidence must be between 0 and 1."); + if (reconciliation.MaximumVarianceTolerance < 0m) + throw new ArgumentOutOfRangeException(nameof(reconciliation), "Capital-account reconciliation tolerance cannot be negative."); + if (reconciliation.ReconciledBeginningNav < 0m || + reconciliation.ReconciledEndingNavBeforeFees < 0m || + reconciliation.ReconciledHighWaterMark < 0m || + reconciliation.CapitalAccountOpeningBalance < 0m || + reconciliation.CapitalAccountEndingBalanceBeforeFees < 0m || + reconciliation.CapitalAccountHighWaterMark < 0m) + { + throw new ArgumentOutOfRangeException(nameof(reconciliation), "Capital-account reconciliation balances cannot be negative."); + } + + return reconciliation with + { + ReconciliationId = Require(reconciliation.ReconciliationId, "Capital-account reconciliation id"), + PeriodId = Require(reconciliation.PeriodId, "Capital-account reconciliation period id"), + Currency = Require(reconciliation.Currency, "Capital-account reconciliation currency").ToUpperInvariant(), + SourceVersion = Require(reconciliation.SourceVersion, "Capital-account reconciliation source version"), + ReviewedBy = Require(reconciliation.ReviewedBy, "Capital-account reconciliation reviewer"), + EvidenceLinks = reconciliation.EvidenceLinks + .Where(static link => !string.IsNullOrWhiteSpace(link.Route)) + .DistinctBy(static link => $"{link.EvidenceId}|{link.Route}", StringComparer.OrdinalIgnoreCase) + .ToArray() + }; + } + + private static void ValidateOptionalNonNegative(decimal? value, string label) + { + if (value is < 0m) throw new ArgumentOutOfRangeException(label, $"{label} cannot be negative."); } diff --git a/src/Meridian.Ui.Shared/Services/AutomatedJournalScheduledWorker.cs b/src/Meridian.Ui.Shared/Services/AutomatedJournalScheduledWorker.cs index a281e3661b..7af80416b9 100644 --- a/src/Meridian.Ui.Shared/Services/AutomatedJournalScheduledWorker.cs +++ b/src/Meridian.Ui.Shared/Services/AutomatedJournalScheduledWorker.cs @@ -1,6 +1,7 @@ using Meridian.Contracts.Api; using Meridian.Contracts.Ledger; using Meridian.Contracts.Workstation; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -13,50 +14,83 @@ public sealed record AutomatedJournalScheduledRunResult( AutomatedJournalScheduleStateDto State, string Summary, IReadOnlyList JournalEntryIds, - IReadOnlyList Blockers); + IReadOnlyList Blockers, + decimal? EvidenceConfidenceScore = null, + AutomatedJournalEvidenceQualityDto? EvidenceQuality = null, + string? NextPeriodId = null, + DateTimeOffset? NextScheduledForUtc = null); public sealed record AutomatedJournalScheduledBatchResult( DateTimeOffset EvaluatedAtUtc, IReadOnlyList Runs); /// -/// Deterministic one-shot worker for due monthly fee and dividend work. It only invokes -/// automated intake, which writes to the existing manual journal workbench; it never -/// submits, approves, or posts a journal entry. +/// Deterministic recurring worker for due monthly fee and dividend work. A completed cycle +/// advances the durable period cursor atomically while retaining its run history. The worker +/// only invokes automated intake into the existing manual journal workbench; it never submits, +/// approves, or posts a journal entry. /// public sealed class AutomatedJournalScheduledWorker { private readonly IAutomatedJournalScheduleStore _store; private readonly AutomatedJournalIntakeRunner _intakeRunner; private readonly ILogger _logger; + private readonly AutomatedJournalEvidencePolicy _evidencePolicy; private readonly SemaphoreSlim _runGate = new(1, 1); public AutomatedJournalScheduledWorker( IAutomatedJournalScheduleStore store, AutomatedJournalIntakeRunner intakeRunner, - ILogger logger) + ILogger logger, + AutomatedJournalEvidencePolicy? evidencePolicy = null) { _store = store ?? throw new ArgumentNullException(nameof(store)); _intakeRunner = intakeRunner ?? throw new ArgumentNullException(nameof(intakeRunner)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _evidencePolicy = evidencePolicy ?? AutomatedJournalEvidencePolicy.Default; } public async Task RunDueAsync( DateTimeOffset nowUtc, CancellationToken ct = default) + => await RunDueCoreAsync(nowUtc, tenantId: null, companyId: null, scopeSpecified: false, ct: ct) + .ConfigureAwait(false); + + /// + /// Runs only schedules owned by the exact tenant/company scope. Null values are treated as + /// the legacy unscoped identity, not as wildcards, so an unscoped operator cannot trigger + /// another tenant's accounting automation. + /// + public async Task RunDueForScopeAsync( + DateTimeOffset nowUtc, + string? tenantId, + string? companyId, + CancellationToken ct = default) + => await RunDueCoreAsync(nowUtc, NormalizeScope(tenantId), NormalizeScope(companyId), scopeSpecified: true, ct: ct) + .ConfigureAwait(false); + + private async Task RunDueCoreAsync( + DateTimeOffset nowUtc, + string? tenantId, + string? companyId, + bool scopeSpecified, + CancellationToken ct) { nowUtc = nowUtc.ToUniversalTime(); await _runGate.WaitAsync(ct).ConfigureAwait(false); try { var due = (await _store.ListAsync(ct).ConfigureAwait(false)) + .Where(item => !scopeSpecified || + (string.Equals(item.TenantId, tenantId, StringComparison.OrdinalIgnoreCase) && + string.Equals(item.CompanyId, companyId, StringComparison.OrdinalIgnoreCase))) .Where(static item => item.IsEnabled) .Where(static item => item.ScheduledForUtc.HasValue) .Where(item => item.ScheduledForUtc!.Value <= nowUtc) .Where(item => - item.State == AutomatedJournalScheduleStateDto.Running || + IsIncompleteRunningClaim(item) || item.LastScheduledForUtc != item.ScheduledForUtc) - .Where(item => item.State is AutomatedJournalScheduleStateDto.Scheduled or AutomatedJournalScheduleStateDto.Running) + .Where(item => item.State == AutomatedJournalScheduleStateDto.Scheduled || IsIncompleteRunningClaim(item)) .OrderBy(static item => item.ScheduledForUtc) .ThenBy(static item => item.ScheduleId, StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -92,7 +126,10 @@ private async Task RunWorkItemAsync( existingRun?.StartedAtUtc ?? nowUtc, CompletedAtUtc: null, State: AutomatedJournalScheduleStateDto.Running, - Summary: $"Monthly {item.Kind} schedule '{item.ScheduleId}' is running for {scheduledForUtc:O}."); + Summary: $"Monthly {item.Kind} schedule '{item.ScheduleId}' is running for {scheduledForUtc:O}.", + PeriodId: item.PeriodId, + PeriodStart: item.PeriodStart, + PeriodEnd: item.PeriodEnd); var running = item with { State = AutomatedJournalScheduleStateDto.Running, @@ -106,6 +143,35 @@ private async Task RunWorkItemAsync( try { + if (item.Kind == AutomatedJournalScheduleKind.FeeAccrual) + { + var feeEvidence = AutomatedJournalFeeEvidenceEvaluator.Evaluate( + item.PeriodId, + item.Currency, + item.BeginningNav, + item.EndingNavBeforeFees, + item.HighWaterMark, + item.CapitalAccountReconciliation, + item.MinimumCapitalAccountConfidence, + nowUtc, + _evidencePolicy); + if (!feeEvidence.IsReady) + { + return await CompleteAsync( + running, + runKey, + nowUtc, + feeEvidence.FailureState, + feeEvidence.Assessment.Summary, + [], + BuildScheduleEvidenceLinks(running, feeEvidence.EvidenceLinks, nowUtc), + feeEvidence.Blockers, + feeEvidence.Assessment.ConfidenceScore, + feeEvidence.Assessment.Quality, + ct).ConfigureAwait(false); + } + } + if (item.Kind == AutomatedJournalScheduleKind.DividendCapture && item.Positions.Count == 0) { const string blocker = "No positions are configured for the monthly dividend-capture scope."; @@ -118,6 +184,8 @@ private async Task RunWorkItemAsync( [], [], [blocker], + null, + null, ct).ConfigureAwait(false); } @@ -149,6 +217,8 @@ private async Task RunWorkItemAsync( [], [], [blocker], + null, + null, ct).ConfigureAwait(false); } } @@ -177,7 +247,9 @@ private Task RunFeeAccrualAsync( [ $"{UiApiRoutes.LedgerJournalAutomationMonthlySchedules}?scheduleId={Uri.EscapeDataString(item.ScheduleId)}" ], - EvidenceRetainedAtUtc: scheduledForUtc), + EvidenceRetainedAtUtc: scheduledForUtc, + CapitalAccountReconciliation: item.CapitalAccountReconciliation, + MinimumCapitalAccountConfidence: item.MinimumCapitalAccountConfidence), ct); private Task RunDividendCaptureAsync( @@ -209,11 +281,23 @@ private async Task CompleteFromIntakeAsync( DateTimeOffset nowUtc, CancellationToken ct) { - var duplicateSkips = run.Intake.Skipped - .Where(static skip => skip.Reason.StartsWith("Draft already exists", StringComparison.OrdinalIgnoreCase)) + var readyDuplicateSkips = run.Intake.Skipped + .Where(static skip => skip.IsReadyDuplicate) .ToArray(); var intakeBlockers = run.Intake.Skipped - .Except(duplicateSkips) + .Where(static skip => skip.Disposition == AutomatedJournalDraftIntakeDisposition.ProjectionFailed) + .Select(static skip => $"{skip.IdempotencyKey}: {skip.Reason}") + .ToArray(); + var duplicateInvestigationBlockers = run.Intake.Skipped + .Where(static skip => skip.Disposition is + AutomatedJournalDraftIntakeDisposition.ExistingDraftNeedsFix or + AutomatedJournalDraftIntakeDisposition.ExistingDraftReassessmentRequired) + .Select(static skip => $"{skip.IdempotencyKey}: {skip.Reason}") + .ToArray(); + var duplicateTerminalBlockers = run.Intake.Skipped + .Where(static skip => skip.Disposition is + AutomatedJournalDraftIntakeDisposition.ExistingDraftRejected or + AutomatedJournalDraftIntakeDisposition.ExistingDraftTerminal) .Select(static skip => $"{skip.IdempotencyKey}: {skip.Reason}") .ToArray(); var producerBlockers = run.ProducerSkips @@ -234,25 +318,61 @@ private async Task CompleteFromIntakeAsync( .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); var journalEntryIds = run.Intake.Created.Select(static draft => draft.JournalEntryId) - .Concat(duplicateSkips.Select(static skip => skip.JournalEntryId)) + .Concat(readyDuplicateSkips.Select(static skip => skip.JournalEntryId)) + .Concat(run.Intake.Skipped + .Where(static skip => skip.Disposition is + AutomatedJournalDraftIntakeDisposition.ExistingDraftNeedsFix or + AutomatedJournalDraftIntakeDisposition.ExistingDraftRejected or + AutomatedJournalDraftIntakeDisposition.ExistingDraftTerminal or + AutomatedJournalDraftIntakeDisposition.ExistingDraftReassessmentRequired) + .Select(static skip => skip.JournalEntryId)) .Where(static id => id != Guid.Empty) .Distinct() .ToArray(); var evidenceLinks = BuildEvidenceLinks(running, run, nowUtc); + var evidenceConfidenceScore = run.EvidenceAssessments.Count == 0 + ? (decimal?)null + : run.EvidenceAssessments.Values.Min(static assessment => assessment.ConfidenceScore); + var evidenceQuality = run.EvidenceAssessments.Count == 0 + ? (AutomatedJournalEvidenceQualityDto?)null + : run.EvidenceAssessments.Values.Min(static assessment => assessment.Quality); AutomatedJournalScheduleStateDto state; string summary; IReadOnlyList blockers; - if (investigationAssessments.Length > 0 || producerBlockers.Length > 0) + if (run.Readiness != AutomatedJournalIntakeReadiness.Ready) + { + state = run.Readiness == AutomatedJournalIntakeReadiness.Blocked + ? AutomatedJournalScheduleStateDto.Blocked + : AutomatedJournalScheduleStateDto.NeedsInvestigation; + blockers = run.ReadinessBlockers + .Concat(investigationBlockers) + .Concat(producerBlockers) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + summary = state == AutomatedJournalScheduleStateDto.Blocked + ? $"Monthly {running.Kind} intake is blocked by server-owned evidence policy." + : $"Monthly {running.Kind} intake needs evidence investigation before approval."; + } + else if (investigationAssessments.Length > 0 || producerBlockers.Length > 0 || duplicateInvestigationBlockers.Length > 0) { state = AutomatedJournalScheduleStateDto.NeedsInvestigation; - blockers = investigationBlockers.Concat(producerBlockers).Concat(intakeBlockers).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + blockers = investigationBlockers + .Concat(producerBlockers) + .Concat(duplicateInvestigationBlockers) + .Concat(intakeBlockers) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); summary = $"Monthly {running.Kind} produced {journalEntryIds.Length} governed draft(s), but source evidence needs investigation before approval."; } - else if (intakeBlockers.Length > 0 || validationBlockers.Length > 0) + else if (duplicateTerminalBlockers.Length > 0 || intakeBlockers.Length > 0 || validationBlockers.Length > 0) { state = AutomatedJournalScheduleStateDto.Blocked; - blockers = intakeBlockers.Concat(validationBlockers).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + blockers = duplicateTerminalBlockers + .Concat(intakeBlockers) + .Concat(validationBlockers) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); summary = $"Monthly {running.Kind} intake is blocked; no journal can enter approval until the listed issues are resolved."; } else if (journalEntryIds.Length > 0) @@ -283,6 +403,8 @@ private async Task CompleteFromIntakeAsync( journalEntryIds, evidenceLinks, blockers, + evidenceConfidenceScore, + evidenceQuality, ct).ConfigureAwait(false); } @@ -295,6 +417,8 @@ private async Task CompleteAsync( IReadOnlyList journalEntryIds, IReadOnlyList evidenceLinks, IReadOnlyList blockers, + decimal? evidenceConfidenceScore, + AutomatedJournalEvidenceQualityDto? evidenceQuality, CancellationToken ct) { var prior = running.RunHistory.First(history => string.Equals(history.RunKey, runKey, StringComparison.OrdinalIgnoreCase)); @@ -305,9 +429,14 @@ private async Task CompleteAsync( Summary = summary, JournalEntryIds = journalEntryIds, EvidenceLinks = evidenceLinks, - Blockers = blockers + Blockers = blockers, + PeriodId = running.PeriodId, + PeriodStart = running.PeriodStart, + PeriodEnd = running.PeriodEnd, + EvidenceConfidenceScore = evidenceConfidenceScore, + EvidenceQuality = evidenceQuality }; - var completed = running with + var completedCycle = running with { State = state, LastRunAtUtc = nowUtc, @@ -315,17 +444,27 @@ private async Task CompleteAsync( LastSummary = summary, EvidenceLinks = evidenceLinks, Blockers = blockers, + LastEvidenceConfidenceScore = evidenceConfidenceScore, + LastEvidenceQuality = evidenceQuality, RunHistory = UpsertHistory(running.RunHistory, completedHistory) }; - await _store.SaveAsync(completed, ct).ConfigureAwait(false); + var next = ShouldAdvance(completedCycle, state, journalEntryIds) + ? AdvanceRecurringCycle(completedCycle) + : completedCycle; + var persisted = await _store.SaveAsync(next, ct).ConfigureAwait(false); + var advanced = !string.Equals(persisted.PeriodId, running.PeriodId, StringComparison.OrdinalIgnoreCase); return new AutomatedJournalScheduledRunResult( - completed.ScheduleId, + persisted.ScheduleId, runKey, prior.ScheduledForUtc, state, summary, journalEntryIds, - blockers); + blockers, + evidenceConfidenceScore, + evidenceQuality, + advanced ? persisted.PeriodId : null, + advanced ? persisted.ScheduledForUtc : null); } private static IReadOnlyList BuildEvidenceLinks( @@ -354,10 +493,77 @@ private static IReadOnlyList BuildEvidenceLinks( return evidence; } + private static IReadOnlyList BuildScheduleEvidenceLinks( + AutomatedJournalScheduleWorkItem item, + IReadOnlyList retainedEvidence, + DateTimeOffset capturedAtUtc) + { + var evidence = retainedEvidence + .Where(static link => !string.IsNullOrWhiteSpace(link.Route)) + .DistinctBy(static link => $"{link.EvidenceId}|{link.Route}", StringComparer.OrdinalIgnoreCase) + .ToList(); + evidence.Add(new OperationsEvidenceLinkDto( + $"automated-journal-schedule:{item.ScheduleId}", + "Monthly automated-journal schedule and run history", + $"{UiApiRoutes.LedgerJournalAutomationMonthlySchedules}?scheduleId={Uri.EscapeDataString(item.ScheduleId)}", + "automated-journal-scheduler", + capturedAtUtc)); + return evidence; + } + private static string BuildRunKey(AutomatedJournalScheduleWorkItem item, DateTimeOffset scheduledForUtc) => FormattableString.Invariant( $"{item.ScheduleId.Trim().ToLowerInvariant()}|{item.PeriodId.Trim().ToLowerInvariant()}|{scheduledForUtc:O}"); + private static bool ShouldAdvance( + AutomatedJournalScheduleWorkItem item, + AutomatedJournalScheduleStateDto state, + IReadOnlyList journalEntryIds) + => item.RecurrenceEnabled && + (state is AutomatedJournalScheduleStateDto.DraftReady or AutomatedJournalScheduleStateDto.NoDraftRequired || + state == AutomatedJournalScheduleStateDto.NeedsInvestigation && journalEntryIds.Count > 0); + + private static AutomatedJournalScheduleWorkItem AdvanceRecurringCycle( + AutomatedJournalScheduleWorkItem completed) + { + var currentToken = completed.PeriodStart.ToString("yyyy-MM", System.Globalization.CultureInfo.InvariantCulture); + var nextPeriodStart = completed.PeriodStart.AddMonths(1); + var nextToken = nextPeriodStart.ToString("yyyy-MM", System.Globalization.CultureInfo.InvariantCulture); + var tokenIndex = completed.PeriodId.IndexOf(currentToken, StringComparison.Ordinal); + if (tokenIndex < 0) + { + throw new InvalidOperationException( + $"Recurring schedule '{completed.ScheduleId}' cannot advance period id '{completed.PeriodId}' because it does not contain '{currentToken}'."); + } + + var nextPeriodId = string.Concat( + completed.PeriodId.AsSpan(0, tokenIndex), + nextToken, + completed.PeriodId.AsSpan(tokenIndex + currentToken.Length)); + var nextPeriodEnd = nextPeriodStart.AddMonths(1).AddDays(-1); + var dueDaysAfterPeriodEnd = completed.DueDate.DayNumber - completed.PeriodEnd.DayNumber; + var nextDueDate = nextPeriodEnd.AddDays(dueDaysAfterPeriodEnd); + return completed with + { + PeriodId = nextPeriodId, + PeriodStart = nextPeriodStart, + PeriodEnd = nextPeriodEnd, + DueDate = nextDueDate, + ScheduledForUtc = null, + State = AutomatedJournalScheduleStateDto.Scheduled, + JournalEntryIds = [], + LastSummary = $"Next monthly {completed.Kind} cycle is scheduled for period '{nextPeriodId}'.", + EvidenceLinks = [], + Blockers = [], + BeginningNav = completed.Kind == AutomatedJournalScheduleKind.FeeAccrual ? null : completed.BeginningNav, + EndingNavBeforeFees = completed.Kind == AutomatedJournalScheduleKind.FeeAccrual ? null : completed.EndingNavBeforeFees, + HighWaterMark = completed.Kind == AutomatedJournalScheduleKind.FeeAccrual ? null : completed.HighWaterMark, + CapitalAccountReconciliation = null, + LastEvidenceConfidenceScore = null, + LastEvidenceQuality = null + }; + } + private static IReadOnlyList UpsertHistory( IReadOnlyList history, AutomatedJournalScheduleRunHistory entry) @@ -366,31 +572,52 @@ private static IReadOnlyList UpsertHistory( .Append(entry) .OrderBy(static item => item.ScheduledForUtc) .ToArray(); + + private static bool IsIncompleteRunningClaim(AutomatedJournalScheduleWorkItem item) + { + if (item.State != AutomatedJournalScheduleStateDto.Running || !item.ScheduledForUtc.HasValue) + return false; + + var runKey = BuildRunKey(item, item.ScheduledForUtc.Value.ToUniversalTime()); + return item.RunHistory.Any(history => + string.Equals(history.RunKey, runKey, StringComparison.OrdinalIgnoreCase) && + history.HistoryKind == AutomatedJournalScheduleHistoryKind.Execution && + history.State == AutomatedJournalScheduleStateDto.Running && + history.CompletedAtUtc is null); + } + + private static string? NormalizeScope(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } /// TimeProvider-driven host loop; is the deterministic seam. public sealed class AutomatedJournalSchedulerHostedService : BackgroundService { private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1); - private readonly AutomatedJournalScheduledWorker _worker; + private readonly IServiceProvider _services; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; public AutomatedJournalSchedulerHostedService( - AutomatedJournalScheduledWorker worker, + IServiceProvider services, TimeProvider timeProvider, ILogger logger) { - _worker = worker ?? throw new ArgumentNullException(nameof(worker)); + _services = services ?? throw new ArgumentNullException(nameof(services)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public Task RunOnceAsync(CancellationToken ct = default) - => _worker.RunDueAsync(_timeProvider.GetUtcNow(), ct); + => _services.GetRequiredService() + .RunDueAsync(_timeProvider.GetUtcNow(), ct); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + // Keep host startup independent from the first journal tick. Some host/runtime + // combinations execute BackgroundService code synchronously until its first yield. + await Task.Yield(); + while (!stoppingToken.IsCancellationRequested) { try diff --git a/src/Meridian.Ui.Shared/Services/DailyValuationBatchLifecycleService.cs b/src/Meridian.Ui.Shared/Services/DailyValuationBatchLifecycleService.cs new file mode 100644 index 0000000000..2d9395f81f --- /dev/null +++ b/src/Meridian.Ui.Shared/Services/DailyValuationBatchLifecycleService.cs @@ -0,0 +1,424 @@ +using Meridian.Contracts.Ledger; +using Meridian.Contracts.Workstation; + +namespace Meridian.Ui.Shared.Services; + +/// +/// Applies one governed operator decision to every retained draft in the current daily-valuation +/// batch. Batch membership is read from the server-owned schedule, validation runs for every +/// member before posting begins, and a retry resumes already submitted/approved/posted members. +/// +public sealed class DailyValuationBatchLifecycleService +{ + private readonly IDailyValuationPortfolioSource _scheduleSource; + private readonly IManualJournalEntryDraftStore _draftStore; + private readonly IManualJournalEntryLifecycleService _lifecycle; + private readonly SemaphoreSlim _gate = new(1, 1); + + public DailyValuationBatchLifecycleService( + IDailyValuationPortfolioSource scheduleSource, + IManualJournalEntryDraftStore draftStore, + IManualJournalEntryLifecycleService lifecycle) + { + _scheduleSource = scheduleSource ?? throw new ArgumentNullException(nameof(scheduleSource)); + _draftStore = draftStore ?? throw new ArgumentNullException(nameof(draftStore)); + _lifecycle = lifecycle ?? throw new ArgumentNullException(nameof(lifecycle)); + } + + public async Task ApproveAndPostAsync( + DailyValuationBatchLifecycleRequestDto request, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + var scheduleId = RequireText(request.ScheduleId, nameof(request.ScheduleId)); + var fundProfileId = RequireText(request.FundProfileId, nameof(request.FundProfileId)); + var actor = RequireText(request.Actor, nameof(request.Actor)); + var notes = RequireText(request.Notes, nameof(request.Notes)); + var tenantId = NormalizeOptional(request.TenantId); + var companyId = NormalizeOptional(request.CompanyId); + + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + var schedule = await _scheduleSource.GetAsync(scheduleId, ct).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Daily valuation schedule '{scheduleId}' was not found."); + EnsureOwnedScope(schedule, tenantId, companyId); + if (!string.Equals(schedule.FundProfileId, fundProfileId, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Daily valuation schedule '{scheduleId}' does not belong to fund profile '{fundProfileId}'."); + } + + var memberIds = schedule.JournalEntryIds + .Where(static id => id != Guid.Empty) + .Distinct() + .OrderBy(static id => id) + .ToArray(); + if (memberIds.Length == 0) + { + throw new InvalidOperationException( + $"Daily valuation schedule '{scheduleId}' has no retained draft batch to approve."); + } + var batchCorrelationId = string.IsNullOrWhiteSpace(schedule.BatchCorrelationId) + ? BuildRecoveredBatchCorrelationId(schedule, memberIds) + : schedule.BatchCorrelationId.Trim(); + var lifecycleEvidence = schedule.EvidenceLinks + .Select(static link => link.Route) + .Concat(request.EvidenceLinks) + .Where(static link => !string.IsNullOrWhiteSpace(link)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var drafts = new List(memberIds.Length); + var blockers = new List(); + foreach (var journalEntryId in memberIds) + { + var draft = await _draftStore + .GetAsync(fundProfileId, journalEntryId, ct, tenantId, companyId) + .ConfigureAwait(false); + if (draft is null) + { + blockers.Add($"Daily valuation draft '{journalEntryId:D}' is missing from the governed workbench."); + continue; + } + + if (draft.LedgerBookId != schedule.LedgerBookId || + !IsDailyValuationDraft(draft) || + !string.Equals(draft.EntityId, schedule.EntityId, StringComparison.OrdinalIgnoreCase) || + !string.Equals(draft.TenantId, tenantId, StringComparison.OrdinalIgnoreCase) || + !string.Equals(draft.CompanyId, companyId, StringComparison.OrdinalIgnoreCase)) + { + blockers.Add($"Draft '{journalEntryId:D}' does not match the retained daily-valuation batch scope."); + continue; + } + + if (string.Equals(draft.PreparedBy, actor, StringComparison.OrdinalIgnoreCase) && + draft.Status is not (ManualJournalEntryStatusDto.Posted or ManualJournalEntryStatusDto.CloseLocked)) + { + blockers.Add($"Draft '{journalEntryId:D}' requires an approver independent from preparer '{draft.PreparedBy}'."); + continue; + } + + if (draft.Status is ManualJournalEntryStatusDto.NeedsFix or ManualJournalEntryStatusDto.Rejected) + { + blockers.Add($"Draft '{journalEntryId:D}' is {draft.Status} and must be repaired before batch approval."); + continue; + } + + drafts.Add(draft); + } + + if (blockers.Count == 0) + { + // Validate every mutable member before any member can post. This catches stale + // Security Master, chart, period, and evidence controls without partially posting + // an otherwise invalid batch. + for (var index = 0; index < drafts.Count; index++) + { + var draft = drafts[index]; + if (draft.Status is ManualJournalEntryStatusDto.Posted or ManualJournalEntryStatusDto.CloseLocked) + { + continue; + } + + try + { + var validation = await ApplyAsync( + draft, + JournalEntryLifecycleActionDto.Validate, + actor, + notes, + batchCorrelationId, + lifecycleEvidence, + ct).ConfigureAwait(false); + drafts[index] = validation.JournalEntry; + if (validation.JournalEntry.Status == ManualJournalEntryStatusDto.NeedsFix || + validation.JournalEntry.ValidationIssues.Any(static issue => + issue.Severity == AccountingConfigurationValidationSeverityDto.Critical)) + { + blockers.Add($"Draft '{draft.JournalEntryId:D}' has critical validation issues and was not posted."); + } + } + catch (InvalidOperationException ex) + { + blockers.Add($"Draft '{draft.JournalEntryId:D}' validation failed: {ex.Message}"); + } + } + } + + if (blockers.Count == 0) + { + for (var index = 0; index < drafts.Count; index++) + { + try + { + drafts[index] = await AdvanceToPostedAsync( + drafts[index], + actor, + notes, + batchCorrelationId, + lifecycleEvidence, + ct).ConfigureAwait(false); + } + catch (InvalidOperationException ex) + { + blockers.Add($"Draft '{drafts[index].JournalEntryId:D}' could not complete batch posting: {ex.Message}"); + break; + } + } + } + + var postedIds = new List(memberIds.Length); + foreach (var journalEntryId in memberIds) + { + var current = await _draftStore + .GetAsync(fundProfileId, journalEntryId, ct, tenantId, companyId) + .ConfigureAwait(false); + if (current?.Status is ManualJournalEntryStatusDto.Posted or ManualJournalEntryStatusDto.CloseLocked) + { + postedIds.Add(journalEntryId); + } + } + + var isComplete = blockers.Count == 0 && postedIds.Count == memberIds.Length; + if (!isComplete && blockers.Count == 0) + { + blockers.Add("Not every retained daily valuation draft reached Posted state."); + } + + var nowUtc = DateTimeOffset.UtcNow; + var batchEvidence = new OperationsEvidenceLinkDto( + $"daily-valuation-batch:{schedule.ScheduleId}:{batchCorrelationId}", + isComplete ? "Daily valuation batch approval and posting" : "Daily valuation batch posting exception", + BuildLifecycleEvidenceRoute( + isComplete ? "posting" : "review", + schedule, + memberIds[0], + tenantId, + companyId), + "manual-journal-workbench", + nowUtc); + await _scheduleSource.SaveAsync(schedule with + { + State = isComplete ? DailyValuationScheduleStateDto.Posted : DailyValuationScheduleStateDto.Blocked, + LastSummary = isComplete + ? $"Daily valuation batch '{batchCorrelationId}' posted all {postedIds.Count} governed draft(s)." + : $"Daily valuation batch '{batchCorrelationId}' is partially complete ({postedIds.Count}/{memberIds.Length} posted).", + EvidenceLinks = schedule.EvidenceLinks + .Append(batchEvidence) + .DistinctBy(static link => link.EvidenceId, StringComparer.OrdinalIgnoreCase) + .ToArray(), + Blockers = blockers.ToArray(), + JournalEntryId = memberIds[0], + JournalEntryIds = memberIds, + BatchCorrelationId = batchCorrelationId + }, ct).ConfigureAwait(false); + + return new DailyValuationBatchLifecycleResultDto( + schedule.ScheduleId, + batchCorrelationId, + isComplete, + memberIds, + postedIds, + blockers); + } + finally + { + _gate.Release(); + } + } + + private async Task AdvanceToPostedAsync( + ManualJournalEntryDraftDto draft, + string actor, + string notes, + string batchCorrelationId, + IReadOnlyList callerEvidence, + CancellationToken ct) + { + var current = draft; + if (current.Status == ManualJournalEntryStatusDto.Draft) + { + current = (await ApplyAsync( + current, + JournalEntryLifecycleActionDto.Submit, + actor, + notes, + batchCorrelationId, + callerEvidence, + ct).ConfigureAwait(false)).JournalEntry; + } + + if (current.Status == ManualJournalEntryStatusDto.Submitted) + { + current = (await ApplyAsync( + current, + JournalEntryLifecycleActionDto.Approve, + actor, + notes, + batchCorrelationId, + callerEvidence, + ct).ConfigureAwait(false)).JournalEntry; + } + + if (current.Status == ManualJournalEntryStatusDto.Approved) + { + current = (await ApplyAsync( + current, + JournalEntryLifecycleActionDto.Post, + actor, + notes, + batchCorrelationId, + callerEvidence, + ct).ConfigureAwait(false)).JournalEntry; + } + + if (current.Status is not (ManualJournalEntryStatusDto.Posted or ManualJournalEntryStatusDto.CloseLocked)) + { + throw new InvalidOperationException( + $"Draft remained {current.Status} after the governed batch lifecycle command."); + } + + return current; + } + + private async Task ApplyAsync( + ManualJournalEntryDraftDto draft, + JournalEntryLifecycleActionDto action, + string actor, + string notes, + string batchCorrelationId, + IReadOnlyList callerEvidence, + CancellationToken ct) + { + var evidence = callerEvidence + .Append(BuildLifecycleEvidenceRoute( + ActionEvidenceToken(action), + draft, + draft.JournalEntryId, + draft.TenantId, + draft.CompanyId)) + .Where(static link => !string.IsNullOrWhiteSpace(link)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + return await _lifecycle.ApplyLifecycleActionAsync( + new JournalEntryLifecycleActionRequestDto( + draft.JournalEntryId, + draft.FundProfileId, + action, + actor, + draft.Version, + Notes: $"{notes} Daily valuation batch {batchCorrelationId}.", + CorrelationId: BuildActionCorrelationId(batchCorrelationId, draft.JournalEntryId, action), + EvidenceLinks: evidence, + LedgerBookId: draft.LedgerBookId, + TenantId: draft.TenantId, + CompanyId: draft.CompanyId), + ct).ConfigureAwait(false); + } + + private static string BuildLifecycleEvidenceRoute( + string action, + DailyValuationScheduleWorkItem schedule, + Guid journalEntryId, + string? tenantId, + string? companyId) + => BuildLifecycleEvidenceRoute( + action, + schedule.LedgerBookId, + schedule.PeriodId.ToString("D"), + journalEntryId, + tenantId, + companyId); + + private static string BuildLifecycleEvidenceRoute( + string action, + ManualJournalEntryDraftDto draft, + Guid journalEntryId, + string? tenantId, + string? companyId) + => BuildLifecycleEvidenceRoute( + action, + draft.LedgerBookId ?? Guid.Empty, + draft.PeriodId, + journalEntryId, + tenantId, + companyId); + + private static string BuildLifecycleEvidenceRoute( + string action, + Guid ledgerBookId, + string? periodId, + Guid journalEntryId, + string? tenantId, + string? companyId) + { + var route = $"/api/workstation/evidence/subjects/accounting-record/{action}/ledger-book/{ledgerBookId:D}/{Uri.EscapeDataString(periodId ?? "unknown")}" + + $"?journalEntryId={journalEntryId:D}"; + if (!string.IsNullOrWhiteSpace(tenantId)) + { + route += $"&tenantId={Uri.EscapeDataString(tenantId)}"; + } + + if (!string.IsNullOrWhiteSpace(companyId)) + { + route += $"&companyId={Uri.EscapeDataString(companyId)}"; + } + + return route; + } + + private static string BuildActionCorrelationId( + string batchCorrelationId, + Guid journalEntryId, + JournalEntryLifecycleActionDto action) + { + var seed = System.Text.Encoding.UTF8.GetBytes( + $"daily-valuation-lifecycle|{batchCorrelationId}|{journalEntryId:N}|{action}"); + return new Guid(System.Security.Cryptography.SHA256.HashData(seed).AsSpan(0, 16)).ToString("D"); + } + + private static string BuildRecoveredBatchCorrelationId( + DailyValuationScheduleWorkItem schedule, + IReadOnlyList journalEntryIds) + { + var seed = System.Text.Encoding.UTF8.GetBytes( + $"daily-valuation-recovered-lifecycle|{schedule.ScheduleId.Trim().ToLowerInvariant()}|{string.Join('|', journalEntryIds.Order())}"); + return new Guid(System.Security.Cryptography.SHA256.HashData(seed).AsSpan(0, 16)).ToString("D"); + } + + private static string ActionEvidenceToken(JournalEntryLifecycleActionDto action) + => action switch + { + JournalEntryLifecycleActionDto.Approve => "approval", + JournalEntryLifecycleActionDto.Post => "posting", + JournalEntryLifecycleActionDto.Submit or JournalEntryLifecycleActionDto.Validate => "review", + _ => action.ToString().ToLowerInvariant() + }; + + private static bool IsDailyValuationDraft(ManualJournalEntryDraftDto draft) + => draft.TreasuryContext?.IdempotencyKey?.StartsWith( + "fair-value|", + StringComparison.OrdinalIgnoreCase) == true; + + private static void EnsureOwnedScope( + DailyValuationScheduleWorkItem schedule, + string? tenantId, + string? companyId) + { + if (!string.Equals(schedule.TenantId, tenantId, StringComparison.OrdinalIgnoreCase) || + !string.Equals(schedule.CompanyId, companyId, StringComparison.OrdinalIgnoreCase)) + { + throw new UnauthorizedAccessException( + $"Daily valuation schedule '{schedule.ScheduleId}' is owned by another tenant/company scope."); + } + } + + private static string RequireText(string? value, string parameterName) + => string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("A non-empty value is required.", parameterName) + : value.Trim(); + + private static string? NormalizeOptional(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} diff --git a/src/Meridian.Ui.Shared/Services/DailyValuationPositionService.cs b/src/Meridian.Ui.Shared/Services/DailyValuationPositionService.cs new file mode 100644 index 0000000000..03dda44d6d --- /dev/null +++ b/src/Meridian.Ui.Shared/Services/DailyValuationPositionService.cs @@ -0,0 +1,398 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Meridian.Application.Accounting; +using Meridian.Contracts.Catalog; +using Meridian.Contracts.Domain; +using Meridian.Contracts.SecurityMaster; +using Meridian.Contracts.Workstation; + +namespace Meridian.Ui.Shared.Services; + +/// +/// One explicit durable run/account position-snapshot scope. Accounting ownership is inherited +/// from the immutable schedule identity and must match the retained snapshot exactly. +/// +public sealed record DailyValuationPositionSnapshotScope(string RunId, string AccountId); + +/// Fail-closed result of resolving the positions for one valuation run. +public sealed record DailyValuationPositionResolution( + IReadOnlyList Positions, + IReadOnlyList EvidenceLinks, + IReadOnlyList Blockers) +{ + public bool IsReady => Blockers.Count == 0 && Positions.Count > 0; +} + +/// +/// Resolves current positions from explicitly named durable snapshots, or from an explicitly +/// time-stamped static override. Every position must resolve to an active Security Master record +/// in the valuation base currency before a provider mark can enter an accounting draft. +/// +public sealed class DailyValuationPositionService +{ + private readonly IPositionSnapshotStore? _snapshotStore; + private readonly ICanonicalSymbolRegistry? _symbolRegistry; + private readonly ISecurityMasterQueryService? _securityMaster; + + public DailyValuationPositionService( + IPositionSnapshotStore? snapshotStore, + ICanonicalSymbolRegistry? symbolRegistry, + ISecurityMasterQueryService? securityMaster) + { + _snapshotStore = snapshotStore; + _symbolRegistry = symbolRegistry; + _securityMaster = securityMaster; + } + + public async Task ResolveConfiguredAsync( + DailyValuationScheduleWorkItem workItem, + DateTimeOffset valuationAsOfUtc, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(workItem); + valuationAsOfUtc = valuationAsOfUtc.ToUniversalTime(); + + if (workItem.PositionSnapshotScopes.Count > 0 && workItem.UseStaticPositionOverride) + { + return Blocked("Daily valuation cannot combine durable position snapshots with a static position override."); + } + + if (workItem.PositionSnapshotScopes.Count > 0) + { + if (_snapshotStore is null) + { + return Blocked("Durable position snapshots are configured, but the position snapshot store is unavailable."); + } + + var ownerScope = BuildOwnerScope(workItem); + if (ownerScope is null) + { + return Blocked( + "Durable position snapshots require tenant, company, fund profile, ledger book, and entity ownership on the valuation schedule."); + } + + var positions = new List(); + var evidence = new List(); + var blockers = new List(); + foreach (var scope in workItem.PositionSnapshotScopes) + { + ct.ThrowIfCancellationRequested(); + var snapshot = await _snapshotStore + .GetLatestSnapshotAsync(scope.RunId, scope.AccountId, ownerScope, ct) + .ConfigureAwait(false); + if (snapshot is null) + { + blockers.Add($"No durable position snapshot exists for run '{scope.RunId}' and account '{scope.AccountId}'."); + continue; + } + + if (!string.Equals(snapshot.RunId?.Trim(), scope.RunId, StringComparison.OrdinalIgnoreCase) || + !string.Equals(snapshot.AccountId?.Trim(), scope.AccountId, StringComparison.OrdinalIgnoreCase)) + { + blockers.Add( + $"Position snapshot store returned scope '{snapshot.RunId}/{snapshot.AccountId}' for requested scope '{scope.RunId}/{scope.AccountId}'."); + continue; + } + + if (!IsOwnedBy(snapshot, ownerScope)) + { + blockers.Add( + $"Position snapshot '{scope.RunId}/{scope.AccountId}' does not match the valuation schedule's immutable tenant/company/fund/book/entity ownership."); + continue; + } + + var freshnessBlocker = ValidateFreshness( + snapshot.AsOf, + valuationAsOfUtc, + workItem.MaximumPositionAgeDays, + $"Position snapshot '{scope.RunId}/{scope.AccountId}'"); + if (freshnessBlocker is not null) + { + blockers.Add(freshnessBlocker); + continue; + } + + evidence.Add(new OperationsEvidenceLinkDto( + $"daily-valuation-position:{workItem.ScheduleId}:{scope.RunId}:{scope.AccountId}", + "Durable portfolio position snapshot", + BuildSnapshotEvidenceRoute(scope, snapshot.AsOf), + "position-snapshot-store", + snapshot.AsOf)); + positions.AddRange(snapshot.Positions + .Where(static position => position.Quantity != 0m) + .Select(position => new MarkToMarketPosition( + position.Symbol, + position.Quantity, + position.CostBasis, + scope.AccountId))); + } + + if (blockers.Count > 0) + { + return new DailyValuationPositionResolution([], evidence, blockers); + } + + return await ResolveSecurityMasterAsync(positions, workItem.Currency, valuationAsOfUtc, evidence, ct) + .ConfigureAwait(false); + } + + if (!workItem.UseStaticPositionOverride) + { + return Blocked( + "No fresh durable position-snapshot scope is configured. Static positions require an explicit, time-stamped override."); + } + + if (!workItem.StaticPositionsAsOfUtc.HasValue) + { + return Blocked("The static position override is missing its as-of timestamp."); + } + + var overrideFreshnessBlocker = ValidateFreshness( + workItem.StaticPositionsAsOfUtc.Value, + valuationAsOfUtc, + workItem.MaximumPositionAgeDays, + "Static position override"); + if (overrideFreshnessBlocker is not null) + { + return Blocked(overrideFreshnessBlocker); + } + + if (workItem.Positions.Count == 0) + { + return Blocked("The explicit static position override contains no open positions."); + } + + var actualHash = ComputeStaticPositionHash(workItem.Positions); + if (string.IsNullOrWhiteSpace(workItem.StaticPositionHash) || + !string.Equals(actualHash, workItem.StaticPositionHash, StringComparison.OrdinalIgnoreCase)) + { + return Blocked("The static position override hash does not match the retained configured positions."); + } + + var staticEvidence = new OperationsEvidenceLinkDto( + $"daily-valuation-position:{workItem.ScheduleId}:static-override", + "Explicit static position override", + $"evidence://daily-valuation/position-override/{Uri.EscapeDataString(workItem.ScheduleId)}/{actualHash}", + "daily-valuation-scheduler", + workItem.StaticPositionsAsOfUtc.Value.ToUniversalTime()); + return await ResolveSecurityMasterAsync( + workItem.Positions, + workItem.Currency, + valuationAsOfUtc, + [staticEvidence], + ct) + .ConfigureAwait(false); + } + + public Task ResolveAdHocAsync( + IReadOnlyList positions, + string baseCurrency, + DateTimeOffset valuationAsOfUtc, + CancellationToken ct = default) + => ResolveSecurityMasterAsync(positions, baseCurrency, valuationAsOfUtc.ToUniversalTime(), [], ct); + + /// + /// Returns whether this process has the dependencies required by one retained schedule. + /// Scheduler hosts use this before claiming due work so an optional desktop composition + /// cannot turn a healthy retained schedule into a durable Blocked state. + /// + public bool CanResolveConfigured(DailyValuationScheduleWorkItem workItem) + { + ArgumentNullException.ThrowIfNull(workItem); + if (_symbolRegistry is null || _securityMaster is null) + return false; + + return workItem.PositionSnapshotScopes.Count == 0 || _snapshotStore is not null; + } + + public static string ComputeStaticPositionHash(IReadOnlyList positions) + { + ArgumentNullException.ThrowIfNull(positions); + var canonical = positions + .Select(static position => string.Join( + '|', + position.SecurityId?.ToString("N") ?? "-", + position.Symbol?.Trim().ToUpperInvariant() ?? string.Empty, + position.FinancialAccountId?.Trim() ?? "-", + position.InstrumentType?.Trim().ToUpperInvariant() ?? "-", + position.Quantity.ToString(CultureInfo.InvariantCulture), + position.CostPrice.ToString(CultureInfo.InvariantCulture))) + .OrderBy(static value => value, StringComparer.Ordinal) + .ToArray(); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join('\n', canonical)))) + .ToLowerInvariant(); + } + + private async Task ResolveSecurityMasterAsync( + IReadOnlyList positions, + string baseCurrency, + DateTimeOffset valuationAsOfUtc, + IReadOnlyList evidence, + CancellationToken ct) + { + if (positions.Count == 0) + { + return new DailyValuationPositionResolution([], evidence, ["No open positions are available for daily valuation."]); + } + + if (_symbolRegistry is null || _securityMaster is null) + { + return new DailyValuationPositionResolution( + [], + evidence, + ["Daily valuation requires the canonical symbol registry and authoritative Security Master query service."]); + } + + var normalizedCurrency = RequireText(baseCurrency, nameof(baseCurrency)).ToUpperInvariant(); + var resolved = new List(positions.Count); + var blockers = new List(); + foreach (var position in positions) + { + ct.ThrowIfCancellationRequested(); + var symbol = position.Symbol?.Trim().ToUpperInvariant(); + if (string.IsNullOrWhiteSpace(symbol)) + { + blockers.Add("A configured position is missing its canonical symbol."); + continue; + } + + var definition = _symbolRegistry.GetDefinition(symbol); + if (definition?.SecurityId is not { } securityId || securityId == Guid.Empty) + { + blockers.Add($"Position symbol '{symbol}' is unresolved or ambiguous in the canonical symbol registry."); + continue; + } + + if (position.SecurityId.HasValue && position.SecurityId.Value != securityId) + { + blockers.Add($"Position symbol '{symbol}' supplied Security Master id '{position.SecurityId:D}', but the canonical registry resolved '{securityId:D}'."); + continue; + } + + var security = await _securityMaster.GetByIdAsOfAsync(securityId, valuationAsOfUtc, ct).ConfigureAwait(false) + ?? await _securityMaster.GetByIdAsync(securityId, ct).ConfigureAwait(false); + if (security is null) + { + blockers.Add($"Position symbol '{symbol}' resolved to Security Master id '{securityId:D}', but no authoritative record exists."); + continue; + } + + if (security.Status != SecurityStatusDto.Active) + { + blockers.Add($"Position symbol '{symbol}' resolves to {security.Status} Security Master record '{securityId:D}'."); + continue; + } + + if (!SecurityContainsSymbol(security, definition.Canonical, symbol)) + { + blockers.Add($"Position symbol '{symbol}' does not match authoritative Security Master record '{securityId:D}'."); + continue; + } + + if (!string.Equals(security.Currency?.Trim(), normalizedCurrency, StringComparison.OrdinalIgnoreCase)) + { + blockers.Add($"Position symbol '{symbol}' is denominated in '{security.Currency}', not valuation base currency '{normalizedCurrency}'; configure governed FX translation before posting."); + continue; + } + + if (!string.IsNullOrWhiteSpace(definition.Currency) && + !string.Equals(definition.Currency.Trim(), normalizedCurrency, StringComparison.OrdinalIgnoreCase)) + { + blockers.Add($"Canonical symbol '{definition.Canonical}' is denominated in '{definition.Currency}', not valuation base currency '{normalizedCurrency}'."); + continue; + } + + resolved.Add(position with + { + Symbol = definition.Canonical.Trim().ToUpperInvariant(), + InstrumentType = security.AssetClass, + SecurityId = securityId + }); + } + + var duplicate = resolved + .GroupBy(static position => MarkToMarketCarryingValueKey.FromPosition(position)) + .FirstOrDefault(static group => group.Count() > 1); + if (duplicate is not null) + { + blockers.Add($"Position sources contain duplicate security/account scope '{duplicate.Key.Symbol}/{duplicate.Key.FinancialAccountId ?? "unscoped"}'; reconcile the scopes before valuation."); + } + + return blockers.Count > 0 + ? new DailyValuationPositionResolution([], evidence, blockers) + : new DailyValuationPositionResolution(resolved, evidence, []); + } + + private static string? ValidateFreshness( + DateTimeOffset sourceAsOfUtc, + DateTimeOffset valuationAsOfUtc, + int maximumAgeDays, + string label) + { + sourceAsOfUtc = sourceAsOfUtc.ToUniversalTime(); + if (sourceAsOfUtc > valuationAsOfUtc) + { + return $"{label} is dated after the valuation timestamp and cannot be used as-of {valuationAsOfUtc:O}."; + } + + if (sourceAsOfUtc < valuationAsOfUtc.AddDays(-maximumAgeDays)) + { + return $"{label} is stale as-of {sourceAsOfUtc:O}; maximum position age is {maximumAgeDays} day(s)."; + } + + return null; + } + + private static string BuildSnapshotEvidenceRoute( + DailyValuationPositionSnapshotScope scope, + DateTimeOffset snapshotAsOfUtc) + => $"evidence://position-snapshots/{Uri.EscapeDataString(scope.RunId)}/{Uri.EscapeDataString(scope.AccountId)}?asOf={Uri.EscapeDataString(snapshotAsOfUtc.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture))}"; + + private static PositionSnapshotOwnerScope? BuildOwnerScope(DailyValuationScheduleWorkItem workItem) + { + if (string.IsNullOrWhiteSpace(workItem.TenantId) || + string.IsNullOrWhiteSpace(workItem.CompanyId) || + string.IsNullOrWhiteSpace(workItem.FundProfileId) || + workItem.LedgerBookId == Guid.Empty || + string.IsNullOrWhiteSpace(workItem.EntityId)) + { + return null; + } + + return new PositionSnapshotOwnerScope( + workItem.TenantId.Trim(), + workItem.CompanyId.Trim(), + workItem.FundProfileId.Trim(), + workItem.LedgerBookId, + workItem.EntityId.Trim()); + } + + private static bool IsOwnedBy(AccountSnapshotRecord snapshot, PositionSnapshotOwnerScope ownerScope) + => string.Equals(snapshot.TenantId?.Trim(), ownerScope.TenantId, StringComparison.OrdinalIgnoreCase) && + string.Equals(snapshot.CompanyId?.Trim(), ownerScope.CompanyId, StringComparison.OrdinalIgnoreCase) && + string.Equals(snapshot.FundProfileId?.Trim(), ownerScope.FundProfileId, StringComparison.OrdinalIgnoreCase) && + snapshot.LedgerBookId == ownerScope.LedgerBookId && + string.Equals(snapshot.EntityId?.Trim(), ownerScope.EntityId, StringComparison.OrdinalIgnoreCase); + + private static bool SecurityContainsSymbol(SecurityDetailDto security, params string[] candidates) + { + var tokens = candidates + .Where(static candidate => !string.IsNullOrWhiteSpace(candidate)) + .Select(static candidate => candidate.Trim()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + return tokens.Contains(security.DisplayName) || + security.Identifiers.Any(identifier => + tokens.Contains(identifier.Value) || + (!string.IsNullOrWhiteSpace(identifier.NormalizedValue) && tokens.Contains(identifier.NormalizedValue))) || + security.Aliases.Any(alias => alias.IsEnabled && tokens.Contains(alias.AliasValue)); + } + + private static DailyValuationPositionResolution Blocked(string blocker) + => new([], [], [blocker]); + + private static string RequireText(string? value, string parameterName) + => string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("A non-empty value is required.", parameterName) + : value.Trim(); +} diff --git a/src/Meridian.Ui.Shared/Services/DailyValuationScheduler.cs b/src/Meridian.Ui.Shared/Services/DailyValuationScheduler.cs index b8a2bab32e..735674a543 100644 --- a/src/Meridian.Ui.Shared/Services/DailyValuationScheduler.cs +++ b/src/Meridian.Ui.Shared/Services/DailyValuationScheduler.cs @@ -2,9 +2,11 @@ using System.Text.Json; using Meridian.Application.Accounting; using Meridian.Contracts.Api; +using Meridian.Contracts.Ledger; using Meridian.Contracts.Workstation; using Meridian.Ledger; using Meridian.Storage.Store; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -12,8 +14,8 @@ namespace Meridian.Ui.Shared.Services; /// /// Persisted, explicitly configured portfolio scope for one daily valuation schedule. -/// Meridian does not yet have a canonical cross-fund position enumerator, so the scheduler -/// fails visibly when this governed position scope is absent or empty. +/// Positions are resolved from named durable account snapshots unless an explicitly timestamped +/// static override is selected; an unqualified retained position list is never replayed. /// public sealed record DailyValuationScheduleWorkItem( string ScheduleId, @@ -44,11 +46,25 @@ public sealed record DailyValuationScheduleWorkItem( Guid? JournalEntryId = null, string? LastSummary = null, IReadOnlyList? EvidenceLinks = null, - IReadOnlyList? Blockers = null) + IReadOnlyList? Blockers = null, + IReadOnlyList? PositionSnapshotScopes = null, + bool UseStaticPositionOverride = false, + DateTimeOffset? StaticPositionsAsOfUtc = null, + int MaximumPositionAgeDays = 1, + string? StaticPositionHash = null, + IReadOnlyList? JournalEntryIds = null, + string? BatchCorrelationId = null, + string? CreatedBy = null, + string? LastConfiguredBy = null) { public IReadOnlyList EvidenceLinks { get; init; } = EvidenceLinks ?? []; public IReadOnlyList Blockers { get; init; } = Blockers ?? []; + + public IReadOnlyList PositionSnapshotScopes { get; init; } = + PositionSnapshotScopes ?? []; + + public IReadOnlyList JournalEntryIds { get; init; } = JournalEntryIds ?? []; } /// Durable source of explicitly configured portfolio scopes for EOD valuation. @@ -104,6 +120,11 @@ public Task SaveAsync( var normalized = DailyValuationScheduleProjection.Normalize(workItem); lock (_gate) { + if (_items.TryGetValue(normalized.ScheduleId, out var existing)) + { + DailyValuationScheduleProjection.EnsureOwnershipUnchanged(existing, normalized); + } + _items[normalized.ScheduleId] = normalized; } @@ -114,12 +135,18 @@ public async Task GetStatusAsync( string? fundProfileId, Guid? ledgerBookId, string? periodId, - CancellationToken ct = default) + CancellationToken ct = default, + string? entityId = null, + string? tenantId = null, + string? companyId = null) => DailyValuationScheduleProjection.ProjectStatus( await ListAsync(ct).ConfigureAwait(false), fundProfileId, ledgerBookId, - periodId); + periodId, + entityId, + tenantId, + companyId); } /// Atomic JSON-backed daily valuation schedule source. @@ -147,6 +174,7 @@ public FileDailyValuationPortfolioSource(string snapshotPath) public async Task> ListAsync(CancellationToken ct = default) => await ReadSnapshotAsync( snapshot => snapshot.WorkItems + .Select(DailyValuationScheduleProjection.HydrateCompatibilityState) .OrderBy(static item => item.NextRunAtUtc) .ThenBy(static item => item.ScheduleId, StringComparer.OrdinalIgnoreCase) .ToArray(), @@ -159,8 +187,10 @@ public async Task> ListAsync(Cance ArgumentException.ThrowIfNullOrWhiteSpace(scheduleId); var normalizedScheduleId = scheduleId.Trim(); return await ReadSnapshotAsync( - snapshot => snapshot.WorkItems.FirstOrDefault(item => - string.Equals(item.ScheduleId, normalizedScheduleId, StringComparison.OrdinalIgnoreCase)), + snapshot => snapshot.WorkItems + .Where(item => string.Equals(item.ScheduleId, normalizedScheduleId, StringComparison.OrdinalIgnoreCase)) + .Select(DailyValuationScheduleProjection.HydrateCompatibilityState) + .FirstOrDefault(), ct).ConfigureAwait(false); } @@ -172,6 +202,15 @@ public async Task SaveAsync( return await UpdateSnapshotAsync( snapshot => { + var existing = snapshot.WorkItems.FirstOrDefault(item => string.Equals( + item.ScheduleId, + normalized.ScheduleId, + StringComparison.OrdinalIgnoreCase)); + if (existing is not null) + { + DailyValuationScheduleProjection.EnsureOwnershipUnchanged(existing, normalized); + } + var workItems = snapshot.WorkItems .Where(item => !string.Equals( item.ScheduleId, @@ -190,12 +229,18 @@ public async Task GetStatusAsync( string? fundProfileId, Guid? ledgerBookId, string? periodId, - CancellationToken ct = default) + CancellationToken ct = default, + string? entityId = null, + string? tenantId = null, + string? companyId = null) => DailyValuationScheduleProjection.ProjectStatus( await ListAsync(ct).ConfigureAwait(false), fundProfileId, ledgerBookId, - periodId); + periodId, + entityId, + tenantId, + companyId); public sealed record DailyValuationScheduleSnapshot( IReadOnlyList WorkItems); @@ -207,7 +252,12 @@ public sealed record DailyValuationScheduledRunResult( DailyValuationScheduleStateDto State, string Summary, Guid? JournalEntryId, - IReadOnlyList Blockers); + IReadOnlyList Blockers, + IReadOnlyList? JournalEntryIds = null, + string? BatchCorrelationId = null) +{ + public IReadOnlyList JournalEntryIds { get; init; } = JournalEntryIds ?? []; +} public sealed record DailyValuationScheduledBatchResult( DateTimeOffset EvaluatedAtUtc, @@ -221,22 +271,47 @@ public sealed class DailyValuationScheduledWorker { private readonly IDailyValuationPortfolioSource _portfolioSource; private readonly AutomatedJournalIntakeRunner _intakeRunner; + private readonly DailyValuationPositionService? _positionService; private readonly ILogger _logger; private readonly SemaphoreSlim _runGate = new(1, 1); public DailyValuationScheduledWorker( IDailyValuationPortfolioSource portfolioSource, AutomatedJournalIntakeRunner intakeRunner, - ILogger logger) + ILogger logger, + DailyValuationPositionService? positionService = null) { _portfolioSource = portfolioSource ?? throw new ArgumentNullException(nameof(portfolioSource)); _intakeRunner = intakeRunner ?? throw new ArgumentNullException(nameof(intakeRunner)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _positionService = positionService; } public async Task RunDueAsync( DateTimeOffset nowUtc, CancellationToken ct = default) + => await RunDueCoreAsync(nowUtc, scope: null, ct).ConfigureAwait(false); + + /// + /// Executes due schedules owned by exactly one tenant/company scope. This is the operator-route + /// seam; the host loop intentionally uses to process every scope. + /// + public async Task RunDueForScopeAsync( + DateTimeOffset nowUtc, + string? tenantId, + string? companyId, + CancellationToken ct = default) + => await RunDueCoreAsync( + nowUtc, + new DailyValuationOwnerScope( + DailyValuationScheduleProjection.NormalizeOptionalScope(tenantId), + DailyValuationScheduleProjection.NormalizeOptionalScope(companyId)), + ct).ConfigureAwait(false); + + private async Task RunDueCoreAsync( + DateTimeOffset nowUtc, + DailyValuationOwnerScope? scope, + CancellationToken ct) { nowUtc = nowUtc.ToUniversalTime(); await _runGate.WaitAsync(ct).ConfigureAwait(false); @@ -244,6 +319,7 @@ public async Task RunDueAsync( { var due = (await _portfolioSource.ListAsync(ct).ConfigureAwait(false)) .Where(static item => item.IsEnabled) + .Where(item => scope is null || scope.Matches(item)) .Where(item => item.NextRunAtUtc <= nowUtc) .Where(item => item.LastScheduledForUtc != item.NextRunAtUtc || item.State == DailyValuationScheduleStateDto.Running) @@ -255,6 +331,14 @@ public async Task RunDueAsync( foreach (var item in due) { ct.ThrowIfCancellationRequested(); + if (!CanExecute(item)) + { + _logger.LogDebug( + "Daily valuation schedule remains pending because this host lacks its execution dependencies. ScheduleId={ScheduleId}", + item.ScheduleId); + continue; + } + results.Add(await RunWorkItemAsync(item, nowUtc, ct).ConfigureAwait(false)); } @@ -266,6 +350,18 @@ public async Task RunDueAsync( } } + private bool CanExecute(DailyValuationScheduleWorkItem item) + => _positionService is not null && + _positionService.CanResolveConfigured(item) && + _intakeRunner.CanRunDailyMarkToMarket; + + private sealed record DailyValuationOwnerScope(string? TenantId, string? CompanyId) + { + public bool Matches(DailyValuationScheduleWorkItem item) + => string.Equals(item.TenantId, TenantId, StringComparison.OrdinalIgnoreCase) && + string.Equals(item.CompanyId, CompanyId, StringComparison.OrdinalIgnoreCase); + } + private async Task RunWorkItemAsync( DailyValuationScheduleWorkItem item, DateTimeOffset nowUtc, @@ -277,20 +373,24 @@ private async Task RunWorkItemAsync( State = DailyValuationScheduleStateDto.Running, LastScheduledForUtc = scheduledForUtc, LastSummary = $"Daily valuation schedule '{item.ScheduleId}' is running for {scheduledForUtc:O}.", + JournalEntryId = null, + JournalEntryIds = [], + BatchCorrelationId = null, EvidenceLinks = [], Blockers = [] }; await _portfolioSource.SaveAsync(running, ct).ConfigureAwait(false); - if (item.Positions.Count == 0) + if (_positionService is null) { - const string blocker = "No configured portfolio positions are available for the scheduled daily valuation scope."; + const string blocker = "Daily valuation position resolution is unavailable; no static schedule positions will be replayed implicitly."; return await CompleteAsync( running, nowUtc, DailyValuationScheduleStateDto.Blocked, blocker, - journalEntryId: null, + journalEntryIds: [], + batchCorrelationId: null, evidenceLinks: [], blockers: [blocker], ct).ConfigureAwait(false); @@ -298,6 +398,27 @@ private async Task RunWorkItemAsync( try { + var positionResolution = await _positionService + .ResolveConfiguredAsync(item, scheduledForUtc, ct) + .ConfigureAwait(false); + if (!positionResolution.IsReady) + { + var blockedSummary = positionResolution.Blockers.Count == 1 + ? positionResolution.Blockers[0] + : $"Daily valuation was blocked by {positionResolution.Blockers.Count} position-scope control(s)."; + return await CompleteAsync( + running, + nowUtc, + DailyValuationScheduleStateDto.Blocked, + blockedSummary, + journalEntryIds: [], + batchCorrelationId: null, + positionResolution.EvidenceLinks, + positionResolution.Blockers, + ct).ConfigureAwait(false); + } + + var batchCorrelationId = BuildBatchCorrelationId(item, scheduledForUtc); var run = await _intakeRunner.RunDailyMarkToMarketIntakeAsync( new RunDailyMarkToMarketDraftIntakeRequest( item.FundProfileId, @@ -306,7 +427,7 @@ private async Task RunWorkItemAsync( item.LedgerBookId, item.PeriodId, scheduledForUtc, - item.Positions, + positionResolution.Positions, item.PolicyId, item.PolicyName, item.ValuationMethod, @@ -318,58 +439,123 @@ private async Task RunWorkItemAsync( item.RequireCompleteCoverage, item.EntityId, item.TenantId, - item.CompanyId), + item.CompanyId, + batchCorrelationId), ct).ConfigureAwait(false); + batchCorrelationId = run.BatchCorrelationId ?? batchCorrelationId; - var created = run.Intake.Created.FirstOrDefault(); - var skipped = run.Intake.Skipped.FirstOrDefault(); - var journalEntryId = created?.JournalEntryId ?? skipped?.JournalEntryId; - var evidenceLinks = BuildEvidenceLinks(item, run, nowUtc); - var blockers = run.Valuation.UnpricedSymbols - .Select(static symbol => $"{symbol}: no trusted closing mark available") - .Concat(run.Valuation.StalePricedSymbols - .Select(static symbol => $"{symbol}: closing mark rejected — exceeds maximum mark-age policy")) + var journalEntryIds = run.Intake.Created.Select(static draft => draft.JournalEntryId) + .Concat(run.Intake.Skipped.Select(static skipped => skipped.JournalEntryId)) + .Where(static id => id != Guid.Empty) + .Distinct() + .OrderBy(static id => id) + .ToArray(); + var evidenceLinks = BuildEvidenceLinks(item, run, positionResolution.EvidenceLinks, nowUtc); + var markBlockers = run.Valuation.RejectedMarks + .Select(static rejection => $"{rejection.Symbol}: {rejection.Reason}") .ToArray(); + var intakeBlockers = BuildIntakeBlockers(run.Intake); + var blockers = markBlockers.Concat(intakeBlockers).ToArray(); + + if (run.Valuation.Projection is null && markBlockers.Length > 0) + { + return await CompleteAsync( + running, + nowUtc, + DailyValuationScheduleStateDto.Blocked, + $"Daily valuation was blocked because {markBlockers.Length} closing mark(s) failed trust policy.", + journalEntryIds, + batchCorrelationId, + evidenceLinks, + blockers, + ct).ConfigureAwait(false); + } - if (run.Valuation.Projection is null && blockers.Length > 0) + if (intakeBlockers.Count > 0) { return await CompleteAsync( running, nowUtc, DailyValuationScheduleStateDto.Blocked, - $"Daily valuation was blocked because {blockers.Length} closing mark(s) failed trust policy.", - journalEntryId, + $"Daily valuation retained {intakeBlockers.Count} draft intake blocker(s); the batch is not ready for approval.", + journalEntryIds, + batchCorrelationId, evidenceLinks, blockers, ct).ConfigureAwait(false); } - if (run.Valuation.Approval is null) + if (run.Valuation.Approvals.Count == 0) { return await CompleteAsync( running, nowUtc, DailyValuationScheduleStateDto.NoAdjustment, "Daily valuation completed with trusted marks and no unrealized adjustment to draft.", - journalEntryId: null, + journalEntryIds: [], + batchCorrelationId, evidenceLinks, blockers, ct).ConfigureAwait(false); } - var summary = created is not null - ? $"Daily valuation created governed draft '{created.JournalEntryId:D}' awaiting human approval." - : $"Daily valuation idempotently reused governed draft '{journalEntryId:D}' ({skipped?.Reason})."; + if (journalEntryIds.Length != run.Valuation.Approvals.Count) + { + throw new InvalidOperationException( + $"Daily valuation produced {run.Valuation.Approvals.Count} governed draft(s), but intake retained {journalEntryIds.Length} draft id(s)."); + } + + var allAlreadyPosted = run.Intake.Created.Count == 0 && + run.Intake.Skipped.Count == run.Valuation.Approvals.Count && + run.Intake.Skipped.All(static skipped => + skipped.Disposition == AutomatedJournalDraftIntakeDisposition.ExistingDraftGoverned && + (skipped.ExistingStatus is ManualJournalEntryStatusDto.Posted or + ManualJournalEntryStatusDto.CloseLocked)); + var state = allAlreadyPosted + ? DailyValuationScheduleStateDto.Posted + : DailyValuationScheduleStateDto.DraftReady; + var summary = allAlreadyPosted + ? $"Daily valuation idempotently confirmed all {journalEntryIds.Length} governed draft(s) in batch '{batchCorrelationId}' were already posted." + : run.Intake.Created.Count > 0 + ? $"Daily valuation created {run.Intake.Created.Count} governed draft(s) in batch '{batchCorrelationId}' awaiting human approval." + : $"Daily valuation idempotently reused {journalEntryIds.Length} governed draft(s) in batch '{batchCorrelationId}'."; return await CompleteAsync( running, nowUtc, - DailyValuationScheduleStateDto.DraftReady, + state, summary, - journalEntryId, + journalEntryIds, + batchCorrelationId, evidenceLinks, blockers, ct).ConfigureAwait(false); } + catch (DailyValuationPendingDraftException ex) + { + var retainedScheduleIds = ex.PendingBatchCorrelationId is not null && + string.Equals( + item.BatchCorrelationId, + ex.PendingBatchCorrelationId, + StringComparison.OrdinalIgnoreCase) + ? item.JournalEntryIds + : []; + var retainedIds = retainedScheduleIds + .Concat(ex.PendingJournalEntryIds) + .Where(static id => id != Guid.Empty) + .Distinct() + .OrderBy(static id => id) + .ToArray(); + return await CompleteAsync( + running, + nowUtc, + DailyValuationScheduleStateDto.Blocked, + ex.Message, + retainedIds, + ex.PendingBatchCorrelationId ?? BuildRecoveredBatchCorrelationId(item, retainedIds), + item.EvidenceLinks, + [ex.Message], + ct).ConfigureAwait(false); + } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; @@ -388,7 +574,8 @@ private async Task RunWorkItemAsync( nowUtc, DailyValuationScheduleStateDto.Failed, blocker, - journalEntryId: null, + journalEntryIds: [], + batchCorrelationId: null, evidenceLinks: [], blockers: [blocker], ct).ConfigureAwait(false); @@ -400,7 +587,8 @@ private async Task CompleteAsync( DateTimeOffset nowUtc, DailyValuationScheduleStateDto state, string summary, - Guid? journalEntryId, + IReadOnlyList journalEntryIds, + string? batchCorrelationId, IReadOnlyList evidenceLinks, IReadOnlyList blockers, CancellationToken ct) @@ -410,7 +598,9 @@ private async Task CompleteAsync( NextRunAtUtc = AdvanceToNextRun(running.NextRunAtUtc, nowUtc), State = state, LastRunAtUtc = nowUtc, - JournalEntryId = journalEntryId, + JournalEntryId = journalEntryIds.FirstOrDefault() is var first && first != Guid.Empty ? first : null, + JournalEntryIds = journalEntryIds, + BatchCorrelationId = batchCorrelationId, LastSummary = summary, EvidenceLinks = evidenceLinks, Blockers = blockers @@ -421,8 +611,34 @@ private async Task CompleteAsync( running.LastScheduledForUtc!.Value, state, summary, - journalEntryId, - blockers); + completed.JournalEntryId, + blockers, + journalEntryIds, + batchCorrelationId); + } + + internal static IReadOnlyList BuildIntakeBlockers(AutomatedJournalDraftIntakeResult intake) + { + var blockers = intake.Created + .Where(static draft => draft.Status is + ManualJournalEntryStatusDto.NeedsFix or + ManualJournalEntryStatusDto.Rejected or + ManualJournalEntryStatusDto.Reversed or + ManualJournalEntryStatusDto.Rebooked) + .Select(static draft => + $"Draft '{draft.JournalEntryId:D}' was retained as {draft.Status} and is not ready for approval.") + .Concat(intake.Skipped + .Where(static skipped => skipped.Disposition is + AutomatedJournalDraftIntakeDisposition.ProjectionFailed or + AutomatedJournalDraftIntakeDisposition.ExistingDraftNeedsFix or + AutomatedJournalDraftIntakeDisposition.ExistingDraftRejected or + AutomatedJournalDraftIntakeDisposition.ExistingDraftTerminal or + AutomatedJournalDraftIntakeDisposition.ExistingDraftReassessmentRequired) + .Select(static skipped => skipped.Reason)) + .Where(static blocker => !string.IsNullOrWhiteSpace(blocker)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + return blockers; } private static DateTimeOffset AdvanceToNextRun(DateTimeOffset scheduledForUtc, DateTimeOffset nowUtc) @@ -439,6 +655,7 @@ private static DateTimeOffset AdvanceToNextRun(DateTimeOffset scheduledForUtc, D private static IReadOnlyList BuildEvidenceLinks( DailyValuationScheduleWorkItem item, DailyMarkToMarketIntakeRunResult run, + IReadOnlyList positionEvidence, DateTimeOffset capturedAtUtc) { var uris = run.Intake.Created @@ -448,31 +665,59 @@ private static IReadOnlyList BuildEvidenceLinks( .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); - return uris.Select((uri, index) => new OperationsEvidenceLinkDto( + return positionEvidence.Concat(uris.Select((uri, index) => new OperationsEvidenceLinkDto( $"daily-valuation:{item.ScheduleId}:{index + 1}", "Trusted daily closing mark", uri, "daily-valuation-scheduler", - capturedAtUtc)) + capturedAtUtc))) .ToArray(); } + + private static string BuildBatchCorrelationId( + DailyValuationScheduleWorkItem item, + DateTimeOffset scheduledForUtc) + { + var seed = FormattableString.Invariant( + $"daily-valuation-batch|{item.ScheduleId.Trim().ToLowerInvariant()}|{item.FundProfileId.Trim().ToLowerInvariant()}|{item.LedgerBookId:N}|{item.PeriodId:N}|{scheduledForUtc:O}"); + return new Guid(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(seed)).AsSpan(0, 16)) + .ToString("D"); + } + + private static string BuildRecoveredBatchCorrelationId( + DailyValuationScheduleWorkItem item, + IReadOnlyList journalEntryIds) + { + var seed = FormattableString.Invariant( + $"daily-valuation-recovered-batch|{item.ScheduleId.Trim().ToLowerInvariant()}|{string.Join('|', journalEntryIds.Order())}"); + return new Guid(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(seed)).AsSpan(0, 16)) + .ToString("D"); + } } /// Minute-cadence host loop for configured EOD valuation schedules. public sealed class DailyValuationSchedulerHostedService( - DailyValuationScheduledWorker worker, + IServiceProvider services, ILogger logger) : BackgroundService { private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1); + public Task RunOnceAsync(CancellationToken ct = default) + => services.GetRequiredService() + .RunDueAsync(DateTimeOffset.UtcNow, ct); + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + // Keep host startup independent from the first valuation tick. Some host/runtime + // combinations execute BackgroundService code synchronously until its first yield. + await Task.Yield(); + using var timer = new PeriodicTimer(TickInterval); while (!stoppingToken.IsCancellationRequested) { try { - var batch = await worker.RunDueAsync(DateTimeOffset.UtcNow, stoppingToken).ConfigureAwait(false); + var batch = await RunOnceAsync(stoppingToken).ConfigureAwait(false); if (batch.Runs.Count > 0) { logger.LogInformation( @@ -527,6 +772,24 @@ public static DailyValuationScheduleWorkItem Normalize(DailyValuationScheduleWor throw new ArgumentException("Ledger period id is required.", nameof(workItem)); if (workItem.MaximumMarkAgeDays < 0) throw new ArgumentOutOfRangeException(nameof(workItem), "Maximum mark age cannot be negative."); + if (workItem.MaximumPositionAgeDays < 0) + throw new ArgumentOutOfRangeException(nameof(workItem), "Maximum position age cannot be negative."); + + var normalizedPositions = workItem.Positions ?? []; + var normalizedScopes = (workItem.PositionSnapshotScopes ?? []) + .Select(static scope => new DailyValuationPositionSnapshotScope( + string.IsNullOrWhiteSpace(scope.RunId) + ? throw new ArgumentException("Position snapshot run id is required.") + : scope.RunId.Trim(), + string.IsNullOrWhiteSpace(scope.AccountId) + ? throw new ArgumentException("Position snapshot account id is required.") + : scope.AccountId.Trim())) + .Distinct() + .OrderBy(static scope => scope.RunId, StringComparer.OrdinalIgnoreCase) + .ThenBy(static scope => scope.AccountId, StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (normalizedScopes.Length > 0 && workItem.UseStaticPositionOverride) + throw new ArgumentException("Choose durable position snapshots or a static override, not both.", nameof(workItem)); return workItem with { @@ -535,7 +798,12 @@ public static DailyValuationScheduleWorkItem Normalize(DailyValuationScheduleWor Currency = workItem.Currency.Trim().ToUpperInvariant(), Actor = workItem.Actor.Trim(), NextRunAtUtc = workItem.NextRunAtUtc.ToUniversalTime(), - Positions = workItem.Positions ?? [], + Positions = normalizedPositions, + PositionSnapshotScopes = normalizedScopes, + StaticPositionsAsOfUtc = workItem.StaticPositionsAsOfUtc?.ToUniversalTime(), + StaticPositionHash = workItem.UseStaticPositionOverride + ? DailyValuationPositionService.ComputeStaticPositionHash(normalizedPositions) + : null, PolicyId = workItem.PolicyId.Trim(), PolicyName = workItem.PolicyName.Trim(), ValuationMethod = workItem.ValuationMethod.Trim(), @@ -546,9 +814,20 @@ public static DailyValuationScheduleWorkItem Normalize(DailyValuationScheduleWor EntityId = NormalizeOptional(workItem.EntityId), TenantId = NormalizeOptional(workItem.TenantId), CompanyId = NormalizeOptional(workItem.CompanyId), + CreatedBy = RequireText(workItem.CreatedBy ?? workItem.Actor, "Schedule creator"), + LastConfiguredBy = RequireText( + workItem.LastConfiguredBy ?? workItem.CreatedBy ?? workItem.Actor, + "Last configured by"), LastSummary = NormalizeOptional(workItem.LastSummary), EvidenceLinks = workItem.EvidenceLinks ?? [], - Blockers = workItem.Blockers ?? [] + Blockers = workItem.Blockers ?? [], + JournalEntryIds = (workItem.JournalEntryIds ?? []) + .Concat(workItem.JournalEntryId.HasValue ? [workItem.JournalEntryId.Value] : []) + .Where(static id => id != Guid.Empty) + .Distinct() + .OrderBy(static id => id) + .ToArray(), + BatchCorrelationId = NormalizeOptional(workItem.BatchCorrelationId) }; } @@ -556,9 +835,15 @@ public static DailyValuationScheduleStatusDto ProjectStatus( IReadOnlyList items, string? fundProfileId, Guid? ledgerBookId, - string? periodId) + string? periodId, + string? entityId = null, + string? tenantId = null, + string? companyId = null) { var normalizedFundProfileId = NormalizeOptional(fundProfileId); + var normalizedEntityId = NormalizeOptional(entityId); + var normalizedTenantId = NormalizeOptional(tenantId); + var normalizedCompanyId = NormalizeOptional(companyId); var matching = items .Where(item => normalizedFundProfileId is null || string.Equals( item.FundProfileId, @@ -566,9 +851,17 @@ public static DailyValuationScheduleStatusDto ProjectStatus( StringComparison.OrdinalIgnoreCase)) .Where(item => !ledgerBookId.HasValue || item.LedgerBookId == ledgerBookId.Value) .Where(item => MatchesPeriod(item, periodId)) + .Where(item => string.Equals(item.EntityId, normalizedEntityId, StringComparison.OrdinalIgnoreCase)) + .Where(item => string.Equals(item.TenantId, normalizedTenantId, StringComparison.OrdinalIgnoreCase)) + .Where(item => string.Equals(item.CompanyId, normalizedCompanyId, StringComparison.OrdinalIgnoreCase)) .OrderByDescending(static item => item.IsEnabled) + .ThenByDescending(static item => item.State is + DailyValuationScheduleStateDto.Running or + DailyValuationScheduleStateDto.Scheduled) + .ThenByDescending(static item => item.LastScheduledForUtc ?? item.LastRunAtUtc) .ThenByDescending(static item => item.LastRunAtUtc) .ThenBy(static item => item.NextRunAtUtc) + .ThenBy(static item => item.ScheduleId, StringComparer.OrdinalIgnoreCase) .FirstOrDefault(); if (matching is null) @@ -586,7 +879,12 @@ public static DailyValuationScheduleStatusDto ProjectStatus( MissingScopeMessage, JournalEntryId: null, EvidenceLinks: [], - Blockers: [MissingScopeMessage]); + Blockers: [MissingScopeMessage], + JournalEntryIds: [], + BatchCorrelationId: null, + normalizedEntityId, + normalizedTenantId, + normalizedCompanyId); } var state = matching.IsEnabled @@ -612,9 +910,51 @@ public static DailyValuationScheduleStatusDto ProjectStatus( summary, matching.JournalEntryId, matching.EvidenceLinks, - blockers); + blockers, + matching.JournalEntryIds, + matching.BatchCorrelationId, + matching.EntityId, + matching.TenantId, + matching.CompanyId); } + public static void EnsureOwnershipUnchanged( + DailyValuationScheduleWorkItem existing, + DailyValuationScheduleWorkItem replacement) + { + if (!string.Equals(existing.TenantId, replacement.TenantId, StringComparison.OrdinalIgnoreCase) || + !string.Equals(existing.CompanyId, replacement.CompanyId, StringComparison.OrdinalIgnoreCase) || + !string.Equals(existing.FundProfileId, replacement.FundProfileId, StringComparison.OrdinalIgnoreCase) || + existing.LedgerBookId != replacement.LedgerBookId || + !string.Equals(existing.EntityId, replacement.EntityId, StringComparison.OrdinalIgnoreCase) || + !string.Equals(existing.Currency, replacement.Currency, StringComparison.OrdinalIgnoreCase) || + !string.Equals( + existing.CreatedBy ?? existing.Actor, + replacement.CreatedBy ?? replacement.Actor, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Daily valuation schedule '{existing.ScheduleId}' belongs to a different immutable identity scope."); + } + } + + public static string? NormalizeOptionalScope(string? value) => NormalizeOptional(value); + + public static DailyValuationScheduleWorkItem HydrateCompatibilityState( + DailyValuationScheduleWorkItem workItem) + => workItem with + { + EvidenceLinks = workItem.EvidenceLinks ?? [], + Blockers = workItem.Blockers ?? [], + PositionSnapshotScopes = workItem.PositionSnapshotScopes ?? [], + JournalEntryIds = (workItem.JournalEntryIds ?? []) + .Concat(workItem.JournalEntryId.HasValue ? [workItem.JournalEntryId.Value] : []) + .Where(static id => id != Guid.Empty) + .Distinct() + .OrderBy(static id => id) + .ToArray() + }; + private static bool MatchesPeriod(DailyValuationScheduleWorkItem item, string? periodId) { var normalized = NormalizeOptional(periodId); @@ -634,4 +974,9 @@ private static bool MatchesPeriod(DailyValuationScheduleWorkItem item, string? p private static string? NormalizeOptional(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static string RequireText(string? value, string label) + => string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException($"{label} is required.") + : value.Trim(); } diff --git a/src/Meridian.Ui.Shared/Services/LedgerMarkToMarketCarryingValueSource.cs b/src/Meridian.Ui.Shared/Services/LedgerMarkToMarketCarryingValueSource.cs new file mode 100644 index 0000000000..889df48fd6 --- /dev/null +++ b/src/Meridian.Ui.Shared/Services/LedgerMarkToMarketCarryingValueSource.cs @@ -0,0 +1,47 @@ +using System.Globalization; +using Meridian.Application.Accounting; +using Meridian.Ledger; +using Meridian.Storage.Ledger; + +namespace Meridian.Ui.Shared.Services; + +/// +/// Hydrates the requested ledger book once and returns the durable securities-account carrying +/// value for every requested security/account key. Missing accounts are explicit null results; +/// present zero-balance accounts remain zero. +/// +public sealed class LedgerMarkToMarketCarryingValueSource(ILedgerJournalStore store) + : IMarkToMarketCarryingValueSource +{ + public async Task> GetCarryingValuesAsync( + MarkToMarketCarryingValueRequest request, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + if (!request.LedgerBookId.HasValue || request.LedgerBookId.Value == Guid.Empty) + { + throw new InvalidOperationException("Daily valuation carrying-value hydration requires a ledger book id."); + } + + var asOfUtc = request.AsOf.ToUniversalTime(); + var ledger = await store + .HydrateLedgerAsOfAsync(request.LedgerBookId.Value, asOfUtc, ct: ct) + .ConfigureAwait(false); + var trialBalance = ledger.TrialBalanceAsOf(asOfUtc); + var results = new Dictionary(); + foreach (var position in request.Positions) + { + var key = MarkToMarketCarryingValueKey.FromPosition(position); + var account = LedgerAccounts.Securities(key.Symbol, key.FinancialAccountId); + var accountExists = trialBalance.TryGetValue(account, out var balance); + var evidence = $"ledger://books/{request.LedgerBookId.Value:D}/accounts/{Uri.EscapeDataString(account.ToString())}/as-of/{Uri.EscapeDataString(asOfUtc.ToString("O", CultureInfo.InvariantCulture))}"; + results.Add(key, new MarkToMarketCarryingValue( + accountExists ? balance : null, + "durable-ledger-trial-balance", + asOfUtc, + evidence)); + } + + return results; + } +} diff --git a/src/Meridian.Ui.Shared/Services/ManualJournalEntryDraftStores.cs b/src/Meridian.Ui.Shared/Services/ManualJournalEntryDraftStores.cs index c60ff313c7..971a94887b 100644 --- a/src/Meridian.Ui.Shared/Services/ManualJournalEntryDraftStores.cs +++ b/src/Meridian.Ui.Shared/Services/ManualJournalEntryDraftStores.cs @@ -15,16 +15,21 @@ namespace Meridian.Ui.Shared.Services; public sealed class InMemoryManualJournalEntryDraftStore : IManualJournalEntryDraftStore { - private readonly Dictionary _drafts = new(StringComparer.OrdinalIgnoreCase); + private Dictionary _drafts = new(StringComparer.OrdinalIgnoreCase); + private readonly object _gate = new(); public Task> ListFundProfileIdsAsync(CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var fundProfileIds = _drafts.Values - .Select(static item => NormalizeFundProfileId(item.FundProfileId)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(static item => item, StringComparer.OrdinalIgnoreCase) - .ToArray(); + string[] fundProfileIds; + lock (_gate) + { + fundProfileIds = _drafts.Values + .Select(static item => NormalizeFundProfileId(item.FundProfileId)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(static item => item, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } return Task.FromResult>(fundProfileIds); } @@ -40,14 +45,18 @@ public Task> ListAsync( var normalizedFundProfileId = NormalizeFundProfileId(fundProfileId); var normalizedTenantId = NormalizeOptional(tenantId); var normalizedCompanyId = NormalizeOptional(companyId); - var drafts = _drafts.Values - .Where(item => string.Equals(item.FundProfileId, normalizedFundProfileId, StringComparison.OrdinalIgnoreCase)) - .Where(item => !ledgerBookId.HasValue || item.LedgerBookId == ledgerBookId) - .Where(item => normalizedTenantId is null || string.Equals(NormalizeOptional(item.TenantId), normalizedTenantId, StringComparison.OrdinalIgnoreCase)) - .Where(item => normalizedCompanyId is null || string.Equals(NormalizeOptional(item.CompanyId), normalizedCompanyId, StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(item => item.UpdatedAtUtc) - .ThenBy(item => item.JournalEntryId) - .ToArray(); + ManualJournalEntryDraftDto[] drafts; + lock (_gate) + { + drafts = _drafts.Values + .Where(item => string.Equals(item.FundProfileId, normalizedFundProfileId, StringComparison.OrdinalIgnoreCase)) + .Where(item => !ledgerBookId.HasValue || item.LedgerBookId == ledgerBookId) + .Where(item => normalizedTenantId is null || string.Equals(NormalizeOptional(item.TenantId), normalizedTenantId, StringComparison.OrdinalIgnoreCase)) + .Where(item => normalizedCompanyId is null || string.Equals(NormalizeOptional(item.CompanyId), normalizedCompanyId, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(item => item.UpdatedAtUtc) + .ThenBy(item => item.JournalEntryId) + .ToArray(); + } return Task.FromResult>(drafts); } @@ -63,19 +72,43 @@ public Task> ListAsync( var normalizedFundProfileId = NormalizeFundProfileId(fundProfileId); var normalizedTenantId = NormalizeOptional(tenantId); var normalizedCompanyId = NormalizeOptional(companyId); - var draft = _drafts.Values.FirstOrDefault(item => - item.JournalEntryId == journalEntryId && - string.Equals(item.FundProfileId, normalizedFundProfileId, StringComparison.OrdinalIgnoreCase) && - (normalizedTenantId is null || string.Equals(NormalizeOptional(item.TenantId), normalizedTenantId, StringComparison.OrdinalIgnoreCase)) && - (normalizedCompanyId is null || string.Equals(NormalizeOptional(item.CompanyId), normalizedCompanyId, StringComparison.OrdinalIgnoreCase))); + ManualJournalEntryDraftDto? draft; + lock (_gate) + { + draft = _drafts.Values.FirstOrDefault(item => + item.JournalEntryId == journalEntryId && + string.Equals(item.FundProfileId, normalizedFundProfileId, StringComparison.OrdinalIgnoreCase) && + (normalizedTenantId is null || string.Equals(NormalizeOptional(item.TenantId), normalizedTenantId, StringComparison.OrdinalIgnoreCase)) && + (normalizedCompanyId is null || string.Equals(NormalizeOptional(item.CompanyId), normalizedCompanyId, StringComparison.OrdinalIgnoreCase))); + } return Task.FromResult(draft); } public Task SaveAsync(ManualJournalEntryDraftDto draft, CancellationToken ct = default) + => SaveBatchAsync([draft], ct); + + public Task SaveBatchAsync( + IReadOnlyList drafts, + CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - ArgumentNullException.ThrowIfNull(draft); - _drafts[Key(NormalizeFundProfileId(draft.FundProfileId), draft.JournalEntryId, draft.TenantId, draft.CompanyId)] = draft; + ArgumentNullException.ThrowIfNull(drafts); + var retained = drafts + .Select(draft => draft ?? throw new ArgumentException("Manual journal entry draft batches cannot contain null drafts.", nameof(drafts))) + .ToArray(); + lock (_gate) + { + var next = new Dictionary( + _drafts, + StringComparer.OrdinalIgnoreCase); + foreach (var draft in retained) + { + next[Key(NormalizeFundProfileId(draft.FundProfileId), draft.JournalEntryId, draft.TenantId, draft.CompanyId)] = draft; + } + + _drafts = next; + } + return Task.CompletedTask; } @@ -162,34 +195,48 @@ public async Task> ListAsync( } public async Task SaveAsync(ManualJournalEntryDraftDto draft, CancellationToken ct = default) + => await SaveBatchAsync([draft], ct).ConfigureAwait(false); + + public async Task SaveBatchAsync( + IReadOnlyList drafts, + CancellationToken ct = default) { - ArgumentNullException.ThrowIfNull(draft); - var normalizedFundProfileId = NormalizeFundProfileId(draft.FundProfileId); - var normalizedTenantId = NormalizeOptional(draft.TenantId); - var normalizedCompanyId = NormalizeOptional(draft.CompanyId); + ArgumentNullException.ThrowIfNull(drafts); + var normalizedDrafts = drafts + .Select(draft => draft ?? throw new ArgumentException("Manual journal entry draft batches cannot contain null drafts.", nameof(drafts))) + .Select(draft => draft with + { + FundProfileId = NormalizeFundProfileId(draft.FundProfileId), + TenantId = NormalizeOptional(draft.TenantId), + CompanyId = NormalizeOptional(draft.CompanyId) + }) + .ToArray(); + if (normalizedDrafts.Length == 0) + { + return; + } + + var replacementKeys = normalizedDrafts + .Select(static draft => Key(draft)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); await UpdateSnapshotAsync( snapshot => { - var drafts = snapshot.Drafts - .Where(item => item.JournalEntryId != draft.JournalEntryId || - !string.Equals(item.FundProfileId, normalizedFundProfileId, StringComparison.OrdinalIgnoreCase) || - !string.Equals(NormalizeOptional(item.TenantId), normalizedTenantId, StringComparison.OrdinalIgnoreCase) || - !string.Equals(NormalizeOptional(item.CompanyId), normalizedCompanyId, StringComparison.OrdinalIgnoreCase)) - .Append(draft with - { - FundProfileId = normalizedFundProfileId, - TenantId = normalizedTenantId, - CompanyId = normalizedCompanyId - }) + var retainedDrafts = snapshot.Drafts + .Where(item => !replacementKeys.Contains(Key(item))) + .Concat(normalizedDrafts) .OrderByDescending(item => item.UpdatedAtUtc) .ThenBy(item => item.JournalEntryId) .ToArray(); - return new ManualJournalEntryDraftSnapshot(drafts); + return new ManualJournalEntryDraftSnapshot(retainedDrafts); }, ct).ConfigureAwait(false); } + private static string Key(ManualJournalEntryDraftDto draft) + => $"{NormalizeFundProfileId(draft.FundProfileId)}|{NormalizeOptional(draft.TenantId) ?? "tenant:any"}|{NormalizeOptional(draft.CompanyId) ?? "company:any"}|{draft.JournalEntryId:D}"; + private static string NormalizeFundProfileId(string value) => string.IsNullOrWhiteSpace(value) ? "default-fund" : value.Trim(); diff --git a/src/Meridian.Ui.Shared/Services/ManualJournalEntryWorkbenchService.AccountingCloseReceipts.cs b/src/Meridian.Ui.Shared/Services/ManualJournalEntryWorkbenchService.AccountingCloseReceipts.cs new file mode 100644 index 0000000000..4c0374842c --- /dev/null +++ b/src/Meridian.Ui.Shared/Services/ManualJournalEntryWorkbenchService.AccountingCloseReceipts.cs @@ -0,0 +1,90 @@ +using System.Security.Cryptography; +using System.Text; +using Meridian.Contracts.Ledger; + +namespace Meridian.Ui.Shared.Services; + +internal enum CloseReopenReceiptRetention +{ + Created, + ExistingExact, + Missing, + Conflict +} + +public sealed partial class ManualJournalEntryWorkbenchService +{ + private readonly SemaphoreSlim _closeReopenReceiptGate = new(1, 1); + + internal async Task RetainCloseReopenReceiptAsync( + string fundProfileId, + Guid ledgerBookId, + Guid ledgerPeriodId, + long ledgerPeriodVersion, + string actor, + string correlationId, + string commandHash, + IReadOnlyList evidenceLinks, + string? tenantId, + string? companyId, + bool allowCreate, + CancellationToken ct) + { + var actionPrefix = $"GovernedLedgerPeriodReopen:{ledgerPeriodId:D}:"; + var action = $"{actionPrefix}from-version:{ledgerPeriodVersion}"; + await _closeReopenReceiptGate.WaitAsync(ct).ConfigureAwait(false); + try + { + var retained = await _auditStore + .ListAsync(fundProfileId, ledgerBookId, ct, tenantId, companyId) + .ConfigureAwait(false); + var periodReceipts = retained + .Where(item => item.Action.StartsWith(actionPrefix, StringComparison.Ordinal)) + .OrderByDescending(static item => item.RecordedAtUtc) + .ToArray(); + var relevantReceipts = allowCreate + ? periodReceipts.Where(item => string.Equals(item.Action, action, StringComparison.Ordinal)).ToArray() + : periodReceipts.Take(1).ToArray(); + if (relevantReceipts.Any(item => + string.Equals(item.CorrelationId, correlationId, StringComparison.OrdinalIgnoreCase) && + string.Equals(item.AfterHash, commandHash, StringComparison.OrdinalIgnoreCase))) + { + return CloseReopenReceiptRetention.ExistingExact; + } + + if (relevantReceipts.Length > 0) + { + return CloseReopenReceiptRetention.Conflict; + } + + if (!allowCreate) + { + return CloseReopenReceiptRetention.Missing; + } + + var receiptIdBytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{action}|{commandHash}")); + await _auditStore.AppendAsync( + new AccountingActionAuditEventDto( + new Guid(receiptIdBytes.AsSpan(0, 16)), + DateTimeOffset.UtcNow, + actor.Trim(), + action, + fundProfileId, + ledgerBookId, + correlationId.Trim(), + "ledger-period:hard-closed;intent:governed-reopen", + commandHash, + [], + evidenceLinks, + CompanyId: companyId, + TenantId: tenantId), + ct) + .ConfigureAwait(false); + return CloseReopenReceiptRetention.Created; + } + finally + { + _closeReopenReceiptGate.Release(); + } + } +} diff --git a/src/Meridian.Ui.Shared/Services/ManualJournalEntryWorkbenchService.Lifecycle.cs b/src/Meridian.Ui.Shared/Services/ManualJournalEntryWorkbenchService.Lifecycle.cs index 116383c530..a902c05ab8 100644 --- a/src/Meridian.Ui.Shared/Services/ManualJournalEntryWorkbenchService.Lifecycle.cs +++ b/src/Meridian.Ui.Shared/Services/ManualJournalEntryWorkbenchService.Lifecycle.cs @@ -613,6 +613,45 @@ await ValidateLedgerBookPeriodScopeAsync( { issues.Add(Issue("manual-je.security-missing", AccountingConfigurationValidationSeverityDto.Critical, $"Security Master id '{normalizedLine.SecurityId:D}' was not found.", normalizedLine.LineId, "Choose a resolved Security Master instrument or clear the line security.")); } + else if (normalizedLine.SecurityId.HasValue && IsDailyValuationDraft(draft)) + { + if (_securityMasterQueryService is null) + { + issues.Add(Issue( + "manual-je.security-service-missing", + AccountingConfigurationValidationSeverityDto.Critical, + "Daily valuation drafts require authoritative Security Master validation.", + normalizedLine.LineId, + "Restore the Security Master query service before submitting the valuation draft.")); + } + else + { + var security = await _securityMasterQueryService + .GetByIdAsync(normalizedLine.SecurityId.Value, ct) + .ConfigureAwait(false); + if (security is not null && security.Status != SecurityStatusDto.Active) + { + issues.Add(Issue( + "manual-je.security-inactive", + AccountingConfigurationValidationSeverityDto.Critical, + $"Security Master id '{normalizedLine.SecurityId:D}' is {security.Status}.", + normalizedLine.LineId, + "Resolve the Security Master lifecycle state before submitting the valuation draft.")); + } + else if (security is not null && !string.Equals( + security.Currency?.Trim(), + draft.Currency?.Trim(), + StringComparison.OrdinalIgnoreCase)) + { + issues.Add(Issue( + "manual-je.security-currency-mismatch", + AccountingConfigurationValidationSeverityDto.Critical, + $"Security Master currency '{security.Currency}' does not match journal currency '{draft.Currency}'.", + normalizedLine.LineId, + "Configure governed FX translation before submitting the valuation draft.")); + } + } + } ValidateRequiredDimensions(lineDimensions, allowIncomplete, normalizedLine.LineId, issues); @@ -684,11 +723,37 @@ private async Task ValidateLedgerBookPeriodScopeAsync( "Select a period that belongs to the journal entry ledger book.")); } - // Closing entries are the governed exception: they must post into the (closed) period - // being finalized, so the closed-period bar does not apply to them. The posting guard and - // the ClosingEntry posting kind carry the governance for this path. - if (draft.EntryType != ManualJournalEntryTypeDto.ClosingEntry && - !string.Equals(period.Status, "Open", StringComparison.OrdinalIgnoreCase)) + var isGovernedClosingReversal = false; + if (draft.EntryType == ManualJournalEntryTypeDto.Reversal && + draft.ReversalOfJournalEntryId is { } reversalSourceId) + { + var reversalSource = await _draftStore + .GetAsync( + draft.FundProfileId, + reversalSourceId, + ct, + draft.TenantId, + draft.CompanyId) + .ConfigureAwait(false); + isGovernedClosingReversal = reversalSource?.EntryType == ManualJournalEntryTypeDto.ClosingEntry; + } + + // Closing entries and their source-linked governed reversals are the mutations accepted + // after soft close. They must not enter approval/posting while the period is still open, + // and hard close remains the final mutation boundary. + if ((draft.EntryType == ManualJournalEntryTypeDto.ClosingEntry || isGovernedClosingReversal) && + !string.Equals(period.Status, "SoftClosed", StringComparison.OrdinalIgnoreCase)) + { + issues.Add(Issue( + "manual-je.closing-period-not-soft-closed", + AccountingConfigurationValidationSeverityDto.Critical, + $"Ledger period '{periodId:D}' is {period.Status}; closing entries and governed closing-entry reversals require an exactly soft-closed period.", + "periodId", + "Soft-close the period before approving its closing entry, or use governed reopen before a restatement.")); + } + else if (draft.EntryType != ManualJournalEntryTypeDto.ClosingEntry && + !isGovernedClosingReversal && + !string.Equals(period.Status, "Open", StringComparison.OrdinalIgnoreCase)) { issues.Add(Issue( "manual-je.period-closed", @@ -710,6 +775,11 @@ private async Task SecurityExistsAsync(Guid securityId, CancellationToken return detail is not null; } + private static bool IsDailyValuationDraft(ManualJournalEntryDraftDto draft) + => draft.TreasuryContext?.IdempotencyKey?.StartsWith( + "fair-value|", + StringComparison.OrdinalIgnoreCase) == true; + private async Task AppendAuditAsync( ManualJournalEntryDraftDto draft, string action, diff --git a/src/Meridian.Ui.Shared/Services/ManualJournalEntryWorkbenchService.cs b/src/Meridian.Ui.Shared/Services/ManualJournalEntryWorkbenchService.cs index 8695661472..8028418ac8 100644 --- a/src/Meridian.Ui.Shared/Services/ManualJournalEntryWorkbenchService.cs +++ b/src/Meridian.Ui.Shared/Services/ManualJournalEntryWorkbenchService.cs @@ -254,13 +254,25 @@ public async Task SaveDraftAsync( ct.ThrowIfCancellationRequested(); ArgumentNullException.ThrowIfNull(request); EnsurePeriodUnlocked(request.PeriodIsLocked, "save manual journal entry drafts"); + var requestedTenantId = NormalizeOptional(request.TenantId) ?? NormalizeOptional(request.Draft.TenantId); + var requestedCompanyId = NormalizeOptional(request.CompanyId) ?? NormalizeOptional(request.Draft.CompanyId); + var existing = await _draftStore.GetAsync( + request.Draft.FundProfileId, + request.Draft.JournalEntryId, + ct, + requestedTenantId, + requestedCompanyId).ConfigureAwait(false); var normalizedDraft = await NormalizeAndValidateAsync(request.Draft with { - TenantId = NormalizeOptional(request.TenantId) ?? NormalizeOptional(request.Draft.TenantId), - CompanyId = NormalizeOptional(request.CompanyId) ?? NormalizeOptional(request.Draft.CompanyId) + TenantId = requestedTenantId, + CompanyId = requestedCompanyId, + // Automated evidence grades are server-produced accounting-control facts. A browser or + // WPF resave may edit the draft, but cannot clear, replace, or upgrade the retained grade. + AutomationEvidenceAssessment = existing is null + ? request.Draft.AutomationEvidenceAssessment + : existing.AutomationEvidenceAssessment }, allowIncomplete: true, ct).ConfigureAwait(false); EnsureRequestedLedgerBookMatchesDraft(request.LedgerBookId, normalizedDraft); - var existing = await _draftStore.GetAsync(normalizedDraft.FundProfileId, normalizedDraft.JournalEntryId, ct, normalizedDraft.TenantId, normalizedDraft.CompanyId).ConfigureAwait(false); if (existing is not null) { EnsureRequestedLedgerBookMatchesDraft(request.LedgerBookId, existing); @@ -709,6 +721,8 @@ private async Task BuildManualJournalEntryWriteAsync( var chartByPath = BuildChartByPath(configuration.ChartOfAccounts); var timestamp = ToAccountingTimestamp(draft.AccountingDate); var description = NormalizeOptional(draft.Memo) ?? $"Manual journal entry {draft.JournalEntryId:D}"; + var securityLineage = await ResolveSecurityMasterPostingLineageAsync(draft, chartByPath, ct) + .ConfigureAwait(false); var lines = new List(draft.Lines.Count); foreach (var line in draft.Lines) @@ -749,7 +763,7 @@ private async Task BuildManualJournalEntryWriteAsync( timestamp, description, lines, - BuildManualJournalEntryMetadata(draft, postingCommand, evidenceLinks, recordedAtUtc)); + BuildManualJournalEntryMetadata(draft, postingCommand, evidenceLinks, recordedAtUtc, securityLineage)); return new LedgerJournalEntryWrite( entry, @@ -827,7 +841,8 @@ private static JournalEntryMetadata BuildManualJournalEntryMetadata( ManualJournalEntryDraftDto draft, AccountingPostingCommandDto postingCommand, IReadOnlyList evidenceLinks, - DateTimeOffset recordedAtUtc) + DateTimeOffset recordedAtUtc, + ManualJournalSecurityMasterLineage? securityLineage) { var tags = new Dictionary(StringComparer.OrdinalIgnoreCase); AddMetadataTag(tags, "manualJournalEntryId", draft.JournalEntryId.ToString("D")); @@ -840,6 +855,8 @@ private static JournalEntryMetadata BuildManualJournalEntryMetadata( AddMetadataTag(tags, "companyId", draft.CompanyId); AddMetadataTag(tags, "sourceEventId", postingCommand.SourceEventId?.ToString("D")); AddMetadataTag(tags, "sourceJournalEntryId", postingCommand.SourceJournalEntryId?.ToString("D")); + AddMetadataTag(tags, "securityMasterProvenance", securityLineage?.Provenance); + AddMetadataTag(tags, "securityMasterLineage", securityLineage?.Lineage); if (evidenceLinks.Count > 0) { tags["evidenceLinks"] = string.Join("|", evidenceLinks); @@ -847,8 +864,11 @@ private static JournalEntryMetadata BuildManualJournalEntryMetadata( return new JournalEntryMetadata( ActivityType: "ManualJournalEntry", + Symbol: securityLineage?.Symbol, + SecurityId: securityLineage?.SecurityId, ProjectId: NormalizeOptional(draft.FundProfileId), LedgerBook: draft.LedgerBookId?.ToString("D"), + FinancialAccountId: securityLineage?.FinancialAccountId, EffectiveDate: postingCommand.TreasuryContext?.EffectiveDate ?? draft.AccountingDate, IdempotencyKey: postingCommand.IdempotencyKey, FundEventId: NormalizeOptional(postingCommand.TreasuryContext?.FundEventId), @@ -868,6 +888,116 @@ private static JournalEntryMetadata BuildManualJournalEntryMetadata( SubjectId: draft.JournalEntryId.ToString("D"))).ToArray()); } + private async Task ResolveSecurityMasterPostingLineageAsync( + ManualJournalEntryDraftDto draft, + IReadOnlyDictionary chartByPath, + CancellationToken ct) + { + var instrumentLines = draft.Lines + .Select(line => (Line: line, Account: chartByPath.GetValueOrDefault(line.AccountPath))) + .Where(static item => + item.Line.SecurityId.HasValue || + item.Line.Dimensions?.InstrumentId.HasValue == true || + !string.IsNullOrWhiteSpace(item.Line.LedgerAccountSymbol) || + !string.IsNullOrWhiteSpace(item.Account?.Symbol)) + .ToArray(); + if (instrumentLines.Length == 0) + return null; + + var securityIds = instrumentLines + .Select(static item => item.Line.SecurityId ?? item.Line.Dimensions?.InstrumentId) + .Where(static value => value.HasValue && value.Value != Guid.Empty) + .Select(static value => value!.Value) + .Distinct() + .ToArray(); + if (securityIds.Length != 1) + { + throw new InvalidOperationException( + securityIds.Length == 0 + ? "Instrument-bearing manual journal lines require a resolved Security Master security id before posting." + : "Manual journal entries must be split by Security Master security id before posting."); + } + + if (_securityMasterQueryService is null) + { + throw new InvalidOperationException( + "Instrument-bearing manual journal posting requires an authoritative Security Master query service."); + } + + var securityId = securityIds[0]; + var asOfUtc = new DateTimeOffset(draft.AccountingDate.ToDateTime(TimeOnly.MaxValue), TimeSpan.Zero); + var security = await _securityMasterQueryService.GetByIdAsOfAsync(securityId, asOfUtc, ct).ConfigureAwait(false) + ?? await _securityMasterQueryService.GetByIdAsync(securityId, ct).ConfigureAwait(false) + ?? throw new InvalidOperationException( + $"Security Master security '{securityId:D}' was not found at posting time."); + if (security.Status != SecurityStatusDto.Active) + { + throw new InvalidOperationException( + $"Security Master security '{securityId:D}' is {security.Status}; only active securities can be posted."); + } + + if (!string.Equals(security.Currency?.Trim(), draft.Currency?.Trim(), StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Security Master security '{securityId:D}' currency '{security.Currency}' does not match journal base currency '{draft.Currency}'; governed FX translation is required."); + } + + var symbols = instrumentLines + .Select(static item => NormalizeOptional(item.Line.LedgerAccountSymbol) ?? NormalizeOptional(item.Account?.Symbol)) + .Where(static value => value is not null) + .Select(static value => value!.ToUpperInvariant()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (symbols.Length != 1) + { + throw new InvalidOperationException( + symbols.Length == 0 + ? $"Security Master security '{securityId:D}' requires one instrument symbol before posting." + : "Manual journal entries with multiple instrument symbols must be split before posting."); + } + + var symbol = symbols[0]; + if (!SecurityMasterRecordContainsSymbol(security, symbol)) + { + throw new InvalidOperationException( + $"Instrument symbol '{symbol}' does not match authoritative Security Master record '{securityId:D}'."); + } + + var securityIdToken = securityId.ToString("N"); + var provenance = $"security-master:{securityIdToken};server-resolved:true;approved:true;status:{security.Status};version:{security.Version};currency:{security.Currency.Trim().ToUpperInvariant()}"; + var lineage = string.Join( + '|', + instrumentLines.Select(item => + $"{symbol}:{securityIdToken}:ledger-map:manual-journal:{symbol}:{securityIdToken}:{Uri.EscapeDataString(item.Line.AccountPath)}:sm-approval:security-master-active:{securityIdToken}:security-status:{security.Status}:{provenance}")); + var financialAccounts = instrumentLines + .Select(static item => NormalizeOptional(item.Line.LedgerAccountFinancialAccountId)) + .Where(static value => value is not null) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return new ManualJournalSecurityMasterLineage( + securityId, + symbol, + financialAccounts.Length == 1 ? financialAccounts[0] : null, + provenance, + lineage); + } + + private static bool SecurityMasterRecordContainsSymbol(SecurityDetailDto security, string symbol) + => string.Equals(security.DisplayName?.Trim(), symbol, StringComparison.OrdinalIgnoreCase) || + security.Identifiers.Any(identifier => + string.Equals(identifier.Value?.Trim(), symbol, StringComparison.OrdinalIgnoreCase) || + string.Equals(identifier.NormalizedValue?.Trim(), symbol, StringComparison.OrdinalIgnoreCase)) || + security.Aliases.Any(alias => + alias.IsEnabled && string.Equals(alias.AliasValue?.Trim(), symbol, StringComparison.OrdinalIgnoreCase)); + + private sealed record ManualJournalSecurityMasterLineage( + Guid SecurityId, + string Symbol, + string? FinancialAccountId, + string Provenance, + string Lineage); + private static AccountingPostingIntentDto BuildManualPostingIntent(ManualJournalEntryDraftDto draft) { if (draft.ReversalOfJournalEntryId.HasValue) @@ -1136,8 +1266,10 @@ private async Task CreateCorrectionDraftAs throw new InvalidOperationException("Manual journal reversal and rebook actions cannot transition the posted entry while the generated correction draft has critical validation issues."); } - await _draftStore.SaveAsync(corrected, ct).ConfigureAwait(false); - await _draftStore.SaveAsync(correction, ct).ConfigureAwait(false); + // The source transition and its source-linked correction are one accounting mutation. + // Retaining either draft without the other creates an unrecoverable workbench state, so + // stores must publish both together or leave the prior source unchanged. + await _draftStore.SaveBatchAsync([corrected, correction], ct).ConfigureAwait(false); await AppendAuditAsync(corrected, reverseSides ? "manual-je.reverse" : "manual-je.rebook", request.Actor, request.CorrelationId, corrected.EvidenceLinks, request.ReportGroupPrincipalIds, ct).ConfigureAwait(false); await AppendAuditAsync(correction, auditAction, request.Actor, request.CorrelationId, correction.EvidenceLinks, request.ReportGroupPrincipalIds, ct).ConfigureAwait(false); return new JournalEntryLifecycleActionResultDto(corrected, transition, [correction]); diff --git a/src/Meridian.Ui.Shared/Services/WorkstationServiceCollectionExtensions.cs b/src/Meridian.Ui.Shared/Services/WorkstationServiceCollectionExtensions.cs index d0a9332857..a511aa0774 100644 --- a/src/Meridian.Ui.Shared/Services/WorkstationServiceCollectionExtensions.cs +++ b/src/Meridian.Ui.Shared/Services/WorkstationServiceCollectionExtensions.cs @@ -16,6 +16,8 @@ using Meridian.Backtesting.Sdk; using Meridian.Contracts.Ledger; using Meridian.Contracts.AssetOperations; +using Meridian.Contracts.Catalog; +using Meridian.Contracts.Domain; using Meridian.Contracts.Etl; using Meridian.Contracts.SecurityMaster; using Meridian.Contracts.Services; @@ -532,6 +534,11 @@ public static IServiceCollection AddWorkstationSharedServices(this IServiceColle sp.GetRequiredService()); services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => + new DailyValuationPositionService( + sp.GetService(), + sp.GetService(), + sp.GetService())); services.TryAddSingleton(sp => new FileAutomatedJournalScheduleStore( Path.Combine(ResolveWorkstationDataDirectory(sp), "accounting", "monthly-automated-journal-schedules.json"))); @@ -552,6 +559,8 @@ public static IServiceCollection AddWorkstationSharedServices(this IServiceColle sp.GetService())); services.TryAddSingleton(sp => (IManualJournalEntryLifecycleService)sp.GetRequiredService()); + services.TryAddSingleton(); + services.TryAddSingleton(AutomatedJournalEvidencePolicy.Default); services.TryAddSingleton(sp => new AutomatedJournalDraftIntakeService( sp.GetRequiredService(), @@ -561,14 +570,20 @@ public static IServiceCollection AddWorkstationSharedServices(this IServiceColle { var securityMaster = sp.GetService(); var providerRegistry = sp.GetService(); + var journalStore = sp.GetService(); + var positionService = sp.GetRequiredService(); return new AutomatedJournalIntakeRunner( sp.GetRequiredService(), new FeeScheduleAccrualEventProducer(), securityMaster is null ? null : new CorporateActionDividendEventProducer(securityMaster), sp.GetService(), - providerRegistry is null + providerRegistry is null || journalStore is null ? null - : new DailyMarkToMarketService(new RegisteredHistoricalCloseMarkPriceSource(providerRegistry))); + : new DailyMarkToMarketService( + new RegisteredHistoricalCloseMarkPriceSource(providerRegistry), + new LedgerMarkToMarketCarryingValueSource(journalStore)), + positionService, + sp.GetRequiredService()); }); // The durable ledger book service is only registered when a persistence-backed ledger is // configured (see StorageFeatureRegistration). Resolve it optionally so the workstation graph diff --git a/src/Meridian.Ui/dashboard/README.md b/src/Meridian.Ui/dashboard/README.md index a91797107f..088f7150f0 100644 --- a/src/Meridian.Ui/dashboard/README.md +++ b/src/Meridian.Ui/dashboard/README.md @@ -74,6 +74,12 @@ Current dense-row detail consumers covered by regression tests include Portfolio Portfolio run evidence, Trading recent fills, Data backfill queue rows, Data export rows, and Security Master lots. +The Data backfill workstream reads `/api/backfill/executions` as the durable remediation evidence +source. Its remediation SLA queue keeps server-owned tier, deadline, status, provider, workflow, +owner-assignment, outcome, and compatibility-derived provenance visible, with operator sorting on +SLA tier and deadline. Live provider-attempt progress remains a separate bounded projection so a +dropped transient notification cannot erase the retained execution/SLA record. + ## Important workflows The browser workstation exposes `/accounting/entity-setup` for the shared fund-structure setup wizard. The feature posts drafts to `/api/fund-structure/setup-drafts/validate` for validation and preview, then `/api/fund-structure/setup-drafts/create` for review-and-create instead of reimplementing setup orchestration in React. diff --git a/src/Meridian.Ui/dashboard/src/components/meridian/workspace-nav.tsx b/src/Meridian.Ui/dashboard/src/components/meridian/workspace-nav.tsx index 939376919a..a4b4253c8c 100644 --- a/src/Meridian.Ui/dashboard/src/components/meridian/workspace-nav.tsx +++ b/src/Meridian.Ui/dashboard/src/components/meridian/workspace-nav.tsx @@ -23,10 +23,6 @@ import type { AppShellOperatingScopeInput } from "@/app-shell.operating-scope"; * **Status tones** for nav items are one of: * `"live"`, `"review"`, `"paper"`, `"preview"`, `"setup"`, or `"muted"` — each has a * matching `.operator-nav-status-*` CSS modifier. - * - * @example - * // Mount inside the .workstation-shell grid: - * */ interface WorkspaceNavProps { className?: string; diff --git a/src/Meridian.Ui/dashboard/src/design-system/button.tsx b/src/Meridian.Ui/dashboard/src/design-system/button.tsx index da02a81d14..88540b701d 100644 --- a/src/Meridian.Ui/dashboard/src/design-system/button.tsx +++ b/src/Meridian.Ui/dashboard/src/design-system/button.tsx @@ -130,6 +130,7 @@ export const DesignSystemButton = forwardRef {vm.showBusyIndicator && } diff --git a/src/Meridian.Ui/dashboard/src/lib/api.ts b/src/Meridian.Ui/dashboard/src/lib/api.ts index 9779eab71c..be08835357 100644 --- a/src/Meridian.Ui/dashboard/src/lib/api.ts +++ b/src/Meridian.Ui/dashboard/src/lib/api.ts @@ -1,5 +1,6 @@ import type { BackfillPreviewResult, + BackfillExecutionHistoryResponse, BackfillProgressResponse, BackfillTriggerRequest, BackfillTriggerResult, @@ -56,6 +57,10 @@ import type { DataUploadTemplateCatalog, DataUploadWorkbookPreviewResult, DataWorkspaceResponse, + DailyValuationBatchLifecycleRequest, + DailyValuationBatchLifecycleResult, + DailyValuationScheduleWorkItem, + DailyValuationScheduledBatchResult, EquityCurveSummary, EvidenceCompleteness, EvidenceGraph, @@ -2176,6 +2181,43 @@ export function applyManualJournalEntryLifecycleAction( return postJson(WORKSTATION_API_ENDPOINTS.manualJournalEntryLifecycleAction, request, options); } +export function listDailyValuationSchedules(options: ApiRequestOptions = {}) { + return getJson( + WORKSTATION_API_ENDPOINTS.dailyValuationSchedules, + options + ); +} + +export function configureDailyValuationSchedule( + request: DailyValuationScheduleWorkItem, + options: ApiRequestOptions = {} +) { + return postJson( + WORKSTATION_API_ENDPOINTS.dailyValuationSchedules, + request, + options + ); +} + +export function runDueDailyValuationSchedules(options: ApiRequestOptions = {}) { + return postJson( + WORKSTATION_API_ENDPOINTS.dailyValuationRunDue, + undefined, + options + ); +} + +export function approveAndPostDailyValuationBatch( + request: DailyValuationBatchLifecycleRequest, + options: ApiRequestOptions = {} +) { + return postJson( + WORKSTATION_API_ENDPOINTS.dailyValuationBatchLifecycle, + request, + options + ); +} + export function getAccountingSystemProviders(options: ApiRequestOptions = {}) { return getJson(ACCOUNTING_SYSTEM_API_ENDPOINTS.providers, options); } @@ -3262,6 +3304,11 @@ export function getBackfillProgress(options: ApiRequestOptions = {}) { return getJson(BACKFILL_API_ENDPOINTS.progress, options); } +export function getBackfillExecutionHistory(limit = 100, options: ApiRequestOptions = {}) { + const query = new URLSearchParams({ limit: String(Math.max(1, Math.min(limit, 1000))) }); + return getJson(`${BACKFILL_API_ENDPOINTS.executions}?${query}`, options); +} + export function triggerBackfill(request: BackfillTriggerRequest) { return postJson(BACKFILL_API_ENDPOINTS.run, request); } diff --git a/src/Meridian.Ui/dashboard/src/lib/ui-api-routes.generated.ts b/src/Meridian.Ui/dashboard/src/lib/ui-api-routes.generated.ts index 1e5d11a4cc..82e062db46 100644 --- a/src/Meridian.Ui/dashboard/src/lib/ui-api-routes.generated.ts +++ b/src/Meridian.Ui/dashboard/src/lib/ui-api-routes.generated.ts @@ -672,6 +672,7 @@ export const UI_API_ROUTES = { LedgerCloseManagementTaskSignOffs: "/api/ledger/close-management/task-signoffs", LedgerCloseManagementEvidenceReview: "/api/ledger/close-management/evidence-review", LedgerCloseManagementPeriodLock: "/api/ledger/close-management/period-lock", + LedgerCloseManagementPeriodReopen: "/api/ledger/close-management/period-reopen", LedgerManualJournalEntryWorkbench: "/api/ledger/journal-entry-workbench", LedgerPrivateCapitalActivity: "/api/ledger/private-capital/activity", LedgerPrivateCapitalFundEventRecord: "/api/ledger/private-capital/fund-event-record", @@ -690,6 +691,7 @@ export const UI_API_ROUTES = { LedgerJournalAutomationDailyMarkToMarketIntake: "/api/ledger/journal-automation/daily-mark-to-market-intake", LedgerJournalAutomationDailyMarkToMarketSchedules: "/api/ledger/journal-automation/daily-mark-to-market-schedules", LedgerJournalAutomationDailyMarkToMarketRunDue: "/api/ledger/journal-automation/daily-mark-to-market-run-due", + LedgerJournalAutomationDailyMarkToMarketBatchLifecycle: "/api/ledger/journal-automation/daily-mark-to-market-batch-lifecycle", LedgerJournalAutomationMonthlySchedules: "/api/ledger/journal-automation/monthly-schedules", LedgerJournalAutomationMonthlyRunDue: "/api/ledger/journal-automation/monthly-schedules/run-due", LedgerReportsTrialBalance: "/api/ledger/reports/trial-balance", diff --git a/src/Meridian.Ui/dashboard/src/lib/workstation-endpoints.ts b/src/Meridian.Ui/dashboard/src/lib/workstation-endpoints.ts index 19a14061eb..e87dfddbc8 100644 --- a/src/Meridian.Ui/dashboard/src/lib/workstation-endpoints.ts +++ b/src/Meridian.Ui/dashboard/src/lib/workstation-endpoints.ts @@ -60,6 +60,9 @@ export const WORKSTATION_API_ENDPOINTS = { manualJournalEntrySubmitApproval: UI_API_ROUTES.LedgerManualJournalEntrySubmitApproval, manualJournalEntryEvidence: UI_API_ROUTES.LedgerManualJournalEntryEvidence, manualJournalEntryLifecycleAction: UI_API_ROUTES.LedgerManualJournalEntryLifecycleAction, + dailyValuationSchedules: UI_API_ROUTES.LedgerJournalAutomationDailyMarkToMarketSchedules, + dailyValuationRunDue: UI_API_ROUTES.LedgerJournalAutomationDailyMarkToMarketRunDue, + dailyValuationBatchLifecycle: UI_API_ROUTES.LedgerJournalAutomationDailyMarkToMarketBatchLifecycle, accountingReportPackage: UI_API_ROUTES.LedgerReportsAccountingPackage, accountingReportPackages: UI_API_ROUTES.LedgerReportsAccountingPackages, accountingReportPackageExport: UI_API_ROUTES.LedgerReportsAccountingPackageExport, @@ -394,6 +397,7 @@ export const BACKFILL_API_ENDPOINTS = { checkpoints: UI_API_ROUTES.BackfillCheckpoints, checkpointsResumable: UI_API_ROUTES.BackfillCheckpointsResumable, checkpointsValidation: UI_API_ROUTES.BackfillCheckpointsValidation, + executions: UI_API_ROUTES.BackfillExecutions, progress: UI_API_ROUTES.BackfillProgress, run: UI_API_ROUTES.BackfillRun, runPreview: UI_API_ROUTES.BackfillRunPreview diff --git a/src/Meridian.Ui/dashboard/src/screens/accounting-screen.close-cockpit-panels.tsx b/src/Meridian.Ui/dashboard/src/screens/accounting-screen.close-cockpit-panels.tsx index eaddf6f9e9..1bc6a37468 100644 --- a/src/Meridian.Ui/dashboard/src/screens/accounting-screen.close-cockpit-panels.tsx +++ b/src/Meridian.Ui/dashboard/src/screens/accounting-screen.close-cockpit-panels.tsx @@ -117,10 +117,16 @@ export function AccountingWorkflowLaunchPanel({ view }: { view: AccountingWorkfl export function CloseCommandCenterPanel({ view, - onRefresh + onRefresh, + onCommand, + activeCommand = null, + commandStatusText = null }: { view: CloseCommandCenterViewState; onRefresh: () => void; + onCommand?: (command: NonNullable) => void; + activeCommand?: NonNullable | null; + commandStatusText?: string | null; }) { return (
@@ -230,11 +236,27 @@ export function CloseCommandCenterPanel({
Close actions
- {view.actionRows.map((action) => ( + {view.actionRows.map((action) => action.command && onCommand ? ( + + ) : ( ))} + {commandStatusText ?

{commandStatusText}

: null}
@@ -489,6 +511,19 @@ export function AccountingCloseReportPackagePanel({ view }: { view: AccountingCl {view.configureClosePlanStatusText} ) : null} + {view.queueClosingEntriesStatusText ? ( +
+ {view.queueClosingEntriesStatusText} +
+ ) : null} {view.lockClosePeriodStatusText ? (
+
+
+
+
Post closing entries
+

+ Shared close-service projection of the scoped temporary-account roll before period lock. +

+
+
+ + {view.closingEntriesGate?.statusLabel ?? "Not supplied"} + + +
+
+ {view.closingEntriesGate ? ( +
+
+
+
Net-income roll
+
{view.closingEntriesGate.netIncomeRollLabel}
+
+
+
Scoped balances
+
{view.closingEntriesGate.temporaryAccountBalanceLabel}
+
+
+
Lock posture
+
+ {view.closingEntriesGate.isReadyForLock ? "Ready for lock" : "Posting required before lock"} +
+
+
+
+ {view.closingEntriesGate.detail} + {view.closingEntriesGate.draftLabel} + {view.closingEntriesGate.idempotencyLabel} + {view.closingEntriesGate.closingBatchLabel} + {view.closingEntriesGate.reversalDraftLabel} + {view.closingEntriesGate.evidenceLabel} +
+ {view.closingEntriesGate.balances.length > 0 ? ( +
+ + + + + + + + + + + + {view.closingEntriesGate.balances.map((balance) => ( + + + + + + + + ))} + +
AccountTypeBalanceScopeFinancial account
{balance.accountLabel}{balance.accountTypeLabel}{balance.balanceLabel}{balance.scopeLabel}{balance.financialAccountLabel}
+
+ ) : ( +

No non-zero scoped temporary-account balances were returned.

+ )} +
+ ) : ( +

+ The shared close plan did not return the typed closing-entry posting gate. +

+ )} +
+
Operating coverage
diff --git a/src/Meridian.Ui/dashboard/src/screens/accounting-screen.close-cockpit.view-model.test.ts b/src/Meridian.Ui/dashboard/src/screens/accounting-screen.close-cockpit.view-model.test.ts index 6cb17ecf1a..bb231925d0 100644 --- a/src/Meridian.Ui/dashboard/src/screens/accounting-screen.close-cockpit.view-model.test.ts +++ b/src/Meridian.Ui/dashboard/src/screens/accounting-screen.close-cockpit.view-model.test.ts @@ -12,6 +12,7 @@ import type { AccountingSystemReconciliationSummary, AccountingWorkspaceResponse, ClosePeriodPlan, + DailyValuationScheduleWorkItem, FinancialOperationsCommandCenter, MultiAssetCoverageSummary, OperationsContinuityWorkflow, @@ -294,6 +295,7 @@ const closeWorkflow: OperationsContinuityWorkflow = { const closePeriodPlan: ClosePeriodPlan = { closePlanId: "close-plan-alpha-202605", + workflowVersion: 19, fundProfileId: "fund-alpha", ledgerBookId: "book-alpha", periodId: "2026-05", @@ -505,7 +507,58 @@ const closePeriodPlan: ClosePeriodPlan = { evidenceLinks: [], blockingIssues: [] } - ] + ], + closingEntriesGate: { + gateId: "closing-entries:book-alpha:2026-05", + label: "Post closing entries", + state: "DraftQueued", + isReadyForLock: false, + netIncomeRoll: 1500, + temporaryAccountBalanceCount: 2, + detail: "A closing-entry draft is queued for controller approval and posting.", + draftJournalEntryId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + draftStatus: "Draft", + idempotencyKey: "closing-entries:book-alpha:2026-05:v1", + balances: [ + { + accountName: "Advisory fee revenue", + accountType: "Revenue", + balance: 2500, + symbol: "ADV-FEE", + financialAccountId: "financial-account-revenue", + dimensions: { + fundId: "fund-alpha", + entityId: "entity-alpha", + sleeveId: "sleeve-credit", + externalGlDimensions: { class: "private-fund" } + } + }, + { + accountName: "Fund administration expense", + accountType: "Expense", + balance: -1000, + financialAccountId: "financial-account-expense", + dimensions: { + fundId: "fund-alpha", + entityId: "entity-alpha", + costCenterId: "fund-operations" + } + } + ], + evidenceLinks: ["evidence/closing-entry-preview"], + closingBatchJournalEntryIds: ["11111111-2222-3333-4444-555555555555"], + reversalDraftJournalEntryIds: ["66666666-7777-8888-9999-aaaaaaaaaaaa"] + } +}; + +const postedClosePeriodPlan: ClosePeriodPlan = { + ...closePeriodPlan, + closingEntriesGate: { + ...closePeriodPlan.closingEntriesGate!, + state: "Posted", + isReadyForLock: true, + detail: "Closing entries are posted and retained for period lock." + } }; const accountingReportPackage: AccountingReportPackageBundle = { @@ -795,10 +848,10 @@ describe("accounting-screen close-cockpit view model", () => { const createLateAdjustment = vi.fn(async () => closePeriodPlan); const reviewLateAdjustment = vi.fn(async () => closePeriodPlan); const signOffCloseTask = vi.fn(async () => closePeriodPlan); - const configureClosePlan = vi.fn(async () => closePeriodPlan); + const configureClosePlan = vi.fn(async () => postedClosePeriodPlan); const lockClosePeriod = vi.fn(async () => ({ isLocked: true, - plan: { ...closePeriodPlan, isPeriodLocked: true }, + plan: { ...postedClosePeriodPlan, isPeriodLocked: true }, transition: null, issues: [] })); @@ -990,6 +1043,36 @@ describe("accounting-screen close-cockpit view model", () => { requiredAction: "Retain close-package evidence and lock the period." }) ])); + expect(result.current.closingEntriesGate).toMatchObject({ + gateId: "closing-entries:book-alpha:2026-05", + label: "Post closing entries", + statusLabel: "Draft queued", + statusTone: "warning", + isReadyForLock: false, + netIncomeRollLabel: "+$1,500.00 USD", + temporaryAccountBalanceLabel: "2 temporary-account balances", + draftLabel: "Draft aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee | Draft", + idempotencyLabel: "Idempotency closing-entries:book-alpha:2026-05:v1", + closingBatchLabel: "1 closing batch journal entry: 11111111-2222-3333-4444-555555555555", + reversalDraftLabel: "1 reversal draft journal entry: 66666666-7777-8888-9999-aaaaaaaaaaaa", + evidenceLabel: "1 evidence link" + }); + expect(result.current.closingEntriesGate?.balances).toEqual([ + expect.objectContaining({ + accountLabel: "Advisory fee revenue (ADV-FEE)", + accountTypeLabel: "Revenue", + balanceLabel: "+$2,500.00 USD", + scopeLabel: "Fund: fund-alpha | Entity: entity-alpha | Sleeve: sleeve-credit | External class: private-fund", + financialAccountLabel: "financial-account-revenue" + }), + expect.objectContaining({ + accountLabel: "Fund administration expense", + accountTypeLabel: "Expense", + balanceLabel: "-$1,000.00 USD", + scopeLabel: "Fund: fund-alpha | Entity: entity-alpha | Cost center: fund-operations", + financialAccountLabel: "financial-account-expense" + }) + ]); expect(result.current.evidenceReviewRows).toEqual(expect.arrayContaining([ expect.objectContaining({ rowId: "task-evidence-task-nav", @@ -1161,7 +1244,7 @@ describe("accounting-screen close-cockpit view model", () => { label: "Period lock", statusLabel: "Open", actionId: "lock-period", - disabledReason: null, + disabledReason: "Closing entries must be Posted or Not required before period lock; current state is Draft queued.", tone: "default" }) ])); @@ -1253,7 +1336,7 @@ describe("accounting-screen close-cockpit view model", () => { expect(lockClosePeriod).toHaveBeenCalledWith(expect.objectContaining({ workflowId: "workflow-close-1", - expectedWorkflowVersion: closeWorkflow.version, + expectedWorkflowVersion: closePeriodPlan.workflowVersion, actor: "browser-accounting-controller", rationale: "Lock close period 2026-05 after close checklist and report package review.", reportPackId: "accounting-report-package-alpha-202605", @@ -1262,6 +1345,7 @@ describe("accounting-screen close-cockpit view model", () => { closePackageManifestId: "close-manifest-2026-05", closePackageRetainedManifestRoute: "/api/ledger/reports/accounting-packages/accounting-report-package-alpha-202605/exports/report-export-financial-statements", actionOrigin: "HumanOperator", + prepareClosingEntriesOnly: false, checklistControlApprovals: [ { taskId: "task-nav", @@ -1329,6 +1413,136 @@ describe("accounting-screen close-cockpit view model", () => { expect(signOffCloseTask).not.toHaveBeenCalled(); }); + it("queues required closing entries with the close-plan workflow version and refreshes the gate", async () => { + const requiredClosePlan: ClosePeriodPlan = { + ...closePeriodPlan, + workflowVersion: 27, + closingEntriesGate: { + ...closePeriodPlan.closingEntriesGate!, + state: "Required", + isReadyForLock: false, + detail: "Non-zero temporary-account balances require closing entries before period lock.", + draftJournalEntryId: null, + draftStatus: null, + closingBatchJournalEntryIds: [], + reversalDraftJournalEntryIds: [] + } + }; + const queuedClosePlan: ClosePeriodPlan = { + ...requiredClosePlan, + workflowVersion: 28, + closingEntriesGate: { + ...requiredClosePlan.closingEntriesGate!, + state: "DraftQueued", + detail: "A closing-entry draft is queued for controller approval and posting." + } + }; + const getClosePlan = vi.fn() + .mockResolvedValueOnce(requiredClosePlan) + .mockResolvedValue(queuedClosePlan); + const lockClosePeriod = vi.fn(async () => ({ + isLocked: false, + plan: queuedClosePlan, + transition: null, + issues: [] + })); + const services: AccountingCloseReportPackageServices = { + getClosePlan, + createLateAdjustment: vi.fn(async () => requiredClosePlan), + reviewLateAdjustment: vi.fn(async () => requiredClosePlan), + signOffCloseTask: vi.fn(async () => requiredClosePlan), + reviewCloseEvidence: vi.fn(async () => requiredClosePlan), + configureClosePlan: vi.fn(async () => requiredClosePlan), + lockClosePeriod, + buildPackage: vi.fn(async () => accountingReportPackage), + certifyPackage: vi.fn(async () => accountingReportPackage), + getExportManifest: vi.fn(async () => accountingReportExportManifest), + listPackages: vi.fn(async () => [accountingReportPackage]) + }; + + const { result } = renderHook(() => useAccountingCloseReportPackageViewModel(closeWorkflow, services)); + + await waitFor(() => expect(result.current.closingEntriesGate?.statusLabel).toBe("Required")); + expect(result.current.queueClosingEntriesDisabledReason).toBeNull(); + expect(result.current.lockClosePeriodDisabledReason).toBe("Queue and post closing entries before locking the period."); + + await act(async () => { + await result.current.queueClosingEntries(); + }); + + expect(lockClosePeriod).toHaveBeenCalledWith(expect.objectContaining({ + workflowId: "workflow-close-1", + expectedWorkflowVersion: 27, + actor: "browser-accounting-controller", + rationale: "Prepare closing entries for close period 2026-05 before period lock.", + reportPackId: "accounting-report-package-alpha-202605", + correlationId: "browser-close-period-closing-entries-workflow-close-1", + prepareClosingEntriesOnly: true, + evidenceLinks: expect.arrayContaining([ + "browser://accounting/close/closing-entry-preparation/workflow-close-1", + "evidence://close-package/workflow/workflow-close-1/period/2026-05/book/book-alpha/closing-entry-preparation" + ]) + })); + expect(getClosePlan).toHaveBeenCalledTimes(2); + expect(result.current.queueClosingEntriesStatusText).toBe("Queued closing entries for 2026-05; state is Draft queued."); + expect(result.current.queueClosingEntriesStatusTone).toBe("success"); + expect(result.current.closingEntriesGate?.statusLabel).toBe("Draft queued"); + expect(result.current.queueClosingEntriesDisabledReason).toContain("current state is Draft queued"); + expect(result.current.lockClosePeriodDisabledReason).toContain("current state is Draft queued"); + }); + + it.each([ + { state: "Required", isReadyForLock: true }, + { state: "DraftQueued", isReadyForLock: true }, + { state: "Submitted", isReadyForLock: true }, + { state: "Approved", isReadyForLock: true }, + { state: "ReversalQueued", isReadyForLock: true }, + { state: "Blocked", isReadyForLock: true }, + { state: "Unavailable", isReadyForLock: true }, + { state: "Unexpected", isReadyForLock: true }, + { state: "Posted", isReadyForLock: false }, + { state: "NotRequired", isReadyForLock: false } + ])( + "keeps hard period lock disabled for gate state $state with readiness $isReadyForLock", + async ({ state, isReadyForLock }) => { + const intermediateClosePlan: ClosePeriodPlan = { + ...closePeriodPlan, + closingEntriesGate: { + ...closePeriodPlan.closingEntriesGate!, + state: state as NonNullable["state"], + isReadyForLock + } + }; + const lockClosePeriod = vi.fn(); + const services: AccountingCloseReportPackageServices = { + getClosePlan: vi.fn(async () => intermediateClosePlan), + createLateAdjustment: vi.fn(async () => intermediateClosePlan), + reviewLateAdjustment: vi.fn(async () => intermediateClosePlan), + signOffCloseTask: vi.fn(async () => intermediateClosePlan), + reviewCloseEvidence: vi.fn(async () => intermediateClosePlan), + configureClosePlan: vi.fn(async () => intermediateClosePlan), + lockClosePeriod, + buildPackage: vi.fn(async () => accountingReportPackage), + certifyPackage: vi.fn(async () => accountingReportPackage), + getExportManifest: vi.fn(async () => accountingReportExportManifest), + listPackages: vi.fn(async () => [accountingReportPackage]) + }; + + const { result } = renderHook(() => useAccountingCloseReportPackageViewModel(closeWorkflow, services)); + + await waitFor(() => expect(result.current.closingEntriesGate).not.toBeNull()); + expect(result.current.closingEntriesGate?.isReadyForLock).toBe(false); + expect(result.current.lockClosePeriodDisabledReason).toContain("Closing entries must be Posted or Not required before period lock"); + + await act(async () => { + await result.current.lockClosePeriod(); + }); + + expect(lockClosePeriod).not.toHaveBeenCalled(); + expect(result.current.lockClosePeriodStatusTone).toBe("danger"); + } + ); + it("selects retained close setup tasks before retaining dependency and sign-off edits", async () => { const multiTaskClosePlan: ClosePeriodPlan = { ...closePeriodPlan, @@ -2504,6 +2718,199 @@ describe("accounting-screen close-cockpit view model", () => { ])); }); + it("gates daily valuation schedule and batch commands from retained typed state", () => { + const currentDailyValuationSchedule: DailyValuationScheduleWorkItem = { + scheduleId: "daily-fund-alpha", + fundProfileId: "fund-alpha", + currency: "USD", + actor: "valuation-scheduler", + ledgerBookId: "11111111-1111-1111-1111-111111111111", + periodId: "22222222-2222-2222-2222-222222222222", + nextRunAtUtc: "2026-06-02T01:00:00Z", + positions: [], + policyId: "daily-close-policy", + policyName: "Approved daily close marks", + valuationMethod: "ClosingPrice", + policyApprovedBy: "controller", + policyApprovedAtUtc: "2026-05-01T00:00:00Z", + reason: "Retained daily close valuation schedule.", + isEnabled: true, + entityId: "entity-alpha", + tenantId: "tenant-alpha", + companyId: "company-alpha", + state: "DraftReady" + }; + const commandCenter: FinancialOperationsCommandCenter = { + generatedAtUtc: "2026-06-01T05:00:00Z", + fundProfileId: "fund-alpha", + ledgerBookId: "11111111-1111-1111-1111-111111111111", + fundAccountId: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + periodId: "2026-06", + status: "ReviewRequired", + isReadyToComplete: false, + summary: "Daily valuation drafts await controller approval.", + activeItemCount: 1, + blockedItemCount: 0, + reviewItemCount: 1, + metrics: [], + queueRows: [], + activeWorkflow: null, + closeCalendar: null, + closeSupportDecision: null, + privateCapitalCloseCockpit: { + fundProfileId: "fund-alpha", + ledgerBookId: "11111111-1111-1111-1111-111111111111", + fundAccountId: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + periodId: "2026-06", + entityId: "entity-alpha", + projectedAtUtc: "2026-06-01T05:00:00Z", + cockpitRoute: "/accounting/close", + overallStatus: "ReviewRequired", + isReadyToClose: false, + readinessScore: 80, + workflowCount: 1, + fundEventCount: 0, + capitalAccountCount: 0, + reportOutputCount: 0, + deliveredReportOutputCount: 0, + readyLaneCount: 0, + blockedLaneCount: 1, + lanes: [], + workflows: [], + blockers: [], + nextActions: [], + liveCapabilities: [], + plannedCapabilities: [], + dailyValuationStatus: { + scheduleId: "daily-fund-alpha", + fundProfileId: "fund-alpha", + ledgerBookId: "11111111-1111-1111-1111-111111111111", + periodId: "2026-06", + isConfigured: true, + isEnabled: true, + nextRunAtUtc: "2026-06-02T01:00:00Z", + lastRunAtUtc: "2026-06-01T01:00:00Z", + state: "DraftReady", + summary: "Two retained valuation drafts are ready.", + evidenceLinks: [], + blockers: [], + journalEntryIds: [ + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1", + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2" + ], + batchCorrelationId: "daily-valuation:fund-alpha:2026-06-01" + } + } + }; + + const state = buildCloseCommandCenterViewState({ + data: accountingWorkspace, + commandCenter, + commandCenterLoading: false, + commandCenterError: null, + workflow: closeWorkflow, + workflowLoading: false, + workflowError: null, + accountingSystemProviders: [accountingSystemProvider], + accountingSystemImport: null, + accountingSystemReconciliation, + multiAssetCoverage, + currentDailyValuationSchedule + }); + + expect(state.actionRows[0]).toMatchObject({ + id: "daily-valuation-configure", + command: "configure-daily-valuation-schedule", + disabledReason: "Complete or correct the retained daily valuation batch before reconfiguring its schedule." + }); + expect(state.actionRows[1]).toMatchObject({ + id: "daily-valuation-run-due", + command: "run-due-daily-valuation-schedules", + disabledReason: "Run due is available only from Scheduled state; current state is DraftReady." + }); + expect(state.actionRows[2]).toMatchObject({ + id: "daily-valuation-approve-post", + label: "Approve and post 2 valuation drafts", + command: "approve-daily-valuation-batch", + ariaLabel: "Approve and post the complete retained daily valuation batch" + }); + + const scheduledCommandCenter: FinancialOperationsCommandCenter = { + ...commandCenter, + privateCapitalCloseCockpit: { + ...commandCenter.privateCapitalCloseCockpit!, + dailyValuationStatus: { + ...commandCenter.privateCapitalCloseCockpit!.dailyValuationStatus!, + state: "Scheduled", + summary: "Daily valuation is scheduled.", + journalEntryId: null, + journalEntryIds: [], + batchCorrelationId: null + } + } + }; + const scheduledState = buildCloseCommandCenterViewState({ + data: accountingWorkspace, + commandCenter: scheduledCommandCenter, + commandCenterLoading: false, + commandCenterError: null, + workflow: closeWorkflow, + workflowLoading: false, + workflowError: null, + accountingSystemProviders: [accountingSystemProvider], + accountingSystemImport: null, + accountingSystemReconciliation, + multiAssetCoverage, + currentDailyValuationSchedule: { ...currentDailyValuationSchedule, state: "Scheduled" } + }); + expect(scheduledState.actionRows.slice(0, 2)).toEqual([ + expect.objectContaining({ + command: "configure-daily-valuation-schedule", + disabledReason: null + }), + expect.objectContaining({ + command: "run-due-daily-valuation-schedules", + disabledReason: null + }) + ]); + + const blockedCommandCenter: FinancialOperationsCommandCenter = { + ...commandCenter, + privateCapitalCloseCockpit: { + ...commandCenter.privateCapitalCloseCockpit!, + dailyValuationStatus: { + ...commandCenter.privateCapitalCloseCockpit!.dailyValuationStatus!, + state: "Blocked", + summary: "One of two valuation drafts posted before a retained blocker stopped the batch.", + blockers: ["Draft correction is required."], + batchCorrelationId: "daily-valuation:fund-alpha:2026-06-01" + } + } + }; + const blockedState = buildCloseCommandCenterViewState({ + data: accountingWorkspace, + commandCenter: blockedCommandCenter, + commandCenterLoading: false, + commandCenterError: null, + workflow: closeWorkflow, + workflowLoading: false, + workflowError: null, + accountingSystemProviders: [accountingSystemProvider], + accountingSystemImport: null, + accountingSystemReconciliation, + multiAssetCoverage, + currentDailyValuationSchedule: { ...currentDailyValuationSchedule, state: "Blocked" } + }); + expect(blockedState.actionRows[0].disabledReason).toContain("retained daily valuation batch"); + expect(blockedState.actionRows[1].disabledReason).toContain("current state is Blocked"); + expect(blockedState.actionRows[2]).toMatchObject({ + id: "daily-valuation-retry-batch", + label: "Correct and retry 2 valuation drafts", + command: "retry-daily-valuation-batch", + disabledReason: null + }); + }); + it("surfaces shared FINOPS queue owner due evidence action and impact metadata", () => { const commandCenter: FinancialOperationsCommandCenter = { generatedAtUtc: "2026-06-01T05:00:00Z", @@ -2581,4 +2988,3 @@ describe("accounting-screen close-cockpit view model", () => { }); }); }); - diff --git a/src/Meridian.Ui/dashboard/src/screens/accounting-screen.close-cockpit.view-model.ts b/src/Meridian.Ui/dashboard/src/screens/accounting-screen.close-cockpit.view-model.ts index 8ae5ee016a..064716d553 100644 --- a/src/Meridian.Ui/dashboard/src/screens/accounting-screen.close-cockpit.view-model.ts +++ b/src/Meridian.Ui/dashboard/src/screens/accounting-screen.close-cockpit.view-model.ts @@ -34,6 +34,7 @@ import type { AccountingCloseDependencyGraphRowViewModel, AccountingCloseEvidenceReviewRowViewModel, AccountingCloseOperatingCoverageRowViewModel, + AccountingClosePostingGateViewModel, AccountingClosePlanTaskRowViewModel, AccountingCloseReportPackageServices, AccountingCloseReportPackageViewModel, @@ -71,10 +72,13 @@ import type { CloseCalendarMilestone, CloseDependency, ClosePeriodPlan, + ClosePostingGateState, CloseSignOffRequirement, CloseTask, + DailyValuationScheduleWorkItem, FinancialOperationsCommandCenter, LateAdjustmentRequest, + LedgerDimensionSet, LockClosePeriodRequest, MultiAssetCoverageSummary, OperationsContinuityWorkflow, @@ -132,6 +136,9 @@ export function useAccountingCloseReportPackageViewModel( const [lockClosePeriodBusy, setLockClosePeriodBusy] = useState(false); const [lockClosePeriodStatusText, setLockClosePeriodStatusText] = useState(null); const [lockClosePeriodStatusTone, setLockClosePeriodStatusTone] = useState<"neutral" | "success" | "danger">("neutral"); + const [queueClosingEntriesBusy, setQueueClosingEntriesBusy] = useState(false); + const [queueClosingEntriesStatusText, setQueueClosingEntriesStatusText] = useState(null); + const [queueClosingEntriesStatusTone, setQueueClosingEntriesStatusTone] = useState<"neutral" | "success" | "danger">("neutral"); const [configureClosePlanBusy, setConfigureClosePlanBusy] = useState(false); const [configureClosePlanStatusText, setConfigureClosePlanStatusText] = useState(null); const [configureClosePlanStatusTone, setConfigureClosePlanStatusTone] = useState<"neutral" | "success" | "danger">("neutral"); @@ -303,6 +310,67 @@ export function useAccountingCloseReportPackageViewModel( } }, [closePlan, closeSignOffDraft, services, workflow]); + const queueClosingEntries = useCallback(async () => { + if (!workflow || !closePlan) { + setQueueClosingEntriesStatusText("A close plan is required before queueing closing entries."); + setQueueClosingEntriesStatusTone("danger"); + return; + } + + if (closePlan.isPeriodLocked) { + setQueueClosingEntriesStatusText("The period is already locked; closing entries cannot be queued."); + setQueueClosingEntriesStatusTone("danger"); + return; + } + + if (closePlan.closingEntriesGate?.state !== "Required") { + const stateLabel = closePlan.closingEntriesGate + ? formatClosePostingGateState(closePlan.closingEntriesGate.state).label + : "Not supplied"; + setQueueClosingEntriesStatusText(`Closing entries can only be queued from Required state; current state is ${stateLabel}.`); + setQueueClosingEntriesStatusTone("danger"); + return; + } + + const selectedBundle = packages.find((bundle) => bundle.financialStatements.packageId === selectedPackageId) ?? packages[0] ?? null; + setQueueClosingEntriesBusy(true); + setQueueClosingEntriesStatusText(null); + setQueueClosingEntriesStatusTone("neutral"); + try { + const result = await services.lockClosePeriod( + buildClosePeriodLockRequest(workflow, closePlan, selectedBundle, true) + ); + if (result.plan) { + setClosePlan(result.plan); + } + + const blockingIssueCount = result.issues.filter((issue) => issue.severity === "Critical").length; + const preparedState = result.plan?.closingEntriesGate?.state; + if (blockingIssueCount > 0) { + setQueueClosingEntriesStatusText(`Closing-entry preparation blocked by ${formatCount(blockingIssueCount, "critical issue")}.`); + setQueueClosingEntriesStatusTone("danger"); + } else if (preparedState && preparedState !== "Required" && preparedState !== "Blocked" && preparedState !== "Unavailable") { + setQueueClosingEntriesStatusText( + `Queued closing entries for ${result.plan?.periodId ?? closePlan.periodId}; state is ${formatClosePostingGateState(preparedState).label}.` + ); + setQueueClosingEntriesStatusTone("success"); + } else if (result.issues.length > 0) { + setQueueClosingEntriesStatusText(result.issues.map((issue) => issue.message).join(" ")); + setQueueClosingEntriesStatusTone("neutral"); + } else { + setQueueClosingEntriesStatusText("Closing-entry preparation did not advance beyond Required state."); + setQueueClosingEntriesStatusTone("danger"); + } + + await refresh(); + } catch (error) { + setQueueClosingEntriesStatusText(formatAccountingWorkflowError(error, "Closing entries could not be queued.")); + setQueueClosingEntriesStatusTone("danger"); + } finally { + setQueueClosingEntriesBusy(false); + } + }, [closePlan, packages, refresh, selectedPackageId, services, workflow]); + const lockClosePeriod = useCallback(async () => { if (!workflow || !closePlan) { setLockClosePeriodStatusText("A close plan is required before locking the close period."); @@ -316,12 +384,23 @@ export function useAccountingCloseReportPackageViewModel( return; } + if (!isClosePostingGateReadyForHardLock(closePlan.closingEntriesGate ?? null)) { + const stateLabel = closePlan.closingEntriesGate + ? formatClosePostingGateState(closePlan.closingEntriesGate.state).label + : "Not supplied"; + setLockClosePeriodStatusText(`Closing entries are not ready for period lock; current state is ${stateLabel}.`); + setLockClosePeriodStatusTone("danger"); + return; + } + const selectedBundle = packages.find((bundle) => bundle.financialStatements.packageId === selectedPackageId) ?? packages[0] ?? null; setLockClosePeriodBusy(true); setLockClosePeriodStatusText(null); setLockClosePeriodStatusTone("neutral"); try { - const result = await services.lockClosePeriod(buildClosePeriodLockRequest(workflow, closePlan, selectedBundle)); + const result = await services.lockClosePeriod( + buildClosePeriodLockRequest(workflow, closePlan, selectedBundle, false) + ); if (result.plan) { setClosePlan(result.plan); } @@ -680,6 +759,9 @@ export function useAccountingCloseReportPackageViewModel( lockClosePeriodBusy, lockClosePeriodStatusText, lockClosePeriodStatusTone, + queueClosingEntriesBusy, + queueClosingEntriesStatusText, + queueClosingEntriesStatusTone, configureClosePlanBusy, configureClosePlanStatusText, configureClosePlanStatusTone, @@ -703,6 +785,7 @@ export function useAccountingCloseReportPackageViewModel( buildReportPackage, certifyPackage, lockClosePeriod, + queueClosingEntries, configureClosePlan, signOffNextTask, updateCloseSetupDraft, @@ -734,6 +817,10 @@ export function useAccountingCloseReportPackageViewModel( lockClosePeriodBusy, lockClosePeriodStatusText, lockClosePeriodStatusTone, + queueClosingEntries, + queueClosingEntriesBusy, + queueClosingEntriesStatusText, + queueClosingEntriesStatusTone, configureClosePlan, configureClosePlanBusy, configureClosePlanStatusText, @@ -1025,6 +1112,9 @@ function buildAccountingCloseReportPackageViewState({ lockClosePeriodBusy, lockClosePeriodStatusText, lockClosePeriodStatusTone, + queueClosingEntriesBusy, + queueClosingEntriesStatusText, + queueClosingEntriesStatusTone, createLateAdjustmentBusy, createLateAdjustmentStatusText, createLateAdjustmentStatusTone, @@ -1048,6 +1138,7 @@ function buildAccountingCloseReportPackageViewState({ buildReportPackage, certifyPackage, lockClosePeriod, + queueClosingEntries, configureClosePlan, signOffNextTask, updateCloseSetupDraft, @@ -1083,6 +1174,9 @@ function buildAccountingCloseReportPackageViewState({ lockClosePeriodBusy: boolean; lockClosePeriodStatusText: string | null; lockClosePeriodStatusTone: "neutral" | "success" | "danger"; + queueClosingEntriesBusy: boolean; + queueClosingEntriesStatusText: string | null; + queueClosingEntriesStatusTone: "neutral" | "success" | "danger"; createLateAdjustmentBusy: boolean; createLateAdjustmentStatusText: string | null; createLateAdjustmentStatusTone: "neutral" | "success" | "danger"; @@ -1106,6 +1200,7 @@ function buildAccountingCloseReportPackageViewState({ buildReportPackage: () => Promise; certifyPackage: () => Promise; lockClosePeriod: () => Promise; + queueClosingEntries: () => Promise; configureClosePlan: () => Promise; signOffNextTask: () => Promise; updateCloseSetupDraft: (patch: Partial) => void; @@ -1136,6 +1231,7 @@ function buildAccountingCloseReportPackageViewState({ const dependencyGraphRows = closePlan ? buildCloseDependencyGraphRows(closePlan) : []; const signOffMatrixRows = closePlan ? buildCloseSignOffMatrixRows(closePlan) : []; const operatingCoverageRows = closePlan ? buildCloseOperatingCoverageRows(closePlan) : []; + const closingEntriesGate = closePlan ? buildClosePostingGateViewModel(closePlan) : null; const closeSetupTaskOptions = closePlan ? buildCloseSetupTaskOptions(closePlan, closeSetupDraft.taskId) : []; const closeSetupDependencyOptions = closePlan ? buildCloseSetupDependencyOptions(closePlan, closeSetupDraft) : []; const closeSetupSignOffRoleOptions = closePlan ? buildCloseSetupSignOffRoleOptions(closePlan, closeSetupDraft) : []; @@ -1181,7 +1277,7 @@ function buildAccountingCloseReportPackageViewState({ : "Materiality policy pending"; const buildDisabledReason = !workflow ? "A close workflow must be loaded before building a package." - : loading || buildBusy || certifyBusy || signOffBusy || lockClosePeriodBusy + : loading || buildBusy || certifyBusy || signOffBusy || lockClosePeriodBusy || queueClosingEntriesBusy ? "Close/report package refresh is running." : null; const criticalIssueCount = [...closeValidationIssues, ...packageValidationIssues].filter((issue) => issue.severity === "Critical").length; @@ -1193,7 +1289,7 @@ function buildAccountingCloseReportPackageViewState({ ? `Certification requires Ready for review state; current state is ${formatAccountingCertificationState(selectedBundle.certification.state)}.` : criticalIssueCount > 0 ? "Critical validation issues must be cleared before certification." - : loading || buildBusy || certifyBusy || signOffBusy || lockClosePeriodBusy + : loading || buildBusy || certifyBusy || signOffBusy || lockClosePeriodBusy || queueClosingEntriesBusy ? "Close/report package certification is running." : null; const signOffTarget = closePlan ? resolveCloseTaskSignOffDraftTarget(closePlan, closeSignOffDraft) : null; @@ -1206,20 +1302,42 @@ function buildAccountingCloseReportPackageViewState({ ? "The period is locked; close task sign-off is disabled." : signOffDraftValidation ? signOffDraftValidation - : loading || buildBusy || certifyBusy || signOffBusy || lockClosePeriodBusy + : loading || buildBusy || certifyBusy || signOffBusy || lockClosePeriodBusy || queueClosingEntriesBusy + ? "Close/report package action is running." + : null; + const queueClosingEntriesDisabledReason = !workflow + ? "A close workflow must be loaded before queueing closing entries." + : !closePlan + ? "A close plan must be loaded before queueing closing entries." + : locked + ? "The close period is already locked." + : closePlan.closingEntriesGate?.state !== "Required" + ? closePlan.closingEntriesGate + ? `Closing entries can only be queued from Required state; current state is ${formatClosePostingGateState(closePlan.closingEntriesGate.state).label}.` + : "The close plan must supply the typed closing-entry gate before entries can be queued." + : loading || buildBusy || certifyBusy || signOffBusy || lockClosePeriodBusy || queueClosingEntriesBusy ? "Close/report package action is running." : null; + const closingEntriesLockDisabledReason = closePlan && !isClosePostingGateReadyForHardLock(closePlan.closingEntriesGate ?? null) + ? closePlan.closingEntriesGate + ? closePlan.closingEntriesGate.state === "Required" + ? "Queue and post closing entries before locking the period." + : `Closing entries must be Posted or Not required before period lock; current state is ${formatClosePostingGateState(closePlan.closingEntriesGate.state).label}.` + : "The close plan must supply the typed closing-entry gate before period lock." + : null; const lockClosePeriodDisabledReason = !workflow ? "A close workflow must be loaded before locking the period." : !closePlan ? "A close plan must be loaded before locking the period." : locked ? "The close period is already locked." - : !selectedBundle - ? "A report package must be built before locking the period." - : loading || buildBusy || certifyBusy || signOffBusy || lockClosePeriodBusy - ? "Close/report package action is running." - : null; + : closingEntriesLockDisabledReason + ? closingEntriesLockDisabledReason + : !selectedBundle + ? "A report package must be built before locking the period." + : loading || buildBusy || certifyBusy || signOffBusy || lockClosePeriodBusy || queueClosingEntriesBusy + ? "Close/report package action is running." + : null; const configureClosePlanDisabledReason = !workflow ? "A close workflow must be loaded before configuring close setup." : !closePlan @@ -1229,7 +1347,7 @@ function buildAccountingCloseReportPackageViewState({ : validateCloseSetupMaterialityDraft(closeSetupDraft) ?? validateCloseSetupTaskSelection(closePlan, closeSetupDraft) ?? validateCloseSetupSignOffDraft(closeSetupDraft) - ?? (loading || buildBusy || certifyBusy || signOffBusy || lockClosePeriodBusy || configureClosePlanBusy + ?? (loading || buildBusy || certifyBusy || signOffBusy || lockClosePeriodBusy || queueClosingEntriesBusy || configureClosePlanBusy ? "Close/report package action is running." : null); const createLateAdjustmentAmount = Number(lateAdjustmentDraft.amount); @@ -1245,7 +1363,7 @@ function buildAccountingCloseReportPackageViewState({ ? "Enter a non-zero late adjustment amount." : !lateAdjustmentDraft.reason.trim() ? "Enter the late adjustment reason." - : loading || buildBusy || certifyBusy || signOffBusy || lockClosePeriodBusy || createLateAdjustmentBusy || reviewLateAdjustmentBusy + : loading || buildBusy || certifyBusy || signOffBusy || lockClosePeriodBusy || queueClosingEntriesBusy || createLateAdjustmentBusy || reviewLateAdjustmentBusy ? "Close/report package action is running." : null; const reviewLateAdjustmentDisabledReason = !workflow @@ -1256,7 +1374,7 @@ function buildAccountingCloseReportPackageViewState({ ? "The period is locked; late-adjustment review is disabled." : !lateAdjustments.some((adjustment) => adjustment.reviewDisabledReason === null) ? "No submitted late adjustment is ready for review." - : loading || buildBusy || certifyBusy || signOffBusy || lockClosePeriodBusy || createLateAdjustmentBusy || reviewLateAdjustmentBusy + : loading || buildBusy || certifyBusy || signOffBusy || lockClosePeriodBusy || queueClosingEntriesBusy || createLateAdjustmentBusy || reviewLateAdjustmentBusy ? "Close/report package action is running." : null; const reviewCloseEvidenceDisabledReason = !workflow @@ -1267,7 +1385,7 @@ function buildAccountingCloseReportPackageViewState({ ? "The period is locked; evidence review changes require a governed reopen workflow." : !evidenceReviewRows.some((row) => row.issueCode && !row.reviewDisabledReason) ? "No active close blocker is ready for evidence review." - : loading || buildBusy || certifyBusy || signOffBusy || lockClosePeriodBusy || reviewCloseEvidenceBusy + : loading || buildBusy || certifyBusy || signOffBusy || lockClosePeriodBusy || queueClosingEntriesBusy || reviewCloseEvidenceBusy ? "Close/report package action is running." : null; const selectedExportArtifact = selectedBundle?.exportArtifacts?.[0] ?? null; @@ -1275,7 +1393,7 @@ function buildAccountingCloseReportPackageViewState({ ? "A report package must be selected before export manifest inspection." : !selectedExportArtifact ? "The selected report package has no retained export artifacts." - : loading || buildBusy || certifyBusy || signOffBusy || lockClosePeriodBusy || reviewLateAdjustmentBusy || exportManifestBusy + : loading || buildBusy || certifyBusy || signOffBusy || lockClosePeriodBusy || queueClosingEntriesBusy || reviewLateAdjustmentBusy || exportManifestBusy ? "Close/report package action is running." : null; const closeWorkflowSteps = buildAccountingCloseWorkflowSteps({ @@ -1325,6 +1443,9 @@ function buildAccountingCloseReportPackageViewState({ lockClosePeriodBusy, lockClosePeriodStatusText, lockClosePeriodStatusTone, + queueClosingEntriesBusy, + queueClosingEntriesStatusText, + queueClosingEntriesStatusTone, configureClosePlanBusy, configureClosePlanStatusText, configureClosePlanStatusTone, @@ -1349,6 +1470,8 @@ function buildAccountingCloseReportPackageViewState({ signOffDisabledReason, lockClosePeriodButtonLabel: locked ? "Period locked" : "Lock period", lockClosePeriodDisabledReason, + queueClosingEntriesButtonLabel: "Queue closing entries", + queueClosingEntriesDisabledReason, configureClosePlanButtonLabel: closePlan ? "Retain close setup" : "Configure close setup", configureClosePlanDisabledReason, createLateAdjustmentDisabledReason, @@ -1410,17 +1533,19 @@ function buildAccountingCloseReportPackageViewState({ signOffMatrixRows, evidenceReviewRows, operatingCoverageRows, + closingEntriesGate, lateAdjustments, packageRows, selectedPackage, certificationSafeguards: buildAccountingReportCertificationSafeguards(closePlan, selectedBundle, criticalIssueCount), closeWorkflowSteps, validationIssues, - liveRegionText: `Close report package ${statusLabel}. ${formatCount(openTaskCount, "open task")}. ${formatCount(packageRows.length, "package")}. ${formatCount(closeWorkflowSteps.filter((step) => step.tone === "danger" || step.tone === "warning").length, "workflow step")} needs review.`, + liveRegionText: `Close report package ${statusLabel}. ${formatCount(openTaskCount, "open task")}. ${formatCount(packageRows.length, "package")}. ${formatCount(closeWorkflowSteps.filter((step) => step.tone === "danger" || step.tone === "warning").length, "workflow step")} needs review.${closingEntriesGate ? ` Closing entries ${closingEntriesGate.statusLabel}.` : ""}`, refresh, buildReportPackage, certifyPackage, lockClosePeriod, + queueClosingEntries, configureClosePlan, signOffNextTask, updateCloseSetupDraft, @@ -2489,6 +2614,116 @@ function buildCloseOperatingCoverageRows(closePlan: ClosePeriodPlan): Accounting })); } +function buildClosePostingGateViewModel(closePlan: ClosePeriodPlan): AccountingClosePostingGateViewModel | null { + const gate = closePlan.closingEntriesGate; + if (!gate) { + return null; + } + + const { label: statusLabel, tone: statusTone } = formatClosePostingGateState(gate.state); + const currency = closePlan.materialityPolicy.currency; + const closingBatchIds = gate.closingBatchJournalEntryIds ?? []; + const reversalDraftIds = gate.reversalDraftJournalEntryIds ?? []; + const balances = (gate.balances ?? []).map((balance, index) => ({ + rowId: `${gate.gateId}:${balance.financialAccountId?.trim() || balance.accountName}:${index}`, + accountLabel: balance.symbol?.trim() + ? `${balance.accountName} (${balance.symbol.trim()})` + : balance.accountName, + accountTypeLabel: balance.accountType, + balanceLabel: formatCurrencyWithCode(balance.balance, currency, true), + scopeLabel: formatClosePostingBalanceScope(balance.dimensions), + financialAccountLabel: balance.financialAccountId?.trim() || "No financial-account id" + })); + + return { + gateId: gate.gateId, + label: gate.label, + statusLabel, + statusTone, + isReadyForLock: isClosePostingGateReadyForHardLock(gate), + netIncomeRollLabel: formatCurrencyWithCode(gate.netIncomeRoll, currency, true), + temporaryAccountBalanceLabel: formatCount(gate.temporaryAccountBalanceCount, "temporary-account balance"), + detail: gate.detail, + draftLabel: gate.draftJournalEntryId + ? `Draft ${gate.draftJournalEntryId}${gate.draftStatus ? ` | ${gate.draftStatus}` : ""}` + : "No closing-entry draft queued", + idempotencyLabel: gate.idempotencyKey?.trim() + ? `Idempotency ${gate.idempotencyKey.trim()}` + : "No idempotency key returned", + closingBatchLabel: closingBatchIds.length > 0 + ? `${formatCount(closingBatchIds.length, "closing batch journal entry")}: ${closingBatchIds.join(", ")}` + : "No posted closing batch journal entries", + reversalDraftLabel: reversalDraftIds.length > 0 + ? `${formatCount(reversalDraftIds.length, "reversal draft journal entry")}: ${reversalDraftIds.join(", ")}` + : "No reversal drafts queued", + evidenceLabel: formatCount((gate.evidenceLinks ?? []).length, "evidence link"), + balances + }; +} + +function isClosePostingGateReadyForHardLock( + gate: NonNullable | null +): boolean { + if (!gate) { + return false; + } + + return gate.isReadyForLock && (gate.state === "Posted" || gate.state === "NotRequired"); +} + +function formatClosePostingGateState(state: ClosePostingGateState): { label: string; tone: AccountingToolingTone } { + switch (state) { + case "NotRequired": + return { label: "Not required", tone: "success" }; + case "Posted": + return { label: "Posted", tone: "success" }; + case "DraftQueued": + return { label: "Draft queued", tone: "warning" }; + case "Submitted": + return { label: "Submitted", tone: "warning" }; + case "Approved": + return { label: "Approved", tone: "warning" }; + case "ReversalQueued": + return { label: "Reversal queued", tone: "warning" }; + case "Required": + return { label: "Required", tone: "danger" }; + case "Blocked": + return { label: "Blocked", tone: "danger" }; + case "Unavailable": + default: + return { label: "Unavailable", tone: "danger" }; + } +} + +function formatClosePostingBalanceScope(dimensions: LedgerDimensionSet | null | undefined): string { + const labels = [ + ["Fund", dimensions?.fundId], + ["Entity", dimensions?.entityId], + ["Sleeve", dimensions?.sleeveId], + ["Strategy", dimensions?.strategyId], + ["Investor", dimensions?.investorId], + ["Capital account", dimensions?.capitalAccountId], + ["Instrument", dimensions?.instrumentId], + ["Position", dimensions?.positionId], + ["Tax lot", dimensions?.taxLotId], + ["Cost center", dimensions?.costCenterId], + ["Counterparty", dimensions?.counterpartyId], + ["Organization", dimensions?.organizationId], + ["Portfolio", dimensions?.portfolioId], + ["Book", dimensions?.bookId], + ["Account", dimensions?.accountId] + ] + .filter((entry): entry is [string, string] => Boolean(entry[1]?.trim())) + .map(([label, value]) => `${label}: ${value.trim()}`); + for (const [key, value] of Object.entries(dimensions?.externalGlDimensions ?? {}).sort(([left], [right]) => left.localeCompare(right))) { + if (value?.trim()) { + labels.push(`External ${key}: ${value.trim()}`); + } + } + + return labels.length > 0 ? labels.join(" | ") : "No scoped dimensions returned"; +} + function buildCloseEvidenceReviewRows( closePlan: ClosePeriodPlan | null, bundle: AccountingReportPackageBundle | null, @@ -2816,15 +3051,17 @@ function buildClosePlanConfigurationRequest( function buildClosePeriodLockRequest( workflow: OperationsContinuityWorkflow, closePlan: ClosePeriodPlan, - selectedBundle: AccountingReportPackageBundle | null + selectedBundle: AccountingReportPackageBundle | null, + prepareClosingEntriesOnly: boolean ): LockClosePeriodRequest { const reportPackId = selectedBundle?.financialStatements.packageId ?? workflow.reportPackReadiness.reportPackId ?? `report-pack-${closePlan.fundProfileId}-${closePlan.periodId}`; const evidenceLinks = collectAccountingCloseEvidenceLinks(workflow, closePlan); + const actionSegment = prepareClosingEntriesOnly ? "closing-entry-preparation" : "period-lock"; evidenceLinks.push( - `browser://accounting/close/period-lock/${workflow.workflowId}`, - `evidence://close-package/workflow/${workflow.workflowId}/period/${closePlan.periodId}/book/${closePlan.ledgerBookId ?? "primary"}/period-lock`, + `browser://accounting/close/${actionSegment}/${workflow.workflowId}`, + `evidence://close-package/workflow/${workflow.workflowId}/period/${closePlan.periodId}/book/${closePlan.ledgerBookId ?? "primary"}/${actionSegment}`, `evidence://report-package/${reportPackId}/workflow/${workflow.workflowId}/period/${closePlan.periodId}/book/${closePlan.ledgerBookId ?? "primary"}` ); selectedBundle?.financialStatements.evidenceLinks.forEach((link) => evidenceLinks.push(link)); @@ -2833,19 +3070,24 @@ function buildClosePeriodLockRequest( return { workflowId: workflow.workflowId, - expectedWorkflowVersion: workflow.version, + expectedWorkflowVersion: closePlan.workflowVersion ?? workflow.version, actor: "browser-accounting-controller", - rationale: `Lock close period ${closePlan.periodId} after close checklist and report package review.`, + rationale: prepareClosingEntriesOnly + ? `Prepare closing entries for close period ${closePlan.periodId} before period lock.` + : `Lock close period ${closePlan.periodId} after close checklist and report package review.`, reportPackId, evidenceLinks: Array.from(new Set(evidenceLinks)), checklistControlApprovals: buildClosePeriodChecklistApprovals(closePlan), - correlationId: `browser-close-period-lock-${workflow.workflowId}`, + correlationId: prepareClosingEntriesOnly + ? `browser-close-period-closing-entries-${workflow.workflowId}` + : `browser-close-period-lock-${workflow.workflowId}`, closePackageId: workflow.closePackage?.closePackageId ?? `close-package-${closePlan.periodId}`, closePackageManifestId: workflow.closePackage?.retainedManifestId ?? `close-manifest-${closePlan.periodId}`, closePackageRetainedManifestRoute: workflow.closePackage?.retainedManifestRoute ?? selectedBundle?.exportArtifacts?.[0]?.route ?? `/workstation/accounting/close/${closePlan.periodId}`, - actionOrigin: "HumanOperator" + actionOrigin: "HumanOperator", + prepareClosingEntriesOnly }; } @@ -2991,7 +3233,8 @@ export function buildCloseCommandCenterViewState({ accountingSystemProviders, accountingSystemImport, accountingSystemReconciliation, - multiAssetCoverage + multiAssetCoverage, + currentDailyValuationSchedule }: { data: AccountingWorkspaceResponse; commandCenter?: FinancialOperationsCommandCenter | null; @@ -3004,9 +3247,15 @@ export function buildCloseCommandCenterViewState({ accountingSystemImport: AccountingSystemImportDetail | null; accountingSystemReconciliation: AccountingSystemReconciliationSummary | null; multiAssetCoverage: MultiAssetCoverageSummary | null | undefined; + currentDailyValuationSchedule?: DailyValuationScheduleWorkItem | null; }): CloseCommandCenterViewState { if (commandCenter) { - return buildSharedFinancialOperationsCommandCenterViewState(commandCenter, commandCenterLoading ?? workflowLoading, commandCenterError ?? workflowError); + return buildSharedFinancialOperationsCommandCenterViewState( + commandCenter, + commandCenterLoading ?? workflowLoading, + commandCenterError ?? workflowError, + currentDailyValuationSchedule ?? null + ); } const openBreakCount = data.breakQueue.filter((item) => isOpenAccountingBreakStatus(item.status)).length; @@ -3210,7 +3459,8 @@ export function buildCloseCommandCenterViewState({ function buildSharedFinancialOperationsCommandCenterViewState( commandCenter: FinancialOperationsCommandCenter, loading: boolean, - errorText: string | null + errorText: string | null, + currentDailyValuationSchedule: DailyValuationScheduleWorkItem | null ): CloseCommandCenterViewState { const status = mapCommandCenterStatus(commandCenter.status, loading); const statusTone = closeCommandCenterStatusTone(status); @@ -3308,7 +3558,7 @@ function buildSharedFinancialOperationsCommandCenterViewState( const routedDecisionRows = (closeSupportDecision?.decisions ?? []) .filter((decision) => localCommandCenterRoute(decision.routeHint, decision.category)) .slice(0, 3); - const actionRows: CloseCommandCenterActionViewModel[] = routedRows.length > 0 + const baseActionRows: CloseCommandCenterActionViewModel[] = routedRows.length > 0 ? routedRows.map((row) => ({ id: row.queueId, label: row.actionLabel || row.title, @@ -3333,6 +3583,77 @@ function buildSharedFinancialOperationsCommandCenterViewState( tone: commandCenter.isReadyToComplete ? "success" : "warning" } ]; + const dailyValuationStatus = commandCenter.privateCapitalCloseCockpit?.dailyValuationStatus ?? null; + const hasRetainedValuationBatch = Boolean(dailyValuationStatus?.batchCorrelationId) && + (dailyValuationStatus?.journalEntryIds.length ?? 0) > 0; + const configureScheduleDisabledReason = !currentDailyValuationSchedule + ? "No server-retained daily valuation schedule is loaded for this close scope." + : dailyValuationStatus?.state === "Running" + ? "Wait for the running daily valuation schedule to finish before reconfiguring it." + : dailyValuationStatus?.state === "DraftReady" || + (dailyValuationStatus?.state === "Blocked" && hasRetainedValuationBatch) + ? "Complete or correct the retained daily valuation batch before reconfiguring its schedule." + : null; + const runDueScheduleDisabledReason = !currentDailyValuationSchedule || !dailyValuationStatus?.isConfigured + ? "Configure a retained daily valuation schedule before running due work." + : !dailyValuationStatus.isEnabled + ? "Enable the retained daily valuation schedule before running due work." + : dailyValuationStatus.state !== "Scheduled" + ? `Run due is available only from Scheduled state; current state is ${dailyValuationStatus.state}.` + : null; + const dailyValuationScheduleActions: CloseCommandCenterActionViewModel[] = dailyValuationStatus || currentDailyValuationSchedule ? [ + { + id: "daily-valuation-configure", + label: currentDailyValuationSchedule ? "Configure current valuation schedule" : "Configure valuation schedule", + href: WORKSTATION_ROUTE_CATALOG.accountingApprovals, + ariaLabel: "Configure the server-retained daily valuation schedule for the current close scope", + tone: configureScheduleDisabledReason ? "warning" : "success", + command: "configure-daily-valuation-schedule", + busyLabel: "Configuring daily valuation schedule", + disabledReason: configureScheduleDisabledReason + }, + { + id: "daily-valuation-run-due", + label: "Run due valuation schedules", + href: WORKSTATION_ROUTE_CATALOG.accountingApprovals, + ariaLabel: "Run due daily valuation schedules for the current tenant scope", + tone: runDueScheduleDisabledReason ? "warning" : "success", + command: "run-due-daily-valuation-schedules", + busyLabel: "Running due daily valuation schedules", + disabledReason: runDueScheduleDisabledReason + } + ] : []; + const dailyValuationLifecycleAction: CloseCommandCenterActionViewModel[] = + dailyValuationStatus?.state === "DraftReady" && + Boolean(dailyValuationStatus.scheduleId) && + Boolean(dailyValuationStatus.fundProfileId) && + dailyValuationStatus.journalEntryIds.length > 0 + ? [{ + id: "daily-valuation-approve-post", + label: `Approve and post ${formatCount(dailyValuationStatus.journalEntryIds.length, "valuation draft")}`, + href: WORKSTATION_ROUTE_CATALOG.accountingApprovals, + ariaLabel: "Approve and post the complete retained daily valuation batch", + tone: "warning", + command: "approve-daily-valuation-batch", + busyLabel: "Approving and posting daily valuation batch", + disabledReason: null + }] + : dailyValuationStatus?.state === "Blocked" && + Boolean(dailyValuationStatus.scheduleId) && + Boolean(dailyValuationStatus.fundProfileId) && + hasRetainedValuationBatch + ? [{ + id: "daily-valuation-retry-batch", + label: `Correct and retry ${formatCount(dailyValuationStatus.journalEntryIds.length, "valuation draft")}`, + href: WORKSTATION_ROUTE_CATALOG.accountingJournalEntries, + ariaLabel: "Correct and retry the incomplete retained daily valuation batch", + tone: "warning", + command: "retry-daily-valuation-batch", + busyLabel: "Retrying daily valuation batch", + disabledReason: null + }] + : []; + const actionRows = [...dailyValuationScheduleActions, ...dailyValuationLifecycleAction, ...baseActionRows].slice(0, 4); return { title: "CFO / Controller close command center", diff --git a/src/Meridian.Ui/dashboard/src/screens/accounting-screen.test.tsx b/src/Meridian.Ui/dashboard/src/screens/accounting-screen.test.tsx index 67b91b4298..136aab0ab8 100644 --- a/src/Meridian.Ui/dashboard/src/screens/accounting-screen.test.tsx +++ b/src/Meridian.Ui/dashboard/src/screens/accounting-screen.test.tsx @@ -19,10 +19,12 @@ import type { ExternalGlExportPackageManifest, ExternalGlMappingProfile, ClosePeriodPlan, + DailyValuationScheduleWorkItem, LedgerTrialBalanceLine, ReconciliationCalibrationSummary, OperationsContinuityWorkflow, OperationsContinuityWorkflowSummary, + PrivateCapitalCloseCockpit, CapitalAccountWorkbench, GeneratedPostingLine, PostingRuleJournalCandidateResult, @@ -220,6 +222,7 @@ vi.mock("@/lib/api", async () => { applyManualJournalEntryLifecycleAction: vi.fn(), getLedgerCloseManagementPeriodPlan: vi.fn(), configureLedgerCloseManagementPeriodPlan: vi.fn(), + lockLedgerCloseManagementPeriod: vi.fn(), createLedgerCloseManagementLateAdjustment: vi.fn(), reviewLedgerCloseManagementLateAdjustment: vi.fn(), signOffLedgerCloseManagementTask: vi.fn(), @@ -270,6 +273,11 @@ vi.mock("@/lib/api", async () => { }), getOperationsContinuityWorkflows: vi.fn().mockResolvedValue([]), getOperationsContinuityWorkflow: vi.fn(), + getPrivateCapitalCloseCockpit: vi.fn().mockResolvedValue(null), + listDailyValuationSchedules: vi.fn().mockResolvedValue([]), + configureDailyValuationSchedule: vi.fn(), + runDueDailyValuationSchedules: vi.fn(), + approveAndPostDailyValuationBatch: vi.fn(), getFinancialOperationsCommandCenter: vi.fn().mockResolvedValue({ generatedAtUtc: "2026-06-01T12:00:00Z", fundProfileId: "fund-alpha", @@ -2963,6 +2971,7 @@ describe("AccountingScreen", () => { const user = userEvent.setup(); const closePlan: ClosePeriodPlan = { closePlanId: "close-plan-workflow-approval-1", + workflowVersion: 12, fundProfileId: "fund-alpha", ledgerBookId: "book-alpha", periodId: "2026-05", @@ -2981,7 +2990,44 @@ describe("AccountingScreen", () => { requiresLateAdjustmentApproval: true }, validationIssues: [], - closeCalendar: [] + closeCalendar: [], + closingEntriesGate: { + gateId: "closing-entries:book-alpha:2026-05", + label: "Post closing entries", + state: "Required", + isReadyForLock: true, + netIncomeRoll: 1500, + temporaryAccountBalanceCount: 1, + detail: "Non-zero temporary-account balances require a closing-entry draft before period lock.", + idempotencyKey: "closing-entries:book-alpha:2026-05:v1", + balances: [{ + accountName: "Advisory fee revenue", + accountType: "Revenue", + balance: 1500, + symbol: "ADV-FEE", + financialAccountId: "financial-account-revenue", + dimensions: { + fundId: "fund-alpha", + entityId: "entity-alpha", + sleeveId: "sleeve-credit" + } + }], + evidenceLinks: ["evidence/closing-entry-preview"], + closingBatchJournalEntryIds: [], + reversalDraftJournalEntryIds: [] + } + }; + const queuedClosePlan: ClosePeriodPlan = { + ...closePlan, + workflowVersion: 13, + closingEntriesGate: { + ...closePlan.closingEntriesGate!, + state: "DraftQueued", + isReadyForLock: false, + detail: "A closing-entry draft is queued for controller approval and posting.", + draftJournalEntryId: "closing-entry-draft-2026-05", + draftStatus: "Draft" + } }; const lateAdjustmentPlan: ClosePeriodPlan = { ...closePlan, @@ -3002,15 +3048,51 @@ describe("AccountingScreen", () => { }; vi.mocked(api.getOperationsContinuityWorkflows).mockResolvedValueOnce([approvalWorkflowSummary]); vi.mocked(api.getOperationsContinuityWorkflow).mockResolvedValueOnce(approvalWorkflowDetail); - vi.mocked(api.getLedgerCloseManagementPeriodPlan).mockResolvedValueOnce(closePlan); + vi.mocked(api.getLedgerCloseManagementPeriodPlan) + .mockResolvedValueOnce(closePlan) + .mockResolvedValueOnce(queuedClosePlan); vi.mocked(api.listLedgerAccountingReportPackages).mockResolvedValueOnce([]); vi.mocked(api.configureLedgerCloseManagementPeriodPlan).mockResolvedValueOnce(closePlan); + vi.mocked(api.lockLedgerCloseManagementPeriod).mockResolvedValueOnce({ + isLocked: false, + plan: queuedClosePlan, + transition: null, + issues: [] + }); vi.mocked(api.createLedgerCloseManagementLateAdjustment).mockResolvedValueOnce(lateAdjustmentPlan); await renderAccountingScreen(data, "/accounting"); const cockpit = await screen.findByRole("region", { name: "Accounting close and report package certification cockpit" }); expect(await within(cockpit).findByText("$1,000 USD or 1% review by Controller")).toBeInTheDocument(); + const closingEntriesGate = within(cockpit).getByRole("region", { name: "Post closing entries gate" }); + expect(within(closingEntriesGate).getByText("Required")).toBeInTheDocument(); + expect(within(closingEntriesGate).getByText("+$1,500 USD")).toBeInTheDocument(); + expect(within(closingEntriesGate).getByText("Posting required before lock")).toBeInTheDocument(); + const scopedBalances = within(closingEntriesGate).getByRole("table", { name: "Scoped temporary-account balances" }); + expect(within(scopedBalances).getByText("Advisory fee revenue (ADV-FEE)")).toBeInTheDocument(); + expect(within(scopedBalances).getByText("Fund: fund-alpha | Entity: entity-alpha | Sleeve: sleeve-credit")).toBeInTheDocument(); + const queueClosingEntriesButton = within(closingEntriesGate).getByRole("button", { name: "Queue closing entries" }); + expect(queueClosingEntriesButton).toBeEnabled(); + expect(within(cockpit).getByRole("button", { name: "Lock period" })).toBeDisabled(); + + await user.click(queueClosingEntriesButton); + + expect(await within(cockpit).findByText("Queued closing entries for 2026-05; state is Draft queued.")).toBeInTheDocument(); + expect(api.lockLedgerCloseManagementPeriod).toHaveBeenCalledWith(expect.objectContaining({ + workflowId: "workflow-approval-1", + expectedWorkflowVersion: 12, + actor: "browser-accounting-controller", + rationale: "Prepare closing entries for close period 2026-05 before period lock.", + correlationId: "browser-close-period-closing-entries-workflow-approval-1", + prepareClosingEntriesOnly: true, + evidenceLinks: expect.arrayContaining([ + "browser://accounting/close/closing-entry-preparation/workflow-approval-1", + "evidence://close-package/workflow/workflow-approval-1/period/2026-05/book/book-alpha/closing-entry-preparation" + ]) + })); + await waitFor(() => expect(within(closingEntriesGate).getByRole("button", { name: "Queue closing entries" })).toBeDisabled()); + expect(within(cockpit).getByRole("button", { name: "Lock period" })).toBeDisabled(); await waitFor(() => expect(screen.getByRole("button", { name: "Retain close setup" })).toBeEnabled()); await user.click(screen.getByRole("button", { name: "Retain close setup" })); expect(await within(cockpit).findByText("Retained close-plan setup for 2026-05.")).toBeInTheDocument(); @@ -3050,6 +3132,238 @@ describe("AccountingScreen", () => { })); }); + it("runs retained daily valuation schedule, posting, and blocked-retry commands through typed endpoints", async () => { + vi.clearAllMocks(); + const user = userEvent.setup(); + const ledgerBookId = "11111111-1111-1111-1111-111111111111"; + const periodId = "22222222-2222-2222-2222-222222222222"; + const journalEntryIds = [ + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1", + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2" + ]; + const evidenceLink = { + evidenceId: "daily-valuation-schedule-evidence", + label: "Daily valuation schedule evidence", + route: `/api/workstation/evidence/subjects/accounting-record/daily-valuation/ledger-book/${ledgerBookId}/${periodId}`, + source: "daily-valuation-scheduler", + capturedAtUtc: "2026-06-01T01:00:00Z" + }; + let currentSchedule: DailyValuationScheduleWorkItem = { + scheduleId: "daily-fund-alpha", + fundProfileId: "fund-alpha", + currency: "USD", + actor: "valuation-scheduler", + ledgerBookId, + periodId, + nextRunAtUtc: "2026-06-02T01:00:00Z", + positions: [], + policyId: "daily-close-policy", + policyName: "Approved daily close marks", + valuationMethod: "ClosingPrice", + policyApprovedBy: "controller", + policyApprovedAtUtc: "2026-05-01T00:00:00Z", + reason: "Retained daily close valuation schedule.", + isEnabled: true, + entityId: "entity-alpha", + tenantId: "tenant-alpha", + companyId: "company-alpha", + state: "Scheduled", + evidenceLinks: [evidenceLink], + blockers: [], + journalEntryIds: [] + }; + const buildCockpit = ( + state: NonNullable + ): PrivateCapitalCloseCockpit => ({ + fundProfileId: "fund-alpha", + ledgerBookId, + fundAccountId: "fund-account-alpha", + periodId, + entityId: "entity-alpha", + projectedAtUtc: "2026-06-01T05:00:00Z", + cockpitRoute: "/accounting", + overallStatus: state.state === "Posted" ? "Ready" : state.state === "Blocked" ? "Blocked" : "ReviewRequired", + isReadyToClose: state.state === "Posted", + readinessScore: state.state === "Posted" ? 100 : 75, + workflowCount: 1, + fundEventCount: 0, + capitalAccountCount: 0, + reportOutputCount: 0, + deliveredReportOutputCount: 0, + readyLaneCount: state.state === "Posted" ? 1 : 0, + blockedLaneCount: state.state === "Blocked" ? 1 : 0, + lanes: [], + workflows: [], + blockers: [], + nextActions: [], + liveCapabilities: [], + plannedCapabilities: [], + dailyValuationStatus: state + }); + const scheduledStatus: NonNullable = { + scheduleId: currentSchedule.scheduleId, + fundProfileId: currentSchedule.fundProfileId, + ledgerBookId, + periodId, + isConfigured: true, + isEnabled: true, + nextRunAtUtc: currentSchedule.nextRunAtUtc, + lastRunAtUtc: null, + state: "Scheduled", + summary: "Daily valuation is scheduled.", + journalEntryId: null, + evidenceLinks: [evidenceLink], + blockers: [], + journalEntryIds: [], + batchCorrelationId: null, + entityId: "entity-alpha", + tenantId: "tenant-alpha", + companyId: "company-alpha" + }; + const draftReadyStatus = { + ...scheduledStatus, + state: "DraftReady" as const, + summary: "Two retained valuation drafts are ready.", + journalEntryId: journalEntryIds[0], + journalEntryIds, + batchCorrelationId: "daily-valuation:fund-alpha:2026-06-02" + }; + const blockedStatus = { + ...draftReadyStatus, + state: "Blocked" as const, + summary: "One of two retained valuation drafts posted before correction was required.", + blockers: ["Draft correction is required before retry."] + }; + const postedStatus = { + ...blockedStatus, + state: "Posted" as const, + summary: "Both retained valuation drafts posted.", + blockers: [] + }; + let currentCockpit = buildCockpit(scheduledStatus); + + vi.mocked(api.getPrivateCapitalCloseCockpit).mockImplementation(async () => currentCockpit); + vi.mocked(api.listDailyValuationSchedules).mockImplementation(async () => [currentSchedule]); + vi.mocked(api.configureDailyValuationSchedule).mockImplementation(async (request) => { + currentSchedule = { ...request, state: "Scheduled" }; + return currentSchedule; + }); + vi.mocked(api.runDueDailyValuationSchedules).mockImplementation(async () => { + currentSchedule = { + ...currentSchedule, + state: "DraftReady", + journalEntryId: journalEntryIds[0], + journalEntryIds, + batchCorrelationId: draftReadyStatus.batchCorrelationId + }; + currentCockpit = buildCockpit(draftReadyStatus); + return { + evaluatedAtUtc: "2026-06-02T01:00:00Z", + runs: [{ + scheduleId: currentSchedule.scheduleId, + scheduledForUtc: "2026-06-02T01:00:00Z", + state: "DraftReady", + summary: "Two retained valuation drafts are ready.", + journalEntryId: journalEntryIds[0], + blockers: [], + journalEntryIds, + batchCorrelationId: draftReadyStatus.batchCorrelationId + }] + }; + }); + vi.mocked(api.approveAndPostDailyValuationBatch) + .mockImplementationOnce(async () => { + currentSchedule = { ...currentSchedule, state: "Blocked", blockers: blockedStatus.blockers }; + currentCockpit = buildCockpit(blockedStatus); + return { + scheduleId: currentSchedule.scheduleId, + batchCorrelationId: draftReadyStatus.batchCorrelationId!, + isComplete: false, + journalEntryIds, + postedJournalEntryIds: [journalEntryIds[0]], + blockers: blockedStatus.blockers + }; + }) + .mockImplementationOnce(async () => { + currentSchedule = { ...currentSchedule, state: "Posted", blockers: [] }; + currentCockpit = buildCockpit(postedStatus); + return { + scheduleId: currentSchedule.scheduleId, + batchCorrelationId: draftReadyStatus.batchCorrelationId!, + isComplete: true, + journalEntryIds, + postedJournalEntryIds: journalEntryIds, + blockers: [] + }; + }); + + await renderAccountingScreen( + data, + `/accounting?fundProfileId=fund-alpha&ledgerBookId=${ledgerBookId}&periodId=${periodId}` + ); + + const commandCenter = await screen.findByRole("region", { name: "CFO and controller close command center" }); + const configureButton = await within(commandCenter).findByRole("button", { + name: "Configure the server-retained daily valuation schedule for the current close scope" + }); + const runDueButton = within(commandCenter).getByRole("button", { + name: "Run due daily valuation schedules for the current tenant scope" + }); + expect(configureButton).toBeEnabled(); + expect(runDueButton).toBeEnabled(); + + await user.click(configureButton); + + expect(await within(commandCenter).findByText(`Configured daily valuation schedule daily-fund-alpha for ${currentSchedule.nextRunAtUtc}.`)).toBeInTheDocument(); + expect(api.configureDailyValuationSchedule).toHaveBeenCalledWith(expect.objectContaining({ + scheduleId: "daily-fund-alpha", + fundProfileId: "fund-alpha", + ledgerBookId, + periodId, + entityId: "entity-alpha", + tenantId: "tenant-alpha", + companyId: "company-alpha", + policyId: "daily-close-policy", + actor: "close-cockpit-operator" + })); + + await user.click(within(commandCenter).getByRole("button", { + name: "Run due daily valuation schedules for the current tenant scope" + })); + + expect(api.runDueDailyValuationSchedules).toHaveBeenCalledWith(); + expect(await within(commandCenter).findByText(/finished in DraftReady/)).toBeInTheDocument(); + const approveButton = await within(commandCenter).findByRole("button", { + name: "Approve and post the complete retained daily valuation batch" + }); + await user.click(approveButton); + + expect(await within(commandCenter).findByText(/batch remains blocked: Draft correction is required before retry/)).toBeInTheDocument(); + expect(api.approveAndPostDailyValuationBatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ + scheduleId: "daily-fund-alpha", + fundProfileId: "fund-alpha", + tenantId: "tenant-alpha", + companyId: "company-alpha", + notes: "Approved the complete retained daily valuation batch from the controller close cockpit.", + evidenceLinks: [evidenceLink.route] + })); + const retryButton = await within(commandCenter).findByRole("button", { + name: "Correct and retry the incomplete retained daily valuation batch" + }); + await user.click(retryButton); + + expect(await within(commandCenter).findByText(/Retried and posted all 2 daily valuation drafts/)).toBeInTheDocument(); + expect(api.approveAndPostDailyValuationBatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ + scheduleId: "daily-fund-alpha", + fundProfileId: "fund-alpha", + tenantId: "tenant-alpha", + companyId: "company-alpha", + notes: "Retried the incomplete retained daily valuation batch from the controller close cockpit." + })); + expect(api.getPrivateCapitalCloseCockpit).toHaveBeenCalledTimes(5); + expect(api.listDailyValuationSchedules).toHaveBeenCalledTimes(5); + }); + it("scopes the close command center workflow lookup to route fund and period", async () => { const unrelatedWorkflowSummary: OperationsContinuityWorkflowSummary = { ...approvalWorkflowSummary, diff --git a/src/Meridian.Ui/dashboard/src/screens/accounting-screen.tsx b/src/Meridian.Ui/dashboard/src/screens/accounting-screen.tsx index 7686c14607..813dcddafd 100644 --- a/src/Meridian.Ui/dashboard/src/screens/accounting-screen.tsx +++ b/src/Meridian.Ui/dashboard/src/screens/accounting-screen.tsx @@ -20,21 +20,26 @@ import { CoveragePassportDrillIn } from "@/components/meridian/coverage-passport import { AccountingTrialBalanceSelectedDetailPanel, trialBalanceColumns } from "@/components/accounting/TrialBalanceRowDetail"; import { ReconciliationComparisonPanel, TrialBalanceTable } from "@/components/accounting"; import { + approveAndPostDailyValuationBatch, approveOperationsContinuityWorkflow, certifyAccountingSystemExportPackage, + configureDailyValuationSchedule, createAccountingSystemExportPackage, getAccountingSystemExportPackageManifest, getAccountingSystemMappingProfiles, getAccountingSystemProviders, getFinancialOperationsCommandCenter, + getPrivateCapitalCloseCockpit, getLatestAccountingSystemImport, getLatestAccountingSystemReconciliation, getFinancialRecordExplorer, getOperationsContinuityWorkflow, getOperationsContinuityWorkflows, listAccountingSystemExportPackages, + listDailyValuationSchedules, previewAccountingSystemImport, rejectOperationsContinuityWorkflow, + runDueDailyValuationSchedules, saveFinancialRecordExplorerView } from "@/lib/api"; import { cn } from "@/lib/utils"; @@ -109,6 +114,7 @@ import type { AccountingSystemProvider, AccountingSystemReconciliationSummary, AccountingWorkspaceResponse, + DailyValuationScheduleWorkItem, FinancialRecordExplorerDto, FinancialRecordExplorerSavedViewSaveRequestDto, FinancialOperationsCommandCenter, @@ -117,6 +123,7 @@ import type { OperationsContinuityWorkflow, OperationsContinuityWorkflowSummary, OperationsTimelineEntry, + PrivateCapitalCloseCockpit, } from "@/types"; import { approvalBlockedReason, @@ -1063,7 +1070,15 @@ function mergeExternalGlExportPackage( return [nextPackage, ...remaining].sort((left, right) => right.createdAtUtc.localeCompare(left.createdAtUtc)); } -function parseCloseWorkflowQuery(search: string): { fundProfileId?: string; fundAccountId?: string; ledgerBookId?: string; periodId?: string; status?: string } { +interface CloseWorkflowQuery { + fundProfileId?: string; + fundAccountId?: string; + ledgerBookId?: string; + periodId?: string; + status?: string; +} + +function parseCloseWorkflowQuery(search: string): CloseWorkflowQuery { const params = new URLSearchParams(search); return { fundProfileId: normalizeOptionalQueryValue(params.get("fundProfileId")), @@ -1074,6 +1089,32 @@ function parseCloseWorkflowQuery(search: string): { fundProfileId?: string; fund }; } +function selectCurrentDailyValuationSchedule( + schedules: DailyValuationScheduleWorkItem[], + status: PrivateCapitalCloseCockpit["dailyValuationStatus"] | null | undefined, + query: CloseWorkflowQuery +): DailyValuationScheduleWorkItem | null { + const expectedScheduleId = status?.scheduleId?.trim() || null; + const expectedFundProfileId = status?.fundProfileId?.trim() || query.fundProfileId || null; + const expectedLedgerBookId = status?.ledgerBookId?.trim() || query.ledgerBookId || null; + const expectedPeriodId = status?.periodId?.trim() || query.periodId || null; + const expectedEntityId = status?.entityId?.trim() || null; + const expectedTenantId = status?.tenantId?.trim() || null; + const expectedCompanyId = status?.companyId?.trim() || null; + const matches = (actual: string | null | undefined, expected: string | null) => + !expected || actual?.trim().localeCompare(expected, undefined, { sensitivity: "accent" }) === 0; + + return schedules.find((schedule) => + matches(schedule.scheduleId, expectedScheduleId) && + matches(schedule.fundProfileId, expectedFundProfileId) && + matches(schedule.ledgerBookId, expectedLedgerBookId) && + matches(schedule.periodId, expectedPeriodId) && + matches(schedule.entityId, expectedEntityId) && + matches(schedule.tenantId, expectedTenantId) && + matches(schedule.companyId, expectedCompanyId) + ) ?? null; +} + function normalizeOptionalQueryValue(value: string | null): string | undefined { const normalized = value?.trim(); return normalized ? normalized : undefined; @@ -1693,6 +1734,10 @@ export function AccountingScreen({ data, multiAssetCoverage }: AccountingScreenP const [financialOperationsCommandCenter, setFinancialOperationsCommandCenter] = useState(null); const [financialOperationsCommandCenterLoading, setFinancialOperationsCommandCenterLoading] = useState(false); const [financialOperationsCommandCenterError, setFinancialOperationsCommandCenterError] = useState(null); + const [privateCapitalCloseCockpit, setPrivateCapitalCloseCockpit] = useState(null); + const [dailyValuationSchedules, setDailyValuationSchedules] = useState([]); + const [activeDailyValuationCommand, setActiveDailyValuationCommand] = useState | null>(null); + const [dailyValuationBatchStatusText, setDailyValuationBatchStatusText] = useState(null); const [closeWorkflow, setCloseWorkflow] = useState(null); const [closeWorkflowLoading, setCloseWorkflowLoading] = useState(false); const [closeWorkflowError, setCloseWorkflowError] = useState(null); @@ -2105,6 +2150,8 @@ export function AccountingScreen({ data, multiAssetCoverage }: AccountingScreenP if (!data) { setCloseWorkflow(null); setFinancialOperationsCommandCenter(null); + setPrivateCapitalCloseCockpit(null); + setDailyValuationSchedules([]); return; } @@ -2113,17 +2160,24 @@ export function AccountingScreen({ data, multiAssetCoverage }: AccountingScreenP setCloseWorkflowError(null); setFinancialOperationsCommandCenterError(null); try { - const [commandCenter, rows] = await Promise.all([ + const [commandCenter, closeCockpit, rows, schedules] = await Promise.all([ getFinancialOperationsCommandCenter(closeWorkflowQuery).catch(err => { setFinancialOperationsCommandCenterError(formatApprovalError(err, "Financial Operations command center could not be loaded.")); return null; }), + getPrivateCapitalCloseCockpit(closeWorkflowQuery).catch(err => { + setFinancialOperationsCommandCenterError(formatApprovalError(err, "Private-capital close cockpit could not be loaded.")); + return null; + }), getOperationsContinuityWorkflows(closeWorkflowQuery).catch(err => { setCloseWorkflowError(formatApprovalError(err, "Close workflow detail could not be loaded.")); return []; - }) + }), + listDailyValuationSchedules().catch(() => []) ]); setFinancialOperationsCommandCenter(commandCenter); + setPrivateCapitalCloseCockpit(closeCockpit); + setDailyValuationSchedules(schedules); const selected = selectCloseWorkflowSummary(rows, closeWorkflowQuery); if (!selected) { setCloseWorkflow(null); @@ -2135,6 +2189,8 @@ export function AccountingScreen({ data, multiAssetCoverage }: AccountingScreenP } catch (error) { setCloseWorkflow(null); setFinancialOperationsCommandCenter(null); + setPrivateCapitalCloseCockpit(null); + setDailyValuationSchedules([]); setCloseWorkflowError(formatApprovalError(error, "Close workflow detail could not be loaded.")); } finally { setCloseWorkflowLoading(false); @@ -2142,6 +2198,101 @@ export function AccountingScreen({ data, multiAssetCoverage }: AccountingScreenP } }; + const effectiveDailyValuationStatus = privateCapitalCloseCockpit?.dailyValuationStatus + ?? financialOperationsCommandCenter?.privateCapitalCloseCockpit?.dailyValuationStatus + ?? null; + const currentDailyValuationSchedule = useMemo( + () => selectCurrentDailyValuationSchedule(dailyValuationSchedules, effectiveDailyValuationStatus, closeWorkflowQuery), + [closeWorkflowQuery, dailyValuationSchedules, effectiveDailyValuationStatus] + ); + + const runCloseCommand = async ( + command: NonNullable + ) => { + const status = effectiveDailyValuationStatus; + const hasRetainedBatch = Boolean(status?.batchCorrelationId) && (status?.journalEntryIds.length ?? 0) > 0; + if (command === "configure-daily-valuation-schedule" && !currentDailyValuationSchedule) { + setDailyValuationBatchStatusText("No server-retained daily valuation schedule is loaded for this close scope."); + return; + } + if (command === "configure-daily-valuation-schedule" && + (status?.state === "Running" || status?.state === "DraftReady" || + (status?.state === "Blocked" && hasRetainedBatch))) { + setDailyValuationBatchStatusText("Complete or correct the retained daily valuation batch before reconfiguring its schedule."); + return; + } + if (command === "run-due-daily-valuation-schedules" && + (!currentDailyValuationSchedule || !status?.isConfigured || !status.isEnabled || status.state !== "Scheduled")) { + setDailyValuationBatchStatusText("The current close scope does not have an enabled Scheduled daily valuation configuration."); + return; + } + if ((command === "approve-daily-valuation-batch" && status?.state !== "DraftReady") || + (command === "retry-daily-valuation-batch" && (status?.state !== "Blocked" || !hasRetainedBatch)) || + ((command === "approve-daily-valuation-batch" || command === "retry-daily-valuation-batch") && + (!status?.scheduleId || !status.fundProfileId || status.journalEntryIds.length === 0))) { + setDailyValuationBatchStatusText("No retained daily valuation batch is available for this command."); + return; + } + + setActiveDailyValuationCommand(command); + setDailyValuationBatchStatusText(null); + try { + if (command === "configure-daily-valuation-schedule") { + const configured = await configureDailyValuationSchedule({ + ...currentDailyValuationSchedule!, + actor: "close-cockpit-operator" + }); + setDailyValuationBatchStatusText( + `Configured daily valuation schedule ${configured.scheduleId} for ${configured.nextRunAtUtc}.` + ); + await refreshCloseWorkflow(); + return; + } + + if (command === "run-due-daily-valuation-schedules") { + const result = await runDueDailyValuationSchedules(); + const currentRun = result.runs.find((run) => run.scheduleId === currentDailyValuationSchedule!.scheduleId); + setDailyValuationBatchStatusText(currentRun + ? `Daily valuation schedule ${currentRun.scheduleId} finished in ${currentRun.state}: ${currentRun.summary}` + : result.runs.length > 0 + ? `Ran ${result.runs.length} due daily valuation schedule(s); the current schedule was not due.` + : "No daily valuation schedules were due for the current tenant scope."); + await refreshCloseWorkflow(); + return; + } + + const isRetry = command === "retry-daily-valuation-batch"; + const result = await approveAndPostDailyValuationBatch({ + scheduleId: status!.scheduleId!, + fundProfileId: status!.fundProfileId!, + actor: "close-cockpit-operator", + notes: isRetry + ? "Retried the incomplete retained daily valuation batch from the controller close cockpit." + : "Approved the complete retained daily valuation batch from the controller close cockpit.", + evidenceLinks: status!.evidenceLinks + .map((link) => link.route) + .filter((route): route is string => Boolean(route)), + tenantId: status!.tenantId ?? null, + companyId: status!.companyId ?? null + }); + setDailyValuationBatchStatusText(result.isComplete + ? `${isRetry ? "Retried and posted" : "Posted"} all ${result.postedJournalEntryIds.length} daily valuation drafts in batch ${result.batchCorrelationId}.` + : `Daily valuation batch ${isRetry ? "retry " : ""}remains blocked: ${result.blockers.join(" ")}`); + await refreshCloseWorkflow(); + } catch (error) { + const fallback = command === "configure-daily-valuation-schedule" + ? "Daily valuation schedule could not be configured." + : command === "run-due-daily-valuation-schedules" + ? "Due daily valuation schedules could not be run." + : command === "retry-daily-valuation-batch" + ? "Daily valuation batch correction and retry could not complete." + : "Daily valuation batch could not be approved and posted."; + setDailyValuationBatchStatusText(formatApprovalError(error, fallback)); + } finally { + setActiveDailyValuationCommand(null); + } + }; + useEffect(() => { if (!sectionVisibility.showCloseCockpitLanding && !sectionVisibility.showWorkflowDetails) { return; @@ -2153,7 +2304,13 @@ export function AccountingScreen({ data, multiAssetCoverage }: AccountingScreenP const closeCommandCenter = useMemo( () => data ? buildCloseCommandCenterViewState({ data, - commandCenter: financialOperationsCommandCenter, + commandCenter: financialOperationsCommandCenter + ? { + ...financialOperationsCommandCenter, + privateCapitalCloseCockpit: privateCapitalCloseCockpit + ?? financialOperationsCommandCenter.privateCapitalCloseCockpit + } + : null, commandCenterLoading: financialOperationsCommandCenterLoading, commandCenterError: financialOperationsCommandCenterError, workflow: closeWorkflow, @@ -2162,7 +2319,8 @@ export function AccountingScreen({ data, multiAssetCoverage }: AccountingScreenP accountingSystemProviders, accountingSystemImport, accountingSystemReconciliation, - multiAssetCoverage + multiAssetCoverage, + currentDailyValuationSchedule }) : null, [ accountingSystemImport, @@ -2175,7 +2333,9 @@ export function AccountingScreen({ data, multiAssetCoverage }: AccountingScreenP financialOperationsCommandCenter, financialOperationsCommandCenterError, financialOperationsCommandCenterLoading, - multiAssetCoverage + multiAssetCoverage, + privateCapitalCloseCockpit, + currentDailyValuationSchedule ] ); const closeReportPackage = useAccountingCloseReportPackageViewModel(closeWorkflow); @@ -2283,7 +2443,15 @@ export function AccountingScreen({ data, multiAssetCoverage }: AccountingScreenP
{workflowLaunch ? : null} - {closeCommandCenter ? void refreshCloseWorkflow()} /> : null} + {closeCommandCenter ? ( + void refreshCloseWorkflow()} + onCommand={(command) => void runCloseCommand(command)} + activeCommand={activeDailyValuationCommand} + commandStatusText={dailyValuationBatchStatusText} + /> + ) : null}
@@ -2294,7 +2462,13 @@ export function AccountingScreen({ data, multiAssetCoverage }: AccountingScreenP {sectionVisibility.showWorkflowDetails && workflowLaunch ? : null} {sectionVisibility.showWorkflowDetails && closeCommandCenter ? ( - void refreshCloseWorkflow()} /> + void refreshCloseWorkflow()} + onCommand={(command) => void runCloseCommand(command)} + activeCommand={activeDailyValuationCommand} + commandStatusText={dailyValuationBatchStatusText} + /> ) : null} {sectionVisibility.showWorkflowDetails ? : null} diff --git a/src/Meridian.Ui/dashboard/src/screens/accounting-screen.view-model.ts b/src/Meridian.Ui/dashboard/src/screens/accounting-screen.view-model.ts index 90673b77a9..2dde606029 100644 --- a/src/Meridian.Ui/dashboard/src/screens/accounting-screen.view-model.ts +++ b/src/Meridian.Ui/dashboard/src/screens/accounting-screen.view-model.ts @@ -2455,6 +2455,13 @@ export interface CloseCommandCenterActionViewModel { href: string; ariaLabel: string; tone: AccountingToolingTone; + command?: + | "configure-daily-valuation-schedule" + | "run-due-daily-valuation-schedules" + | "approve-daily-valuation-batch" + | "retry-daily-valuation-batch"; + busyLabel?: string; + disabledReason?: string | null; } export interface CloseCommandCenterViewState { @@ -2584,6 +2591,32 @@ export interface AccountingCloseOperatingCoverageRowViewModel { issueLabels: string[]; } +export interface AccountingClosePostingBalanceRowViewModel { + rowId: string; + accountLabel: string; + accountTypeLabel: string; + balanceLabel: string; + scopeLabel: string; + financialAccountLabel: string; +} + +export interface AccountingClosePostingGateViewModel { + gateId: string; + label: string; + statusLabel: string; + statusTone: AccountingToolingTone; + isReadyForLock: boolean; + netIncomeRollLabel: string; + temporaryAccountBalanceLabel: string; + detail: string; + draftLabel: string; + idempotencyLabel: string; + closingBatchLabel: string; + reversalDraftLabel: string; + evidenceLabel: string; + balances: AccountingClosePostingBalanceRowViewModel[]; +} + export interface AccountingCloseSetupTaskOptionViewModel { taskId: string; displayName: string; @@ -2740,6 +2773,9 @@ export interface AccountingCloseReportPackageViewModel { lockClosePeriodBusy: boolean; lockClosePeriodStatusText: string | null; lockClosePeriodStatusTone: "neutral" | "success" | "danger"; + queueClosingEntriesBusy: boolean; + queueClosingEntriesStatusText: string | null; + queueClosingEntriesStatusTone: "neutral" | "success" | "danger"; configureClosePlanBusy: boolean; configureClosePlanStatusText: string | null; configureClosePlanStatusTone: "neutral" | "success" | "danger"; @@ -2764,6 +2800,8 @@ export interface AccountingCloseReportPackageViewModel { signOffDisabledReason: string | null; lockClosePeriodButtonLabel: string; lockClosePeriodDisabledReason: string | null; + queueClosingEntriesButtonLabel: string; + queueClosingEntriesDisabledReason: string | null; configureClosePlanButtonLabel: string; configureClosePlanDisabledReason: string | null; createLateAdjustmentDisabledReason: string | null; @@ -2785,6 +2823,7 @@ export interface AccountingCloseReportPackageViewModel { signOffMatrixRows: AccountingCloseSignOffMatrixRowViewModel[]; evidenceReviewRows: AccountingCloseEvidenceReviewRowViewModel[]; operatingCoverageRows: AccountingCloseOperatingCoverageRowViewModel[]; + closingEntriesGate: AccountingClosePostingGateViewModel | null; lateAdjustments: AccountingLateAdjustmentRowViewModel[]; packageRows: AccountingReportPackageRowViewModel[]; selectedPackage: AccountingReportPackageRowViewModel | null; @@ -2796,6 +2835,7 @@ export interface AccountingCloseReportPackageViewModel { buildReportPackage: () => Promise; certifyPackage: () => Promise; lockClosePeriod: () => Promise; + queueClosingEntries: () => Promise; configureClosePlan: () => Promise; signOffNextTask: () => Promise; updateCloseSetupDraft: (patch: Partial) => void; diff --git a/src/Meridian.Ui/dashboard/src/screens/data-screen.data-regions.tsx b/src/Meridian.Ui/dashboard/src/screens/data-screen.data-regions.tsx index eafa680f65..974e9daf1a 100644 --- a/src/Meridian.Ui/dashboard/src/screens/data-screen.data-regions.tsx +++ b/src/Meridian.Ui/dashboard/src/screens/data-screen.data-regions.tsx @@ -9,6 +9,7 @@ import { CellActionConfirmDialog, CellActionTrigger, ContextMenu, + type CellActionApi, useCellActions } from "@/screens/data-screen.cell-actions"; import type { CoverageGapsViewModel } from "@/screens/data-screen.coverage-gaps.view-model"; @@ -154,12 +155,19 @@ export function CoverageGapsRegion({ panel }: { panel: CoverageGapsViewModel }) ); } -export function DataQualityRegion({ panel }: { panel: DataQualityPanelViewModel }) { +export function DataQualityRegion({ + panel, + actionApi +}: { + panel: DataQualityPanelViewModel; + actionApi?: Partial; +}) { const toast = useToast(); const cellActions = useCellActions({ toast, onOpenCapabilityMatrix: (symbol) => revealCapabilityMatrix(toast, symbol), - onAfterMutation: () => void panel.refresh() + onAfterMutation: () => void panel.refresh(), + ...(actionApi ? { api: actionApi } : {}) }); return (
@@ -280,6 +288,10 @@ export function DataQualityRegion({ panel }: { panel: DataQualityPanelViewModel

Open gaps

    {row.openGaps.map((gap) => { + const disabledReason = !gap.canBackfill + ? gap.disabledReason ?? "Server policy has disabled remediation for this gap." + : null; + const disabledReasonId = `quality-gap-${gap.gapId.replace(/[^a-zA-Z0-9_-]/g, "-")}-disabled-reason`; const context = { kind: "quality-gap" as const, symbol: gap.symbol, @@ -299,6 +311,10 @@ export function DataQualityRegion({ panel }: { panel: DataQualityPanelViewModel > {gap.severity} {gap.eventType} + + Gap {gap.gapId} + {" · "}{gap.provider ?? "Default provider"} + {new Date(gap.from).toLocaleString()} – {new Date(gap.to).toLocaleString()} ·{" "} {gap.estimatedMissingEvents.toLocaleString()} estimated missing @@ -308,7 +324,11 @@ export function DataQualityRegion({ panel }: { panel: DataQualityPanelViewModel variant="outline" size="sm" disabled={!gap.canBackfill || cellActions.running} - title={gap.disabledReason ?? "Backfill this exact provider and date range"} + title={disabledReason ?? "Backfill this exact provider and date range"} + aria-label={disabledReason + ? `Backfill gap ${gap.gapId} unavailable: ${disabledReason}` + : `Backfill gap ${gap.gapId} for ${gap.symbol}`} + aria-describedby={disabledReason ? disabledReasonId : undefined} onClick={() => cellActions.run(context, "backfill")} > {cellActions.running ? "Working…" : "Backfill gap"} @@ -317,6 +337,14 @@ export function DataQualityRegion({ panel }: { panel: DataQualityPanelViewModel label={`Actions for the ${gap.symbol} quality gap`} onOpen={(event) => cellActions.openFor(event, context)} /> + {disabledReason ? ( + + Remediation unavailable: {disabledReason} + + ) : null} ); })} diff --git a/src/Meridian.Ui/dashboard/src/screens/data-screen.test.tsx b/src/Meridian.Ui/dashboard/src/screens/data-screen.test.tsx index 4f1446a08b..ebefd22b8e 100644 --- a/src/Meridian.Ui/dashboard/src/screens/data-screen.test.tsx +++ b/src/Meridian.Ui/dashboard/src/screens/data-screen.test.tsx @@ -14,13 +14,15 @@ import { import { renderWithRouter } from "@/test/render"; import type { BackfillProgressResponse, + BackfillExecutionHistoryResponse, BackfillPreviewResult, BackfillTriggerResult, DataWorkspaceResponse, ProviderConnectionRow, ProviderCredentialVerificationResult, ProviderReadinessSummary, - ProviderSetupResult + ProviderSetupResult, + QualityDashboardResponse } from "@/types"; const data: DataWorkspaceResponse = { diff --git a/src/Meridian.Ui/dashboard/src/screens/data-screen.tsx b/src/Meridian.Ui/dashboard/src/screens/data-screen.tsx index e1fcbe4d90..b4f3906cf6 100644 --- a/src/Meridian.Ui/dashboard/src/screens/data-screen.tsx +++ b/src/Meridian.Ui/dashboard/src/screens/data-screen.tsx @@ -37,8 +37,12 @@ import { buildDataAnalyticsDegradedViewModel, DataAnalyticsDegradedRegion } from "@/screens/data-screen.analytics-status"; +import type { CellActionApi } from "@/screens/data-screen.cell-actions"; import { useDataQueryPanel } from "@/screens/data-screen.query-panel.view-model"; -import { useDataQualityPanel } from "@/screens/data-screen.data-quality.view-model"; +import { + useDataQualityPanel, + type QualityDashboardFetcher +} from "@/screens/data-screen.data-quality.view-model"; import { CAPABILITY_LEGEND, useCapabilityMatrixPanel, @@ -93,6 +97,8 @@ interface DataScreenProps { providerRoutingBindings?: ProviderRoutingBinding[] | null; providerRoutingTrustSnapshots?: ProviderRoutingTrustSnapshot[] | null; providerRoutingRefreshing?: boolean; + dataQualityFetcher?: QualityDashboardFetcher; + dataQualityActionApi?: Partial; onProviderSetupConfigured?: () => Promise | void; onProviderRoutingRefresh?: () => Promise | void; } @@ -165,6 +171,8 @@ export function DataScreen({ providerRoutingBindings = null, providerRoutingTrustSnapshots = null, providerRoutingRefreshing = false, + dataQualityFetcher, + dataQualityActionApi, onProviderSetupConfigured, onProviderRoutingRefresh }: DataScreenProps) { @@ -193,7 +201,7 @@ export function DataScreen({ ]); const vm = useDataViewModel(data, pathname, undefined, providerSetupLifecycle, providerEvidence); const queryPanel = useDataQueryPanel(); - const qualityPanel = useDataQualityPanel(); + const qualityPanel = useDataQualityPanel(dataQualityFetcher); const capabilityMatrixPanel = useCapabilityMatrixPanel(); const corporateActionInboxPanel = useCorporateActionInboxPanel(); const coverageGapsPanel = useCoverageGapsPanel(); @@ -293,7 +301,12 @@ export function DataScreen({
    {analyticsDegraded ? : null} - {!analyticsUnavailable.has("data-quality") ? : null} + {!analyticsUnavailable.has("data-quality") ? ( + + ) : null} {!analyticsUnavailable.has("capability-matrix") ? : null} diff --git a/src/Meridian.Ui/dashboard/src/screens/data-screen.view-model.test.ts b/src/Meridian.Ui/dashboard/src/screens/data-screen.view-model.test.ts index 65f6312b41..1a282bad46 100644 --- a/src/Meridian.Ui/dashboard/src/screens/data-screen.view-model.test.ts +++ b/src/Meridian.Ui/dashboard/src/screens/data-screen.view-model.test.ts @@ -14,6 +14,7 @@ import { buildBackfillNarrative, buildBackfillRequest, buildBackfillLiveProgressState, + buildBackfillRemediationQueueState, buildBackfillResultCardState, buildBackfillTriggerState, buildDataUploadPanelState, @@ -52,6 +53,7 @@ import { import type { CorporateActionDescriptor } from "@/types"; import type { BackfillProgressResponse, + BackfillExecutionHistoryResponse, BackfillPreviewResult, BackfillTriggerRequest, BackfillTriggerResult, @@ -1012,6 +1014,90 @@ describe("data-screen view model", () => { }); }); + it("projects durable remediation SLA evidence and sorts by tier or deadline", () => { + const history: BackfillExecutionHistoryResponse = { + executions: [ + { + executionId: "standard-sooner", + scheduleName: "Gap repair", + trigger: "AutoRemediation", + scheduleId: "", + status: "Completed", + startedAt: "2026-07-15T09:00:00Z", + completedAt: "2026-07-15T09:01:00Z", + symbolsProcessed: 1, + barsDownloaded: 4, + errorMessage: null, + fromDate: "2026-07-10", + toDate: "2026-07-11", + symbols: ["AAPL"], + autoRemediationTriggerReason: "StoredGap", + autoRemediationAttemptCount: 1, + autoRemediationLastOutcome: "Completed", + autoRemediationIdempotencyKey: "aapl|polygon", + autoRemediationSla: { + tier: "Standard", + status: "Completed", + dueAtUtc: "2026-07-16T12:00:00Z", + requiresOwnerAssignment: false, + downstreamWorkflow: "research", + reasonCode: "StandardGap", + provider: "", + triggerSource: "GapAnalyzerScan", + isCompatibilityDerived: false + } + }, + { + executionId: "critical-later", + scheduleName: "Accounting repair", + trigger: "AutoRemediation", + scheduleId: "", + status: "Failed", + startedAt: "2026-07-15T10:00:00Z", + completedAt: "2026-07-15T10:01:00Z", + symbolsProcessed: 1, + barsDownloaded: 0, + errorMessage: "provider unavailable", + fromDate: "2026-07-12", + toDate: "2026-07-13", + symbols: ["MSFT"], + autoRemediationTriggerReason: "CriticalWorkflow", + autoRemediationAttemptCount: 2, + autoRemediationLastOutcome: "FailedTransient", + autoRemediationIdempotencyKey: "msft|stooq", + autoRemediationSla: { + tier: "SameBusinessDay", + status: "Overdue", + dueAtUtc: "2026-07-17T12:00:00Z", + requiresOwnerAssignment: true, + downstreamWorkflow: "accounting", + reasonCode: "CriticalWorkflow", + provider: "stooq", + triggerSource: "QualityAlert", + isCompatibilityDerived: true + } + } + ], + total: 2, + autoRemediation: { total: 2, withReason: 2, lastOutcome: "FailedTransient", defaultProvider: "polygon" }, + timestamp: "2026-07-15T12:00:00Z" + }; + + const byTier = buildBackfillRemediationQueueState(history, { columnId: "tier", direction: "asc" }); + expect(byTier?.rows.map((row) => row.executionId)).toEqual(["critical-later", "standard-sooner"]); + expect(byTier?.rows[0]).toMatchObject({ + tier: "Same business day", + status: "Overdue", + owner: "Assignment required", + provider: "stooq" + }); + expect(byTier?.rows[0].evidence).toContain("Compatibility-derived legacy evidence"); + expect(byTier?.rows[1].provider).toBe("polygon"); + + const byDeadline = buildBackfillRemediationQueueState(history, { columnId: "deadline", direction: "asc" }); + expect(byDeadline?.rows.map((row) => row.executionId)).toEqual(["standard-sooner", "critical-later"]); + }); + it("polls provider progress while a backfill run is in flight and keeps the final snapshot", async () => { let resolveRun!: (value: BackfillTriggerResult) => void; let runSettled = false; @@ -1029,17 +1115,69 @@ describe("data-screen view model", () => { }, timestamp: "2026-07-13T17:05:00Z" })); + const refreshedHistory: BackfillExecutionHistoryResponse = { + executions: [{ + executionId: "manual-run-17", + scheduleName: "Manual backfill", + trigger: "Manual", + scheduleId: "", + status: "Completed", + startedAt: "2026-07-13T17:04:00Z", + completedAt: "2026-07-13T17:05:00Z", + symbolsProcessed: 1, + barsDownloaded: 100, + errorMessage: null, + fromDate: "2026-07-01", + toDate: "2026-07-12", + symbols: ["AAPL"], + autoRemediationTriggerReason: "OperatorRequested", + autoRemediationAttemptCount: 1, + autoRemediationLastOutcome: "Completed", + autoRemediationIdempotencyKey: "aapl|polygon|manual-run-17", + autoRemediationSla: { + tier: "Standard", + status: "Completed", + dueAtUtc: "2026-07-14T17:05:00Z", + requiresOwnerAssignment: false, + downstreamWorkflow: "research", + reasonCode: "OperatorRequested", + provider: "polygon", + triggerSource: "DataWorkstation", + isCompatibilityDerived: false + } + }], + total: 1, + autoRemediation: { + total: 1, + withReason: 1, + lastOutcome: "Completed", + defaultProvider: "polygon" + }, + timestamp: "2026-07-13T17:05:01Z" + }; + const initialHistory: BackfillExecutionHistoryResponse = { + executions: [], + total: 0, + autoRemediation: { total: 0, withReason: 0, lastOutcome: null, defaultProvider: "polygon" }, + timestamp: "2026-07-13T17:03:00Z" + }; + let executionReadCount = 0; + const executionReads = vi.fn(async (): Promise => ( + executionReadCount++ === 0 ? initialHistory : refreshedHistory + )); const services = { preview: async () => preview, run: () => new Promise((resolve) => { resolveRun = resolve; }), - getProgress: progressReads + getProgress: progressReads, + getExecutions: executionReads }; const workspace: DataWorkspaceResponse = { metrics: [], providers, backfills: [], exports: [] }; const { result } = renderHook(() => useDataViewModel(workspace, "/data/backfills", services)); await waitFor(() => expect(result.current.form.provider).toBe("polygon")); + await waitFor(() => expect(executionReads).toHaveBeenCalledTimes(1)); act(() => result.current.updateBackfillForm("symbols", "AAPL")); await act(async () => result.current.previewBackfill()); @@ -1065,6 +1203,12 @@ describe("data-screen view model", () => { title: "Final provider progress" }); expect(progressReads.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(executionReads).toHaveBeenCalledTimes(2); + expect(executionReads.mock.invocationCallOrder[1]).toBeGreaterThan( + progressReads.mock.invocationCallOrder.at(-1) ?? 0 + ); + expect(result.current.remediationQueueState?.rows.map((row) => row.executionId)) + .toEqual(["manual-run-17"]); }); it("derives failed backfill result cards with danger tone and error evidence", () => { diff --git a/src/Meridian.Ui/dashboard/src/screens/data-screen.view-model.ts b/src/Meridian.Ui/dashboard/src/screens/data-screen.view-model.ts index 2808d45efa..ce890e4a3a 100644 --- a/src/Meridian.Ui/dashboard/src/screens/data-screen.view-model.ts +++ b/src/Meridian.Ui/dashboard/src/screens/data-screen.view-model.ts @@ -41,6 +41,7 @@ import type { import { buildDataUploadWorkbookReviewState } from "@/screens/data-screen.workbook-review"; import type { BackfillPreviewResult, + BackfillExecutionHistoryResponse, BackfillProviderProgressSnapshot, BackfillProgressResponse, BackfillTriggerRequest, @@ -211,10 +212,40 @@ export interface BackfillLiveProgressState { observedAt: string; } +export type BackfillRemediationQueueSortColumn = "tier" | "deadline"; + +export interface BackfillRemediationQueueSortState { + columnId: BackfillRemediationQueueSortColumn; + direction: "asc" | "desc"; +} + +export interface BackfillRemediationQueueRow { + executionId: string; + symbols: string; + provider: string; + tier: string; + tierSort: number; + status: string; + deadline: string; + deadlineSort: number; + workflow: string; + owner: string; + outcome: string; + evidence: string; +} + +export interface BackfillRemediationQueueState { + rows: BackfillRemediationQueueRow[]; + summary: string; + defaultProvider: string; + observedAt: string; +} + export interface BackfillTriggerServices { preview: (request: BackfillTriggerRequest) => Promise; run: (request: BackfillTriggerRequest) => Promise; getProgress: (signal?: AbortSignal) => Promise; + getExecutions?: (signal?: AbortSignal) => Promise; } export interface ProviderSetupLifecycleServices { @@ -933,7 +964,8 @@ export const BACKFILL_PROVIDER_OPTIONS: BackfillProviderOptionState[] = [ const defaultBackfillServices: BackfillTriggerServices = { preview: (request) => workstationApi.previewBackfill(request), run: (request) => workstationApi.triggerBackfill(request), - getProgress: (signal) => workstationApi.getBackfillProgress({ signal }) + getProgress: (signal) => workstationApi.getBackfillProgress({ signal }), + getExecutions: (signal) => workstationApi.getBackfillExecutionHistory(100, { signal }) }; const BACKFILL_PROGRESS_POLL_INTERVAL_MS = 500; const defaultProviderSetupLifecycle: ProviderSetupLifecycleServices = {}; @@ -1135,6 +1167,14 @@ export function useDataViewModel( const [preview, setPreview] = useState(null); const [result, setResult] = useState(null); const [liveProgress, setLiveProgress] = useState(null); + const [remediationHistory, setRemediationHistory] = useState(null); + const [remediationHistoryLoading, setRemediationHistoryLoading] = useState(false); + const [remediationHistoryError, setRemediationHistoryError] = useState(null); + const [remediationQueueSort, setRemediationQueueSort] = useState({ + columnId: "tier", + direction: "asc" + }); + const remediationHistoryRevisionRef = useRef(0); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const [phase, setPhase] = useState("idle"); @@ -1223,6 +1263,56 @@ export function useDataViewModel( }, [backfillLifecycle.invalidate, uploadLifecycle.invalidate]); const workstream = useMemo(() => resolveDataWorkstream(pathname), [pathname]); + const getBackfillExecutions = services.getExecutions; + const refreshRemediationHistory = useCallback(async (signal?: AbortSignal) => { + if (!getBackfillExecutions) { + return; + } + + const revision = remediationHistoryRevisionRef.current + 1; + remediationHistoryRevisionRef.current = revision; + setRemediationHistoryLoading(true); + setRemediationHistoryError(null); + + try { + const response = await getBackfillExecutions(signal); + if (!signal?.aborted && remediationHistoryRevisionRef.current === revision) { + setRemediationHistory(response); + } + } catch (failure: unknown) { + if (!signal?.aborted && remediationHistoryRevisionRef.current === revision) { + setRemediationHistoryError( + describeApiError(failure, "Backfill remediation history is unavailable.").summary + ); + } + } finally { + if (!signal?.aborted && remediationHistoryRevisionRef.current === revision) { + setRemediationHistoryLoading(false); + } + } + }, [getBackfillExecutions]); + + useEffect(() => { + if (workstream !== "backfills") { + return undefined; + } + + const controller = new AbortController(); + void refreshRemediationHistory(controller.signal); + + return () => controller.abort(); + }, [refreshRemediationHistory, workstream]); + + const remediationQueueState = useMemo( + () => buildBackfillRemediationQueueState(remediationHistory, remediationQueueSort), + [remediationHistory, remediationQueueSort] + ); + const toggleRemediationQueueSort = useCallback((columnId: string) => { + if (columnId !== "tier" && columnId !== "deadline") return; + setRemediationQueueSort((current) => current.columnId === columnId + ? { columnId, direction: current.direction === "asc" ? "desc" : "asc" } + : { columnId, direction: "asc" }); + }, []); const selectedProvider = useMemo( () => resolveSelectedProvider(data?.providers ?? [], selectedProviderId), [data, selectedProviderId] @@ -1590,6 +1680,11 @@ export function useDataViewModel( if (finalProgress && token.isCurrent()) { token.safeSetState(setLiveProgress, finalProgress); } + await refreshRemediationHistory(token.signal); + if (!token.isCurrent()) { + backfillLifecycle.markStale(token.version); + return; + } backfillLifecycle.succeed(token, { message: "Historical backfill run completed." }); } catch (err) { if (!token.isCurrent()) { @@ -1607,7 +1702,7 @@ export function useDataViewModel( } backfillLifecycle.finish(token); } - }, [backfillLifecycle.fail, backfillLifecycle.finish, backfillLifecycle.markStale, backfillLifecycle.start, backfillLifecycle.succeed, configuredBackfillProviders, form, preview, services]); + }, [backfillLifecycle.fail, backfillLifecycle.finish, backfillLifecycle.markStale, backfillLifecycle.start, backfillLifecycle.succeed, configuredBackfillProviders, form, preview, refreshRemediationHistory, services]); const resetPlaidInstitutionSearch = useCallback(() => { plaidInstitutionSearchRevisionRef.current += 1; @@ -2006,6 +2101,11 @@ export function useDataViewModel( result, runResultCard, liveProgressState, + remediationQueueState, + remediationHistoryLoading, + remediationHistoryError, + remediationQueueSort, + toggleRemediationQueueSort, error, busy, phase, @@ -3989,6 +4089,63 @@ export function buildBackfillLiveProgressState( }; } +export function buildBackfillRemediationQueueState( + response: BackfillExecutionHistoryResponse | null, + sort: BackfillRemediationQueueSortState = { columnId: "tier", direction: "asc" } +): BackfillRemediationQueueState | null { + if (!response) return null; + + const defaultProvider = formatBackfillValue(response.autoRemediation.defaultProvider, "Not configured"); + const rows = response.executions + .filter((execution) => execution.autoRemediationSla !== null) + .map((execution): BackfillRemediationQueueRow => { + const sla = execution.autoRemediationSla!; + const dueAt = new Date(sla.dueAtUtc); + const deadlineSort = Number.isNaN(dueAt.getTime()) ? Number.MAX_SAFE_INTEGER : dueAt.getTime(); + return { + executionId: execution.executionId, + symbols: execution.symbols.length > 0 ? execution.symbols.join(", ") : `${execution.symbolsProcessed} symbol${execution.symbolsProcessed === 1 ? "" : "s"}`, + provider: formatBackfillValue(sla.provider, defaultProvider), + tier: sla.tier === "SameBusinessDay" ? "Same business day" : "Standard", + tierSort: sla.tier === "SameBusinessDay" ? 0 : 1, + status: formatBackfillRemediationStatus(sla.status), + deadline: Number.isNaN(dueAt.getTime()) ? "Deadline unavailable" : `${formatUtcMinute(dueAt)} UTC`, + deadlineSort, + workflow: formatBackfillValue(sla.downstreamWorkflow, "Unassigned"), + owner: sla.requiresOwnerAssignment ? "Assignment required" : "Not required", + outcome: formatBackfillValue(execution.autoRemediationLastOutcome, execution.status), + evidence: [ + formatBackfillValue(sla.reasonCode, "Reason unavailable"), + sla.triggerSource, + sla.isCompatibilityDerived ? "Compatibility-derived legacy evidence" : null + ].filter(Boolean).join(" · ") + }; + }); + + const direction = sort.direction === "asc" ? 1 : -1; + rows.sort((left, right) => { + const comparison = sort.columnId === "deadline" + ? left.deadlineSort - right.deadlineSort + : left.tierSort - right.tierSort || left.deadlineSort - right.deadlineSort; + return comparison === 0 + ? left.executionId.localeCompare(right.executionId) + : comparison * direction; + }); + + return { + rows, + summary: `${rows.length} retained remediation execution${rows.length === 1 ? "" : "s"}; ${response.autoRemediation.withReason} carry typed reason evidence.`, + defaultProvider, + observedAt: formatBackfillProgressTimestamp(response.timestamp) + }; +} + +function formatBackfillRemediationStatus(status: string): string { + return status + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/^./, (value) => value.toUpperCase()); +} + function resolveBackfillProviderProgressStatus( symbol: BackfillProviderProgressSnapshot["symbols"][string] ): string { diff --git a/src/Meridian.Ui/dashboard/src/screens/data-screen.workstreams.tsx b/src/Meridian.Ui/dashboard/src/screens/data-screen.workstreams.tsx index 7608aa2f68..1a45d6a827 100644 --- a/src/Meridian.Ui/dashboard/src/screens/data-screen.workstreams.tsx +++ b/src/Meridian.Ui/dashboard/src/screens/data-screen.workstreams.tsx @@ -14,6 +14,7 @@ import { DATA_EXPORT_DETAIL_PANEL_ID, type DataOperationsBackfillDetailState, type DataOperationsBackfillRow, + type BackfillRemediationQueueRow, type DataOperationsDetailField, type DataOperationsEmptyState, type DataOperationsExportDetailState, @@ -69,6 +70,65 @@ const backfillQueueColumns: DenseDataTableColumn[] = } ]; +const remediationQueueColumns: DenseDataTableColumn[] = [ + { + id: "execution", + label: "Execution", + render: (row) => ( + + {row.executionId} + {row.symbols} + + ) + }, + { + id: "provider", + label: "Provider", + render: (row) => {row.provider} + }, + { + id: "tier", + label: "SLA tier", + sortable: true, + render: (row) => {row.tier} + }, + { + id: "status", + label: "Status", + render: (row) => ( + + {row.status} + + ) + }, + { + id: "deadline", + label: "Deadline", + sortable: true, + render: (row) => {row.deadline} + }, + { + id: "ownership", + label: "Workflow / owner", + render: (row) => ( + + {row.workflow} + {row.owner} + + ) + }, + { + id: "outcome", + label: "Outcome / evidence", + render: (row) => ( + + {row.outcome} + {row.evidence} + + ) + } +]; + const exportColumns: DenseDataTableColumn[] = [ { id: "profile", @@ -144,6 +204,45 @@ export function DataBackfillWorkstream({ vm }: { vm: DataOperationsVm }) { emptyState={vm.backfillDetailEmptyState ?? vm.backfillSection.emptyState} />
    +
    +
    +
    +

    Remediation SLA queue

    +

    + Durable auto-remediation evidence, ordered by typed SLA tier or deadline. +

    +
    + {vm.remediationQueueState ? ( +
    +
    Default provider: {vm.remediationQueueState.defaultProvider}
    +
    Observed {vm.remediationQueueState.observedAt}
    +
    + ) : null} +
    + {vm.remediationHistoryError ? ( + + ) : vm.remediationHistoryLoading ? ( +

    Loading durable remediation history…

    + ) : vm.remediationQueueState && vm.remediationQueueState.rows.length > 0 ? ( + row.executionId} + getRowAriaLabel={(row) => `${row.symbols}; ${row.tier}; ${row.status}; ${row.deadline}`} + emptyText="No auto-remediation SLA evidence is retained." + ariaLabel="Backfill remediation SLA queue" + caption={vm.remediationQueueState.summary} + sort={vm.remediationQueueSort} + onToggleSort={vm.toggleRemediationQueueSort} + maxVisibleRows={100} + /> + ) : ( + + )} +
); diff --git a/src/Meridian.Ui/dashboard/src/types/workstation-3.ts b/src/Meridian.Ui/dashboard/src/types/workstation-3.ts index a2b75b454e..cbe0e3142a 100644 --- a/src/Meridian.Ui/dashboard/src/types/workstation-3.ts +++ b/src/Meridian.Ui/dashboard/src/types/workstation-3.ts @@ -345,6 +345,7 @@ export interface LockClosePeriodRequest { closePackageManifestId?: string | null; closePackageRetainedManifestRoute?: string | null; actionOrigin?: OperationsActionOrigin | null; + prepareClosingEntriesOnly?: boolean; } export interface ClosePeriodLockResult { @@ -365,8 +366,46 @@ export interface CloseOperatingCoverageItem { blockingIssues?: AccountingConfigurationValidationIssue[] | null; } +export type ClosePostingGateState = + | "Unavailable" + | "NotRequired" + | "Required" + | "DraftQueued" + | "Submitted" + | "Approved" + | "Posted" + | "ReversalQueued" + | "Blocked"; + +export interface ClosePostingBalance { + accountName: string; + accountType: string; + balance: number; + symbol?: string | null; + financialAccountId?: string | null; + dimensions?: LedgerDimensionSet | null; +} + +export interface ClosePostingGate { + gateId: string; + label: string; + state: ClosePostingGateState; + isReadyForLock: boolean; + netIncomeRoll: number; + temporaryAccountBalanceCount: number; + detail: string; + draftJournalEntryId?: string | null; + draftStatus?: ManualJournalEntryStatus | null; + idempotencyKey?: string | null; + balances?: ClosePostingBalance[] | null; + evidenceLinks?: string[] | null; + closingBatchJournalEntryIds?: string[] | null; + reversalDraftJournalEntryIds?: string[] | null; +} + export interface ClosePeriodPlan { closePlanId: string; + workflowVersion?: number | null; fundProfileId: string; ledgerBookId: string | null; periodId: string; @@ -382,6 +421,7 @@ export interface ClosePeriodPlan { configuration?: ClosePeriodPlanConfiguration | null; evidenceReviews?: CloseEvidenceReview[] | null; operatingCoverage?: CloseOperatingCoverageItem[] | null; + closingEntriesGate?: ClosePostingGate | null; } export interface ReportCertification { diff --git a/src/Meridian.Ui/dashboard/src/types/workstation-6.ts b/src/Meridian.Ui/dashboard/src/types/workstation-6.ts index cb60069495..d368b7b3a1 100644 --- a/src/Meridian.Ui/dashboard/src/types/workstation-6.ts +++ b/src/Meridian.Ui/dashboard/src/types/workstation-6.ts @@ -484,6 +484,127 @@ export interface PrivateCapitalCloseCockpitLane { requiredActions: string[]; } +export type DailyValuationScheduleState = + | "NotConfigured" + | "Scheduled" + | "Running" + | "DraftReady" + | "NoAdjustment" + | "Blocked" + | "Failed" + | "Posted"; + +export interface DailyValuationScheduleStatus { + scheduleId?: string | null; + fundProfileId?: string | null; + ledgerBookId?: string | null; + periodId?: string | null; + isConfigured: boolean; + isEnabled: boolean; + nextRunAtUtc?: string | null; + lastRunAtUtc?: string | null; + state: DailyValuationScheduleState; + summary: string; + journalEntryId?: string | null; + evidenceLinks: OperationsEvidenceLink[]; + blockers: string[]; + journalEntryIds: string[]; + batchCorrelationId?: string | null; + entityId?: string | null; + tenantId?: string | null; + companyId?: string | null; +} + +export interface DailyValuationPosition { + symbol: string; + quantity: number; + costPrice: number; + financialAccountId?: string | null; + instrumentType?: string | null; + securityId?: string | null; +} + +export interface DailyValuationPositionSnapshotScope { + runId: string; + accountId: string; +} + +export interface DailyValuationScheduleWorkItem { + scheduleId: string; + fundProfileId: string; + currency: string; + actor: string; + ledgerBookId: string; + periodId: string; + nextRunAtUtc: string; + positions: DailyValuationPosition[]; + policyId: string; + policyName: string; + valuationMethod: string; + policyApprovedBy: string; + policyApprovedAtUtc: string; + reason: string; + maximumMarkAgeDays?: number; + minimumConfidence?: "Low" | "Medium" | "High"; + requireCompleteCoverage?: boolean; + isEnabled?: boolean; + closePeriodId?: string | null; + entityId?: string | null; + tenantId?: string | null; + companyId?: string | null; + state?: DailyValuationScheduleState; + lastRunAtUtc?: string | null; + lastScheduledForUtc?: string | null; + journalEntryId?: string | null; + lastSummary?: string | null; + evidenceLinks?: OperationsEvidenceLink[]; + blockers?: string[]; + positionSnapshotScopes?: DailyValuationPositionSnapshotScope[]; + useStaticPositionOverride?: boolean; + staticPositionsAsOfUtc?: string | null; + maximumPositionAgeDays?: number; + staticPositionHash?: string | null; + journalEntryIds?: string[]; + batchCorrelationId?: string | null; + createdBy?: string | null; + lastConfiguredBy?: string | null; +} + +export interface DailyValuationScheduledRunResult { + scheduleId: string; + scheduledForUtc: string; + state: DailyValuationScheduleState; + summary: string; + journalEntryId?: string | null; + blockers: string[]; + journalEntryIds: string[]; + batchCorrelationId?: string | null; +} + +export interface DailyValuationScheduledBatchResult { + evaluatedAtUtc: string; + runs: DailyValuationScheduledRunResult[]; +} + +export interface DailyValuationBatchLifecycleRequest { + scheduleId: string; + fundProfileId: string; + actor: string; + notes: string; + evidenceLinks?: string[]; + tenantId?: string | null; + companyId?: string | null; +} + +export interface DailyValuationBatchLifecycleResult { + scheduleId: string; + batchCorrelationId: string; + isComplete: boolean; + journalEntryIds: string[]; + postedJournalEntryIds: string[]; + blockers: string[]; +} + export interface PrivateCapitalCloseCockpit { fundProfileId?: string | null; ledgerBookId?: string | null; @@ -511,6 +632,7 @@ export interface PrivateCapitalCloseCockpit { approvalHistory?: PrivateCapitalCloseCockpitApproval[] | null; navSupportPackages?: PrivateCapitalNavSupportPackage[] | null; evidencePackages?: OperationsEvidencePackageSummary[] | null; + dailyValuationStatus?: DailyValuationScheduleStatus | null; } export interface PrivateCapitalCapitalAccountSubledger { diff --git a/src/Meridian.Ui/dashboard/src/types/workstation-7.ts b/src/Meridian.Ui/dashboard/src/types/workstation-7.ts index 746fb85a9e..78d0ae9a4a 100644 --- a/src/Meridian.Ui/dashboard/src/types/workstation-7.ts +++ b/src/Meridian.Ui/dashboard/src/types/workstation-7.ts @@ -1184,6 +1184,55 @@ export interface BackfillProgressResponse { message?: string | null; } +export type BackfillRemediationSlaTier = "Standard" | "SameBusinessDay"; + +export type BackfillRemediationSlaStatus = "Open" | "DueSoon" | "Overdue" | "Failed" | "Completed"; + +export interface BackfillRemediationSla { + tier: BackfillRemediationSlaTier; + status: BackfillRemediationSlaStatus; + dueAtUtc: string; + requiresOwnerAssignment: boolean; + downstreamWorkflow: string; + reasonCode: string; + provider: string; + triggerSource: string | null; + isCompatibilityDerived: boolean; +} + +export interface BackfillExecutionHistoryRow { + executionId: string; + scheduleName: string; + trigger: string; + scheduleId: string; + status: string; + startedAt: string; + completedAt: string | null; + symbolsProcessed: number; + barsDownloaded: number; + errorMessage: string | null; + fromDate: string; + toDate: string; + symbols: string[]; + autoRemediationTriggerReason: string | null; + autoRemediationAttemptCount: number; + autoRemediationLastOutcome: string | null; + autoRemediationIdempotencyKey: string | null; + autoRemediationSla: BackfillRemediationSla | null; +} + +export interface BackfillExecutionHistoryResponse { + executions: BackfillExecutionHistoryRow[]; + total: number; + autoRemediation: { + total: number; + withReason: number; + lastOutcome: string | null; + defaultProvider: string; + }; + timestamp: string; +} + // --- System Overview types --- export interface SystemEventRecord { diff --git a/src/Meridian.Wpf/Features/Accounting/AccountingFeatureModule.cs b/src/Meridian.Wpf/Features/Accounting/AccountingFeatureModule.cs index 21c0192819..ac85e1f804 100644 --- a/src/Meridian.Wpf/Features/Accounting/AccountingFeatureModule.cs +++ b/src/Meridian.Wpf/Features/Accounting/AccountingFeatureModule.cs @@ -1,7 +1,10 @@ using System; using System.IO; using System.Threading.Tasks; +using Meridian.Application.Accounting; using Meridian.Application.FundStructure; +using Meridian.Contracts.Catalog; +using Meridian.Contracts.Domain; using Meridian.FinancialOperations.AccountingClose; using Meridian.DataIntegration.AccountingSystem.Fixtures; using Meridian.DataIntegration.AccountingSystem.QuickBooks; @@ -13,6 +16,7 @@ using Meridian.FinancialOperations.OperationsContinuity; using Meridian.FinancialOperations.PrivateCapital; using Meridian.Instruments.AssetOperations; +using Meridian.Infrastructure.Adapters.Core; using Meridian.PortfolioRecords.FundAccounts; using Meridian.ProviderSdk.AccountingSystem; using Meridian.Ui.Services.Services.Accounting; @@ -25,6 +29,7 @@ using Meridian.Storage.Ledger; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; namespace Meridian.Wpf.Features.Accounting; @@ -91,6 +96,11 @@ public void Register(IServiceCollection services) sp.GetRequiredService()); services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => + new DailyValuationPositionService( + sp.GetService(), + sp.GetService(), + sp.GetService())); services.TryAddSingleton(sp => new FileAutomatedJournalScheduleStore( Path.Combine(ResolveAccountingDataDirectory(sp), "monthly-automated-journal-schedules.json"))); @@ -98,6 +108,7 @@ public void Register(IServiceCollection services) sp.GetRequiredService()); services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(TimeProvider.System); services.TryAddSingleton(sp => ActivatorUtilities.CreateInstance(sp)); services.TryAddSingleton(sp => @@ -107,12 +118,38 @@ public void Register(IServiceCollection services) sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService())); + services.TryAddSingleton(); + services.TryAddSingleton(AutomatedJournalEvidencePolicy.Default); services.TryAddSingleton(sp => - new AutomatedJournalIntakeRunner( + { + var securityMaster = sp.GetService(); + var providerRegistry = sp.GetService(); + var journalStore = sp.GetService(); + return new AutomatedJournalIntakeRunner( sp.GetRequiredService(), new FeeScheduleAccrualEventProducer(), - ledgerBookService: sp.GetService())); - services.TryAddSingleton(); + securityMaster is null ? null : new CorporateActionDividendEventProducer(securityMaster), + sp.GetService(), + providerRegistry is null || journalStore is null + ? null + : new DailyMarkToMarketService( + new RegisteredHistoricalCloseMarkPriceSource(providerRegistry), + new LedgerMarkToMarketCarryingValueSource(journalStore)), + sp.GetRequiredService(), + sp.GetRequiredService()); + }); + services.TryAddSingleton(sp => + new AccountingClosePostingWorkbenchBridge( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetService())); + services.TryAddSingleton(); + services.TryAddEnumerable( + ServiceDescriptor.Singleton()); + services.TryAddSingleton(); + services.TryAddEnumerable( + ServiceDescriptor.Singleton()); services.TryAddSingleton(sp => new CapitalAccountWorkbenchService( sp.GetRequiredService(), diff --git a/src/Meridian.Wpf/ViewModels/Accounting/AccountingCloseViewModel.cs b/src/Meridian.Wpf/ViewModels/Accounting/AccountingCloseViewModel.cs index 2645eae8b4..3e836f3c07 100644 --- a/src/Meridian.Wpf/ViewModels/Accounting/AccountingCloseViewModel.cs +++ b/src/Meridian.Wpf/ViewModels/Accounting/AccountingCloseViewModel.cs @@ -12,6 +12,7 @@ public sealed class AccountingCloseViewModel : Meridian.Wpf.ViewModels.BindableB private readonly IAccountingProjectionQueryService _queryService; private readonly IAccountingCloseManagementService? _closeManagementService; private ClosePeriodPlanDto? _closePlan; + private ClosePostingGateDto? _closingEntriesGate; private Guid _closeWorkflowId; private long _closeWorkflowVersion; private ClosePeriodState _closeState = ClosePeriodState.Open; @@ -70,6 +71,7 @@ public AccountingCloseViewModel( RequestLateAdjustmentCommand = new AsyncRelayCommand(RequestLateAdjustmentAsync, CanRequestLateAdjustment); ReviewLateAdjustmentCommand = new AsyncRelayCommand(ReviewLateAdjustmentAsync, CanReviewLateAdjustment); ReviewCloseEvidenceCommand = new AsyncRelayCommand(ReviewCloseEvidenceAsync, CanReviewCloseEvidence); + QueueClosingEntriesCommand = new AsyncRelayCommand(QueueClosingEntriesAsync, CanQueueClosingEntries); LockClosePeriodCommand = new AsyncRelayCommand(LockClosePeriodAsync, CanLockClosePeriod); } @@ -85,6 +87,7 @@ public AccountingCloseViewModel( public ObservableCollection CloseEvidenceReviewRows { get; } = []; public ObservableCollection ClosePeriodLockIssueRows { get; } = []; public ObservableCollection CloseOperatingCoverageRows { get; } = []; + public ObservableCollection ClosingEntryBalanceRows { get; } = []; public ObservableCollection CloseWorkflowSteps { get; } = []; public ObservableCollection CloseSetupTaskOptions { get; } = []; public IReadOnlyList CloseTaskSignOffDecisionOptions { get; } = @@ -105,6 +108,7 @@ public AccountingCloseViewModel( public IAsyncRelayCommand RequestLateAdjustmentCommand { get; } public IAsyncRelayCommand ReviewLateAdjustmentCommand { get; } public IAsyncRelayCommand ReviewCloseEvidenceCommand { get; } + public IAsyncRelayCommand QueueClosingEntriesCommand { get; } public IAsyncRelayCommand LockClosePeriodCommand { get; } public string CloseWorkflowIdText @@ -633,6 +637,79 @@ private set } } + public ClosePostingGateDto? ClosingEntriesGate + { + get => _closingEntriesGate; + private set + { + if (!SetProperty(ref _closingEntriesGate, value)) + { + return; + } + + RaisePropertyChanged(nameof(ClosingEntriesGateStatusText)); + RaisePropertyChanged(nameof(ClosingEntriesNetIncomeRollText)); + RaisePropertyChanged(nameof(ClosingEntriesBalanceCountText)); + RaisePropertyChanged(nameof(ClosingEntriesLockPostureText)); + RaisePropertyChanged(nameof(ClosingEntriesDetailText)); + RaisePropertyChanged(nameof(ClosingEntriesJournalEvidenceText)); + QueueClosingEntriesCommand.NotifyCanExecuteChanged(); + LockClosePeriodCommand.NotifyCanExecuteChanged(); + } + } + + public string ClosingEntriesGateStatusText + => ClosingEntriesGate is null + ? "Not supplied" + : FormatClosePostingGateState(ClosingEntriesGate.State); + + public string ClosingEntriesNetIncomeRollText + => ClosingEntriesGate is null + ? "Net-income roll unavailable" + : string.Format( + CultureInfo.InvariantCulture, + "{0:+#,##0.00;-#,##0.00;0.00} {1}", + ClosingEntriesGate.NetIncomeRoll, + _closePlan?.MaterialityPolicy.Currency ?? string.Empty).TrimEnd(); + + public string ClosingEntriesBalanceCountText + => ClosingEntriesGate is null + ? "Scoped balances unavailable" + : $"{ClosingEntriesGate.TemporaryAccountBalanceCount:N0} {Pluralize(ClosingEntriesGate.TemporaryAccountBalanceCount, "temporary-account balance", "temporary-account balances")}"; + + public string ClosingEntriesLockPostureText + => ClosingEntriesGate is null + ? "Lock posture unavailable" + : ClosingEntriesGate.IsReadyForLock + ? "Ready for lock" + : "Posting required before lock"; + + public string ClosingEntriesDetailText + => ClosingEntriesGate?.Detail + ?? "The shared close plan did not return the typed closing-entry posting gate."; + + public string ClosingEntriesJournalEvidenceText + { + get + { + if (ClosingEntriesGate is not { } gate) + { + return "No closing-entry draft, batch, reversal, or evidence identifiers were returned."; + } + + var draft = gate.DraftJournalEntryId is { } draftId + ? $"Draft {draftId:D}{(gate.DraftStatus is { } status ? $" ({status})" : string.Empty)}" + : "No draft queued"; + var closingBatches = gate.ClosingBatchJournalEntryIds.Count == 0 + ? "no closing batches" + : $"closing batches {string.Join(", ", gate.ClosingBatchJournalEntryIds.Select(static id => id.ToString("D")))}"; + var reversals = gate.ReversalDraftJournalEntryIds.Count == 0 + ? "no reversal drafts" + : $"reversal drafts {string.Join(", ", gate.ReversalDraftJournalEntryIds.Select(static id => id.ToString("D")))}"; + return $"{draft}; {closingBatches}; {reversals}; {gate.EvidenceLinks.Count:N0} {Pluralize(gate.EvidenceLinks.Count, "evidence link", "evidence links")}."; + } + } + public SourceLinkedAuditLine? SelectedAuditLine { get => _selectedAuditLine; @@ -712,18 +789,24 @@ public void ApplyCloseProjection(AccountingCloseProjection projection) public void ApplyClosePlan(ClosePeriodPlanDto closePlan) { - ApplyClosePlan(closePlan.Configuration?.WorkflowId ?? Guid.Empty, closePlan); + ApplyClosePlan( + closePlan.Configuration?.WorkflowId ?? Guid.Empty, + closePlan.WorkflowVersion, + closePlan); } public void ApplyClosePlan(Guid workflowId, ClosePeriodPlanDto closePlan) - => ApplyClosePlan(workflowId, _closeWorkflowVersion, closePlan); + => ApplyClosePlan(workflowId, closePlan.WorkflowVersion, closePlan); public void ApplyClosePlan(Guid workflowId, long workflowVersion, ClosePeriodPlanDto closePlan) { ArgumentNullException.ThrowIfNull(closePlan); _closeWorkflowId = workflowId; - _closeWorkflowVersion = Math.Max(0, workflowVersion); + _closeWorkflowVersion = closePlan.WorkflowVersion > 0 + ? closePlan.WorkflowVersion + : Math.Max(0, workflowVersion); _closePlan = closePlan; + ApplyClosingEntriesGate(closePlan); ApplyCloseSetupDraft(closePlan); ApplyCloseReviewRows(closePlan); ClosePlanSetupStatusText = workflowId == Guid.Empty @@ -735,7 +818,7 @@ public void ApplyClosePlan(Guid workflowId, long workflowVersion, ClosePeriodPla ? $"Close plan {closePlan.PeriodId} loaded without workflow context; period lock is disabled." : closePlan.IsPeriodLocked ? $"Close plan {closePlan.PeriodId} is already locked." - : $"Close plan {closePlan.PeriodId} is ready for governed period-lock review."; + : ResolveClosePeriodLockStatus(closePlan); CloseTaskSignOffStatusText = workflowId == Guid.Empty ? $"Close plan {closePlan.PeriodId} loaded without workflow context; task sign-off is disabled." : closePlan.IsPeriodLocked @@ -769,6 +852,7 @@ public void ApplyClosePlan(Guid workflowId, long workflowVersion, ClosePeriodPla RequestLateAdjustmentCommand.NotifyCanExecuteChanged(); ReviewLateAdjustmentCommand.NotifyCanExecuteChanged(); ReviewCloseEvidenceCommand.NotifyCanExecuteChanged(); + QueueClosingEntriesCommand.NotifyCanExecuteChanged(); LockClosePeriodCommand.NotifyCanExecuteChanged(); RefreshCloseWorkflowSteps(); } @@ -997,10 +1081,35 @@ private bool CanRequestLateAdjustment() _closePlan is { IsPeriodLocked: false } closePlan && ValidateLateAdjustmentDraft(closePlan) is null; + private bool CanQueueClosingEntries() + => _closeManagementService is not null && + _closeWorkflowId != Guid.Empty && + _closePlan is { IsPeriodLocked: false } && + ClosingEntriesGate?.State == ClosePostingGateStateDto.Required; + + private static string ResolveClosePeriodLockStatus(ClosePeriodPlanDto closePlan) + => closePlan.ClosingEntriesGate switch + { + null => "The shared close plan did not return a closing-entry gate; period lock is disabled.", + { State: ClosePostingGateStateDto.Required } => + $"Close plan {closePlan.PeriodId} requires closing entries to be queued before period lock.", + { State: ClosePostingGateStateDto.DraftQueued or ClosePostingGateStateDto.Submitted or ClosePostingGateStateDto.Approved } gate => + $"Close plan {closePlan.PeriodId} cannot lock until closing entries advance from {FormatClosePostingGateState(gate.State)} to Posted.", + { IsReadyForLock: true, State: ClosePostingGateStateDto.Posted or ClosePostingGateStateDto.NotRequired } => + $"Close plan {closePlan.PeriodId} is ready for governed period-lock review.", + { } gate => + $"Close plan {closePlan.PeriodId} cannot lock while closing-entry gate state is {FormatClosePostingGateState(gate.State)}." + }; + private bool CanLockClosePeriod() => _closeManagementService is not null && _closeWorkflowId != Guid.Empty && - _closePlan is { IsPeriodLocked: false }; + _closePlan is { IsPeriodLocked: false } && + ClosingEntriesGate is + { + IsReadyForLock: true, + State: ClosePostingGateStateDto.Posted or ClosePostingGateStateDto.NotRequired + }; private async Task LoadClosePlanAsync() { @@ -1023,7 +1132,7 @@ private async Task LoadClosePlanAsync() return; } - ApplyClosePlan(workflowId, 0, closePlan); + ApplyClosePlan(workflowId, closePlan); CloseWorkflowIdText = workflowId.ToString("D"); ClosePlanSetupStatusText = $"Loaded close plan {closePlan.PeriodId} for governed setup retention."; } @@ -1068,7 +1177,7 @@ private async Task ConfigureClosePlanAsync() return; } - ApplyClosePlan(_closeWorkflowId, _closeWorkflowVersion, updated); + ApplyClosePlan(_closeWorkflowId, updated); ClosePlanSetupStatusText = $"Retained close-plan setup for {updated.PeriodId}."; } catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) @@ -1130,7 +1239,7 @@ private async Task SignOffCloseTaskAsync() return; } - ApplyClosePlan(_closeWorkflowId, _closeWorkflowVersion, updated); + ApplyClosePlan(_closeWorkflowId, updated); CloseTaskSignOffStatusText = request.Decision == ManualJournalEntryStatusDto.Approved ? $"Retained {request.Role} sign-off evidence for close task {request.TaskId}." : $"Retained {request.Role} rejection evidence for close task {request.TaskId}."; @@ -1187,7 +1296,7 @@ private async Task RequestLateAdjustmentAsync() return; } - ApplyClosePlan(_closeWorkflowId, _closeWorkflowVersion, updated); + ApplyClosePlan(_closeWorkflowId, updated); LateAdjustmentRequestStatusText = $"Requested retained late adjustment for journal {request.JournalEntryId:D}."; } catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) @@ -1242,7 +1351,7 @@ private async Task ReviewLateAdjustmentAsync() return; } - ApplyClosePlan(_closeWorkflowId, _closeWorkflowVersion, updated); + ApplyClosePlan(_closeWorkflowId, updated); LateAdjustmentReviewStatusText = $"{request.Decision} late adjustment {request.RequestId} with retained WPF review evidence."; } catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) @@ -1297,7 +1406,7 @@ private async Task ReviewCloseEvidenceAsync() return; } - ApplyClosePlan(_closeWorkflowId, _closeWorkflowVersion, updated); + ApplyClosePlan(_closeWorkflowId, updated); CloseEvidenceReviewStatusText = $"Retained WPF evidence review for blocker {request.IssueCode}."; } catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) @@ -1306,6 +1415,73 @@ private async Task ReviewCloseEvidenceAsync() } } + private async Task QueueClosingEntriesAsync() + { + if (_closeManagementService is null) + { + ClosePeriodLockStatusText = "Close management service is not registered for this desktop session."; + return; + } + + if (_closePlan is null || _closeWorkflowId == Guid.Empty) + { + ClosePeriodLockStatusText = "Load a workflow-scoped close plan before queuing closing entries."; + return; + } + + if (_closePlan.IsPeriodLocked) + { + ClosePeriodLockStatusText = $"Close plan {_closePlan.PeriodId} is already locked."; + return; + } + + if (!CanQueueClosingEntries()) + { + ClosePeriodLockStatusText = ClosingEntriesGate is null + ? "The shared close plan did not return a closing-entry gate." + : $"Closing entries can only be queued while the gate is Required; current state is {ClosingEntriesGateStatusText}."; + return; + } + + try + { + var request = BuildClosePeriodLockRequest( + _closeWorkflowId, + _closeWorkflowVersion, + _closePlan, + prepareClosingEntriesOnly: true); + var result = await _closeManagementService + .LockClosePeriodAsync(request, "wpf-accounting-controller") + .ConfigureAwait(true); + + if (result is null) + { + ClosePeriodLockStatusText = $"Close workflow {_closeWorkflowId:D} was not found."; + return; + } + + if (result.Plan is not null) + { + ApplyClosePlan(_closeWorkflowId, result.Plan); + } + + ApplyClosePeriodLockIssues(result.Issues); + ClosePeriodLockStatusText = result.Plan is + { + ClosingEntriesGate.State: ClosePostingGateStateDto.DraftQueued or + ClosePostingGateStateDto.Submitted or + ClosePostingGateStateDto.Approved or + ClosePostingGateStateDto.Posted + } preparedPlan + ? $"Prepared closing-entry workflow for close period {preparedPlan.PeriodId}; human approval and posting remain governed." + : $"Closing-entry preparation is blocked by {result.Issues.Count} issue(s)."; + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + ClosePeriodLockStatusText = $"Closing entries could not be queued: {ex.Message}"; + } + } + private async Task LockClosePeriodAsync() { if (_closeManagementService is null) @@ -1332,9 +1508,21 @@ private async Task LockClosePeriodAsync() return; } + if (!CanLockClosePeriod()) + { + ClosePeriodLockStatusText = ClosingEntriesGate is null + ? "The accounting period cannot lock without a shared closing-entry gate." + : $"The accounting period cannot lock while closing-entry gate state is {ClosingEntriesGateStatusText}. Post closing entries or resolve the gate first."; + return; + } + try { - var request = BuildClosePeriodLockRequest(_closeWorkflowId, _closeWorkflowVersion, _closePlan); + var request = BuildClosePeriodLockRequest( + _closeWorkflowId, + _closeWorkflowVersion, + _closePlan, + prepareClosingEntriesOnly: false); var result = await _closeManagementService .LockClosePeriodAsync(request, "wpf-accounting-controller") .ConfigureAwait(true); @@ -1347,7 +1535,10 @@ private async Task LockClosePeriodAsync() if (result.Plan is not null) { - ApplyClosePlan(_closeWorkflowId, result.Transition?.NewVersion ?? _closeWorkflowVersion, result.Plan); + ApplyClosePlan( + _closeWorkflowId, + result.Transition?.NewVersion ?? _closeWorkflowVersion, + result.Plan); } ApplyClosePeriodLockIssues(result.Issues); @@ -1943,6 +2134,77 @@ private static IEnumerable BuildCloseOperatingCoverageRo } } + private void ApplyClosingEntriesGate(ClosePeriodPlanDto closePlan) + { + ClosingEntryBalanceRows.Clear(); + ClosingEntriesGate = closePlan.ClosingEntriesGate; + foreach (var balance in closePlan.ClosingEntriesGate?.Balances ?? []) + { + ClosingEntryBalanceRows.Add(new AccountingClosePostingBalanceRow( + string.IsNullOrWhiteSpace(balance.Symbol) + ? balance.AccountName + : $"{balance.AccountName} ({balance.Symbol.Trim()})", + balance.AccountType, + string.Format( + CultureInfo.InvariantCulture, + "{0:+#,##0.00;-#,##0.00;0.00} {1}", + balance.Balance, + closePlan.MaterialityPolicy.Currency).TrimEnd(), + FormatClosePostingBalanceScope(balance.Dimensions), + NormalizeOptional(balance.FinancialAccountId) ?? "No financial-account id")); + } + } + + private static string FormatClosePostingGateState(ClosePostingGateStateDto state) + => state switch + { + ClosePostingGateStateDto.NotRequired => "Not required", + ClosePostingGateStateDto.DraftQueued => "Draft queued", + ClosePostingGateStateDto.ReversalQueued => "Reversal queued", + _ => state.ToString() + }; + + private static string FormatClosePostingBalanceScope(LedgerDimensionSetDto? dimensions) + { + if (dimensions is null) + { + return "No scoped dimensions returned"; + } + + var labels = new List(); + AddScopeLabel(labels, "Fund", dimensions.FundId); + AddScopeLabel(labels, "Entity", dimensions.EntityId); + AddScopeLabel(labels, "Sleeve", dimensions.SleeveId); + AddScopeLabel(labels, "Strategy", dimensions.StrategyId); + AddScopeLabel(labels, "Investor", dimensions.InvestorId); + AddScopeLabel(labels, "Capital account", dimensions.CapitalAccountId); + AddScopeLabel(labels, "Instrument", dimensions.InstrumentId?.ToString("D")); + AddScopeLabel(labels, "Position", dimensions.PositionId?.ToString("D")); + AddScopeLabel(labels, "Tax lot", dimensions.TaxLotId); + AddScopeLabel(labels, "Cost center", dimensions.CostCenterId); + AddScopeLabel(labels, "Counterparty", dimensions.CounterpartyId); + AddScopeLabel(labels, "Organization", dimensions.OrganizationId); + AddScopeLabel(labels, "Portfolio", dimensions.PortfolioId); + AddScopeLabel(labels, "Book", dimensions.BookId); + AddScopeLabel(labels, "Account", dimensions.AccountId); + foreach (var (key, value) in dimensions.ExternalGlDimensions.OrderBy(static pair => pair.Key, StringComparer.OrdinalIgnoreCase)) + { + AddScopeLabel(labels, $"External {key}", value); + } + + return labels.Count == 0 + ? "No scoped dimensions returned" + : string.Join(" | ", labels); + } + + private static void AddScopeLabel(ICollection labels, string label, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + labels.Add($"{label}: {value.Trim()}"); + } + } + private static AccountingWorkbenchRow BuildMaterialityPolicyRow(ClosePeriodPlanDto closePlan) { var materiality = closePlan.MaterialityPolicy; @@ -2317,7 +2579,8 @@ private static IReadOnlyList BuildLateAdjustmentRequestEvidence( private static LockClosePeriodRequestDto BuildClosePeriodLockRequest( Guid workflowId, long workflowVersion, - ClosePeriodPlanDto closePlan) + ClosePeriodPlanDto closePlan, + bool prepareClosingEntriesOnly) { var reportPackId = BuildCloseReportPackId(closePlan); var closePackageId = $"close-package-{closePlan.FundProfileId}-{closePlan.PeriodId}"; @@ -2326,15 +2589,20 @@ private static LockClosePeriodRequestDto BuildClosePeriodLockRequest( workflowId, ExpectedWorkflowVersion: workflowVersion, Actor: "wpf-accounting-controller", - Rationale: "Lock close period from WPF Accounting Close after checklist, sign-off, reconciliation, and report certification review.", + Rationale: prepareClosingEntriesOnly + ? "Queue closing entries from WPF Accounting Close without hard-locking the accounting period." + : "Lock close period from WPF Accounting Close after checklist, sign-off, reconciliation, report certification, and closing-entry posting review.", ReportPackId: reportPackId, EvidenceLinks: BuildClosePeriodLockEvidence(workflowId, closePlan, reportPackId, closePackageId, manifestId), ChecklistControlApprovals: BuildClosePeriodLockApprovals(closePlan), - CorrelationId: $"wpf-close-period-lock-{workflowId:D}", + CorrelationId: prepareClosingEntriesOnly + ? $"wpf-close-period-prepare-closing-entries-{workflowId:D}" + : $"wpf-close-period-lock-{workflowId:D}", ClosePackageId: closePackageId, ClosePackageManifestId: manifestId, ClosePackageRetainedManifestRoute: $"/workstation/reporting/packages/{manifestId}", - ActionOrigin: OperationsActionOriginDto.HumanOperator); + ActionOrigin: OperationsActionOriginDto.HumanOperator, + PrepareClosingEntriesOnly: prepareClosingEntriesOnly); } private SignOffCloseTaskRequestDto BuildCloseTaskSignOffRequest( @@ -2637,6 +2905,13 @@ public sealed record CloseSetupTaskOption( string DueDate, string SignOffSummary); +public sealed record AccountingClosePostingBalanceRow( + string AccountName, + string AccountType, + string Balance, + string Scope, + string FinancialAccountId); + public sealed record CloseWorkflowStep( string StepId, string Label, diff --git a/src/Meridian.Wpf/ViewModels/DataQualityViewModel.cs b/src/Meridian.Wpf/ViewModels/DataQualityViewModel.cs index fdd8cad4a7..17542bc3dd 100644 --- a/src/Meridian.Wpf/ViewModels/DataQualityViewModel.cs +++ b/src/Meridian.Wpf/ViewModels/DataQualityViewModel.cs @@ -215,13 +215,19 @@ public SymbolQualityModel? SelectedSymbolQuality public string PageTitle => "Data Quality"; public ObservableCollection Actions { get; } = new(); - public DataQualityViewModel(WpfServices.StatusService statusService, WpfServices.LoggingService loggingService, WpfServices.NotificationService notificationService, IRefreshScheduler? refreshScheduler = null) + public DataQualityViewModel( + WpfServices.StatusService statusService, + WpfServices.LoggingService loggingService, + WpfServices.NotificationService notificationService, + IDataQualityApiClient apiClient, + IDataQualityPresentationService presentationService, + IRefreshScheduler? refreshScheduler = null) { _loggingService = loggingService; _notificationService = notificationService; ApiClientService.Instance.Configure(statusService.BaseUrl); - _apiClient = new DataQualityApiClient(ApiClientService.Instance); - _presentationService = new DataQualityPresentationService(_apiClient); + _apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + _presentationService = presentationService ?? throw new ArgumentNullException(nameof(presentationService)); _refreshCoordinator = new DataQualityRefreshCoordinator(refreshScheduler ?? new PeriodicRefreshScheduler(), RefreshDataAsync, ex => _loggingService.LogError("Failed to refresh data quality", ex)); SymbolQualityTable = BuildSymbolQualityTable(FilteredSymbols); UpdateTrendState(); diff --git a/src/Meridian.Wpf/Views/AccountingClosePage.xaml b/src/Meridian.Wpf/Views/AccountingClosePage.xaml index be1de96c75..8fa089364f 100644 --- a/src/Meridian.Wpf/Views/AccountingClosePage.xaml +++ b/src/Meridian.Wpf/Views/AccountingClosePage.xaml @@ -129,6 +129,13 @@ Style="{StaticResource GhostButtonStyle}" AutomationProperties.AutomationId="AccountingCloseEvidenceReviewButton" Margin="0,0,8,8" /> +
public sealed class OrderManagementSystemReportStreamTests { @@ -145,6 +148,114 @@ await WaitUntilAsync(() => oms.GetOrder(result.OrderId)!.Status == OrderStatus.F because: "published fills must carry the increment, not the cumulative quantity"); } + [Fact] + public async Task Scenario_DuplicateVenueFillAfterHandoffOutage_ReplayResumesWithoutPortfolioDuplication() + { + var portfolio = new PaperTradingPortfolio(100_000m); + var publisher = new FailOnceTradeEventPublisher(); + var accountId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + var gateway = new StreamingGateway + { + SubmitAck = BuildReport("pending", OrderStatus.Accepted, ExecutionReportType.New, filledQty: 0m, fillPrice: null) + }; + using var oms = new OrderManagementSystem( + gateway, + NullLogger.Instance, + portfolioState: portfolio, + tradeEventPublisher: publisher); + + var result = await oms.PlaceOrderAsync(new OrderRequest + { + Symbol = "AAPL", + Side = OrderSide.Buy, + Type = OrderType.Market, + Quantity = 10m, + FundAccountId = accountId + }); + var fill = BuildReport( + result.OrderId, + OrderStatus.Filled, + ExecutionReportType.Fill, + filledQty: 10m, + fillPrice: 150m); + + await gateway.PublishAsync(fill); + await WaitUntilAsync(() => publisher.PublishAttempts == 1, + "the first publication attempt must reach the configured accounting handoff"); + await gateway.PublishAsync(fill); + + using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var publishedReport = await oms.ExecutionReports.ReadAsync(readCts.Token); + + publisher.PublishAttempts.Should().Be(2); + publisher.AcceptedEvents.Should().ContainSingle(); + publisher.AcceptedEvents.Single().FillId.Should().NotBeEmpty(); + publisher.AcceptedEvents.Single().FinancialAccountId.Should().Be(accountId.ToString("D")); + publishedReport.FilledQuantity.Should().Be(10m); + portfolio.Positions["AAPL"].Quantity.Should().Be(10m, + "retry resumes after publication and must not reapply the portfolio side effect"); + portfolio.Cash.Should().Be(98_500m); + } + + [Fact] + public async Task Scenario_ExecutionBurstSaturatesSubscriber_BackpressurePreservesEveryFill() + { + var publisher = new RecordingTradeEventPublisher(); + var gateway = new StreamingGateway + { + SubmitAck = BuildReport("pending", OrderStatus.Accepted, ExecutionReportType.New, filledQty: 0m, fillPrice: null) + }; + using var oms = new OrderManagementSystem( + gateway, + NullLogger.Instance, + options: new OrderManagementSystemOptions { ExecutionChannelCapacity = 1 }, + tradeEventPublisher: publisher); + var firstOrder = await oms.PlaceOrderAsync(new OrderRequest + { + Symbol = "AAA", + Side = OrderSide.Buy, + Type = OrderType.Market, + Quantity = 1m + }); + var secondOrder = await oms.PlaceOrderAsync(new OrderRequest + { + Symbol = "BBB", + Side = OrderSide.Buy, + Type = OrderType.Market, + Quantity = 2m + }); + var first = BuildReport( + firstOrder.OrderId, + OrderStatus.Filled, + ExecutionReportType.Fill, + filledQty: 1m, + fillPrice: 10m, + symbol: "AAA"); + var second = BuildReport( + secondOrder.OrderId, + OrderStatus.Filled, + ExecutionReportType.Fill, + filledQty: 2m, + fillPrice: 20m, + symbol: "BBB"); + + await gateway.PublishAsync(first); + await WaitUntilAsync( + () => oms.ExecutionReports.CanCount && oms.ExecutionReports.Count == 1, + "the first fill must occupy the bounded channel"); + await gateway.PublishAsync(second); + await WaitUntilAsync(() => publisher.AcceptedEvents.Count == 2, + "the second fill must reach publication before waiting for channel capacity"); + + using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var firstPublished = await oms.ExecutionReports.ReadAsync(readCts.Token); + var secondPublished = await oms.ExecutionReports.ReadAsync(readCts.Token); + + firstPublished.Symbol.Should().Be("AAA"); + secondPublished.Symbol.Should().Be("BBB", + "the full channel must delay the producer rather than discard the second fill"); + } + private static ExecutionReport BuildReport( string orderId, OrderStatus status, @@ -225,4 +336,26 @@ public IAsyncEnumerable StreamExecutionReportsAsync(Cancellatio public ValueTask PublishAsync(ExecutionReport report) => _reports.Writer.WriteAsync(report); } + + private class RecordingTradeEventPublisher : ITradeEventPublisher + { + public ConcurrentQueue AcceptedEvents { get; } = new(); + + public virtual void Publish(TradeExecutedEvent tradeEvent) => AcceptedEvents.Enqueue(tradeEvent); + } + + private sealed class FailOnceTradeEventPublisher : RecordingTradeEventPublisher + { + private int _publishAttempts; + + public int PublishAttempts => Volatile.Read(ref _publishAttempts); + + public override void Publish(TradeExecutedEvent tradeEvent) + { + if (Interlocked.Increment(ref _publishAttempts) == 1) + throw new InvalidOperationException("simulated durable handoff outage"); + + base.Publish(tradeEvent); + } + } } diff --git a/tests/Meridian.Tests/FinancialOperations/AccountingClose/AccountingCloseServicesTests.cs b/tests/Meridian.Tests/FinancialOperations/AccountingClose/AccountingCloseServicesTests.cs index 0b6a2ac351..afacaf5039 100644 --- a/tests/Meridian.Tests/FinancialOperations/AccountingClose/AccountingCloseServicesTests.cs +++ b/tests/Meridian.Tests/FinancialOperations/AccountingClose/AccountingCloseServicesTests.cs @@ -678,6 +678,392 @@ public async Task Scenario_ClosePlan_LockPeriodFailsClosedWhenPostingGateIsUnava await workflowService.DidNotReceiveWithAnyArgs().CloseWorkflowAsync(default, default!, default); } + [Fact] + public async Task Scenario_ClosePlan_StaleWorkflowVersionStopsBeforeClosingDraftOrHardCloseMutation() + { + var workflowId = Guid.Parse("47474747-4747-4747-4747-474747474749"); + var ledgerBookId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + var workflow = BuildCloseWorkflow(workflowId, firstTaskStatus: "Done", secondTaskStatus: "Done"); + var workflowService = Substitute.For(); + workflowService.GetAsync(workflowId, Arg.Any()).Returns(workflow); + var postingWorkbench = Substitute.For(); + postingWorkbench.EvaluateAsync( + Arg.Any(), + Arg.Any()) + .Returns(new ClosePostingGateDto( + "period-close-posting:stale-version", + "Post closing entries", + ClosePostingGateStateDto.Posted, + true, + 0m, + 0, + "Temporary balances are zero.")); + var service = new AccountingCloseManagementService(workflowService, postingWorkbench); + await ApproveRequiredCloseTasksAsync(service, workflowId, ledgerBookId); + + var result = await service.LockClosePeriodAsync( + new LockClosePeriodRequestDto( + workflowId, + ExpectedWorkflowVersion: workflow.Version - 1, + Actor: "controller-reviewer", + Rationale: "Attempt stale close-period lock.", + ReportPackId: "report-pack-2026-03", + EvidenceLinks: + [ + $"evidence:close-package:{workflowId:D}:2026-03:book:{ledgerBookId:D}:period-lock" + ]), + "controller-reviewer"); + + result.Should().NotBeNull(); + result!.IsLocked.Should().BeFalse(); + result.Issues.Should().ContainSingle(issue => + issue.Code == "ClosePeriodLockVersionMismatch" && + issue.Severity == AccountingConfigurationValidationSeverityDto.Critical); + await postingWorkbench.DidNotReceive().EnsureClosingDraftQueuedAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + await postingWorkbench.DidNotReceive().FinalizeHardCloseAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + await workflowService.DidNotReceiveWithAnyArgs().CloseWorkflowAsync(default, default!, default); + } + + [Fact] + public async Task Scenario_ClosePlan_PrepareClosingEntriesOnly_NeverHardClosesEvenWhenGateIsReady() + { + var workflowId = Guid.Parse("47474747-4747-4747-4747-474747474750"); + var ledgerBookId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + var workflow = BuildCloseWorkflow(workflowId, firstTaskStatus: "Done", secondTaskStatus: "Done"); + var workflowService = Substitute.For(); + workflowService.GetAsync(workflowId, Arg.Any()).Returns(workflow); + var postingWorkbench = Substitute.For(); + var readyGate = new ClosePostingGateDto( + "period-close-posting:prepare-only", + "Post closing entries", + ClosePostingGateStateDto.Posted, + true, + 0m, + 0, + "Closing entries are already posted."); + postingWorkbench.EnsureClosingDraftQueuedAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(readyGate); + postingWorkbench.EvaluateAsync( + Arg.Any(), + Arg.Any()) + .Returns(readyGate); + var service = new AccountingCloseManagementService(workflowService, postingWorkbench); + await ApproveRequiredCloseTasksAsync(service, workflowId, ledgerBookId); + + var result = await service.LockClosePeriodAsync( + new LockClosePeriodRequestDto( + workflowId, + workflow.Version, + "controller-reviewer", + "Prepare the governed closing-entry batch.", + "report-pack-2026-03", + [$"evidence:close-package:{workflowId:D}:2026-03:book:{ledgerBookId:D}:period-lock"], + PrepareClosingEntriesOnly: true), + "controller-reviewer"); + + result.Should().NotBeNull(); + result!.IsLocked.Should().BeFalse(); + result.Plan!.ClosingEntriesGate.Should().Be(readyGate); + result.Issues.Should().BeEmpty(); + await postingWorkbench.Received(1).EnsureClosingDraftQueuedAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + await postingWorkbench.DidNotReceive().FinalizeHardCloseAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + await workflowService.DidNotReceiveWithAnyArgs().CloseWorkflowAsync(default, default!, default); + } + + [Fact] + public async Task Scenario_ClosePlan_RechecksWorkflowVersionImmediatelyBeforeLedgerHardClose() + { + var workflowId = Guid.Parse("47474747-4747-4747-4747-474747474751"); + var ledgerBookId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + var currentWorkflow = BuildCloseWorkflow(workflowId, firstTaskStatus: "Done", secondTaskStatus: "Done"); + var originalVersion = currentWorkflow.Version; + var workflowService = Substitute.For(); + workflowService.GetAsync(workflowId, Arg.Any()) + .Returns(_ => currentWorkflow); + var postingWorkbench = Substitute.For(); + var readyGate = new ClosePostingGateDto( + "period-close-posting:jit-version", + "Post closing entries", + ClosePostingGateStateDto.Posted, + true, + 0m, + 0, + "Closing entries are posted."); + postingWorkbench.EnsureClosingDraftQueuedAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(_ => + { + currentWorkflow = currentWorkflow with { Version = originalVersion + 1 }; + return readyGate; + }); + postingWorkbench.EvaluateAsync( + Arg.Any(), + Arg.Any()) + .Returns(readyGate); + var service = new AccountingCloseManagementService(workflowService, postingWorkbench); + await ApproveRequiredCloseTasksAsync(service, workflowId, ledgerBookId); + + var result = await service.LockClosePeriodAsync( + new LockClosePeriodRequestDto( + workflowId, + originalVersion, + "controller-reviewer", + "Attempt close across a concurrent workflow mutation.", + "report-pack-2026-03", + [$"evidence:close-package:{workflowId:D}:2026-03:book:{ledgerBookId:D}:period-lock"]), + "controller-reviewer"); + + result.Should().NotBeNull(); + result!.IsLocked.Should().BeFalse(); + result.Plan!.WorkflowVersion.Should().Be(originalVersion + 1); + result.Issues.Should().ContainSingle(issue => issue.Code == "ClosePeriodLockVersionMismatch"); + await postingWorkbench.DidNotReceive().FinalizeHardCloseAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + await workflowService.DidNotReceiveWithAnyArgs().CloseWorkflowAsync(default, default!, default); + } + + [Fact] + public async Task Scenario_ClosePlan_CasFailureAfterLedgerHardClose_ExactRetryConvergesWorkflow() + { + var workflowId = Guid.Parse("47474747-4747-4747-4747-474747474752"); + var ledgerBookId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + var currentWorkflow = BuildCloseWorkflow(workflowId, firstTaskStatus: "Done", secondTaskStatus: "Done"); + var workflowService = Substitute.For(); + workflowService.GetAsync(workflowId, Arg.Any()).Returns(_ => currentWorkflow); + var closeAttempts = 0; + workflowService.CloseWorkflowAsync( + workflowId, + Arg.Any(), + Arg.Any()) + .Returns(_ => + { + closeAttempts++; + if (closeAttempts == 1) + { + currentWorkflow = currentWorkflow with { Version = currentWorkflow.Version + 1 }; + return new OperationsTransitionResultDto( + false, + "VERSION_CONFLICT", + "Concurrent workflow mutation.", + currentWorkflow, + [], + [], + currentWorkflow.Version); + } + + currentWorkflow = BuildLockedCloseWorkflow(currentWorkflow, currentWorkflow.Version + 1); + return new OperationsTransitionResultDto( + true, + null, + null, + currentWorkflow, + [], + [], + currentWorkflow.Version); + }); + var postingWorkbench = Substitute.For(); + var readyGate = new ClosePostingGateDto( + "period-close-posting:cas-retry", + "Post closing entries", + ClosePostingGateStateDto.Posted, + true, + 0m, + 0, + "Closing entries are posted."); + postingWorkbench.EnsureClosingDraftQueuedAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(readyGate); + postingWorkbench.EvaluateAsync( + Arg.Any(), + Arg.Any()) + .Returns(readyGate); + postingWorkbench.FinalizeHardCloseAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new LedgerPeriodDto( + Guid.NewGuid(), + ledgerBookId, + 2026, + 3, + "2026-03", + new DateOnly(2026, 3, 1), + new DateOnly(2026, 3, 31), + LedgerPeriodStatusDto.HardClosed, + DateTimeOffset.Parse("2026-03-01T00:00:00Z"), + DateTimeOffset.Parse("2026-04-03T12:09:00Z"), + 3)); + var service = new AccountingCloseManagementService(workflowService, postingWorkbench); + await ApproveRequiredCloseTasksAsync(service, workflowId, ledgerBookId); + + LockClosePeriodRequestDto Request(long version) => new( + workflowId, + version, + "controller-reviewer", + "Lock close period after report certification.", + "report-pack-2026-03", + [$"evidence:close-package:{workflowId:D}:2026-03:book:{ledgerBookId:D}:period-lock"], + CorrelationId: "close-cas-retry"); + + var first = await service.LockClosePeriodAsync(Request(currentWorkflow.Version), "controller-reviewer"); + var retry = await service.LockClosePeriodAsync(Request(currentWorkflow.Version), "controller-reviewer"); + + first!.IsLocked.Should().BeFalse(); + first.Issues.Should().Contain(issue => issue.Code == "CloseWorkflowTransitionPendingAfterLedgerHardClose"); + retry!.IsLocked.Should().BeTrue(); + retry.Issues.Should().BeEmpty(); + await postingWorkbench.Received(2).FinalizeHardCloseAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + await workflowService.Received(2).CloseWorkflowAsync( + workflowId, + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Scenario_ClosePlan_ReopenRechecksWorkflowVersionBeforeLedgerMutation() + { + var workflowId = Guid.Parse("47474747-4747-4747-4747-474747474753"); + var initial = BuildLockedCloseWorkflow( + BuildCloseWorkflow(workflowId, firstTaskStatus: "Done", secondTaskStatus: "Done")); + var changed = initial with { Version = initial.Version + 1 }; + var workflowService = Substitute.For(); + workflowService.GetAsync(workflowId, Arg.Any()).Returns(initial, changed); + var postingWorkbench = Substitute.For(); + postingWorkbench.EvaluateAsync( + Arg.Any(), + Arg.Any()) + .Returns(new ClosePostingGateDto( + "period-close-posting:reopen-jit", + "Post closing entries", + ClosePostingGateStateDto.Posted, + true, + 0m, + 0, + "Closing entries are posted.")); + var service = new AccountingCloseManagementService(workflowService, postingWorkbench); + + var result = await service.ReopenClosePeriodAsync(BuildReopenRequest(workflowId, initial.Version), "controller-reviewer"); + + result.Should().NotBeNull(); + result!.IsReopened.Should().BeFalse(); + result.Plan!.WorkflowVersion.Should().Be(changed.Version); + result.Issues.Should().ContainSingle(issue => issue.Code == "ClosePeriodReopenVersionMismatch"); + await postingWorkbench.DidNotReceive().ReopenAndQueueClosingReversalsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + await workflowService.DidNotReceiveWithAnyArgs().ReopenWorkflowAsync(default, default!, default); + } + + [Fact] + public async Task Scenario_ClosePlan_CasFailureAfterLedgerReopen_ExactRetryConvergesWorkflow() + { + var workflowId = Guid.Parse("47474747-4747-4747-4747-474747474754"); + var currentWorkflow = BuildLockedCloseWorkflow( + BuildCloseWorkflow(workflowId, firstTaskStatus: "Done", secondTaskStatus: "Done")); + var workflowService = Substitute.For(); + workflowService.GetAsync(workflowId, Arg.Any()).Returns(_ => currentWorkflow); + var reopenAttempts = 0; + workflowService.ReopenWorkflowAsync( + workflowId, + Arg.Any(), + Arg.Any()) + .Returns(_ => + { + reopenAttempts++; + if (reopenAttempts == 1) + { + currentWorkflow = currentWorkflow with { Version = currentWorkflow.Version + 1 }; + return new OperationsTransitionResultDto( + false, + "VERSION_CONFLICT", + "Concurrent workflow mutation.", + currentWorkflow, + [], + [], + currentWorkflow.Version); + } + + currentWorkflow = currentWorkflow with + { + Version = currentWorkflow.Version + 1, + Status = OperationsWorkflowStatusDto.ApprovalPending, + ClosePackage = null + }; + return new OperationsTransitionResultDto( + true, + null, + null, + currentWorkflow, + [], + [], + currentWorkflow.Version); + }); + var postingWorkbench = Substitute.For(); + var reversalGate = new ClosePostingGateDto( + "period-close-posting:reopen-cas", + "Post closing entries", + ClosePostingGateStateDto.ReversalQueued, + false, + 0m, + 0, + "No active retained closing batch requires reversal."); + postingWorkbench.ReopenAndQueueClosingReversalsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(reversalGate); + postingWorkbench.EvaluateAsync( + Arg.Any(), + Arg.Any()) + .Returns(reversalGate); + var service = new AccountingCloseManagementService(workflowService, postingWorkbench); + + var first = await service.ReopenClosePeriodAsync( + BuildReopenRequest(workflowId, currentWorkflow.Version), + "controller-reviewer"); + var retry = await service.ReopenClosePeriodAsync( + BuildReopenRequest(workflowId, currentWorkflow.Version), + "controller-reviewer"); + + first!.IsReopened.Should().BeFalse(); + first.Issues.Should().Contain(issue => issue.Code == "CloseWorkflowReopenPendingAfterLedgerReopen"); + retry!.IsReopened.Should().BeTrue(); + retry.Issues.Should().BeEmpty(); + await postingWorkbench.Received(2).ReopenAndQueueClosingReversalsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + await workflowService.Received(2).ReopenWorkflowAsync( + workflowId, + Arg.Any(), + Arg.Any()); + } + [Fact] public async Task Scenario_ClosePlan_LockPeriodDelegatesToOperationsWorkflowAfterCloseControlsPass() { @@ -1240,6 +1626,40 @@ private static OperationsContinuityWorkflowDto BuildCloseWorkflow( LedgerBookId: Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")); } + private static OperationsContinuityWorkflowDto BuildLockedCloseWorkflow( + OperationsContinuityWorkflowDto workflow, + long? version = null) + => workflow with + { + Version = version ?? workflow.Version, + Status = OperationsWorkflowStatusDto.Closed, + ClosePackage = new OperationsClosePackagePublicationDto( + $"close-package-{workflow.WorkflowId:D}", + "report-pack-2026-03", + $"manifest-{workflow.WorkflowId:D}", + $"/workstation/reporting/packages/manifest-{workflow.WorkflowId:D}", + "sha256-close-package", + DateTimeOffset.Parse("2026-04-03T12:10:00Z"), + "controller-reviewer", + "Lock close period after report certification.", + [], + []) + }; + + private static ReopenClosePeriodRequestDto BuildReopenRequest(Guid workflowId, long version) + => new( + workflowId, + version, + "controller-reviewer", + "Fund Controller", + "Reopen the close for a governed restatement.", + "incident-2026-03", + "A material restatement requires corrected accounting evidence.", + "reopen-approval-2026-03", + "March financial statements and downstream reports require recertification.", + ["evidence:restatement:2026-03:reopen-approval-2026-03"], + "reopen-correlation-2026-03"); + private static async Task ApproveRequiredCloseTasksAsync( AccountingCloseManagementService service, Guid workflowId, diff --git a/tests/Meridian.Tests/FinancialOperations/OperationsContinuity/FinancialOperationsCommandCenterReadServiceTests.cs b/tests/Meridian.Tests/FinancialOperations/OperationsContinuity/FinancialOperationsCommandCenterReadServiceTests.cs index a2008fbefd..a60859324f 100644 --- a/tests/Meridian.Tests/FinancialOperations/OperationsContinuity/FinancialOperationsCommandCenterReadServiceTests.cs +++ b/tests/Meridian.Tests/FinancialOperations/OperationsContinuity/FinancialOperationsCommandCenterReadServiceTests.cs @@ -1137,7 +1137,7 @@ public Task UpsertItemAsync(Operatio private sealed class StubPrivateCapitalCloseCockpitService(PrivateCapitalCloseCockpitDto cockpit) : IPrivateCapitalCloseCockpitService { - public Task GetCockpitAsync(string? fundProfileId = null, Guid? ledgerBookId = null, Guid? fundAccountId = null, string? periodId = null, string? entityId = null, CancellationToken ct = default) + public Task GetCockpitAsync(string? fundProfileId = null, Guid? ledgerBookId = null, Guid? fundAccountId = null, string? periodId = null, string? entityId = null, CancellationToken ct = default, string? tenantId = null, string? companyId = null) => Task.FromResult(cockpit); } } diff --git a/tests/Meridian.Tests/FinancialOperations/PrivateCapital/PrivateCapitalCloseCockpitServiceTests.cs b/tests/Meridian.Tests/FinancialOperations/PrivateCapital/PrivateCapitalCloseCockpitServiceTests.cs index 16ae9bab48..6df5cebcc3 100644 --- a/tests/Meridian.Tests/FinancialOperations/PrivateCapital/PrivateCapitalCloseCockpitServiceTests.cs +++ b/tests/Meridian.Tests/FinancialOperations/PrivateCapital/PrivateCapitalCloseCockpitServiceTests.cs @@ -1583,7 +1583,10 @@ public Task GetStatusAsync( string? fundProfileId, Guid? ledgerBookId, string? periodId, - CancellationToken ct = default) + CancellationToken ct = default, + string? entityId = null, + string? tenantId = null, + string? companyId = null) { ct.ThrowIfCancellationRequested(); return Task.FromResult(status); @@ -1597,7 +1600,10 @@ public Task GetStatusAsync( string? fundProfileId, Guid? ledgerBookId, string? periodId, - CancellationToken ct = default) + CancellationToken ct = default, + string? tenantId = null, + string? companyId = null, + string? entityId = null) { ct.ThrowIfCancellationRequested(); return Task.FromResult(status); diff --git a/tests/Meridian.Tests/Infrastructure/Providers/AlphaVantageCorporateActionProviderTests.cs b/tests/Meridian.Tests/Infrastructure/Providers/AlphaVantageCorporateActionProviderTests.cs index da32c313d0..9e25ebcbf3 100644 --- a/tests/Meridian.Tests/Infrastructure/Providers/AlphaVantageCorporateActionProviderTests.cs +++ b/tests/Meridian.Tests/Infrastructure/Providers/AlphaVantageCorporateActionProviderTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Text; using FluentAssertions; +using Meridian.Core.Exceptions; using Meridian.Infrastructure.Adapters.AlphaVantage; using Meridian.Infrastructure.Http; using Meridian.Tests.TestHelpers; @@ -113,14 +114,37 @@ public async Task FetchAsync_WithoutApiKey_ReturnsEmptyWithoutCreatingClient() } [Fact] - public async Task FetchAsync_WhenBodyIsRateLimited_ReturnsEmpty() + public async Task FetchAsync_WhenBodyIsRateLimited_ThrowsTypedRateLimitMetadata() { using var handler = new StubHttpMessageHandler(_ => JsonResponse(RateLimitResponse)); var provider = CreateSut(handler); - var results = await provider.FetchAsync("AAPL", Guid.NewGuid(), CancellationToken.None); + var act = () => provider.FetchAsync("AAPL", Guid.NewGuid(), CancellationToken.None); - results.Should().BeEmpty(); + var exception = await act.Should().ThrowAsync(); + exception.Which.Provider.Should().Be("alphavantage"); + exception.Which.Symbol.Should().Be("AAPL"); + exception.Which.RetryAfter.Should().Be(TimeSpan.FromMinutes(1)); + handler.CallCount.Should().Be(1); + } + + [Fact] + public async Task FetchAsync_WhenHttp429_ThrowsTypedRateLimitAndPreservesRetryAfter() + { + using var handler = new StubHttpMessageHandler(_ => + { + var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests); + response.Headers.RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.FromSeconds(19)); + return response; + }); + var provider = CreateSut(handler); + + var act = () => provider.FetchAsync("aapl", Guid.NewGuid(), CancellationToken.None); + + var exception = await act.Should().ThrowAsync(); + exception.Which.Provider.Should().Be("alphavantage"); + exception.Which.Symbol.Should().Be("AAPL"); + exception.Which.RetryAfter.Should().Be(TimeSpan.FromSeconds(19)); handler.CallCount.Should().Be(1); } diff --git a/tests/Meridian.Tests/Infrastructure/Providers/AlphaVantageSymbolSearchProviderTests.cs b/tests/Meridian.Tests/Infrastructure/Providers/AlphaVantageSymbolSearchProviderTests.cs index 50b2362b31..2a098fc9ec 100644 --- a/tests/Meridian.Tests/Infrastructure/Providers/AlphaVantageSymbolSearchProviderTests.cs +++ b/tests/Meridian.Tests/Infrastructure/Providers/AlphaVantageSymbolSearchProviderTests.cs @@ -2,6 +2,7 @@ using System.Text; using FluentAssertions; using Meridian.Contracts.Domain; +using Meridian.Core.Exceptions; using Meridian.Infrastructure.Adapters.AlphaVantage; using Meridian.Tests.TestHelpers; @@ -149,15 +150,38 @@ public async Task SearchAsync_WithoutApiKey_ReturnsEmptyListWithoutHttpCall() } [Fact] - public async Task SearchAsync_WithRateLimitBody_ReturnsEmptyList() + public async Task SearchAsync_WithRateLimitBody_ThrowsTypedRateLimitMetadata() { using var handler = new StubHttpMessageHandler(_ => JsonResponse(RateLimitResponse)); using var httpClient = new HttpClient(handler); using var provider = new AlphaVantageSymbolSearchProvider(ApiKey, httpClient); - var results = await provider.SearchAsync("IBM", 10, CancellationToken.None); + var act = () => provider.SearchAsync("IBM", 10, CancellationToken.None); - results.Should().BeEmpty(); + var exception = await act.Should().ThrowAsync(); + exception.Which.Provider.Should().Be("alphavantage"); + exception.Which.Symbol.Should().Be("IBM"); + exception.Which.RetryAfter.Should().Be(TimeSpan.FromMinutes(1)); + } + + [Fact] + public async Task SearchAsync_WithHttp429_ThrowsTypedRateLimitAndPreservesRetryAfter() + { + using var handler = new StubHttpMessageHandler(_ => + { + var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests); + response.Headers.RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.FromSeconds(23)); + return response; + }); + using var httpClient = new HttpClient(handler); + using var provider = new AlphaVantageSymbolSearchProvider(ApiKey, httpClient); + + var act = () => provider.SearchAsync("IBM", 10, CancellationToken.None); + + var exception = await act.Should().ThrowAsync(); + exception.Which.Provider.Should().Be("alphavantage"); + exception.Which.Symbol.Should().Be("IBM"); + exception.Which.RetryAfter.Should().Be(TimeSpan.FromSeconds(23)); } [Fact] diff --git a/tests/Meridian.Tests/Infrastructure/Providers/BackfillRetryAfterTests.cs b/tests/Meridian.Tests/Infrastructure/Providers/BackfillRetryAfterTests.cs index c992c29c69..af8b97358a 100644 --- a/tests/Meridian.Tests/Infrastructure/Providers/BackfillRetryAfterTests.cs +++ b/tests/Meridian.Tests/Infrastructure/Providers/BackfillRetryAfterTests.cs @@ -1,5 +1,7 @@ using System.Net; using System.Net.Http; +using FluentAssertions; +using Meridian.Core.Exceptions; using Meridian.Infrastructure.Adapters.Core; using Xunit; @@ -11,6 +13,63 @@ namespace Meridian.Tests.Infrastructure.Adapters; ///
public sealed class BackfillRetryAfterTests { + [Fact] + public void IsRateLimited_MessageOnlySignal_IsNotClassifiedAsRateLimit() + { + var ex = new InvalidOperationException("provider said rate limit 429 but supplied no typed status"); + + Assert.False(BackfillWorkerService.IsRateLimited(ex)); + } + + [Fact] + public void IsRateLimited_TypedAndHttpStatusSignals_AreClassified() + { + Assert.True(BackfillWorkerService.IsRateLimited( + new RateLimitException("quota exhausted", provider: "test"))); + Assert.True(BackfillWorkerService.IsRateLimited( + new HttpRequestException("too many requests", null, HttpStatusCode.TooManyRequests))); + Assert.True(BackfillWorkerService.IsRateLimited(new AggregateException( + new InvalidOperationException("first provider failed"), + new HttpRequestException("second provider throttled", null, HttpStatusCode.TooManyRequests)))); + } + + [Fact] + public void TryExtractRetryAfter_NestedAggregateSecondBranch_ExtractsTypedMetadata() + { + var ex = new AggregateException( + new InvalidOperationException("first provider failed"), + new AggregateException( + new InvalidOperationException("fallback failed"), + new RateLimitException( + "final provider throttled", + provider: "alphavantage", + symbol: "AAPL", + retryAfter: TimeSpan.FromSeconds(37)))); + + BackfillWorkerService.TryExtractRetryAfter(ex).Should().Be(TimeSpan.FromSeconds(37)); + BackfillWorkerService.ResolveRateLimitedProvider(ex, "first-provider").Should().Be("alphavantage"); + } + + [Fact] + public void ResolveRateLimitedProvider_TypedProviderMissing_FallsBackToAssignedProvider() + { + var ex = new RateLimitException("quota exhausted", retryAfter: TimeSpan.FromSeconds(10)); + + BackfillWorkerService.ResolveRateLimitedProvider(ex, "assigned-provider") + .Should().Be("assigned-provider"); + } + + [Fact] + public void ResolveRateLimitedProvider_LaterAggregateBranchHasProvider_UsesTypedProvider() + { + var ex = new AggregateException( + new RateLimitException("first provider omitted identity"), + new RateLimitException("fallback provider throttled", provider: "alphavantage")); + + BackfillWorkerService.ResolveRateLimitedProvider(ex, "assigned-provider") + .Should().Be("alphavantage"); + } + [Fact] public void TryExtractRetryAfter_NoRetryAfterInMessage_ReturnsNull() { diff --git a/tests/Meridian.Tests/Infrastructure/Providers/FreeHistoricalProviderParsingTests.cs b/tests/Meridian.Tests/Infrastructure/Providers/FreeHistoricalProviderParsingTests.cs index 59ff272886..bdd2b13f77 100644 --- a/tests/Meridian.Tests/Infrastructure/Providers/FreeHistoricalProviderParsingTests.cs +++ b/tests/Meridian.Tests/Infrastructure/Providers/FreeHistoricalProviderParsingTests.cs @@ -662,7 +662,7 @@ public async Task GetDailyBarsAsync_WithErrorMessageInBody_ReturnsEmpty() } [Fact] - public async Task GetDailyBarsAsync_WhenRateLimited_ThrowsHttpRequestException() + public async Task GetDailyBarsAsync_WhenRateLimited_ThrowsTypedRateLimitException() { using var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) @@ -674,7 +674,7 @@ public async Task GetDailyBarsAsync_WhenRateLimited_ThrowsHttpRequestException() Func act = () => provider.GetDailyBarsAsync("AAPL", null, null, CancellationToken.None); - await act.Should().ThrowAsync() + await act.Should().ThrowAsync() .WithMessage("*rate limit*"); } } diff --git a/tests/Meridian.Tests/Infrastructure/Providers/IBSimulationClientContractTests.cs b/tests/Meridian.Tests/Infrastructure/Providers/IBSimulationClientContractTests.cs index 4bea1073c0..08027052af 100644 --- a/tests/Meridian.Tests/Infrastructure/Providers/IBSimulationClientContractTests.cs +++ b/tests/Meridian.Tests/Infrastructure/Providers/IBSimulationClientContractTests.cs @@ -1,4 +1,6 @@ +using FluentAssertions; using Meridian.Infrastructure.Adapters.InteractiveBrokers; +using Meridian.Infrastructure.Resilience; using Meridian.Tests.TestHelpers; namespace Meridian.Tests.Infrastructure.Providers; @@ -12,3 +14,31 @@ public sealed class IBSimulationClientContractTests : MarketDataClientContractTe protected override IBSimulationClient CreateClient() => new(new TestMarketEventPublisher(), enableAutoTicks: false); } + +public sealed class IBSimulationClientDiagnosticsTests +{ + [Fact] + public async Task Diagnostics_TrackDirectConnectAndDisconnectHonestly() + { + await using var client = new IBSimulationClient( + new TestMarketEventPublisher(), + enableAutoTicks: false); + + await client.ConnectAsync(); + client.SubscribeTrades(new Meridian.Contracts.Configuration.SymbolConfig("AAPL")); + + var connected = client.GetConnectionDiagnosticsSnapshot(); + connected.ProviderName.Should().Be("Interactive Brokers (Simulation)"); + connected.LifecycleState.Should().Be(ProviderConnectionLifecycleState.Connected); + connected.IsConnected.Should().BeTrue(); + connected.LastConnectedAt.Should().NotBeNull(); + connected.ActiveSubscriptions.Should().Be(1); + + await client.DisconnectAsync(); + + var disconnected = client.GetConnectionDiagnosticsSnapshot(); + disconnected.LifecycleState.Should().Be(ProviderConnectionLifecycleState.Disconnected); + disconnected.IsConnected.Should().BeFalse(); + disconnected.LastDisconnectedAt.Should().NotBeNull(); + } +} diff --git a/tests/Meridian.Tests/Infrastructure/Providers/MarketDataClientContractTests.cs b/tests/Meridian.Tests/Infrastructure/Providers/MarketDataClientContractTests.cs index 975615d01f..00a5035f6f 100644 --- a/tests/Meridian.Tests/Infrastructure/Providers/MarketDataClientContractTests.cs +++ b/tests/Meridian.Tests/Infrastructure/Providers/MarketDataClientContractTests.cs @@ -1,6 +1,8 @@ using FluentAssertions; using Meridian.Contracts.Configuration; using Meridian.Infrastructure; +using Meridian.Infrastructure.Adapters.Core; +using Meridian.Infrastructure.Resilience; using Xunit; namespace Meridian.Tests.Infrastructure.Providers; @@ -75,6 +77,48 @@ public async Task IProviderMetadata_ProviderCapabilities_IsNotNull() act.Should().NotThrow("ProviderCapabilities must never throw"); } + // ------------------------------------------------------------------ // + // Connection diagnostics contract // + // ------------------------------------------------------------------ // + + [Fact] + public async Task ConnectionDiagnostics_StreamingContractAlwaysExposesSafeSnapshot() + { + await using var client = CreateClient(); + IMarketDataClient streamingClient = client; + IProviderConnectionDiagnosticsSource diagnosticsSource = streamingClient; + + var snapshot = diagnosticsSource.GetConnectionDiagnosticsSnapshot(); + + snapshot.ProviderName.Should().NotBeNullOrWhiteSpace( + "every streaming provider must identify its connection diagnostics"); + snapshot.IsConnected.Should().BeFalse( + "a newly constructed provider has not completed a connection transaction"); + snapshot.LastError.Should().BeNull( + "the safe initial snapshot must not fabricate or expose transport failure details"); + + Action observer = _ => { }; + var subscribe = () => diagnosticsSource.ConnectionDiagnosticsChanged += observer; + var unsubscribe = () => diagnosticsSource.ConnectionDiagnosticsChanged -= observer; + subscribe.Should().NotThrow("the contract fallback must accept diagnostics observers"); + unsubscribe.Should().NotThrow("the contract fallback must accept observer cleanup"); + } + + [Fact] + public async Task ConnectionDiagnostics_DisabledStreamingClientNeverClaimsLiveConnection() + { + await using var client = CreateClient(); + if (client.IsEnabled) + return; + + var diagnosticsSource = (IProviderConnectionDiagnosticsSource)client; + var snapshot = diagnosticsSource.GetConnectionDiagnosticsSnapshot(); + + snapshot.LifecycleState.Should().NotBe(ProviderConnectionLifecycleState.Connected); + snapshot.IsConnected.Should().BeFalse(); + snapshot.IsReconnecting.Should().BeFalse(); + } + // ------------------------------------------------------------------ // // Subscription contract // // ------------------------------------------------------------------ // diff --git a/tests/Meridian.Tests/Infrastructure/Resilience/ProviderConnectionSupervisorTests.cs b/tests/Meridian.Tests/Infrastructure/Resilience/ProviderConnectionSupervisorTests.cs index 8bca77b8e0..029ca12ae4 100644 --- a/tests/Meridian.Tests/Infrastructure/Resilience/ProviderConnectionSupervisorTests.cs +++ b/tests/Meridian.Tests/Infrastructure/Resilience/ProviderConnectionSupervisorTests.cs @@ -1,5 +1,6 @@ using System.Net.WebSockets; using System.Security.Authentication; +using System.Diagnostics; using FluentAssertions; using Meridian.Infrastructure.Resilience; using Xunit; @@ -136,6 +137,66 @@ await supervisor.DisconnectAsync(_ => supervisor.GetSnapshot().LifecycleState.Should().Be(ProviderConnectionLifecycleState.Disconnected); } + [Fact] + public async Task DisconnectAndDispose_NonCooperativeReconnect_RemainCallerBoundedAndTerminal() + { + var supervisor = CreateSupervisor(); + await EstablishAndLoseConnectionAsync(supervisor); + var transactionEntered = NewSignal(); + var releaseTransaction = NewSignal(); + var reconnectTask = supervisor.ReconnectAsync(async _ => + { + transactionEntered.TrySetResult(true); + await releaseTransaction.Task; + }); + await transactionEntered.Task.WaitAsync(TimeSpan.FromSeconds(1)); + + using var disconnectCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(40)); + var elapsed = Stopwatch.StartNew(); + Func disconnect = async () => await supervisor.DisconnectAsync( + static _ => Task.CompletedTask, + disconnectCts.Token); + + await disconnect.Should().ThrowAsync(); + elapsed.Stop(); + elapsed.Elapsed.Should().BeLessThan(TimeSpan.FromMilliseconds(750)); + + using var disposeCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(40)); + await supervisor.DisposeAsync(disposeCts.Token).AsTask().WaitAsync(TimeSpan.FromSeconds(1)); + supervisor.GetSnapshot().LifecycleState.Should().Be(ProviderConnectionLifecycleState.Disconnected); + supervisor.IsReconnecting.Should().BeFalse(); + + releaseTransaction.TrySetResult(true); + (await reconnectTask.WaitAsync(TimeSpan.FromSeconds(1))).Should().BeFalse( + "a reconnect transaction that returns after disposal cannot reactivate the provider"); + await supervisor.DisposeAsync(); + } + + [Fact] + public async Task DisconnectAsync_NonCooperativeCleanup_RemainsCallerBoundedAndTerminal() + { + await using var supervisor = CreateSupervisor(); + await supervisor.ConnectAsync(static _ => Task.CompletedTask); + var cleanupEntered = NewSignal(); + var releaseCleanup = NewSignal(); + using var disconnectCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(40)); + var elapsed = Stopwatch.StartNew(); + + Func disconnect = async () => await supervisor.DisconnectAsync(async _ => + { + cleanupEntered.TrySetResult(true); + await releaseCleanup.Task; + }, disconnectCts.Token); + + await cleanupEntered.Task.WaitAsync(TimeSpan.FromSeconds(1)); + await disconnect.Should().ThrowAsync(); + elapsed.Stop(); + + elapsed.Elapsed.Should().BeLessThan(TimeSpan.FromMilliseconds(750)); + supervisor.GetSnapshot().LifecycleState.Should().Be(ProviderConnectionLifecycleState.Disconnected); + releaseCleanup.TrySetResult(true); + } + [Fact] public async Task ReconnectAsync_NonRetryableAuthenticationFailureStopsAfterFirstAttempt() { diff --git a/tests/Meridian.Tests/Infrastructure/Resilience/WebSocketConnectionManagerTests.cs b/tests/Meridian.Tests/Infrastructure/Resilience/WebSocketConnectionManagerTests.cs index f4e87adaa3..7b5d143dad 100644 --- a/tests/Meridian.Tests/Infrastructure/Resilience/WebSocketConnectionManagerTests.cs +++ b/tests/Meridian.Tests/Infrastructure/Resilience/WebSocketConnectionManagerTests.cs @@ -4,6 +4,7 @@ using System.Net.WebSockets; using System.Reflection; using System.Security.Authentication; +using System.Diagnostics; using Xunit; namespace Meridian.Tests.Infrastructure.Resilience; @@ -103,6 +104,198 @@ await FluentActions.Awaiting(() => disconnectTask!) } } + [Fact] + public async Task DisposeAsync_WhenReconnectTransactionIgnoresCancellation_IsBoundedAndIdempotent() + { + var manager = new WebSocketConnectionManager( + providerName: "test-provider", + config: null, + logger: null, + shutdownTimeout: TimeSpan.FromMilliseconds(40)); + var supervisor = GetPrivateField(manager, "_supervisor"); + supervisor.Should().NotBeNull(); + await supervisor!.ConnectAsync(static _ => Task.CompletedTask); + supervisor.MarkConnectionLost().Should().BeTrue(); + var reconnectEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var reconnectRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var connectionCts = new CancellationTokenSource(); + var receiveLoopCts = new CancellationTokenSource(); + var webSocket = new ClientWebSocket(); + SetPrivateField(manager, "_connectionCts", connectionCts); + SetPrivateField(manager, "_receiveLoopCts", receiveLoopCts); + SetPrivateField(manager, "_receiveTask", Task.CompletedTask); + SetPrivateField(manager, "_webSocket", webSocket); + var reconnectTask = supervisor.ReconnectAsync(async _ => + { + reconnectEntered.TrySetResult(true); + await reconnectRelease.Task; + }); + await reconnectEntered.Task.WaitAsync(TimeSpan.FromSeconds(1)); + + var elapsed = Stopwatch.StartNew(); + await manager.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)); + elapsed.Stop(); + + elapsed.Elapsed.Should().BeLessThan(TimeSpan.FromMilliseconds(750)); + manager.LifecycleState.Should().Be(ProviderConnectionLifecycleState.Disconnected); + GetPrivateField(manager, "_receiveTask").Should().BeNull(); + GetPrivateField(manager, "_connectionCts").Should().BeNull(); + GetPrivateField(manager, "_receiveLoopCts").Should().BeNull(); + GetPrivateField(manager, "_webSocket").Should().BeNull(); + FluentActions.Invoking(connectionCts.Cancel) + .Should().Throw(); + FluentActions.Invoking(receiveLoopCts.Cancel) + .Should().Throw(); + await manager.DisposeAsync(); + + reconnectRelease.TrySetResult(true); + (await reconnectTask.WaitAsync(TimeSpan.FromSeconds(1))).Should().BeFalse(); + manager.IsConnected.Should().BeFalse(); + webSocket.Dispose(); + } + + [Fact] + public async Task DisposeAsync_WhenReceiveCleanupIgnoresCancellation_ForceDetachesTransport() + { + var manager = new WebSocketConnectionManager( + providerName: "test-provider", + config: null, + logger: null, + shutdownTimeout: TimeSpan.FromMilliseconds(40)); + var receiveTaskRelease = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var connectionCts = new CancellationTokenSource(); + var receiveLoopCts = new CancellationTokenSource(); + var webSocket = new ClientWebSocket(); + + SetPrivateField(manager, "_connectionCts", connectionCts); + SetPrivateField(manager, "_receiveLoopCts", receiveLoopCts); + SetPrivateField(manager, "_receiveTask", receiveTaskRelease.Task); + SetPrivateField(manager, "_webSocket", webSocket); + + try + { + await manager.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)); + + manager.LifecycleState.Should().Be(ProviderConnectionLifecycleState.Disconnected); + manager.IsConnected.Should().BeFalse(); + GetPrivateField(manager, "_receiveTask").Should().BeNull(); + GetPrivateField(manager, "_connectionCts").Should().BeNull(); + GetPrivateField(manager, "_receiveLoopCts").Should().BeNull(); + GetPrivateField(manager, "_webSocket").Should().BeNull(); + + FluentActions.Invoking(connectionCts.Cancel) + .Should().Throw(); + FluentActions.Invoking(receiveLoopCts.Cancel) + .Should().Throw(); + } + finally + { + receiveTaskRelease.TrySetResult(true); + await receiveTaskRelease.Task; + webSocket.Dispose(); + await manager.DisposeAsync(); + } + } + + [Fact] + public async Task DisposeAsync_WhenHeartbeatCleanupIgnoresCancellation_StillReleasesTransportResources() + { + var manager = new WebSocketConnectionManager( + providerName: "test-provider", + config: null, + logger: null, + shutdownTimeout: TimeSpan.FromMilliseconds(40)); + var heartbeatRelease = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var connectionCts = new CancellationTokenSource(); + var receiveLoopCts = new CancellationTokenSource(); + var webSocket = new ClientWebSocket(); + var heartbeat = new WebSocketHeartbeat(webSocket); + + manager.HeartbeatDisposer = _ => heartbeatRelease.Task; + SetPrivateField(manager, "_heartbeat", heartbeat); + SetPrivateField(manager, "_connectionCts", connectionCts); + SetPrivateField(manager, "_receiveLoopCts", receiveLoopCts); + SetPrivateField(manager, "_receiveTask", Task.CompletedTask); + SetPrivateField(manager, "_webSocket", webSocket); + + try + { + await manager.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)); + + manager.LifecycleState.Should().Be(ProviderConnectionLifecycleState.Disconnected); + manager.IsConnected.Should().BeFalse(); + GetPrivateField(manager, "_heartbeat").Should().BeNull(); + GetPrivateField(manager, "_receiveTask").Should().BeNull(); + GetPrivateField(manager, "_connectionCts").Should().BeNull(); + GetPrivateField(manager, "_receiveLoopCts").Should().BeNull(); + GetPrivateField(manager, "_webSocket").Should().BeNull(); + + FluentActions.Invoking(connectionCts.Cancel) + .Should().Throw(); + FluentActions.Invoking(receiveLoopCts.Cancel) + .Should().Throw(); + } + finally + { + heartbeatRelease.TrySetResult(true); + await heartbeat.DisposeAsync(); + webSocket.Dispose(); + await manager.DisposeAsync(); + } + } + + [Fact] + public async Task DisconnectAsync_WithoutCallerCancellation_WhenHeartbeatCleanupIgnoresCancellation_IsBoundedAndReleasesTransportResources() + { + var manager = new WebSocketConnectionManager( + providerName: "test-provider", + config: null, + logger: null, + shutdownTimeout: TimeSpan.FromMilliseconds(40)); + var heartbeatRelease = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var connectionCts = new CancellationTokenSource(); + var receiveLoopCts = new CancellationTokenSource(); + var webSocket = new ClientWebSocket(); + var heartbeat = new WebSocketHeartbeat(webSocket); + + manager.HeartbeatDisposer = _ => heartbeatRelease.Task; + SetPrivateField(manager, "_heartbeat", heartbeat); + SetPrivateField(manager, "_connectionCts", connectionCts); + SetPrivateField(manager, "_receiveLoopCts", receiveLoopCts); + SetPrivateField(manager, "_receiveTask", Task.CompletedTask); + SetPrivateField(manager, "_webSocket", webSocket); + + try + { + await manager.DisconnectAsync().WaitAsync(TimeSpan.FromSeconds(1)); + + heartbeatRelease.Task.IsCompleted.Should().BeFalse( + "default disconnect must be bounded even when heartbeat cleanup never completes"); + manager.LifecycleState.Should().Be(ProviderConnectionLifecycleState.Disconnected); + manager.IsConnected.Should().BeFalse(); + GetPrivateField(manager, "_heartbeat").Should().BeNull(); + GetPrivateField(manager, "_receiveTask").Should().BeNull(); + GetPrivateField(manager, "_connectionCts").Should().BeNull(); + GetPrivateField(manager, "_receiveLoopCts").Should().BeNull(); + GetPrivateField(manager, "_webSocket").Should().BeNull(); + + FluentActions.Invoking(connectionCts.Cancel) + .Should().Throw(); + FluentActions.Invoking(receiveLoopCts.Cancel) + .Should().Throw(); + } + finally + { + heartbeatRelease.TrySetResult(true); + await heartbeat.DisposeAsync(); + webSocket.Dispose(); + await manager.DisposeAsync(); + } + } + [Fact] public void GetDiagnosticsSnapshot_BeforeConnect_ExposesSafeInitialProviderState() { diff --git a/tests/Meridian.Tests/Ledger/DailyPortfolioPricingDeltaTests.cs b/tests/Meridian.Tests/Ledger/DailyPortfolioPricingDeltaTests.cs new file mode 100644 index 0000000000..990b4aef9d --- /dev/null +++ b/tests/Meridian.Tests/Ledger/DailyPortfolioPricingDeltaTests.cs @@ -0,0 +1,171 @@ +using FluentAssertions; +using Meridian.Ledger; +using Xunit; + +namespace Meridian.Tests.Ledger; + +/// +/// Guards the daily-close accounting scenario where cumulative unrealized performance must remain +/// visible while only the change from the durable carrying value reaches the journal. +/// +public sealed class DailyPortfolioPricingDeltaTests +{ + private static readonly DateTimeOffset AsOf = new(2026, 07, 03, 21, 0, 0, TimeSpan.Zero); + + private static DailyPortfolioPricingPolicy Policy => new( + "fund-alpha", + "policy-close-v1", + "Fund Alpha daily close", + "market-close", + "valuation-controller", + AsOf.AddDays(-30)); + + [Fact] + public void Project_AbsentAndZeroCarryingValues_PreservesExplicitSemanticsAndUsesDelta() + { + var absentSecurityId = Guid.Parse("10000000-0000-0000-0000-000000000001"); + var zeroSecurityId = Guid.Parse("20000000-0000-0000-0000-000000000002"); + var projection = DailyPortfolioPricingProjector.Project(new DailyPortfolioPricingInput( + Policy, + "2026-07", + AsOf, + "USD", + [ + new DailyPortfolioPriceMark( + "AAPL", 10m, 100m, 120m, "official-close", "price://aapl/2026-07-03", + FinancialAccountId: "broker-1", + PriceObservedOn: new DateOnly(2026, 07, 03), + SecurityId: absentSecurityId, + PriorCarryingValue: null, + CarryingValueSource: "ledger:account-absent"), + new DailyPortfolioPriceMark( + "MSFT", 10m, 100m, 120m, "official-close", "price://msft/2026-07-03", + FinancialAccountId: "broker-1", + PriceObservedOn: new DateOnly(2026, 07, 03), + SecurityId: zeroSecurityId, + PriorCarryingValue: 0m, + CarryingValueSource: "ledger:book-1") + ])); + + var absent = projection.Lines.Single(line => line.SecurityId == absentSecurityId); + absent.HasPriorCarryingValue.Should().BeFalse(); + absent.PriorCarryingValue.Should().Be(1_000m, "an explicitly absent account starts from cost basis"); + absent.UnrealizedGainOrLoss.Should().Be(200m); + absent.MarkAdjustment.Should().Be(200m); + + var zero = projection.Lines.Single(line => line.SecurityId == zeroSecurityId); + zero.HasPriorCarryingValue.Should().BeTrue(); + zero.PriorCarryingValue.Should().Be(0m, "an existing zero balance must not be mistaken for an absent account"); + zero.UnrealizedGainOrLoss.Should().Be(200m); + zero.MarkAdjustment.Should().Be(1_200m); + + projection.NetUnrealizedGainOrLoss.Should().Be(400m, "reporting remains cumulative versus cost"); + projection.NetMarkAdjustment.Should().Be(1_400m, "posting uses the durable carrying-value delta"); + projection.IsBalanced.Should().BeTrue(); + } + + [Fact] + public void BuildDrafts_MultiSecurityAccount_ProducesStableSecurityScopedDrafts() + { + var aaplSecurityId = Guid.Parse("30000000-0000-0000-0000-000000000003"); + var msftSecurityId = Guid.Parse("40000000-0000-0000-0000-000000000004"); + var marks = new[] + { + Mark("AAPL", aaplSecurityId, cost: 100m, price: 120m, priorCarrying: 1_000m, "price://aapl/v1"), + Mark("MSFT", msftSecurityId, cost: 200m, price: 190m, priorCarrying: 2_000m, "price://msft/v1") + }; + + var first = DailyPortfolioPricingDraftBuilder.BuildDrafts(Project(marks)); + var reordered = DailyPortfolioPricingDraftBuilder.BuildDrafts(Project(marks.Reverse().ToArray())); + + first.Should().HaveCount(2); + first.Select(draft => draft.Metadata.SecurityId).Should().BeEquivalentTo([aaplSecurityId, msftSecurityId]); + first.Should().OnlyContain(draft => draft.Lines.Count == 2 && draft.IsBalanced); + first.Should().OnlyContain(draft => draft.Lines.All(line => + line.dimensions is not null + && line.dimensions.InstrumentId == draft.Metadata.SecurityId + && line.dimensions.FundId == Policy.FundId + && line.dimensions.AccountId == "broker-1")); + + first.ToDictionary(draft => draft.Metadata.SecurityId!.Value, draft => draft.Metadata.IdempotencyKey) + .Should().BeEquivalentTo( + reordered.ToDictionary(draft => draft.Metadata.SecurityId!.Value, draft => draft.Metadata.IdempotencyKey), + "input order must not change correction/retry identity"); + + var corrected = DailyPortfolioPricingDraftBuilder.BuildDrafts(Project( + [ + Mark("AAPL", aaplSecurityId, cost: 100m, price: 121m, priorCarrying: 1_000m, "price://aapl/v2"), + marks[1] + ])); + + corrected.Single(draft => draft.Metadata.SecurityId == aaplSecurityId).Metadata.IdempotencyKey + .Should().NotBe(first.Single(draft => draft.Metadata.SecurityId == aaplSecurityId).Metadata.IdempotencyKey, + "a corrected same-day mark must create a distinct governed adjustment identity"); + corrected.Single(draft => draft.Metadata.SecurityId == msftSecurityId).Metadata.IdempotencyKey + .Should().Be(first.Single(draft => draft.Metadata.SecurityId == msftSecurityId).Metadata.IdempotencyKey, + "an unchanged security keeps its retry identity"); + } + + [Fact] + public void BuildDrafts_SameSecurityAcrossAccounts_ProducesOneDraftPerAccount() + { + var securityId = Guid.Parse("50000000-0000-0000-0000-000000000005"); + var projection = Project( + [ + Mark( + "AAPL", securityId, cost: 100m, price: 120m, priorCarrying: 1_000m, + "price://aapl/broker-1/v1", financialAccountId: "broker-1"), + Mark( + "AAPL", securityId, cost: 100m, price: 110m, priorCarrying: 1_000m, + "price://aapl/broker-2/v1", financialAccountId: "broker-2") + ]); + + var drafts = DailyPortfolioPricingDraftBuilder.BuildDrafts(projection); + + drafts.Should().HaveCount(2); + drafts.Should().OnlyContain(draft => + draft.Metadata.SecurityId == securityId + && draft.Lines.Count == 2 + && draft.IsBalanced); + drafts.Select(draft => draft.Metadata.FinancialAccountId) + .Should().BeEquivalentTo(["broker-1", "broker-2"]); + drafts.Should().OnlyContain(draft => draft.Lines.All(line => + line.dimensions is not null + && line.dimensions.InstrumentId == securityId + && line.dimensions.AccountId == draft.Metadata.FinancialAccountId + && line.account.FinancialAccountId == draft.Metadata.FinancialAccountId)); + drafts.Select(draft => draft.Metadata.IdempotencyKey) + .Should().OnlyHaveUniqueItems("the financial account is part of the deterministic scope"); + } + + private static DailyPortfolioPriceMark Mark( + string symbol, + Guid securityId, + decimal cost, + decimal price, + decimal priorCarrying, + string evidence, + string financialAccountId = "broker-1") + => new( + symbol, + Quantity: 10m, + CostPrice: cost, + MarkPrice: price, + PriceSource: "official-close", + EvidenceReference: evidence, + FinancialAccountId: financialAccountId, + PriceObservedOn: new DateOnly(2026, 07, 03), + SecurityId: securityId, + PriorCarryingValue: priorCarrying, + CarryingValueSource: "ledger:book-1", + CarryingValueCapturedAtUtc: AsOf, + CarryingValueEvidenceReference: $"ledger://book-1/{symbol}"); + + private static DailyPortfolioPricingProjection Project(IReadOnlyList marks) + => DailyPortfolioPricingProjector.Project(new DailyPortfolioPricingInput( + Policy, + "2026-07", + AsOf, + "USD", + marks)); +} diff --git a/tests/Meridian.Tests/Ledger/LedgerIntegrationTests.cs b/tests/Meridian.Tests/Ledger/LedgerIntegrationTests.cs index 2eaf1d208b..8003669f63 100644 --- a/tests/Meridian.Tests/Ledger/LedgerIntegrationTests.cs +++ b/tests/Meridian.Tests/Ledger/LedgerIntegrationTests.cs @@ -941,6 +941,16 @@ public void Ledger_AsOfBalanceSnapshots_HandleOutOfOrderPostings() ledger.TrialBalanceAsOf(t1)[revenue].Should().Be(100m); ledger.TrialBalanceAsOf(t2)[revenue].Should().Be(150m); + ledger.Journal.Select(entry => entry.Description) + .Should().Equal("first sale", "second sale"); + ledger.GetJournalEntries().Select(entry => entry.Description) + .Should().Equal("first sale", "second sale"); + var running = ledger.GetRunningBalance(cash); + running.Select(point => point.Description) + .Should().Equal("first sale", "second sale"); + running.Select(point => point.Balance) + .Should().Equal(100m, 150m); + var snapshot = ledger.SnapshotAsOf(t1); snapshot.JournalEntryCount.Should().Be(1); snapshot.LedgerEntryCount.Should().Be(2); @@ -989,6 +999,12 @@ public void Ledger_DimensionalAsOfBalanceIndex_PreservesPartialScopeAndOutOfOrde t2, lineDimensions: new LedgerLineDimensionSet(FundId: "fund-a", SleeveId: "core")); var allFundsAtT1 = ledger.TrialBalanceAsOf(t1, lineDimensions: new LedgerLineDimensionSet()); + var fundASnapshotAtT1 = ledger.SnapshotAsOf( + t1, + lineDimensions: new LedgerLineDimensionSet(FundId: "fund-a")); + var fundACoreSnapshotAtT2 = ledger.SnapshotAsOf( + t2, + lineDimensions: new LedgerLineDimensionSet(FundId: "fund-a", SleeveId: "core")); fundAAtT1[cash].Should().Be(130m); fundAAtT1[revenue].Should().Be(130m); @@ -996,6 +1012,12 @@ public void Ledger_DimensionalAsOfBalanceIndex_PreservesPartialScopeAndOutOfOrde fundACoreAtT2[cash].Should().Be(150m); allFundsAtT1[cash].Should().Be(200m, "an empty dimension filter retains the existing no-filter semantics"); + fundASnapshotAtT1.JournalEntryCount.Should().Be(2); + fundASnapshotAtT1.LedgerEntryCount.Should().Be(4); + fundASnapshotAtT1.Balances[cash].Should().Be(130m); + fundACoreSnapshotAtT2.JournalEntryCount.Should().Be(2); + fundACoreSnapshotAtT2.LedgerEntryCount.Should().Be(4); + fundACoreSnapshotAtT2.Balances[cash].Should().Be(150m); } [Fact] diff --git a/tests/Meridian.Tests/Storage/GovernedLedgerPostingTargetTests.cs b/tests/Meridian.Tests/Storage/GovernedLedgerPostingTargetTests.cs new file mode 100644 index 0000000000..3d853eb301 --- /dev/null +++ b/tests/Meridian.Tests/Storage/GovernedLedgerPostingTargetTests.cs @@ -0,0 +1,629 @@ +using FluentAssertions; +using Meridian.Contracts.FundStructure; +using Meridian.Contracts.Ledger; +using Meridian.Ledger; +using Meridian.Storage.Ledger; +using Moq; + +namespace Meridian.Tests.Storage; + +public sealed class GovernedLedgerPostingTargetTests +{ + private static readonly Guid LedgerBookId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid PeriodId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + private static readonly Guid AggregateId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + private static readonly DateTimeOffset OccurredAt = new(2026, 7, 8, 16, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task PostAsync_CrashAfterAppendThenAlteredEvidence_FailsClosedWithoutSecondAppend() + { + var retained = new List(); + var appendCount = 0; + var store = BuildStore(retained, write => + { + appendCount++; + retained.Add(ToRecord(write)); + throw new IOException("response lost after durable append"); + }); + using var target = new DurableLedgerPostingTarget(store.Object); + var write = BuildWrite(); + + Func firstPost = async () => await target.PostAsync(write); + await firstPost.Should().ThrowAsync(); + + var alteredEvidence = CloneEntry( + write.Entry, + write.Entry.Metadata with + { + EvidenceReferences = + [ + new JournalEvidenceReference( + "price-close", + "evidence://provider/AAPL/2026-07-08/corrected", + "Source", + "trusted-close", + OccurredAt, + "valuation-worker", + ContentHash: "sha256:changed") + ] + }); + Func retry = async () => await target.PostAsync(write with { Entry = alteredEvidence }); + + await retry.Should().ThrowAsync() + .WithMessage("*already retained with different accounting content*"); + appendCount.Should().Be(1, "a crash retry with altered evidence must never append another fact"); + } + + [Fact] + public async Task PostAsync_RetainedRetry_ComparesEveryDurableIdentityAndMetadataEnvelope() + { + var original = BuildWrite(); + var retained = new List { ToRecord(original) }; + var store = BuildStore(retained, _ => throw new InvalidOperationException("append must not run")); + using var target = new DurableLedgerPostingTarget(store.Object); + var mutations = new (string Name, LedgerJournalEntryWrite Write)[] + { + ("period", original with { PeriodId = Guid.NewGuid() }), + ("command", original with { CommandId = Guid.NewGuid() }), + ("correlation", original with { CorrelationId = Guid.NewGuid() }), + ("basis", original with { AccountingBasis = AccountingBasisKindDto.Tax }), + ("policy id", original with { AccountingPolicyId = "fair-value-policy-2" }), + ("policy version", original with { AccountingPolicyVersion = "v2" }), + ("rule id", original with { RuleId = "mark-rule-2" }), + ("rule version", original with { RuleVersion = "v2" }), + ("source event", original with { SourceEventId = Guid.NewGuid() }), + ("source journal", original with { SourceJournalEntryId = Guid.NewGuid() }), + ("posting kind", original with { PostingKind = LedgerPostingKindDto.Originating }), + ("ledger book", WithLedgerBook(original, Guid.NewGuid())), + ("approval", original with + { + AdjustmentApproval = original.AdjustmentApproval! with { EvidenceLink = "evidence://approval/changed" } + }), + ("metadata", original with + { + Entry = CloneEntry(original.Entry, original.Entry.Metadata with + { + Tags = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["batchId"] = "valuation-batch-changed" + } + }) + }), + ("line dimensions", original with + { + Entry = CloneFirstLine( + original.Entry, + original.Entry.Lines[0].Dimensions! with { FundId = "fund-beta" }) + }) + }; + + foreach (var mutation in mutations) + { + Func retry = async () => await target.PostAsync(mutation.Write); + await retry.Should().ThrowAsync(mutation.Name) + .WithMessage("*already retained with different accounting content*"); + } + + store.Verify( + candidate => candidate.AppendAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task PostAsync_LedgerBookFieldConflictsWithMetadata_FailsBeforeStoreLookup() + { + var store = new Mock(MockBehavior.Strict); + using var target = new DurableLedgerPostingTarget(store.Object); + var write = BuildWrite() with { LedgerBookId = Guid.NewGuid() }; + + Func post = async () => await target.PostAsync(write); + + await post.Should().ThrowAsync() + .WithMessage("*conflicts with journal metadata ledger book*"); + store.VerifyNoOtherCalls(); + } + + [Fact] + public async Task PostAsync_EquivalentRetry_AllowsEvidenceReorderingWithoutAppending() + { + var original = BuildWrite(); + var secondEvidence = new JournalEvidenceReference( + "approval", + "evidence://approval/valuation-batch", + "Approval", + "accounting-workbench", + OccurredAt.AddMinutes(1), + "controller"); + original = original with + { + Entry = CloneEntry( + original.Entry, + original.Entry.Metadata with + { + EvidenceReferences = [.. original.Entry.Metadata.EvidenceReferences, secondEvidence] + }) + }; + var retained = new List { ToRecord(original) }; + var store = BuildStore(retained, _ => throw new InvalidOperationException("append must not run")); + using var target = new DurableLedgerPostingTarget(store.Object); + var reordered = CloneEntry( + original.Entry, + original.Entry.Metadata with + { + EvidenceReferences = original.Entry.Metadata.EvidenceReferences.Reverse().ToArray() + }); + + var result = await target.PostAsync(original with { Entry = reordered }); + + result.WasAppended.Should().BeFalse(); + result.JournalEntryId.Should().Be(original.Entry.JournalEntryId); + } + + [Fact] + public async Task PostAsync_SameCommandWithRegeneratedJournalAndLineIds_ReturnsRetainedJournalId() + { + var original = BuildCommandWrite(); + var normalized = AccountingPostingCommandValidator.NormalizeAndValidate(original); + normalized.Entry.Metadata.Tags.Should() + .ContainKey(AccountingPostingCommandValidator.PostingCommandFingerprintTag); + normalized.Entry.Metadata.Tags![AccountingPostingCommandValidator.PostingCommandFingerprintTag] + .Should().StartWith("sha256:"); + var retained = new List { ToRecord(normalized) }; + var store = BuildStore(retained, _ => throw new InvalidOperationException("append must not run")); + using var target = new DurableLedgerPostingTarget(store.Object); + var regenerated = original with { Entry = RegenerateEntryIds(original.Entry) }; + + var result = await target.PostAsync(regenerated); + + result.WasAppended.Should().BeFalse(); + result.JournalEntryId.Should().Be(normalized.Entry.JournalEntryId); + result.JournalEntryId.Should().NotBe(regenerated.Entry.JournalEntryId); + store.Verify( + candidate => candidate.AppendAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task PostAsync_FullPostingCommandFingerprint_RejectsSemanticMutations() + { + var original = BuildCommandWrite(); + var normalized = AccountingPostingCommandValidator.NormalizeAndValidate(original); + var retained = new List { ToRecord(normalized) }; + var store = BuildStore(retained, _ => throw new InvalidOperationException("append must not run")); + using var target = new DurableLedgerPostingTarget(store.Object); + var command = original.PostingCommand!; + var mutations = new (string Name, AccountingPostingCommandDto Command)[] + { + ("causation", command with { CausationId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd") }), + ("posting date", command with { PostingDate = command.PostingDate.AddMinutes(1) }), + ("expected version", command with { ExpectedVersion = command.ExpectedVersion + 1 }), + ("operator rationale", command with { OperatorRationale = "Controller approved corrected rationale" }), + ("book context", command with + { + BookContext = command.BookContext! with { DisplayName = "GAAP valuation book - amended" } + }) + }; + + foreach (var mutation in mutations) + { + Func retry = async () => + await target.PostAsync(original with { PostingCommand = mutation.Command }); + + await retry.Should().ThrowAsync(mutation.Name) + .WithMessage("*already retained with different accounting content*"); + } + + store.Verify( + candidate => candidate.AppendAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task PostAsync_BookContextMustMatchWriteBookPeriodBasisAndPolicy() + { + var original = BuildCommandWrite(); + var command = original.PostingCommand!; + var context = command.BookContext!; + var store = new Mock(MockBehavior.Strict); + using var target = new DurableLedgerPostingTarget(store.Object); + var mutations = new (string Name, AccountingBookContextDto Context)[] + { + ("book", context with { LedgerBookId = Guid.NewGuid() }), + ("period", context with { PeriodId = Guid.NewGuid() }), + ("missing period", context with { PeriodId = null }), + ("basis", context with { AccountingBasis = AccountingBasisKindDto.Tax }), + ("policy", context with { AccountingPolicyId = "fair-value-policy-changed" }), + ("policy version", context with { AccountingPolicyVersion = "v2" }) + }; + + foreach (var mutation in mutations) + { + Func post = async () => await target.PostAsync(original with + { + PostingCommand = command with { BookContext = mutation.Context } + }); + + await post.Should().ThrowAsync(mutation.Name) + .WithMessage("*book context*"); + } + + store.VerifyNoOtherCalls(); + } + + [Fact] + public async Task PostAsync_GlobalCommandCollision_RejectsAggregateMutationWithRegeneratedIds() + { + var original = BuildCommandWrite(); + var normalized = AccountingPostingCommandValidator.NormalizeAndValidate(original); + var retained = new List { ToRecord(normalized) }; + var store = BuildStore(retained, _ => throw new InvalidOperationException("append must not run")); + using var target = new DurableLedgerPostingTarget(store.Object); + var changedAggregateId = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"); + var retry = original with + { + AggregateId = changedAggregateId, + Entry = RegenerateEntryIds(original.Entry), + PostingCommand = original.PostingCommand! with { AggregateId = changedAggregateId } + }; + + Func post = async () => await target.PostAsync(retry); + + await post.Should().ThrowAsync() + .WithMessage("*already retained with different accounting content*"); + store.Verify( + candidate => candidate.AppendAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task PostAsync_ValidatesEveryRetainedIdentityCollisionBeforeAcceptingRetry() + { + var original = BuildCommandWrite(); + var normalized = AccountingPostingCommandValidator.NormalizeAndValidate(original); + var regenerated = RegenerateEntryIds(normalized.Entry); + var conflicting = normalized with + { + Entry = CloneEntry( + regenerated, + regenerated.Metadata with + { + EvidenceReferences = + [ + new JournalEvidenceReference( + "price-close", + "evidence://provider/AAPL/2026-07-08/conflicting", + "Source", + "trusted-close", + OccurredAt, + "valuation-worker", + ContentHash: "sha256:conflicting") + ] + }) + }; + var retained = new List + { + ToRecord(normalized, globalSequence: 1), + ToRecord(conflicting, globalSequence: 2) + }; + var store = BuildStore(retained, _ => throw new InvalidOperationException("append must not run")); + using var target = new DurableLedgerPostingTarget(store.Object); + + Func retry = async () => await target.PostAsync(original); + + await retry.Should().ThrowAsync() + .WithMessage("*already retained with different accounting content*"); + store.Verify( + candidate => candidate.AppendAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public void GlobalPostingCommandIdentityMigration_ReplacesAggregateCommandIndex() + { + var sql = ReadMigration("V_ledger_025__global_posting_command_identity.sql"); + + sql.Should().Contain("drop index if exists __SCHEMA__.ux_journal_entries_aggregate_command"); + sql.Should().Contain("create unique index if not exists ux_journal_entries_command"); + sql.Should().Contain("on __SCHEMA__.journal_entries (command_id)"); + sql.Should().Contain("where command_id is not null"); + } + + [Fact] + public void PostingIdentityCollisionScope_IsGlobalForJournalAndCommandButAggregateScopedForSourceAndIdempotency() + { + var write = BuildWrite(); + var identity = LedgerPostingIdentity.FromWrite(write); + var otherAggregate = Guid.Parse("12121212-1212-1212-1212-121212121212"); + var otherJournal = Guid.Parse("13131313-1313-1313-1313-131313131313"); + var record = ToRecord(write); + + LedgerPostingIdentityCollisionLookupExtensions.IsCollision( + record with { AggregateId = otherAggregate }, + identity) + .Should().BeTrue("journal entry identity is global"); + LedgerPostingIdentityCollisionLookupExtensions.IsCollision( + record with + { + AggregateId = otherAggregate, + Entry = RegenerateEntryIds(record.Entry) + }, + identity) + .Should().BeTrue("posting command identity is global"); + + var sourceOnlyIdentity = identity with + { + JournalEntryId = otherJournal, + CommandId = Guid.NewGuid(), + IdempotencyKey = null + }; + LedgerPostingIdentityCollisionLookupExtensions.IsCollision(record, sourceOnlyIdentity) + .Should().BeTrue(); + LedgerPostingIdentityCollisionLookupExtensions.IsCollision( + record with { AggregateId = otherAggregate }, + sourceOnlyIdentity) + .Should().BeFalse("source-event identity is aggregate scoped"); + + var idempotencyOnlyIdentity = sourceOnlyIdentity with + { + SourceEventId = Guid.NewGuid(), + IdempotencyKey = write.Entry.Metadata.IdempotencyKey + }; + LedgerPostingIdentityCollisionLookupExtensions.IsCollision(record, idempotencyOnlyIdentity) + .Should().BeTrue(); + LedgerPostingIdentityCollisionLookupExtensions.IsCollision( + record with { AggregateId = otherAggregate }, + idempotencyOnlyIdentity) + .Should().BeFalse("idempotency identity is aggregate scoped"); + } + + private static Mock BuildStore( + List retained, + Action append) + { + var store = new Mock(); + store.Setup(candidate => candidate.GetByAggregateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => retained.ToArray()); + store.As() + .Setup(candidate => candidate.FindPostingIdentityCollisionsAsync( + It.IsAny(), + It.IsAny())) + .Returns((LedgerPostingIdentity identity, CancellationToken _) => + Task.FromResult>( + retained + .Where(record => LedgerPostingIdentityCollisionLookupExtensions.IsCollision(record, identity)) + .ToArray())); + store.Setup(candidate => candidate.AppendAsync(It.IsAny(), It.IsAny())) + .Returns((write, _) => + { + append(write); + return Task.CompletedTask; + }); + return store; + } + + private static LedgerJournalEntryWrite BuildCommandWrite() + { + var write = BuildWrite(); + var command = new AccountingPostingCommandDto( + write.CommandId!.Value, + write.AggregateId, + write.PeriodId, + new DateOnly(2026, 7, 8), + OccurredAt, + write.Entry.Metadata.IdempotencyKey!, + Intent: AccountingPostingIntentDto.Adjustment, + SourceEventId: write.SourceEventId, + CorrelationId: write.CorrelationId, + CausationId: Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"), + SourceJournalEntryId: write.SourceJournalEntryId, + ExpectedVersion: 7, + SourceEventType: "FairValueMarkAdjustment", + ApprovalState: AccountingPostingApprovalStateDto.Approved, + ApprovalId: "approval-1", + OperatorRationale: "Controller approved daily valuation", + Evidence: + [ + new AccountingPostingEvidenceReferenceDto( + "price-close", + "evidence://provider/AAPL/2026-07-08", + AccountingPostingEvidenceKindDto.Source, + "trusted-close", + OccurredAt, + "valuation-worker", + ContentHash: "sha256:original") + ], + LedgerBookId: LedgerBookId) + { + BookContext = new AccountingBookContextDto( + LedgerBookId, + "fund-alpha", + Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff"), + FundStructureNodeKindDto.Fund, + "GAAP valuation book", + "USD", + AccountingBasisKindDto.Gaap, + write.AccountingPolicyId, + write.AccountingPolicyVersion, + write.PeriodId) + }; + + return write with { PostingCommand = command }; + } + + private static LedgerJournalEntryWrite BuildWrite() + { + var journalEntryId = Guid.Parse("44444444-4444-4444-4444-444444444444"); + const string description = "Daily fair-value adjustment for AAPL"; + var dimensions = new LedgerLineDimensionSet( + FundId: "fund-alpha", + EntityId: "entity-alpha", + InstrumentId: Guid.Parse("55555555-5555-5555-5555-555555555555"), + ExternalGlDimensions: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["desk"] = "valuation" + }); + var metadata = new JournalEntryMetadata( + ActivityType: "FairValueMarkAdjustment", + Symbol: "AAPL", + SecurityId: dimensions.InstrumentId, + LedgerBook: LedgerBookId.ToString("D"), + EffectiveDate: new DateOnly(2026, 7, 8), + IdempotencyKey: "fair-value|fund-alpha|2026-07-08|AAPL", + Tags: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["batchId"] = "valuation-batch-1" + }, + EvidenceReferences: + [ + new JournalEvidenceReference( + "price-close", + "evidence://provider/AAPL/2026-07-08", + "Source", + "trusted-close", + OccurredAt, + "valuation-worker", + ContentHash: "sha256:original") + ]); + var entry = new JournalEntry( + journalEntryId, + OccurredAt, + description, + [ + new LedgerEntry( + Guid.Parse("66666666-6666-6666-6666-666666666666"), + journalEntryId, + OccurredAt, + LedgerAccounts.Securities("AAPL", "broker-alpha"), + 100m, + 0m, + description, + dimensions), + new LedgerEntry( + Guid.Parse("77777777-7777-7777-7777-777777777777"), + journalEntryId, + OccurredAt, + LedgerAccounts.UnrealizedGainFor("broker-alpha"), + 0m, + 100m, + description, + dimensions) + ], + metadata); + + return new LedgerJournalEntryWrite( + entry, + AggregateId, + PeriodId, + CommandId: Guid.Parse("88888888-8888-8888-8888-888888888888"), + CorrelationId: Guid.Parse("99999999-9999-9999-9999-999999999999"), + AccountingBasis: AccountingBasisKindDto.Gaap, + AccountingPolicyId: "fair-value-policy", + AccountingPolicyVersion: "v1", + RuleId: "mark-rule", + RuleVersion: "v1", + SourceEventId: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + SourceJournalEntryId: Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + PostingKind: LedgerPostingKindDto.Adjustment, + AdjustmentApproval: new LedgerAdjustmentApprovalMetadataDto( + "approval-1", + LedgerAdjustmentApprovalStatusDto.Approved, + "controller", + OccurredAt.AddMinutes(2), + "daily-valuation", + EvidenceLink: "evidence://approval/valuation-batch"), + LedgerBookId: LedgerBookId); + } + + private static LedgerJournalEntryRecord ToRecord( + LedgerJournalEntryWrite write, + long globalSequence = 1) + => new( + write.Entry, + write.AggregateId, + write.PeriodId, + write.CommandId, + write.CorrelationId, + globalSequence, + OccurredAt, + write.AccountingBasis, + write.AccountingPolicyId, + write.AccountingPolicyVersion, + write.RuleId, + write.RuleVersion, + write.SourceEventId, + write.SourceJournalEntryId, + write.PostingKind, + write.AdjustmentApproval); + + private static JournalEntry RegenerateEntryIds(JournalEntry entry) + { + var journalEntryId = Guid.NewGuid(); + var lines = entry.Lines + .Select(line => new LedgerEntry( + Guid.NewGuid(), + journalEntryId, + line.Timestamp, + line.Account, + line.Debit, + line.Credit, + line.Description, + line.Dimensions)) + .Reverse() + .ToArray(); + return new JournalEntry( + journalEntryId, + entry.Timestamp, + entry.Description, + lines, + entry.Metadata); + } + + private static JournalEntry CloneEntry(JournalEntry entry, JournalEntryMetadata metadata) + => new(entry.JournalEntryId, entry.Timestamp, entry.Description, entry.Lines, metadata); + + private static JournalEntry CloneFirstLine(JournalEntry entry, LedgerLineDimensionSet dimensions) + { + var first = entry.Lines[0]; + var lines = entry.Lines.ToArray(); + lines[0] = new LedgerEntry( + first.EntryId, + first.JournalEntryId, + first.Timestamp, + first.Account, + first.Debit, + first.Credit, + first.Description, + dimensions); + return new JournalEntry(entry.JournalEntryId, entry.Timestamp, entry.Description, lines, entry.Metadata); + } + + private static LedgerJournalEntryWrite WithLedgerBook(LedgerJournalEntryWrite write, Guid ledgerBookId) + => write with + { + LedgerBookId = ledgerBookId, + Entry = CloneEntry( + write.Entry, + write.Entry.Metadata with { LedgerBook = ledgerBookId.ToString("D") }) + }; + + private static string ReadMigration(string fileName) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var path = Path.Combine( + directory.FullName, + "src", + "Meridian.Storage", + "Ledger", + "Migrations", + fileName); + if (File.Exists(path)) + return File.ReadAllText(path); + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Unable to locate Meridian repository root."); + } +} diff --git a/tests/Meridian.Tests/Storage/LedgerBookServiceTests.cs b/tests/Meridian.Tests/Storage/LedgerBookServiceTests.cs index 6ad1eabb27..f321f5d009 100644 --- a/tests/Meridian.Tests/Storage/LedgerBookServiceTests.cs +++ b/tests/Meridian.Tests/Storage/LedgerBookServiceTests.cs @@ -343,6 +343,34 @@ await act.Should().ThrowAsync() .WithMessage("*Cannot transition*SoftClosed to SoftClosed*"); } + [Fact] + public async Task ClosePeriodAsync_OpenPeriodRejectsHardCloseUntilGovernedSoftCloseCompletes() + { + var store = new InMemoryLedgerJournalStore(); + var service = new PostgresLedgerBookService(store); + var book = await service.CreateBookAsync(new CreateLedgerBookRequest( + "alpha-fund", + Guid.NewGuid(), + FundStructureNodeKindDto.Fund, + "Alpha Fund", + "USD")); + var period = await service.CreatePeriodAsync(new CreateLedgerPeriodRequest( + book.LedgerBookId, + 2026, + 3, + "2026-P03", + new DateOnly(2026, 3, 1), + new DateOnly(2026, 3, 31))); + + var act = () => service.ClosePeriodAsync( + period.PeriodId, + new CloseLedgerPeriodRequest(LedgerPeriodCloseKindDto.HardClose, "fund-controller")); + + await act.Should().ThrowAsync() + .WithMessage("*Cannot transition*Open to HardClosed*"); + (await store.GetPeriodAsync(period.PeriodId))!.Status.Should().Be("Open"); + } + [Fact] public async Task ClosePeriodAsync_HardCloseWithTemporaryAccountResiduals_FailsClosedWithoutMutation() { @@ -382,6 +410,74 @@ await act.Should().ThrowAsync() retained.ClosedAt.Should().BeNull(); } + [Fact] + public async Task ClosePeriodAsync_HardCloseAndLateAdjustment_SerializeOnOnePeriodMutationGate() + { + var store = new InMemoryLedgerJournalStore(); + var service = new PostgresLedgerBookService(store); + var book = await service.CreateBookAsync(new CreateLedgerBookRequest( + "alpha-fund", + Guid.NewGuid(), + FundStructureNodeKindDto.Fund, + "Alpha Fund", + "USD")); + var period = await service.CreatePeriodAsync(new CreateLedgerPeriodRequest( + book.LedgerBookId, + 2026, + 4, + "2026-P04-race", + new DateOnly(2026, 4, 1), + new DateOnly(2026, 4, 30))); + await service.ClosePeriodAsync( + period.PeriodId, + new CloseLedgerPeriodRequest(LedgerPeriodCloseKindDto.SoftClose, "fund-controller")); + using var hardCloseEntered = new ManualResetEventSlim(); + using var releaseHardClose = new ManualResetEventSlim(); + using var appendAttempted = new ManualResetEventSlim(); + store.HardCloseEntered = hardCloseEntered; + store.ReleaseHardClose = releaseHardClose; + store.AppendAttempted = appendAttempted; + var closeTask = Task.Run(() => service.ClosePeriodAsync( + period.PeriodId, + new CloseLedgerPeriodRequest(LedgerPeriodCloseKindDto.HardClose, "fund-controller"))); + var enteredHardClose = hardCloseEntered.Wait(TimeSpan.FromSeconds(5)); + if (!enteredHardClose) + { + releaseHardClose.Set(); + } + + enteredHardClose.Should().BeTrue(); + var lateAdjustment = BuildBalancedEntry( + period.PeriodId, + revenue: 200m, + expense: 50m, + timestamp: DateTimeOffset.Parse("2026-04-30T23:59:59Z")) with + { + PostingKind = LedgerPostingKindDto.Adjustment, + AdjustmentApproval = BuildApprovedAdjustmentApproval() + }; + + var appendTask = Task.Run(() => store.AppendAsync(lateAdjustment)); + var attemptedAppend = appendAttempted.Wait(TimeSpan.FromSeconds(5)); + if (!attemptedAppend) + { + releaseHardClose.Set(); + } + + attemptedAppend.Should().BeTrue(); + var appendCompletedWhileHardCloseHeld = appendTask.IsCompleted; + releaseHardClose.Set(); + + var closed = await closeTask; + var append = async () => await appendTask; + appendCompletedWhileHardCloseHeld.Should().BeFalse( + "a journal append must wait while hard-close holds the period mutation boundary"); + closed.Period.Status.Should().Be(LedgerPeriodStatusDto.HardClosed); + await append.Should().ThrowAsync() + .WithMessage("*hard-closed*"); + (await store.GetByPeriodAsync(period.PeriodId)).Should().BeEmpty(); + } + [Fact] public async Task ReopenPeriodAsync_HardClosedPeriod_RequiresControllerAndScopedRestatementEvidence() { @@ -400,6 +496,9 @@ public async Task ReopenPeriodAsync_HardClosedPeriod_RequiresControllerAndScoped "2026-P05", new DateOnly(2026, 5, 1), new DateOnly(2026, 5, 31))); + await service.ClosePeriodAsync( + period.PeriodId, + new CloseLedgerPeriodRequest(LedgerPeriodCloseKindDto.SoftClose, "fund-controller")); await service.ClosePeriodAsync( period.PeriodId, new CloseLedgerPeriodRequest(LedgerPeriodCloseKindDto.HardClose, "fund-controller")); @@ -489,7 +588,7 @@ public async Task CreateBookAsync_WhenCanceled_PropagatesCancellation() } [Fact] - public async Task LedgerEndpoints_CreateListAndClosePeriod_PropagatesCloseWorkItemToOperatorInbox() + public async Task LedgerEndpoints_CreateListAndSoftClosePeriod_PropagatesCloseWorkItemToOperatorInbox() { await using var app = await CreateAppAsync(); var client = app.GetTestClient(); @@ -524,12 +623,12 @@ public async Task LedgerEndpoints_CreateListAndClosePeriod_PropagatesCloseWorkIt client, closeRoute, new CloseLedgerPeriodRequest( - LedgerPeriodCloseKindDto.HardClose, + LedgerPeriodCloseKindDto.SoftClose, ClosedBy: "fund-controller", RequiredSignoffRole: "Fund Controller", ToleranceProfileId: "close-tolerance-v1")); - close.Period.Status.Should().Be(LedgerPeriodStatusDto.HardClosed); + close.Period.Status.Should().Be(LedgerPeriodStatusDto.SoftClosed); close.WorkItem.Kind.Should().Be(OperatorWorkItemKindDto.LedgerPeriodClose); close.WorkItem.TargetRoute.Should().Be(UiApiRoutes.ReconciliationBreakQueue); close.WorkItem.TargetPageTag.Should().Be("FundReconciliation"); @@ -1536,8 +1635,11 @@ private static string FindRepoRoot() throw new DirectoryNotFoundException("Unable to locate Meridian repository root."); } - private sealed class InMemoryLedgerJournalStore : ILedgerJournalStore + private sealed class InMemoryLedgerJournalStore : + ILedgerJournalStore, + IAtomicLedgerPeriodCloseStore { + private readonly object _periodMutationGate = new(); private readonly Dictionary _books = []; private readonly Dictionary _periods = []; private readonly Dictionary> _entriesByPeriod = []; @@ -1545,47 +1647,58 @@ private sealed class InMemoryLedgerJournalStore : ILedgerJournalStore public List QueryHistory { get; } = []; + public ManualResetEventSlim? HardCloseEntered { get; set; } + + public ManualResetEventSlim? ReleaseHardClose { get; set; } + + public ManualResetEventSlim? AppendAttempted { get; set; } + public Task AppendAsync(LedgerJournalEntryWrite entry, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (!_periods.TryGetValue(entry.PeriodId, out var period)) + AppendAttempted?.Set(); + lock (_periodMutationGate) { - throw new LedgerValidationException($"Accounting period '{entry.PeriodId}' was not found."); - } + if (!_periods.TryGetValue(entry.PeriodId, out var period)) + { + throw new LedgerValidationException($"Accounting period '{entry.PeriodId}' was not found."); + } - LedgerPeriodPostingGuard.Validate(entry, period); + LedgerPeriodPostingGuard.Validate(entry, period); - if (period.LedgerBookId is { } ledgerBookId && - _books.TryGetValue(ledgerBookId, out var book) && - book.AccountingBasis != entry.AccountingBasis) - { - throw new LedgerValidationException( - $"Journal entry '{entry.Entry.JournalEntryId}' basis '{entry.AccountingBasis}' does not match ledger book '{book.DisplayName}' basis '{book.AccountingBasis}'."); - } + if (period.LedgerBookId is { } ledgerBookId && + _books.TryGetValue(ledgerBookId, out var book) && + book.AccountingBasis != entry.AccountingBasis) + { + throw new LedgerValidationException( + $"Journal entry '{entry.Entry.JournalEntryId}' basis '{entry.AccountingBasis}' does not match ledger book '{book.DisplayName}' basis '{book.AccountingBasis}'."); + } - if (!_entriesByPeriod.TryGetValue(entry.PeriodId, out var entries)) - { - entries = []; - _entriesByPeriod[entry.PeriodId] = entries; + if (!_entriesByPeriod.TryGetValue(entry.PeriodId, out var entries)) + { + entries = []; + _entriesByPeriod[entry.PeriodId] = entries; + } + + entries.Add(new LedgerJournalEntryRecord( + entry.Entry, + entry.AggregateId, + entry.PeriodId, + entry.CommandId, + entry.CorrelationId, + ++_sequence, + DateTimeOffset.UtcNow, + entry.AccountingBasis, + entry.AccountingPolicyId, + entry.AccountingPolicyVersion, + entry.RuleId, + entry.RuleVersion, + entry.SourceEventId, + entry.SourceJournalEntryId, + entry.PostingKind, + entry.AdjustmentApproval)); } - entries.Add(new LedgerJournalEntryRecord( - entry.Entry, - entry.AggregateId, - entry.PeriodId, - entry.CommandId, - entry.CorrelationId, - ++_sequence, - DateTimeOffset.UtcNow, - entry.AccountingBasis, - entry.AccountingPolicyId, - entry.AccountingPolicyVersion, - entry.RuleId, - entry.RuleVersion, - entry.SourceEventId, - entry.SourceJournalEntryId, - entry.PostingKind, - entry.AdjustmentApproval)); return Task.CompletedTask; } @@ -1727,26 +1840,78 @@ public Task SavePeriodAsync( CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - if (_periods.TryGetValue(period.PeriodId, out var current)) + lock (_periodMutationGate) { - if (current.Version != expectedVersion) + if (_periods.TryGetValue(period.PeriodId, out var current)) + { + if (current.Version != expectedVersion) + { + throw new InvalidOperationException("Simulated period version conflict."); + } + + var updated = period with { Version = expectedVersion + 1 }; + _periods[period.PeriodId] = updated; + return Task.FromResult(updated); + } + + if (expectedVersion != 0) { throw new InvalidOperationException("Simulated period version conflict."); } - var updated = period with { Version = expectedVersion + 1 }; - _periods[period.PeriodId] = updated; - return Task.FromResult(updated); + var saved = period with { Version = 1 }; + _periods[period.PeriodId] = saved; + return Task.FromResult(saved); } + } - if (expectedVersion != 0) + public Task SaveHardClosedPeriodAsync( + LedgerAccountingPeriod period, + long expectedVersion, + PeriodCloseEventRecord closeEvent, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + lock (_periodMutationGate) { - throw new InvalidOperationException("Simulated period version conflict."); - } + HardCloseEntered?.Set(); + ReleaseHardClose?.Wait(ct); + IEnumerable retainedEntries = + _entriesByPeriod.TryGetValue(period.PeriodId, out var entries) + ? entries + : []; + var residuals = retainedEntries + .SelectMany(static record => record.Entry.Lines) + .Where(static line => line.Account.AccountType is LedgerAccountType.Revenue or LedgerAccountType.Expense) + .GroupBy(static line => new + { + line.Account.Name, + line.Account.AccountType, + line.Account.Symbol, + line.Account.FinancialAccountId, + line.Dimensions + }) + .Select(static group => new + { + group.Key.Name, + Balance = group.Key.AccountType == LedgerAccountType.Revenue + ? group.Sum(static line => line.Credit - line.Debit) + : group.Sum(static line => line.Debit - line.Credit) + }) + .Where(static row => row.Balance != 0m) + .ToArray(); + if (residuals.Length > 0) + { + var preview = string.Join( + "; ", + residuals.Take(5).Select(static row => + FormattableString.Invariant($"{row.Name}={row.Balance}"))); + throw new LedgerBookValidationException( + $"Accounting period '{period.Label}' cannot be hard-closed while {residuals.Length} revenue/expense balance(s) remain non-zero ({preview}). Post and approve the closing-entry draft before period lock."); + } - var saved = period with { Version = 1 }; - _periods[period.PeriodId] = saved; - return Task.FromResult(saved); + return SavePeriodAsync(period, expectedVersion, closeEvent, ct); + } } public Task GetLedgerBookAsync(Guid ledgerBookId, CancellationToken ct = default) diff --git a/tests/Meridian.Tests/Storage/LedgerJournalStoreTests.cs b/tests/Meridian.Tests/Storage/LedgerJournalStoreTests.cs index f3a7524e31..06b5102ba2 100644 --- a/tests/Meridian.Tests/Storage/LedgerJournalStoreTests.cs +++ b/tests/Meridian.Tests/Storage/LedgerJournalStoreTests.cs @@ -259,6 +259,21 @@ public void PostingGuard_OpenPeriod_AllowsOriginatingAndAdjustmentEntries() adjustmentAct.Should().NotThrow(); } + [Fact] + public void PostingGuard_OpenPeriod_RejectsClosingEntryUntilPeriodIsSoftClosed() + { + var period = BuildAccountingPeriod("Open"); + var write = BuildBalancedJournalWrite(period.PeriodId) with + { + PostingKind = LedgerPostingKindDto.ClosingEntry + }; + + var act = () => LedgerPeriodPostingGuard.Validate(write, period); + + act.Should().Throw() + .WithMessage("*closing entries*soft-closed*"); + } + [Fact] public void PostingGuard_SoftClosedPeriod_RejectsOriginatingEntry() { @@ -365,7 +380,7 @@ public void PostingGuard_SoftClosedPeriod_AllowsClosingEntry() } [Fact] - public void PostingGuard_HardClosedPeriod_AllowsClosingEntry() + public void PostingGuard_HardClosedPeriod_RejectsClosingEntrySoHardCloseIsFinalMutationBoundary() { var period = BuildAccountingPeriod("HardClosed"); var write = BuildBalancedJournalWrite(period.PeriodId) with @@ -375,8 +390,8 @@ public void PostingGuard_HardClosedPeriod_AllowsClosingEntry() var act = () => LedgerPeriodPostingGuard.Validate(write, period); - act.Should().NotThrow( - "closing entries are the sanctioned exception to the closed-period posting bar"); + act.Should().Throw() + .WithMessage("*hard-closed*no postings*"); } [Fact] diff --git a/tests/Meridian.Tests/Storage/PositionSnapshotStoreTests.cs b/tests/Meridian.Tests/Storage/PositionSnapshotStoreTests.cs index 4c6bf44a55..38f7f0c63c 100644 --- a/tests/Meridian.Tests/Storage/PositionSnapshotStoreTests.cs +++ b/tests/Meridian.Tests/Storage/PositionSnapshotStoreTests.cs @@ -54,6 +54,52 @@ public async Task SaveAndGetLatest_RoundTrip_MatchesOriginal() loaded.Cash.Should().Be(50_000m); } + [Fact] + public async Task SaveAndGetLatest_OwnedScope_DoesNotFallBackAcrossTenant() + { + var owner = new PositionSnapshotOwnerScope( + "tenant-a", + "company-a", + "fund-a", + Guid.Parse("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + "entity-a"); + var snapshot = BuildSnapshot("run-owned", "acc-owned", cash: 50_000m) with + { + TenantId = owner.TenantId, + CompanyId = owner.CompanyId, + FundProfileId = owner.FundProfileId, + LedgerBookId = owner.LedgerBookId, + EntityId = owner.EntityId + }; + + await _store.SaveSnapshotAsync(snapshot); + + var owned = await _store.GetLatestSnapshotAsync("run-owned", "acc-owned", owner); + var otherTenant = await _store.GetLatestSnapshotAsync( + "run-owned", + "acc-owned", + owner with { TenantId = "tenant-b" }); + + owned.Should().Be(snapshot); + otherTenant.Should().BeNull(); + (await _store.GetLatestSnapshotAsync("run-owned", "acc-owned")).Should().BeNull( + "owned snapshots must not be exposed through the legacy unscoped lookup"); + } + + [Fact] + public async Task SaveSnapshot_PartialOwnership_FailsClosed() + { + var partial = BuildSnapshot("run-partial", "acc-partial", cash: 0m) with + { + TenantId = "tenant-a" + }; + + var act = () => _store.SaveSnapshotAsync(partial); + + await act.Should().ThrowAsync() + .WithMessage("*tenant, company, fund profile, ledger book, and entity together*"); + } + [Fact] public async Task GetLatestSnapshot_MultipleWrites_ReturnsNewest() { diff --git a/tests/Meridian.Tests/Storage/SymbolRegistryServiceTests.cs b/tests/Meridian.Tests/Storage/SymbolRegistryServiceTests.cs index 3074650b84..ff43af9323 100644 --- a/tests/Meridian.Tests/Storage/SymbolRegistryServiceTests.cs +++ b/tests/Meridian.Tests/Storage/SymbolRegistryServiceTests.cs @@ -420,4 +420,18 @@ await _service.RegisterSymbolAsync(new SymbolRegistryEntry savedRegistry.Should().NotBeNull(); savedRegistry!.Symbols.Should().ContainKey("TEST"); } + + [Fact] + public async Task SetMigrationMarkerAsync_ProcessRestart_PersistsMarkerThroughLockedStoreOperation() + { + await _service.InitializeAsync(); + + await _service.SetMigrationMarkerAsync("canonical-symbol-spine-v1", "FINGERPRINT-1"); + + var reloaded = new SymbolRegistryService(_testDirectory); + await reloaded.InitializeAsync(); + + (await reloaded.GetMigrationMarkerAsync("canonical-symbol-spine-v1")) + .Should().Be("FINGERPRINT-1"); + } } diff --git a/tests/Meridian.Tests/SymbolSearch/OpenFigiClientTests.cs b/tests/Meridian.Tests/SymbolSearch/OpenFigiClientTests.cs index 41ddf8c099..7e4e3d2793 100644 --- a/tests/Meridian.Tests/SymbolSearch/OpenFigiClientTests.cs +++ b/tests/Meridian.Tests/SymbolSearch/OpenFigiClientTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Text; using FluentAssertions; +using Meridian.Core.Exceptions; using Meridian.Core.Subscriptions.Models; using Meridian.Infrastructure.Adapters.Core; using Meridian.Infrastructure.Adapters.OpenFigi; @@ -305,17 +306,25 @@ public async Task LookupByTicker_WhenApiReturnsErrorField_ReturnsEmptyList() } [Fact] - public async Task LookupByTicker_WhenApiReturns429_ThrowsHttpRequestException() + public async Task LookupByTicker_WhenApiReturns429_ThrowsTypedRateLimitException() { using var handler = new StubHttpMessageHandler(_ => - new HttpResponseMessage((HttpStatusCode)429) + { + var response = new HttpResponseMessage((HttpStatusCode)429) { Content = new StringContent("{\"error\":\"Too Many Requests\"}", Encoding.UTF8, "application/json") - }); + }; + response.Headers.RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.FromSeconds(45)); + return response; + }); using var httpClient = new HttpClient(handler); using var client = new OpenFigiClient(httpClient: httpClient); - await Assert.ThrowsAsync(() => client.LookupByTickerAsync("AAPL")); + var exception = await Assert.ThrowsAsync( + () => client.LookupByTickerAsync("AAPL")); + + exception.Provider.Should().Be("openfigi"); + exception.RetryAfter.Should().Be(TimeSpan.FromSeconds(45)); } [Fact] diff --git a/tests/Meridian.Tests/Ui/AccountingConfigurationServiceTests.cs b/tests/Meridian.Tests/Ui/AccountingConfigurationServiceTests.cs index 5cf3a3268f..8691c52a1b 100644 --- a/tests/Meridian.Tests/Ui/AccountingConfigurationServiceTests.cs +++ b/tests/Meridian.Tests/Ui/AccountingConfigurationServiceTests.cs @@ -1,9 +1,12 @@ +using System.Text.Json; using FluentAssertions; using Meridian.Application.Accounting; using Meridian.Application.SecurityMaster; using Meridian.Contracts.Banking; +using Meridian.Contracts.Catalog; using Meridian.Contracts.FundStructure; using Meridian.Contracts.Ledger; +using Meridian.Contracts.SecurityMaster; using Meridian.Contracts.Workstation; using Meridian.FinancialOperations.PrivateCapital; using Meridian.Ledger; @@ -11,6 +14,7 @@ using Meridian.Storage.Ledger; using Meridian.Ui.Shared.Services; using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; namespace Meridian.Tests.Ui; @@ -18,6 +22,8 @@ public sealed class AccountingConfigurationServiceTests { private static readonly Guid ManualJournalLedgerBookId = Guid.Parse("11111111-1111-1111-1111-111111111111"); private static readonly Guid ManualJournalPeriodId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + private static readonly Guid DailyValuationAaplSecurityId = Guid.Parse("A1111111-1111-4111-8111-111111111111"); + private static readonly Guid DailyValuationMsftSecurityId = Guid.Parse("B2222222-2222-4222-8222-222222222222"); [Fact] public async Task AccountingConfigurationService_IsolatesWorkspacesByTenantAndCompanyScope() @@ -1329,10 +1335,16 @@ public async Task Scenario_ManualJournalEntryLifecycle_PostAppendsDurableLedgerW write.Entry.Metadata.IdempotencyKey.Should().Be($"manual-je:{ManualJournalLedgerBookId:N}:{saved.JournalEntryId:N}"); } + /// + /// Scenario: two listed securities receive a corrected same-day close. Both the original and + /// corrected batches traverse intake and the all-entry approval/post lifecycle, while restart + /// proof confirms the correction posts only the incremental carrying-value delta. + /// [Fact] - public async Task DailyValuation_ApprovedPostedAndRestarted_HydratesMarkedStatementsAndNavIdempotently() + public async Task Scenario_SameDayMultiSecurityCorrection_PostsIncrementalBatchAndRestartsWithoutCompounding() { var asOf = new DateTimeOffset(2026, 6, 30, 16, 0, 0, TimeSpan.Zero); + var correctionAsOf = asOf.AddHours(2); var configuration = CreateService(); await SeedBalancedConfigurationAsync(configuration); await configuration.UpsertChartNodeAsync(new UpsertChartOfAccountsNodeRequest( @@ -1353,18 +1365,41 @@ await configuration.UpsertChartNodeAsync(new UpsertChartOfAccountsNodeRequest( "Revenue", FinancialAccountId: "AAPL"), "valuation-ops")); + await configuration.UpsertChartNodeAsync(new UpsertChartOfAccountsNodeRequest( + "fund-alpha", + new ChartOfAccountsNodeDto( + "securities-msft", + "Assets:Securities:MSFT", + "Securities", + "Asset", + Symbol: "MSFT"), + "valuation-ops")); + await configuration.UpsertChartNodeAsync(new UpsertChartOfAccountsNodeRequest( + "fund-alpha", + new ChartOfAccountsNodeDto( + "unrealized-gain-msft", + "Income:Unrealized Gain:MSFT", + "Unrealized Gain", + "Revenue", + FinancialAccountId: "MSFT"), + "valuation-ops")); var journalStore = WritableManualJournalLedgerJournalStore.Default(AccountingBasisKindDto.Primary); journalStore.Seed(BuildJournal( asOf.AddDays(-10), "capital contribution", - (LedgerAccounts.Cash, 15_000m, 0m), - (LedgerAccounts.CapitalAccount, 0m, 15_000m))); + (LedgerAccounts.Cash, 25_000m, 0m), + (LedgerAccounts.CapitalAccount, 0m, 25_000m))); journalStore.Seed(BuildJournal( asOf.AddDays(-9), "buy AAPL at cost", (LedgerAccounts.Securities("AAPL"), 15_000m, 0m), (LedgerAccounts.Cash, 0m, 15_000m))); + journalStore.Seed(BuildJournal( + asOf.AddDays(-8), + "buy MSFT at cost", + (LedgerAccounts.Securities("MSFT"), 10_000m, 0m), + (LedgerAccounts.Cash, 0m, 10_000m))); var draftStore = new InMemoryManualJournalEntryDraftStore(); using var postingTarget = new DurableLedgerPostingTarget(journalStore); @@ -1372,9 +1407,24 @@ await configuration.UpsertChartNodeAsync(new UpsertChartOfAccountsNodeRequest( draftStore, configuration, new InMemoryAccountingActionAuditStore(), + securityMasterQueryService: new DailyValuationSecurityMasterQueryService(), journalStore: journalStore, postingTarget: postingTarget); var intake = new AutomatedJournalDraftIntakeService(workbench, draftStore, configuration); + var positionService = CreateDailyValuationPositionService(); + var priceSource = new MutableMarkPriceSource(); + priceSource.Set("AAPL", new MarkPriceQuote( + 160m, + "trusted-close", + "evidence://prices/AAPL/2026-06-30/initial", + new DateOnly(2026, 6, 30), + DailyPortfolioPriceConfidence.High)); + priceSource.Set("MSFT", new MarkPriceQuote( + 210m, + "trusted-close", + "evidence://prices/MSFT/2026-06-30/initial", + new DateOnly(2026, 6, 30), + DailyPortfolioPriceConfidence.High)); var runner = new AutomatedJournalIntakeRunner( intake, new FeeScheduleAccrualEventProducer(), @@ -1385,7 +1435,7 @@ await configuration.UpsertChartNodeAsync(new UpsertChartOfAccountsNodeRequest( "evidence://prices/AAPL/2026-06-30", PriceAsOf: new DateOnly(2026, 6, 30))))); var scheduleSource = new InMemoryDailyValuationPortfolioSource(); - await scheduleSource.SaveAsync(new DailyValuationScheduleWorkItem( + var configured = await scheduleSource.SaveAsync(new DailyValuationScheduleWorkItem( "daily-valuation-fund-alpha", "fund-alpha", "USD", @@ -1393,7 +1443,10 @@ await scheduleSource.SaveAsync(new DailyValuationScheduleWorkItem( ManualJournalLedgerBookId, ManualJournalPeriodId, asOf, - [new MarkToMarketPosition("AAPL", 100m, 150m)], + [ + new MarkToMarketPosition("AAPL", 100m, 150m, SecurityId: DailyValuationAaplSecurityId), + new MarkToMarketPosition("MSFT", 50m, 200m, SecurityId: DailyValuationMsftSecurityId) + ], "valuation-policy-1", "Daily close", "market-close", @@ -1404,11 +1457,14 @@ [new MarkToMarketPosition("AAPL", 100m, 150m)], MinimumConfidence: DailyPortfolioPriceConfidence.Medium, RequireCompleteCoverage: true, ClosePeriodId: "2026-06", - EntityId: "entity-master")); + EntityId: "entity-master", + UseStaticPositionOverride: true, + StaticPositionsAsOfUtc: asOf)); var scheduler = new DailyValuationScheduledWorker( scheduleSource, runner, - NullLogger.Instance); + NullLogger.Instance, + positionService); var beforeDue = await scheduler.RunDueAsync(asOf.AddTicks(-1)); var firstBatch = await scheduler.RunDueAsync(asOf); @@ -1418,63 +1474,116 @@ [new MarkToMarketPosition("AAPL", 100m, 150m)], var scheduledRun = firstBatch.Runs.Should().ContainSingle().Subject; scheduledRun.State.Should().Be(DailyValuationScheduleStateDto.DraftReady); duplicateBatch.Runs.Should().BeEmpty("the scheduled timestamp is claimed exactly once"); - var draft = (await draftStore.ListAsync("fund-alpha", ManualJournalLedgerBookId)) - .Should().ContainSingle().Subject; - draft.Status.Should().Be(ManualJournalEntryStatusDto.Draft); - scheduledRun.JournalEntryId.Should().Be(draft.JournalEntryId); - draft.TreasuryContext!.IdempotencyKey.Should() - .Be($"fair-value|fund-alpha|{ManualJournalPeriodId:D}|2026-06-30"); + var initialDrafts = (await draftStore.ListAsync("fund-alpha", ManualJournalLedgerBookId)) + .OrderBy(static draft => draft.JournalEntryId) + .ToArray(); + initialDrafts.Should().HaveCount(2).And.OnlyContain(draft => draft.Status == ManualJournalEntryStatusDto.Draft); + scheduledRun.JournalEntryIds.Should().BeEquivalentTo(initialDrafts.Select(static draft => draft.JournalEntryId)); + initialDrafts.Should().OnlyContain(draft => + draft.TreasuryContext!.IdempotencyKey!.StartsWith( + $"fair-value|fund=fund-alpha|period={ManualJournalPeriodId:D}|date=2026-06-30|", + StringComparison.Ordinal)); + initialDrafts.Select(static draft => draft.TreasuryContext!.BatchCorrelationId) + .Should().OnlyContain(correlationId => correlationId == scheduledRun.BatchCorrelationId); var scheduleStatus = await scheduleSource.GetStatusAsync( "fund-alpha", ManualJournalLedgerBookId, "2026-06"); scheduleStatus.State.Should().Be(DailyValuationScheduleStateDto.DraftReady); scheduleStatus.NextRunAtUtc.Should().Be(asOf.AddDays(1)); - scheduleStatus.EvidenceLinks.Should().ContainSingle(link => - link.Route == "evidence://prices/AAPL/2026-06-30"); + scheduleStatus.EvidenceLinks.Should().Contain(link => link.Route == "evidence://prices/AAPL/2026-06-30/initial"); + scheduleStatus.EvidenceLinks.Should().Contain(link => link.Route == "evidence://prices/MSFT/2026-06-30/initial"); - var submitted = await workbench.ApplyLifecycleActionAsync(new JournalEntryLifecycleActionRequestDto( - draft.JournalEntryId, - draft.FundProfileId, - JournalEntryLifecycleActionDto.Submit, - "valuation-ops", - draft.Version, - Notes: "Submit trusted daily closing marks.", - EvidenceLinks: ["evidence://accounting/valuation/submit"], - LedgerBookId: draft.LedgerBookId)); - var approved = await workbench.ApplyLifecycleActionAsync(new JournalEntryLifecycleActionRequestDto( - submitted.JournalEntry.JournalEntryId, - submitted.JournalEntry.FundProfileId, - JournalEntryLifecycleActionDto.Approve, - "controller", - submitted.JournalEntry.Version, - Notes: "Controller approved provider close evidence.", - EvidenceLinks: [ManualJournalApprovalEvidence(submitted.JournalEntry)], - LedgerBookId: submitted.JournalEntry.LedgerBookId)); - var posted = await workbench.ApplyLifecycleActionAsync(new JournalEntryLifecycleActionRequestDto( - approved.JournalEntry.JournalEntryId, - approved.JournalEntry.FundProfileId, - JournalEntryLifecycleActionDto.Post, + var batchLifecycle = new DailyValuationBatchLifecycleService(scheduleSource, draftStore, workbench); + var initialPosting = await batchLifecycle.ApproveAndPostAsync(new DailyValuationBatchLifecycleRequestDto( + configured.ScheduleId, + configured.FundProfileId, "controller", - approved.JournalEntry.Version, - Notes: "Post approved daily valuation.", - EvidenceLinks: [ManualJournalPostingEvidence(approved.JournalEntry)], - LedgerBookId: approved.JournalEntry.LedgerBookId)); - - posted.JournalEntry.Status.Should().Be(ManualJournalEntryStatusDto.Posted); - journalStore.Appended.Should().ContainSingle("the duplicate valuation run must not append again"); + "Approve and post the complete trusted closing-mark batch.", + ["evidence://accounting/valuation/initial-batch"])); + + initialPosting.IsComplete.Should().BeTrue(); + initialPosting.JournalEntryIds.Should().HaveCount(2); + initialPosting.PostedJournalEntryIds.Should().BeEquivalentTo(initialPosting.JournalEntryIds); + journalStore.Appended.Should().HaveCount(2); + + priceSource.Set("AAPL", new MarkPriceQuote( + 162m, + "trusted-corrected-close", + "evidence://prices/AAPL/2026-06-30/correction", + new DateOnly(2026, 6, 30), + DailyPortfolioPriceConfidence.High)); + priceSource.Set("MSFT", new MarkPriceQuote( + 208m, + "trusted-corrected-close", + "evidence://prices/MSFT/2026-06-30/correction", + new DateOnly(2026, 6, 30), + DailyPortfolioPriceConfidence.High)); + var postedSchedule = (await scheduleSource.GetAsync(configured.ScheduleId)).Should().NotBeNull().Subject!; + await scheduleSource.SaveAsync(postedSchedule with + { + State = DailyValuationScheduleStateDto.Scheduled, + NextRunAtUtc = correctionAsOf, + StaticPositionsAsOfUtc = correctionAsOf, + LastSummary = "Corrected same-day provider marks scheduled for review.", + Blockers = [] + }); - // Simulate a process restart: rebuild every read from the retained durable journal. - var restartedLedger = await journalStore.HydrateFundLedgerAsOfAsync( + var correctionBatch = await scheduler.RunDueAsync(correctionAsOf); + var correctionRun = correctionBatch.Runs.Should().ContainSingle().Subject; + correctionRun.State.Should().Be(DailyValuationScheduleStateDto.DraftReady); + correctionRun.JournalEntryIds.Should().HaveCount(2) + .And.NotBeEquivalentTo(initialPosting.JournalEntryIds); + var correctionPosting = await batchLifecycle.ApproveAndPostAsync(new DailyValuationBatchLifecycleRequestDto( + configured.ScheduleId, + configured.FundProfileId, + "controller", + "Approve and post both corrected same-day marks.", + ["evidence://accounting/valuation/correction-batch"])); + + correctionPosting.IsComplete.Should().BeTrue(); + correctionPosting.JournalEntryIds.Should().HaveCount(2); + correctionPosting.PostedJournalEntryIds.Should().BeEquivalentTo(correctionPosting.JournalEntryIds); + (await draftStore.ListAsync("fund-alpha", ManualJournalLedgerBookId)) + .Should().HaveCount(4) + .And.OnlyContain(draft => draft.Status == ManualJournalEntryStatusDto.Posted); + var finalSchedule = (await scheduleSource.GetAsync(configured.ScheduleId)).Should().NotBeNull().Subject!; + finalSchedule.State.Should().Be(DailyValuationScheduleStateDto.Posted); + finalSchedule.JournalEntryIds.Should().BeEquivalentTo(correctionPosting.JournalEntryIds); + journalStore.Appended.Should().HaveCount(4); + journalStore.Appended.GroupBy(static write => write.Entry.Metadata.Symbol) + .Should().OnlyContain(group => group.Count() == 2); + journalStore.Appended + .Where(static write => write.Entry.Metadata.Symbol == "AAPL") + .Select(static write => write.Entry.Lines.Max(line => Math.Max(line.Debit, line.Credit))) + .Should().BeEquivalentTo([1_000m, 200m]); + journalStore.Appended + .Where(static write => write.Entry.Metadata.Symbol == "MSFT") + .Select(static write => write.Entry.Lines.Max(line => Math.Max(line.Debit, line.Credit))) + .Should().BeEquivalentTo([500m, 100m]); + journalStore.Appended.Select(static write => write.Entry.Metadata.SecurityId) + .Should().BeEquivalentTo( + [ + DailyValuationAaplSecurityId, + DailyValuationAaplSecurityId, + DailyValuationMsftSecurityId, + DailyValuationMsftSecurityId + ]); + + // Recreate the journal-store process boundary from retained records, then rebuild every + // downstream read without carrying any in-memory ledger projection across the restart. + var restartedJournalStore = journalStore.RestartFromRetainedRecords(); + var restartedLedger = await restartedJournalStore.HydrateFundLedgerAsOfAsync( "fund-alpha", - asOf, + correctionAsOf, AccountingBasisKindDto.Primary); - restartedLedger.Journal.Should().HaveCount(3); - restartedLedger.GetBalance(LedgerAccounts.Securities("AAPL")).Should().Be(16_000m); + restartedLedger.Journal.Should().HaveCount(7); + restartedLedger.GetBalance(LedgerAccounts.Securities("AAPL")).Should().Be(16_200m); + restartedLedger.GetBalance(LedgerAccounts.Securities("MSFT")).Should().Be(10_400m); - var statements = LedgerFinancialStatementBuilder.BuildAsOf(restartedLedger, asOf); - statements.TotalAssets.Should().Be(16_000m); - statements.NetIncome.Should().Be(1_000m); + var statements = LedgerFinancialStatementBuilder.BuildAsOf(restartedLedger, correctionAsOf); + statements.TotalAssets.Should().Be(26_600m); + statements.NetIncome.Should().Be(1_600m); var restartedFundBook = new FundLedgerBook("fund-alpha"); foreach (var journal in restartedLedger.Journal) @@ -1483,8 +1592,8 @@ [new MarkToMarketPosition("AAPL", 100m, 150m)], } var nav = await new NavAttributionService(new NullSecurityMasterQueryService()).AttributeAsync( - new NavAttributionRequest("fund-alpha", asOf, restartedFundBook)); - nav.Consolidated.TotalNav.Should().Be(16_000m); + new NavAttributionRequest("fund-alpha", correctionAsOf, restartedFundBook)); + nav.Consolidated.TotalNav.Should().Be(26_600m); } [Fact] @@ -1497,9 +1606,11 @@ public async Task DailyValuationScheduler_EmptyConfiguredPortfolio_RecordsVisibl draftStore, configuration, new InMemoryAccountingActionAuditStore()); + var positionService = CreateDailyValuationPositionService(); var runner = new AutomatedJournalIntakeRunner( new AutomatedJournalDraftIntakeService(workbench, draftStore, configuration), - new FeeScheduleAccrualEventProducer()); + new FeeScheduleAccrualEventProducer(), + dailyValuationPositionService: positionService); var source = new InMemoryDailyValuationPortfolioSource(); await source.SaveAsync(new DailyValuationScheduleWorkItem( "daily-valuation-empty-scope", @@ -1516,11 +1627,14 @@ await source.SaveAsync(new DailyValuationScheduleWorkItem( "cfo", dueAt.AddMonths(-1), "End-of-day valuation", - ClosePeriodId: "2026-06")); + ClosePeriodId: "2026-06", + UseStaticPositionOverride: true, + StaticPositionsAsOfUtc: dueAt)); var scheduler = new DailyValuationScheduledWorker( source, runner, - NullLogger.Instance); + NullLogger.Instance, + positionService); var batch = await scheduler.RunDueAsync(dueAt); var rerun = await scheduler.RunDueAsync(dueAt); @@ -1528,10 +1642,10 @@ await source.SaveAsync(new DailyValuationScheduleWorkItem( batch.Runs.Should().ContainSingle(run => run.State == DailyValuationScheduleStateDto.Blocked && - run.Blockers.Any(blocker => blocker.Contains("No configured portfolio positions", StringComparison.Ordinal))); + run.Blockers.Any(blocker => blocker.Contains("no open positions", StringComparison.OrdinalIgnoreCase))); rerun.Runs.Should().BeEmpty(); status.State.Should().Be(DailyValuationScheduleStateDto.Blocked); - status.Summary.Should().Contain("No configured portfolio positions"); + status.Summary.Should().ContainEquivalentOf("no open positions"); status.Blockers.Should().ContainSingle(); status.NextRunAtUtc.Should().Be(dueAt.AddDays(1)); (await draftStore.ListAsync("fund-alpha", ManualJournalLedgerBookId)).Should().BeEmpty(); @@ -5296,6 +5410,68 @@ await reverseCloseLocked.Should().ThrowAsync() workbench.AuditTrail.Select(item => item.Action).Should().Contain(new[] { "manual-je.approve", "manual-je.post", "manual-je.lock-after-close", "manual-je.reverse", "manual-je.reverse-draft", "manual-je.rebook", "manual-je.rebook-draft" }); } + [Fact] + public async Task ManualJournalEntryCorrection_AtomicBatchFailure_LeavesPostedSourceUnchanged() + { + var configuration = CreateService(); + await SeedBalancedConfigurationAsync(configuration); + var draftStore = new FailOnceManualJournalEntryDraftStore(); + var service = CreateManualJournalEntryWorkbenchService( + configuration, + draftStore: draftStore); + var saved = await service.SaveDraftAsync(new SaveManualJournalEntryDraftRequest( + BalancedManualJournalEntry(), + "ops-user")); + var submitted = await service.SubmitApprovalAsync(new SubmitManualJournalEntryApprovalRequest( + saved.JournalEntryId, + saved.FundProfileId, + "controller", + saved.Version, + LedgerBookId: saved.LedgerBookId)); + var approved = await service.ApplyLifecycleActionAsync(new JournalEntryLifecycleActionRequestDto( + submitted.JournalEntryId, + submitted.FundProfileId, + JournalEntryLifecycleActionDto.Approve, + "controller", + submitted.Version, + Notes: "Approve source before atomic correction test.", + EvidenceLinks: [ManualJournalApprovalEvidence(submitted)], + LedgerBookId: submitted.LedgerBookId)); + var posted = await service.ApplyLifecycleActionAsync(new JournalEntryLifecycleActionRequestDto( + approved.JournalEntry.JournalEntryId, + approved.JournalEntry.FundProfileId, + JournalEntryLifecycleActionDto.Post, + "controller", + approved.JournalEntry.Version, + Notes: "Post source before atomic correction test.", + EvidenceLinks: [ManualJournalPostingEvidence(approved.JournalEntry)], + LedgerBookId: approved.JournalEntry.LedgerBookId)); + draftStore.FailNextBatch = true; + var reversalEvidence = + $"/api/workstation/evidence/subjects/accounting-record/reversal/ledger-book/{posted.JournalEntry.LedgerBookId:D}/{posted.JournalEntry.PeriodId}"; + + var act = async () => await service.ApplyLifecycleActionAsync(new JournalEntryLifecycleActionRequestDto( + posted.JournalEntry.JournalEntryId, + posted.JournalEntry.FundProfileId, + JournalEntryLifecycleActionDto.Reverse, + "controller", + posted.JournalEntry.Version, + Notes: "Reverse with an injected atomic persistence failure.", + EvidenceLinks: [reversalEvidence], + LedgerBookId: posted.JournalEntry.LedgerBookId)); + + await act.Should().ThrowAsync() + .WithMessage("Injected atomic manual-journal batch failure."); + var retained = await draftStore.ListAsync(posted.JournalEntry.FundProfileId, posted.JournalEntry.LedgerBookId); + retained.Should().ContainSingle(draft => + draft.JournalEntryId == posted.JournalEntry.JournalEntryId && + draft.Status == ManualJournalEntryStatusDto.Posted && + draft.Version == posted.JournalEntry.Version); + retained.Should().NotContain(draft => + draft.ReversalOfJournalEntryId == posted.JournalEntry.JournalEntryId); + draftStore.BatchSaveAttempts.Should().Be(1); + } + [Fact] public async Task Scenario_ManualJournalEntry_ReviewedAutomationCannotSubmitApproval() { @@ -7212,14 +7388,15 @@ private static ManualJournalEntryWorkbenchService CreateManualJournalEntryWorkbe ReportPackWorkflowService? reportPackWorkflowService = null, IBankTransactionSource? bankTransactionSource = null, bool includeDefaultJournalStore = true, - IGovernedLedgerPostingTarget? postingTarget = null) + IGovernedLedgerPostingTarget? postingTarget = null, + IManualJournalEntryDraftStore? draftStore = null) { journalStore ??= includeDefaultJournalStore ? WritableManualJournalLedgerJournalStore.Default() : null; return new ManualJournalEntryWorkbenchService( - new InMemoryManualJournalEntryDraftStore(), + draftStore ?? new InMemoryManualJournalEntryDraftStore(), configurationService, new InMemoryAccountingActionAuditStore(), journalStore: journalStore, @@ -7228,6 +7405,52 @@ private static ManualJournalEntryWorkbenchService CreateManualJournalEntryWorkbe postingTarget: postingTarget); } + private sealed class FailOnceManualJournalEntryDraftStore : IManualJournalEntryDraftStore + { + private readonly InMemoryManualJournalEntryDraftStore _inner = new(); + + public bool FailNextBatch { get; set; } + + public int BatchSaveAttempts { get; private set; } + + public Task> ListFundProfileIdsAsync(CancellationToken ct = default) + => _inner.ListFundProfileIdsAsync(ct); + + public Task> ListAsync( + string fundProfileId, + Guid? ledgerBookId = null, + CancellationToken ct = default, + string? tenantId = null, + string? companyId = null) + => _inner.ListAsync(fundProfileId, ledgerBookId, ct, tenantId, companyId); + + public Task GetAsync( + string fundProfileId, + Guid journalEntryId, + CancellationToken ct = default, + string? tenantId = null, + string? companyId = null) + => _inner.GetAsync(fundProfileId, journalEntryId, ct, tenantId, companyId); + + public Task SaveAsync(ManualJournalEntryDraftDto draft, CancellationToken ct = default) + => _inner.SaveAsync(draft, ct); + + public Task SaveBatchAsync( + IReadOnlyList drafts, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + BatchSaveAttempts++; + if (FailNextBatch) + { + FailNextBatch = false; + throw new IOException("Injected atomic manual-journal batch failure."); + } + + return _inner.SaveBatchAsync(drafts, ct); + } + } + private static string ManualJournalApprovalEvidence(ManualJournalEntryDraftDto journalEntry) => $"/api/workstation/evidence/subjects/accounting-record/approval/ledger-book/{journalEntry.LedgerBookId:D}/{journalEntry.PeriodId}"; @@ -7363,6 +7586,153 @@ private sealed class StaticMarkPriceSource(MarkPriceQuote quote) : IMarkPriceSou } } + private sealed class MutableMarkPriceSource : IMarkPriceSource + { + private readonly Dictionary _quotes = new(StringComparer.OrdinalIgnoreCase); + + public void Set(string symbol, MarkPriceQuote quote) => _quotes[symbol] = quote; + + public Task GetMarkPriceAsync( + string symbol, + DateOnly asOf, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(_quotes.TryGetValue(symbol, out var quote) ? quote : null); + } + } + + private static DailyValuationPositionService CreateDailyValuationPositionService() + { + var registry = Substitute.For(); + registry.GetDefinition(Arg.Is(symbol => + string.Equals(symbol, "AAPL", StringComparison.OrdinalIgnoreCase))) + .Returns(new CanonicalSymbolDefinition + { + Canonical = "AAPL", + SecurityId = DailyValuationAaplSecurityId, + DisplayName = "Apple Inc.", + AssetClass = "equity", + Exchange = "NASDAQ", + Currency = "USD", + Aliases = ["AAPL"] + }); + registry.GetDefinition(Arg.Is(symbol => + string.Equals(symbol, "MSFT", StringComparison.OrdinalIgnoreCase))) + .Returns(new CanonicalSymbolDefinition + { + Canonical = "MSFT", + SecurityId = DailyValuationMsftSecurityId, + DisplayName = "Microsoft Corp.", + AssetClass = "equity", + Exchange = "NASDAQ", + Currency = "USD", + Aliases = ["MSFT"] + }); + return new DailyValuationPositionService( + snapshotStore: null, + registry, + new DailyValuationSecurityMasterQueryService()); + } + + private sealed class DailyValuationSecurityMasterQueryService + : Meridian.Contracts.SecurityMaster.ISecurityMasterQueryService + { + private static readonly JsonElement EmptyTerms = JsonDocument.Parse("{}").RootElement.Clone(); + + public Task GetByIdAsync(Guid securityId, CancellationToken ct = default) + => Task.FromResult( + securityId == DailyValuationAaplSecurityId + ? CreateDetail(DailyValuationAaplSecurityId, "AAPL", "Apple Inc.", new DateTimeOffset(1980, 12, 12, 0, 0, 0, TimeSpan.Zero)) + : securityId == DailyValuationMsftSecurityId + ? CreateDetail(DailyValuationMsftSecurityId, "MSFT", "Microsoft Corp.", new DateTimeOffset(1986, 3, 13, 0, 0, 0, TimeSpan.Zero)) + : null); + + public Task GetByIdAsOfAsync( + Guid securityId, + DateTimeOffset asOfUtc, + CancellationToken ct = default) + => GetByIdAsync(securityId, ct); + + public Task GetByIdentifierAsync( + SecurityIdentifierKind identifierKind, + string identifierValue, + string? provider, + CancellationToken ct = default, + DateTimeOffset? asOfUtc = null) + => Task.FromResult( + identifierKind != SecurityIdentifierKind.Ticker + ? null + : string.Equals(identifierValue, "AAPL", StringComparison.OrdinalIgnoreCase) + ? CreateDetail(DailyValuationAaplSecurityId, "AAPL", "Apple Inc.", new DateTimeOffset(1980, 12, 12, 0, 0, 0, TimeSpan.Zero)) + : string.Equals(identifierValue, "MSFT", StringComparison.OrdinalIgnoreCase) + ? CreateDetail(DailyValuationMsftSecurityId, "MSFT", "Microsoft Corp.", new DateTimeOffset(1986, 3, 13, 0, 0, 0, TimeSpan.Zero)) + : null); + + public Task> SearchAsync( + SecuritySearchRequest request, + CancellationToken ct = default) + => Task.FromResult>([]); + + public Task> GetHistoryAsync( + SecurityHistoryRequest request, + CancellationToken ct = default) + => Task.FromResult>([]); + + public Task GetEconomicDefinitionByIdAsync( + Guid securityId, + CancellationToken ct = default) + => Task.FromResult(null); + + public Task GetTradingParametersAsync( + Guid securityId, + DateTimeOffset asOf, + CancellationToken ct = default) + => Task.FromResult(null); + + public Task> GetCorporateActionsAsync( + Guid securityId, + CancellationToken ct = default) + => Task.FromResult>([]); + + public Task GetPreferredEquityTermsAsync( + Guid securityId, + CancellationToken ct = default) + => Task.FromResult(null); + + public Task GetConvertibleEquityTermsAsync( + Guid securityId, + CancellationToken ct = default) + => Task.FromResult(null); + + private static SecurityDetailDto CreateDetail( + Guid securityId, + string symbol, + string displayName, + DateTimeOffset validFrom) + => new( + securityId, + "Equity", + SecurityStatusDto.Active, + displayName, + "USD", + EmptyTerms, + EmptyTerms, + Identifiers: + [ + new SecurityIdentifierDto( + SecurityIdentifierKind.Ticker, + symbol, + IsPrimary: true, + ValidFrom: validFrom, + NormalizedValue: symbol) + ], + Aliases: [], + Version: 7, + EffectiveFrom: validFrom, + EffectiveTo: null); + } + private sealed class WritableManualJournalLedgerJournalStore( LedgerBookRecord book, LedgerAccountingPeriod period) : ILedgerJournalStore @@ -7418,6 +7788,13 @@ public void Seed(JournalEntry entry) AccountingPolicyVersion: book.AccountingPolicyVersion)); } + public WritableManualJournalLedgerJournalStore RestartFromRetainedRecords() + { + var restarted = new WritableManualJournalLedgerJournalStore(book, period); + restarted._records.AddRange(_records); + return restarted; + } + public Task AppendAsync(LedgerJournalEntryWrite entry, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); diff --git a/tests/Meridian.Tests/Ui/AutomatedJournalDraftIntakeServiceTests.cs b/tests/Meridian.Tests/Ui/AutomatedJournalDraftIntakeServiceTests.cs index 8c61a10bbc..57a1656f2b 100644 --- a/tests/Meridian.Tests/Ui/AutomatedJournalDraftIntakeServiceTests.cs +++ b/tests/Meridian.Tests/Ui/AutomatedJournalDraftIntakeServiceTests.cs @@ -67,6 +67,162 @@ public async Task IntakeAsync_SameEventTwice_SkipsDuplicateInsteadOfOverwriting( var skip = second.Skipped.Should().ContainSingle().Subject; skip.JournalEntryId.Should().Be(first.Created[0].JournalEntryId); skip.Reason.Should().Contain("already exists"); + skip.Disposition.Should().Be(AutomatedJournalDraftIntakeDisposition.ExistingDraftReady); + skip.IsReadyDuplicate.Should().BeTrue(); + } + + [Fact] + public async Task IntakeAsync_DuplicateNeedsFixRejectedAndReassessment_AreTypedAsUnready() + { + var fixture = await CreateFixtureAsync(seedChart: true); + var investigation = InvestigationAssessment(); + var firstRequest = BuildRequest(DividendDeclaredEvent()) with + { + EvidenceAssessments = new Dictionary + { + [DividendDeclaredEvent().IdempotencyKey!] = investigation + } + }; + var first = await fixture.Intake.IntakeAsync(firstRequest); + + var needsFix = await fixture.Intake.IntakeAsync(firstRequest); + var reassessment = await fixture.Intake.IntakeAsync(firstRequest with + { + EvidenceAssessments = new Dictionary + { + [DividendDeclaredEvent().IdempotencyKey!] = ReadyAssessment() + } + }); + await fixture.DraftStore.SaveAsync(first.Created.Single() with + { + Status = ManualJournalEntryStatusDto.Rejected, + Version = first.Created.Single().Version + 1 + }); + var rejected = await fixture.Intake.IntakeAsync(firstRequest); + + needsFix.Skipped.Should().ContainSingle().Which.Disposition.Should() + .Be(AutomatedJournalDraftIntakeDisposition.ExistingDraftNeedsFix); + needsFix.Skipped.Single().IsReadyDuplicate.Should().BeFalse(); + reassessment.Skipped.Should().ContainSingle().Which.Disposition.Should() + .Be(AutomatedJournalDraftIntakeDisposition.ExistingDraftReassessmentRequired); + reassessment.Skipped.Single().IsReadyDuplicate.Should().BeFalse(); + rejected.Skipped.Should().ContainSingle().Which.Disposition.Should() + .Be(AutomatedJournalDraftIntakeDisposition.ExistingDraftRejected); + rejected.Skipped.Single().IsReadyDuplicate.Should().BeFalse(); + } + + [Fact] + public async Task WorkbenchResave_CannotClearOrUpgradeAutomatedEvidenceAssessment() + { + var fixture = await CreateFixtureAsync(seedChart: true); + var retainedAssessment = InvestigationAssessment(); + var intake = await fixture.Intake.IntakeAsync(BuildRequest(DividendDeclaredEvent()) with + { + EvidenceAssessments = new Dictionary + { + [DividendDeclaredEvent().IdempotencyKey!] = retainedAssessment + } + }); + var retained = intake.Created.Should().ContainSingle().Subject; + + var resaved = await fixture.Workbench.SaveDraftAsync(new SaveManualJournalEntryDraftRequest( + retained with { AutomationEvidenceAssessment = ReadyAssessment(), ValidationIssues = [] }, + Actor: "controller", + LedgerBookId: BookId)); + + resaved.AutomationEvidenceAssessment.Should().Be(retainedAssessment); + resaved.Status.Should().Be(ManualJournalEntryStatusDto.NeedsFix); + resaved.ValidationIssues.Should().Contain(issue => + issue.Code == "manual-je.automation-investigation-required" && + issue.Severity == AccountingConfigurationValidationSeverityDto.Critical); + } + + [Fact] + public async Task IntakeAsync_DeterministicIdsIncludeFullAccountingAndTenantScope() + { + var fixture = await CreateFixtureAsync(seedChart: true); + var source = BuildRequest(DividendDeclaredEvent()) with + { + TenantId = "tenant-a", + CompanyId = "company-a" + }; + var requests = new[] + { + source, + source with { FundProfileId = "fund-beta" }, + source with { LedgerBookId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc") }, + source with { EntityId = "entity-beta" }, + source with { Currency = "EUR" }, + source with { TenantId = "tenant-b" }, + source with { CompanyId = "company-b" } + }; + + var ids = new List(); + foreach (var request in requests) + { + var result = await fixture.Intake.IntakeAsync(request); + ids.Add(result.Created.Should().ContainSingle().Subject.JournalEntryId); + } + + ids.Should().OnlyHaveUniqueItems( + "tenant, company, fund, book, entity, currency, and event identity all scope deterministic drafts"); + } + + [Fact] + public void SelectPendingCorrectionBatchIds_IncludesOnlySameRetainedBatchAndEntity() + { + var date = new DateOnly(2026, 7, 1); + var aaplSecurityId = Guid.Parse("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + var msftSecurityId = Guid.Parse("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); + var sameBatchAapl = PendingValuationDraft( + Guid.Parse("11111111-1111-4111-8111-111111111111"), + aaplSecurityId, + "AAPL", + "batch-a"); + var sameBatchMsft = PendingValuationDraft( + Guid.Parse("22222222-2222-4222-8222-222222222222"), + msftSecurityId, + "MSFT", + "batch-a"); + var otherBatch = PendingValuationDraft( + Guid.Parse("33333333-3333-4333-8333-333333333333"), + Guid.Parse("cccccccc-cccc-4ccc-8ccc-cccccccccccc"), + "GOOG", + "batch-b"); + var otherEntity = PendingValuationDraft( + Guid.Parse("44444444-4444-4444-8444-444444444444"), + Guid.Parse("dddddddd-dddd-4ddd-8ddd-dddddddddddd"), + "NVDA", + "batch-a") with { EntityId = "entity-beta" }; + var candidate = new AutomatedJournalDraft( + new AutomatedJournalEvent( + AutomatedJournalEventKind.FairValueMarkAdjustment, + "AAPL", + 10m, + AsOf, + SecurityId: aaplSecurityId, + EffectiveDate: date, + IdempotencyKey: "fair-value|corrected-aapl"), + "Correct AAPL close", + [ + (LedgerAccounts.Securities("AAPL"), 10m, 0m, null), + (LedgerAccounts.Cash, 0m, 10m, null) + ], + new JournalEntryMetadata( + Symbol: "AAPL", + SecurityId: aaplSecurityId, + EffectiveDate: date, + IdempotencyKey: "fair-value|corrected-aapl")); + + var ids = AutomatedJournalDraftIntakeService.SelectPendingCorrectionBatchIds( + [sameBatchAapl, sameBatchMsft, otherBatch, otherEntity], + sameBatchAapl, + candidate, + date, + "2026-07", + "entity-alpha"); + + ids.Should().BeEquivalentTo([sameBatchAapl.JournalEntryId, sameBatchMsft.JournalEntryId]); } [Fact] @@ -224,4 +380,64 @@ private static AutomatedJournalDraftIntakeRequest BuildRequest(params AutomatedJ AsOf, EffectiveDate: new DateOnly(2026, 07, 01), IdempotencyKey: "wht|AAPL|2026-07-01"); + + private static AutomatedJournalEvidenceAssessmentDto InvestigationAssessment() + => new( + "corporate-action-confidence", + 0.50m, + AutomatedJournalEvidenceQualityDto.Low, + RequiresInvestigation: true, + "Corporate-action evidence needs investigation.", + ["Pay date is missing."], + ["evidence://corporate-actions/ca-aapl-2026-07"]); + + private static AutomatedJournalEvidenceAssessmentDto ReadyAssessment() + => new( + "corporate-action-confidence", + 0.99m, + AutomatedJournalEvidenceQualityDto.High, + RequiresInvestigation: false, + "Corporate-action evidence is ready.", + [], + ["evidence://corporate-actions/ca-aapl-2026-07"]); + + private static ManualJournalEntryDraftDto PendingValuationDraft( + Guid journalEntryId, + Guid securityId, + string symbol, + string batchCorrelationId) + => new( + journalEntryId, + ManualJournalEntryStatusDto.Draft, + FundProfileId, + BookId, + AccountingBasisKindDto.Primary, + new DateOnly(2026, 7, 1), + "2026-07", + "entity-alpha", + FundNodeId: null, + Currency: "USD", + Memo: $"{symbol} daily valuation", + PreparedBy: "valuation-ops", + CreatedAtUtc: AsOf, + UpdatedAtUtc: AsOf, + Version: 1, + Lines: + [ + new ManualJournalEntryLineDto( + "line-1", + AccountingTemplateLineSideDto.Debit, + 10m, + "USD", + $"Assets:Securities:{symbol}", + SecurityId: securityId, + SecurityDisplayName: symbol, + LedgerAccountSymbol: symbol) + ], + EvidenceLinks: [], + ValidationIssues: [], + TreasuryContext: new TreasuryLedgerContextDto( + EffectiveDate: new DateOnly(2026, 7, 1), + IdempotencyKey: $"fair-value|{symbol}|2026-07-01", + BatchCorrelationId: batchCorrelationId)); } diff --git a/tests/Meridian.Tests/Ui/AutomatedJournalEventProducerTests.cs b/tests/Meridian.Tests/Ui/AutomatedJournalEventProducerTests.cs index 4cdd047adc..57f1b99595 100644 --- a/tests/Meridian.Tests/Ui/AutomatedJournalEventProducerTests.cs +++ b/tests/Meridian.Tests/Ui/AutomatedJournalEventProducerTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using FluentAssertions; +using Meridian.Contracts.FundStructure; using Meridian.Contracts.Ledger; using Meridian.Contracts.SecurityMaster; using Meridian.Contracts.Workstation; @@ -125,6 +126,7 @@ public async Task DividendProducer_EmitsEffectiveInWindowDividends() var production = await producer.ProduceAsync(new CorporateActionDividendRequest( [new DividendAccrualPosition("AAPL", Quantity: 400m)], + Currency: "USD", WindowStart: new DateOnly(2026, 07, 01), WindowEnd: new DateOnly(2026, 07, 31), AsOf)); @@ -155,6 +157,7 @@ public async Task DividendProducer_UnresolvedTicker_SurfacesSkipAndContinues() new DividendAccrualPosition("UNKNOWN", 10m), new DividendAccrualPosition("AAPL", 100m) ], + "USD", new DateOnly(2026, 07, 01), new DateOnly(2026, 07, 31), AsOf)); @@ -174,6 +177,7 @@ public async Task DividendProducer_WithholdingRate_AccruesPairedWithholdingTax() var production = await producer.ProduceAsync(new CorporateActionDividendRequest( [new DividendAccrualPosition("AAPL", Quantity: 400m)], + "USD", new DateOnly(2026, 07, 01), new DateOnly(2026, 07, 31), AsOf, @@ -206,6 +210,7 @@ public async Task DividendProducer_ZeroWithholdingRate_ProducesNoWithholdingEven var production = await producer.ProduceAsync(new CorporateActionDividendRequest( [new DividendAccrualPosition("AAPL", 400m)], + "USD", new DateOnly(2026, 07, 01), new DateOnly(2026, 07, 31), AsOf)); @@ -213,6 +218,29 @@ [new DividendAccrualPosition("AAPL", 400m)], production.Events.Should().OnlyContain(e => e.Kind == AutomatedJournalEventKind.DividendDeclared); } + [Fact] + public async Task DividendProducer_MismatchedCorporateActionCurrency_IsSkippedExactly() + { + var dividend = DividendAction(AaplSecurityId, new DateOnly(2026, 07, 02), 0.26m) with + { + Currency = "EUR" + }; + var securityMaster = new FakeSecurityMasterQueryService( + tickerToSecurityId: new Dictionary { ["AAPL"] = AaplSecurityId }, + corporateActions: [dividend]); + var producer = new CorporateActionDividendEventProducer(securityMaster); + + var production = await producer.ProduceAsync(new CorporateActionDividendRequest( + [new DividendAccrualPosition("AAPL", 400m)], + "USD", + new DateOnly(2026, 07, 01), + new DateOnly(2026, 07, 31), + AsOf)); + + production.Events.Should().BeEmpty(); + production.Skipped.Should().ContainSingle().Which.Reason.Should().Contain("currency", StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task DividendProducer_InvalidWithholdingRate_Throws() { @@ -223,6 +251,7 @@ public async Task DividendProducer_InvalidWithholdingRate_Throws() var act = () => producer.ProduceAsync(new CorporateActionDividendRequest( [new DividendAccrualPosition("AAPL", 400m)], + "USD", new DateOnly(2026, 07, 01), new DateOnly(2026, 07, 31), AsOf, @@ -252,7 +281,9 @@ public async Task Runner_FeeAccrual_LandsDraftsInWorkbenchQueue() ManagementFeeRate: 0.02m, PerformanceFeeRate: 0.20m, LedgerBookId: BookId, - EntityId: "entity-alpha")); + EntityId: "entity-alpha", + EvidenceRetainedAtUtc: AsOf, + CapitalAccountReconciliation: FeeReconciliation())); result.ProducerSkips.Should().BeEmpty(); result.Intake.Created.Should().HaveCount(2); @@ -262,6 +293,47 @@ public async Task Runner_FeeAccrual_LandsDraftsInWorkbenchQueue() workbench.Drafts.Should().HaveCount(2, "fee accrual drafts must be visible in the close cockpit's queue"); } + [Fact] + public async Task Runner_FeeAccrual_WithoutReviewedCapitalAccountEvidence_FailsClosed() + { + var fixture = CreateIntakeFixture(); + var runner = new AutomatedJournalIntakeRunner(fixture.Intake, new FeeScheduleAccrualEventProducer()); + + var result = await runner.RunFeeAccrualIntakeAsync(FeeIntakeRequest()); + + result.Readiness.Should().Be(AutomatedJournalIntakeReadiness.Blocked); + result.ReadinessBlockers.Should().Contain(item => + item.Contains("capital-account reconciliation", StringComparison.OrdinalIgnoreCase)); + result.Intake.Created.Should().BeEmpty(); + (await fixture.Workbench.GetWorkbenchAsync("fund-alpha", BookId)).Drafts.Should().BeEmpty(); + } + + [Fact] + public async Task Runner_FeeAccrual_ClientCannotLowerServerConfidenceOrVarianceBounds() + { + var fixture = CreateIntakeFixture(); + var runner = new AutomatedJournalIntakeRunner(fixture.Intake, new FeeScheduleAccrualEventProducer()); + + var lowConfidence = await runner.RunFeeAccrualIntakeAsync(FeeIntakeRequest() with + { + CapitalAccountReconciliation = FeeReconciliation(confidence: 0.80m), + MinimumCapitalAccountConfidence = 0m + }); + var looseTolerance = await runner.RunFeeAccrualIntakeAsync(FeeIntakeRequest() with + { + CapitalAccountReconciliation = FeeReconciliation( + maximumVarianceTolerance: 100m, + capitalAccountOpeningBalance: 999_999.98m) + }); + + lowConfidence.Readiness.Should().Be(AutomatedJournalIntakeReadiness.NeedsInvestigation); + lowConfidence.ReadinessBlockers.Should().Contain(item => item.Contains("90%", StringComparison.Ordinal)); + looseTolerance.Readiness.Should().Be(AutomatedJournalIntakeReadiness.NeedsInvestigation); + looseTolerance.ReadinessBlockers.Should().Contain(item => + item.Contains("server-governed tolerance 0.01", StringComparison.OrdinalIgnoreCase)); + (await fixture.Workbench.GetWorkbenchAsync("fund-alpha", BookId)).Drafts.Should().BeEmpty(); + } + [Fact] public async Task Runner_DividendIntake_WithoutSecurityMaster_FailsLoudly() { @@ -312,12 +384,16 @@ [new DividendAccrualPosition("AAPL", 400m)], // ------------------------------------------------------------------------- private static readonly Guid BookId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + private static readonly Guid FundAccountId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); private sealed record IntakeFixture( AutomatedJournalDraftIntakeService Intake, - IManualJournalEntryWorkbenchService Workbench); + IManualJournalEntryWorkbenchService Workbench, + IManualJournalEntryDraftStore DraftStore); - private static IntakeFixture CreateIntakeFixture() + private static IntakeFixture CreateIntakeFixture( + ILedgerJournalStore? journalStore = null, + IManualJournalEntryDraftStore? retainedDraftStore = null) { var configurationStore = new InMemoryAccountingConfigurationStore(); configurationStore.SaveAsync(new AccountingConfigurationWorkspaceDto( @@ -346,14 +422,59 @@ private static IntakeFixture CreateIntakeFixture() var configurationService = new AccountingConfigurationService( configurationStore, new InMemoryAccountingActionAuditStore()); - var draftStore = new InMemoryManualJournalEntryDraftStore(); + var draftStore = retainedDraftStore ?? new InMemoryManualJournalEntryDraftStore(); var workbench = new ManualJournalEntryWorkbenchService( draftStore, configurationService, - new InMemoryAccountingActionAuditStore()); + new InMemoryAccountingActionAuditStore(), + journalStore: journalStore); return new IntakeFixture( new AutomatedJournalDraftIntakeService(workbench, draftStore, configurationService), - workbench); + workbench, + draftStore); + } + + private sealed class FailOnceCorrectionDraftStore : IManualJournalEntryDraftStore + { + private readonly InMemoryManualJournalEntryDraftStore _inner = new(); + + public bool FailNextBatch { get; set; } + + public Task> ListFundProfileIdsAsync(CancellationToken ct = default) + => _inner.ListFundProfileIdsAsync(ct); + + public Task> ListAsync( + string fundProfileId, + Guid? ledgerBookId = null, + CancellationToken ct = default, + string? tenantId = null, + string? companyId = null) + => _inner.ListAsync(fundProfileId, ledgerBookId, ct, tenantId, companyId); + + public Task GetAsync( + string fundProfileId, + Guid journalEntryId, + CancellationToken ct = default, + string? tenantId = null, + string? companyId = null) + => _inner.GetAsync(fundProfileId, journalEntryId, ct, tenantId, companyId); + + public Task SaveAsync(ManualJournalEntryDraftDto draft, CancellationToken ct = default) + => _inner.SaveAsync(draft, ct); + + public Task SaveBatchAsync( + IReadOnlyList drafts, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + if (FailNextBatch) + { + FailNextBatch = false; + throw new IOException("Injected closing-reversal batch failure."); + } + + return _inner.SaveBatchAsync(drafts, ct); + } } // ------------------------------------------------------------------------- @@ -402,6 +523,15 @@ private static ILedgerBookService LedgerBookServiceWithClosedPeriod( Version: 1); var service = Substitute.For(); + service.GetBookAsync(ledgerBookId, Arg.Any()).Returns(new LedgerBookDto( + ledgerBookId, + "fund-alpha", + FundAccountId, + FundStructureNodeKindDto.Account, + "Fund Alpha primary ledger", + "USD", + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow)); service.GetPeriodSummaryAsync(ClosedPeriodId, Arg.Any()).Returns(summary); service.ListPeriodsAsync(Arg.Any(), Arg.Any()) .Returns(new[] { period }); @@ -415,6 +545,29 @@ private static LedgerPeriodTrialBalanceLineDto TrialBalanceLine( DebitTotal: debits, CreditTotal: credits, Balance: balance, EntryCount: 1, Dimensions: dimensions); + private static ILedgerBookService LedgerBookServiceWithHardClosedPeriod( + params LedgerPeriodTrialBalanceLineDto[] trialBalance) + { + var service = LedgerBookServiceWithClosedPeriod(trialBalance); + service.ListPeriodsAsync(Arg.Any(), Arg.Any()) + .Returns( + [ + new LedgerPeriodDto( + ClosedPeriodId, + BookId, + 2026, + 6, + "2026-06", + new DateOnly(2026, 6, 1), + PeriodEndDate, + LedgerPeriodStatusDto.HardClosed, + new DateTimeOffset(2026, 6, 1, 0, 0, 0, TimeSpan.Zero), + AsOf, + 2) + ]); + return service; + } + [Fact] public async Task Runner_PeriodCloseIntake_PreservesAccountSymbolAndFinancialAccountScope() { @@ -605,6 +758,33 @@ public async Task Runner_PeriodCloseIntake_NoTemporaryBalances_ReturnsEmptyIntak result.Intake.Skipped.Should().BeEmpty(); } + [Fact] + public async Task Runner_PeriodCloseIntake_HardClosedPeriod_AllowsPreviewButRejectsDraftMutation() + { + var fixture = CreateIntakeFixture(); + var bookService = LedgerBookServiceWithHardClosedPeriod( + TrialBalanceLine("Dividend Income", "Revenue", 0m, 300m, 300m)); + var runner = new AutomatedJournalIntakeRunner( + fixture.Intake, + new FeeScheduleAccrualEventProducer(), + ledgerBookService: bookService); + var request = new RunPeriodCloseDraftIntakeRequest( + "fund-alpha", + "USD", + "fund-controller", + ClosedPeriodId, + BookId); + + var preview = await runner.PreviewPeriodCloseAsync(request); + var mutate = () => runner.RunPeriodCloseIntakeAsync(request); + + preview.Draft.Should().NotBeNull("hard-closed periods remain available for read-only close review"); + await mutate.Should().ThrowAsync() + .WithMessage("*must be soft-closed*current status is HardClosed*"); + var workbench = await fixture.Workbench.GetWorkbenchAsync("fund-alpha", BookId); + workbench.Drafts.Should().BeEmpty("a hard-closed period must never acquire a new closing draft"); + } + [Fact] public async Task ClosePostingBridge_FinalizeHardClose_RechecksGateAndLeavesNonReadyPeriodSoftClosed() { @@ -619,7 +799,7 @@ public async Task ClosePostingBridge_FinalizeHardClose_RechecksGateAndLeavesNonR (IManualJournalEntryLifecycleService)fixture.Workbench, bookService); var context = new AccountingClosePostingContext( - Guid.NewGuid(), "fund-alpha", BookId, "2026-06", "USD"); + Guid.NewGuid(), FundAccountId, BookId, "2026-06", "USD"); var command = new AccountingClosePostingCommand( "fund-controller", "Finalize the retained close package.", @@ -640,6 +820,265 @@ await bookService.DidNotReceive().ClosePeriodAsync( retained.Should().ContainSingle().Which.Status.Should().Be(LedgerPeriodStatusDto.SoftClosed); } + [Fact] + public async Task ClosePostingBridge_ReopenRetry_ReusesRetainedReversalAndRejectsDifferentCorrelation() + { + var journalPeriod = new LedgerAccountingPeriod( + ClosedPeriodId, + BookId, + 2026, + 6, + "2026-06", + new DateOnly(2026, 6, 1), + PeriodEndDate, + "SoftClosed", + new DateTimeOffset(2026, 6, 1, 0, 0, 0, TimeSpan.Zero), + null, + 1); + var journalBook = new LedgerBookRecord( + BookId, + "fund-alpha", + FundAccountId, + FundStructureNodeKindDto.Account, + "Fund Alpha primary ledger", + "USD", + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow); + var journalStore = Substitute.For(); + journalStore.GetLedgerBookAsync(BookId, Arg.Any()) + .Returns(journalBook); + journalStore.ListLedgerBooksAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new[] { journalBook }); + journalStore.GetPeriodAsync(ClosedPeriodId, Arg.Any()) + .Returns(_ => journalPeriod); + journalStore.ListPeriodsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(_ => new[] { journalPeriod }); + journalStore.GetByPeriodAsync(ClosedPeriodId, Arg.Any()) + .Returns(Array.Empty()); + var faultingDraftStore = new FailOnceCorrectionDraftStore(); + var fixture = CreateIntakeFixture(journalStore, faultingDraftStore); + var bookService = LedgerBookServiceWithClosedPeriod( + TrialBalanceLine("Dividend Income", "Revenue", 0m, 300m, 300m)); + var runner = new AutomatedJournalIntakeRunner( + fixture.Intake, new FeeScheduleAccrualEventProducer(), ledgerBookService: bookService); + var intake = await runner.RunPeriodCloseIntakeAsync(new RunPeriodCloseDraftIntakeRequest( + "fund-alpha", "USD", "close-preparer", ClosedPeriodId, BookId)); + var created = intake.Intake.Created.Should().ContainSingle().Subject; + var postedClosingBatch = created with + { + Status = ManualJournalEntryStatusDto.Posted, + UpdatedAtUtc = AsOf, + Version = created.Version + 1, + PostedAtUtc = AsOf, + PostedBy = "fund-controller" + }; + await fixture.DraftStore.SaveAsync(postedClosingBatch); + + var currentPeriod = (await bookService.ListPeriodsAsync( + new LedgerPeriodQuery(LedgerBookId: BookId))).Single() with + { + Status = LedgerPeriodStatusDto.HardClosed, + ClosedAt = AsOf + }; + journalPeriod = journalPeriod with + { + Status = "HardClosed", + ClosedAt = AsOf, + Version = currentPeriod.Version + }; + bookService.ListPeriodsAsync(Arg.Any(), Arg.Any()) + .Returns(_ => new[] { currentPeriod }); + bookService.ReopenPeriodAsync( + ClosedPeriodId, + Arg.Any(), + Arg.Any()) + .Returns(callInfo => + { + var request = callInfo.Arg(); + var priorStatus = currentPeriod.Status.ToString(); + currentPeriod = currentPeriod with + { + Status = LedgerPeriodStatusDto.SoftClosed, + ClosedAt = null, + Version = currentPeriod.Version + 1 + }; + journalPeriod = journalPeriod with + { + Status = "SoftClosed", + ClosedAt = null, + Version = currentPeriod.Version + }; + return new LedgerPeriodReopenResultDto( + currentPeriod, + priorStatus, + request.ReopenedBy, + AsOf, + request.ApprovalReference, + request.EvidenceLinks); + }); + var bridge = new AccountingClosePostingWorkbenchBridge( + runner, + fixture.Workbench, + (IManualJournalEntryLifecycleService)fixture.Workbench, + bookService); + var context = new AccountingClosePostingContext( + Guid.NewGuid(), FundAccountId, BookId, ClosedPeriodId.ToString("D"), "USD"); + const string approvalReference = "reopen-approval-42"; + var evidence = + $"/api/workstation/evidence/subjects/accounting-record/reversal/ledger-book/{BookId:D}/{ClosedPeriodId:D}/{approvalReference}"; + var supportEvidence = + $"/api/workstation/evidence/subjects/accounting-record/reversal/ledger-book/{BookId:D}/{ClosedPeriodId:D}/support-package"; + var command = new AccountingClosePostingCommand( + "fund-controller", + "Reopen the period for a governed restatement.", + [evidence, supportEvidence], + OperationsActionOriginDto.HumanOperator, + Role: "Fund Controller", + ApprovalReference: approvalReference, + CorrelationId: "reopen-correlation-42"); + + faultingDraftStore.FailNextBatch = true; + var interrupted = () => bridge.ReopenAndQueueClosingReversalsAsync(context, command); + + await interrupted.Should().ThrowAsync() + .WithMessage("*was reopened under a retained governed intent*Retry the exact reopen command*"); + currentPeriod.Status.Should().Be(LedgerPeriodStatusDto.SoftClosed); + var afterInterruptedAttempt = await fixture.Workbench.GetWorkbenchAsync("fund-alpha", BookId); + afterInterruptedAttempt.Drafts.Should().ContainSingle(draft => + draft.JournalEntryId == postedClosingBatch.JournalEntryId && + draft.Status == ManualJournalEntryStatusDto.Posted); + afterInterruptedAttempt.Drafts.Should().NotContain(draft => + draft.ReversalOfJournalEntryId == postedClosingBatch.JournalEntryId); + + var first = await bridge.ReopenAndQueueClosingReversalsAsync(context, command); + var retry = await bridge.ReopenAndQueueClosingReversalsAsync(context, command); + var differentCorrelation = () => bridge.ReopenAndQueueClosingReversalsAsync( + context, + command with { CorrelationId = "reopen-correlation-different" }); + var reducedEvidenceReplay = () => bridge.ReopenAndQueueClosingReversalsAsync( + context, + command with { EvidenceLinks = [evidence] }); + + first.State.Should().Be(ClosePostingGateStateDto.ReversalQueued); + retry.ReversalDraftJournalEntryIds.Should().Equal(first.ReversalDraftJournalEntryIds); + first.ClosingBatchJournalEntryIds.Should().ContainSingle() + .Which.Should().Be(postedClosingBatch.JournalEntryId); + first.ReversalDraftJournalEntryIds.Should().ContainSingle(); + await reducedEvidenceReplay.Should().ThrowAsync() + .WithMessage("*do not match this reopen actor, correlation, reason, and evidence*"); + await differentCorrelation.Should().ThrowAsync() + .WithMessage("*do not match this reopen actor, correlation, reason, and evidence*"); + await bookService.Received(1).ReopenPeriodAsync( + ClosedPeriodId, + Arg.Any(), + Arg.Any()); + var retained = await fixture.Workbench.GetWorkbenchAsync("fund-alpha", BookId); + retained.Drafts.Count(draft => + draft.ReversalOfJournalEntryId == postedClosingBatch.JournalEntryId) + .Should().Be(1, "retries and rejected correlations must reuse the retained reversal draft"); + } + + [Fact] + public async Task ClosePostingBridge_ZeroBalanceReopen_RetainsExactReceiptAcrossRetry() + { + var fixture = CreateIntakeFixture(); + var bookService = LedgerBookServiceWithHardClosedPeriod( + TrialBalanceLine("Cash", "Asset", 500m, 0m, 500m)); + var currentPeriod = (await bookService.ListPeriodsAsync( + new LedgerPeriodQuery(LedgerBookId: BookId))).Single(); + bookService.ListPeriodsAsync(Arg.Any(), Arg.Any()) + .Returns(_ => new[] { currentPeriod }); + bookService.ReopenPeriodAsync( + ClosedPeriodId, + Arg.Any(), + Arg.Any()) + .Returns(callInfo => + { + var request = callInfo.Arg(); + currentPeriod = currentPeriod with + { + Status = LedgerPeriodStatusDto.SoftClosed, + ClosedAt = null, + Version = currentPeriod.Version + 1 + }; + return new LedgerPeriodReopenResultDto( + currentPeriod, + LedgerPeriodStatusDto.HardClosed.ToString(), + request.ReopenedBy, + AsOf, + request.ApprovalReference, + request.EvidenceLinks); + }); + var runner = new AutomatedJournalIntakeRunner( + fixture.Intake, + new FeeScheduleAccrualEventProducer(), + ledgerBookService: bookService); + var bridge = new AccountingClosePostingWorkbenchBridge( + runner, + fixture.Workbench, + (IManualJournalEntryLifecycleService)fixture.Workbench, + bookService); + var workflowId = Guid.NewGuid(); + var context = new AccountingClosePostingContext( + workflowId, + FundAccountId, + BookId, + ClosedPeriodId.ToString("D"), + "USD"); + const string approvalReference = "zero-balance-reopen-approval"; + var evidence = + $"/api/workstation/evidence/subjects/accounting-record/reversal/ledger-book/{BookId:D}/{ClosedPeriodId:D}/{approvalReference}"; + var command = new AccountingClosePostingCommand( + "fund-controller", + "Reopen the zero-balance period for a governed restatement.", + [evidence], + OperationsActionOriginDto.HumanOperator, + Role: "Fund Controller", + ApprovalReference: approvalReference, + CorrelationId: "zero-balance-reopen-correlation"); + + var first = await bridge.ReopenAndQueueClosingReversalsAsync(context, command); + var retry = await bridge.ReopenAndQueueClosingReversalsAsync(context, command); + var changedReason = () => bridge.ReopenAndQueueClosingReversalsAsync( + context, + command with { Reason = "Changed reopen reason." }); + var changedEvidence = () => bridge.ReopenAndQueueClosingReversalsAsync( + context, + command with + { + EvidenceLinks = + [ + evidence, + $"/api/workstation/evidence/subjects/accounting-record/reversal/ledger-book/{BookId:D}/{ClosedPeriodId:D}/changed" + ] + }); + + first.State.Should().Be(ClosePostingGateStateDto.NotRequired); + retry.State.Should().Be(ClosePostingGateStateDto.NotRequired); + await changedReason.Should().ThrowAsync() + .WithMessage("*retained governed reopen intent does not match*"); + await changedEvidence.Should().ThrowAsync() + .WithMessage("*retained governed reopen intent does not match*"); + await bookService.Received(1).ReopenPeriodAsync( + ClosedPeriodId, + Arg.Any(), + Arg.Any()); + var retained = await fixture.Workbench.GetWorkbenchAsync("fund-alpha", BookId); + retained.AuditTrail.Should().ContainSingle(item => + item.Action.StartsWith($"GovernedLedgerPeriodReopen:{ClosedPeriodId:D}:", StringComparison.Ordinal) && + item.CorrelationId == command.CorrelationId && + !string.IsNullOrWhiteSpace(item.AfterHash)); + } + private static ChartOfAccountsNodeDto Node(string path, string name, string type) => new(NodeId: path, Path: path, AccountName: name, AccountType: type); @@ -660,6 +1099,51 @@ private static CorporateActionDto DividendAction(Guid securityId, DateOnly exDat SubscriptionPricePerShare: null, RightsPerShare: null); + private static RunFeeAccrualDraftIntakeRequest FeeIntakeRequest() + => new( + FundProfileId: "fund-alpha", + Currency: "USD", + Actor: "automated-journal", + PeriodId: "2026-Q2", + BeginningNav: 1_000_000m, + EndingNavBeforeFees: 1_100_000m, + HighWaterMark: 1_050_000m, + ManagementFeeRate: 0.02m, + PerformanceFeeRate: 0.20m, + LedgerBookId: BookId, + EntityId: "entity-alpha", + EvidenceRetainedAtUtc: AsOf); + + private static AutomatedJournalCapitalAccountReconciliationDto FeeReconciliation( + decimal confidence = 0.98m, + decimal maximumVarianceTolerance = 0m, + decimal capitalAccountOpeningBalance = 1_000_000m) + => new( + ReconciliationId: "capital-tie-out-2026-q2", + PeriodId: "2026-Q2", + Currency: "USD", + ReconciledBeginningNav: 1_000_000m, + ReconciledEndingNavBeforeFees: 1_100_000m, + ReconciledHighWaterMark: 1_050_000m, + CapitalAccountOpeningBalance: capitalAccountOpeningBalance, + CapitalAccountEndingBalanceBeforeFees: 1_100_000m, + CapitalAccountHighWaterMark: 1_050_000m, + MaximumVarianceTolerance: maximumVarianceTolerance, + ConfidenceScore: confidence, + IsReconciled: true, + SourceVersion: "capital-ledger:v42", + ReviewedBy: "fund-controller", + ReviewedAtUtc: AsOf.AddMinutes(-5), + EvidenceLinks: + [ + new OperationsEvidenceLinkDto( + "capital-tie-out-2026-q2", + "Reviewed capital-account reconciliation", + "evidence://capital-accounts/fund-alpha/2026-Q2/v42", + "capital-account-subledger", + AsOf.AddMinutes(-5)) + ]); + private sealed class FakeSecurityMasterQueryService : ISecurityMasterQueryService { private readonly IReadOnlyDictionary _tickerToSecurityId; diff --git a/tests/Meridian.Tests/Ui/AutomatedJournalScheduleTests.cs b/tests/Meridian.Tests/Ui/AutomatedJournalScheduleTests.cs index 34857b3dd5..067410e65f 100644 --- a/tests/Meridian.Tests/Ui/AutomatedJournalScheduleTests.cs +++ b/tests/Meridian.Tests/Ui/AutomatedJournalScheduleTests.cs @@ -4,6 +4,7 @@ using Meridian.Contracts.SecurityMaster; using Meridian.Contracts.Workstation; using Meridian.Ui.Shared.Services; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -26,8 +27,11 @@ public async Task MonthlyFeeSchedule_RunsAtConfiguredDueTime_AndDoesNotRunTwice( saved.ScheduledForUtc.Should().Be(DueAt); (await worker.RunDueAsync(DueAt.AddTicks(-1))).Runs.Should().BeEmpty(); + using var services = new ServiceCollection() + .AddSingleton(worker) + .BuildServiceProvider(); var hosted = new AutomatedJournalSchedulerHostedService( - worker, + services, new FixedTimeProvider(DueAt), NullLogger.Instance); var due = await hosted.RunOnceAsync(); @@ -35,10 +39,18 @@ public async Task MonthlyFeeSchedule_RunsAtConfiguredDueTime_AndDoesNotRunTwice( var run = due.Runs.Should().ContainSingle().Subject; run.State.Should().Be(AutomatedJournalScheduleStateDto.DraftReady); run.JournalEntryIds.Should().HaveCount(2); + run.NextPeriodId.Should().Be("2026-08"); + run.NextScheduledForUtc.Should().Be(new DateTimeOffset(2026, 9, 1, 9, 0, 0, TimeSpan.Zero)); (await worker.RunDueAsync(DueAt.AddMinutes(1))).Runs.Should().BeEmpty(); var persisted = await store.GetAsync("fees-2026-07"); persisted!.RunHistory.Should().ContainSingle(); - persisted.State.Should().Be(AutomatedJournalScheduleStateDto.DraftReady); + persisted.State.Should().Be(AutomatedJournalScheduleStateDto.Scheduled); + persisted.PeriodId.Should().Be("2026-08"); + persisted.PeriodStart.Should().Be(new DateOnly(2026, 8, 1)); + persisted.PeriodEnd.Should().Be(new DateOnly(2026, 8, 31)); + persisted.ScheduledForUtc.Should().Be(new DateTimeOffset(2026, 9, 1, 9, 0, 0, TimeSpan.Zero)); + persisted.CapitalAccountReconciliation.Should().BeNull("each recurring fee cycle requires a new reviewed tie-out"); + persisted.RunHistory.Single().State.Should().Be(AutomatedJournalScheduleStateDto.DraftReady); var workbench = await fixture.Workbench.GetWorkbenchAsync("fund-alpha", BookId); workbench.Drafts.Should().HaveCount(2); workbench.Drafts.Should().OnlyContain(static draft => @@ -75,6 +87,142 @@ public async Task ScheduleDueTime_UsesConfiguredLocalTimeZone() saved.ScheduledForUtc.Should().Be(new DateTimeOffset(2026, 8, 1, 16, 0, 0, TimeSpan.Zero)); } + [Fact] + public async Task FeeSchedule_WithoutReviewedCapitalAccountEvidence_BlocksBeforeDraftCreation() + { + var fixture = CreateFixture(); + var store = new InMemoryAutomatedJournalScheduleStore(); + await store.SaveAsync(FeeSchedule("fees-missing-capital-evidence") with + { + CapitalAccountReconciliation = null + }); + + var result = await CreateWorker(store, fixture.Runner).RunDueAsync(DueAt); + + var run = result.Runs.Should().ContainSingle().Subject; + run.State.Should().Be(AutomatedJournalScheduleStateDto.Blocked); + run.Blockers.Should().Contain(item => item.Contains("capital-account reconciliation", StringComparison.OrdinalIgnoreCase)); + (await fixture.Workbench.GetWorkbenchAsync("fund-alpha", BookId)).Drafts.Should().BeEmpty(); + } + + [Fact] + public async Task FeeSchedule_LowConfidenceCapitalAccountEvidence_NeedsInvestigationAndCanBeRearmed() + { + var fixture = CreateFixture(); + var store = new InMemoryAutomatedJournalScheduleStore(); + await store.SaveAsync(FeeSchedule("fees-low-confidence") with + { + CapitalAccountReconciliation = Reconciliation(confidence: 0.60m) + }); + var worker = CreateWorker(store, fixture.Runner); + + var first = await worker.RunDueAsync(DueAt); + + first.Runs.Should().ContainSingle().Which.State.Should().Be(AutomatedJournalScheduleStateDto.NeedsInvestigation); + (await fixture.Workbench.GetWorkbenchAsync("fund-alpha", BookId)).Drafts.Should().BeEmpty(); + var retained = (await store.GetAsync("fees-low-confidence"))!; + await store.SaveAsync(retained with + { + CapitalAccountReconciliation = Reconciliation(), + State = AutomatedJournalScheduleStateDto.Scheduled, + LastScheduledForUtc = null, + Blockers = [], + EvidenceLinks = [] + }); + + var retry = await worker.RunDueAsync(DueAt.AddMinutes(1)); + + retry.Runs.Should().ContainSingle().Which.State.Should().Be(AutomatedJournalScheduleStateDto.DraftReady); + var advanced = (await store.GetAsync("fees-low-confidence"))!; + advanced.PeriodId.Should().Be("2026-08"); + advanced.RunHistory.Should().ContainSingle(history => + history.State == AutomatedJournalScheduleStateDto.DraftReady && + history.JournalEntryIds.Count == 2); + } + + [Fact] + public async Task RunDueForScope_ExecutesOnlyExactTenantAndCompany() + { + var fixture = CreateFixture(); + var store = new InMemoryAutomatedJournalScheduleStore(); + await store.SaveAsync(FeeSchedule("fees-tenant-a") with { TenantId = "tenant-a", CompanyId = "company-a" }); + await store.SaveAsync(FeeSchedule("fees-tenant-b") with { TenantId = "tenant-b", CompanyId = "company-b" }); + var worker = CreateWorker(store, fixture.Runner); + + var result = await worker.RunDueForScopeAsync(DueAt, "tenant-a", "company-a"); + + result.Runs.Should().ContainSingle().Which.ScheduleId.Should().Be("fees-tenant-a"); + (await store.GetAsync("fees-tenant-a"))!.PeriodId.Should().Be("2026-08"); + (await store.GetAsync("fees-tenant-b"))!.State.Should().Be(AutomatedJournalScheduleStateDto.Scheduled); + } + + [Fact] + public async Task ScheduleStore_RejectsIdentityTakeover_ButAllowsNewHumanConfigurator() + { + var store = new InMemoryAutomatedJournalScheduleStore(); + var original = await store.SaveAsync(FeeSchedule("fees-owned") with + { + TenantId = "tenant-a", + CompanyId = "company-a", + Actor = "creator-a", + CreatedBy = "creator-a" + }); + + var reconfigured = await store.SaveAsync(original with + { + Actor = "controller-b", + LastConfiguredBy = "controller-b" + }); + reconfigured.Actor.Should().Be("controller-b"); + reconfigured.CreatedBy.Should().Be("creator-a"); + + var takeover = () => store.SaveAsync(original with + { + TenantId = "tenant-b", + CompanyId = "company-b", + Actor = "attacker", + CreatedBy = "attacker" + }); + await takeover.Should().ThrowAsync() + .WithMessage("*different immutable identity scope*"); + } + + [Fact] + public async Task ScheduleStore_RejectsStaleVersionWithOptimisticConcurrency() + { + var store = new InMemoryAutomatedJournalScheduleStore(); + var original = await store.SaveAsync(FeeSchedule("fees-cas")); + var current = await store.SaveAsync(original with { LastConfiguredBy = "controller-a" }); + + var staleWrite = () => store.SaveAsync(original with { LastConfiguredBy = "controller-b" }); + + current.Version.Should().Be(original.Version + 1); + await staleWrite.Should().ThrowAsync() + .WithMessage("*version*stale*"); + } + + [Fact] + public async Task CompletedRunningClaim_IsNotResumed() + { + var fixture = CreateFixture(); + var store = new InMemoryAutomatedJournalScheduleStore(); + var original = await store.SaveAsync(FeeSchedule("fees-completed-claim")); + var worker = CreateWorker(store, fixture.Runner); + await worker.RunDueAsync(DueAt); + var completed = (await store.GetAsync(original.ScheduleId))!; + await store.SaveAsync(original with + { + Version = completed.Version, + State = AutomatedJournalScheduleStateDto.Running, + LastScheduledForUtc = DueAt, + RunHistory = completed.RunHistory + }); + + var resumed = await worker.RunDueAsync(DueAt.AddMinutes(5)); + + resumed.Runs.Should().BeEmpty("only a durable Running history row without completion may be resumed"); + } + [Fact] public async Task PersistedRunningClaim_RestartsWithSameRunKey_AndDeduplicatesDraftsAndHistory() { @@ -82,9 +230,9 @@ public async Task PersistedRunningClaim_RestartsWithSameRunKey_AndDeduplicatesDr var snapshotPath = Path.Combine(directory, "monthly-schedules.json"); try { - var fixture = CreateFixture(); + var fixture = await CreateFileFixtureAsync(directory, initializeConfiguration: true); var firstStore = new FileAutomatedJournalScheduleStore(snapshotPath); - await firstStore.SaveAsync(FeeSchedule("fees-restart-2026-07")); + var original = await firstStore.SaveAsync(FeeSchedule("fees-restart-2026-07")); var firstRun = await CreateWorker(firstStore, fixture.Runner).RunDueAsync(DueAt); firstRun.Runs.Should().ContainSingle(); var completed = (await firstStore.GetAsync("fees-restart-2026-07"))!; @@ -94,24 +242,28 @@ public async Task PersistedRunningClaim_RestartsWithSameRunKey_AndDeduplicatesDr CompletedAtUtc = null, Summary = "Simulated process termination after intake and before completion." }; - await firstStore.SaveAsync(completed with + await firstStore.SaveAsync(original with { + Version = completed.Version, State = AutomatedJournalScheduleStateDto.Running, LastRunAtUtc = null, + LastScheduledForUtc = DueAt, RunHistory = [runningHistory] }); var restartedStore = new FileAutomatedJournalScheduleStore(snapshotPath); - var restarted = await CreateWorker(restartedStore, fixture.Runner).RunDueAsync(DueAt.AddMinutes(5)); + var restartedFixture = await CreateFileFixtureAsync(directory, initializeConfiguration: false); + var restarted = await CreateWorker(restartedStore, restartedFixture.Runner).RunDueAsync(DueAt.AddMinutes(5)); var restartedRun = restarted.Runs.Should().ContainSingle().Subject; restartedRun.RunKey.Should().Be(firstRun.Runs.Single().RunKey); restartedRun.State.Should().Be(AutomatedJournalScheduleStateDto.DraftReady); var persisted = (await restartedStore.GetAsync("fees-restart-2026-07"))!; persisted.RunHistory.Should().ContainSingle("a restart replaces the durable record for the same run key"); - persisted.JournalEntryIds.Should().BeEquivalentTo(firstRun.Runs.Single().JournalEntryIds); - (await fixture.Workbench.GetWorkbenchAsync("fund-alpha", BookId)).Drafts.Should().HaveCount(2, - "downstream deterministic ids must deduplicate a restart after intake"); + persisted.RunHistory.Single().JournalEntryIds.Should().BeEquivalentTo(firstRun.Runs.Single().JournalEntryIds); + persisted.PeriodId.Should().Be("2026-08"); + (await restartedFixture.Workbench.GetWorkbenchAsync("fund-alpha", BookId)).Drafts.Should().HaveCount(2, + "a fully reconstructed process graph must load the durable drafts and deduplicate restart intake"); } finally { @@ -168,7 +320,7 @@ public async Task DividendSchedule_WithNoEligibleProviderEvidence_IsVisiblyBlock [Fact] public async Task LowConfidenceDividend_RemainsNeedsFixWithInvestigationIssue_AndCannotSubmit() { - var lowConfidence = DividendAction(payDate: null, currency: null, recordDate: null); + var lowConfidence = DividendAction(payDate: null, currency: "USD", recordDate: null); var fixture = CreateFixture([lowConfidence]); var store = new InMemoryAutomatedJournalScheduleStore(); await store.SaveAsync(DividendSchedule( @@ -238,6 +390,38 @@ await store.SaveAsync(DividendSchedule("dividends-status") with status.Blockers.Should().ContainSingle(); } + [Fact] + public async Task StatusProjection_IsolatesTenantCompanyAndEntityScope() + { + var store = new InMemoryAutomatedJournalScheduleStore(); + await store.SaveAsync(FeeSchedule("fees-a-entity-a") with + { + TenantId = "tenant-a", + CompanyId = "company-a", + EntityId = "entity-a" + }); + await store.SaveAsync(FeeSchedule("fees-a-entity-b") with + { + TenantId = "tenant-a", + CompanyId = "company-a", + EntityId = "entity-b" + }); + await store.SaveAsync(FeeSchedule("fees-b-entity-a") with + { + TenantId = "tenant-b", + CompanyId = "company-b", + EntityId = "entity-a" + }); + + var status = await store.GetStatusAsync( + "fund-alpha", BookId, "2026-07", tenantId: "tenant-a", companyId: "company-a", entityId: "entity-a"); + + status.ConfiguredCount.Should().Be(1); + status.TenantId.Should().Be("tenant-a"); + status.CompanyId.Should().Be("company-a"); + status.EntityId.Should().Be("entity-a"); + } + private static AutomatedJournalScheduledWorker CreateWorker( IAutomatedJournalScheduleStore store, AutomatedJournalIntakeRunner runner) @@ -262,7 +446,38 @@ private static AutomatedJournalScheduleWorkItem FeeSchedule(string scheduleId) EndingNavBeforeFees: 1_100_000m, HighWaterMark: 1_050_000m, ManagementFeeRate: 0.02m, - PerformanceFeeRate: 0.20m); + PerformanceFeeRate: 0.20m, + CapitalAccountReconciliation: Reconciliation()); + + private static AutomatedJournalCapitalAccountReconciliationDto Reconciliation( + decimal confidence = 0.98m, + bool reconciled = true, + decimal maximumVarianceTolerance = 0m) + => new( + ReconciliationId: "capital-tie-out-2026-07", + PeriodId: "2026-07", + Currency: "USD", + ReconciledBeginningNav: 1_000_000m, + ReconciledEndingNavBeforeFees: 1_100_000m, + ReconciledHighWaterMark: 1_050_000m, + CapitalAccountOpeningBalance: 1_000_000m, + CapitalAccountEndingBalanceBeforeFees: 1_100_000m, + CapitalAccountHighWaterMark: 1_050_000m, + MaximumVarianceTolerance: maximumVarianceTolerance, + ConfidenceScore: confidence, + IsReconciled: reconciled, + SourceVersion: "capital-ledger:v42", + ReviewedBy: "fund-controller", + ReviewedAtUtc: DueAt.AddHours(-2), + EvidenceLinks: + [ + new OperationsEvidenceLinkDto( + "capital-tie-out-evidence-2026-07", + "Reviewed capital-account reconciliation", + "evidence://capital-accounts/fund-alpha/2026-07/v42", + "capital-account-subledger", + DueAt.AddHours(-2)) + ]); private static AutomatedJournalScheduleWorkItem DividendSchedule( string scheduleId, @@ -319,7 +534,59 @@ private sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider private static Fixture CreateFixture(IReadOnlyList? corporateActions = null) { var configurationStore = new InMemoryAccountingConfigurationStore(); - configurationStore.SaveAsync(new AccountingConfigurationWorkspaceDto( + configurationStore.SaveAsync(ConfigurationWorkspace()).GetAwaiter().GetResult(); + var configurationService = new AccountingConfigurationService( + configurationStore, + new InMemoryAccountingActionAuditStore()); + var draftStore = new InMemoryManualJournalEntryDraftStore(); + var workbench = new ManualJournalEntryWorkbenchService( + draftStore, + configurationService, + new InMemoryAccountingActionAuditStore()); + return CreateFixture(corporateActions, configurationService, draftStore, workbench); + } + + private static async Task CreateFileFixtureAsync( + string directory, + bool initializeConfiguration, + IReadOnlyList? corporateActions = null) + { + var configurationStore = new FileAccountingConfigurationStore( + Path.Combine(directory, "accounting-configuration.json")); + if (initializeConfiguration) + { + await configurationStore.SaveAsync(ConfigurationWorkspace()); + } + + var configurationService = new AccountingConfigurationService(configurationStore, configurationStore); + var draftStore = new FileManualJournalEntryDraftStore(Path.Combine(directory, "manual-journal-drafts.json")); + var workbench = new ManualJournalEntryWorkbenchService( + draftStore, + configurationService, + configurationStore); + return CreateFixture(corporateActions, configurationService, draftStore, workbench); + } + + private static Fixture CreateFixture( + IReadOnlyList? corporateActions, + IAccountingConfigurationService configurationService, + IManualJournalEntryDraftStore draftStore, + ManualJournalEntryWorkbenchService workbench) + { + var intake = new AutomatedJournalDraftIntakeService(workbench, draftStore, configurationService); + var securityMaster = corporateActions is null + ? null + : new FakeSecurityMasterQueryService(corporateActions); + return new Fixture( + new AutomatedJournalIntakeRunner( + intake, + new FeeScheduleAccrualEventProducer(), + securityMaster is null ? null : new CorporateActionDividendEventProducer(securityMaster)), + workbench); + } + + private static AccountingConfigurationWorkspaceDto ConfigurationWorkspace() + => new( "fund-alpha", LedgerBookId: null, AccountingConfigurationStatusDto.Draft, @@ -340,26 +607,7 @@ private static Fixture CreateFixture(IReadOnlyList? corporat JournalTemplates: [], PostingRules: [], ValidationIssues: [], - AuditTrail: [])).GetAwaiter().GetResult(); - var configurationService = new AccountingConfigurationService( - configurationStore, - new InMemoryAccountingActionAuditStore()); - var draftStore = new InMemoryManualJournalEntryDraftStore(); - var workbench = new ManualJournalEntryWorkbenchService( - draftStore, - configurationService, - new InMemoryAccountingActionAuditStore()); - var intake = new AutomatedJournalDraftIntakeService(workbench, draftStore, configurationService); - var securityMaster = corporateActions is null - ? null - : new FakeSecurityMasterQueryService(corporateActions); - return new Fixture( - new AutomatedJournalIntakeRunner( - intake, - new FeeScheduleAccrualEventProducer(), - securityMaster is null ? null : new CorporateActionDividendEventProducer(securityMaster)), - workbench); - } + AuditTrail: []); private static ChartOfAccountsNodeDto Node(string path, string name, string type) => new(NodeId: path, Path: path, AccountName: name, AccountType: type); diff --git a/tests/Meridian.Tests/Ui/BrokerageConnectionEndpointsTests.cs b/tests/Meridian.Tests/Ui/BrokerageConnectionEndpointsTests.cs index c1441dfacc..bfa81d99e6 100644 --- a/tests/Meridian.Tests/Ui/BrokerageConnectionEndpointsTests.cs +++ b/tests/Meridian.Tests/Ui/BrokerageConnectionEndpointsTests.cs @@ -180,7 +180,7 @@ public async Task UiServer_RegistersLifecycleRoutes_ForManagedShutdown() try { await using var server = new UiServer(configPath, GetFreeTcpPort()); - await server.StartAsync(); + await server.StartAsync().WaitAsync(TimeSpan.FromSeconds(30)); var app = GetServerApp(server); var routes = app.Services.GetServices() diff --git a/tests/Meridian.Tests/Ui/DailyValuationBatchLifecycleServiceTests.cs b/tests/Meridian.Tests/Ui/DailyValuationBatchLifecycleServiceTests.cs new file mode 100644 index 0000000000..e7c8df2d8d --- /dev/null +++ b/tests/Meridian.Tests/Ui/DailyValuationBatchLifecycleServiceTests.cs @@ -0,0 +1,308 @@ +using FluentAssertions; +using Meridian.Application.Accounting; +using Meridian.Contracts.Ledger; +using Meridian.Contracts.Workstation; +using Meridian.Ui.Shared.Services; + +namespace Meridian.Tests.Ui; + +public sealed class DailyValuationBatchLifecycleServiceTests +{ + private static readonly Guid BookId = Guid.Parse("2f617234-41db-463f-a4be-6c99a026cf62"); + private static readonly Guid PeriodId = Guid.Parse("2e7b27b8-c3cf-42e9-bf70-e7d863ca7180"); + private static readonly Guid FirstId = Guid.Parse("00e92600-80df-4dd7-8b93-a2da56a40fb5"); + private static readonly Guid SecondId = Guid.Parse("67d61dcf-198c-42f4-af8e-eb73f20927d4"); + + [Fact] + public async Task ApproveAndPostAsync_AllMembersCompleteAndRetainedEvidenceFlowsToEveryAction() + { + var fixture = await CreateFixtureAsync(Draft(FirstId), Draft(SecondId)); + + var result = await fixture.Service.ApproveAndPostAsync(Request()); + + result.IsComplete.Should().BeTrue(); + result.PostedJournalEntryIds.Should().BeEquivalentTo([FirstId, SecondId]); + fixture.Lifecycle.Requests.Should().HaveCount(8); + fixture.Lifecycle.Requests.Should().OnlyContain(request => + request.EvidenceLinks.Contains("evidence://daily-valuation/retained", StringComparer.OrdinalIgnoreCase) && + request.EvidenceLinks.Contains("evidence://operator/approval", StringComparer.OrdinalIgnoreCase)); + (await fixture.Source.GetAsync("daily-a"))!.State.Should().Be(DailyValuationScheduleStateDto.Posted); + } + + [Fact] + public async Task ApproveAndPostAsync_RetrySkipsPostedMemberAndCompletesRemainingMember() + { + var fixture = await CreateFixtureAsync( + Draft(FirstId, ManualJournalEntryStatusDto.Posted), + Draft(SecondId, ManualJournalEntryStatusDto.Approved)); + + var result = await fixture.Service.ApproveAndPostAsync(Request()); + + result.IsComplete.Should().BeTrue(); + fixture.Lifecycle.Requests.Should().HaveCount(2); + fixture.Lifecycle.Requests.Select(request => request.Action) + .Should().Equal(JournalEntryLifecycleActionDto.Validate, JournalEntryLifecycleActionDto.Post); + fixture.Lifecycle.Requests.Should().OnlyContain(request => request.JournalEntryId == SecondId); + } + + [Fact] + public async Task ApproveAndPostAsync_MissingMemberBlocksBeforeAnyLifecycleAction() + { + var fixture = await CreateFixtureAsync(Draft(FirstId)); + + var result = await fixture.Service.ApproveAndPostAsync(Request()); + + result.IsComplete.Should().BeFalse(); + result.Blockers.Should().ContainSingle(message => message.Contains(SecondId.ToString("D"), StringComparison.Ordinal)); + fixture.Lifecycle.Requests.Should().BeEmpty(); + } + + [Fact] + public async Task ApproveAndPostAsync_ValidatesAllMembersBeforePostingAnyMember() + { + var fixture = await CreateFixtureAsync(Draft(FirstId), Draft(SecondId)); + fixture.Lifecycle.FailValidationFor = SecondId; + + var result = await fixture.Service.ApproveAndPostAsync(Request()); + + result.IsComplete.Should().BeFalse(); + fixture.Lifecycle.Requests.Should().HaveCount(2) + .And.OnlyContain(request => request.Action == JournalEntryLifecycleActionDto.Validate); + fixture.Store.Items.Values.Should().NotContain(draft => draft.Status == ManualJournalEntryStatusDto.Posted); + } + + [Fact] + public async Task ApproveAndPostAsync_PreparerCannotApproveOwnBatch() + { + var fixture = await CreateFixtureAsync(Draft(FirstId, preparedBy: "controller-a"), Draft(SecondId)); + + var result = await fixture.Service.ApproveAndPostAsync(Request()); + + result.IsComplete.Should().BeFalse(); + result.Blockers.Should().Contain(message => message.Contains("independent from preparer", StringComparison.Ordinal)); + fixture.Lifecycle.Requests.Should().BeEmpty(); + } + + [Fact] + public async Task ApproveAndPostAsync_DraftEntityMismatchBlocksBatch() + { + var fixture = await CreateFixtureAsync( + Draft(FirstId), + Draft(SecondId) with { EntityId = "entity-other" }); + + var result = await fixture.Service.ApproveAndPostAsync(Request()); + + result.IsComplete.Should().BeFalse(); + result.Blockers.Should().Contain(message => message.Contains("batch scope", StringComparison.Ordinal)); + fixture.Lifecycle.Requests.Should().BeEmpty(); + } + + [Fact] + public async Task ApproveAndPostAsync_CrossTenantRequestIsRejected() + { + var fixture = await CreateFixtureAsync(Draft(FirstId), Draft(SecondId)); + + var act = () => fixture.Service.ApproveAndPostAsync(Request() with { TenantId = "tenant-other" }); + + await act.Should().ThrowAsync(); + fixture.Lifecycle.Requests.Should().BeEmpty(); + } + + private static async Task CreateFixtureAsync(params ManualJournalEntryDraftDto[] drafts) + { + var source = new InMemoryDailyValuationPortfolioSource(); + await source.SaveAsync(Schedule()); + var store = new RecordingDraftStore(drafts); + var lifecycle = new RecordingLifecycleService(store); + return new Fixture(source, store, lifecycle, new DailyValuationBatchLifecycleService(source, store, lifecycle)); + } + + private static DailyValuationBatchLifecycleRequestDto Request() + => new( + "daily-a", + "fund-a", + "controller-a", + "Reviewed trusted marks and approved the complete valuation batch.", + ["evidence://operator/approval"], + "tenant-a", + "company-a"); + + private static DailyValuationScheduleWorkItem Schedule() + => new( + "daily-a", + "fund-a", + "USD", + "preparer-a", + BookId, + PeriodId, + DateTimeOffset.Parse("2026-07-16T23:00:00Z"), + [new MarkToMarketPosition("AAPL", 10m, 150m)], + "policy-a", + "Listed equity close", + "Provider close", + "controller-a", + DateTimeOffset.Parse("2026-07-01T00:00:00Z"), + "Daily close", + EntityId: "entity-a", + TenantId: "tenant-a", + CompanyId: "company-a", + State: DailyValuationScheduleStateDto.DraftReady, + JournalEntryId: FirstId, + EvidenceLinks: + [ + new OperationsEvidenceLinkDto( + "daily-retained", + "Retained mark evidence", + "evidence://daily-valuation/retained", + "daily-valuation-scheduler", + DateTimeOffset.Parse("2026-07-15T23:00:00Z")) + ], + JournalEntryIds: [FirstId, SecondId], + BatchCorrelationId: "valuation-batch-a", + UseStaticPositionOverride: true, + StaticPositionsAsOfUtc: DateTimeOffset.Parse("2026-07-15T22:00:00Z")); + + private static ManualJournalEntryDraftDto Draft( + Guid id, + ManualJournalEntryStatusDto status = ManualJournalEntryStatusDto.Draft, + string preparedBy = "preparer-a") + => new( + id, + status, + "fund-a", + BookId, + AccountingBasisKindDto.Primary, + new DateOnly(2026, 7, 15), + PeriodId.ToString("D"), + "entity-a", + null, + "USD", + "Daily fair value adjustment", + preparedBy, + DateTimeOffset.Parse("2026-07-15T23:00:00Z"), + DateTimeOffset.Parse("2026-07-15T23:00:00Z"), + Version: 1, + Lines: [], + EvidenceLinks: [], + ValidationIssues: [], + TreasuryContext: new TreasuryLedgerContextDto(IdempotencyKey: $"fair-value|{id:N}"), + TenantId: "tenant-a", + CompanyId: "company-a"); + + private sealed record Fixture( + InMemoryDailyValuationPortfolioSource Source, + RecordingDraftStore Store, + RecordingLifecycleService Lifecycle, + DailyValuationBatchLifecycleService Service); + + private sealed class RecordingDraftStore(IEnumerable drafts) + : IManualJournalEntryDraftStore + { + public Dictionary Items { get; } = + drafts.ToDictionary(static draft => draft.JournalEntryId); + + public Task> ListFundProfileIdsAsync(CancellationToken ct = default) + => Task.FromResult>(["fund-a"]); + + public Task> ListAsync( + string fundProfileId, + Guid? ledgerBookId = null, + CancellationToken ct = default, + string? tenantId = null, + string? companyId = null) + => Task.FromResult>(Items.Values.ToArray()); + + public Task GetAsync( + string fundProfileId, + Guid journalEntryId, + CancellationToken ct = default, + string? tenantId = null, + string? companyId = null) + { + Items.TryGetValue(journalEntryId, out var draft); + if (draft is not null && + (!string.Equals(draft.FundProfileId, fundProfileId, StringComparison.OrdinalIgnoreCase) || + !string.Equals(draft.TenantId, tenantId, StringComparison.OrdinalIgnoreCase) || + !string.Equals(draft.CompanyId, companyId, StringComparison.OrdinalIgnoreCase))) + { + draft = null; + } + + return Task.FromResult(draft); + } + + public Task SaveAsync(ManualJournalEntryDraftDto draft, CancellationToken ct = default) + { + Items[draft.JournalEntryId] = draft; + return Task.CompletedTask; + } + + public Task SaveBatchAsync( + IReadOnlyList drafts, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + foreach (var draft in drafts) + { + Items[draft.JournalEntryId] = draft; + } + + return Task.CompletedTask; + } + } + + private sealed class RecordingLifecycleService(RecordingDraftStore store) + : IManualJournalEntryLifecycleService + { + public List Requests { get; } = []; + + public Guid? FailValidationFor { get; set; } + + public async Task ApplyLifecycleActionAsync( + JournalEntryLifecycleActionRequestDto request, + CancellationToken ct = default) + { + Requests.Add(request); + var current = store.Items[request.JournalEntryId]; + var nextStatus = request.Action switch + { + JournalEntryLifecycleActionDto.Validate when FailValidationFor == request.JournalEntryId => + ManualJournalEntryStatusDto.NeedsFix, + JournalEntryLifecycleActionDto.Validate => current.Status, + JournalEntryLifecycleActionDto.Submit => ManualJournalEntryStatusDto.Submitted, + JournalEntryLifecycleActionDto.Approve => ManualJournalEntryStatusDto.Approved, + JournalEntryLifecycleActionDto.Post => ManualJournalEntryStatusDto.Posted, + _ => current.Status + }; + IReadOnlyList issues = + nextStatus == ManualJournalEntryStatusDto.NeedsFix + ? [new AccountingConfigurationValidationIssueDto( + "valuation-control", + AccountingConfigurationValidationSeverityDto.Critical, + "Injected validation failure.")] + : current.ValidationIssues; + var updated = current with + { + Status = nextStatus, + Version = current.Version + 1, + ValidationIssues = issues, + EvidenceLinks = current.EvidenceLinks + .Concat(request.EvidenceLinks) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() + }; + await store.SaveAsync(updated, ct); + var transition = new JournalEntryLifecycleTransitionDto( + $"transition-{Requests.Count}", + current.Status, + nextStatus, + request.Action, + request.Actor, + DateTimeOffset.UtcNow, + request.Notes, + request.CorrelationId, + request.EvidenceLinks); + return new JournalEntryLifecycleActionResultDto(updated, transition); + } + } +} diff --git a/tests/Meridian.Tests/Ui/DailyValuationPositionServiceTests.cs b/tests/Meridian.Tests/Ui/DailyValuationPositionServiceTests.cs new file mode 100644 index 0000000000..c7f8c1954b --- /dev/null +++ b/tests/Meridian.Tests/Ui/DailyValuationPositionServiceTests.cs @@ -0,0 +1,252 @@ +using System.Text.Json; +using FluentAssertions; +using Meridian.Application.Accounting; +using Meridian.Contracts.Catalog; +using Meridian.Contracts.Domain; +using Meridian.Contracts.SecurityMaster; +using Meridian.Ui.Shared.Services; +using NSubstitute; + +namespace Meridian.Tests.Ui; + +public sealed class DailyValuationPositionServiceTests +{ + private static readonly Guid SecurityId = Guid.Parse("84f0e04b-354b-4448-a7d7-ef88025280ae"); + private static readonly Guid BookId = Guid.Parse("58a459dc-1eb8-4116-bf40-bf8e3846835d"); + private static readonly Guid PeriodId = Guid.Parse("092fbb21-e03f-4f9a-9cb1-dcbd10d3ee53"); + private static readonly DateTimeOffset ValuationAsOf = DateTimeOffset.Parse("2026-07-15T23:00:00Z"); + private static readonly JsonElement EmptyTerms = JsonDocument.Parse("{}").RootElement.Clone(); + + [Fact] + public async Task ResolveConfiguredAsync_FreshOwnedSnapshot_ResolvesSecurityAndEvidence() + { + var store = Substitute.For(); + store.GetLatestSnapshotAsync( + "run-a", + "account-a", + Arg.Any(), + Arg.Any()) + .Returns(Snapshot("run-a", "account-a", ValuationAsOf.AddMinutes(-15))); + var service = CreateService(store); + + var result = await service.ResolveConfiguredAsync( + WorkItem() with { PositionSnapshotScopes = [new("run-a", "account-a")] }, + ValuationAsOf); + + result.IsReady.Should().BeTrue(); + result.Positions.Should().ContainSingle().Which.Should().BeEquivalentTo( + new MarkToMarketPosition("AAPL", 10m, 150m, "account-a", "Equity", SecurityId)); + result.EvidenceLinks.Should().ContainSingle(link => + link.Route.Contains("run-a/account-a", StringComparison.Ordinal)); + } + + [Theory] + [InlineData(-3, "stale")] + [InlineData(1, "dated after")] + public async Task ResolveConfiguredAsync_StaleOrFutureSnapshot_FailsClosed( + int offsetDays, + string expectedBlocker) + { + var store = Substitute.For(); + store.GetLatestSnapshotAsync( + "run-a", + "account-a", + Arg.Any(), + Arg.Any()) + .Returns(Snapshot("run-a", "account-a", ValuationAsOf.AddDays(offsetDays))); + var service = CreateService(store); + + var result = await service.ResolveConfiguredAsync( + WorkItem() with + { + PositionSnapshotScopes = [new("run-a", "account-a")], + MaximumPositionAgeDays = 1 + }, + ValuationAsOf); + + result.IsReady.Should().BeFalse(); + result.Blockers.Should().ContainSingle(message => + message.Contains(expectedBlocker, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task ResolveConfiguredAsync_SnapshotStoreReturnsDifferentOwner_FailsClosed() + { + var store = Substitute.For(); + store.GetLatestSnapshotAsync( + "run-a", + "account-a", + Arg.Any(), + Arg.Any()) + .Returns(Snapshot("run-a", "account-a", ValuationAsOf) with { TenantId = "tenant-other" }); + var service = CreateService(store); + + var result = await service.ResolveConfiguredAsync( + WorkItem() with { PositionSnapshotScopes = [new("run-a", "account-a")] }, + ValuationAsOf); + + result.IsReady.Should().BeFalse(); + result.Blockers.Should().ContainSingle(message => message.Contains("immutable tenant/company/fund/book/entity", StringComparison.Ordinal)); + } + + [Fact] + public async Task ResolveConfiguredAsync_ScheduleMissingSnapshotOwner_FailsClosedBeforeLookup() + { + var store = Substitute.For(); + var service = CreateService(store); + + var result = await service.ResolveConfiguredAsync( + WorkItem() with + { + EntityId = null, + PositionSnapshotScopes = [new("run-a", "account-a")] + }, + ValuationAsOf); + + result.IsReady.Should().BeFalse(); + result.Blockers.Should().ContainSingle(message => message.Contains("tenant, company, fund profile, ledger book, and entity", StringComparison.Ordinal)); + await store.DidNotReceiveWithAnyArgs().GetLatestSnapshotAsync( + default!, + default!, + default!, + default); + } + + [Fact] + public async Task ResolveConfiguredAsync_StaticOverrideHashMismatch_FailsClosed() + { + var positions = new[] { new MarkToMarketPosition("AAPL", 10m, 150m, "account-a") }; + var service = CreateService(); + + var result = await service.ResolveConfiguredAsync( + WorkItem() with + { + Positions = positions, + UseStaticPositionOverride = true, + StaticPositionsAsOfUtc = ValuationAsOf, + StaticPositionHash = "tampered" + }, + ValuationAsOf); + + result.IsReady.Should().BeFalse(); + result.Blockers.Should().ContainSingle(message => message.Contains("hash does not match", StringComparison.Ordinal)); + DailyValuationPositionService.ComputeStaticPositionHash(positions) + .Should().Be(DailyValuationPositionService.ComputeStaticPositionHash(positions.Reverse().ToArray())); + } + + [Theory] + [InlineData(false, "USD", "Inactive")] + [InlineData(true, "EUR", "not valuation base currency")] + public async Task ResolveAdHocAsync_InactiveOrWrongCurrencySecurity_FailsClosed( + bool isActive, + string securityCurrency, + string expectedBlocker) + { + var service = CreateService( + securityStatus: isActive ? SecurityStatusDto.Active : SecurityStatusDto.Inactive, + securityCurrency: securityCurrency); + + var result = await service.ResolveAdHocAsync( + [new MarkToMarketPosition("AAPL", 10m, 150m, "account-a")], + "USD", + ValuationAsOf); + + result.IsReady.Should().BeFalse(); + result.Blockers.Should().ContainSingle(message => + message.Contains(expectedBlocker, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task ResolveAdHocAsync_DuplicateSecurityAccountScope_FailsClosed() + { + var service = CreateService(); + + var result = await service.ResolveAdHocAsync( + [ + new MarkToMarketPosition("AAPL", 10m, 150m, "account-a"), + new MarkToMarketPosition("AAPL", 5m, 151m, "account-a") + ], + "USD", + ValuationAsOf); + + result.IsReady.Should().BeFalse(); + result.Blockers.Should().ContainSingle(message => message.Contains("duplicate security/account", StringComparison.Ordinal)); + } + + private static DailyValuationPositionService CreateService( + IPositionSnapshotStore? store = null, + SecurityStatusDto securityStatus = SecurityStatusDto.Active, + string securityCurrency = "USD") + { + var registry = Substitute.For(); + registry.GetDefinition("AAPL").Returns(new CanonicalSymbolDefinition + { + Canonical = "AAPL", + DisplayName = "Apple Inc.", + SecurityId = SecurityId, + AssetClass = "Equity", + Exchange = "NASDAQ", + Currency = securityCurrency, + Aliases = ["AAPL"] + }); + var securityMaster = Substitute.For(); + var detail = SecurityDetail(securityStatus, securityCurrency); + securityMaster.GetByIdAsOfAsync(SecurityId, Arg.Any(), Arg.Any()) + .Returns(detail); + securityMaster.GetByIdAsync(SecurityId, Arg.Any()).Returns(detail); + return new DailyValuationPositionService(store, registry, securityMaster); + } + + private static AccountSnapshotRecord Snapshot(string runId, string accountId, DateTimeOffset asOf) + => new( + runId, + accountId, + accountId, + "Brokerage", + Cash: 0m, + MarginBalance: 0m, + UnrealisedPnl: 0m, + RealisedPnl: 0m, + Positions: [new PositionRecord("AAPL", 10m, 150m, 0m, 0m)], + AsOf: asOf, + TenantId: "tenant-a", + CompanyId: "company-a", + FundProfileId: "fund-a", + LedgerBookId: BookId, + EntityId: "entity-a"); + + private static SecurityDetailDto SecurityDetail(SecurityStatusDto status, string currency) + => new( + SecurityId, + "Equity", + status, + "Apple Inc.", + currency, + EmptyTerms, + EmptyTerms, + [new SecurityIdentifierDto(SecurityIdentifierKind.Ticker, "AAPL", true, ValuationAsOf.AddYears(-1))], + [], + Version: 1, + EffectiveFrom: ValuationAsOf.AddYears(-1), + EffectiveTo: null); + + private static DailyValuationScheduleWorkItem WorkItem() + => new( + "daily-a", + "fund-a", + "USD", + "preparer-a", + BookId, + PeriodId, + ValuationAsOf, + [], + "policy-a", + "Listed equity close", + "Provider close", + "controller-a", + ValuationAsOf.AddDays(-1), + "Daily close", + EntityId: "entity-a", + TenantId: "tenant-a", + CompanyId: "company-a"); +} diff --git a/tests/Meridian.Tests/Ui/DailyValuationScheduleIdentityTests.cs b/tests/Meridian.Tests/Ui/DailyValuationScheduleIdentityTests.cs new file mode 100644 index 0000000000..f796322b35 --- /dev/null +++ b/tests/Meridian.Tests/Ui/DailyValuationScheduleIdentityTests.cs @@ -0,0 +1,196 @@ +using FluentAssertions; +using Meridian.Application.Accounting; +using Meridian.Contracts.Ledger; +using Meridian.Contracts.Workstation; +using Meridian.Ui.Shared.Services; + +namespace Meridian.Tests.Ui; + +public sealed class DailyValuationScheduleIdentityTests +{ + private static readonly Guid BookId = Guid.Parse("11111111-1111-4111-8111-111111111111"); + private static readonly Guid PeriodId = Guid.Parse("22222222-2222-4222-8222-222222222222"); + + [Fact] + public async Task SaveAsync_AllowsOperatorHandoffWhilePreservingCreatorIdentity() + { + var source = new InMemoryDailyValuationPortfolioSource(); + var original = await source.SaveAsync(CreateWorkItem() with + { + Actor = "creator-a", + CreatedBy = "creator-a", + LastConfiguredBy = "creator-a" + }); + + var reconfigured = await source.SaveAsync(original with + { + Actor = "controller-b", + LastConfiguredBy = "controller-b", + NextRunAtUtc = original.NextRunAtUtc.AddDays(1) + }); + + reconfigured.Actor.Should().Be("controller-b"); + reconfigured.CreatedBy.Should().Be("creator-a"); + reconfigured.LastConfiguredBy.Should().Be("controller-b"); + } + + [Theory] + [InlineData("tenant")] + [InlineData("company")] + [InlineData("fund")] + [InlineData("book")] + [InlineData("entity")] + [InlineData("currency")] + [InlineData("creator")] + public async Task SaveAsync_RejectsImmutableIdentityTakeover(string mutation) + { + var source = new InMemoryDailyValuationPortfolioSource(); + var original = await source.SaveAsync(CreateWorkItem()); + var replacement = mutation switch + { + "tenant" => original with { TenantId = "tenant-b" }, + "company" => original with { CompanyId = "company-b" }, + "fund" => original with { FundProfileId = "fund-b" }, + "book" => original with { LedgerBookId = Guid.NewGuid() }, + "entity" => original with { EntityId = "entity-b" }, + "currency" => original with { Currency = "EUR" }, + "creator" => original with { CreatedBy = "creator-b" }, + _ => throw new ArgumentOutOfRangeException(nameof(mutation)) + }; + + var act = () => source.SaveAsync(replacement); + + await act.Should().ThrowAsync() + .WithMessage("*different immutable identity scope*"); + } + + [Fact] + public async Task GetStatusAsync_RequiresExactTenantCompanyAndEntityScope() + { + var source = new InMemoryDailyValuationPortfolioSource(); + await source.SaveAsync(CreateWorkItem()); + + var wrongScope = await source.GetStatusAsync( + "fund-a", + BookId, + "2026-07", + entityId: "entity-b", + tenantId: "tenant-a", + companyId: "company-a"); + var ownedScope = await source.GetStatusAsync( + "fund-a", + BookId, + "2026-07", + entityId: "entity-a", + tenantId: "tenant-a", + companyId: "company-a"); + + wrongScope.IsConfigured.Should().BeFalse(); + ownedScope.IsConfigured.Should().BeTrue(); + ownedScope.EntityId.Should().Be("entity-a"); + ownedScope.TenantId.Should().Be("tenant-a"); + ownedScope.CompanyId.Should().Be("company-a"); + } + + [Fact] + public async Task GetStatusAsync_CurrentScheduledWorkWinsOlderPostedScheduleForSameScope() + { + var source = new InMemoryDailyValuationPortfolioSource(); + await source.SaveAsync(CreateWorkItem() with + { + ScheduleId = "older-posted", + State = DailyValuationScheduleStateDto.Posted, + LastRunAtUtc = DateTimeOffset.Parse("2026-07-15T23:05:00Z"), + LastScheduledForUtc = DateTimeOffset.Parse("2026-07-15T23:00:00Z"), + NextRunAtUtc = DateTimeOffset.Parse("2026-07-16T23:00:00Z") + }); + await source.SaveAsync(CreateWorkItem() with + { + ScheduleId = "current-scheduled", + State = DailyValuationScheduleStateDto.Scheduled, + LastRunAtUtc = null, + LastScheduledForUtc = null, + NextRunAtUtc = DateTimeOffset.Parse("2026-07-16T22:00:00Z") + }); + + var status = await source.GetStatusAsync( + "fund-a", + BookId, + "2026-07", + entityId: "entity-a", + tenantId: "tenant-a", + companyId: "company-a"); + + status.ScheduleId.Should().Be("current-scheduled"); + status.State.Should().Be(DailyValuationScheduleStateDto.Scheduled); + } + + [Fact] + public void BuildIntakeBlockers_RejectedTerminalNeedsFixReassessmentAndProjectionFailure_AreNeverReady() + { + var needsFixDraft = new ManualJournalEntryDraftDto( + Guid.Parse("33333333-3333-4333-8333-333333333333"), + ManualJournalEntryStatusDto.NeedsFix, + "fund-a", + BookId, + AccountingBasisKindDto.Primary, + new DateOnly(2026, 7, 15), + PeriodId.ToString("D"), + "entity-a", + FundNodeId: null, + Currency: "USD", + Memo: "Needs repair", + PreparedBy: "preparer-a", + CreatedAtUtc: DateTimeOffset.Parse("2026-07-15T23:00:00Z"), + UpdatedAtUtc: DateTimeOffset.Parse("2026-07-15T23:00:00Z"), + Version: 1, + Lines: [], + EvidenceLinks: [], + ValidationIssues: []); + var dispositions = new[] + { + AutomatedJournalDraftIntakeDisposition.ProjectionFailed, + AutomatedJournalDraftIntakeDisposition.ExistingDraftNeedsFix, + AutomatedJournalDraftIntakeDisposition.ExistingDraftRejected, + AutomatedJournalDraftIntakeDisposition.ExistingDraftTerminal, + AutomatedJournalDraftIntakeDisposition.ExistingDraftReassessmentRequired + }; + var intake = new AutomatedJournalDraftIntakeResult( + [needsFixDraft], + dispositions.Select((disposition, index) => new AutomatedJournalDraftIntakeSkip( + Guid.Parse($"44444444-4444-4444-8444-{index + 1:000000000000}"), + $"key-{index}", + $"blocked-{disposition}", + disposition)).ToArray()); + + var blockers = DailyValuationScheduledWorker.BuildIntakeBlockers(intake); + + blockers.Should().HaveCount(dispositions.Length + 1); + blockers.Should().Contain(message => message.Contains("NeedsFix", StringComparison.Ordinal)); + blockers.Should().Contain(dispositions.Select(disposition => $"blocked-{disposition}")); + } + + private static DailyValuationScheduleWorkItem CreateWorkItem() + => new( + "daily-fund-a", + "fund-a", + "USD", + "creator-a", + BookId, + PeriodId, + DateTimeOffset.Parse("2026-07-16T23:00:00Z"), + [new MarkToMarketPosition("AAPL", 10m, 150m)], + "fair-value-policy", + "Listed equities close", + "Provider close", + "controller-a", + DateTimeOffset.Parse("2026-07-01T00:00:00Z"), + "Daily governed valuation", + EntityId: "entity-a", + TenantId: "tenant-a", + CompanyId: "company-a", + UseStaticPositionOverride: true, + StaticPositionsAsOfUtc: DateTimeOffset.Parse("2026-07-16T22:00:00Z"), + CreatedBy: "creator-a", + LastConfiguredBy: "creator-a"); +} diff --git a/tests/Meridian.Tests/Ui/ProviderConnectionDiagnosticsProjectionTests.cs b/tests/Meridian.Tests/Ui/ProviderConnectionDiagnosticsProjectionTests.cs index a604873a0b..bf5e4d14b7 100644 --- a/tests/Meridian.Tests/Ui/ProviderConnectionDiagnosticsProjectionTests.cs +++ b/tests/Meridian.Tests/Ui/ProviderConnectionDiagnosticsProjectionTests.cs @@ -1,5 +1,7 @@ using System.Net.WebSockets; using FluentAssertions; +using Meridian.Contracts.Configuration; +using Meridian.Infrastructure; using Meridian.Infrastructure.Adapters.Core; using Meridian.Infrastructure.Resilience; using Meridian.Ui.Shared.Endpoints; @@ -64,6 +66,29 @@ public void BuildByProviderId_IndexesSafeWebSocketDiagnosticsByProviderIdentity( diagnostics.LastSubscriptionMessageAt.Should().Be(lastMessage); } + [Fact] + public void BuildByProviderId_StreamingContractFallbackProjectsConservativeConfiguredState() + { + using var registry = new ProviderRegistry(); + registry.Register(new ContractFallbackStreamingProvider()); + + var diagnosticsByProviderId = ProviderConnectionDiagnosticsProjection.BuildByProviderId(registry); + var diagnostics = ProviderConnectionDiagnosticsProjection.Find( + diagnosticsByProviderId, + "fallback-stream", + "Fallback Streaming"); + + diagnostics.Should().NotBeNull( + "IMarketDataClient guarantees connection diagnostics even when an adapter has no supervisor"); + diagnostics!.ProviderName.Should().Be("Fallback Streaming"); + diagnostics.LifecycleState.Should().Be("Configured"); + diagnostics.WebSocketState.Should().Be("None"); + diagnostics.IsConnected.Should().BeFalse( + "the compatibility fallback must never infer a live connection from configuration alone"); + diagnostics.IsReconnecting.Should().BeFalse(); + diagnostics.ReconnectAttempts.Should().Be(0); + } + private sealed class DiagnosticProvider : IProviderMetadata, IProviderConnectionDiagnosticsSource { private readonly WebSocketConnectionDiagnostics _diagnostics; @@ -96,4 +121,22 @@ public event Action? ConnectionDiagnosticsChange public WebSocketConnectionDiagnostics GetConnectionDiagnosticsSnapshot() => _diagnostics; } + + private sealed class ContractFallbackStreamingProvider : IMarketDataClient + { + public bool IsEnabled => true; + public string ProviderId => "fallback-stream"; + public string ProviderDisplayName => "Fallback Streaming"; + public string ProviderDescription => "Streaming contract fallback diagnostics test double."; + public int ProviderPriority => 100; + public ProviderCapabilities ProviderCapabilities { get; } = ProviderCapabilities.Streaming(); + + public Task ConnectAsync(CancellationToken ct = default) => Task.CompletedTask; + public Task DisconnectAsync(CancellationToken ct = default) => Task.CompletedTask; + public int SubscribeMarketDepth(SymbolConfig cfg) => 1; + public void UnsubscribeMarketDepth(int subscriptionId) { } + public int SubscribeTrades(SymbolConfig cfg) => 2; + public void UnsubscribeTrades(int subscriptionId) { } + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } } diff --git a/tests/Meridian.Tests/Ui/WorkstationEndpointsTests.JournalAutomation.cs b/tests/Meridian.Tests/Ui/WorkstationEndpointsTests.JournalAutomation.cs new file mode 100644 index 0000000000..927efd54c5 --- /dev/null +++ b/tests/Meridian.Tests/Ui/WorkstationEndpointsTests.JournalAutomation.cs @@ -0,0 +1,121 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Meridian.Contracts.Api; +using Meridian.Contracts.Ledger; +using Meridian.Contracts.Workstation; +using Meridian.Ui.Shared.Services; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Meridian.Tests.Ui; + +public sealed partial class WorkstationEndpointsTests +{ + [Fact] + public async Task MonthlyAutomationEndpoints_IsolateTenant_RearmWithAudit_AndRejectStaleOrRunningWrites() + { + var store = new InMemoryAutomatedJournalScheduleStore(); + var draftStore = new InMemoryManualJournalEntryDraftStore(); + var owned = await store.SaveAsync(MonthlySchedule("owned") with + { + TenantId = "tenant-test", + CompanyId = "tenant-test" + }); + await store.SaveAsync(MonthlySchedule("other") with + { + TenantId = "tenant-other", + CompanyId = "tenant-other" + }); + await using var app = await CreateAppAsync( + services => + { + services.AddSingleton(store); + services.AddSingleton(draftStore); + services.AddSingleton(TimeProvider.System); + }, + mapLedgerApi: true, + currentUserPermissions: Meridian.Identity.Auth.UserPermission.AdminMaintenance); + var client = app.GetTestClient(); + + var list = await client.GetFromJsonAsync( + UiApiRoutes.LedgerJournalAutomationMonthlySchedules, + ServerJsonOptions); + var rearmResponse = await client.PostAsJsonAsync( + UiApiRoutes.LedgerJournalAutomationMonthlySchedules, + owned, + ServerJsonOptions); + var rearmed = await rearmResponse.Content.ReadFromJsonAsync(ServerJsonOptions); + var staleResponse = await client.PostAsJsonAsync( + UiApiRoutes.LedgerJournalAutomationMonthlySchedules, + owned, + ServerJsonOptions); + var running = await store.SaveAsync(rearmed! with { State = AutomatedJournalScheduleStateDto.Running }); + var runningResponse = await client.PostAsJsonAsync( + UiApiRoutes.LedgerJournalAutomationMonthlySchedules, + running, + ServerJsonOptions); + + list.Should().ContainSingle().Which.ScheduleId.Should().Be("owned"); + rearmResponse.StatusCode.Should().Be(HttpStatusCode.OK); + rearmed!.RunHistory.Should().ContainSingle(history => + history.HistoryKind == AutomatedJournalScheduleHistoryKind.Rearm && + history.Actor == "ops-user" && + history.PreviousVersion == owned.Version && + history.ResultVersion == rearmed.Version); + staleResponse.StatusCode.Should().Be(HttpStatusCode.Conflict); + runningResponse.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task MonthlyRunDueEndpoint_ResolvesWorkerAndExecutesOneShot() + { + var store = new InMemoryAutomatedJournalScheduleStore(); + var configurationStore = new InMemoryAccountingConfigurationStore(); + var auditStore = new InMemoryAccountingActionAuditStore(); + var configuration = new AccountingConfigurationService(configurationStore, auditStore); + var draftStore = new InMemoryManualJournalEntryDraftStore(); + var workbench = new ManualJournalEntryWorkbenchService(draftStore, configuration, auditStore); + var intake = new AutomatedJournalDraftIntakeService(workbench, draftStore, configuration); + var runner = new AutomatedJournalIntakeRunner(intake, new FeeScheduleAccrualEventProducer()); + var worker = new AutomatedJournalScheduledWorker( + store, + runner, + NullLogger.Instance); + await using var app = await CreateAppAsync( + services => + { + services.AddSingleton(store); + services.AddSingleton(worker); + services.AddSingleton(TimeProvider.System); + }, + mapLedgerApi: true, + currentUserPermissions: Meridian.Identity.Auth.UserPermission.AdminMaintenance); + + var response = await app.GetTestClient().PostAsync( + UiApiRoutes.LedgerJournalAutomationMonthlyRunDue, + content: null); + var result = await response.Content.ReadFromJsonAsync(ServerJsonOptions); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + result!.Runs.Should().BeEmpty(); + } + + private static AutomatedJournalScheduleWorkItem MonthlySchedule(string scheduleId) + => new( + ScheduleId: scheduleId, + Kind: AutomatedJournalScheduleKind.DividendCapture, + FundProfileId: "fund-alpha", + LedgerBookId: Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + PeriodId: "2026-07", + EntityId: "entity-alpha", + Currency: "USD", + PeriodStart: new DateOnly(2026, 7, 1), + PeriodEnd: new DateOnly(2026, 7, 31), + DueDate: new DateOnly(2026, 8, 1), + DueTimeLocal: new TimeOnly(9, 0), + TimeZoneId: "UTC", + Actor: "client-supplied-actor", + Positions: [new DividendAccrualPosition("AAPL", 100m)]); +} diff --git a/tests/Meridian.Tests/Ui/WorkstationEndpointsTests.Wave4.cs b/tests/Meridian.Tests/Ui/WorkstationEndpointsTests.Wave4.cs index 5a68ba9e91..3c4eddaaa1 100644 --- a/tests/Meridian.Tests/Ui/WorkstationEndpointsTests.Wave4.cs +++ b/tests/Meridian.Tests/Ui/WorkstationEndpointsTests.Wave4.cs @@ -3,8 +3,10 @@ using System.Text.Json; using FluentAssertions; using Meridian.Contracts.Api; +using Meridian.Contracts.FundStructure; using Meridian.Identity.Auth; using Meridian.Contracts.Ledger; +using Meridian.Contracts.Tenancy; using Meridian.Contracts.Workstation; using Meridian.FinancialOperations.AccountingClose; using Meridian.FinancialOperations.OperationsContinuity; @@ -414,7 +416,8 @@ public async Task LedgerCloseManagementEndpoints_ProjectClosePlanAndRetainLateAd { await using var app = await CreateAppAsync( RegisterOperationsContinuityServices, - currentUserPermissions: UserPermission.AdminMaintenance); + currentUserPermissions: UserPermission.AdminMaintenance, + currentUserCompanyId: null); var client = app.GetTestClient(); var fundAccountId = Guid.NewGuid(); var ledgerBookId = Guid.NewGuid(); @@ -448,6 +451,7 @@ public async Task LedgerCloseManagementEndpoints_ProjectClosePlanAndRetainLateAd var plan = await planResponse.Content.ReadFromJsonAsync(ServerJsonOptions); plan.Should().NotBeNull(); plan!.ClosePlanId.Should().Be($"close-plan-{workflowId:D}"); + plan.WorkflowVersion.Should().Be(start.Workflow.Version); plan.FundProfileId.Should().Be(fundAccountId.ToString("D")); plan.LedgerBookId.Should().Be(ledgerBookId); plan.PeriodId.Should().Be("2026-07"); @@ -997,7 +1001,8 @@ public async Task LedgerCloseManagementEndpoints_ConfigureClosePlanMaterialitySi { await using var app = await CreateAppAsync( RegisterOperationsContinuityServices, - currentUserPermissions: UserPermission.AdminMaintenance); + currentUserPermissions: UserPermission.AdminMaintenance, + currentUserCompanyId: null); var client = app.GetTestClient(); var fundAccountId = Guid.NewGuid(); var ledgerBookId = Guid.NewGuid(); @@ -1126,7 +1131,8 @@ public async Task LedgerCloseManagementEndpoints_LockPeriodReturnsServiceBlocker { await using var app = await CreateAppAsync( RegisterOperationsContinuityServices, - currentUserPermissions: UserPermission.AdminMaintenance); + currentUserPermissions: UserPermission.AdminMaintenance, + currentUserCompanyId: null); var client = app.GetTestClient(); var fundAccountId = Guid.NewGuid(); var ledgerBookId = Guid.NewGuid(); @@ -1171,6 +1177,258 @@ public async Task LedgerCloseManagementEndpoints_LockPeriodReturnsServiceBlocker result.Issues.Should().Contain(issue => issue.Code == "ClosePeriodLockReportPackMissing"); } + [Theory] + [InlineData("tenant-other", "tenant-alpha")] + [InlineData("tenant-alpha", "company-other")] + [InlineData("tenant-alpha", "")] + public async Task LedgerCloseManagementEndpoints_ForeignTenantOrCompany_DeniesPlanLockAndReopen( + string ownerTenantId, + string ownerCompanyId) + { + var workflowId = Guid.NewGuid(); + var ledgerBookId = Guid.NewGuid(); + var fundAccountId = Guid.NewGuid(); + var plan = BuildScopedClosePlan(workflowId, ledgerBookId, fundAccountId); + var service = Substitute.For(); + service.GetPeriodPlanScopedAsync( + workflowId, + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(plan); + var ledger = BuildScopedCloseLedger(ledgerBookId, fundAccountId); + var registry = Substitute.For(); + registry.ResolveAsync("fund-profile-alpha", Arg.Any()) + .Returns(new FundProfileOwnership("fund-profile-alpha", ownerTenantId, ownerCompanyId)); + + await using var app = await CreateAppAsync( + services => + { + services.AddSingleton(service); + services.AddSingleton(ledger); + services.AddSingleton(registry); + }, + currentUserPermissions: UserPermission.AdminMaintenance, + currentUserRole: UserRole.Controller, + currentUserCompanyId: "tenant-alpha"); + var client = app.GetTestClient(); + var planRoute = UiApiRoutes.LedgerCloseManagementPeriodPlan + .Replace("{workflowId:guid}", workflowId.ToString("D")); + + using var planResponse = await client.GetAsync(planRoute); + using var lockResponse = await client.PostAsJsonAsync( + UiApiRoutes.LedgerCloseManagementPeriodLock, + new LockClosePeriodRequestDto(workflowId, 7, "actor", "scope probe", "report", [])); + using var reopenResponse = await client.PostAsJsonAsync( + UiApiRoutes.LedgerCloseManagementPeriodReopen, + new ReopenClosePeriodRequestDto( + workflowId, + 7, + "actor", + "Controller", + "scope probe", + "incident", + "justification", + "approval", + "impact", + ["evidence:scope-probe"], + "correlation")); + + planResponse.StatusCode.Should().Be(HttpStatusCode.Forbidden); + lockResponse.StatusCode.Should().Be(HttpStatusCode.Forbidden); + reopenResponse.StatusCode.Should().Be(HttpStatusCode.Forbidden); + await service.DidNotReceiveWithAnyArgs().LockClosePeriodScopedAsync(default!, default!, default, default, default); + await service.DidNotReceiveWithAnyArgs().ReopenClosePeriodScopedAsync(default!, default!, default, default, default); + } + + [Fact] + public async Task LedgerCloseManagementEndpoints_MissingTenantScope_DeniesPlanLockAndReopen() + { + var workflowId = Guid.NewGuid(); + var plan = BuildScopedClosePlan(workflowId, Guid.NewGuid(), Guid.NewGuid()); + var service = Substitute.For(); + service.GetPeriodPlanScopedAsync( + workflowId, + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(plan); + + await using var app = await CreateAppAsync( + services => services.AddSingleton(service), + currentUserPermissions: UserPermission.AdminMaintenance, + currentUserRole: UserRole.Controller, + currentUserCompanyId: null); + var client = app.GetTestClient(); + var planRoute = UiApiRoutes.LedgerCloseManagementPeriodPlan + .Replace("{workflowId:guid}", workflowId.ToString("D")); + + using var planResponse = await client.GetAsync(planRoute); + using var lockResponse = await client.PostAsJsonAsync( + UiApiRoutes.LedgerCloseManagementPeriodLock, + new LockClosePeriodRequestDto(workflowId, 7, "actor", "scope probe", "report", [])); + using var reopenResponse = await client.PostAsJsonAsync( + UiApiRoutes.LedgerCloseManagementPeriodReopen, + new ReopenClosePeriodRequestDto( + workflowId, + 7, + "actor", + "Controller", + "scope probe", + "incident", + "justification", + "approval", + "impact", + ["evidence:scope-probe"], + "correlation")); + + planResponse.StatusCode.Should().Be(HttpStatusCode.Forbidden); + lockResponse.StatusCode.Should().Be(HttpStatusCode.Forbidden); + reopenResponse.StatusCode.Should().Be(HttpStatusCode.Forbidden); + await service.DidNotReceiveWithAnyArgs().LockClosePeriodScopedAsync(default!, default!, default, default, default); + await service.DidNotReceiveWithAnyArgs().ReopenClosePeriodScopedAsync(default!, default!, default, default, default); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task LedgerCloseManagementEndpoints_MissingLedgerOrOwnershipService_FailsClosed(bool registerLedger) + { + var workflowId = Guid.NewGuid(); + var ledgerBookId = Guid.NewGuid(); + var fundAccountId = Guid.NewGuid(); + var plan = BuildScopedClosePlan(workflowId, ledgerBookId, fundAccountId); + var service = Substitute.For(); + service.GetPeriodPlanScopedAsync( + workflowId, + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(plan); + + await using var app = await CreateAppAsync( + services => + { + services.AddSingleton(service); + if (registerLedger) + { + services.AddSingleton(BuildScopedCloseLedger(ledgerBookId, fundAccountId)); + } + }, + currentUserPermissions: UserPermission.AdminMaintenance, + currentUserRole: UserRole.Controller, + currentUserCompanyId: "tenant-alpha"); + var client = app.GetTestClient(); + var planRoute = UiApiRoutes.LedgerCloseManagementPeriodPlan + .Replace("{workflowId:guid}", workflowId.ToString("D")); + + using var planResponse = await client.GetAsync(planRoute); + using var lockResponse = await client.PostAsJsonAsync( + UiApiRoutes.LedgerCloseManagementPeriodLock, + new LockClosePeriodRequestDto(workflowId, 7, "actor", "scope probe", "report", [])); + using var reopenResponse = await client.PostAsJsonAsync( + UiApiRoutes.LedgerCloseManagementPeriodReopen, + new ReopenClosePeriodRequestDto( + workflowId, + 7, + "actor", + "Controller", + "scope probe", + "incident", + "justification", + "approval", + "impact", + ["evidence:scope-probe"], + "correlation")); + + planResponse.StatusCode.Should().Be(HttpStatusCode.Forbidden); + lockResponse.StatusCode.Should().Be(HttpStatusCode.Forbidden); + reopenResponse.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task LedgerCloseManagementEndpoints_CorrectLedgerFundAndOwnerScope_AllowsPlanLockAndReopen() + { + var workflowId = Guid.NewGuid(); + var ledgerBookId = Guid.NewGuid(); + var fundAccountId = Guid.NewGuid(); + var plan = BuildScopedClosePlan(workflowId, ledgerBookId, fundAccountId); + var service = Substitute.For(); + service.GetPeriodPlanScopedAsync( + workflowId, + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(plan); + service.LockClosePeriodScopedAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new ClosePeriodLockResultDto(false, plan, null)); + service.ReopenClosePeriodScopedAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new ClosePeriodReopenResultDto(false, plan, null, null)); + var ledger = BuildScopedCloseLedger(ledgerBookId, fundAccountId); + var registry = Substitute.For(); + registry.ResolveAsync("fund-profile-alpha", Arg.Any()) + .Returns(new FundProfileOwnership("fund-profile-alpha", "tenant-alpha", "tenant-alpha")); + + await using var app = await CreateAppAsync( + services => + { + services.AddSingleton(service); + services.AddSingleton(ledger); + services.AddSingleton(registry); + }, + currentUserPermissions: UserPermission.AdminMaintenance, + currentUserRole: UserRole.Controller, + currentUserCompanyId: "tenant-alpha"); + var client = app.GetTestClient(); + var planRoute = UiApiRoutes.LedgerCloseManagementPeriodPlan + .Replace("{workflowId:guid}", workflowId.ToString("D")); + + using var planResponse = await client.GetAsync(planRoute); + using var lockResponse = await client.PostAsJsonAsync( + UiApiRoutes.LedgerCloseManagementPeriodLock, + new LockClosePeriodRequestDto(workflowId, 7, "actor", "scope probe", "report", [])); + using var reopenResponse = await client.PostAsJsonAsync( + UiApiRoutes.LedgerCloseManagementPeriodReopen, + new ReopenClosePeriodRequestDto( + workflowId, + 7, + "actor", + "Controller", + "scope probe", + "incident", + "justification", + "approval", + "impact", + ["evidence:scope-probe"], + "correlation")); + + planResponse.StatusCode.Should().Be(HttpStatusCode.OK); + lockResponse.StatusCode.Should().Be(HttpStatusCode.OK); + reopenResponse.StatusCode.Should().Be(HttpStatusCode.OK); + await service.Received(1).LockClosePeriodScopedAsync( + Arg.Any(), + "ops-user", + "tenant-alpha", + "tenant-alpha", + Arg.Any()); + await service.Received(1).ReopenClosePeriodScopedAsync( + Arg.Any(), + "ops-user", + "tenant-alpha", + "tenant-alpha", + Arg.Any()); + } + [Fact] public async Task LedgerCloseManagementService_BlocksLateAdjustmentAfterPeriodLock() { @@ -2032,7 +2290,8 @@ public async Task LedgerAccountingCloseAndReportPackages_RetainDurableHistoryAcr await using (var app = await CreateAppAsync( services => RegisterDurableOperationsContinuityServices(services, dataRoot), - currentUserPermissions: UserPermission.AdminMaintenance)) + currentUserPermissions: UserPermission.AdminMaintenance, + currentUserCompanyId: null)) { var client = app.GetTestClient(); @@ -2087,7 +2346,8 @@ public async Task LedgerAccountingCloseAndReportPackages_RetainDurableHistoryAcr await using var restartedApp = await CreateAppAsync( services => RegisterDurableOperationsContinuityServices(services, dataRoot), - currentUserPermissions: UserPermission.AdminMaintenance); + currentUserPermissions: UserPermission.AdminMaintenance, + currentUserCompanyId: null); var restartedClient = restartedApp.GetTestClient(); var planRoute = UiApiRoutes.LedgerCloseManagementPeriodPlan.Replace("{workflowId:guid}", workflowId.ToString("D")); @@ -2503,6 +2763,56 @@ private static PrivateCapitalCloseCockpitDto BuildPrivateCapitalCloseCockpit(Gui LiveCapabilities: ["Fund/book/period close lane projection"], PlannedCapabilities: ["Live payment release"]); + private static ClosePeriodPlanDto BuildScopedClosePlan( + Guid workflowId, + Guid ledgerBookId, + Guid fundAccountId) + => new( + $"close-plan-{workflowId:D}", + fundAccountId.ToString("D"), + ledgerBookId, + "2026-06", + new DateOnly(2026, 6, 1), + new DateOnly(2026, 6, 30), + new DateOnly(2026, 7, 5), + IsPeriodLocked: true, + Tasks: [], + LateAdjustments: [], + MaterialityPolicy: new MaterialityPolicyDto("scope-test", 10_000m, 0.01m, "USD", "Controller", true), + WorkflowVersion: 7); + + private static ILedgerBookService BuildScopedCloseLedger(Guid ledgerBookId, Guid fundAccountId) + { + var ledger = Substitute.For(); + ledger.GetBookAsync(ledgerBookId, Arg.Any()) + .Returns(new LedgerBookDto( + ledgerBookId, + "fund-profile-alpha", + fundAccountId, + FundStructureNodeKindDto.Account, + "Fund Alpha primary ledger", + "USD", + DateTimeOffset.Parse("2026-06-01T00:00:00Z"), + DateTimeOffset.Parse("2026-06-30T23:59:59Z"))); + ledger.ListPeriodsAsync(Arg.Any(), Arg.Any()) + .Returns( + [ + new LedgerPeriodDto( + Guid.NewGuid(), + ledgerBookId, + 2026, + 6, + "2026-06", + new DateOnly(2026, 6, 1), + new DateOnly(2026, 6, 30), + LedgerPeriodStatusDto.HardClosed, + DateTimeOffset.Parse("2026-06-01T00:00:00Z"), + DateTimeOffset.Parse("2026-07-03T12:00:00Z"), + 3) + ]); + return ledger; + } + private sealed class StubPrivateCapitalCloseCockpitService : IPrivateCapitalCloseCockpitService { private readonly PrivateCapitalCloseCockpitDto _cockpit; @@ -2522,7 +2832,9 @@ public Task GetCockpitAsync( Guid? fundAccountId = null, string? periodId = null, string? entityId = null, - CancellationToken ct = default) + CancellationToken ct = default, + string? tenantId = null, + string? companyId = null) { ct.ThrowIfCancellationRequested(); _captured?.Add((fundProfileId, ledgerBookId, fundAccountId, periodId, entityId)); diff --git a/tests/Meridian.Wpf.Tests/Features/Accounting/AccountingFeatureModuleTests.cs b/tests/Meridian.Wpf.Tests/Features/Accounting/AccountingFeatureModuleTests.cs index 9cdf8648a6..87d7461ad6 100644 --- a/tests/Meridian.Wpf.Tests/Features/Accounting/AccountingFeatureModuleTests.cs +++ b/tests/Meridian.Wpf.Tests/Features/Accounting/AccountingFeatureModuleTests.cs @@ -17,6 +17,7 @@ using Meridian.Wpf.ViewModels.Accounting; using Meridian.Wpf.Views; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; namespace Meridian.Wpf.Tests.Features.Accounting; @@ -80,6 +81,22 @@ public void Register_AddsAccountingViewModelsPagesAndServicesWithIntendedLifetim DesktopFeatureModuleTestAssertions.AssertRegistered(services, ServiceLifetime.Singleton); DesktopFeatureModuleTestAssertions.AssertRegistered(services, ServiceLifetime.Singleton); DesktopFeatureModuleTestAssertions.AssertRegistered(services, ServiceLifetime.Singleton); + DesktopFeatureModuleTestAssertions.AssertRegistered(services, ServiceLifetime.Singleton); + DesktopFeatureModuleTestAssertions.AssertRegistered(services, ServiceLifetime.Singleton); + DesktopFeatureModuleTestAssertions.AssertRegistered(services, ServiceLifetime.Singleton); + DesktopFeatureModuleTestAssertions.AssertRegistered(services, ServiceLifetime.Singleton); + DesktopFeatureModuleTestAssertions.AssertRegistered(services, ServiceLifetime.Singleton); + DesktopFeatureModuleTestAssertions.AssertRegistered(services, ServiceLifetime.Singleton); + DesktopFeatureModuleTestAssertions.AssertRegistered(services, ServiceLifetime.Singleton); + DesktopFeatureModuleTestAssertions.AssertRegistered(services, ServiceLifetime.Singleton); + services.Should().Contain(descriptor => + descriptor.ServiceType == typeof(IHostedService) && + descriptor.ImplementationType == typeof(DailyValuationSchedulerHostedService) && + descriptor.Lifetime == ServiceLifetime.Singleton); + services.Should().Contain(descriptor => + descriptor.ServiceType == typeof(IHostedService) && + descriptor.ImplementationType == typeof(AutomatedJournalSchedulerHostedService) && + descriptor.Lifetime == ServiceLifetime.Singleton); DesktopFeatureModuleTestAssertions.AssertRegistered(services, ServiceLifetime.Singleton); DesktopFeatureModuleTestAssertions.AssertRegistered(services, ServiceLifetime.Singleton); DesktopFeatureModuleTestAssertions.AssertRegistered(services, ServiceLifetime.Singleton); @@ -97,6 +114,37 @@ public void Register_AddsAccountingViewModelsPagesAndServicesWithIntendedLifetim DesktopFeatureModuleTestAssertions.AssertRegistered(services, ServiceLifetime.Singleton); } + [Fact] + public async Task Register_ResolvesMonthlyAutomationGraph_AndHostedOneShot() + { + var services = new ServiceCollection(); + services.AddLogging(); + var configurationStore = new InMemoryAccountingConfigurationStore(); + var auditStore = new InMemoryAccountingActionAuditStore(); + var configurationService = new AccountingConfigurationService(configurationStore, auditStore); + var draftStore = new InMemoryManualJournalEntryDraftStore(); + var workbench = new ManualJournalEntryWorkbenchService(draftStore, configurationService, auditStore); + var scheduleStore = new InMemoryAutomatedJournalScheduleStore(); + services.AddSingleton(configurationStore); + services.AddSingleton(auditStore); + services.AddSingleton(configurationService); + services.AddSingleton(draftStore); + services.AddSingleton(workbench); + services.AddSingleton(scheduleStore); + services.AddSingleton(scheduleStore); + + new AccountingFeatureModule().Register(services); + await using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService().Should().NotBeNull(); + var hosted = provider.GetServices() + .OfType() + .Should().ContainSingle().Subject; + var result = await hosted.RunOnceAsync(); + + result.Runs.Should().BeEmpty(); + } + [Theory] [InlineData("AccountingShell", "AccountingShell", "accounting")] [InlineData("GovernanceShell", "AccountingShell", "accounting")] diff --git a/tests/Meridian.Wpf.Tests/ViewModels/AccountingCloseViewModelTests.cs b/tests/Meridian.Wpf.Tests/ViewModels/AccountingCloseViewModelTests.cs index cc876d6b05..cd4db3a62b 100644 --- a/tests/Meridian.Wpf.Tests/ViewModels/AccountingCloseViewModelTests.cs +++ b/tests/Meridian.Wpf.Tests/ViewModels/AccountingCloseViewModelTests.cs @@ -81,7 +81,8 @@ public async Task LoadClosePlanCommand_LoadsSharedClosePlanByWorkflowId() viewModel.SignOffCloseTaskCommand.CanExecute(null).Should().BeTrue(); viewModel.ReviewLateAdjustmentCommand.CanExecute(null).Should().BeFalse(); viewModel.ReviewCloseEvidenceCommand.CanExecute(null).Should().BeFalse(); - viewModel.LockClosePeriodCommand.CanExecute(null).Should().BeTrue(); + viewModel.QueueClosingEntriesCommand.CanExecute(null).Should().BeFalse(); + viewModel.LockClosePeriodCommand.CanExecute(null).Should().BeFalse(); viewModel.CloseTaskRows.Should().ContainSingle(row => row.Name == "task-nav"); viewModel.CloseSignOffMatrixRows.Should().ContainSingle(row => row.Name == "task-nav:controller"); viewModel.CloseLateAdjustmentRows.Should().ContainSingle(row => row.Name == "late-adjustment-1"); @@ -96,6 +97,26 @@ public async Task LoadClosePlanCommand_LoadsSharedClosePlanByWorkflowId() row.Name == "Period lock" && row.Status == "Ready for review" && row.Detail == "Retain close-package evidence and lock the period."); + viewModel.ClosingEntriesGate.Should().NotBeNull(); + viewModel.ClosingEntriesGate!.State.Should().Be(ClosePostingGateStateDto.DraftQueued); + viewModel.ClosingEntriesGateStatusText.Should().Be("Draft queued"); + viewModel.ClosingEntriesNetIncomeRollText.Should().Be("+1,500.00 USD"); + viewModel.ClosingEntriesBalanceCountText.Should().Be("2 temporary-account balances"); + viewModel.ClosingEntriesLockPostureText.Should().Be("Posting required before lock"); + viewModel.ClosingEntriesJournalEvidenceText.Should().Contain("Draft aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee (Draft)"); + viewModel.ClosingEntriesJournalEvidenceText.Should().Contain("closing batches 11111111-2222-3333-4444-555555555555"); + viewModel.ClosingEntriesJournalEvidenceText.Should().Contain("reversal drafts 66666666-7777-8888-9999-aaaaaaaaaaaa"); + viewModel.ClosingEntryBalanceRows.Should().HaveCount(2); + viewModel.ClosingEntryBalanceRows.Should().Contain(row => + row.AccountName == "Advisory fee revenue (ADV-FEE)" && + row.AccountType == "Revenue" && + row.Balance == "+2,500.00 USD" && + row.Scope == "Fund: fund-alpha | Entity: entity-alpha | Sleeve: sleeve-credit | External class: private-fund" && + row.FinancialAccountId == "financial-account-revenue"); + viewModel.ClosingEntryBalanceRows.Should().Contain(row => + row.AccountName == "Fund administration expense" && + row.Balance == "-1,000.00 USD" && + row.Scope == "Fund: fund-alpha | Entity: entity-alpha | Cost center: fund-operations"); viewModel.CloseWorkflowSteps.Should().HaveCount(6); viewModel.CloseWorkflowSteps.Should().Contain(step => step.StepId == "close-setup" && @@ -136,7 +157,8 @@ public void ApplyClosePlan_ShowsFallbackWhenSharedOperatingCoverageIsMissing() { var closePlan = BuildClosePlan(Guid.Parse("11111111-2222-3333-4444-555555555555")) with { - OperatingCoverage = [] + OperatingCoverage = [], + ClosingEntriesGate = null }; var viewModel = new AccountingCloseViewModel(Substitute.For()); @@ -146,6 +168,10 @@ public void ApplyClosePlan_ShowsFallbackWhenSharedOperatingCoverageIsMissing() row.Name == "Operating coverage" && row.Status == "Missing" && row.Detail.Contains("did not return", StringComparison.OrdinalIgnoreCase)); + viewModel.ClosingEntriesGate.Should().BeNull(); + viewModel.ClosingEntriesGateStatusText.Should().Be("Not supplied"); + viewModel.ClosingEntriesNetIncomeRollText.Should().Be("Net-income roll unavailable"); + viewModel.ClosingEntryBalanceRows.Should().BeEmpty(); } [Fact] @@ -932,12 +958,132 @@ public async Task ReviewCloseEvidenceCommand_UsesSelectedDesktopBlockerAndNotes( viewModel.CloseEvidenceReviewStatusText.Should().Be("Retained WPF evidence review for blocker LateAdjustmentRequiresApproval."); } + [Fact] + public async Task LoadThenLock_UsesWorkflowVersionReturnedWithPlan() + { + var workflowId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + var closePlan = WithClosingEntriesGate( + BuildClosePlan(Guid.Parse("11111111-2222-3333-4444-555555555555")), + ClosePostingGateStateDto.Posted, + isReadyForLock: true) with + { + WorkflowVersion = 12 + }; + var service = new CapturingCloseManagementService(closePlan); + var viewModel = new AccountingCloseViewModel(Substitute.For(), service) + { + CloseWorkflowIdText = workflowId.ToString("D") + }; + + await viewModel.LoadClosePlanCommand.ExecuteAsync(null); + await viewModel.LockClosePeriodCommand.ExecuteAsync(null); + + service.LockRequest.Should().NotBeNull(); + service.LockRequest!.ExpectedWorkflowVersion.Should().Be(12); + service.LockRequest.PrepareClosingEntriesOnly.Should().BeFalse(); + } + + [Theory] + [InlineData(ClosePostingGateStateDto.Required, false, true, false)] + [InlineData(ClosePostingGateStateDto.DraftQueued, false, false, false)] + [InlineData(ClosePostingGateStateDto.Submitted, false, false, false)] + [InlineData(ClosePostingGateStateDto.Approved, false, false, false)] + [InlineData(ClosePostingGateStateDto.Posted, true, false, true)] + [InlineData(ClosePostingGateStateDto.NotRequired, true, false, true)] + public void ClosingEntryGate_DrivesQueueAndHardLockEligibility( + ClosePostingGateStateDto state, + bool isReadyForLock, + bool queueEnabled, + bool hardLockEnabled) + { + var workflowId = Guid.NewGuid(); + var closePlan = WithClosingEntriesGate( + BuildClosePlan(Guid.Parse("11111111-2222-3333-4444-555555555555")), + state, + isReadyForLock); + var viewModel = new AccountingCloseViewModel( + Substitute.For(), + new CapturingCloseManagementService(closePlan)); + + viewModel.ApplyClosePlan(workflowId, 7, closePlan); + + viewModel.QueueClosingEntriesCommand.CanExecute(null).Should().Be(queueEnabled); + viewModel.LockClosePeriodCommand.CanExecute(null).Should().Be(hardLockEnabled); + } + + [Fact] + public void ApplyClosePlan_WhenClosingGateChanges_NotifiesQueueAndHardLockCommands() + { + var workflowId = Guid.NewGuid(); + var requiredPlan = WithClosingEntriesGate( + BuildClosePlan(Guid.Parse("11111111-2222-3333-4444-555555555555")), + ClosePostingGateStateDto.Required, + isReadyForLock: false); + var postedPlan = WithClosingEntriesGate( + requiredPlan, + ClosePostingGateStateDto.Posted, + isReadyForLock: true); + var viewModel = new AccountingCloseViewModel( + Substitute.For(), + new CapturingCloseManagementService(requiredPlan)); + viewModel.ApplyClosePlan(workflowId, 7, requiredPlan); + var queueNotifications = 0; + var lockNotifications = 0; + viewModel.QueueClosingEntriesCommand.CanExecuteChanged += (_, _) => queueNotifications++; + viewModel.LockClosePeriodCommand.CanExecuteChanged += (_, _) => lockNotifications++; + + viewModel.ApplyClosePlan(workflowId, 8, postedPlan); + + queueNotifications.Should().BeGreaterThan(0); + lockNotifications.Should().BeGreaterThan(0); + viewModel.QueueClosingEntriesCommand.CanExecute(null).Should().BeFalse(); + viewModel.LockClosePeriodCommand.CanExecute(null).Should().BeTrue(); + } + + [Fact] + public async Task QueueClosingEntriesCommand_SendsPreparationOnlyRequestAndKeepsHardLockDisabled() + { + var workflowId = Guid.Parse("99999999-aaaa-bbbb-cccc-dddddddddddd"); + var requiredPlan = WithClosingEntriesGate( + BuildClosePlan(Guid.Parse("11111111-2222-3333-4444-555555555555")), + ClosePostingGateStateDto.Required, + isReadyForLock: false) with + { + WorkflowVersion = 9 + }; + var queuedPlan = WithClosingEntriesGate( + requiredPlan, + ClosePostingGateStateDto.DraftQueued, + isReadyForLock: false); + var service = new CapturingCloseManagementService(requiredPlan) + { + LockResult = new ClosePeriodLockResultDto(false, queuedPlan, null) + }; + var viewModel = new AccountingCloseViewModel(Substitute.For(), service); + viewModel.ApplyClosePlan(workflowId, requiredPlan); + + viewModel.QueueClosingEntriesCommand.CanExecute(null).Should().BeTrue(); + viewModel.LockClosePeriodCommand.CanExecute(null).Should().BeFalse(); + + await viewModel.QueueClosingEntriesCommand.ExecuteAsync(null); + + service.LockRequest.Should().NotBeNull(); + service.LockRequest!.ExpectedWorkflowVersion.Should().Be(9); + service.LockRequest.PrepareClosingEntriesOnly.Should().BeTrue(); + service.LockRequest.CorrelationId.Should().Be($"wpf-close-period-prepare-closing-entries-{workflowId:D}"); + viewModel.QueueClosingEntriesCommand.CanExecute(null).Should().BeFalse(); + viewModel.LockClosePeriodCommand.CanExecute(null).Should().BeFalse(); + } + [Fact] public async Task LockClosePeriodCommand_BuildsGovernedRequestAndRendersSharedBlockers() { var workflowId = Guid.Parse("bbbbbbbb-cccc-dddd-eeee-ffffffffffff"); var ledgerBookId = Guid.Parse("11111111-2222-3333-4444-555555555555"); - var closePlan = BuildClosePlan(ledgerBookId); + var closePlan = WithClosingEntriesGate( + BuildClosePlan(ledgerBookId), + ClosePostingGateStateDto.Posted, + isReadyForLock: true); var service = new CapturingCloseManagementService(closePlan) { LockResult = new ClosePeriodLockResultDto( @@ -967,6 +1113,7 @@ public async Task LockClosePeriodCommand_BuildsGovernedRequestAndRendersSharedBl service.LockRequest.ExpectedWorkflowVersion.Should().Be(7); service.LockRequest.Actor.Should().Be("wpf-accounting-controller"); service.LockRequest.ActionOrigin.Should().Be(OperationsActionOriginDto.HumanOperator); + service.LockRequest.PrepareClosingEntriesOnly.Should().BeFalse(); service.LockRequest.ReportPackId.Should().Be("report-pack-fund-alpha-2026-05"); service.LockRequest.CorrelationId.Should().Be($"wpf-close-period-lock-{workflowId:D}"); service.LockRequest.ClosePackageId.Should().Be("close-package-fund-alpha-2026-05"); @@ -992,8 +1139,14 @@ public async Task LockClosePeriodCommand_UpdatesLoadedPlanWhenSharedServiceLocks { var workflowId = Guid.Parse("cccccccc-dddd-eeee-ffff-aaaaaaaaaaaa"); var ledgerBookId = Guid.Parse("11111111-2222-3333-4444-555555555555"); - var closePlan = BuildClosePlan(ledgerBookId, signedOff: true); - var lockedPlan = closePlan with { IsPeriodLocked = true }; + var closePlan = WithClosingEntriesGate( + BuildClosePlan(ledgerBookId, signedOff: true), + ClosePostingGateStateDto.Posted, + isReadyForLock: true) with + { + WorkflowVersion = 7 + }; + var lockedPlan = closePlan with { IsPeriodLocked = true, WorkflowVersion = 8 }; var service = new CapturingCloseManagementService(closePlan) { LockResult = new ClosePeriodLockResultDto( @@ -1152,9 +1305,62 @@ private static ClosePeriodPlanDto BuildClosePlan( EvidenceCount: 0, BlockingIssueCount: 0, "Retain close-package evidence and lock the period.") - ]); + ], + ClosingEntriesGate: new ClosePostingGateDto( + $"closing-entries:{ledgerBookId:D}:2026-05", + "Post closing entries", + ClosePostingGateStateDto.DraftQueued, + IsReadyForLock: false, + NetIncomeRoll: 1_500m, + TemporaryAccountBalanceCount: 2, + Detail: "A closing-entry draft is queued for controller approval and posting.", + DraftJournalEntryId: Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), + DraftStatus: ManualJournalEntryStatusDto.Draft, + IdempotencyKey: $"closing-entries:{ledgerBookId:D}:2026-05:v1", + Balances: + [ + new ClosePostingBalanceDto( + "Advisory fee revenue", + "Revenue", + 2_500m, + "ADV-FEE", + "financial-account-revenue", + new LedgerDimensionSetDto( + FundId: "fund-alpha", + EntityId: "entity-alpha", + SleeveId: "sleeve-credit", + ExternalGlDimensions: new Dictionary + { + ["class"] = "private-fund" + })), + new ClosePostingBalanceDto( + "Fund administration expense", + "Expense", + -1_000m, + FinancialAccountId: "financial-account-expense", + Dimensions: new LedgerDimensionSetDto( + FundId: "fund-alpha", + EntityId: "entity-alpha", + CostCenterId: "fund-operations")) + ], + EvidenceLinks: ["evidence/closing-entry-preview"], + ClosingBatchJournalEntryIds: [Guid.Parse("11111111-2222-3333-4444-555555555555")], + ReversalDraftJournalEntryIds: [Guid.Parse("66666666-7777-8888-9999-aaaaaaaaaaaa")])); } + private static ClosePeriodPlanDto WithClosingEntriesGate( + ClosePeriodPlanDto closePlan, + ClosePostingGateStateDto state, + bool isReadyForLock) + => closePlan with + { + ClosingEntriesGate = closePlan.ClosingEntriesGate! with + { + State = state, + IsReadyForLock = isReadyForLock + } + }; + private static ClosePeriodPlanDto BuildClosePlanWithReportTask(Guid ledgerBookId) { var closePlan = BuildClosePlan(ledgerBookId); diff --git a/tests/Meridian.Wpf.Tests/ViewModels/DataQualityViewModelCharacterizationTests.cs b/tests/Meridian.Wpf.Tests/ViewModels/DataQualityViewModelCharacterizationTests.cs index 979f53013b..9d6c152b5f 100644 --- a/tests/Meridian.Wpf.Tests/ViewModels/DataQualityViewModelCharacterizationTests.cs +++ b/tests/Meridian.Wpf.Tests/ViewModels/DataQualityViewModelCharacterizationTests.cs @@ -2,6 +2,9 @@ using System.Text.Json; using System.Windows.Media; using FluentAssertions; +using Meridian.Contracts.Api.Quality; +using Meridian.Ui.Services.DataQuality; +using Meridian.Ui.Services.Services; using Meridian.Wpf.Models; using Meridian.Wpf.Services; using Meridian.Wpf.ViewModels; @@ -179,8 +182,115 @@ public void ApplySymbolFilter_WhenLibraryIsEmpty_KeepsSetupEmptyState() viewModel.SymbolEmptyStateDetail.Should().Be("Add symbols from the workspace before running quality checks."); } - private static DataQualityViewModel CreateSubject() => - new(StatusService.Instance, LoggingService.Instance, NotificationService.Instance); + [Fact] + public async Task RefreshAndRepairGap_UsesInjectedCompositeServicesAndRetainsExactRemediationIdentity() + { + var apiClient = Substitute.For(); + var presentationService = Substitute.For(); + var observedAt = new DateTimeOffset(2026, 7, 15, 12, 0, 0, TimeSpan.Zero); + var snapshot = new DataQualityPresentationSnapshot + { + IsAvailable = true, + IsPartial = true, + DashboardVersion = "quality-v42", + OverallScore = 81.25, + OverallScoreText = "81.3", + OverallGradeText = "B", + StatusText = "Partial evidence", + Symbols = + [ + new DataQualitySymbolPresentation + { + Symbol = "AAPL", + Score = 81.25, + ScoreFormatted = "81.3%", + Grade = "B", + Status = "Warning", + Issues = "1 open gap", + LastUpdate = observedAt, + LastUpdateFormatted = "Now", + Components = + [ + new QualityComponentResponse("StoredCompleteness", "Stored completeness", 0.4, 98.5, "Available", observedAt, 0, "Complete"), + new QualityComponentResponse("StreamingFreshness", "Streaming freshness", 0.35, null, "Unavailable", null, 0, "Feed offline"), + new QualityComponentResponse("AdapterGapIntegrity", "Adapter gap integrity", 0.25, 62.0, "Partial", observedAt, 1, "One gap") + ], + GapCount = 1 + } + ], + Gaps = + [ + new DataQualityGapPresentation + { + GapId = "gap-stable-17", + Symbol = "AAPL", + Provider = "polygon", + Description = "Missing minute bars", + Duration = "15m", + DashboardVersion = "quality-v42", + CanRepair = true + } + ] + }; + presentationService + .GetSnapshotAsync("7d", Arg.Any()) + .Returns(snapshot); + apiClient + .RepairGapAsync( + "AAPL", + Arg.Is(request => + request.GapId == "gap-stable-17" && request.DashboardVersion == "quality-v42"), + Arg.Any()) + .Returns(new QualityGapRemediationResponse( + "gap-stable-17", + "AAPL", + "Completed", + "polygon", + new DateOnly(2026, 7, 14), + new DateOnly(2026, 7, 15), + "repair-17", + "Repair queued")); + + using var viewModel = new DataQualityViewModel( + StatusService.Instance, + LoggingService.Instance, + NotificationService.Instance, + apiClient, + presentationService); + + await viewModel.RefreshAsync(); + + viewModel.StatusText.Should().Be("Partial evidence"); + viewModel.SymbolQuality.Should().ContainSingle(); + viewModel.SymbolQuality[0].StoredCompletenessText.Should().Be("98.5"); + viewModel.SymbolQuality[0].StreamingFreshnessText.Should().Be("--"); + viewModel.SymbolQuality[0].AdapterIntegrityText.Should().Be("62.0"); + viewModel.Gaps.Should().ContainSingle(gap => + gap.GapId == "gap-stable-17" && gap.DashboardVersion == "quality-v42"); + + (await viewModel.RepairGapAsync("gap-stable-17")).Should().BeTrue(); + viewModel.Gaps.Should().BeEmpty(); + await apiClient.Received(1).RepairGapAsync( + "AAPL", + Arg.Is(request => + request.GapId == "gap-stable-17" && request.DashboardVersion == "quality-v42"), + Arg.Any()); + } + + private static DataQualityViewModel CreateSubject() + { + var apiClient = Substitute.For(); + var presentationService = Substitute.For(); + presentationService + .GetSnapshotAsync(Arg.Any(), Arg.Any()) + .Returns(new DataQualityPresentationSnapshot()); + return new DataQualityViewModel( + StatusService.Instance, + LoggingService.Instance, + NotificationService.Instance, + apiClient, + presentationService); + } private static T InvokePrivate(object instance, string methodName, params object[] args) { diff --git a/tests/Meridian.Wpf.Tests/ViewModels/FundLedgerViewModelTests.cs b/tests/Meridian.Wpf.Tests/ViewModels/FundLedgerViewModelTests.cs index 2b5ae41ac7..840efa9e3d 100644 --- a/tests/Meridian.Wpf.Tests/ViewModels/FundLedgerViewModelTests.cs +++ b/tests/Meridian.Wpf.Tests/ViewModels/FundLedgerViewModelTests.cs @@ -2304,7 +2304,9 @@ public Task GetCockpitAsync( Guid? fundAccountId = null, string? periodId = null, string? entityId = null, - CancellationToken ct = default) + CancellationToken ct = default, + string? tenantId = null, + string? companyId = null) { ct.ThrowIfCancellationRequested(); RequestedFundProfileId = fundProfileId;