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);