From bb1f6545f9417adc9394e2d333005eb4c38c2d43 Mon Sep 17 00:00:00 2001 From: rodoHasArrived <55965792+rodoHasArrived@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:59:59 -0700 Subject: [PATCH 1/3] accounting see above --- ...-provider-accounting-brainstorm-2026-07.md | 27 +- .../Accounting/DailyMarkToMarketService.cs | 380 ++++++++++++++-- src/Meridian.Contracts/Api/UiApiRoutes.cs | 2 + .../AutomatedJournalScheduleDtos.cs | 32 +- .../Workstation/DailyValuationScheduleDtos.cs | 43 +- .../AccountingCloseManagementService.cs | 202 +++++++-- .../AccountingClosePostingWorkbench.cs | 6 +- .../PrivateCapitalCloseCockpitService.cs | 66 ++- .../AlphaVantageHistoricalDataProvider.cs | 11 +- .../Core/Backfill/BackfillWorkerService.cs | 36 +- .../Adapters/OpenFigi/OpenFigiClient.cs | 13 +- .../ProviderConnectionSupervisor.cs | 109 ++++- .../Resilience/WebSocketConnectionManager.cs | 104 ++++- .../DailyPortfolioPriceMark.cs | 46 +- .../DailyPortfolioPricingDraftBuilder.cs | 245 ++++++++-- .../DailyPortfolioPricingLine.cs | 11 +- .../DailyPortfolioPricingProjection.cs | 23 +- .../DailyPortfolioPricingProjector.cs | 47 +- src/Meridian.Ledger/Ledger.cs | 210 ++++++++- .../Ledger/GovernedLedgerPostingTarget.cs | 204 ++++++++- .../Ledger/LedgerPeriodPostingGuard.cs | 19 +- .../LedgerEndpoints.JournalAutomation.cs | 144 +++++- .../Endpoints/LedgerEndpoints.cs | 281 +++++++++++- .../AccountingClosePostingWorkbenchBridge.cs | 192 +++++--- .../AutomatedJournalDraftIntakeService.cs | 122 ++++- .../Services/AutomatedJournalIntakeRunner.cs | 75 +++- .../Services/AutomatedJournalScheduleStore.cs | 340 ++++++++++++-- .../AutomatedJournalScheduledWorker.cs | 194 +++++++- .../DailyValuationBatchLifecycleService.cs | 417 ++++++++++++++++++ .../Services/DailyValuationPositionService.cs | 333 ++++++++++++++ .../Services/DailyValuationScheduler.cs | 298 +++++++++++-- .../LedgerMarkToMarketCarryingValueSource.cs | 47 ++ ...lJournalEntryWorkbenchService.Lifecycle.cs | 44 ++ .../ManualJournalEntryWorkbenchService.cs | 122 ++++- .../WorkstationServiceCollectionExtensions.cs | 17 +- .../src/components/meridian/workspace-nav.tsx | 4 - .../dashboard/src/design-system/button.tsx | 1 + .../DailyMarkToMarketServiceTests.cs | 192 +++++++- .../AdditionalProviderContractTests.cs | 7 +- .../Providers/BackfillRetryAfterTests.cs | 21 + .../FreeHistoricalProviderParsingTests.cs | 4 +- .../ProviderConnectionSupervisorTests.cs | 61 +++ .../WebSocketConnectionManagerTests.cs | 35 ++ .../Ledger/DailyPortfolioPricingDeltaTests.cs | 171 +++++++ .../Ledger/LedgerIntegrationTests.cs | 12 + .../GovernedLedgerPostingTargetTests.cs | 307 +++++++++++++ .../Storage/LedgerJournalStoreTests.cs | 6 +- .../SymbolSearch/OpenFigiClientTests.cs | 17 +- .../Ui/AccountingConfigurationServiceTests.cs | 104 ++++- .../Ui/AutomatedJournalScheduleTests.cs | 157 ++++++- 50 files changed, 5127 insertions(+), 434 deletions(-) create mode 100644 src/Meridian.Ui.Shared/Services/DailyValuationBatchLifecycleService.cs create mode 100644 src/Meridian.Ui.Shared/Services/DailyValuationPositionService.cs create mode 100644 src/Meridian.Ui.Shared/Services/LedgerMarkToMarketCarryingValueSource.cs create mode 100644 tests/Meridian.Tests/Ledger/DailyPortfolioPricingDeltaTests.cs create mode 100644 tests/Meridian.Tests/Storage/GovernedLedgerPostingTargetTests.cs diff --git a/docs/product/data-provider-accounting-brainstorm-2026-07.md b/docs/product/data-provider-accounting-brainstorm-2026-07.md index adf51e7bed..24fc882e41 100644 --- a/docs/product/data-provider-accounting-brainstorm-2026-07.md +++ b/docs/product/data-provider-accounting-brainstorm-2026-07.md @@ -20,11 +20,13 @@ --- -## 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: +2026-07-05, with dated update notes where the premise has changed. An independent accounting, +durability, and tenant-isolation audit reopened #6–#10 on 2026-07-14; those rows remain in progress +until the corrected invariants compile and their focused tests pass. Current branch status: | # | Idea | Status | What remains | |---|------|--------|--------------| @@ -32,12 +34,12 @@ implementation pass. The narratives below are preserved as the point-in-time ana | 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. | +| 5 | Failure & rate-limit hardening | Done (2026-07-15 audit) | The catalog exposes immutable, sanitized registration failures; historical and streaming rate diagnostics use coherent lock-guarded snapshots. NYSE, Alpha Vantage, and OpenFIGI now map provider quota responses to typed `RateLimitException`, and the background worker classifies only typed exceptions or preserved HTTP 429 status—not message text. Browser and WPF show current usage, reset, failure, and retry posture while stating that history is unavailable. The new typed-path tests and aggregate CI still need execution. | +| 6 | Mark-to-market wiring | In progress (completion audit) | The provider-mark, governed-draft, hydration, NAV, and cockpit foundation exists. The audit found cumulative unrealized P&L being reposted instead of a daily delta, multi-security drafts without posting-guard Security Master lineage, indefinitely reused configured positions, incomplete same-day correction/batch semantics, tenant-scope takeover risk, and stale cockpit readiness. Delta carrying-value hydration, per-security drafts, fresh position scopes, lineage/currency gates, all-entry batch state, and current-run precedence are being implemented and tested. | +| 7 | Automated journal drafts | In progress (completion audit) | Dividend and fee producers plus a durable schedule foundation exist. Completion now requires truly recurring monthly auto-advance in the configured time zone, durable restart/idempotency proof, immutable tenant/company/identity scope, and explicit capital-account reconciliation and confidence evidence before fee drafts can be ready for approval. | +| 8 | Closing entries + retained-earnings roll | In progress (completion audit) | The retained-earnings projector and governed close workbench exist. Completion now requires a final ready-gate recheck, an actual ledger hard lock, SoftClosed-only closing-entry mutation, retry-safe controller-gated reopen/reversal, hard-close temporary-account guards, and tenant/company/book/period-isolated API proof. | +| 9 | One ledger spine | In progress (completion audit) | Durable posting and hydration exist. The audit is completing dimension-aware snapshot indexes for out-of-order/as-of reads and strengthening crash-after-append semantic equivalence so a retry cannot change policy, book, posting kind, command/source identity, metadata, or evidence. | +| 10 | Fill-to-ledger durability | In progress (completion audit) | Bounded-channel backpressure prevents the original full-channel drop. Completion now requires bounded two-phase shutdown under non-cooperative work, a no-post-after-disposal boundary, deterministic blocked-publisher release, and idempotent repeated disposal proof. | ## The Two Headline Findings @@ -326,6 +328,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..504a414a43 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,10 +403,33 @@ 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) @@ -172,7 +437,10 @@ public async Task PrepareAsync(DailyMarkToMarketRequest re _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 }; + 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.Contracts/Api/UiApiRoutes.cs b/src/Meridian.Contracts/Api/UiApiRoutes.cs index d84e408340..a104919984 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/Workstation/AutomatedJournalScheduleDtos.cs b/src/Meridian.Contracts/Workstation/AutomatedJournalScheduleDtos.cs index beef96584f..07e5e828ad 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,10 @@ 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) { public IReadOnlyList EvidenceLinks { get; init; } = EvidenceLinks ?? []; diff --git a/src/Meridian.Contracts/Workstation/DailyValuationScheduleDtos.cs b/src/Meridian.Contracts/Workstation/DailyValuationScheduleDtos.cs index af090a774b..fb27759f7c 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,45 @@ public sealed record DailyValuationScheduleStatusDto( string Summary, Guid? JournalEntryId, IReadOnlyList EvidenceLinks, - IReadOnlyList Blockers); + IReadOnlyList Blockers, + IReadOnlyList? JournalEntryIds = null, + string? BatchCorrelationId = 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 diff --git a/src/Meridian.FinancialOperations/AccountingClose/AccountingCloseManagementService.cs b/src/Meridian.FinancialOperations/AccountingClose/AccountingCloseManagementService.cs index 6699087c94..6a010f752a 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,13 +642,21 @@ 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); EnsureHumanOrigin(request.ActionOrigin, "configure close period plans"); @@ -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); @@ -643,7 +765,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 +783,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 +801,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"), @@ -714,7 +836,7 @@ request.ExpectedConfiguredAtUtc is { } expectedConfiguredAtUtc && try { await _postingWorkbench.FinalizeHardCloseAsync( - RequirePostingContext(workflow, plan), + RequirePostingContext(workflow, plan, tenantId, companyId), new AccountingClosePostingCommand( resolvedActor, RequireText(request.Rationale, "Rationale"), @@ -758,7 +880,7 @@ [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 @@ -771,9 +893,17 @@ [new AccountingConfigurationValidationIssueDto( transitionIssues); } - 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); @@ -809,7 +939,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 +954,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( @@ -847,7 +977,7 @@ [new AccountingConfigurationValidationIssueDto( } var reversalGate = await _postingWorkbench.ReopenAndQueueClosingReversalsAsync( - RequirePostingContext(workflow, plan), + RequirePostingContext(workflow, plan, tenantId, companyId), new AccountingClosePostingCommand( resolvedActor, RequireText(request.Rationale, "Rationale"), @@ -947,13 +1077,23 @@ private ClosePeriodPlanDto BuildPeriodPlan(OperationsContinuityWorkflowDto workf 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 +1104,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 +1165,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 +1177,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) 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..cb0dd5c2ef 100644 --- a/src/Meridian.FinancialOperations/PrivateCapital/PrivateCapitalCloseCockpitService.cs +++ b/src/Meridian.FinancialOperations/PrivateCapital/PrivateCapitalCloseCockpitService.cs @@ -802,46 +802,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) + { + 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 isReady = latestDraft.Status is ManualJournalEntryStatusDto.Posted or ManualJournalEntryStatusDto.CloseLocked; - var isBlocked = latestDraft.Status is ManualJournalEntryStatusDto.NeedsFix or ManualJournalEntryStatusDto.Rejected; - var laneStatus = isReady + 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 +882,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" }; 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/Core/Backfill/BackfillWorkerService.cs b/src/Meridian.Infrastructure/Adapters/Core/Backfill/BackfillWorkerService.cs index 87d5f043e3..bf5b514d96 100644 --- a/src/Meridian.Infrastructure/Adapters/Core/Backfill/BackfillWorkerService.cs +++ b/src/Meridian.Infrastructure/Adapters/Core/Backfill/BackfillWorkerService.cs @@ -313,12 +313,10 @@ 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); + var isRateLimited = IsRateLimited(ex); + var retryAfter = isRateLimited + ? rateLimit?.RetryAfter ?? TryExtractRetryAfter(ex) + : null; if (isRateLimited && request.AssignedProvider != null) { @@ -389,6 +387,16 @@ private static TimeSpan CalculateBackoff(int attempt, TimeSpan baseDelay, TimeSp return ex.InnerException is { } innerException ? FindRateLimitException(innerException) : null; } + /// + /// 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 @@ -504,19 +512,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/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/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..ec3642f235 100644 --- a/src/Meridian.Infrastructure/Resilience/WebSocketConnectionManager.cs +++ b/src/Meridian.Infrastructure/Resilience/WebSocketConnectionManager.cs @@ -26,17 +26,21 @@ 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; // Transport activity complements the supervisor's lifecycle diagnostics. private DateTimeOffset? _lastMessageReceivedAt; @@ -133,10 +137,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, @@ -518,14 +535,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 +558,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(heartbeat.DisposeAsync().AsTask(), "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); } } 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..c13b453a24 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; @@ -69,6 +75,7 @@ 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); @@ -350,7 +357,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 +439,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 +453,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 +597,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 +615,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) @@ -785,6 +942,12 @@ private static int ComparePostingCountSnapshots(LedgerPostingCountSnapshot left, return timestamp != 0 ? timestamp : left.Sequence.CompareTo(right.Sequence); } + private static int CompareScopedPostings(ScopedLedgerLinePosting left, ScopedLedgerLinePosting right) + { + var timestamp = left.Timestamp.CompareTo(right.Timestamp); + return timestamp != 0 ? timestamp : left.Sequence.CompareTo(right.Sequence); + } + private static LedgerLineInput ToLedgerLineInput(LedgerEntry line) => new() { @@ -839,6 +1002,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.Storage/Ledger/GovernedLedgerPostingTarget.cs b/src/Meridian.Storage/Ledger/GovernedLedgerPostingTarget.cs index f332124bed..d2cd383d93 100644 --- a/src/Meridian.Storage/Ledger/GovernedLedgerPostingTarget.cs +++ b/src/Meridian.Storage/Ledger/GovernedLedgerPostingTarget.cs @@ -39,6 +39,7 @@ public async Task PostAsync( { ArgumentNullException.ThrowIfNull(write); ArgumentNullException.ThrowIfNull(write.Entry); + write = NormalizeWrite(AccountingPostingCommandValidator.NormalizeAndValidate(write)); await _writeGate.WaitAsync(ct).ConfigureAwait(false); try @@ -73,13 +74,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.JournalEntryId == candidate.JournalEntryId && retained.Timestamp == candidate.Timestamp && string.Equals(retained.Description, candidate.Description, StringComparison.Ordinal) - && string.Equals( - retained.Metadata.IdempotencyKey, - candidate.Metadata.IdempotencyKey, - StringComparison.OrdinalIgnoreCase) + && MetadataEquivalent(retained.Metadata, candidate.Metadata) && retained.Lines.Count == candidate.Lines.Count && retained.Lines.Zip(candidate.Lines, LinesEquivalent).All(static matches => matches); @@ -92,6 +101,8 @@ private static void EnsureEquivalent( private static bool LinesEquivalent(LedgerEntry retained, LedgerEntry candidate) => retained.EntryId == candidate.EntryId + && retained.JournalEntryId == candidate.JournalEntryId + && 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 +111,188 @@ 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) + { + retained ??= new Dictionary(); + candidate ??= new Dictionary(); + if (retained.Count != candidate.Count) + return false; + + foreach (var (key, retainedValue) in retained) + { + var candidatePair = candidate.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 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/LedgerPeriodPostingGuard.cs b/src/Meridian.Storage/Ledger/LedgerPeriodPostingGuard.cs index 1c0eea84ec..1a1b7f4cd4 100644 --- a/src/Meridian.Storage/Ledger/LedgerPeriodPostingGuard.cs +++ b/src/Meridian.Storage/Ledger/LedgerPeriodPostingGuard.cs @@ -27,18 +27,17 @@ public static void Validate(LedgerJournalEntryWrite entry, LedgerAccountingPerio return; } - // 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) diff --git a/src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.JournalAutomation.cs b/src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.JournalAutomation.cs index 08d6c0e045..fab4b88d58 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,18 +78,32 @@ private static void MapJournalAutomationEndpoints(WebApplication app, JsonSerial return EndpointHelpers.Forbidden(); } + if (existing is not null && existing.JournalEntryIds.Count > 0) + { + return Results.Conflict(new + { + error = $"Automated journal schedule '{existing.ScheduleId}' cannot be re-armed while its current cycle retains governed drafts. Resolve those drafts before changing the cycle." + }); + } + + var actor = ResolveMutationActor(context, request.Actor); + 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 ?? [], + LastScheduledForUtc = null, + JournalEntryIds = [], + LastSummary = existing is null + ? $"Monthly {request.Kind} work is scheduled." + : $"Monthly {request.Kind} work was re-armed by {actor} after configuration review.", + EvidenceLinks = [], + Blockers = [], RunHistory = existing?.RunHistory ?? [] }, context.RequestAborted).ConfigureAwait(false); return Results.Json(saved, jsonOptions); @@ -106,6 +121,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 +140,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") @@ -182,6 +203,42 @@ 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), @@ -190,9 +247,11 @@ private static void MapJournalAutomationEndpoints(WebApplication app, JsonSerial 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 +260,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 +287,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 +303,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..38e5a2cb0f 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; @@ -1060,10 +1061,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 +1094,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 +1156,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 +1218,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 +1280,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 +1342,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 +1404,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 +1453,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 +2380,98 @@ 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 (plan is null || !tenant.HasTenantScope) + { + 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); + } + + 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); + } + + var registry = context.RequestServices.GetService(); + if (registry is not null) + { + var owner = await registry.ResolveAsync(book.FundProfileId, context.RequestAborted).ConfigureAwait(false); + if (owner is not null && + (!owner.IsHeldBy(tenant.TenantId) || + (!string.IsNullOrWhiteSpace(owner.CompanyId) && + !string.IsNullOrWhiteSpace(tenant.CompanyId) && + !string.Equals(owner.CompanyId, tenant.CompanyId, StringComparison.OrdinalIgnoreCase)))) + { + return new CloseWorkflowTenantScope(plan, tenant, IsAccessible: false); + } + } + + var guard = context.RequestServices.GetService(); + 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 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 +2505,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/Services/AccountingClosePostingWorkbenchBridge.cs b/src/Meridian.Ui.Shared/Services/AccountingClosePostingWorkbenchBridge.cs index 33dcb4aa2d..99a48e231f 100644 --- a/src/Meridian.Ui.Shared/Services/AccountingClosePostingWorkbenchBridge.cs +++ b/src/Meridian.Ui.Shared/Services/AccountingClosePostingWorkbenchBridge.cs @@ -51,14 +51,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 +91,9 @@ public async Task EnsureClosingDraftQueuedAsync( return before; } - var period = await ResolvePeriodAsync(context, ct).ConfigureAwait(false); + var scope = await ResolveScopeAsync(context, ct).ConfigureAwait(false); 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 +106,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 +150,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 +160,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,23 +196,43 @@ 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."); } + if (retainedReversal.Status is not ManualJournalEntryStatusDto.Posted + and not ManualJournalEntryStatusDto.CloseLocked) + { + activeClosingBatches.Add(batch); + } + } + + foreach (var batch in activeClosingBatches) + { + if (reversalDrafts.ContainsKey(batch.JournalEntryId)) + { + continue; + } + var reversed = await _lifecycle.ApplyLifecycleActionAsync( new JournalEntryLifecycleActionRequestDto( batch.JournalEntryId, - context.FundProfileId, + scope.FundProfileId, JournalEntryLifecycleActionDto.Reverse, command.Actor, batch.Version, @@ -203,7 +241,9 @@ public async Task ReopenAndQueueClosingReversalsAsync( command.EvidenceLinks, command.ActionOrigin, PeriodIsLocked: false, - LedgerBookId: context.LedgerBookId), + LedgerBookId: context.LedgerBookId, + TenantId: context.TenantId, + CompanyId: context.CompanyId), ct) .ConfigureAwait(false); var generated = reversed.GeneratedJournalEntries.SingleOrDefault(draft => @@ -213,18 +253,22 @@ public async Task ReopenAndQueueClosingReversalsAsync( reversalDrafts[batch.JournalEntryId] = generated; } - if (reversalDrafts.Count != closingBatches.Length) + 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("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) { - if (reversalDrafts.Values.Any(draft => !IsSameReopenReplay(draft, command))) - { - 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 durable period transition already completed on an earlier attempt. The exact + // reversal replay match above proves this is a retry, not a new reopen command. } else { @@ -242,8 +286,7 @@ await RequireLedgerBookService().ReopenPeriodAsync( } 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,10 +294,12 @@ 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() }; } @@ -262,17 +307,19 @@ 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)); + return transition is not null && command.EvidenceLinks.All(requested => + transition.EvidenceLinks.Any(retained => + string.Equals(requested, retained, StringComparison.OrdinalIgnoreCase))); } private static ClosePostingGateDto BuildGate( @@ -298,6 +345,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 +436,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 +460,55 @@ 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 must carry tenant and company scope together.", nameof(context)); } } @@ -463,6 +539,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..038a94b01c 100644 --- a/src/Meridian.Ui.Shared/Services/AutomatedJournalDraftIntakeService.cs +++ b/src/Meridian.Ui.Shared/Services/AutomatedJournalDraftIntakeService.cs @@ -5,6 +5,21 @@ namespace Meridian.Ui.Shared.Services; +internal sealed class DailyValuationPendingDraftException : InvalidOperationException +{ + public DailyValuationPendingDraftException( + IReadOnlyList pendingJournalEntryIds, + ManualJournalEntryStatusDto pendingStatus, + string scope) + : 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; + } + + public IReadOnlyList PendingJournalEntryIds { 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 +34,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,7 +52,8 @@ 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. @@ -120,7 +137,8 @@ public async Task IntakeAsync( request.EntityId, request.TenantId, request.CompanyId, - request.EvidenceAssessments), + request.EvidenceAssessments, + BatchCorrelationId: null), skipped, ct).ConfigureAwait(false); } @@ -156,6 +174,10 @@ 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) { @@ -198,7 +220,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 +234,96 @@ 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.FundProfileId, 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) && + HasOverlappingValuationScope(existingDraft, draft)); + if (pendingOverlap is not null) + { + var pendingIds = existingDrafts + .Where(IsPendingDailyValuationDraft) + .Where(existingDraft => existingDraft.AccountingDate == effectiveDate) + .Where(existingDraft => string.Equals( + existingDraft.PeriodId, + request.PeriodId, + StringComparison.OrdinalIgnoreCase)) + .Select(static existingDraft => existingDraft.JournalEntryId) + .Where(static id => id != Guid.Empty) + .Distinct() + .OrderBy(static id => id) + .ToArray(); + throw new DailyValuationPendingDraftException( + pendingIds, + pendingOverlap.Status, + DescribeValuationScope(draft)); + } + } + } + + 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 sealed record ValuationScopeKey(Guid? SecurityId, string? Symbol, string? FinancialAccountId); + private static ManualJournalEntryDraftDto BuildDraftDto( AutomatedJournalPreparedDraftIntakeRequest request, AutomatedJournalDraft draft, diff --git a/src/Meridian.Ui.Shared/Services/AutomatedJournalIntakeRunner.cs b/src/Meridian.Ui.Shared/Services/AutomatedJournalIntakeRunner.cs index ed942641ad..29b407855b 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,7 +87,8 @@ 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 @@ -103,7 +107,8 @@ public sealed record AutomatedJournalIntakeRunResult( /// 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,19 +131,22 @@ public sealed class AutomatedJournalIntakeRunner private readonly CorporateActionDividendEventProducer? _dividendProducer; private readonly ILedgerBookService? _ledgerBookService; private readonly DailyMarkToMarketService? _dailyMarkToMarketService; + private readonly DailyValuationPositionService? _dailyValuationPositionService; public AutomatedJournalIntakeRunner( AutomatedJournalDraftIntakeService intake, FeeScheduleAccrualEventProducer feeProducer, CorporateActionDividendEventProducer? dividendProducer = null, ILedgerBookService? ledgerBookService = null, - DailyMarkToMarketService? dailyMarkToMarketService = null) + DailyMarkToMarketService? dailyMarkToMarketService = null, + DailyValuationPositionService? dailyValuationPositionService = null) { _intake = intake ?? throw new ArgumentNullException(nameof(intake)); _feeProducer = feeProducer ?? throw new ArgumentNullException(nameof(feeProducer)); _dividendProducer = dividendProducer; _ledgerBookService = ledgerBookService; _dailyMarkToMarketService = dailyMarkToMarketService; + _dailyValuationPositionService = dailyValuationPositionService; } public async Task RunDividendIntakeAsync( @@ -202,6 +210,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,30 +244,61 @@ 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"); } /// diff --git a/src/Meridian.Ui.Shared/Services/AutomatedJournalScheduleStore.cs b/src/Meridian.Ui.Shared/Services/AutomatedJournalScheduleStore.cs index ccb10d286a..fab36b950a 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; @@ -23,7 +24,12 @@ 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) { public IReadOnlyList JournalEntryIds { get; init; } = JournalEntryIds ?? []; @@ -33,9 +39,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 +76,14 @@ 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) { public IReadOnlyList Positions { get; init; } = Positions ?? []; @@ -139,10 +152,10 @@ 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."); } _items[normalized.ScheduleId] = normalized; @@ -219,10 +232,10 @@ 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."); } var workItems = snapshot.WorkItems @@ -253,11 +266,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 +298,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 +327,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 +338,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) }; } @@ -318,10 +349,11 @@ public static AutomatedJournalScheduleStatusDto ProjectStatus( Guid? ledgerBookId, string? periodId) { + var normalizedPeriodId = NormalizeOptional(periodId); var scoped = items .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)) + .SelectMany(item => ProjectCycles(item, normalizedPeriodId)) .ToArray(); if (scoped.Length == 0) { @@ -342,26 +374,34 @@ public static AutomatedJournalScheduleStatusDto ProjectStatus( } 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 +412,103 @@ 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); } 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(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 +541,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."); } @@ -451,3 +588,124 @@ private static void RequireRate(decimal? value, string label) throw new ArgumentOutOfRangeException(label, $"{label} must be between 0 and 1."); } } + +internal sealed record AutomatedJournalFeeEvidenceEvaluation( + bool IsReady, + AutomatedJournalScheduleStateDto FailureState, + AutomatedJournalEvidenceAssessmentDto Assessment, + IReadOnlyList Blockers, + IReadOnlyList EvidenceLinks); + +internal static class AutomatedJournalFeeEvidenceEvaluator +{ + public static AutomatedJournalFeeEvidenceEvaluation Evaluate( + string periodId, + string currency, + decimal? beginningNav, + decimal? endingNavBeforeFees, + decimal? highWaterMark, + AutomatedJournalCapitalAccountReconciliationDto? reconciliation, + decimal minimumConfidence, + DateTimeOffset evaluatedAtUtc) + { + 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); + } + + 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(); + if (!reconciliation.IsReconciled) + mismatches.Add("Capital-account reconciliation is not marked reconciled."); + if (maximumObservedVariance > reconciliation.MaximumVarianceTolerance) + { + mismatches.Add(FormattableString.Invariant( + $"Capital-account reconciliation variance {maximumObservedVariance:0.00} exceeds tolerance {reconciliation.MaximumVarianceTolerance:0.00}.")); + } + if (reconciliation.ConfidenceScore < minimumConfidence) + { + mismatches.Add(FormattableString.Invariant( + $"Capital-account reconciliation confidence {reconciliation.ConfidenceScore:P0} is below the configured {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); + } + + private static AutomatedJournalFeeEvidenceEvaluation Build( + bool isReady, + AutomatedJournalScheduleStateDto failureState, + decimal confidence, + IReadOnlyList evidenceLinks, + IReadOnlyList missing, + IReadOnlyList mismatches, + decimal minimumConfidence) + { + 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 configured {minimumConfidence:P0} threshold and the fee basis ties within tolerance.") + : $"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/AutomatedJournalScheduledWorker.cs b/src/Meridian.Ui.Shared/Services/AutomatedJournalScheduledWorker.cs index a281e3661b..bc7f58190f 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,16 +14,21 @@ 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 { @@ -44,12 +50,37 @@ public AutomatedJournalScheduledWorker( 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) @@ -92,7 +123,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 +140,34 @@ 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); + 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 +180,8 @@ private async Task RunWorkItemAsync( [], [], [blocker], + null, + null, ct).ConfigureAwait(false); } @@ -149,6 +213,8 @@ private async Task RunWorkItemAsync( [], [], [blocker], + null, + null, ct).ConfigureAwait(false); } } @@ -177,7 +243,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( @@ -239,6 +307,12 @@ private async Task CompleteFromIntakeAsync( .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; @@ -283,6 +357,8 @@ private async Task CompleteFromIntakeAsync( journalEntryIds, evidenceLinks, blockers, + evidenceConfidenceScore, + evidenceQuality, ct).ConfigureAwait(false); } @@ -295,6 +371,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 +383,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 +398,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 +447,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,28 +526,32 @@ private static IReadOnlyList UpsertHistory( .Append(entry) .OrderBy(static item => item.ScheduledForUtc) .ToArray(); + + 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) { diff --git a/src/Meridian.Ui.Shared/Services/DailyValuationBatchLifecycleService.cs b/src/Meridian.Ui.Shared/Services/DailyValuationBatchLifecycleService.cs new file mode 100644 index 0000000000..c238e669eb --- /dev/null +++ b/src/Meridian.Ui.Shared/Services/DailyValuationBatchLifecycleService.cs @@ -0,0 +1,417 @@ +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 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.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, + request.EvidenceLinks, + 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, + request.EvidenceLinks, + 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..53e7fe0c0a --- /dev/null +++ b/src/Meridian.Ui.Shared/Services/DailyValuationPositionService.cs @@ -0,0 +1,333 @@ +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. +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 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, ct) + .ConfigureAwait(false); + if (snapshot is null) + { + blockers.Add($"No durable position snapshot exists for run '{scope.RunId}' and account '{scope.AccountId}'."); + 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); + + 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 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..fec63f98be 100644 --- a/src/Meridian.Ui.Shared/Services/DailyValuationScheduler.cs +++ b/src/Meridian.Ui.Shared/Services/DailyValuationScheduler.cs @@ -5,6 +5,7 @@ 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 +13,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 +45,23 @@ 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) { 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 +117,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; } @@ -147,6 +165,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 +178,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 +193,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, @@ -207,7 +237,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 +256,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 +304,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) @@ -266,6 +327,13 @@ public async Task RunDueAsync( } } + 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 +345,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 +370,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 +399,7 @@ private async Task RunWorkItemAsync( item.LedgerBookId, item.PeriodId, scheduledForUtc, - item.Positions, + positionResolution.Positions, item.PolicyId, item.PolicyName, item.ValuationMethod, @@ -318,17 +411,20 @@ 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 blockers = run.Valuation.RejectedMarks + .Select(static rejection => $"{rejection.Symbol}: {rejection.Reason}") .ToArray(); if (run.Valuation.Projection is null && blockers.Length > 0) @@ -338,38 +434,66 @@ private async Task RunWorkItemAsync( nowUtc, DailyValuationScheduleStateDto.Blocked, $"Daily valuation was blocked because {blockers.Length} closing mark(s) failed trust policy.", - journalEntryId, + 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 summary = 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, summary, - journalEntryId, + journalEntryIds, + batchCorrelationId, evidenceLinks, blockers, ct).ConfigureAwait(false); } + catch (DailyValuationPendingDraftException ex) + { + var retainedIds = item.JournalEntryIds + .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, + item.BatchCorrelationId ?? BuildRecoveredBatchCorrelationId(item, retainedIds), + item.EvidenceLinks, + [ex.Message], + ct).ConfigureAwait(false); + } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; @@ -388,7 +512,8 @@ private async Task RunWorkItemAsync( nowUtc, DailyValuationScheduleStateDto.Failed, blocker, - journalEntryId: null, + journalEntryIds: [], + batchCorrelationId: null, evidenceLinks: [], blockers: [blocker], ct).ConfigureAwait(false); @@ -400,7 +525,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 +536,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 +549,10 @@ private async Task CompleteAsync( running.LastScheduledForUtc!.Value, state, summary, - journalEntryId, - blockers); + completed.JournalEntryId, + blockers, + journalEntryIds, + batchCorrelationId); } private static DateTimeOffset AdvanceToNextRun(DateTimeOffset scheduledForUtc, DateTimeOffset nowUtc) @@ -439,6 +569,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,23 +579,47 @@ 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) { using var timer = new PeriodicTimer(TickInterval); @@ -472,7 +627,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { 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 +682,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 +708,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(), @@ -548,7 +726,14 @@ public static DailyValuationScheduleWorkItem Normalize(DailyValuationScheduleWor CompanyId = NormalizeOptional(workItem.CompanyId), 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) }; } @@ -586,7 +771,9 @@ public static DailyValuationScheduleStatusDto ProjectStatus( MissingScopeMessage, JournalEntryId: null, EvidenceLinks: [], - Blockers: [MissingScopeMessage]); + Blockers: [MissingScopeMessage], + JournalEntryIds: [], + BatchCorrelationId: null); } var state = matching.IsEnabled @@ -612,9 +799,40 @@ public static DailyValuationScheduleStatusDto ProjectStatus( summary, matching.JournalEntryId, matching.EvidenceLinks, - blockers); + blockers, + matching.JournalEntryIds, + matching.BatchCorrelationId); } + 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)) + { + throw new InvalidOperationException( + $"Daily valuation schedule '{existing.ScheduleId}' tenant/company ownership is immutable."); + } + } + + 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); 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/ManualJournalEntryWorkbenchService.Lifecycle.cs b/src/Meridian.Ui.Shared/Services/ManualJournalEntryWorkbenchService.Lifecycle.cs index 116383c530..bc3253b3c3 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); @@ -710,6 +749,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..8ae64d4b35 100644 --- a/src/Meridian.Ui.Shared/Services/ManualJournalEntryWorkbenchService.cs +++ b/src/Meridian.Ui.Shared/Services/ManualJournalEntryWorkbenchService.cs @@ -709,6 +709,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 +751,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 +829,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 +843,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 +852,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 +876,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) diff --git a/src/Meridian.Ui.Shared/Services/WorkstationServiceCollectionExtensions.cs b/src/Meridian.Ui.Shared/Services/WorkstationServiceCollectionExtensions.cs index d0a9332857..ad32755dcd 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,7 @@ public static IServiceCollection AddWorkstationSharedServices(this IServiceColle sp.GetService())); services.TryAddSingleton(sp => (IManualJournalEntryLifecycleService)sp.GetRequiredService()); + services.TryAddSingleton(); services.TryAddSingleton(sp => new AutomatedJournalDraftIntakeService( sp.GetRequiredService(), @@ -561,14 +569,19 @@ 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); }); // 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/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/tests/Meridian.Tests/Application/Accounting/DailyMarkToMarketServiceTests.cs b/tests/Meridian.Tests/Application/Accounting/DailyMarkToMarketServiceTests.cs index 39a52cb372..4912801647 100644 --- a/tests/Meridian.Tests/Application/Accounting/DailyMarkToMarketServiceTests.cs +++ b/tests/Meridian.Tests/Application/Accounting/DailyMarkToMarketServiceTests.cs @@ -16,6 +16,8 @@ namespace Meridian.Tests.Application.Accounting; public sealed class DailyMarkToMarketServiceTests { private static readonly DateTimeOffset AsOf = new(2026, 07, 03, 21, 0, 0, TimeSpan.Zero); + private static readonly Guid AaplSecurityId = Guid.Parse("a1000000-0000-0000-0000-000000000001"); + private static readonly Guid MsftSecurityId = Guid.Parse("b2000000-0000-0000-0000-000000000002"); private static DailyPortfolioPricingPolicy Policy => new( fundId: "fund-alpha", @@ -43,7 +45,8 @@ public async Task PrepareApprovePost_MarksBooksToMarket() var prices = new MapPriceSource() .Add("AAPL", 160m) .Add("MSFT", 190m); - var service = new DailyMarkToMarketService(prices); + var carryingValues = new LedgerCarryingValueSource(ledger); + var service = new DailyMarkToMarketService(prices, carryingValues); var run = await service.PrepareAsync(new DailyMarkToMarketRequest( Policy, @@ -52,25 +55,26 @@ public async Task PrepareApprovePost_MarksBooksToMarket() BaseCurrency: "USD", Positions: [ - new MarkToMarketPosition("AAPL", Quantity: 100m, CostPrice: 150m), - new MarkToMarketPosition("MSFT", Quantity: 50m, CostPrice: 200m) + new MarkToMarketPosition("AAPL", Quantity: 100m, CostPrice: 150m, SecurityId: AaplSecurityId), + new MarkToMarketPosition("MSFT", Quantity: 50m, CostPrice: 200m, SecurityId: MsftSecurityId) ], Actor: "ops", Reason: "daily close marks")); run.HasDraft.Should().BeTrue(); + run.DraftCount.Should().Be(2, "each Security Master/account scope has an unambiguous posting draft"); run.UnpricedSymbols.Should().BeEmpty(); run.Projection!.TotalMarketValue.Should().Be(16_000m + 9_500m); run.Projection.NetUnrealizedGainOrLoss.Should().Be(1_000m - 500m); + run.Projection.NetMarkAdjustment.Should().Be(1_000m - 500m); run.Approval!.Status.Should().Be(AutomatedJournalApprovalStatus.Submitted); - run.Approval.Draft.IsBalanced.Should().BeTrue(); - run.Approval.Draft.Event.Kind.Should().Be(AutomatedJournalEventKind.FairValueMarkAdjustment); - - var posted = run.Approval - .Approve("controller", AsOf, "reviewed against custodian prices", ["evidence:AAPL", "evidence:MSFT"]) - .PostTo(ledger, "controller", AsOf, "posted after approval", ["evidence:AAPL", "evidence:MSFT"]); + run.Approvals.Should().OnlyContain(approval => approval.Draft.IsBalanced); + run.Approvals.Should().OnlyContain(approval => + approval.Draft.Event.Kind == AutomatedJournalEventKind.FairValueMarkAdjustment); + carryingValues.CallCount.Should().Be(1, "the durable ledger scope is hydrated in one batch"); - posted.Status.Should().Be(AutomatedJournalApprovalStatus.Posted); + var posted = PostApprovals(run.Approvals, ledger, AsOf); + posted.Should().OnlyContain(approval => approval.Status == AutomatedJournalApprovalStatus.Posted); // The books now carry market values — the substance of a true NAV. ledger.GetBalance(LedgerAccounts.Securities("AAPL")).Should().Be(16_000m); @@ -79,6 +83,95 @@ public async Task PrepareApprovePost_MarksBooksToMarket() .Should().Be(run.Projection.TotalMarketValue); } + [Fact] + public async Task Scenario_ConsecutiveDailyCloses_PostOnlyIncrementalMarkMovement() + { + var ledger = new Meridian.Ledger.Ledger(); + ledger.PostLines(AsOf.AddDays(-10), "Buy 100 AAPL @ 150", + [ + (LedgerAccounts.Securities("AAPL", "broker-1"), 15_000m, 0m), + (LedgerAccounts.CashAccount("broker-1"), 0m, 15_000m) + ]); + var prices = new MapPriceSource().Add("AAPL", 160m, new DateOnly(2026, 07, 03)); + var carryingValues = new LedgerCarryingValueSource(ledger); + var service = new DailyMarkToMarketService(prices, carryingValues); + var position = new MarkToMarketPosition( + "AAPL", 100m, 150m, FinancialAccountId: "broker-1", SecurityId: AaplSecurityId); + + var dayOne = await service.PrepareAsync(Request(AsOf, position)); + dayOne.Projection!.Lines.Should().ContainSingle() + .Which.MarkAdjustment.Should().Be(1_000m); + PostApprovals(dayOne.Approvals, ledger, AsOf); + ledger.GetBalance(LedgerAccounts.Securities("AAPL", "broker-1")).Should().Be(16_000m); + + prices.Add("AAPL", 160m, new DateOnly(2026, 07, 04)); + var dayTwo = await service.PrepareAsync(Request(AsOf.AddDays(1), position)); + var unchanged = dayTwo.Projection!.Lines.Should().ContainSingle().Subject; + unchanged.PriorCarryingValue.Should().Be(16_000m); + unchanged.HasPriorCarryingValue.Should().BeTrue(); + unchanged.UnrealizedGainOrLoss.Should().Be(1_000m, "cumulative reporting remains versus cost"); + unchanged.MarkAdjustment.Should().Be(0m, "the unchanged close is already carried on the ledger"); + dayTwo.HasDraft.Should().BeFalse(); + + prices.Add("AAPL", 165m, new DateOnly(2026, 07, 05)); + var dayThree = await service.PrepareAsync(Request(AsOf.AddDays(2), position)); + var changed = dayThree.Projection!.Lines.Should().ContainSingle().Subject; + changed.PriorCarryingValue.Should().Be(16_000m); + changed.UnrealizedGainOrLoss.Should().Be(1_500m); + changed.MarkAdjustment.Should().Be(500m, "only the movement from the prior carrying value is posted"); + PostApprovals(dayThree.Approvals, ledger, AsOf.AddDays(2)); + + ledger.GetBalance(LedgerAccounts.Securities("AAPL", "broker-1")).Should().Be(16_500m); + carryingValues.CallCount.Should().Be(3, "each run performs exactly one batch hydration"); + ledger.GetJournalEntries(new LedgerQuery(ActivityType: "fair-value-mark")) + .Should().HaveCount(2, "the unchanged middle day creates no journal"); + } + + [Fact] + public async Task PrepareAsync_SameDayRetryAndCorrectedMark_UsesStableCorrectionAwareIdentity() + { + var ledger = new Meridian.Ledger.Ledger(); + ledger.PostLines(AsOf.AddDays(-10), "Buy 100 AAPL @ 150", + [ + (LedgerAccounts.Securities("AAPL", "broker-1"), 15_000m, 0m), + (LedgerAccounts.CashAccount("broker-1"), 0m, 15_000m) + ]); + var prices = new MapPriceSource().Add( + "AAPL", 160m, new DateOnly(2026, 07, 03), evidenceReference: "price://aapl/revision-1"); + var service = new DailyMarkToMarketService(prices, new LedgerCarryingValueSource(ledger)); + var position = new MarkToMarketPosition( + "AAPL", 100m, 150m, FinancialAccountId: "broker-1", SecurityId: AaplSecurityId); + var request = Request(AsOf, position); + + var first = await service.PrepareAsync(request); + var retry = await service.PrepareAsync(request); + + retry.Approval!.Draft.Metadata.IdempotencyKey + .Should().Be(first.Approval!.Draft.Metadata.IdempotencyKey, + "an identical retry must resolve to the same durable idempotency identity"); + + PostApprovals(first.Approvals, ledger, AsOf); + ledger.GetBalance(LedgerAccounts.Securities("AAPL", "broker-1")).Should().Be(16_000m); + + prices.Add( + "AAPL", 161m, new DateOnly(2026, 07, 03), evidenceReference: "price://aapl/revision-2"); + var corrected = await service.PrepareAsync(request); + + corrected.Approval!.Draft.Metadata.IdempotencyKey + .Should().NotBe(first.Approval.Draft.Metadata.IdempotencyKey, + "a corrected same-day mark must not be collapsed into the earlier draft"); + var correctedLine = corrected.Projection!.Lines.Should().ContainSingle().Subject; + correctedLine.PriorCarryingValue.Should().Be(16_000m, + "the posted first revision is the durable carrying-value baseline"); + correctedLine.UnrealizedGainOrLoss.Should().Be(1_100m, + "cumulative unrealized performance remains measured against cost"); + correctedLine.MarkAdjustment.Should().Be(100m, + "the corrected revision posts only the movement beyond the first posted mark"); + + PostApprovals(corrected.Approvals, ledger, AsOf); + ledger.GetBalance(LedgerAccounts.Securities("AAPL", "broker-1")).Should().Be(16_100m); + } + [Fact] public async Task PrepareAsync_MissingPrice_SurfacesUnpricedSymbolInsteadOfMarkingAtCost() { @@ -173,13 +266,92 @@ private sealed class MapPriceSource : IMarkPriceSource { private readonly Dictionary _quotes = new(StringComparer.OrdinalIgnoreCase); +<<<<<<< Updated upstream public MapPriceSource Add(string symbol, decimal price) { _quotes[symbol] = new MarkPriceQuote(price, "test-source", $"evidence:{symbol}"); +======= + public MapPriceSource Add( + string symbol, + decimal price, + DateOnly? observedOn = null, + DailyPortfolioPriceConfidence confidence = DailyPortfolioPriceConfidence.High, + string? evidenceReference = null) + { + _quotes[symbol] = new MarkPriceQuote( + price, + "test-source", + evidenceReference ?? $"evidence:{symbol}", + observedOn ?? DateOnly.FromDateTime(AsOf.UtcDateTime), + confidence); +>>>>>>> Stashed changes return this; } public Task GetMarkPriceAsync(string symbol, DateOnly asOf, CancellationToken ct = default) => Task.FromResult(_quotes.TryGetValue(symbol, out var quote) ? quote : null); } + + private static DailyMarkToMarketRequest Request( + DateTimeOffset asOf, + params MarkToMarketPosition[] positions) + => new( + Policy, + PeriodId: "2026-07", + AsOf: asOf, + BaseCurrency: "USD", + Positions: positions, + Actor: "ops", + Reason: "daily close marks", + LedgerBookId: Guid.Parse("c3000000-0000-0000-0000-000000000003")); + + private static IReadOnlyList PostApprovals( + IReadOnlyList approvals, + Meridian.Ledger.Ledger ledger, + DateTimeOffset occurredAt) + => approvals + .Select(approval => + { + var evidence = approval.Draft.Metadata.EvidenceReferences + .Select(static reference => reference.Uri) + .ToArray(); + return approval + .Approve("controller", occurredAt, "reviewed against custodian prices", evidence) + .PostTo(ledger, "controller", occurredAt, "posted after approval", evidence); + }) + .ToArray(); + + private sealed class LedgerCarryingValueSource : IMarkToMarketCarryingValueSource + { + private readonly Meridian.Ledger.Ledger _ledger; + + public LedgerCarryingValueSource(Meridian.Ledger.Ledger ledger) + { + _ledger = ledger; + } + + public int CallCount { get; private set; } + + public Task> GetCarryingValuesAsync( + MarkToMarketCarryingValueRequest request, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + CallCount++; + IReadOnlyDictionary result = request.Positions + .ToDictionary( + MarkToMarketCarryingValueKey.FromPosition, + position => + { + var account = LedgerAccounts.Securities(position.Symbol, position.FinancialAccountId); + var existsAsOf = _ledger.GetEntries(account).Any(entry => entry.Timestamp <= request.AsOf); + return new MarkToMarketCarryingValue( + existsAsOf ? _ledger.GetBalanceAsOf(account, request.AsOf) : null, + source: $"ledger:{request.LedgerBookId:D}", + capturedAtUtc: request.AsOf, + evidenceReference: $"ledger://{request.LedgerBookId:D}/{position.Symbol}"); + }); + return Task.FromResult(result); + } + } } diff --git a/tests/Meridian.Tests/Application/Backfill/AdditionalProviderContractTests.cs b/tests/Meridian.Tests/Application/Backfill/AdditionalProviderContractTests.cs index 68c2c60d8d..9e5c48ab2b 100644 --- a/tests/Meridian.Tests/Application/Backfill/AdditionalProviderContractTests.cs +++ b/tests/Meridian.Tests/Application/Backfill/AdditionalProviderContractTests.cs @@ -312,13 +312,16 @@ public async Task AlphaVantage_HandlesErrorMessage_ReturnsEmptyList() } [Fact] - public async Task AlphaVantage_HandlesRateLimitMessage_ThrowsHttpRequestException() + public async Task AlphaVantage_HandlesRateLimitMessage_ThrowsTypedRateLimitException() { var httpClient = CreateMockHttpClient(AlphaVantageResponses.RateLimitResponse); using var provider = new AlphaVantageHistoricalDataProvider(apiKey: "test-key", httpClient: httpClient); - await Assert.ThrowsAsync( + var exception = await Assert.ThrowsAsync( () => provider.GetDailyBarsAsync("AAPL", null, null)); + + exception.Provider.Should().Be("alphavantage"); + exception.Symbol.Should().Be("AAPL"); } [Fact] diff --git a/tests/Meridian.Tests/Infrastructure/Providers/BackfillRetryAfterTests.cs b/tests/Meridian.Tests/Infrastructure/Providers/BackfillRetryAfterTests.cs index c992c29c69..043e4d1d22 100644 --- a/tests/Meridian.Tests/Infrastructure/Providers/BackfillRetryAfterTests.cs +++ b/tests/Meridian.Tests/Infrastructure/Providers/BackfillRetryAfterTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http; +using Meridian.Core.Exceptions; using Meridian.Infrastructure.Adapters.Core; using Xunit; @@ -11,6 +12,26 @@ 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_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/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..df275d113e 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,40 @@ 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 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); + await manager.DisposeAsync(); + + reconnectRelease.TrySetResult(true); + (await reconnectTask.WaitAsync(TimeSpan.FromSeconds(1))).Should().BeFalse(); + manager.IsConnected.Should().BeFalse(); + } + [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..9a12963e2b 100644 --- a/tests/Meridian.Tests/Ledger/LedgerIntegrationTests.cs +++ b/tests/Meridian.Tests/Ledger/LedgerIntegrationTests.cs @@ -989,6 +989,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 +1002,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..716c26e59c --- /dev/null +++ b/tests/Meridian.Tests/Storage/GovernedLedgerPostingTargetTests.cs @@ -0,0 +1,307 @@ +using FluentAssertions; +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); + } + + 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.Setup(candidate => candidate.AppendAsync(It.IsAny(), It.IsAny())) + .Returns((write, _) => + { + append(write); + return Task.CompletedTask; + }); + return store; + } + + 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) + => new( + write.Entry, + write.AggregateId, + write.PeriodId, + write.CommandId, + write.CorrelationId, + 1, + OccurredAt, + write.AccountingBasis, + write.AccountingPolicyId, + write.AccountingPolicyVersion, + write.RuleId, + write.RuleVersion, + write.SourceEventId, + write.SourceJournalEntryId, + write.PostingKind, + write.AdjustmentApproval); + + 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") }) + }; +} diff --git a/tests/Meridian.Tests/Storage/LedgerJournalStoreTests.cs b/tests/Meridian.Tests/Storage/LedgerJournalStoreTests.cs index f3a7524e31..8f3a53a440 100644 --- a/tests/Meridian.Tests/Storage/LedgerJournalStoreTests.cs +++ b/tests/Meridian.Tests/Storage/LedgerJournalStoreTests.cs @@ -365,7 +365,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 +375,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/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 daad8f7b5b..237a475c7a 100644 --- a/tests/Meridian.Tests/Ui/AccountingConfigurationServiceTests.cs +++ b/tests/Meridian.Tests/Ui/AccountingConfigurationServiceTests.cs @@ -1,9 +1,11 @@ +using System.Text.Json; using FluentAssertions; using Meridian.Application.Accounting; using Meridian.Application.SecurityMaster; using Meridian.Contracts.Banking; using Meridian.Contracts.FundStructure; using Meridian.Contracts.Ledger; +using Meridian.Contracts.SecurityMaster; using Meridian.Contracts.Workstation; using Meridian.FinancialOperations.PrivateCapital; using Meridian.Ledger; @@ -18,6 +20,7 @@ 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"); [Fact] public async Task AccountingConfigurationService_IsolatesWorkspacesByTenantAndCompanyScope() @@ -1372,6 +1375,7 @@ await configuration.UpsertChartNodeAsync(new UpsertChartOfAccountsNodeRequest( draftStore, configuration, new InMemoryAccountingActionAuditStore(), + securityMasterQueryService: new DailyValuationSecurityMasterQueryService(), journalStore: journalStore, postingTarget: postingTarget); var intake = new AutomatedJournalDraftIntakeService(workbench, draftStore, configuration); @@ -1394,7 +1398,7 @@ await scheduleSource.SaveAsync(new DailyValuationScheduleWorkItem( ManualJournalLedgerBookId, ManualJournalPeriodId, asOf, - [new MarkToMarketPosition("AAPL", 100m, 150m)], + [new MarkToMarketPosition("AAPL", 100m, 150m, SecurityId: DailyValuationAaplSecurityId)], "valuation-policy-1", "Daily close", "market-close", @@ -1464,6 +1468,18 @@ [new MarkToMarketPosition("AAPL", 100m, 150m)], posted.JournalEntry.Status.Should().Be(ManualJournalEntryStatusDto.Posted); journalStore.Appended.Should().ContainSingle("the duplicate valuation run must not append again"); + var postedWrite = journalStore.Appended.Single(); + postedWrite.Entry.Metadata.SecurityId.Should().Be(DailyValuationAaplSecurityId); + postedWrite.Entry.Metadata.Symbol.Should().Be("AAPL"); + postedWrite.Entry.Metadata.Tags!["securityMasterProvenance"].Should() + .Contain($"security-master:{DailyValuationAaplSecurityId:N}") + .And.Contain("server-resolved:true") + .And.Contain("approved:true"); + postedWrite.Entry.Metadata.Tags["securityMasterLineage"].Should() + .Contain($"AAPL:{DailyValuationAaplSecurityId:N}") + .And.Contain("ledger-map:manual-journal:AAPL") + .And.Contain("sm-approval:security-master-active") + .And.Contain("security-status:Active"); // Simulate a process restart: rebuild every read from the retained durable journal. var restartedLedger = await journalStore.HydrateFundLedgerAsOfAsync( @@ -7364,6 +7380,92 @@ private sealed class StaticMarkPriceSource(MarkPriceQuote quote) : IMarkPriceSou } } + 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 ? CreateAaplDetail() : 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 && + string.Equals(identifierValue, "AAPL", StringComparison.OrdinalIgnoreCase) + ? CreateAaplDetail() + : 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 CreateAaplDetail() + => new( + DailyValuationAaplSecurityId, + "Equity", + SecurityStatusDto.Active, + "Apple Inc.", + "USD", + EmptyTerms, + EmptyTerms, + Identifiers: + [ + new SecurityIdentifierDto( + SecurityIdentifierKind.Ticker, + "AAPL", + IsPrimary: true, + ValidFrom: new DateTimeOffset(1980, 12, 12, 0, 0, 0, TimeSpan.Zero), + NormalizedValue: "AAPL") + ], + Aliases: [], + Version: 7, + EffectiveFrom: new DateTimeOffset(1980, 12, 12, 0, 0, 0, TimeSpan.Zero), + EffectiveTo: null); + } + private sealed class WritableManualJournalLedgerJournalStore( LedgerBookRecord book, LedgerAccountingPeriod period) : ILedgerJournalStore diff --git a/tests/Meridian.Tests/Ui/AutomatedJournalScheduleTests.cs b/tests/Meridian.Tests/Ui/AutomatedJournalScheduleTests.cs index 34857b3dd5..d4b21b9dd5 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,106 @@ 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 PersistedRunningClaim_RestartsWithSameRunKey_AndDeduplicatesDraftsAndHistory() { @@ -84,7 +196,7 @@ public async Task PersistedRunningClaim_RestartsWithSameRunKey_AndDeduplicatesDr { var fixture = CreateFixture(); 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,10 +206,11 @@ 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 { State = AutomatedJournalScheduleStateDto.Running, LastRunAtUtc = null, + LastScheduledForUtc = DueAt, RunHistory = [runningHistory] }); @@ -109,7 +222,8 @@ await firstStore.SaveAsync(completed with 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); + persisted.RunHistory.Single().JournalEntryIds.Should().BeEquivalentTo(firstRun.Runs.Single().JournalEntryIds); + persisted.PeriodId.Should().Be("2026-08"); (await fixture.Workbench.GetWorkbenchAsync("fund-alpha", BookId)).Drafts.Should().HaveCount(2, "downstream deterministic ids must deduplicate a restart after intake"); } @@ -262,7 +376,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, From 6ddd2d751c85bce73d5dda2a26bad9cb5880159a Mon Sep 17 00:00:00 2001 From: rodoHasArrived <55965792+rodoHasArrived@users.noreply.github.com> Date: Wed, 15 Jul 2026 03:01:49 -0700 Subject: [PATCH 2/3] fix(accounting): resolve daily valuation merge artifacts --- .../Application/Accounting/DailyMarkToMarketServiceTests.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/Meridian.Tests/Application/Accounting/DailyMarkToMarketServiceTests.cs b/tests/Meridian.Tests/Application/Accounting/DailyMarkToMarketServiceTests.cs index 4912801647..b31e6f273a 100644 --- a/tests/Meridian.Tests/Application/Accounting/DailyMarkToMarketServiceTests.cs +++ b/tests/Meridian.Tests/Application/Accounting/DailyMarkToMarketServiceTests.cs @@ -266,11 +266,6 @@ private sealed class MapPriceSource : IMarkPriceSource { private readonly Dictionary _quotes = new(StringComparer.OrdinalIgnoreCase); -<<<<<<< Updated upstream - public MapPriceSource Add(string symbol, decimal price) - { - _quotes[symbol] = new MarkPriceQuote(price, "test-source", $"evidence:{symbol}"); -======= public MapPriceSource Add( string symbol, decimal price, @@ -284,7 +279,6 @@ public MapPriceSource Add( evidenceReference ?? $"evidence:{symbol}", observedOn ?? DateOnly.FromDateTime(AsOf.UtcDateTime), confidence); ->>>>>>> Stashed changes return this; } From 806ffbe092813cee3fd6b84e34aa174d2480cb11 Mon Sep 17 00:00:00 2001 From: rodoHasArrived <55965792+rodoHasArrived@users.noreply.github.com> Date: Wed, 15 Jul 2026 07:19:34 -0700 Subject: [PATCH 3/3] feat: advance provider accounting completion --- ...-provider-accounting-brainstorm-2026-07.md | 31 +- .../Accounting/DailyMarkToMarketService.cs | 2 +- .../Features/BackfillFeatureRegistration.cs | 7 + .../Composition/ServiceCompositionRoot.cs | 5 +- ...CanonicalSymbolRegistryMigrationService.cs | 11 +- src/Meridian.Contracts/Api/UiApiClient.cs | 4 +- .../Domain/IPositionSnapshotStore.cs | 33 +- ...countingConfigurationCloseReportingDtos.cs | 14 +- .../Ledger/AccountingConfigurationDtos.cs | 3 +- .../AutomatedJournalScheduleDtos.cs | 10 +- .../Workstation/DailyValuationScheduleDtos.cs | 10 +- .../Workstation/OperationsContinuityDtos.cs | 7 +- .../BrokerageServiceRegistration.cs | 78 ++- .../Events/LedgerPostingConsumer.cs | 222 ++++++-- .../Events/TradeExecutedEvent.cs | 13 +- .../Events/TradeFillPostingStore.cs | 431 +++++++++++++++ .../Meridian.Execution.csproj | 1 + .../OrderManagementSystem.cs | 225 ++++++-- src/Meridian.Execution/README.md | 25 +- .../Serialization/ExecutionJsonContext.cs | 4 + .../AccountingCloseManagementService.cs | 122 ++++- .../PrivateCapitalCloseCockpitService.cs | 23 +- .../AlphaVantageCorporateActionProvider.cs | 51 +- .../AlphaVantageSymbolSearchProvider.cs | 41 +- .../Core/Backfill/BackfillWorkerService.cs | 59 ++- .../Adapters/Core/BaseSymbolSearchProvider.cs | 24 + .../IProviderConnectionDiagnosticsSource.cs | 19 - .../InteractiveBrokers/IBMarketDataClient.cs | 62 +-- .../InteractiveBrokers/IBSimulationClient.cs | 55 +- .../ConnectionDiagnosticsTypeForwarders.cs | 8 + src/Meridian.Infrastructure/README.md | 21 +- .../Resilience/WebSocketConnectionManager.cs | 196 +++---- src/Meridian.Ledger/Ledger.cs | 25 +- .../ConnectionDiagnosticsContracts.cs | 65 +++ src/Meridian.ProviderSdk/IMarketDataClient.cs | 10 +- .../IProviderConnectionDiagnosticsSource.cs | 63 +++ src/Meridian.ProviderSdk/README.md | 9 +- .../Interfaces/ISymbolRegistryService.cs | 11 + ...ingPostingCommandFingerprintJsonContext.cs | 10 + .../AccountingPostingCommandValidator.cs | 110 +++- .../Ledger/GovernedLedgerPostingTarget.cs | 114 +++- .../Ledger/ILedgerJournalStore.cs | 113 +++- .../Ledger/LedgerPeriodPostingGuard.cs | 8 +- ...r_025__global_posting_command_identity.sql | 5 + .../Ledger/PostgresLedgerBookService.cs | 54 +- .../Ledger/PostgresLedgerJournalStore.cs | 176 ++++++- .../Services/JsonlPositionSnapshotStore.cs | 112 +++- .../Services/SymbolRegistryService.cs | 38 ++ .../LedgerEndpoints.JournalAutomation.cs | 79 ++- .../Endpoints/LedgerEndpoints.cs | 50 +- .../Endpoints/WorkstationEndpoints.cs | 11 +- .../AccountingClosePostingWorkbenchBridge.cs | 230 ++++++-- .../AutomatedJournalDraftIntakeService.cs | 180 ++++++- .../AutomatedJournalEventProducers.cs | 19 + .../AutomatedJournalEvidencePolicy.cs | 187 +++++++ .../Services/AutomatedJournalIntakeRunner.cs | 84 ++- .../Services/AutomatedJournalScheduleStore.cs | 242 ++++----- .../AutomatedJournalScheduledWorker.cs | 87 ++- .../DailyValuationBatchLifecycleService.cs | 11 +- .../Services/DailyValuationPositionService.cs | 69 ++- .../Services/DailyValuationScheduler.cs | 161 +++++- .../Services/ManualJournalEntryDraftStores.cs | 121 +++-- ...orkbenchService.AccountingCloseReceipts.cs | 90 ++++ ...lJournalEntryWorkbenchService.Lifecycle.cs | 36 +- .../ManualJournalEntryWorkbenchService.cs | 24 +- .../WorkstationServiceCollectionExtensions.cs | 4 +- src/Meridian.Ui/dashboard/README.md | 6 + src/Meridian.Ui/dashboard/src/lib/api.ts | 47 ++ .../src/lib/ui-api-routes.generated.ts | 2 + .../src/lib/workstation-endpoints.ts | 4 + ...accounting-screen.close-cockpit-panels.tsx | 128 ++++- ...ng-screen.close-cockpit.view-model.test.ts | 418 ++++++++++++++- ...ounting-screen.close-cockpit.view-model.ts | 373 ++++++++++++- .../src/screens/accounting-screen.test.tsx | 318 ++++++++++- .../src/screens/accounting-screen.tsx | 190 ++++++- .../screens/accounting-screen.view-model.ts | 40 ++ .../src/screens/data-screen.data-regions.tsx | 34 +- .../src/screens/data-screen.test.tsx | 270 +++++++++- .../dashboard/src/screens/data-screen.tsx | 19 +- .../screens/data-screen.view-model.test.ts | 146 ++++- .../src/screens/data-screen.view-model.ts | 161 +++++- .../src/screens/data-screen.workstreams.tsx | 99 ++++ .../dashboard/src/types/workstation-3.ts | 40 ++ .../dashboard/src/types/workstation-6.ts | 122 +++++ .../dashboard/src/types/workstation-7.ts | 49 ++ .../Accounting/AccountingFeatureModule.cs | 43 +- .../Accounting/AccountingCloseViewModel.cs | 309 ++++++++++- .../ViewModels/DataQualityViewModel.cs | 12 +- .../Views/AccountingClosePage.xaml | 79 +++ src/Meridian/UiServer.cs | 4 +- .../BackfillFeatureRegistrationTests.cs | 116 ++++ ...icalSymbolRegistryMigrationServiceTests.cs | 200 +++++++ .../Contracts/Api/UiApiClientTests.cs | 36 ++ .../EventDrivenDecouplingTests.cs | 459 +++++++++++++++- .../OrderManagementSystemReportStreamTests.cs | 135 ++++- .../AccountingCloseServicesTests.cs | 420 +++++++++++++++ ...OperationsCommandCenterReadServiceTests.cs | 2 +- .../PrivateCapitalCloseCockpitServiceTests.cs | 10 +- ...lphaVantageCorporateActionProviderTests.cs | 30 +- .../AlphaVantageSymbolSearchProviderTests.cs | 30 +- .../Providers/BackfillRetryAfterTests.cs | 38 ++ .../IBSimulationClientContractTests.cs | 30 ++ .../MarketDataClientContractTests.cs | 44 ++ .../WebSocketConnectionManagerTests.cs | 158 ++++++ .../Ledger/LedgerIntegrationTests.cs | 10 + .../GovernedLedgerPostingTargetTests.cs | 326 +++++++++++- .../Storage/LedgerBookServiceTests.cs | 259 +++++++-- .../Storage/LedgerJournalStoreTests.cs | 15 + .../Storage/PositionSnapshotStoreTests.cs | 46 ++ .../Storage/SymbolRegistryServiceTests.cs | 14 + .../Ui/AccountingConfigurationServiceTests.cs | 454 ++++++++++++---- ...AutomatedJournalDraftIntakeServiceTests.cs | 216 ++++++++ .../Ui/AutomatedJournalEventProducerTests.cs | 498 +++++++++++++++++- .../Ui/AutomatedJournalScheduleTests.cs | 155 +++++- .../Ui/BrokerageConnectionEndpointsTests.cs | 2 +- ...ailyValuationBatchLifecycleServiceTests.cs | 308 +++++++++++ .../Ui/DailyValuationPositionServiceTests.cs | 252 +++++++++ .../Ui/DailyValuationScheduleIdentityTests.cs | 196 +++++++ ...derConnectionDiagnosticsProjectionTests.cs | 43 ++ ...stationEndpointsTests.JournalAutomation.cs | 121 +++++ .../Ui/WorkstationEndpointsTests.Wave4.cs | 324 +++++++++++- .../AccountingFeatureModuleTests.cs | 48 ++ .../AccountingCloseViewModelTests.cs | 218 +++++++- ...taQualityViewModelCharacterizationTests.cs | 114 +++- .../ViewModels/FundLedgerViewModelTests.cs | 4 +- 125 files changed, 11656 insertions(+), 1013 deletions(-) create mode 100644 src/Meridian.Execution/Events/TradeFillPostingStore.cs delete mode 100644 src/Meridian.Infrastructure/Adapters/Core/IProviderConnectionDiagnosticsSource.cs create mode 100644 src/Meridian.Infrastructure/ConnectionDiagnosticsTypeForwarders.cs create mode 100644 src/Meridian.ProviderSdk/ConnectionDiagnosticsContracts.cs create mode 100644 src/Meridian.ProviderSdk/IProviderConnectionDiagnosticsSource.cs create mode 100644 src/Meridian.Storage/Ledger/AccountingPostingCommandFingerprintJsonContext.cs create mode 100644 src/Meridian.Storage/Ledger/Migrations/V_ledger_025__global_posting_command_identity.sql create mode 100644 src/Meridian.Ui.Shared/Services/AutomatedJournalEvidencePolicy.cs create mode 100644 src/Meridian.Ui.Shared/Services/ManualJournalEntryWorkbenchService.AccountingCloseReceipts.cs create mode 100644 tests/Meridian.Tests/Application/SecurityMaster/CanonicalSymbolRegistryMigrationServiceTests.cs create mode 100644 tests/Meridian.Tests/Ui/DailyValuationBatchLifecycleServiceTests.cs create mode 100644 tests/Meridian.Tests/Ui/DailyValuationPositionServiceTests.cs create mode 100644 tests/Meridian.Tests/Ui/DailyValuationScheduleIdentityTests.cs create mode 100644 tests/Meridian.Tests/Ui/WorkstationEndpointsTests.JournalAutomation.cs diff --git a/docs/product/data-provider-accounting-brainstorm-2026-07.md b/docs/product/data-provider-accounting-brainstorm-2026-07.md index 24fc882e41..b22c465be1 100644 --- a/docs/product/data-provider-accounting-brainstorm-2026-07.md +++ b/docs/product/data-provider-accounting-brainstorm-2026-07.md @@ -22,24 +22,25 @@ ## 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. An independent accounting, -durability, and tenant-isolation audit reopened #6–#10 on 2026-07-14; those rows remain in progress -until the corrected invariants compile and their focused tests pass. 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-15 audit) | The catalog exposes immutable, sanitized registration failures; historical and streaming rate diagnostics use coherent lock-guarded snapshots. NYSE, Alpha Vantage, and OpenFIGI now map provider quota responses to typed `RateLimitException`, and the background worker classifies only typed exceptions or preserved HTTP 429 status—not message text. Browser and WPF show current usage, reset, failure, and retry posture while stating that history is unavailable. The new typed-path tests and aggregate CI still need execution. | -| 6 | Mark-to-market wiring | In progress (completion audit) | The provider-mark, governed-draft, hydration, NAV, and cockpit foundation exists. The audit found cumulative unrealized P&L being reposted instead of a daily delta, multi-security drafts without posting-guard Security Master lineage, indefinitely reused configured positions, incomplete same-day correction/batch semantics, tenant-scope takeover risk, and stale cockpit readiness. Delta carrying-value hydration, per-security drafts, fresh position scopes, lineage/currency gates, all-entry batch state, and current-run precedence are being implemented and tested. | -| 7 | Automated journal drafts | In progress (completion audit) | Dividend and fee producers plus a durable schedule foundation exist. Completion now requires truly recurring monthly auto-advance in the configured time zone, durable restart/idempotency proof, immutable tenant/company/identity scope, and explicit capital-account reconciliation and confidence evidence before fee drafts can be ready for approval. | -| 8 | Closing entries + retained-earnings roll | In progress (completion audit) | The retained-earnings projector and governed close workbench exist. Completion now requires a final ready-gate recheck, an actual ledger hard lock, SoftClosed-only closing-entry mutation, retry-safe controller-gated reopen/reversal, hard-close temporary-account guards, and tenant/company/book/period-isolated API proof. | -| 9 | One ledger spine | In progress (completion audit) | Durable posting and hydration exist. The audit is completing dimension-aware snapshot indexes for out-of-order/as-of reads and strengthening crash-after-append semantic equivalence so a retry cannot change policy, book, posting kind, command/source identity, metadata, or evidence. | -| 10 | Fill-to-ledger durability | In progress (completion audit) | Bounded-channel backpressure prevents the original full-channel drop. Completion now requires bounded two-phase shutdown under non-cooperative work, a no-post-after-disposal boundary, deterministic blocked-publisher release, and idempotent repeated disposal proof. | +| 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 diff --git a/src/Meridian.Application/Accounting/DailyMarkToMarketService.cs b/src/Meridian.Application/Accounting/DailyMarkToMarketService.cs index 504a414a43..f72cd7d869 100644 --- a/src/Meridian.Application/Accounting/DailyMarkToMarketService.cs +++ b/src/Meridian.Application/Accounting/DailyMarkToMarketService.cs @@ -436,7 +436,7 @@ public async Task PrepareAsync(DailyMarkToMarketRequest re { _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); + request.Policy.FundId, request.PeriodId, unpriced.Length, stalePriced.Count); return new DailyMarkToMarketRun(null, null, unpriced, rejected) { StalePricedSymbols = stalePriced.Distinct(StringComparer.OrdinalIgnoreCase).ToArray() 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/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 07e5e828ad..b9d8a7a3ff 100644 --- a/src/Meridian.Contracts/Workstation/AutomatedJournalScheduleDtos.cs +++ b/src/Meridian.Contracts/Workstation/AutomatedJournalScheduleDtos.cs @@ -66,7 +66,10 @@ public sealed record AutomatedJournalScheduleStatusDto( IReadOnlyList? JournalEntryIds = null, decimal? MinimumEvidenceConfidence = null, AutomatedJournalEvidenceQualityDto? LowestEvidenceQuality = null, - int HumanReviewQueueCount = 0) + int HumanReviewQueueCount = 0, + string? EntityId = null, + string? TenantId = null, + string? CompanyId = null) { public IReadOnlyList EvidenceLinks { get; init; } = EvidenceLinks ?? []; @@ -84,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 fb27759f7c..f0598cde4d 100644 --- a/src/Meridian.Contracts/Workstation/DailyValuationScheduleDtos.cs +++ b/src/Meridian.Contracts/Workstation/DailyValuationScheduleDtos.cs @@ -34,7 +34,10 @@ public sealed record DailyValuationScheduleStatusDto( IReadOnlyList EvidenceLinks, IReadOnlyList Blockers, IReadOnlyList? JournalEntryIds = null, - string? BatchCorrelationId = null) + string? BatchCorrelationId = null, + string? EntityId = null, + string? TenantId = null, + string? CompanyId = null) { /// /// Every governed draft in the latest valuation batch. remains @@ -82,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 a2d616275e..168e352d62 100644 --- a/src/Meridian.Execution/Meridian.Execution.csproj +++ b/src/Meridian.Execution/Meridian.Execution.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Meridian.Execution/OrderManagementSystem.cs b/src/Meridian.Execution/OrderManagementSystem.cs index 20e83d6491..91086dd5e3 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 @@ -247,6 +254,10 @@ public async Task PlaceOrderAsync(OrderRequest request, Cancellatio }; _orders[orderId] = orderState; + if (safeRequest.FundAccountId is { } fundAccountId) + { + _orderFinancialAccountIds[orderId] = fundAccountId.ToString("D"); + } TrimRetainedOrdersIfNeeded(); if (!string.IsNullOrWhiteSpace(sessionId)) { @@ -653,70 +664,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); + } + + progress.TradeEventPublished = true; + } - await RecordSessionFillAsync(sessionId, fillIncrement, ct).ConfigureAwait(false); + 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, @@ -1076,8 +1189,28 @@ 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; + } } /// Placeholder attribute for ADR traceability. 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 6a010f752a..83a1610faa 100644 --- a/src/Meridian.FinancialOperations/AccountingClose/AccountingCloseManagementService.cs +++ b/src/Meridian.FinancialOperations/AccountingClose/AccountingCloseManagementService.cs @@ -755,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) @@ -824,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( @@ -833,6 +849,29 @@ 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( @@ -885,12 +924,23 @@ [new AccountingConfigurationValidationIssueDto( ? 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 Task ReopenClosePeriodAsync( @@ -913,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)) @@ -976,6 +1029,48 @@ [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, tenantId, companyId), new AccountingClosePostingCommand( @@ -1011,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) @@ -1072,7 +1180,8 @@ private ClosePeriodPlanDto BuildPeriodPlan(OperationsContinuityWorkflowDto workf BuildCloseCalendar(tasks, isPeriodLocked), planConfiguration, evidenceReviews, - operatingCoverage); + operatingCoverage, + WorkflowVersion: workflow.Version); } private async Task BuildPeriodPlanWithGateAsync( @@ -1940,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/PrivateCapital/PrivateCapitalCloseCockpitService.cs b/src/Meridian.FinancialOperations/PrivateCapital/PrivateCapitalCloseCockpitService.cs index cb0dd5c2ef..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( @@ -1376,7 +1379,8 @@ private static IReadOnlyList FilterDailyValuationDra private static IReadOnlyList FilterAutomatedJournalDrafts( ManualJournalEntryWorkbenchDto? workbench, - string? periodId) + string? periodId, + string? entityId) { if (workbench is null) { @@ -1384,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) || @@ -1393,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/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 bf5b514d96..6c7f5ff4bc 100644 --- a/src/Meridian.Infrastructure/Adapters/Core/Backfill/BackfillWorkerService.cs +++ b/src/Meridian.Infrastructure/Adapters/Core/Backfill/BackfillWorkerService.cs @@ -317,10 +317,11 @@ private async Task ProcessRequestAsync(BackfillRequest request, CancellationToke var retryAfter = isRateLimited ? rateLimit?.RetryAfter ?? TryExtractRetryAfter(ex) : null; + var rateLimitedProvider = ResolveRateLimitedProvider(ex, request.AssignedProvider); - if (isRateLimited && request.AssignedProvider != null) + 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) @@ -331,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); @@ -340,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); @@ -387,6 +388,21 @@ 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. @@ -404,10 +420,15 @@ internal static bool IsRateLimited(Exception ex) /// 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; @@ -429,13 +450,33 @@ internal static bool IsRateLimited(Exception ex) 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) 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/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/WebSocketConnectionManager.cs b/src/Meridian.Infrastructure/Resilience/WebSocketConnectionManager.cs index ec3642f235..4d129b77d2 100644 --- a/src/Meridian.Infrastructure/Resilience/WebSocketConnectionManager.cs +++ b/src/Meridian.Infrastructure/Resilience/WebSocketConnectionManager.cs @@ -42,6 +42,11 @@ public sealed class WebSocketConnectionManager : IAsyncDisposable 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; private DateTimeOffset? _lastHeartbeatReceivedAt; @@ -373,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); } @@ -390,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; @@ -397,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 @@ -457,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(); } /// @@ -586,7 +654,7 @@ private void ForceDetachTransport() if (heartbeat is not null) { heartbeat.ConnectionLost -= OnConnectionLostAsync; - ObserveForcedCleanup(heartbeat.DisposeAsync().AsTask(), "heartbeat"); + ObserveForcedCleanup(HeartbeatDisposer(heartbeat), "heartbeat"); } try @@ -905,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. @@ -987,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/Ledger.cs b/src/Meridian.Ledger/Ledger.cs index c13b453a24..f5d7c4d203 100644 --- a/src/Meridian.Ledger/Ledger.cs +++ b/src/Meridian.Ledger/Ledger.cs @@ -62,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) @@ -78,7 +79,7 @@ public void Post(JournalEntry entry) AddScopedPostingIndexes(entry.JournalEntryId, line, snapshot.Sequence); } - AddPostingCountSnapshot(entry); + AddPostingCountSnapshot(entry, postingSequence); } /// Returns all individual ledger lines posted to . @@ -794,11 +795,27 @@ private static void RecalculateAccountBalanceSnapshots(List +/// 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 d2cd383d93..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 { @@ -44,18 +46,31 @@ public async Task PostAsync( 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 @@ -66,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) @@ -85,12 +117,10 @@ private static void EnsureEquivalent( && existing.SourceJournalEntryId == requested.SourceJournalEntryId && existing.PostingKind == requested.PostingKind && existing.AdjustmentApproval == requested.AdjustmentApproval - && retained.JournalEntryId == candidate.JournalEntryId && retained.Timestamp == candidate.Timestamp && string.Equals(retained.Description, candidate.Description, StringComparison.Ordinal) && MetadataEquivalent(retained.Metadata, candidate.Metadata) - && retained.Lines.Count == candidate.Lines.Count - && retained.Lines.Zip(candidate.Lines, LinesEquivalent).All(static matches => matches); + && JournalLinesEquivalent(retained.Lines, candidate.Lines); if (!equivalent) { @@ -99,10 +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.JournalEntryId == candidate.JournalEntryId - && retained.Timestamp == candidate.Timestamp + => 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) @@ -183,14 +240,21 @@ private static bool TagsEquivalent( IReadOnlyDictionary? retained, IReadOnlyDictionary? candidate) { - retained ??= new Dictionary(); - candidate ??= new Dictionary(); - if (retained.Count != candidate.Count) + 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 retained) + foreach (var (key, retainedValue) in retainedPairs) { - var candidatePair = candidate.FirstOrDefault(pair => + var candidatePair = candidatePairs.FirstOrDefault(pair => string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase)); if (candidatePair.Key is null || !string.Equals(retainedValue, candidatePair.Value, StringComparison.Ordinal)) @@ -202,6 +266,14 @@ private static bool TagsEquivalent( 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) 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 1a1b7f4cd4..f6c3577c69 100644 --- a/src/Meridian.Storage/Ledger/LedgerPeriodPostingGuard.cs +++ b/src/Meridian.Storage/Ledger/LedgerPeriodPostingGuard.cs @@ -24,6 +24,12 @@ public static void Validate(LedgerJournalEntryWrite entry, LedgerAccountingPerio if (string.Equals(period.Status, "Open", StringComparison.Ordinal)) { + 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."); + } + return; } @@ -50,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 fab4b88d58..6f3236b779 100644 --- a/src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.JournalAutomation.cs +++ b/src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.JournalAutomation.cs @@ -78,15 +78,76 @@ private static void MapJournalAutomationEndpoints(WebApplication app, JsonSerial return EndpointHelpers.Forbidden(); } - if (existing is not null && existing.JournalEntryIds.Count > 0) + if (existing?.State == AutomatedJournalScheduleStateDto.Running) { return Results.Conflict(new { - error = $"Automated journal schedule '{existing.ScheduleId}' cannot be re-armed while its current cycle retains governed drafts. Resolve those drafts before changing the cycle." + 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 { @@ -101,10 +162,10 @@ private static void MapJournalAutomationEndpoints(WebApplication app, JsonSerial JournalEntryIds = [], LastSummary = existing is null ? $"Monthly {request.Kind} work is scheduled." - : $"Monthly {request.Kind} work was re-armed by {actor} after configuration review.", + : $"Monthly {request.Kind} work was deliberately re-armed by {actor} after configuration review.", EvidenceLinks = [], Blockers = [], - RunHistory = existing?.RunHistory ?? [] + RunHistory = runHistory }, context.RequestAborted).ConfigureAwait(false); return Results.Json(saved, jsonOptions); } @@ -112,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(); @@ -170,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)) @@ -242,6 +307,8 @@ ManualJournalEntryStatusDto.Submitted or 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, diff --git a/src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs b/src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs index 38e5a2cb0f..3f99c49b94 100644 --- a/src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs +++ b/src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs @@ -236,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, @@ -2389,7 +2397,14 @@ private static async Task ResolveCloseWorkflowTenantSc var plan = await service .GetPeriodPlanScopedAsync(workflowId, tenant.TenantId, tenant.CompanyId, context.RequestAborted) .ConfigureAwait(false); - if (plan is null || !tenant.HasTenantScope) + 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); } @@ -2405,6 +2420,13 @@ private static async Task ResolveCloseWorkflowTenantSc 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); @@ -2428,21 +2450,15 @@ private static async Task ResolveCloseWorkflowTenantSc return new CloseWorkflowTenantScope(plan, tenant, IsAccessible: false); } - var registry = context.RequestServices.GetService(); - if (registry is not null) + // 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)) { - var owner = await registry.ResolveAsync(book.FundProfileId, context.RequestAborted).ConfigureAwait(false); - if (owner is not null && - (!owner.IsHeldBy(tenant.TenantId) || - (!string.IsNullOrWhiteSpace(owner.CompanyId) && - !string.IsNullOrWhiteSpace(tenant.CompanyId) && - !string.Equals(owner.CompanyId, tenant.CompanyId, StringComparison.OrdinalIgnoreCase)))) - { - return new CloseWorkflowTenantScope(plan, tenant, IsAccessible: false); - } + return new CloseWorkflowTenantScope(plan, tenant, IsAccessible: false); } - var guard = context.RequestServices.GetService(); if (guard is not null) { var decision = await guard @@ -2462,6 +2478,14 @@ private static async Task ResolveCloseWorkflowTenantSc } } + 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.", diff --git a/src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs b/src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs index 477b718be4..0391bef017 100644 --- a/src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs +++ b/src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs @@ -735,8 +735,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 99a48e231f..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; @@ -92,6 +94,12 @@ public async Task EnsureClosingDraftQueuedAsync( } 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, scope, command.Actor), ct) @@ -222,35 +230,97 @@ or ManualJournalEntryStatusDto.Reversed } } - foreach (var batch in activeClosingBatches) + 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( + "Retained reversal drafts do not match this reopen actor, correlation, reason, and evidence; retry is rejected."); + } + + // 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.ContainsKey(batch.JournalEntryId)) + try { - continue; + 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 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); + } + } - 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; + 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 @@ -265,26 +335,6 @@ or ManualJournalEntryStatusDto.Reversed "Retained reversal drafts do not match this reopen actor, correlation, reason, and evidence; retry is rejected."); } - if (period.Status == LedgerPeriodStatusDto.SoftClosed) - { - // The durable period transition already completed on an earlier attempt. The exact - // reversal replay match above proves this is a retry, not a new reopen command. - } - else - { - await RequireLedgerBookService().ReopenPeriodAsync( - period.PeriodId, - new ReopenLedgerPeriodRequest( - command.Actor, - command.Role!, - command.Reason, - command.ApprovalReference!, - command.EvidenceLinks, - command.ActionOrigin), - ct) - .ConfigureAwait(false); - } - var evaluated = await EvaluateAsync(context, ct).ConfigureAwait(false); var pendingCount = activeReversals.Count(static draft => draft.Status is not ManualJournalEntryStatusDto.Posted and not ManualJournalEntryStatusDto.CloseLocked); @@ -303,6 +353,74 @@ await RequireLedgerBookService().ReopenPeriodAsync( }; } + 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) @@ -317,9 +435,20 @@ private static bool IsSameReopenReplay( string.Equals(item.Actor, command.Actor, StringComparison.OrdinalIgnoreCase) && string.Equals(item.CorrelationId, command.CorrelationId, StringComparison.OrdinalIgnoreCase) && string.Equals(item.Notes, command.Reason, StringComparison.Ordinal)); - return transition is not null && command.EvidenceLinks.All(requested => - transition.EvidenceLinks.Any(retained => - string.Equals(requested, retained, StringComparison.OrdinalIgnoreCase))); + 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( @@ -506,9 +635,10 @@ private static void ValidateContext(AccountingClosePostingContext context) 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)) + if (string.IsNullOrWhiteSpace(context.TenantId) && + !string.IsNullOrWhiteSpace(context.CompanyId)) { - throw new ArgumentException("Period-close posting context must carry tenant and company scope together.", nameof(context)); + throw new ArgumentException("Period-close posting context cannot carry company scope without tenant scope.", nameof(context)); } } diff --git a/src/Meridian.Ui.Shared/Services/AutomatedJournalDraftIntakeService.cs b/src/Meridian.Ui.Shared/Services/AutomatedJournalDraftIntakeService.cs index 038a94b01c..255ed1c8a4 100644 --- a/src/Meridian.Ui.Shared/Services/AutomatedJournalDraftIntakeService.cs +++ b/src/Meridian.Ui.Shared/Services/AutomatedJournalDraftIntakeService.cs @@ -10,14 +10,20 @@ internal sealed class DailyValuationPendingDraftException : InvalidOperationExce public DailyValuationPendingDraftException( IReadOnlyList pendingJournalEntryIds, ManualJournalEntryStatusDto pendingStatus, - string scope) + 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; } } /// @@ -58,10 +64,29 @@ public sealed record AutomatedJournalPreparedDraftIntakeRequest( /// /// 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 @@ -184,17 +209,25 @@ private async Task IntakeCoreAsync( 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; } @@ -244,7 +277,7 @@ private static void EnsureNoPendingDailyValuationCorrections( var idempotencyKey = string.IsNullOrWhiteSpace(draft.Metadata.IdempotencyKey) ? BuildFallbackIdempotencyKey(draft.Event) : draft.Metadata.IdempotencyKey.Trim(); - var journalEntryId = BuildDeterministicJournalEntryId(request.FundProfileId, idempotencyKey); + var journalEntryId = BuildDeterministicJournalEntryId(request, idempotencyKey); if (existingDrafts.Any(existing => existing.JournalEntryId == journalEntryId)) { continue; @@ -256,29 +289,60 @@ private static void EnsureNoPendingDailyValuationCorrections( 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 = existingDrafts - .Where(IsPendingDailyValuationDraft) - .Where(existingDraft => existingDraft.AccountingDate == effectiveDate) - .Where(existingDraft => string.Equals( - existingDraft.PeriodId, - request.PeriodId, - StringComparison.OrdinalIgnoreCase)) - .Select(static existingDraft => existingDraft.JournalEntryId) - .Where(static id => id != Guid.Empty) - .Distinct() - .OrderBy(static id => id) - .ToArray(); + var pendingIds = SelectPendingCorrectionBatchIds( + existingDrafts, + pendingOverlap, + draft, + effectiveDate, + request.PeriodId, + request.EntityId); throw new DailyValuationPendingDraftException( pendingIds, pendingOverlap.Status, - DescribeValuationScope(draft)); + 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 @@ -322,6 +386,9 @@ private static string DescribeValuationScope(AutomatedJournalDraft draft) 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( @@ -379,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); } @@ -405,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 29b407855b..828942d104 100644 --- a/src/Meridian.Ui.Shared/Services/AutomatedJournalIntakeRunner.cs +++ b/src/Meridian.Ui.Shared/Services/AutomatedJournalIntakeRunner.cs @@ -95,13 +95,24 @@ public sealed record RunDailyMarkToMarketDraftIntakeRequest( /// (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. @@ -132,6 +143,7 @@ public sealed class AutomatedJournalIntakeRunner private readonly ILedgerBookService? _ledgerBookService; private readonly DailyMarkToMarketService? _dailyMarkToMarketService; private readonly DailyValuationPositionService? _dailyValuationPositionService; + private readonly AutomatedJournalEvidencePolicy _evidencePolicy; public AutomatedJournalIntakeRunner( AutomatedJournalDraftIntakeService intake, @@ -139,7 +151,8 @@ public AutomatedJournalIntakeRunner( CorporateActionDividendEventProducer? dividendProducer = null, ILedgerBookService? ledgerBookService = null, DailyMarkToMarketService? dailyMarkToMarketService = null, - DailyValuationPositionService? dailyValuationPositionService = null) + DailyValuationPositionService? dailyValuationPositionService = null, + AutomatedJournalEvidencePolicy? evidencePolicy = null) { _intake = intake ?? throw new ArgumentNullException(nameof(intake)); _feeProducer = feeProducer ?? throw new ArgumentNullException(nameof(feeProducer)); @@ -147,8 +160,13 @@ public AutomatedJournalIntakeRunner( _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) @@ -163,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, @@ -303,16 +322,22 @@ private static string BuildDailyValuationBatchCorrelationId( /// /// 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 @@ -441,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, @@ -452,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( @@ -470,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 fab36b950a..963e74f072 100644 --- a/src/Meridian.Ui.Shared/Services/AutomatedJournalScheduleStore.cs +++ b/src/Meridian.Ui.Shared/Services/AutomatedJournalScheduleStore.cs @@ -14,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, @@ -29,7 +37,11 @@ public sealed record AutomatedJournalScheduleRunHistory( DateOnly? PeriodStart = null, DateOnly? PeriodEnd = null, decimal? EvidenceConfidenceScore = null, - AutomatedJournalEvidenceQualityDto? EvidenceQuality = null) + AutomatedJournalEvidenceQualityDto? EvidenceQuality = null, + AutomatedJournalScheduleHistoryKind HistoryKind = AutomatedJournalScheduleHistoryKind.Execution, + string? Actor = null, + long? PreviousVersion = null, + long? ResultVersion = null) { public IReadOnlyList JournalEntryIds { get; init; } = JournalEntryIds ?? []; @@ -83,7 +95,8 @@ public sealed record AutomatedJournalScheduleWorkItem( string? CreatedBy = null, string? LastConfiguredBy = null, decimal? LastEvidenceConfidenceScore = null, - AutomatedJournalEvidenceQualityDto? LastEvidenceQuality = null) + AutomatedJournalEvidenceQualityDto? LastEvidenceQuality = null, + long Version = 0) { public IReadOnlyList Positions { get; init; } = Positions ?? []; @@ -108,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, @@ -158,6 +190,25 @@ public Task SaveAsync( $"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; } @@ -168,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. @@ -238,13 +295,33 @@ public async Task SaveAsync( $"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); } @@ -253,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); @@ -347,12 +430,21 @@ 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 => normalizedEntityId is null || string.Equals(item.EntityId, normalizedEntityId, StringComparison.OrdinalIgnoreCase)) .SelectMany(item => ProjectCycles(item, normalizedPeriodId)) .ToArray(); if (scoped.Length == 0) @@ -370,7 +462,10 @@ 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); @@ -425,7 +520,10 @@ public static AutomatedJournalScheduleStatusDto ProjectStatus( journalEntryIds, confidenceScores.Length == 0 ? null : confidenceScores.Min(), evidenceQualities.Length == 0 ? null : evidenceQualities.Min(), - journalEntryIds.Length); + journalEntryIds.Length, + normalizedEntityId, + normalizedTenantId, + normalizedCompanyId); } private static AutomatedJournalScheduleStateDto SelectAggregateState( @@ -467,6 +565,7 @@ private static IEnumerable ProjectCycles( } 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(); @@ -588,124 +687,3 @@ private static void RequireRate(decimal? value, string label) throw new ArgumentOutOfRangeException(label, $"{label} must be between 0 and 1."); } } - -internal sealed record AutomatedJournalFeeEvidenceEvaluation( - bool IsReady, - AutomatedJournalScheduleStateDto FailureState, - AutomatedJournalEvidenceAssessmentDto Assessment, - IReadOnlyList Blockers, - IReadOnlyList EvidenceLinks); - -internal static class AutomatedJournalFeeEvidenceEvaluator -{ - public static AutomatedJournalFeeEvidenceEvaluation Evaluate( - string periodId, - string currency, - decimal? beginningNav, - decimal? endingNavBeforeFees, - decimal? highWaterMark, - AutomatedJournalCapitalAccountReconciliationDto? reconciliation, - decimal minimumConfidence, - DateTimeOffset evaluatedAtUtc) - { - 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); - } - - 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(); - if (!reconciliation.IsReconciled) - mismatches.Add("Capital-account reconciliation is not marked reconciled."); - if (maximumObservedVariance > reconciliation.MaximumVarianceTolerance) - { - mismatches.Add(FormattableString.Invariant( - $"Capital-account reconciliation variance {maximumObservedVariance:0.00} exceeds tolerance {reconciliation.MaximumVarianceTolerance:0.00}.")); - } - if (reconciliation.ConfidenceScore < minimumConfidence) - { - mismatches.Add(FormattableString.Invariant( - $"Capital-account reconciliation confidence {reconciliation.ConfidenceScore:P0} is below the configured {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); - } - - private static AutomatedJournalFeeEvidenceEvaluation Build( - bool isReady, - AutomatedJournalScheduleStateDto failureState, - decimal confidence, - IReadOnlyList evidenceLinks, - IReadOnlyList missing, - IReadOnlyList mismatches, - decimal minimumConfidence) - { - 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 configured {minimumConfidence:P0} threshold and the fee basis ties within tolerance.") - : $"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/AutomatedJournalScheduledWorker.cs b/src/Meridian.Ui.Shared/Services/AutomatedJournalScheduledWorker.cs index bc7f58190f..7af80416b9 100644 --- a/src/Meridian.Ui.Shared/Services/AutomatedJournalScheduledWorker.cs +++ b/src/Meridian.Ui.Shared/Services/AutomatedJournalScheduledWorker.cs @@ -35,16 +35,19 @@ 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( @@ -85,9 +88,9 @@ private async Task RunDueCoreAsync( .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(); @@ -150,7 +153,8 @@ private async Task RunWorkItemAsync( item.HighWaterMark, item.CapitalAccountReconciliation, item.MinimumCapitalAccountConfidence, - nowUtc); + nowUtc, + _evidencePolicy); if (!feeEvidence.IsReady) { return await CompleteAsync( @@ -277,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 @@ -302,7 +318,14 @@ 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(); @@ -317,16 +340,39 @@ private async Task CompleteFromIntakeAsync( 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) @@ -527,6 +573,19 @@ private static IReadOnlyList UpsertHistory( .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(); } @@ -555,6 +614,10 @@ public Task RunOnceAsync(CancellationToken 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 index c238e669eb..2d9395f81f 100644 --- a/src/Meridian.Ui.Shared/Services/DailyValuationBatchLifecycleService.cs +++ b/src/Meridian.Ui.Shared/Services/DailyValuationBatchLifecycleService.cs @@ -62,6 +62,12 @@ public async Task ApproveAndPostAsync( 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(); @@ -78,6 +84,7 @@ public async Task ApproveAndPostAsync( 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)) { @@ -122,7 +129,7 @@ draft.Status is not (ManualJournalEntryStatusDto.Posted or ManualJournalEntrySta actor, notes, batchCorrelationId, - request.EvidenceLinks, + lifecycleEvidence, ct).ConfigureAwait(false); drafts[index] = validation.JournalEntry; if (validation.JournalEntry.Status == ManualJournalEntryStatusDto.NeedsFix || @@ -150,7 +157,7 @@ draft.Status is not (ManualJournalEntryStatusDto.Posted or ManualJournalEntrySta actor, notes, batchCorrelationId, - request.EvidenceLinks, + lifecycleEvidence, ct).ConfigureAwait(false); } catch (InvalidOperationException ex) diff --git a/src/Meridian.Ui.Shared/Services/DailyValuationPositionService.cs b/src/Meridian.Ui.Shared/Services/DailyValuationPositionService.cs index 53e7fe0c0a..03dda44d6d 100644 --- a/src/Meridian.Ui.Shared/Services/DailyValuationPositionService.cs +++ b/src/Meridian.Ui.Shared/Services/DailyValuationPositionService.cs @@ -9,7 +9,10 @@ namespace Meridian.Ui.Shared.Services; -/// One explicit durable run/account position-snapshot scope. +/// +/// 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. @@ -62,6 +65,13 @@ public async Task ResolveConfiguredAsync( 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(); @@ -69,7 +79,7 @@ public async Task ResolveConfiguredAsync( { ct.ThrowIfCancellationRequested(); var snapshot = await _snapshotStore - .GetLatestSnapshotAsync(scope.RunId, scope.AccountId, ct) + .GetLatestSnapshotAsync(scope.RunId, scope.AccountId, ownerScope, ct) .ConfigureAwait(false); if (snapshot is null) { @@ -77,6 +87,21 @@ public async Task ResolveConfiguredAsync( 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, @@ -167,6 +192,20 @@ public Task ResolveAdHocAsync( 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); @@ -310,6 +349,32 @@ private static string BuildSnapshotEvidenceRoute( 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 diff --git a/src/Meridian.Ui.Shared/Services/DailyValuationScheduler.cs b/src/Meridian.Ui.Shared/Services/DailyValuationScheduler.cs index fec63f98be..735674a543 100644 --- a/src/Meridian.Ui.Shared/Services/DailyValuationScheduler.cs +++ b/src/Meridian.Ui.Shared/Services/DailyValuationScheduler.cs @@ -2,6 +2,7 @@ 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; @@ -52,7 +53,9 @@ public sealed record DailyValuationScheduleWorkItem( int MaximumPositionAgeDays = 1, string? StaticPositionHash = null, IReadOnlyList? JournalEntryIds = null, - string? BatchCorrelationId = null) + string? BatchCorrelationId = null, + string? CreatedBy = null, + string? LastConfiguredBy = null) { public IReadOnlyList EvidenceLinks { get; init; } = EvidenceLinks ?? []; @@ -132,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. @@ -220,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); @@ -316,6 +331,14 @@ private async Task RunDueCoreAsync( 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)); } @@ -327,6 +350,11 @@ private async Task RunDueCoreAsync( } } + 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) @@ -423,17 +451,33 @@ private async Task RunWorkItemAsync( .OrderBy(static id => id) .ToArray(); var evidenceLinks = BuildEvidenceLinks(item, run, positionResolution.EvidenceLinks, nowUtc); - var blockers = run.Valuation.RejectedMarks + 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 && blockers.Length > 0) + if (run.Valuation.Projection is null && markBlockers.Length > 0) { return await CompleteAsync( running, nowUtc, DailyValuationScheduleStateDto.Blocked, - $"Daily valuation was blocked because {blockers.Length} closing mark(s) failed trust policy.", + $"Daily valuation was blocked because {markBlockers.Length} closing mark(s) failed trust policy.", + journalEntryIds, + batchCorrelationId, + evidenceLinks, + blockers, + ct).ConfigureAwait(false); + } + + if (intakeBlockers.Count > 0) + { + return await CompleteAsync( + running, + nowUtc, + DailyValuationScheduleStateDto.Blocked, + $"Daily valuation retained {intakeBlockers.Count} draft intake blocker(s); the batch is not ready for approval.", journalEntryIds, batchCorrelationId, evidenceLinks, @@ -461,13 +505,24 @@ private async Task RunWorkItemAsync( $"Daily valuation produced {run.Valuation.Approvals.Count} governed draft(s), but intake retained {journalEntryIds.Length} draft id(s)."); } - var summary = run.Intake.Created.Count > 0 + 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, journalEntryIds, batchCorrelationId, @@ -477,7 +532,14 @@ private async Task RunWorkItemAsync( } catch (DailyValuationPendingDraftException ex) { - var retainedIds = item.JournalEntryIds + 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() @@ -489,7 +551,7 @@ private async Task RunWorkItemAsync( DailyValuationScheduleStateDto.Blocked, ex.Message, retainedIds, - item.BatchCorrelationId ?? BuildRecoveredBatchCorrelationId(item, retainedIds), + ex.PendingBatchCorrelationId ?? BuildRecoveredBatchCorrelationId(item, retainedIds), item.EvidenceLinks, [ex.Message], ct).ConfigureAwait(false); @@ -555,6 +617,30 @@ private async Task CompleteAsync( 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) { var next = scheduledForUtc.ToUniversalTime().AddDays(1); @@ -622,6 +708,10 @@ public Task RunOnceAsync(CancellationToken c 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) { @@ -724,6 +814,10 @@ 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 ?? [], @@ -741,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, @@ -751,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) @@ -773,7 +881,10 @@ public static DailyValuationScheduleStatusDto ProjectStatus( EvidenceLinks: [], Blockers: [MissingScopeMessage], JournalEntryIds: [], - BatchCorrelationId: null); + BatchCorrelationId: null, + normalizedEntityId, + normalizedTenantId, + normalizedCompanyId); } var state = matching.IsEnabled @@ -801,7 +912,10 @@ public static DailyValuationScheduleStatusDto ProjectStatus( matching.EvidenceLinks, blockers, matching.JournalEntryIds, - matching.BatchCorrelationId); + matching.BatchCorrelationId, + matching.EntityId, + matching.TenantId, + matching.CompanyId); } public static void EnsureOwnershipUnchanged( @@ -809,10 +923,18 @@ public static void EnsureOwnershipUnchanged( DailyValuationScheduleWorkItem replacement) { if (!string.Equals(existing.TenantId, replacement.TenantId, StringComparison.OrdinalIgnoreCase) || - !string.Equals(existing.CompanyId, replacement.CompanyId, 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}' tenant/company ownership is immutable."); + $"Daily valuation schedule '{existing.ScheduleId}' belongs to a different immutable identity scope."); } } @@ -852,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/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 bc3253b3c3..a902c05ab8 100644 --- a/src/Meridian.Ui.Shared/Services/ManualJournalEntryWorkbenchService.Lifecycle.cs +++ b/src/Meridian.Ui.Shared/Services/ManualJournalEntryWorkbenchService.Lifecycle.cs @@ -723,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", diff --git a/src/Meridian.Ui.Shared/Services/ManualJournalEntryWorkbenchService.cs b/src/Meridian.Ui.Shared/Services/ManualJournalEntryWorkbenchService.cs index 8ae64d4b35..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); @@ -1254,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 ad32755dcd..a511aa0774 100644 --- a/src/Meridian.Ui.Shared/Services/WorkstationServiceCollectionExtensions.cs +++ b/src/Meridian.Ui.Shared/Services/WorkstationServiceCollectionExtensions.cs @@ -560,6 +560,7 @@ public static IServiceCollection AddWorkstationSharedServices(this IServiceColle services.TryAddSingleton(sp => (IManualJournalEntryLifecycleService)sp.GetRequiredService()); services.TryAddSingleton(); + services.TryAddSingleton(AutomatedJournalEvidencePolicy.Default); services.TryAddSingleton(sp => new AutomatedJournalDraftIntakeService( sp.GetRequiredService(), @@ -581,7 +582,8 @@ providerRegistry is null || journalStore is null : new DailyMarkToMarketService( new RegisteredHistoricalCloseMarkPriceSource(providerRegistry), new LedgerMarkToMarketCarryingValueSource(journalStore)), - positionService); + 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/lib/api.ts b/src/Meridian.Ui/dashboard/src/lib/api.ts index 9ce830fa9e..2d500ec769 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, @@ -2177,6 +2182,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); } @@ -3273,6 +3315,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 0bd170dd3b..4c0abcbd08 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 00f74aac72..4f1c1bec01 100644 --- a/src/Meridian.Ui/dashboard/src/screens/data-screen.test.tsx +++ b/src/Meridian.Ui/dashboard/src/screens/data-screen.test.tsx @@ -13,13 +13,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 = { @@ -315,6 +317,272 @@ describe("DataScreen", () => { expect(screen.queryByRole("treegrid", { name: "Backfill queue" })).not.toBeInTheDocument(); }); + it("renders composite quality evidence and submits the exact stable gap remediation request", async () => { + const user = userEvent.setup(); + const actionableGap = { + gapId: "gap-actionable-0001", + symbol: "TSLA", + provider: "polygon", + eventType: "Trade", + from: "2026-07-01T14:00:00Z", + to: "2026-07-01T14:07:00Z", + estimatedMissingEvents: 11, + severity: "Significant", + status: "Open" as const, + canBackfill: true, + disabledReason: null + }; + const disabledGap = { + ...actionableGap, + gapId: "gap-disabled-0002", + provider: null, + eventType: "Quote", + estimatedMissingEvents: 23, + canBackfill: false, + disabledReason: "No configured provider supports quote replay for this interval." + }; + const qualityDashboard: QualityDashboardResponse = { + timestamp: "2026-07-15T12:00:00Z", + composite: { + version: "quality-dashboard-v17", + observedAt: "2026-07-15T12:00:00Z", + compositeScore: 72.5, + status: "Amber", + isPartial: true, + coverageWeight: 0.75, + components: [ + { + kind: "StoredCompleteness", + label: "Stored completeness", + weight: 0.4, + score: 91, + availability: "Measured", + observedAt: "2026-07-15T12:00:00Z", + issueCount: 0, + detail: "Stored bars were measured against expected sessions." + }, + { + kind: "StreamingFreshness", + label: "Streaming freshness", + weight: 0.35, + score: 64, + availability: "Partial", + observedAt: "2026-07-15T11:59:00Z", + issueCount: 1, + detail: "One tracked feed is stale." + }, + { + kind: "AdapterGapIntegrity", + label: "Adapter gap integrity", + weight: 0.25, + score: 48, + availability: "Measured", + observedAt: "2026-07-15T12:00:00Z", + issueCount: 2, + detail: "Two stable adapter gaps remain open." + } + ], + symbols: [ + { + symbol: "TSLA", + compositeScore: 54, + status: "Red", + isPartial: true, + coverageWeight: 0.67, + expectedEvents: null, + observedEvents: null, + anomalyCount: 0, + components: [ + { + kind: "StoredCompleteness", + label: "Stored completeness", + weight: 0.4, + score: 78, + availability: "Partial", + observedAt: "2026-07-15T12:00:00Z", + issueCount: 2, + detail: "Stored history contains two retained gaps." + }, + { + kind: "StreamingFreshness", + label: "Streaming freshness", + weight: 0.35, + score: null, + availability: "Unavailable", + observedAt: null, + issueCount: 1, + detail: "No streaming observation is available." + }, + { + kind: "AdapterGapIntegrity", + label: "Adapter gap integrity", + weight: 0.25, + score: 44, + availability: "Measured", + observedAt: "2026-07-15T12:00:00Z", + issueCount: 2, + detail: "Exact retained gap evidence is available." + } + ], + openGaps: [actionableGap, disabledGap], + providerFreshness: [ + { + provider: "polygon", + lastEventAt: "2026-07-15T11:59:00Z", + ageMilliseconds: 60_000, + status: "Stale", + completenessScore: 78, + gapCount: 2 + } + ], + issues: ["Streaming evidence is unavailable."] + } + ], + openGaps: [actionableGap, disabledGap], + anomalyCount: 0 + }, + recentGaps: [], + recentAnomalies: [] + }; + const getQualityDashboard = vi.fn().mockResolvedValue(qualityDashboard); + const remediateQualityGap = vi.fn().mockResolvedValue({ + gapId: actionableGap.gapId, + symbol: actionableGap.symbol, + status: "Completed", + provider: actionableGap.provider, + from: actionableGap.from, + to: actionableGap.to, + idempotencyKey: "gap-actionable-0001|quality-dashboard-v17", + message: "Exact gap remediation completed." + }); + + renderWithRouter( + , + { initialEntries: ["/data"] } + ); + await user.click(screen.getByText("Review data diagnostics")); + + const qualityRegion = await screen.findByRole("region", { name: "Data quality" }); + expect(getQualityDashboard).toHaveBeenCalled(); + const scoreCards = within(qualityRegion).getByRole("list", { name: "Data quality scores" }); + expect(within(scoreCards).getByText("72.5")).toBeInTheDocument(); + expect(within(scoreCards).getByText("91.0")).toBeInTheDocument(); + expect(within(scoreCards).getByText("64.0")).toBeInTheDocument(); + expect(within(scoreCards).getByText("48.0")).toBeInTheDocument(); + expect(within(qualityRegion).getByText("Partial quality evidence")).toBeInTheDocument(); + + await user.click(within(qualityRegion).getByText("TSLA")); + expect(within(qualityRegion).getByText(/stored 78\.0 · streaming Unavailable · adapter 44\.0/i)) + .toBeInTheDocument(); + expect(within(qualityRegion).getByText("Expected-session counts unavailable")).toBeInTheDocument(); + expect(within(qualityRegion).getByText("No streaming observation is available.")).toBeInTheDocument(); + + const gaps = within(qualityRegion).getByRole("list", { name: "TSLA open gaps" }); + expect(within(gaps).getByText("gap-actionable-0001")).toBeInTheDocument(); + expect(within(gaps).getByText("gap-disabled-0002")).toBeInTheDocument(); + expect(within(gaps).getByText("Remediation unavailable: No configured provider supports quote replay for this interval.")) + .toBeInTheDocument(); + expect(within(gaps).getByRole("button", { + name: "Backfill gap gap-disabled-0002 unavailable: No configured provider supports quote replay for this interval." + })).toBeDisabled(); + + await user.click(within(gaps).getByRole("button", { name: "Backfill gap gap-actionable-0001 for TSLA" })); + await waitFor(() => expect(remediateQualityGap).toHaveBeenCalledWith("TSLA", { + gapId: "gap-actionable-0001", + dashboardVersion: "quality-dashboard-v17" + })); + }); + + it("renders the durable remediation SLA queue and sorts tier and deadline columns", async () => { + const user = userEvent.setup(); + 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: 5, + 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" + }; + vi.spyOn(api, "getBackfillExecutionHistory").mockResolvedValueOnce(history); + + renderWithRouter(, { initialEntries: ["/data/backfills"] }); + + const table = await screen.findByRole("table", { name: "Backfill remediation SLA queue" }); + expect(within(table).getByText("Same business day")).toBeInTheDocument(); + expect(within(table).getByText("Assignment required")).toBeInTheDocument(); + expect(within(table).getByText("Compatibility-derived legacy evidence", { exact: false })).toBeInTheDocument(); + + const dataRows = () => within(table).getAllByRole("row").slice(1); + expect(dataRows()[0]).toHaveTextContent("critical-later"); + await user.click(within(table).getByRole("button", { name: /Deadline/i })); + expect(dataRows()[0]).toHaveTextContent("standard-sooner"); + await user.click(within(table).getByRole("button", { name: /SLA tier/i })); + expect(dataRows()[0]).toHaveTextContent("critical-later"); + }); + it("runs SQL queries with paged results and export actions", async () => { const user = userEvent.setup(); const rows = Array.from({ length: 102 }, (_, index) => [`SYM${index}`, String(index)]); diff --git a/src/Meridian.Ui/dashboard/src/screens/data-screen.tsx b/src/Meridian.Ui/dashboard/src/screens/data-screen.tsx index 4a4f6208d2..c1aa63b3e5 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, @@ -91,6 +95,8 @@ interface DataScreenProps { providerRoutingBindings?: ProviderRoutingBinding[] | null; providerRoutingTrustSnapshots?: ProviderRoutingTrustSnapshot[] | null; providerRoutingRefreshing?: boolean; + dataQualityFetcher?: QualityDashboardFetcher; + dataQualityActionApi?: Partial; onProviderSetupConfigured?: () => Promise | void; onProviderRoutingRefresh?: () => Promise | void; } @@ -204,6 +210,8 @@ export function DataScreen({ providerRoutingBindings = null, providerRoutingTrustSnapshots = null, providerRoutingRefreshing = false, + dataQualityFetcher, + dataQualityActionApi, onProviderSetupConfigured, onProviderRoutingRefresh }: DataScreenProps) { @@ -232,7 +240,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(); @@ -330,7 +338,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 88f99d1d11..907b311af2 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, @@ -1010,6 +1012,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; @@ -1027,17 +1113,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()); @@ -1063,6 +1201,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 043e4d1d22..af8b97358a 100644 --- a/tests/Meridian.Tests/Infrastructure/Providers/BackfillRetryAfterTests.cs +++ b/tests/Meridian.Tests/Infrastructure/Providers/BackfillRetryAfterTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http; +using FluentAssertions; using Meridian.Core.Exceptions; using Meridian.Infrastructure.Adapters.Core; using Xunit; @@ -32,6 +33,43 @@ public void IsRateLimited_TypedAndHttpStatusSignals_AreClassified() 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/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/WebSocketConnectionManagerTests.cs b/tests/Meridian.Tests/Infrastructure/Resilience/WebSocketConnectionManagerTests.cs index df275d113e..7b5d143dad 100644 --- a/tests/Meridian.Tests/Infrastructure/Resilience/WebSocketConnectionManagerTests.cs +++ b/tests/Meridian.Tests/Infrastructure/Resilience/WebSocketConnectionManagerTests.cs @@ -118,6 +118,13 @@ public async Task DisposeAsync_WhenReconnectTransactionIgnoresCancellation_IsBou 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); @@ -131,11 +138,162 @@ public async Task DisposeAsync_WhenReconnectTransactionIgnoresCancellation_IsBou 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] diff --git a/tests/Meridian.Tests/Ledger/LedgerIntegrationTests.cs b/tests/Meridian.Tests/Ledger/LedgerIntegrationTests.cs index 9a12963e2b..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); diff --git a/tests/Meridian.Tests/Storage/GovernedLedgerPostingTargetTests.cs b/tests/Meridian.Tests/Storage/GovernedLedgerPostingTargetTests.cs index 716c26e59c..3d853eb301 100644 --- a/tests/Meridian.Tests/Storage/GovernedLedgerPostingTargetTests.cs +++ b/tests/Meridian.Tests/Storage/GovernedLedgerPostingTargetTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Meridian.Contracts.FundStructure; using Meridian.Contracts.Ledger; using Meridian.Ledger; using Meridian.Storage.Ledger; @@ -158,6 +159,223 @@ original.Entry.Metadata with 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) @@ -165,6 +383,15 @@ private static Mock BuildStore( 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, _) => { @@ -174,6 +401,55 @@ private static Mock BuildStore( 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"); @@ -258,14 +534,16 @@ private static LedgerJournalEntryWrite BuildWrite() LedgerBookId: LedgerBookId); } - private static LedgerJournalEntryRecord ToRecord(LedgerJournalEntryWrite write) + private static LedgerJournalEntryRecord ToRecord( + LedgerJournalEntryWrite write, + long globalSequence = 1) => new( write.Entry, write.AggregateId, write.PeriodId, write.CommandId, write.CorrelationId, - 1, + globalSequence, OccurredAt, write.AccountingBasis, write.AccountingPolicyId, @@ -277,6 +555,29 @@ private static LedgerJournalEntryRecord ToRecord(LedgerJournalEntryWrite write) 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); @@ -304,4 +605,25 @@ private static LedgerJournalEntryWrite WithLedgerBook(LedgerJournalEntryWrite wr 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 8f3a53a440..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() { 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/Ui/AccountingConfigurationServiceTests.cs b/tests/Meridian.Tests/Ui/AccountingConfigurationServiceTests.cs index 237a475c7a..dcdcde9369 100644 --- a/tests/Meridian.Tests/Ui/AccountingConfigurationServiceTests.cs +++ b/tests/Meridian.Tests/Ui/AccountingConfigurationServiceTests.cs @@ -3,6 +3,7 @@ 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; @@ -13,6 +14,7 @@ using Meridian.Storage.Ledger; using Meridian.Ui.Shared.Services; using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; namespace Meridian.Tests.Ui; @@ -21,6 +23,7 @@ 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() @@ -1332,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( @@ -1356,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); @@ -1379,18 +1411,29 @@ await configuration.UpsertChartNodeAsync(new UpsertChartOfAccountsNodeRequest( 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(), dailyMarkToMarketService: new DailyMarkToMarketService( - new StaticMarkPriceSource(new MarkPriceQuote( - 160m, - "trusted-close", - "evidence://prices/AAPL/2026-06-30", - new DateOnly(2026, 6, 30), - DailyPortfolioPriceConfidence.High)))); + priceSource, + new LedgerMarkToMarketCarryingValueSource(journalStore)), + dailyValuationPositionService: positionService); var scheduleSource = new InMemoryDailyValuationPortfolioSource(); - await scheduleSource.SaveAsync(new DailyValuationScheduleWorkItem( + var configured = await scheduleSource.SaveAsync(new DailyValuationScheduleWorkItem( "daily-valuation-fund-alpha", "fund-alpha", "USD", @@ -1398,7 +1441,10 @@ await scheduleSource.SaveAsync(new DailyValuationScheduleWorkItem( ManualJournalLedgerBookId, ManualJournalPeriodId, asOf, - [new MarkToMarketPosition("AAPL", 100m, 150m, SecurityId: DailyValuationAaplSecurityId)], + [ + new MarkToMarketPosition("AAPL", 100m, 150m, SecurityId: DailyValuationAaplSecurityId), + new MarkToMarketPosition("MSFT", 50m, 200m, SecurityId: DailyValuationMsftSecurityId) + ], "valuation-policy-1", "Daily close", "market-close", @@ -1409,11 +1455,14 @@ [new MarkToMarketPosition("AAPL", 100m, 150m, SecurityId: DailyValuationAaplSecu 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); @@ -1423,75 +1472,116 @@ [new MarkToMarketPosition("AAPL", 100m, 150m, SecurityId: DailyValuationAaplSecu 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)); + "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 = [] + }); - posted.JournalEntry.Status.Should().Be(ManualJournalEntryStatusDto.Posted); - journalStore.Appended.Should().ContainSingle("the duplicate valuation run must not append again"); - var postedWrite = journalStore.Appended.Single(); - postedWrite.Entry.Metadata.SecurityId.Should().Be(DailyValuationAaplSecurityId); - postedWrite.Entry.Metadata.Symbol.Should().Be("AAPL"); - postedWrite.Entry.Metadata.Tags!["securityMasterProvenance"].Should() - .Contain($"security-master:{DailyValuationAaplSecurityId:N}") - .And.Contain("server-resolved:true") - .And.Contain("approved:true"); - postedWrite.Entry.Metadata.Tags["securityMasterLineage"].Should() - .Contain($"AAPL:{DailyValuationAaplSecurityId:N}") - .And.Contain("ledger-map:manual-journal:AAPL") - .And.Contain("sm-approval:security-master-active") - .And.Contain("security-status:Active"); - - // 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) @@ -1500,8 +1590,8 @@ [new MarkToMarketPosition("AAPL", 100m, 150m, SecurityId: DailyValuationAaplSecu } 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] @@ -1514,9 +1604,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", @@ -1533,11 +1625,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); @@ -1545,10 +1640,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(); @@ -5313,6 +5408,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() { @@ -7229,14 +7386,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, @@ -7245,6 +7403,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}"; @@ -7380,13 +7584,67 @@ 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 ? CreateAaplDetail() : null); + => 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, @@ -7401,10 +7659,13 @@ private sealed class DailyValuationSecurityMasterQueryService CancellationToken ct = default, DateTimeOffset? asOfUtc = null) => Task.FromResult( - identifierKind == SecurityIdentifierKind.Ticker && - string.Equals(identifierValue, "AAPL", StringComparison.OrdinalIgnoreCase) - ? CreateAaplDetail() - : null); + 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, @@ -7442,12 +7703,16 @@ public Task> GetCorporateActionsAsync( CancellationToken ct = default) => Task.FromResult(null); - private static SecurityDetailDto CreateAaplDetail() + private static SecurityDetailDto CreateDetail( + Guid securityId, + string symbol, + string displayName, + DateTimeOffset validFrom) => new( - DailyValuationAaplSecurityId, + securityId, "Equity", SecurityStatusDto.Active, - "Apple Inc.", + displayName, "USD", EmptyTerms, EmptyTerms, @@ -7455,14 +7720,14 @@ private static SecurityDetailDto CreateAaplDetail() [ new SecurityIdentifierDto( SecurityIdentifierKind.Ticker, - "AAPL", + symbol, IsPrimary: true, - ValidFrom: new DateTimeOffset(1980, 12, 12, 0, 0, 0, TimeSpan.Zero), - NormalizedValue: "AAPL") + ValidFrom: validFrom, + NormalizedValue: symbol) ], Aliases: [], Version: 7, - EffectiveFrom: new DateTimeOffset(1980, 12, 12, 0, 0, 0, TimeSpan.Zero), + EffectiveFrom: validFrom, EffectiveTo: null); } @@ -7521,6 +7786,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 d4b21b9dd5..067410e65f 100644 --- a/tests/Meridian.Tests/Ui/AutomatedJournalScheduleTests.cs +++ b/tests/Meridian.Tests/Ui/AutomatedJournalScheduleTests.cs @@ -187,6 +187,42 @@ 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() { @@ -194,7 +230,7 @@ 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); var original = await firstStore.SaveAsync(FeeSchedule("fees-restart-2026-07")); var firstRun = await CreateWorker(firstStore, fixture.Runner).RunDueAsync(DueAt); @@ -208,6 +244,7 @@ public async Task PersistedRunningClaim_RestartsWithSameRunKey_AndDeduplicatesDr }; await firstStore.SaveAsync(original with { + Version = completed.Version, State = AutomatedJournalScheduleStateDto.Running, LastRunAtUtc = null, LastScheduledForUtc = DueAt, @@ -215,7 +252,8 @@ await firstStore.SaveAsync(original with }); 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); @@ -224,8 +262,8 @@ await firstStore.SaveAsync(original with persisted.RunHistory.Should().ContainSingle("a restart replaces the durable record for the same run key"); persisted.RunHistory.Single().JournalEntryIds.Should().BeEquivalentTo(firstRun.Runs.Single().JournalEntryIds); persisted.PeriodId.Should().Be("2026-08"); - (await fixture.Workbench.GetWorkbenchAsync("fund-alpha", BookId)).Drafts.Should().HaveCount(2, - "downstream deterministic ids must deduplicate a restart after intake"); + (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 { @@ -282,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( @@ -352,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) @@ -464,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, @@ -485,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;