diff --git a/build/scripts/docs/generate-governance-readiness-dashboard.py b/build/scripts/docs/generate-governance-readiness-dashboard.py index 6b836a2cf9..abdd0cd6d9 100644 --- a/build/scripts/docs/generate-governance-readiness-dashboard.py +++ b/build/scripts/docs/generate-governance-readiness-dashboard.py @@ -68,7 +68,12 @@ "id": "governance-endpoints", "category": "Governance Operations", "label": "Workstation endpoints expose governance break queue and calibration routes", - "paths": ["src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs"], + "paths": [ + "src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs", + # Route literals moved into UiApiRoutes constants; scan both so + # constant-backed routes still count as endpoint evidence. + "src/Meridian.Contracts/Api/UiApiRoutes.cs", + ], "terms": ["break-queue", "calibration-summary", "sign-off"], "weight": 3, "remediation": "Keep governance readiness tied to workstation endpoints and documented route evidence.", diff --git a/docs/generated/repository-structure.md b/docs/generated/repository-structure.md index 377886bf8e..57c16a13a6 100644 --- a/docs/generated/repository-structure.md +++ b/docs/generated/repository-structure.md @@ -4504,6 +4504,7 @@ Meridian-main │ │ │ │ ├── V_ledger_005__journal_basis_lineage.sql │ │ │ │ ├── V_ledger_006__journal_posting_kind.sql │ │ │ │ ├── V_ledger_007__journal_adjustment_approval_metadata.sql +│ │ │ │ ├── V_ledger_008__closing_entry_posting_kind.sql │ │ │ │ ├── V_ledger_008__operations_continuity.sql │ │ │ │ ├── V_ledger_009__tax_lot_persistence.sql │ │ │ │ ├── V_ledger_010__accounting_configuration.sql @@ -5562,6 +5563,7 @@ Meridian-main │ │ │ ├── IngestionJobEndpoints.cs │ │ │ ├── LeanEndpoints.cs │ │ │ ├── LedgerEndpoints.cs +│ │ │ ├── LedgerEndpoints.JournalAutomation.cs │ │ │ ├── LiveDataEndpoints.cs │ │ │ ├── LoginSessionMiddleware.cs │ │ │ ├── MaintenanceScheduleEndpoints.cs @@ -5691,6 +5693,7 @@ Meridian-main │ │ │ ├── InvestmentAccountingTransactionLabService.cs │ │ │ ├── IProviderModuleSetupService.cs │ │ │ ├── LedgerAmountProvenanceService.cs +│ │ │ ├── LedgerDimensionMapper.cs │ │ │ ├── MultiAssetCoverageReadService.cs │ │ │ ├── OmsIntegrationService.cs │ │ │ ├── OperationsContinuityReconciliationBridge.cs @@ -7216,6 +7219,7 @@ Meridian-main │ │ │ └── SecurityMaster │ │ │ └── SecurityKindMappingTests.cs │ │ ├── Reporting +│ │ │ ├── NavAttributionServiceTests.cs │ │ │ ├── ReportGenerationServiceTests.cs │ │ │ ├── ReportingOrchestrationServiceTests.cs │ │ │ ├── ReportSnapshotDiffEngineTests.cs diff --git a/docs/product/README.md b/docs/product/README.md index 4d2f7175bb..ee8e32f089 100644 --- a/docs/product/README.md +++ b/docs/product/README.md @@ -17,6 +17,9 @@ It routes non-technical audiences to verified evidence and prevents duplicate cl - [High-Value Code Brainstorm (2026-07)](high-value-code-brainstorm-2026-07.md) — market-researched prioritization of the next highest-value implementable features, sequenced against the W6/W7 roadmap lanes + - [Data Provider & Accounting Code Brainstorm (2026-07)](data-provider-accounting-brainstorm-2026-07.md) — + code-grounded improvement lanes for the provider and accounting subsystems with a dated status + table tracking which lanes have since shipped - [Portfolio Cash Ladder Blueprint (2026-07)](portfolio-cash-ladder-blueprint-2026-07.md) — code-ready design for the wave-8 portfolio cash-flow forecasting and liquidity ladder engine, aggregating per-security projection runs into scenario-aware, per-currency cash ladders diff --git a/docs/product/data-provider-accounting-brainstorm-2026-07.md b/docs/product/data-provider-accounting-brainstorm-2026-07.md index 4799093054..ab6881fe6b 100644 --- a/docs/product/data-provider-accounting-brainstorm-2026-07.md +++ b/docs/product/data-provider-accounting-brainstorm-2026-07.md @@ -33,9 +33,9 @@ analysis of 2026-07-05, with dated update notes where the premise has changed. C | 3 | Unified data quality + browser dashboard | Partially done | The Data workspace now renders `DataQualityRegion` over `/api/quality/dashboard`. Remaining: unifying the three scoring subsystems behind one composite health model, and contextual gap actions (one-click backfill). | | 4 | Backfill feedback loop | Done | `OnProgressUpdate` is raised with subscriber-failure guards; SLA metadata is typed (`BackfillRemediationSlaDecision`/`Metadata`/`Status`); the remediation provider is an options default rather than hard-coded. | | 5 | Failure & rate-limit hardening | Partially done | `DataSourceRegistry` now records activation/registration failures via `RecordFailure`. Remaining: typed rate-limit detection (string matching on `"429"`/`"rate limit"` still present in the composite), streaming-side rate-limit tracking, and confirming registration failures surface in the provider catalog UI. | -| 6 | Mark-to-market wiring | Largely done | `DailyMarkToMarketService` runs `DailyPortfolioPricingProjector` into governed drafts, with tests. Remaining: `NavAttributionService` still computes `totalNav` as a sum of all component balances rather than assets − liabilities, and NAV consumption of the marked book needs end-to-end verification. | -| 7 | Automated journal drafts | Partially done | Corporate-action/dividend event producers and draft intake shipped (`AutomatedJournalEventKind`, `DurableAutomatedJournalPoster`, `AutomatedJournalDraftIntakeService`). Remaining: management/performance-fee and withholding-tax accrual producers on a schedule. | -| 8 | Closing entries + retained-earnings roll | Projector built, unwired | `PeriodCloseProjector`/`PeriodCloseDraftBuilder` now exist in `Meridian.Ledger` but have no callers outside the library — the hard-close sequence in close management does not yet invoke them. | +| 6 | Mark-to-market wiring | Done (2026-07-06) | `DailyMarkToMarketService` runs `DailyPortfolioPricingProjector` into governed drafts, with tests. `NavAttributionService` now computes NAV as assets − liabilities (with `ByAssetClass` decomposing that total), covered by `NavAttributionServiceTests`. | +| 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 | Open | `DurableAutomatedJournalPoster` is the emerging draft-posting seam, but hydration of read surfaces from `ILedgerJournalStore`, as-of indexing, and the F#/C# enum-ordinal guard test have not started. | | 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. | @@ -218,6 +218,12 @@ through the existing event-pipeline policy channels, drop-oldest). The SLA metad touches persisted execution logs — route through the WAL/`AtomicFileWriter` patterns and keep the parser as fallback for old records. +> **Update (2026-07-06):** implemented — `CompositeHistoricalDataProvider` raises progress through +> `RaiseProgress`, `AutoGapRemediationService` stores typed `BackfillRemediationSlaMetadata` +> records, and the remediation provider comes from `AutoGapRemediationPolicy.DefaultProvider` +> rather than a hard-coded call-site. This lane is done; the narrative above is the point-in-time +> analysis, not a work list. + ### 5. Provider Failure & Rate-Limit Hardening Three small findings that share a theme — failures that vanish. `DataSourceRegistry.cs` swallows diff --git a/docs/status/TODO.md b/docs/status/TODO.md index ec4acc73be..25d83af902 100644 --- a/docs/status/TODO.md +++ b/docs/status/TODO.md @@ -103,7 +103,6 @@ Total items: **213** | `src/Meridian.Ui/dashboard/src/screens/accounting-screen.view-model.ts` | 6070 | `NOTE` | ❌ | note: event.sourceReason ?? (event.isCurrentProjection ? "Current schedule projection." : null) | | `src/Meridian.Ui/dashboard/src/screens/covered-call-screen.view-model.ts` | 287 | `NOTE` | ❌ | note: string; | | `src/Meridian.Ui/dashboard/src/screens/covered-call-screen.view-model.ts` | 990 | `NOTE` | ❌ | note: "Covered-call net curve requires the underlying cost basis which is not yet threaded through the API. The chart shows the short-call leg only." | -| `src/Meridian.Ui/dashboard/src/screens/daily-control-tower-screen.test.tsx` | 76 | `NOTE` | ❌ | note: "Paper endpoint returned intermittent quote gaps.", | | `src/Meridian.Ui/dashboard/src/screens/data-screen.security-master.ts` | 128 | `NOTE` | ❌ | note: string; | | `src/Meridian.Ui/dashboard/src/screens/data-screen.security-master.ts` | 141 | `NOTE` | ❌ | note: string; | | `src/Meridian.Ui/dashboard/src/screens/data-screen.security-master.ts` | 437 | `NOTE` | ❌ | note: "Matches issuer relations and SEC 8-K references. Amount amended from $2.75 at board confirmation.", | diff --git a/docs/status/api-contract-coverage-dashboard.json b/docs/status/api-contract-coverage-dashboard.json index 0975e64239..5af956df3d 100644 --- a/docs/status/api-contract-coverage-dashboard.json +++ b/docs/status/api-contract-coverage-dashboard.json @@ -6,8 +6,8 @@ "endpoint_coverage_percent": 100.0, "contract_coverage_percent": 100.0, "summary": { - "endpoint_count": 595, - "documented_endpoint_count": 595, + "endpoint_count": 596, + "documented_endpoint_count": 596, "undocumented_endpoint_count": 0, "workstation_contract_count": 814, "documented_workstation_contract_count": 814, @@ -1838,6 +1838,24 @@ "source": "src/Meridian.Ui.Shared/Endpoints/LeanEndpoints.cs:549", "documented": true }, + { + "method": "POST", + "path": "/api/ledger/journal-automation/dividend-intake", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.JournalAutomation.cs:19", + "documented": true + }, + { + "method": "POST", + "path": "/api/ledger/journal-automation/fee-accrual-intake", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.JournalAutomation.cs:61", + "documented": true + }, + { + "method": "POST", + "path": "/api/ledger/journal-automation/period-close-intake", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.JournalAutomation.cs:103", + "documented": true + }, { "method": "GET", "path": "/api/ledger/books", @@ -2132,28 +2150,16 @@ "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1975", "documented": true }, - { - "method": "POST", - "path": "/api/ledger/journal-automation/dividend-intake", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:2018", - "documented": true - }, - { - "method": "POST", - "path": "/api/ledger/journal-automation/fee-accrual-intake", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:2060", - "documented": true - }, { "method": "POST", "path": "/api/ledger/journal-entry-workbench/evidence", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:2102", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:2020", "documented": true }, { "method": "POST", "path": "/api/ledger/journal-entry-workbench/lifecycle-action", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:2145", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:2063", "documented": true }, { diff --git a/docs/status/api-contract-coverage-dashboard.md b/docs/status/api-contract-coverage-dashboard.md index d3238f2a69..6d4f7522c3 100644 --- a/docs/status/api-contract-coverage-dashboard.md +++ b/docs/status/api-contract-coverage-dashboard.md @@ -14,7 +14,7 @@ Tracks whether mapped API routes and workstation DTO contracts are visible in th | Weighted score | 100.0% | | Endpoint coverage | 100.0% | | Workstation contract coverage | 100.0% | -| Endpoints documented | 595 / 595 | +| Endpoints documented | 596 / 596 | | Workstation contracts documented | 814 / 814 | ## Endpoint Coverage @@ -259,12 +259,13 @@ Tracks whether mapped API routes and workstation DTO contracts are visible in th | `POST` | `/api/ledger/close-management/period-plan/configuration` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1071` | | `GET` | `/api/ledger/close-management/period-plan/{workflowId:guid}` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1045` | | `POST` | `/api/ledger/close-management/task-signoffs` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1221` | -| `POST` | `/api/ledger/journal-automation/dividend-intake` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:2018` | -| `POST` | `/api/ledger/journal-automation/fee-accrual-intake` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:2060` | +| `POST` | `/api/ledger/journal-automation/dividend-intake` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.JournalAutomation.cs:19` | +| `POST` | `/api/ledger/journal-automation/fee-accrual-intake` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.JournalAutomation.cs:61` | +| `POST` | `/api/ledger/journal-automation/period-close-intake` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.JournalAutomation.cs:103` | | `GET` | `/api/ledger/journal-entry-workbench` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1562` | | `POST` | `/api/ledger/journal-entry-workbench/drafts` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1883` | -| `POST` | `/api/ledger/journal-entry-workbench/evidence` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:2102` | -| `POST` | `/api/ledger/journal-entry-workbench/lifecycle-action` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:2145` | +| `POST` | `/api/ledger/journal-entry-workbench/evidence` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:2020` | +| `POST` | `/api/ledger/journal-entry-workbench/lifecycle-action` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:2063` | | `POST` | `/api/ledger/journal-entry-workbench/submit-approval` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1975` | | `POST` | `/api/ledger/journal-entry-workbench/validate` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1934` | | `GET` | `/api/ledger/periods` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:147` | diff --git a/docs/status/coverage-report.md b/docs/status/coverage-report.md index 9005b878d6..ba97db21bd 100644 --- a/docs/status/coverage-report.md +++ b/docs/status/coverage-report.md @@ -5,7 +5,7 @@ ## Overall Coverage -**2696 / 7335** items documented (**36.8%**) — Grade: **F** +**2698 / 7335** items documented (**36.8%**) — Grade: **F** ```text [=======-------------] 36.8% @@ -15,7 +15,7 @@ | Category | Documented | Total | Coverage | Grade | | ---------- | ----------- | ------- | ---------- | ------- | -| Public Classes / Interfaces | 2576 | 6913 | 37.3% | F | +| Public Classes / Interfaces | 2578 | 6913 | 37.3% | F | | API Endpoints | 106 | 275 | 38.5% | F | | Configuration Options | 3 | 136 | 2.2% | F | | Provider Implementations | 0 | 0 | 100.0% | A | @@ -23,7 +23,7 @@ ## Undocumented Items -### Public Classes / Interfaces (4337 undocumented) +### Public Classes / Interfaces (4335 undocumented) | Item | Location | | ------ | ---------- | @@ -33,7 +33,6 @@ | `DailyMarkToMarketRequest` | `src/Meridian.Application/Accounting/DailyMarkToMarketService.cs:39` | | `DailyMarkToMarketRun` | `src/Meridian.Application/Accounting/DailyMarkToMarketService.cs:53` | | `BackfillRemediationSlaTier` | `src/Meridian.Application/Backfill/AutoGapRemediationService.cs:46` | -| `BackfillRemediationSlaMetadata` | `src/Meridian.Application/Backfill/AutoGapRemediationService.cs:64` | | `BackfillRemediationSlaStatus` | `src/Meridian.Application/Backfill/AutoGapRemediationService.cs:72` | | `BackfillRemediationSlaStatusItem` | `src/Meridian.Application/Backfill/AutoGapRemediationService.cs:81` | | `BackfillRemediationSlaSnapshot` | `src/Meridian.Application/Backfill/AutoGapRemediationService.cs:98` | @@ -77,7 +76,8 @@ | `RestatementCandidateResult` | `src/Meridian.Application/SecurityMaster/IPeriodAwareRestatementResolver.cs:44` | | `SecurityMasterRevisionRecord` | `src/Meridian.Application/SecurityMaster/ISecurityMasterRevisionStore.cs:52` | | `NullSecurityMasterPricingService` | `src/Meridian.Application/SecurityMaster/NullSecurityMasterClearwaterServices.cs:10` | -| ... and 4287 more | | +| `NullSecurityMasterCashFlowService` | `src/Meridian.Application/SecurityMaster/NullSecurityMasterClearwaterServices.cs:37` | +| ... and 4285 more | | ### API Endpoints (169 undocumented) @@ -193,7 +193,7 @@ ## Recommendations -1. **Public Classes / Interfaces**: 4337 undocumented types. Consider generating API docs with DocFX (`docfx docfx.json`) to cover the long tail of public types automatically. +1. **Public Classes / Interfaces**: 4335 undocumented types. Consider generating API docs with DocFX (`docfx docfx.json`) to cover the long tail of public types automatically. 2. **API Endpoints**: 169 endpoint(s) missing from `docs/reference/api-reference.md`. Run the endpoint audit and update the API reference table. 3. **Configuration Options**: 133 config key(s) not found in `docs/generated/configuration-schema.md`. Re-run the configuration schema generator to synchronise. diff --git a/docs/status/doc-health-dashboard.json b/docs/status/doc-health-dashboard.json index 05a15ff042..1a19508bf3 100644 --- a/docs/status/doc-health-dashboard.json +++ b/docs/status/doc-health-dashboard.json @@ -1,11 +1,11 @@ { "total_files": 543, - "total_lines": 90772, - "orphaned_count": 208, + "total_lines": 90730, + "orphaned_count": 207, "no_heading_count": 38, "stale_count": 0, "todo_count": 204, - "average_lines": 167.2, + "average_lines": 167.1, "health_score": 83, "orphaned_files": [ ".agents/skills/meridian-archive-organizer/SKILL.md", @@ -172,7 +172,6 @@ "docs/plans/ufl-warrant-target-state-v2.md", "docs/plans/wave-implementation-checklists.md", "docs/plans/web-ui-development-pivot.md", - "docs/product/data-provider-accounting-brainstorm-2026-07.md", "docs/product/deferred-expansion-boundaries.md", "docs/reference/governance-report-packs.md", "docs/reference/research-briefing-workflow.md", @@ -2149,7 +2148,7 @@ }, { "path": "docs/generated/repository-structure.md", - "line_count": 7954, + "line_count": 7943, "has_heading": true, "todo_count": 9, "last_modified_utc": "1970-01-01T00:00:00+00:00", @@ -3069,7 +3068,7 @@ }, { "path": "docs/product/data-provider-accounting-brainstorm-2026-07.md", - "line_count": 450, + "line_count": 456, "has_heading": true, "todo_count": 0, "last_modified_utc": "1970-01-01T00:00:00+00:00", @@ -3117,7 +3116,7 @@ }, { "path": "docs/product/README.md", - "line_count": 214, + "line_count": 217, "has_heading": true, "todo_count": 2, "last_modified_utc": "1970-01-01T00:00:00+00:00", @@ -4365,7 +4364,7 @@ }, { "path": "src/Meridian.Storage/README.md", - "line_count": 337, + "line_count": 326, "has_heading": true, "todo_count": 1, "last_modified_utc": "1970-01-01T00:00:00+00:00", diff --git a/docs/status/doc-health-dashboard.md b/docs/status/doc-health-dashboard.md index d039c8f030..2a6f58ed43 100644 --- a/docs/status/doc-health-dashboard.md +++ b/docs/status/doc-health-dashboard.md @@ -20,9 +20,9 @@ Data sources: `repo markdown (*.md)`, `file modification metadata` | Metric | Value | | -------- | ------- | | Total documentation files | 543 | -| Total lines | 90,772 | -| Average file size (lines) | 167.2 | -| Orphaned files | 208 | +| Total lines | 90,730 | +| Average file size (lines) | 167.1 | +| Orphaned files | 207 | | Files without headings | 38 | | Stale files (>90 days) | 0 | | TODO/FIXME markers | 204 | @@ -93,7 +93,7 @@ These files are not linked from any other Markdown file in the repository: | Date | Score | Files | Orphans | Stale | | ------ | ------- | ------- | --------- | ------- | -| 1970-01-01 | 83 | 543 | 208 | 0 | +| 1970-01-01 | 83 | 543 | 207 | 0 | --- diff --git a/docs/status/governance-readiness-dashboard.json b/docs/status/governance-readiness-dashboard.json index d2b43937f4..4e1906875f 100644 --- a/docs/status/governance-readiness-dashboard.json +++ b/docs/status/governance-readiness-dashboard.json @@ -4,15 +4,15 @@ "description": "Tracks whether DK2 governance, reconciliation, and shared-contract controls have current status evidence, route support, and validation coverage.", "generated_at": "1970-01-01T00:00:00+00:00", "root": "Meridian-main", - "score_percent": 26.7, - "passed_weight": 4, + "score_percent": 46.7, + "passed_weight": 7, "total_weight": 15, "summary": { "check_count": 6, - "passed_checks": 2, - "gap_checks": 4, + "passed_checks": 3, + "gap_checks": 3, "missing_source_count": 0, - "missing_term_count": 10 + "missing_term_count": 9 }, "checks": [ { @@ -109,16 +109,18 @@ "id": "governance-endpoints", "category": "Governance Operations", "label": "Workstation endpoints expose governance break queue and calibration routes", - "status": "gap", + "status": "pass", "weight": 3, - "score": 0, + "score": 3, "path_mode": "all", "term_mode": "all", "patterns": [ - "src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs" + "src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs", + "src/Meridian.Contracts/Api/UiApiRoutes.cs" ], "matched_paths": [ - "src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs" + "src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs", + "src/Meridian.Contracts/Api/UiApiRoutes.cs" ], "missing_patterns": [], "terms": [ @@ -128,11 +130,10 @@ ], "found_terms": [ "break-queue", + "calibration-summary", "sign-off" ], - "missing_terms": [ - "calibration-summary" - ], + "missing_terms": [], "detail": "", "remediation": "Keep governance readiness tied to workstation endpoints and documented route evidence." }, diff --git a/docs/status/governance-readiness-dashboard.md b/docs/status/governance-readiness-dashboard.md index 3f8138cbd8..3525cb9c93 100644 --- a/docs/status/governance-readiness-dashboard.md +++ b/docs/status/governance-readiness-dashboard.md @@ -11,11 +11,11 @@ Tracks whether DK2 governance, reconciliation, and shared-contract controls have | Metric | Value | | --- | ---: | -| Score | 26.7% | -| Passed checks | 2 | -| Gap checks | 4 | +| Score | 46.7% | +| Passed checks | 3 | +| Gap checks | 3 | | Missing evidence sources | 0 | -| Missing expected terms | 10 | +| Missing expected terms | 9 | ## Evidence Checks @@ -24,7 +24,7 @@ Tracks whether DK2 governance, reconciliation, and shared-contract controls have | Readiness Board | Kernel dashboard tracks reconciliation and governance DK2 readiness | Gap | 0/3 | `docs/status/kernel-readiness-dashboard.md` | terms: `Reconciliation + governance`, `Governance/Fund Ops owner`, `Operator Sign-off` | | Shared Contracts | Contract compatibility matrix requires review packets and owner decisions | Gap | 0/3 | `docs/status/contract-compatibility-matrix.md` | terms: `Contract review packet`, `Owner decision`, `migration notes` | | Governance Operations | Feature inventory describes reconciliation calibration and sign-off posture | Gap | 0/2 | `docs/status/FEATURE_INVENTORY.md` | terms: `calibration-summary`, `tolerance-profile posture`, `required sign-off role` | -| Governance Operations | Workstation endpoints expose governance break queue and calibration routes | Gap | 0/3 | `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs` | terms: `calibration-summary` | +| Governance Operations | Workstation endpoints expose governance break queue and calibration routes | Pass | 3/3 | `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs`, `src/Meridian.Contracts/Api/UiApiRoutes.cs` | - | | Validation | Endpoint tests cover governance break queue and calibration readiness | Pass | 2/2 | `tests/Meridian.Tests/Ui/WorkstationEndpointsTests.cs` | - | | Status | Provider and contract status dashboards remain present for governance reviews | Pass | 2/2 | `docs/status/provider-validation-matrix.md`, `docs/status/contract-compatibility-matrix.md`, `docs/status/kernel-readiness-dashboard.md` | - | @@ -33,7 +33,6 @@ Tracks whether DK2 governance, reconciliation, and shared-contract controls have - **Kernel dashboard tracks reconciliation and governance DK2 readiness**: Refresh the kernel dashboard governance row before claiming DK2 readiness. - **Contract compatibility matrix requires review packets and owner decisions**: Record contract-review packet evidence and owner decisions in the matrix. - **Feature inventory describes reconciliation calibration and sign-off posture**: Update the feature inventory with the current reconciliation governance scope. -- **Workstation endpoints expose governance break queue and calibration routes**: Keep governance readiness tied to workstation endpoints and documented route evidence. --- diff --git a/src/Meridian.Contracts/Api/UiApiRoutes.cs b/src/Meridian.Contracts/Api/UiApiRoutes.cs index 56ade695e5..bc7919dee0 100644 --- a/src/Meridian.Contracts/Api/UiApiRoutes.cs +++ b/src/Meridian.Contracts/Api/UiApiRoutes.cs @@ -799,6 +799,7 @@ public static class UiApiRoutes public const string LedgerManualJournalEntryLifecycleAction = "/api/ledger/journal-entry-workbench/lifecycle-action"; public const string LedgerJournalAutomationDividendIntake = "/api/ledger/journal-automation/dividend-intake"; public const string LedgerJournalAutomationFeeAccrualIntake = "/api/ledger/journal-automation/fee-accrual-intake"; + public const string LedgerJournalAutomationPeriodCloseIntake = "/api/ledger/journal-automation/period-close-intake"; public const string LedgerReportsTrialBalance = "/api/ledger/reports/trial-balance"; public const string LedgerReportsPnlSummary = "/api/ledger/reports/pnl-summary"; public const string LedgerReportsAccountingPackage = "/api/ledger/reports/accounting-package"; diff --git a/src/Meridian.Contracts/Ledger/AccountingConfigurationDtos.cs b/src/Meridian.Contracts/Ledger/AccountingConfigurationDtos.cs index 4d5bbbf643..9c3c093c8a 100644 --- a/src/Meridian.Contracts/Ledger/AccountingConfigurationDtos.cs +++ b/src/Meridian.Contracts/Ledger/AccountingConfigurationDtos.cs @@ -141,7 +141,10 @@ public enum ManualJournalEntryTypeDto Subscription = 11, Redemption = 12, LpTransfer = 13, - ManagementFee = 14 + ManagementFee = 14, + + /// Period-close closing entries rolling temporary balances into retained earnings. + ClosingEntry = 15 } [JsonConverter(typeof(JsonStringEnumConverter))] diff --git a/src/Meridian.Contracts/Ledger/LedgerBookDtos.cs b/src/Meridian.Contracts/Ledger/LedgerBookDtos.cs index 12f8afa534..dadc01dd06 100644 --- a/src/Meridian.Contracts/Ledger/LedgerBookDtos.cs +++ b/src/Meridian.Contracts/Ledger/LedgerBookDtos.cs @@ -42,7 +42,15 @@ public enum LedgerPeriodSignoffStatusDto public enum LedgerPostingKindDto { Originating = 0, - Adjustment = 1 + Adjustment = 1, + + /// + /// Period-close closing entries. Produced only by the governed period-close workflow after + /// human approval, these are the sanctioned exception to the closed-period posting bar: they + /// finalize the period being closed by zeroing temporary accounts and rolling net income to + /// retained earnings, so the posting guard permits them into soft- and hard-closed periods. + /// + ClosingEntry = 2 } [JsonConverter(typeof(JsonStringEnumConverter))] diff --git a/src/Meridian.Ledger/AutomatedJournalApproval.cs b/src/Meridian.Ledger/AutomatedJournalApproval.cs index 57fb837c66..882ffd6987 100644 --- a/src/Meridian.Ledger/AutomatedJournalApproval.cs +++ b/src/Meridian.Ledger/AutomatedJournalApproval.cs @@ -103,7 +103,8 @@ public JournalEntry ToJournalEntry() line.account, line.debit, line.credit, - Draft.Description)) + Draft.Description, + line.dimensions)) .ToArray(); return new JournalEntry(JournalEntryId, Draft.Event.Timestamp, Draft.Description, entries, metadata); diff --git a/src/Meridian.Ledger/AutomatedJournalDraft.cs b/src/Meridian.Ledger/AutomatedJournalDraft.cs index 15a35e7338..816fb36852 100644 --- a/src/Meridian.Ledger/AutomatedJournalDraft.cs +++ b/src/Meridian.Ledger/AutomatedJournalDraft.cs @@ -6,7 +6,7 @@ namespace Meridian.Ledger; public sealed record AutomatedJournalDraft( AutomatedJournalEvent Event, string Description, - IReadOnlyList<(LedgerAccount account, decimal debit, decimal credit)> Lines, + IReadOnlyList<(LedgerAccount account, decimal debit, decimal credit, LedgerLineDimensionSet? dimensions)> Lines, JournalEntryMetadata Metadata) { /// Total debits in the projected journal draft. diff --git a/src/Meridian.Ledger/AutomatedJournalDraftProjector.cs b/src/Meridian.Ledger/AutomatedJournalDraftProjector.cs index 32522ba134..6989d504dd 100644 --- a/src/Meridian.Ledger/AutomatedJournalDraftProjector.cs +++ b/src/Meridian.Ledger/AutomatedJournalDraftProjector.cs @@ -18,7 +18,11 @@ public static AutomatedJournalDraft Project(AutomatedJournalEvent journalEvent) var description = string.IsNullOrWhiteSpace(journalEvent.Description) ? DefaultDescription(journalEvent.Kind, symbol) : journalEvent.Description.Trim(); - var lines = ProjectLines(journalEvent.Kind, symbol, journalEvent.Amount, financialAccountId); + // Automated economic events (dividends, fees, ...) are scoped by financial account on the + // ledger account itself, not by a dimensional line scope, so their draft lines carry none. + var lines = ProjectLines(journalEvent.Kind, symbol, journalEvent.Amount, financialAccountId) + .Select(static line => (line.account, line.debit, line.credit, (LedgerLineDimensionSet?)null)) + .ToArray(); var metadata = new JournalEntryMetadata( ActivityType: journalEvent.Kind.ToString(), Symbol: symbol, diff --git a/src/Meridian.Ledger/DailyPortfolioPricingDraftBuilder.cs b/src/Meridian.Ledger/DailyPortfolioPricingDraftBuilder.cs index 6844a2dfab..d1a538b4b0 100644 --- a/src/Meridian.Ledger/DailyPortfolioPricingDraftBuilder.cs +++ b/src/Meridian.Ledger/DailyPortfolioPricingDraftBuilder.cs @@ -69,6 +69,9 @@ public static class DailyPortfolioPricingDraftBuilder Tags: tags, EvidenceReferences: evidence); - return new AutomatedJournalDraft(journalEvent, description, projection.JournalLines, metadata); + var lines = projection.JournalLines + .Select(static line => (line.account, line.debit, line.credit, (LedgerLineDimensionSet?)null)) + .ToArray(); + return new AutomatedJournalDraft(journalEvent, description, lines, metadata); } } diff --git a/src/Meridian.Ledger/PeriodCloseInput.cs b/src/Meridian.Ledger/PeriodCloseInput.cs index 235ba9653e..f2c95e3ad6 100644 --- a/src/Meridian.Ledger/PeriodCloseInput.cs +++ b/src/Meridian.Ledger/PeriodCloseInput.cs @@ -1,5 +1,16 @@ namespace Meridian.Ledger; +/// +/// One trial-balance row feeding period-close projection: an account, its normal-balance +/// value, and the optional dimensional scope (entity, sleeve, ...) it was posted under. +/// Rows with the same account but different dimensions are closed independently so +/// entity/sleeve P&L and retained earnings stay dimension-scoped. +/// +public sealed record PeriodCloseAccountBalance( + LedgerAccount Account, + decimal Balance, + LedgerLineDimensionSet? Dimensions = null); + /// /// Input for projecting period-close closing entries from a point-in-time trial balance. /// @@ -8,7 +19,7 @@ public sealed record PeriodCloseInput public PeriodCloseInput( string periodId, DateTimeOffset closedAtUtc, - IReadOnlyDictionary trialBalance, + IReadOnlyList trialBalance, string closedBy) { ArgumentNullException.ThrowIfNull(trialBalance); @@ -23,15 +34,35 @@ public PeriodCloseInput( ClosedBy = closedBy.Trim(); } + /// + /// Convenience overload for a dimension-flat trial balance (for example the in-memory + /// ), + /// which carries no per-line dimensional scope. + /// + public PeriodCloseInput( + string periodId, + DateTimeOffset closedAtUtc, + IReadOnlyDictionary trialBalance, + string closedBy) + : this( + periodId, + closedAtUtc, + (trialBalance ?? throw new ArgumentNullException(nameof(trialBalance))) + .Select(static pair => new PeriodCloseAccountBalance(pair.Key, pair.Value)) + .ToArray(), + closedBy) + { + } + public string PeriodId { get; } public DateTimeOffset ClosedAtUtc { get; } /// - /// Point-in-time trial balance with normal-balance values (see - /// ). + /// Point-in-time trial balance with normal-balance values, one row per + /// account + dimensional scope. /// - public IReadOnlyDictionary TrialBalance { get; } + public IReadOnlyList TrialBalance { get; } public string ClosedBy { get; } } diff --git a/src/Meridian.Ledger/PeriodCloseLine.cs b/src/Meridian.Ledger/PeriodCloseLine.cs index 35bebd9254..9ef2461fbd 100644 --- a/src/Meridian.Ledger/PeriodCloseLine.cs +++ b/src/Meridian.Ledger/PeriodCloseLine.cs @@ -1,10 +1,12 @@ namespace Meridian.Ledger; /// -/// One temporary (revenue or expense) account being closed to retained earnings. +/// One temporary (revenue or expense) account being closed to retained earnings, +/// retaining the dimensional scope it was posted under. /// public sealed record PeriodCloseLine( LedgerAccount Account, decimal PeriodBalance, decimal ClosingDebit, - decimal ClosingCredit); + decimal ClosingCredit, + LedgerLineDimensionSet? Dimensions = null); diff --git a/src/Meridian.Ledger/PeriodCloseProjection.cs b/src/Meridian.Ledger/PeriodCloseProjection.cs index c9c16809a3..253dc2e1ad 100644 --- a/src/Meridian.Ledger/PeriodCloseProjection.cs +++ b/src/Meridian.Ledger/PeriodCloseProjection.cs @@ -7,7 +7,7 @@ namespace Meridian.Ledger; public sealed record PeriodCloseProjection( PeriodCloseInput Input, IReadOnlyList Lines, - IReadOnlyList<(LedgerAccount account, decimal debit, decimal credit)> JournalLines, + IReadOnlyList<(LedgerAccount account, decimal debit, decimal credit, LedgerLineDimensionSet? dimensions)> JournalLines, IReadOnlyDictionary NetIncomeByScope) { /// Scope key used for accounts without a financial-account identifier. diff --git a/src/Meridian.Ledger/PeriodCloseProjector.cs b/src/Meridian.Ledger/PeriodCloseProjector.cs index b0519b1b3a..5d3eae36ad 100644 --- a/src/Meridian.Ledger/PeriodCloseProjector.cs +++ b/src/Meridian.Ledger/PeriodCloseProjector.cs @@ -1,9 +1,13 @@ +using System.Globalization; +using System.Text; + namespace Meridian.Ledger; /// /// Projects period-close closing entries: zeroes every revenue and expense balance and -/// rolls the resulting net income into retained earnings, scoped per financial account. -/// A close without these entries is status-only; this projector makes the roll real. +/// rolls the resulting net income into retained earnings, scoped per financial account and +/// per dimensional accounting scope (entity, sleeve, ...). A close without these entries is +/// status-only; this projector makes the roll real without collapsing dimension-split balances. /// public static class PeriodCloseProjector { @@ -12,20 +16,28 @@ public static PeriodCloseProjection Project(PeriodCloseInput input) ArgumentNullException.ThrowIfNull(input); var lines = new List(); - var journalLines = new List<(LedgerAccount account, decimal debit, decimal credit)>(); + var journalLines = new List<(LedgerAccount account, decimal debit, decimal credit, LedgerLineDimensionSet? dimensions)>(); var netIncomeByScope = new Dictionary(StringComparer.OrdinalIgnoreCase); + // Retained-earnings roll is grouped by (financial-account scope + dimensional scope) so + // the same revenue/expense account across two entities closes to two dimension-scoped + // retained-earnings lines rather than one aggregate. + var rollByScope = new Dictionary(StringComparer.Ordinal); var temporaryAccounts = input.TrialBalance - .Where(static pair => - pair.Key.AccountType is LedgerAccountType.Revenue or LedgerAccountType.Expense && - pair.Value != 0m) - .OrderBy(static pair => pair.Key.AccountType) - .ThenBy(static pair => pair.Key.Name, StringComparer.OrdinalIgnoreCase) - .ThenBy(static pair => pair.Key.Symbol, StringComparer.OrdinalIgnoreCase) - .ThenBy(static pair => pair.Key.FinancialAccountId, StringComparer.OrdinalIgnoreCase); - - foreach (var (account, balance) in temporaryAccounts) + .Where(static row => + row.Account.AccountType is LedgerAccountType.Revenue or LedgerAccountType.Expense && + row.Balance != 0m) + .OrderBy(static row => row.Account.AccountType) + .ThenBy(static row => row.Account.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(static row => row.Account.Symbol, StringComparer.OrdinalIgnoreCase) + .ThenBy(static row => row.Account.FinancialAccountId, StringComparer.OrdinalIgnoreCase) + .ThenBy(static row => DimensionKey(row.Dimensions), StringComparer.Ordinal); + + foreach (var row in temporaryAccounts) { + var account = row.Account; + var balance = row.Balance; + // Normal-balance semantics: revenue balances are credits - debits, expense // balances are debits - credits. Closing flips each account to zero. var closesWithDebit = account.AccountType == LedgerAccountType.Revenue @@ -35,27 +47,38 @@ pair.Key.AccountType is LedgerAccountType.Revenue or LedgerAccountType.Expense & var debit = closesWithDebit ? amount : 0m; var credit = closesWithDebit ? 0m : amount; - lines.Add(new PeriodCloseLine(account, balance, debit, credit)); - journalLines.Add((account, debit, credit)); + lines.Add(new PeriodCloseLine(account, balance, debit, credit, row.Dimensions)); + journalLines.Add((account, debit, credit, row.Dimensions)); - var scope = account.FinancialAccountId ?? PeriodCloseProjection.DefaultScope; + var financialScope = account.FinancialAccountId ?? PeriodCloseProjection.DefaultScope; var signedContribution = account.AccountType == LedgerAccountType.Revenue ? balance : -balance; - netIncomeByScope[scope] = netIncomeByScope.GetValueOrDefault(scope) + signedContribution; + netIncomeByScope[financialScope] = netIncomeByScope.GetValueOrDefault(financialScope) + signedContribution; + + var rollKey = FormattableString.Invariant($"{financialScope}|{DimensionKey(row.Dimensions)}"); + if (!rollByScope.TryGetValue(rollKey, out var roll)) + { + roll = new PeriodCloseRollScope(financialScope, row.Dimensions, 0m); + rollByScope[rollKey] = roll; + } + + rollByScope[rollKey] = roll with { NetIncome = roll.NetIncome + signedContribution }; } - // Roll each scope's net income into retained earnings so the balancing side of the - // close lands on the same financial account as the temporary activity it closes. - foreach (var (scope, netIncome) in netIncomeByScope.OrderBy(static pair => pair.Key, StringComparer.OrdinalIgnoreCase)) + // Roll each (financial-account, dimensional) scope's net income into retained earnings so the + // balancing side of the close lands on the same financial account and dimensions as the + // temporary activity it closes. + foreach (var roll in rollByScope.Values.OrderBy(static scope => scope.FinancialScope, StringComparer.OrdinalIgnoreCase) + .ThenBy(static scope => DimensionKey(scope.Dimensions), StringComparer.Ordinal)) { - if (netIncome == 0m) + if (roll.NetIncome == 0m) continue; - var retainedEarnings = scope.Length == 0 + var retainedEarnings = roll.FinancialScope.Length == 0 ? LedgerAccounts.RetainedEarnings - : LedgerAccounts.RetainedEarningsFor(scope); - journalLines.Add(netIncome > 0m - ? (retainedEarnings, 0m, netIncome) - : (retainedEarnings, -netIncome, 0m)); + : LedgerAccounts.RetainedEarningsFor(roll.FinancialScope); + journalLines.Add(roll.NetIncome > 0m + ? (retainedEarnings, 0m, roll.NetIncome, roll.Dimensions) + : (retainedEarnings, -roll.NetIncome, 0m, roll.Dimensions)); } return new PeriodCloseProjection(input, lines, journalLines, netIncomeByScope); @@ -63,6 +86,8 @@ pair.Key.AccountType is LedgerAccountType.Revenue or LedgerAccountType.Expense & /// /// Convenience overload that snapshots the ledger's trial balance as of the close time. + /// The in-memory trial balance is dimension-flat, so the projected closing entries carry + /// no dimensional scope. /// public static PeriodCloseProjection ProjectFrom( Ledger ledger, @@ -77,4 +102,46 @@ public static PeriodCloseProjection ProjectFrom( ledger.TrialBalanceAsOf(closedAtUtc), closedBy)); } + + private sealed record PeriodCloseRollScope( + string FinancialScope, + LedgerLineDimensionSet? Dimensions, + decimal NetIncome); + + /// + /// Deterministic key for a dimensional scope, used for grouping the retained-earnings roll + /// and ordering journal lines. Record value equality is unreliable for the external-GL + /// dictionary, so this canonicalizes every field into a stable string. + /// + private static string DimensionKey(LedgerLineDimensionSet? dimensions) + { + if (dimensions is null) + return string.Empty; + + var builder = new StringBuilder(); + builder.Append(dimensions.FundId).Append('|') + .Append(dimensions.EntityId).Append('|') + .Append(dimensions.SleeveId).Append('|') + .Append(dimensions.StrategyId).Append('|') + .Append(dimensions.InvestorId).Append('|') + .Append(dimensions.CapitalAccountId).Append('|') + .Append(dimensions.InstrumentId?.ToString("D", CultureInfo.InvariantCulture)).Append('|') + .Append(dimensions.TaxLotId).Append('|') + .Append(dimensions.CostCenterId).Append('|') + .Append(dimensions.CounterpartyId).Append('|') + .Append(dimensions.OrganizationId).Append('|') + .Append(dimensions.PortfolioId).Append('|') + .Append(dimensions.BookId).Append('|') + .Append(dimensions.AccountId).Append('|') + .Append(dimensions.CustomerId).Append('|') + .Append(dimensions.VendorId).Append('|') + .Append(dimensions.ProjectId).Append('|'); + + foreach (var pair in dimensions.ExternalGlDimensions.OrderBy(static entry => entry.Key, StringComparer.Ordinal)) + { + builder.Append(pair.Key).Append('=').Append(pair.Value).Append(';'); + } + + return builder.ToString(); + } } diff --git a/src/Meridian.Reporting/NavAttributionService.cs b/src/Meridian.Reporting/NavAttributionService.cs index b698817abb..c4f3ac755f 100644 --- a/src/Meridian.Reporting/NavAttributionService.cs +++ b/src/Meridian.Reporting/NavAttributionService.cs @@ -111,10 +111,28 @@ private async Task ComputeNavAsync( Balance: balance)); } - var totalNav = components.Sum(c => c.Balance); - var byAssetClass = components - .GroupBy(c => c.AssetClass ?? "Unclassified", StringComparer.OrdinalIgnoreCase) - .ToDictionary(g => g.Key, g => g.Sum(c => c.Balance)); + // NAV is assets net of liabilities. Equity, revenue, and expense balances are the + // financing/earnings view of the same value (Assets − Liabilities = Equity + Net Income), + // so summing them alongside the assets would double-count the fund's value. + var totalNav = 0m; + var byAssetClass = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var component in components) + { + var signedContribution = component.AccountType switch + { + nameof(LedgerAccountType.Asset) => component.Balance, + nameof(LedgerAccountType.Liability) => -component.Balance, + _ => 0m + }; + if (signedContribution == 0m) + { + continue; + } + + totalNav += signedContribution; + var assetClass = component.AssetClass ?? "Unclassified"; + byAssetClass[assetClass] = byAssetClass.GetValueOrDefault(assetClass) + signedContribution; + } return new NavBreakdown(totalNav, components, byAssetClass); } diff --git a/src/Meridian.Storage/Ledger/LedgerPeriodPostingGuard.cs b/src/Meridian.Storage/Ledger/LedgerPeriodPostingGuard.cs index 035a381216..1c0eea84ec 100644 --- a/src/Meridian.Storage/Ledger/LedgerPeriodPostingGuard.cs +++ b/src/Meridian.Storage/Ledger/LedgerPeriodPostingGuard.cs @@ -27,6 +27,16 @@ 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)) { if (entry.PostingKind == LedgerPostingKindDto.Adjustment) diff --git a/src/Meridian.Storage/Ledger/Migrations/V_ledger_008__closing_entry_posting_kind.sql b/src/Meridian.Storage/Ledger/Migrations/V_ledger_008__closing_entry_posting_kind.sql new file mode 100644 index 0000000000..4060e1449f --- /dev/null +++ b/src/Meridian.Storage/Ledger/Migrations/V_ledger_008__closing_entry_posting_kind.sql @@ -0,0 +1,24 @@ +-- Allow the ClosingEntry posting kind alongside Originating and Adjustment. Period-close +-- closing entries are the governed, sanctioned exception to the closed-period posting bar. + +do $$ +begin + alter table __SCHEMA__.journal_entries + drop constraint if exists ck_journal_entries_posting_kind; + alter table __SCHEMA__.journal_entries + add constraint ck_journal_entries_posting_kind + check (posting_kind in ('Originating', 'Adjustment', 'ClosingEntry')); +exception + when duplicate_object then null; +end $$; + +do $$ +begin + alter table __SCHEMA__.journal_legs + drop constraint if exists ck_journal_legs_posting_kind; + alter table __SCHEMA__.journal_legs + add constraint ck_journal_legs_posting_kind + check (posting_kind in ('Originating', 'Adjustment', 'ClosingEntry')); +exception + when duplicate_object then null; +end $$; diff --git a/src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.JournalAutomation.cs b/src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.JournalAutomation.cs new file mode 100644 index 0000000000..ec37e16dda --- /dev/null +++ b/src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.JournalAutomation.cs @@ -0,0 +1,145 @@ +using System.Text.Json; +using Meridian.Contracts.Api; +using Meridian.Ui.Shared.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Meridian.Ui.Shared.Endpoints; + +public static partial class LedgerEndpoints +{ + /// + /// Maps the automated journal-intake routes (corporate-action dividends, fee-schedule + /// accruals, and period-close closing entries) that project economic events or closed-period + /// trial balances into governed manual journal workbench drafts. + /// + private static void MapJournalAutomationEndpoints(WebApplication app, JsonSerializerOptions jsonOptions) + { + app.MapPost(UiApiRoutes.LedgerJournalAutomationDividendIntake, async (RunDividendDraftIntakeRequest request, HttpContext context) => + { + if (!HasLedgerMutationPermission(context)) + { + return EndpointHelpers.Forbidden(); + } + + var runner = context.RequestServices.GetService(); + if (runner is null) + { + return ServiceUnavailable(); + } + + try + { + var tenantContext = HttpContextWorkstationTenantContextAccessor.Resolve(context); + var result = await runner.RunDividendIntakeAsync(request with + { + Actor = ResolveMutationActor(context, request.Actor), + TenantId = tenantContext.TenantId, + CompanyId = tenantContext.CompanyId + }, context.RequestAborted).ConfigureAwait(false); + return Results.Json(result, jsonOptions); + } + catch (ArgumentException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } + catch (InvalidOperationException ex) + { + return Results.Conflict(new { error = ex.Message }); + } + }) + .WithName("RunLedgerJournalAutomationDividendIntake") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status409Conflict) + .Produces(StatusCodes.Status501NotImplemented) + .RequireFundScopedWriteTenant() + .RequireRateLimiting(UiEndpoints.MutationRateLimitPolicy); + + app.MapPost(UiApiRoutes.LedgerJournalAutomationFeeAccrualIntake, async (RunFeeAccrualDraftIntakeRequest request, HttpContext context) => + { + if (!HasLedgerMutationPermission(context)) + { + return EndpointHelpers.Forbidden(); + } + + var runner = context.RequestServices.GetService(); + if (runner is null) + { + return ServiceUnavailable(); + } + + try + { + var tenantContext = HttpContextWorkstationTenantContextAccessor.Resolve(context); + var result = await runner.RunFeeAccrualIntakeAsync(request with + { + Actor = ResolveMutationActor(context, request.Actor), + TenantId = tenantContext.TenantId, + CompanyId = tenantContext.CompanyId + }, context.RequestAborted).ConfigureAwait(false); + return Results.Json(result, jsonOptions); + } + catch (ArgumentException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } + catch (InvalidOperationException ex) + { + return Results.Conflict(new { error = ex.Message }); + } + }) + .WithName("RunLedgerJournalAutomationFeeAccrualIntake") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status409Conflict) + .Produces(StatusCodes.Status501NotImplemented) + .RequireFundScopedWriteTenant() + .RequireRateLimiting(UiEndpoints.MutationRateLimitPolicy); + + app.MapPost(UiApiRoutes.LedgerJournalAutomationPeriodCloseIntake, async (RunPeriodCloseDraftIntakeRequest request, HttpContext context) => + { + if (!HasLedgerMutationPermission(context)) + { + return EndpointHelpers.Forbidden(); + } + + var runner = context.RequestServices.GetService(); + if (runner is null) + { + return ServiceUnavailable(); + } + + try + { + var tenantContext = HttpContextWorkstationTenantContextAccessor.Resolve(context); + var result = await runner.RunPeriodCloseIntakeAsync(request with + { + Actor = ResolveMutationActor(context, request.Actor), + TenantId = tenantContext.TenantId, + CompanyId = tenantContext.CompanyId + }, context.RequestAborted).ConfigureAwait(false); + return Results.Json(result, jsonOptions); + } + catch (ArgumentException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } + catch (InvalidOperationException ex) + { + return Results.Conflict(new { error = ex.Message }); + } + }) + .WithName("RunLedgerJournalAutomationPeriodCloseIntake") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status409Conflict) + .Produces(StatusCodes.Status501NotImplemented) + .RequireFundScopedWriteTenant() + .RequireRateLimiting(UiEndpoints.MutationRateLimitPolicy); + } +} diff --git a/src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs b/src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs index 556fbebc7d..c6030c348c 100644 --- a/src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs +++ b/src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs @@ -18,7 +18,7 @@ namespace Meridian.Ui.Shared.Endpoints; -public static class LedgerEndpoints +public static partial class LedgerEndpoints { public static void MapLedgerEndpoints(this WebApplication app, JsonSerializerOptions jsonOptions) { @@ -2015,89 +2015,7 @@ normalizedReportPackId is null && .RequireFundScopedWriteTenant() .RequireRateLimiting(UiEndpoints.MutationRateLimitPolicy); - app.MapPost(UiApiRoutes.LedgerJournalAutomationDividendIntake, async (RunDividendDraftIntakeRequest request, HttpContext context) => - { - if (!HasLedgerMutationPermission(context)) - { - return EndpointHelpers.Forbidden(); - } - - var runner = context.RequestServices.GetService(); - if (runner is null) - { - return ServiceUnavailable(); - } - - try - { - var tenantContext = HttpContextWorkstationTenantContextAccessor.Resolve(context); - var result = await runner.RunDividendIntakeAsync(request with - { - Actor = ResolveMutationActor(context, request.Actor), - TenantId = tenantContext.TenantId, - CompanyId = tenantContext.CompanyId - }, context.RequestAborted).ConfigureAwait(false); - return Results.Json(result, jsonOptions); - } - catch (ArgumentException ex) - { - return Results.BadRequest(new { error = ex.Message }); - } - catch (InvalidOperationException ex) - { - return Results.Conflict(new { error = ex.Message }); - } - }) - .WithName("RunLedgerJournalAutomationDividendIntake") - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status400BadRequest) - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status409Conflict) - .Produces(StatusCodes.Status501NotImplemented) - .RequireFundScopedWriteTenant() - .RequireRateLimiting(UiEndpoints.MutationRateLimitPolicy); - - app.MapPost(UiApiRoutes.LedgerJournalAutomationFeeAccrualIntake, async (RunFeeAccrualDraftIntakeRequest request, HttpContext context) => - { - if (!HasLedgerMutationPermission(context)) - { - return EndpointHelpers.Forbidden(); - } - - var runner = context.RequestServices.GetService(); - if (runner is null) - { - return ServiceUnavailable(); - } - - try - { - var tenantContext = HttpContextWorkstationTenantContextAccessor.Resolve(context); - var result = await runner.RunFeeAccrualIntakeAsync(request with - { - Actor = ResolveMutationActor(context, request.Actor), - TenantId = tenantContext.TenantId, - CompanyId = tenantContext.CompanyId - }, context.RequestAborted).ConfigureAwait(false); - return Results.Json(result, jsonOptions); - } - catch (ArgumentException ex) - { - return Results.BadRequest(new { error = ex.Message }); - } - catch (InvalidOperationException ex) - { - return Results.Conflict(new { error = ex.Message }); - } - }) - .WithName("RunLedgerJournalAutomationFeeAccrualIntake") - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status400BadRequest) - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status409Conflict) - .Produces(StatusCodes.Status501NotImplemented) - .RequireFundScopedWriteTenant() - .RequireRateLimiting(UiEndpoints.MutationRateLimitPolicy); + MapJournalAutomationEndpoints(app, jsonOptions); app.MapPost(UiApiRoutes.LedgerManualJournalEntryEvidence, async (AttachManualJournalEntryEvidenceRequest request, HttpContext context) => { diff --git a/src/Meridian.Ui.Shared/Services/AccountingConfigurationService.cs b/src/Meridian.Ui.Shared/Services/AccountingConfigurationService.cs index dadb1e3527..a8e9b67640 100644 --- a/src/Meridian.Ui.Shared/Services/AccountingConfigurationService.cs +++ b/src/Meridian.Ui.Shared/Services/AccountingConfigurationService.cs @@ -4143,9 +4143,11 @@ private static AccountingPostingIntentDto BuildManualPostingIntent(ManualJournal } private static LedgerPostingKindDto BuildManualPostingKind(ManualJournalEntryDraftDto draft) - => draft.ReversalOfJournalEntryId.HasValue || draft.RebookedFromJournalEntryId.HasValue - ? LedgerPostingKindDto.Adjustment - : LedgerPostingKindDto.Originating; + => draft.EntryType == ManualJournalEntryTypeDto.ClosingEntry + ? LedgerPostingKindDto.ClosingEntry + : draft.ReversalOfJournalEntryId.HasValue || draft.RebookedFromJournalEntryId.HasValue + ? LedgerPostingKindDto.Adjustment + : LedgerPostingKindDto.Originating; private static AccountingPostingEvidenceKindDto ClassifyManualPostingEvidence(string evidenceLink) { @@ -5024,7 +5026,11 @@ private async Task ValidateLedgerBookPeriodScopeAsync( "Select a period that belongs to the journal entry ledger book.")); } - if (!string.Equals(period.Status, "Open", StringComparison.OrdinalIgnoreCase)) + // 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)) { issues.Add(Issue( "manual-je.period-closed", diff --git a/src/Meridian.Ui.Shared/Services/AutomatedJournalDraftIntakeService.cs b/src/Meridian.Ui.Shared/Services/AutomatedJournalDraftIntakeService.cs index 0d7f474a35..5e5fae25bc 100644 --- a/src/Meridian.Ui.Shared/Services/AutomatedJournalDraftIntakeService.cs +++ b/src/Meridian.Ui.Shared/Services/AutomatedJournalDraftIntakeService.cs @@ -20,6 +20,22 @@ public sealed record AutomatedJournalDraftIntakeRequest( string? TenantId = null, string? CompanyId = null); +/// +/// Batch of prebuilt automated journal drafts (for example period-close closing entries, +/// whose lines come from a projection rather than a single economic event) to admit into +/// the manual journal workbench queue for the named fund profile. +/// +public sealed record AutomatedJournalPreparedDraftIntakeRequest( + string FundProfileId, + string Currency, + IReadOnlyList Drafts, + string Actor, + Guid? LedgerBookId = null, + string? PeriodId = null, + string? EntityId = null, + string? TenantId = null, + string? CompanyId = null); + /// /// One event the intake did not turn into a new draft, with the reason it was skipped. /// @@ -70,28 +86,17 @@ public async Task IntakeAsync( CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(request); - ArgumentException.ThrowIfNullOrWhiteSpace(request.FundProfileId); - ArgumentException.ThrowIfNullOrWhiteSpace(request.Currency); - ArgumentException.ThrowIfNullOrWhiteSpace(request.Actor); if (request.Events.Count == 0) throw new ArgumentException("At least one automated journal event is required.", nameof(request)); - var workspace = await _configurationService - .GetWorkspaceAsync(request.FundProfileId, request.LedgerBookId, ct, request.TenantId, request.CompanyId) - .ConfigureAwait(false); - var chartLookup = ChartAccountLookup.Build(workspace.ChartOfAccounts); - - var created = new List(); + var drafts = new List(request.Events.Count); var skipped = new List(); foreach (var journalEvent in request.Events) { - ct.ThrowIfCancellationRequested(); - - AutomatedJournalDraft draft; try { - draft = AutomatedJournalDraftProjector.Project(journalEvent); + drafts.Add(AutomatedJournalDraftProjector.Project(journalEvent)); } catch (Exception ex) when (ex is ArgumentException or ArgumentOutOfRangeException) { @@ -99,8 +104,59 @@ public async Task IntakeAsync( Guid.Empty, journalEvent.IdempotencyKey ?? BuildFallbackIdempotencyKey(journalEvent), $"Projection failed: {ex.Message}")); - continue; } + } + + return await IntakeCoreAsync( + new AutomatedJournalPreparedDraftIntakeRequest( + request.FundProfileId, + request.Currency, + drafts, + request.Actor, + request.LedgerBookId, + request.PeriodId, + request.EntityId, + request.TenantId, + request.CompanyId), + skipped, + ct).ConfigureAwait(false); + } + + /// + /// Admits prebuilt drafts (for example period-close closing entries) into the workbench + /// queue with the same idempotent dedup, chart mapping, and human approve lifecycle as + /// event-projected drafts. + /// + public Task IntakeDraftsAsync( + AutomatedJournalPreparedDraftIntakeRequest request, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + if (request.Drafts.Count == 0) + throw new ArgumentException("At least one automated journal draft is required.", nameof(request)); + + return IntakeCoreAsync(request, [], ct); + } + + private async Task IntakeCoreAsync( + AutomatedJournalPreparedDraftIntakeRequest request, + List skipped, + CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(request.FundProfileId); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Currency); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Actor); + + var workspace = await _configurationService + .GetWorkspaceAsync(request.FundProfileId, request.LedgerBookId, ct, request.TenantId, request.CompanyId) + .ConfigureAwait(false); + var chartLookup = ChartAccountLookup.Build(workspace.ChartOfAccounts); + + var created = new List(); + + foreach (var draft in request.Drafts) + { + ct.ThrowIfCancellationRequested(); var idempotencyKey = draft.Event.IdempotencyKey ?? BuildFallbackIdempotencyKey(draft.Event); var journalEntryId = BuildDeterministicJournalEntryId(request.FundProfileId, idempotencyKey); @@ -141,7 +197,7 @@ public async Task IntakeAsync( } private static ManualJournalEntryDraftDto BuildDraftDto( - AutomatedJournalDraftIntakeRequest request, + AutomatedJournalPreparedDraftIntakeRequest request, AutomatedJournalDraft draft, Guid journalEntryId, string idempotencyKey, @@ -165,7 +221,8 @@ private static ManualJournalEntryDraftDto BuildDraftDto( SecurityId: line.account.Symbol is not null ? draft.Event.SecurityId : null, SecurityDisplayName: line.account.Symbol, Description: line.account.ToString(), - EvidenceLink: firstEvidenceLink); + EvidenceLink: firstEvidenceLink, + Dimensions: LedgerDimensionMapper.ToDto(line.dimensions)); }) .ToArray(); @@ -203,6 +260,9 @@ AutomatedJournalEventKind.PerformanceFeeAccrued or AutomatedJournalEventKind.CommissionAccrued or AutomatedJournalEventKind.WithholdingTaxAccrued => ManualJournalEntryTypeDto.AccruedExpense, AutomatedJournalEventKind.CorporateActionExpense => ManualJournalEntryTypeDto.Expense, + // Closing entries carry a dedicated type so the workbench posts them as the sanctioned + // ClosingEntry kind into the (closed) period being finalized. + AutomatedJournalEventKind.PeriodCloseClosingEntries => ManualJournalEntryTypeDto.ClosingEntry, _ => ManualJournalEntryTypeDto.General }; diff --git a/src/Meridian.Ui.Shared/Services/AutomatedJournalEventProducers.cs b/src/Meridian.Ui.Shared/Services/AutomatedJournalEventProducers.cs index 7146ae8111..65094eb860 100644 --- a/src/Meridian.Ui.Shared/Services/AutomatedJournalEventProducers.cs +++ b/src/Meridian.Ui.Shared/Services/AutomatedJournalEventProducers.cs @@ -26,13 +26,15 @@ public sealed record DividendAccrualPosition( /// /// Request to produce dividend-declared events from Security Master corporate actions -/// whose ex-date falls inside the window. +/// whose ex-date falls inside the window. A positive +/// additionally accrues withholding tax against each declared dividend. /// public sealed record CorporateActionDividendRequest( IReadOnlyList Positions, DateOnly WindowStart, DateOnly WindowEnd, - DateTimeOffset AsOf); + DateTimeOffset AsOf, + decimal WithholdingTaxRate = 0m); /// /// Produces events from the @@ -58,6 +60,8 @@ public async Task ProduceAsync( throw new ArgumentException("At least one position 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) + throw new ArgumentOutOfRangeException(nameof(request), "Withholding tax rate must be at least 0 and below 1."); var events = new List(); var skipped = new List(); @@ -117,6 +121,19 @@ action.DividendPerShare is > 0m && continue; } + var evidenceReferences = new[] + { + new JournalEvidenceReference( + EvidenceId: FormattableString.Invariant($"corp-act:{dividend.CorpActId:N}"), + Uri: FormattableString.Invariant( + $"/api/workstation/security-master/securities/{securityId.Value:D}/corporate-actions/{dividend.CorpActId:D}"), + Kind: "corporate-action", + SourceSystem: "security-master", + RetainedAtUtc: request.AsOf, + RetainedBy: "automated-journal", + SubjectId: symbol) + }; + events.Add(new AutomatedJournalEvent( AutomatedJournalEventKind.DividendDeclared, symbol, @@ -130,18 +147,36 @@ action.DividendPerShare is > 0m && EffectiveDate: dividend.ExDate, IdempotencyKey: FormattableString.Invariant( $"corp-act-dividend|{dividend.CorpActId:N}|{position.FinancialAccountId ?? "-"}"), - EvidenceReferences: - [ - new JournalEvidenceReference( - EvidenceId: FormattableString.Invariant($"corp-act:{dividend.CorpActId:N}"), - Uri: FormattableString.Invariant( - $"/api/workstation/security-master/securities/{securityId.Value:D}/corporate-actions/{dividend.CorpActId:D}"), - Kind: "corporate-action", - SourceSystem: "security-master", - RetainedAtUtc: request.AsOf, - RetainedBy: "automated-journal", - SubjectId: symbol) - ])); + EvidenceReferences: evidenceReferences)); + + if (request.WithholdingTaxRate <= 0m) + continue; + + var withholding = decimal.Round( + amount * request.WithholdingTaxRate, 2, MidpointRounding.AwayFromZero); + if (withholding <= 0m) + { + skipped.Add(new AutomatedJournalEventProductionSkip( + symbol, + FormattableString.Invariant( + $"Withholding on corporate action {dividend.CorpActId:N} rounds to a non-positive amount."))); + continue; + } + + events.Add(new AutomatedJournalEvent( + AutomatedJournalEventKind.WithholdingTaxAccrued, + symbol, + withholding, + new DateTimeOffset(dividend.ExDate.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero), + FinancialAccountId: position.FinancialAccountId, + Description: FormattableString.Invariant( + $"Withholding tax accrued for {symbol}: {request.WithholdingTaxRate:P2} of dividend {amount}{currencySuffix} (ex {dividend.ExDate:yyyy-MM-dd})"), + SecurityId: securityId, + SourceEventId: dividend.CorpActId.ToString("N"), + EffectiveDate: dividend.ExDate, + IdempotencyKey: FormattableString.Invariant( + $"corp-act-dividend-wht|{dividend.CorpActId:N}|{position.FinancialAccountId ?? "-"}"), + EvidenceReferences: evidenceReferences)); } } catch (OperationCanceledException) diff --git a/src/Meridian.Ui.Shared/Services/AutomatedJournalIntakeRunner.cs b/src/Meridian.Ui.Shared/Services/AutomatedJournalIntakeRunner.cs index 08f3e5c3c0..0d4afb832f 100644 --- a/src/Meridian.Ui.Shared/Services/AutomatedJournalIntakeRunner.cs +++ b/src/Meridian.Ui.Shared/Services/AutomatedJournalIntakeRunner.cs @@ -1,3 +1,6 @@ +using Meridian.Contracts.Ledger; +using Meridian.Ledger; + namespace Meridian.Ui.Shared.Services; /// @@ -15,7 +18,8 @@ public sealed record RunDividendDraftIntakeRequest( string? PeriodId = null, string? EntityId = null, string? TenantId = null, - string? CompanyId = null); + string? CompanyId = null, + decimal WithholdingTaxRate = 0m); /// /// Request to accrue period fees from fund fee terms and land the drafts in the manual @@ -36,6 +40,20 @@ public sealed record RunFeeAccrualDraftIntakeRequest( string? TenantId = null, string? CompanyId = null); +/// +/// Request to project period-close closing entries from a closed ledger period's trial +/// balance and land the governed draft in the manual journal workbench queue. +/// +public sealed record RunPeriodCloseDraftIntakeRequest( + string FundProfileId, + string Currency, + string Actor, + Guid PeriodId, + Guid? LedgerBookId = null, + string? EntityId = null, + string? TenantId = null, + string? CompanyId = null); + /// /// Outcome of one automated intake run: producer-side skips plus the intake result /// (created drafts and intake-side skips). Empty productions return an empty intake @@ -58,15 +76,18 @@ public sealed class AutomatedJournalIntakeRunner private readonly AutomatedJournalDraftIntakeService _intake; private readonly FeeScheduleAccrualEventProducer _feeProducer; private readonly CorporateActionDividendEventProducer? _dividendProducer; + private readonly ILedgerBookService? _ledgerBookService; public AutomatedJournalIntakeRunner( AutomatedJournalDraftIntakeService intake, FeeScheduleAccrualEventProducer feeProducer, - CorporateActionDividendEventProducer? dividendProducer = null) + CorporateActionDividendEventProducer? dividendProducer = null, + ILedgerBookService? ledgerBookService = null) { _intake = intake ?? throw new ArgumentNullException(nameof(intake)); _feeProducer = feeProducer ?? throw new ArgumentNullException(nameof(feeProducer)); _dividendProducer = dividendProducer; + _ledgerBookService = ledgerBookService; } public async Task RunDividendIntakeAsync( @@ -85,7 +106,8 @@ public async Task RunDividendIntakeAsync( request.Positions, request.WindowStart, request.WindowEnd, - DateTimeOffset.UtcNow), + DateTimeOffset.UtcNow, + request.WithholdingTaxRate), ct).ConfigureAwait(false); var intake = production.Events.Count == 0 @@ -106,6 +128,131 @@ public async Task RunDividendIntakeAsync( return new AutomatedJournalIntakeRunResult(production.Skipped, intake); } + /// + /// 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. + /// + public async Task RunPeriodCloseIntakeAsync( + RunPeriodCloseDraftIntakeRequest request, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + if (_ledgerBookService is null) + { + throw new InvalidOperationException( + "Period-close intake requires the ledger book service, which is not configured."); + } + + var summary = await _ledgerBookService.GetPeriodSummaryAsync(request.PeriodId, ct).ConfigureAwait(false) + ?? throw new InvalidOperationException( + $"Ledger period '{request.PeriodId}' was not found or is still open; close the period before running closing entries."); + + // The closed period's ledger book is authoritative. The manual journal workbench filters + // drafts by ledger book, so binding the draft to a null or mismatched request book id would + // orphan the closing entries where the book's own close/reporting workflow cannot see them. + if (request.LedgerBookId is { } requestedBook && requestedBook != summary.LedgerBookId) + { + throw new InvalidOperationException( + $"Ledger period '{request.PeriodId}' belongs to book '{summary.LedgerBookId}', not the requested book '{requestedBook}'."); + } + + // Date closing entries to the period's end date, not the close or run time. Soft close does + // not persist a close timestamp (the summary reports the current time), so the run-independent + // period end date is the stable accounting date for both soft- and hard-closed periods. + var period = (await _ledgerBookService + .ListPeriodsAsync(new LedgerPeriodQuery(LedgerBookId: summary.LedgerBookId), ct) + .ConfigureAwait(false)) + .FirstOrDefault(p => p.PeriodId == request.PeriodId) + ?? throw new InvalidOperationException( + $"Ledger period '{request.PeriodId}' was not found in book '{summary.LedgerBookId}'."); + var closingDate = new DateTimeOffset(period.EndDate.ToDateTime(TimeOnly.MaxValue), TimeSpan.Zero); + + var trialBalance = BuildTrialBalance(summary.TrialBalance); + + var projection = PeriodCloseProjector.Project(new PeriodCloseInput( + request.PeriodId.ToString("D"), + closingDate, + trialBalance, + request.Actor)); + + var draft = PeriodCloseDraftBuilder.BuildDraft(projection); + var intake = draft is null + ? EmptyIntake + : await _intake.IntakeDraftsAsync( + new AutomatedJournalPreparedDraftIntakeRequest( + request.FundProfileId, + request.Currency, + [draft], + request.Actor, + summary.LedgerBookId, + request.PeriodId.ToString("D"), + request.EntityId, + request.TenantId, + request.CompanyId), + ct).ConfigureAwait(false); + + return new AutomatedJournalIntakeRunResult([], intake); + } + + private static IReadOnlyList BuildTrialBalance( + IReadOnlyList lines) + { + // Preserve the dimensional scope of each trial-balance row: rows sharing an account but + // split across entities/sleeves must stay separate so the close zeroes each dimension's + // balance and rolls its retained earnings independently, rather than posting one aggregate. + var balances = new Dictionary(StringComparer.Ordinal); + foreach (var line in lines) + { + if (!Enum.TryParse(line.AccountType, ignoreCase: true, out var accountType)) + { + throw new InvalidOperationException( + $"Trial balance account '{line.AccountName}' has unrecognized account type '{line.AccountType}'; closing entries cannot be projected safely."); + } + + var account = new LedgerAccount(line.AccountName, accountType, line.Symbol, line.FinancialAccountId); + var dimensions = LedgerDimensionMapper.ToDomain(line.Dimensions); + var key = FormattableString.Invariant( + $"{account.Name}|{account.AccountType}|{account.Symbol}|{account.FinancialAccountId}|{DimensionKey(dimensions)}"); + + if (balances.TryGetValue(key, out var existing)) + { + balances[key] = existing with { Balance = existing.Balance + line.Balance }; + } + else + { + balances[key] = (account, dimensions, line.Balance); + } + } + + return balances.Values + .Select(static row => new PeriodCloseAccountBalance(row.Account, row.Balance, row.Dimensions)) + .ToArray(); + } + + private static string DimensionKey(LedgerLineDimensionSet? dimensions) + { + if (dimensions is null) + return string.Empty; + + var externalGl = string.Join( + ";", + dimensions.ExternalGlDimensions + .OrderBy(static pair => pair.Key, StringComparer.Ordinal) + .Select(static pair => FormattableString.Invariant($"{pair.Key}={pair.Value}"))); + + return string.Join( + "|", + dimensions.FundId, dimensions.EntityId, dimensions.SleeveId, dimensions.StrategyId, + dimensions.InvestorId, dimensions.CapitalAccountId, dimensions.InstrumentId?.ToString("D"), + dimensions.TaxLotId, dimensions.CostCenterId, dimensions.CounterpartyId, + dimensions.OrganizationId, dimensions.PortfolioId, dimensions.BookId, + dimensions.AccountId, dimensions.CustomerId, dimensions.VendorId, dimensions.ProjectId, + externalGl); + } + public async Task RunFeeAccrualIntakeAsync( RunFeeAccrualDraftIntakeRequest request, CancellationToken ct = default) diff --git a/src/Meridian.Ui.Shared/Services/LedgerDimensionMapper.cs b/src/Meridian.Ui.Shared/Services/LedgerDimensionMapper.cs new file mode 100644 index 0000000000..8f411f25c4 --- /dev/null +++ b/src/Meridian.Ui.Shared/Services/LedgerDimensionMapper.cs @@ -0,0 +1,65 @@ +using Meridian.Contracts.Ledger; +using Meridian.Ledger; + +namespace Meridian.Ui.Shared.Services; + +/// +/// Field-for-field conversion between the core and the +/// contract . The two records are structurally identical; +/// this keeps the mapping in one place so automated journal drafts can carry per-line +/// dimensional scope across the core/contract boundary without drift. +/// +internal static class LedgerDimensionMapper +{ + public static LedgerDimensionSetDto? ToDto(LedgerLineDimensionSet? dimensions) + { + if (dimensions is null) + return null; + + return new LedgerDimensionSetDto( + FundId: dimensions.FundId, + EntityId: dimensions.EntityId, + SleeveId: dimensions.SleeveId, + StrategyId: dimensions.StrategyId, + InvestorId: dimensions.InvestorId, + CapitalAccountId: dimensions.CapitalAccountId, + InstrumentId: dimensions.InstrumentId, + TaxLotId: dimensions.TaxLotId, + CostCenterId: dimensions.CostCenterId, + CounterpartyId: dimensions.CounterpartyId, + ExternalGlDimensions: dimensions.ExternalGlDimensions.Count == 0 ? null : dimensions.ExternalGlDimensions, + OrganizationId: dimensions.OrganizationId, + PortfolioId: dimensions.PortfolioId, + BookId: dimensions.BookId, + AccountId: dimensions.AccountId, + CustomerId: dimensions.CustomerId, + VendorId: dimensions.VendorId, + ProjectId: dimensions.ProjectId); + } + + public static LedgerLineDimensionSet? ToDomain(LedgerDimensionSetDto? dimensions) + { + if (dimensions is null) + return null; + + return new LedgerLineDimensionSet( + FundId: dimensions.FundId, + EntityId: dimensions.EntityId, + SleeveId: dimensions.SleeveId, + StrategyId: dimensions.StrategyId, + InvestorId: dimensions.InvestorId, + CapitalAccountId: dimensions.CapitalAccountId, + InstrumentId: dimensions.InstrumentId, + TaxLotId: dimensions.TaxLotId, + CostCenterId: dimensions.CostCenterId, + CounterpartyId: dimensions.CounterpartyId, + ExternalGlDimensions: dimensions.ExternalGlDimensions.Count == 0 ? null : dimensions.ExternalGlDimensions, + OrganizationId: dimensions.OrganizationId, + PortfolioId: dimensions.PortfolioId, + BookId: dimensions.BookId, + AccountId: dimensions.AccountId, + CustomerId: dimensions.CustomerId, + VendorId: dimensions.VendorId, + ProjectId: dimensions.ProjectId); + } +} diff --git a/src/Meridian.Ui.Shared/Services/WorkstationServiceCollectionExtensions.cs b/src/Meridian.Ui.Shared/Services/WorkstationServiceCollectionExtensions.cs index 3ca7079b06..c5d2c6a370 100644 --- a/src/Meridian.Ui.Shared/Services/WorkstationServiceCollectionExtensions.cs +++ b/src/Meridian.Ui.Shared/Services/WorkstationServiceCollectionExtensions.cs @@ -467,7 +467,8 @@ public static IServiceCollection AddWorkstationSharedServices(this IServiceColle return new AutomatedJournalIntakeRunner( sp.GetRequiredService(), new FeeScheduleAccrualEventProducer(), - securityMaster is null ? null : new CorporateActionDividendEventProducer(securityMaster)); + securityMaster is null ? null : new CorporateActionDividendEventProducer(securityMaster), + sp.GetService()); }); services.TryAddSingleton(sp => new CapitalAccountWorkbenchService( 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 68f513274e..30c9dcde46 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 @@ -676,6 +676,7 @@ export const UI_API_ROUTES = { LedgerManualJournalEntryLifecycleAction: "/api/ledger/journal-entry-workbench/lifecycle-action", LedgerJournalAutomationDividendIntake: "/api/ledger/journal-automation/dividend-intake", LedgerJournalAutomationFeeAccrualIntake: "/api/ledger/journal-automation/fee-accrual-intake", + LedgerJournalAutomationPeriodCloseIntake: "/api/ledger/journal-automation/period-close-intake", LedgerReportsTrialBalance: "/api/ledger/reports/trial-balance", LedgerReportsPnlSummary: "/api/ledger/reports/pnl-summary", LedgerReportsAccountingPackage: "/api/ledger/reports/accounting-package", diff --git a/src/Meridian.Ui/dashboard/src/types.ts b/src/Meridian.Ui/dashboard/src/types.ts index 4201064989..22870490b9 100644 --- a/src/Meridian.Ui/dashboard/src/types.ts +++ b/src/Meridian.Ui/dashboard/src/types.ts @@ -6313,7 +6313,8 @@ export type ManualJournalEntryType = | "Subscription" | "Redemption" | "LpTransfer" - | "ManagementFee"; + | "ManagementFee" + | "ClosingEntry"; export interface LedgerBook { ledgerBookId: string; @@ -7169,7 +7170,7 @@ export interface RuleDryRunResult { validationIssues: AccountingConfigurationValidationIssue[]; } -export type LedgerPostingKind = "Originating" | "Adjustment"; +export type LedgerPostingKind = "Originating" | "Adjustment" | "ClosingEntry"; export type AccountingTreatmentKind = | "General" | "Accrual" diff --git a/tests/Meridian.Tests/Application/Services/FundOperationsWorkspaceReadServiceTests.cs b/tests/Meridian.Tests/Application/Services/FundOperationsWorkspaceReadServiceTests.cs index c160486256..fe43574f4f 100644 --- a/tests/Meridian.Tests/Application/Services/FundOperationsWorkspaceReadServiceTests.cs +++ b/tests/Meridian.Tests/Application/Services/FundOperationsWorkspaceReadServiceTests.cs @@ -452,7 +452,9 @@ static bool IsExpectedCrossFundDatasetRow(IReadOnlyDictionary ro portfolioExport.Rows.Should().Contain(row => row["cutId"] == "fund:consolidated" && row["totalPnl"] == "50" && - row["shadowNav"] == "2000"); + // Shadow NAV is the consolidated NAV (assets - liabilities) from the NAV + // attribution service, not the sum of every account's normal balance. + row["shadowNav"] == "1000"); portfolioExport.RowLineage.Should().NotBeNull(); portfolioExport.Export.RowLineageCount.Should().Be(portfolioExport.RowLineage!.Count); var warehouseExport = await service.GetStructuredReportingExportAsync(new StructuredReportingExportRequestDto( diff --git a/tests/Meridian.Tests/Ledger/PeriodCloseProjectorTests.cs b/tests/Meridian.Tests/Ledger/PeriodCloseProjectorTests.cs index 4e3e75238b..fa4d175e48 100644 --- a/tests/Meridian.Tests/Ledger/PeriodCloseProjectorTests.cs +++ b/tests/Meridian.Tests/Ledger/PeriodCloseProjectorTests.cs @@ -146,4 +146,44 @@ public void BuildDraft_ProducesGovernedBalancedDraft_ThatPostsThroughApproval() ledger.GetBalance(LedgerAccounts.RealizedGain).Should().Be(0m); ledger.GetBalance(LedgerAccounts.RetainedEarnings).Should().Be(1_000m); } + + [Fact] + public void Project_DimensionSplitBalances_CloseEachScopeIndependently() + { + var revenue = new LedgerAccount("Dividend Income", LedgerAccountType.Revenue); + var entityA = new LedgerLineDimensionSet(EntityId: "entity-a"); + var entityB = new LedgerLineDimensionSet(EntityId: "entity-b"); + + // The same revenue account carries balances under two entities. A dimension-flat close + // would post one aggregate roll; the close must instead zero and roll each entity's slice. + var input = new PeriodCloseInput( + "2026-Q2", + CloseAt, + new[] + { + new PeriodCloseAccountBalance(revenue, 300m, entityA), + new PeriodCloseAccountBalance(revenue, 120m, entityB), + }, + "controller"); + + var projection = PeriodCloseProjector.Project(input); + + projection.IsBalanced.Should().BeTrue(); + projection.Lines.Should().HaveCount(2, "each dimensional slice of the account closes on its own"); + + // Two revenue-closing debits (one per entity) plus two dimension-scoped retained-earnings credits. + projection.JournalLines.Should().HaveCount(4); + projection.JournalLines.Should().ContainSingle(line => + line.account.Name == "Dividend Income" && line.debit == 300m && + line.dimensions!.EntityId == "entity-a"); + projection.JournalLines.Should().ContainSingle(line => + line.account.Name == "Dividend Income" && line.debit == 120m && + line.dimensions!.EntityId == "entity-b"); + projection.JournalLines.Should().ContainSingle(line => + line.account.Name == "Retained Earnings" && line.credit == 300m && + line.dimensions!.EntityId == "entity-a"); + projection.JournalLines.Should().ContainSingle(line => + line.account.Name == "Retained Earnings" && line.credit == 120m && + line.dimensions!.EntityId == "entity-b"); + } } diff --git a/tests/Meridian.Tests/Reporting/NavAttributionServiceTests.cs b/tests/Meridian.Tests/Reporting/NavAttributionServiceTests.cs new file mode 100644 index 0000000000..b9c06b8aa2 --- /dev/null +++ b/tests/Meridian.Tests/Reporting/NavAttributionServiceTests.cs @@ -0,0 +1,112 @@ +using FluentAssertions; +using Meridian.Ledger; +using Meridian.Reporting; +using NSubstitute; +using Xunit; +using SecurityMasterQueryService = Meridian.Contracts.SecurityMaster.ISecurityMasterQueryService; + +namespace Meridian.Tests.Reporting; + +public sealed class NavAttributionServiceTests +{ + private static readonly DateTimeOffset PostedAt = new(2026, 6, 30, 16, 0, 0, TimeSpan.Zero); + private static readonly DateTimeOffset AsOf = new(2026, 6, 30, 23, 59, 0, TimeSpan.Zero); + + private static NavAttributionService CreateService() + => new(Substitute.For()); + + private static FundLedgerBook BuildFundWithMixedAccountTypes() + { + var fund = new FundLedgerBook("fund-nav"); + var cash = new LedgerAccount("Cash", LedgerAccountType.Asset); + var securities = new LedgerAccount("Securities", LedgerAccountType.Asset, Symbol: "AAPL"); + var feePayable = new LedgerAccount("Management Fee Payable", LedgerAccountType.Liability); + var feeExpense = new LedgerAccount("Management Fee Expense", LedgerAccountType.Expense); + var investorCapital = new LedgerAccount("Investor Capital", LedgerAccountType.Equity); + var dividendIncome = new LedgerAccount("Dividend Income", LedgerAccountType.Revenue); + + fund.FundLedger.PostLines(PostedAt, "capital-subscription", new[] + { + (cash, 1000m, 0m), + (investorCapital, 0m, 1000m), + }); + fund.FundLedger.PostLines(PostedAt, "security-purchase", new[] + { + (securities, 500m, 0m), + (cash, 0m, 500m), + }); + fund.FundLedger.PostLines(PostedAt, "dividend-received", new[] + { + (cash, 300m, 0m), + (dividendIncome, 0m, 300m), + }); + fund.FundLedger.PostLines(PostedAt, "fee-accrual", new[] + { + (feeExpense, 200m, 0m), + (feePayable, 0m, 200m), + }); + + // Assets: cash 800 + securities 500 = 1300; liabilities: fee payable 200. + return fund; + } + + [Fact] + public async Task AttributeAsync_TotalNav_IsAssetsMinusLiabilities() + { + var service = CreateService(); + var fund = BuildFundWithMixedAccountTypes(); + + var result = await service.AttributeAsync( + new NavAttributionRequest("fund-nav", AsOf, fund)); + + // NAV must exclude equity, revenue, and expense balances: summing every account's + // normal balance (1300 + 200 + 1000 + 300 + 200 = 3000) double-counts the fund. + result.Consolidated.TotalNav.Should().Be(1100m); + } + + [Fact] + public async Task AttributeAsync_ByAssetClass_DecomposesTotalNav() + { + var service = CreateService(); + var fund = BuildFundWithMixedAccountTypes(); + + var result = await service.AttributeAsync( + new NavAttributionRequest("fund-nav", AsOf, fund)); + + result.Consolidated.ByAssetClass.Values.Sum().Should().Be(result.Consolidated.TotalNav); + } + + [Fact] + public async Task AttributeAsync_Components_StillReportEveryAccount() + { + var service = CreateService(); + var fund = BuildFundWithMixedAccountTypes(); + + var result = await service.AttributeAsync( + new NavAttributionRequest("fund-nav", AsOf, fund)); + + result.Consolidated.Components.Should().HaveCount(6); + result.Consolidated.Components + .Single(component => component.AccountType == nameof(LedgerAccountType.Equity)) + .Balance.Should().Be(1000m); + } + + [Fact] + public async Task AttributeAsync_LiabilityOnlyBook_ReportsNegativeNav() + { + var service = CreateService(); + var fund = new FundLedgerBook("fund-underwater"); + var expense = new LedgerAccount("Accrued Expense", LedgerAccountType.Expense); + var payable = new LedgerAccount("Accrued Payable", LedgerAccountType.Liability); + fund.FundLedger.PostLines(PostedAt, "accrual", new[] + { + (expense, 250m, 0m), + (payable, 0m, 250m), + }); + + var result = await service.AttributeAsync( + new NavAttributionRequest("fund-underwater", AsOf, fund)); + + result.Consolidated.TotalNav.Should().Be(-250m); + } +} diff --git a/tests/Meridian.Tests/Storage/LedgerJournalStoreTests.cs b/tests/Meridian.Tests/Storage/LedgerJournalStoreTests.cs index ca1c963695..f0ce20b186 100644 --- a/tests/Meridian.Tests/Storage/LedgerJournalStoreTests.cs +++ b/tests/Meridian.Tests/Storage/LedgerJournalStoreTests.cs @@ -324,6 +324,35 @@ public void PostingGuard_HardClosedPeriod_RejectsAdjustmentEntry() .WithMessage("*hard-closed*no postings*"); } + [Fact] + public void PostingGuard_SoftClosedPeriod_AllowsClosingEntry() + { + var period = BuildAccountingPeriod("SoftClosed"); + var write = BuildBalancedJournalWrite(period.PeriodId) with + { + PostingKind = LedgerPostingKindDto.ClosingEntry + }; + + var act = () => LedgerPeriodPostingGuard.Validate(write, period); + + act.Should().NotThrow("closing entries finalize the period being closed"); + } + + [Fact] + public void PostingGuard_HardClosedPeriod_AllowsClosingEntry() + { + var period = BuildAccountingPeriod("HardClosed"); + var write = BuildBalancedJournalWrite(period.PeriodId) with + { + PostingKind = LedgerPostingKindDto.ClosingEntry + }; + + var act = () => LedgerPeriodPostingGuard.Validate(write, period); + + act.Should().NotThrow( + "closing entries are the sanctioned exception to the closed-period posting bar"); + } + [Fact] public void PostingGuard_UnknownStatus_RejectsEntry() { diff --git a/tests/Meridian.Tests/Ui/AutomatedJournalEventProducerTests.cs b/tests/Meridian.Tests/Ui/AutomatedJournalEventProducerTests.cs index 0086c7d8b9..687db0ef37 100644 --- a/tests/Meridian.Tests/Ui/AutomatedJournalEventProducerTests.cs +++ b/tests/Meridian.Tests/Ui/AutomatedJournalEventProducerTests.cs @@ -4,6 +4,7 @@ using Meridian.Contracts.SecurityMaster; using Meridian.Ledger; using Meridian.Ui.Shared.Services; +using NSubstitute; using Xunit; namespace Meridian.Tests.Ui; @@ -148,6 +149,74 @@ public async Task DividendProducer_UnresolvedTicker_SurfacesSkipAndContinues() production.Events.Should().ContainSingle("one unresolved ticker must not block the rest of the batch"); } + [Fact] + public async Task DividendProducer_WithholdingRate_AccruesPairedWithholdingTax() + { + var dividend = DividendAction(AaplSecurityId, new DateOnly(2026, 07, 02), 0.26m); + 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", Quantity: 400m)], + new DateOnly(2026, 07, 01), + new DateOnly(2026, 07, 31), + AsOf, + WithholdingTaxRate: 0.15m)); + + production.Skipped.Should().BeEmpty(); + production.Events.Should().HaveCount(2); + + var declared = production.Events.Single(e => e.Kind == AutomatedJournalEventKind.DividendDeclared); + declared.Amount.Should().Be(104.00m); + + var withholding = production.Events.Single(e => e.Kind == AutomatedJournalEventKind.WithholdingTaxAccrued); + withholding.Amount.Should().Be(15.60m, "withholding is the rate applied to the declared dividend amount"); + withholding.SourceEventId.Should().Be(dividend.CorpActId.ToString("N")); + withholding.EffectiveDate.Should().Be(declared.EffectiveDate); + withholding.IdempotencyKey.Should().Be( + FormattableString.Invariant($"corp-act-dividend-wht|{dividend.CorpActId:N}|-"), + "the withholding idempotency key must differ from the dividend key so both drafts intake"); + withholding.EvidenceReferences.Should().NotBeEmpty("withholding inherits the corporate-action evidence"); + } + + [Fact] + public async Task DividendProducer_ZeroWithholdingRate_ProducesNoWithholdingEvents() + { + var dividend = DividendAction(AaplSecurityId, new DateOnly(2026, 07, 02), 0.26m); + 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)], + new DateOnly(2026, 07, 01), + new DateOnly(2026, 07, 31), + AsOf)); + + production.Events.Should().OnlyContain(e => e.Kind == AutomatedJournalEventKind.DividendDeclared); + } + + [Fact] + public async Task DividendProducer_InvalidWithholdingRate_Throws() + { + var securityMaster = new FakeSecurityMasterQueryService( + tickerToSecurityId: new Dictionary(), + corporateActions: []); + var producer = new CorporateActionDividendEventProducer(securityMaster); + + var act = () => producer.ProduceAsync(new CorporateActionDividendRequest( + [new DividendAccrualPosition("AAPL", 400m)], + new DateOnly(2026, 07, 01), + new DateOnly(2026, 07, 31), + AsOf, + WithholdingTaxRate: 1m)); + + await act.Should().ThrowAsync(); + } + // ------------------------------------------------------------------------- // AutomatedJournalIntakeRunner — producers land drafts in the queue // ------------------------------------------------------------------------- @@ -252,7 +321,8 @@ private static IntakeFixture CreateIntakeFixture() Node("Expenses:Management Fee Expense", "Management Fee Expense", "Expense"), Node("Liabilities:Management Fee Payable", "Management Fee Payable", "Liability"), Node("Expenses:Performance Fee Expense", "Performance Fee Expense", "Expense"), - Node("Liabilities:Performance Fee Payable", "Performance Fee Payable", "Liability") + Node("Liabilities:Performance Fee Payable", "Performance Fee Payable", "Liability"), + Node("Equity:Retained Earnings", "Retained Earnings", "Equity") ], JournalTemplates: [], PostingRules: [], @@ -272,6 +342,232 @@ private static IntakeFixture CreateIntakeFixture() workbench); } + // ------------------------------------------------------------------------- + // AutomatedJournalIntakeRunner — period-close closing entries + // ------------------------------------------------------------------------- + + private static readonly Guid ClosedPeriodId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + private static readonly DateOnly PeriodEndDate = new(2026, 6, 30); + + private static ILedgerBookService LedgerBookServiceWithClosedPeriod( + params LedgerPeriodTrialBalanceLineDto[] trialBalance) + => LedgerBookServiceWithClosedPeriod(BookId, trialBalance); + + private static ILedgerBookService LedgerBookServiceWithClosedPeriod( + Guid ledgerBookId, + params LedgerPeriodTrialBalanceLineDto[] trialBalance) + { + var summary = new LedgerPeriodSummaryDto( + PeriodId: ClosedPeriodId, + LedgerBookId: ledgerBookId, + FiscalYear: 2026, + PeriodNo: 6, + Label: "2026-06", + TrialBalance: trialBalance, + TotalDebits: trialBalance.Sum(line => line.DebitTotal), + TotalCredits: trialBalance.Sum(line => line.CreditTotal), + NetIncome: 0m, + PeriodOnPeriodVariance: null, + OpenBreakCount: 0, + SignoffStatus: LedgerPeriodSignoffStatusDto.Pending, + // A soft close reports the current time, not a persisted close date; the runner must + // ignore this and date closing entries to the period end date instead. + CompletedAt: DateTimeOffset.UtcNow); + + var period = new LedgerPeriodDto( + PeriodId: ClosedPeriodId, + LedgerBookId: ledgerBookId, + FiscalYear: 2026, + PeriodNo: 6, + Label: "2026-06", + StartDate: new DateOnly(2026, 6, 1), + EndDate: PeriodEndDate, + Status: LedgerPeriodStatusDto.SoftClosed, + OpenedAt: new DateTimeOffset(2026, 6, 1, 0, 0, 0, TimeSpan.Zero), + ClosedAt: null, + Version: 1); + + var service = Substitute.For(); + service.GetPeriodSummaryAsync(ClosedPeriodId, Arg.Any()).Returns(summary); + service.ListPeriodsAsync(Arg.Any(), Arg.Any()) + .Returns(new[] { period }); + return service; + } + + private static LedgerPeriodTrialBalanceLineDto TrialBalanceLine( + string accountName, string accountType, decimal debits, decimal credits, decimal balance, + LedgerDimensionSetDto? dimensions = null) + => new(accountName, accountType, Symbol: null, FinancialAccountId: null, + DebitTotal: debits, CreditTotal: credits, Balance: balance, EntryCount: 1, + Dimensions: dimensions); + + [Fact] + public async Task Runner_PeriodCloseIntake_PreservesDimensionSplitClosingLines() + { + var fixture = CreateIntakeFixture(); + // The same revenue account under two entities must close to two dimension-scoped lines, + // not a single aggregate, so entity-level P&L and retained earnings stay correct. + var bookService = LedgerBookServiceWithClosedPeriod( + TrialBalanceLine("Dividend Income", "Revenue", 0m, 300m, 300m, + new LedgerDimensionSetDto(EntityId: "entity-a")), + TrialBalanceLine("Dividend Income", "Revenue", 0m, 120m, 120m, + new LedgerDimensionSetDto(EntityId: "entity-b"))); + var runner = new AutomatedJournalIntakeRunner( + fixture.Intake, new FeeScheduleAccrualEventProducer(), ledgerBookService: bookService); + + var result = await runner.RunPeriodCloseIntakeAsync(new RunPeriodCloseDraftIntakeRequest( + "fund-alpha", "USD", "fund-controller", ClosedPeriodId, BookId)); + + var draft = result.Intake.Created.Should().ContainSingle().Subject; + draft.Lines.Should().HaveCount(4, + "two dimension-scoped revenue closings plus two dimension-scoped retained-earnings rolls"); + draft.Lines.Should().Contain(line => + line.Dimensions != null && line.Dimensions.EntityId == "entity-a" && line.Amount == 300m); + draft.Lines.Should().Contain(line => + line.Dimensions != null && line.Dimensions.EntityId == "entity-b" && line.Amount == 120m); + draft.Lines.Where(line => line.Dimensions != null && line.Dimensions.EntityId == "entity-a") + .Should().HaveCount(2, "the entity-a revenue close and its retained-earnings roll both carry the entity scope"); + } + + [Fact] + public async Task Runner_PeriodCloseIntake_LandsClosingEntryDraftInWorkbenchQueue() + { + var fixture = CreateIntakeFixture(); + var bookService = LedgerBookServiceWithClosedPeriod( + TrialBalanceLine("Cash", "Asset", 500m, 0m, 500m), + TrialBalanceLine("Dividend Income", "Revenue", 0m, 300m, 300m), + TrialBalanceLine("Management Fee Expense", "Expense", 200m, 0m, 200m)); + var runner = new AutomatedJournalIntakeRunner( + fixture.Intake, new FeeScheduleAccrualEventProducer(), ledgerBookService: bookService); + + var result = await runner.RunPeriodCloseIntakeAsync(new RunPeriodCloseDraftIntakeRequest( + FundProfileId: "fund-alpha", + Currency: "USD", + Actor: "fund-controller", + PeriodId: ClosedPeriodId, + LedgerBookId: BookId)); + + result.ProducerSkips.Should().BeEmpty(); + var draft = result.Intake.Created.Should().ContainSingle().Subject; + draft.Status.Should().Be(ManualJournalEntryStatusDto.Draft, + "revenue, expense, and retained-earnings accounts must all map onto the chart"); + draft.Memo.Should().Contain("Period-close closing entries"); + draft.Lines.Should().HaveCount(3, + "closing zeroes the revenue and expense accounts and rolls net income to retained earnings"); + draft.Lines.Sum(line => line.Side == AccountingTemplateLineSideDto.Debit ? line.Amount : 0m) + .Should().Be(draft.Lines.Sum(line => line.Side == AccountingTemplateLineSideDto.Credit ? line.Amount : 0m)); + draft.AccountingDate.Should().Be(PeriodEndDate, + "closing entries are dated to the period end date, not the soft-close/run time"); + draft.EntryType.Should().Be(ManualJournalEntryTypeDto.ClosingEntry, + "the ClosingEntry type drives the ClosingEntry posting kind so the close can post into the closed period"); + + var workbench = await fixture.Workbench.GetWorkbenchAsync("fund-alpha", BookId); + workbench.Drafts.Should().ContainSingle( + "the closing-entry draft must be visible in the close cockpit's queue"); + } + + [Fact] + public async Task Runner_PeriodCloseIntake_WithoutRequestBookId_BindsDraftToPeriodBook() + { + var fixture = CreateIntakeFixture(); + var bookService = LedgerBookServiceWithClosedPeriod( + TrialBalanceLine("Dividend Income", "Revenue", 0m, 300m, 300m)); + var runner = new AutomatedJournalIntakeRunner( + fixture.Intake, new FeeScheduleAccrualEventProducer(), ledgerBookService: bookService); + + // No LedgerBookId supplied on the request; the draft must still bind to the period's book. + var result = await runner.RunPeriodCloseIntakeAsync(new RunPeriodCloseDraftIntakeRequest( + "fund-alpha", "USD", "fund-controller", ClosedPeriodId)); + + result.Intake.Created.Should().ContainSingle(); + var workbench = await fixture.Workbench.GetWorkbenchAsync("fund-alpha", BookId); + workbench.Drafts.Should().ContainSingle( + "the closing-entry draft must land under the period's ledger book, not an unscoped queue"); + } + + [Fact] + public async Task Runner_PeriodCloseIntake_MismatchedBookId_FailsLoudly() + { + var fixture = CreateIntakeFixture(); + var bookService = LedgerBookServiceWithClosedPeriod( + TrialBalanceLine("Dividend Income", "Revenue", 0m, 300m, 300m)); + var runner = new AutomatedJournalIntakeRunner( + fixture.Intake, new FeeScheduleAccrualEventProducer(), ledgerBookService: bookService); + + var act = () => runner.RunPeriodCloseIntakeAsync(new RunPeriodCloseDraftIntakeRequest( + "fund-alpha", "USD", "fund-controller", ClosedPeriodId, + LedgerBookId: Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"))); + + await act.Should().ThrowAsync() + .WithMessage("*belongs to book*"); + } + + [Fact] + public async Task Runner_PeriodCloseIntake_SecondRun_SkipsDuplicateInsteadOfDoublingTheClose() + { + var fixture = CreateIntakeFixture(); + var bookService = LedgerBookServiceWithClosedPeriod( + 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 first = await runner.RunPeriodCloseIntakeAsync(request); + var second = await runner.RunPeriodCloseIntakeAsync(request); + + first.Intake.Created.Should().ContainSingle(); + second.Intake.Created.Should().BeEmpty(); + second.Intake.Skipped.Should().ContainSingle() + .Which.Reason.Should().Contain("already exists"); + } + + [Fact] + public async Task Runner_PeriodCloseIntake_OpenOrMissingPeriod_FailsLoudly() + { + var fixture = CreateIntakeFixture(); + var bookService = Substitute.For(); + bookService.GetPeriodSummaryAsync(Arg.Any(), Arg.Any()) + .Returns((LedgerPeriodSummaryDto?)null); + var runner = new AutomatedJournalIntakeRunner( + fixture.Intake, new FeeScheduleAccrualEventProducer(), ledgerBookService: bookService); + + var act = () => runner.RunPeriodCloseIntakeAsync(new RunPeriodCloseDraftIntakeRequest( + "fund-alpha", "USD", "fund-controller", ClosedPeriodId)); + + await act.Should().ThrowAsync() + .WithMessage("*close the period before running closing entries*"); + } + + [Fact] + public async Task Runner_PeriodCloseIntake_WithoutLedgerBookService_FailsLoudly() + { + var fixture = CreateIntakeFixture(); + var runner = new AutomatedJournalIntakeRunner(fixture.Intake, new FeeScheduleAccrualEventProducer()); + + var act = () => runner.RunPeriodCloseIntakeAsync(new RunPeriodCloseDraftIntakeRequest( + "fund-alpha", "USD", "fund-controller", ClosedPeriodId)); + + await act.Should().ThrowAsync() + .WithMessage("*ledger book service*"); + } + + [Fact] + public async Task Runner_PeriodCloseIntake_NoTemporaryBalances_ReturnsEmptyIntake() + { + var fixture = CreateIntakeFixture(); + var bookService = LedgerBookServiceWithClosedPeriod( + TrialBalanceLine("Cash", "Asset", 500m, 0m, 500m)); + var runner = new AutomatedJournalIntakeRunner( + fixture.Intake, new FeeScheduleAccrualEventProducer(), ledgerBookService: bookService); + + var result = await runner.RunPeriodCloseIntakeAsync(new RunPeriodCloseDraftIntakeRequest( + "fund-alpha", "USD", "fund-controller", ClosedPeriodId, BookId)); + + result.Intake.Created.Should().BeEmpty("a period with no temporary-account balances has nothing to close"); + result.Intake.Skipped.Should().BeEmpty(); + } + private static ChartOfAccountsNodeDto Node(string path, string name, string type) => new(NodeId: path, Path: path, AccountName: name, AccountType: type);