From 4f2861ef7ce62803aca717665495f74ca90418ec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 11:31:54 +0000 Subject: [PATCH 1/2] Finance year page: readable figure strips, Holded docs behind the actual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The category/group figures sat inside the accordion's

, so the display serif leaked into them and shrank them to ~13px. They now render in the body face at 0.95rem with tabular numerals and a quiet uppercase label; Holded's value moves off Bootstrap's text-info blue onto the palette's gold. Expanding a category now also lists the approved Holded purchase docs its actual sums (number linking to Holded, supplier, date, amount), so a figure that looks wrong can be traced to the document that caused it. GetActualsForYearAsync carries those docs on HoldedActualRow rather than gaining a second contract method — the service already had them in hand. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WSSBeC1Xg1X31oqgeWGdsw --- src/Humans.Web/wwwroot/css/site.css | 52 +++++++++++ .../Controllers/BudgetAdminController.cs | 4 +- src/Sections/Humans.Budget/Docs/Budget.md | 2 +- .../Humans.Budget/Models/BudgetViewModels.cs | 8 +- .../Views/BudgetAdmin/YearDetail.cshtml | 86 +++++++++++++++---- .../Humans.Budget/Views/_ViewImports.cshtml | 1 + .../Humans.Finance.Contracts/HoldedDtos.cs | 11 ++- src/Sections/Humans.Finance/Docs/Finance.md | 1 + .../Humans.Finance/Services/Service.cs | 14 ++- tests/Humans.Finance.Tests/ServiceTests.cs | 29 ++++++- 10 files changed, 183 insertions(+), 25 deletions(-) diff --git a/src/Humans.Web/wwwroot/css/site.css b/src/Humans.Web/wwwroot/css/site.css index 3d10077397..fb76aeed2d 100644 --- a/src/Humans.Web/wwwroot/css/site.css +++ b/src/Humans.Web/wwwroot/css/site.css @@ -2132,3 +2132,55 @@ body.city-map-fullscreen main { height: 220px; } } + +/* ── Budget/Finance figure strips (peterdrier/Humans, /Finance year page) ──── + The accordion header lives inside an

, so the display serif leaked into + the numbers and shrank them to ~13px — unreadable for a money + figure. These keep the figures in the body face at full size, with the + label as a quiet caption above-the-line rather than another sentence. */ +.budget-figures { + font-family: var(--h-font-body); + font-variant-numeric: tabular-nums; + font-size: 0.95rem; +} + +.budget-figure + .budget-figure { + margin-left: 0.9rem; + padding-left: 0.9rem; + border-left: 1px solid var(--h-border-light); +} + +.budget-figure-label { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--h-sepia); +} + +.budget-figure-value { + font-weight: 600; + color: var(--h-aged-ink); +} + +/* Holded actuals are a different kind of number from the budget's own — they + come from the books. Gold reads as "other source" on parchment; the old + Bootstrap text-info blue did not. */ +.budget-figure-holded { + color: var(--h-gold-dark); +} + +.budget-subhead { + font-family: var(--h-font-body); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--h-sepia); + background: var(--h-vellum); +} + +.budget-table { + font-family: var(--h-font-body); + font-variant-numeric: tabular-nums; +} diff --git a/src/Sections/Humans.Budget/Controllers/BudgetAdminController.cs b/src/Sections/Humans.Budget/Controllers/BudgetAdminController.cs index b8cd4fb07f..23b90e2efe 100644 --- a/src/Sections/Humans.Budget/Controllers/BudgetAdminController.cs +++ b/src/Sections/Humans.Budget/Controllers/BudgetAdminController.cs @@ -589,12 +589,12 @@ private async Task BuildFinanceOverviewAsync(BudgetYea } } - IReadOnlyDictionary holdedActuals = new Dictionary(); + IReadOnlyDictionary holdedActuals = new Dictionary(); if (int.TryParse(year.Year, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var calendarYear)) { var actuals = await holdedFinance.GetActualsForYearAsync(calendarYear); - holdedActuals = actuals.ToDictionary(r => r.BudgetCategoryId, r => r.Actual); + holdedActuals = actuals.ToDictionary(r => r.BudgetCategoryId); } return new FinanceOverviewViewModel diff --git a/src/Sections/Humans.Budget/Docs/Budget.md b/src/Sections/Humans.Budget/Docs/Budget.md index 106dc8d535..4105c27c2e 100644 --- a/src/Sections/Humans.Budget/Docs/Budget.md +++ b/src/Sections/Humans.Budget/Docs/Budget.md @@ -235,7 +235,7 @@ Stored as string via `HasConversion()`. - `BudgetService` lives in `Humans.Budget.Services` and depends only on Application-layer abstractions. `IBudgetService` (internal) is the full surface on top of the read interface; it stays an interface because the ticketing bridge's unit tests substitute it. - `BudgetRepository` (impl `src/Sections/Humans.Budget/Data/BudgetRepository.cs`, §15b Singleton + `IDbContextFactory`) is the only file that touches budget tables via `DbContext`. `IBudgetRepository` exposes atomic per-method operations — multi-entity mutations (e.g. creating a year with its default groups / categories / projection row, or syncing ticketing actuals + re-materializing projected line items) are single repository methods that do all their work inside one short-lived `DbContext`. - **Decorator decision — no caching decorator.** Budget is admin-only, low-traffic. Same rationale as Governance / User / Feedback. -- **Cross-section calls** route through `ITeamServiceRead.GetTeamsAsync` (team lookups; coordinator scope is computed in-section over the same read model, see Cross-Section Dependencies) and `IUserServiceRead.GetUserInfosAsync` for actor display names. The ticketing-actuals data flows *inbound* via `IBudgetService.SyncTicketingActualsAsync`, called by the Tickets-section `TicketingBudgetService` bridge. +- **Cross-section calls** route through `ITeamServiceRead.GetTeamsAsync` (team lookups; coordinator scope is computed in-section over the same read model, see Cross-Section Dependencies) and `IUserServiceRead.GetUserInfosAsync` for actor display names. The ticketing-actuals data flows *inbound* via `IBudgetService.SyncTicketingActualsAsync`, called by the Tickets-section `TicketingBudgetService` bridge. The year page also reads `IHoldedFinanceServiceRead.GetActualsForYearAsync` for the per-category Holded figure and the approved purchase docs behind it, which it lists under the category alongside the budget line items. - **Controller split under `/Finance`.** `BudgetAdminController` (`Humans.Budget.Controllers`) owns Budget's own admin surface — years, groups, categories, line items, ticketing projection, cash flow, audit log — at `[Route("Finance")]`. It shares that route prefix with `Humans.Finance.Controllers.FinanceController`, which owns only the Holded/creditor actions; the two controllers' action templates are disjoint. See [`src/Sections/Humans.Finance/Docs/Finance.md`](../../Humans.Finance/Docs/Finance.md) for the Finance side. - **Render test** — `tests/Humans.Integration.Tests/Controllers/BudgetPageRenderTests.cs`: every page renders with no raw `Budget_` key and no unbound `` tag, in English and Spanish. General architecture coverage (`HUM0009`, `HUM0034`) applies to Budget code paths. No dedicated `BudgetArchitectureTests.cs` file exists. - **Repository shape** — `budget_audit_logs` is append-only (§12); the CRUD mutation methods write their audit rows inside their own `SaveChanges` — the ticketing sync paths write one summary row per run that changed anything — and the repository's audit surface is read-only. diff --git a/src/Sections/Humans.Budget/Models/BudgetViewModels.cs b/src/Sections/Humans.Budget/Models/BudgetViewModels.cs index 2057fd2d87..41e31c5371 100644 --- a/src/Sections/Humans.Budget/Models/BudgetViewModels.cs +++ b/src/Sections/Humans.Budget/Models/BudgetViewModels.cs @@ -1,5 +1,6 @@ using Humans.Teams.Contracts; using Humans.Budget.Contracts; +using Humans.Finance.Contracts; using Humans.Budget.Services; using NodaTime; @@ -17,9 +18,10 @@ internal sealed class FinanceOverviewViewModel public required IReadOnlyList IncomeSlices { get; init; } public required IReadOnlyList ExpenseSlices { get; init; } - /// Holded actual spend per budget category (keyed by BudgetCategoryId). - public IReadOnlyDictionary HoldedActualsByCategory { get; init; } = - new Dictionary(); + /// Holded actual spend per budget category (keyed by BudgetCategoryId), each row + /// carrying the approved purchase docs it sums so the category panel can show them. + public IReadOnlyDictionary HoldedActualsByCategory { get; init; } = + new Dictionary(); } internal sealed class TicketingProjectionUpdateForm diff --git a/src/Sections/Humans.Budget/Views/BudgetAdmin/YearDetail.cshtml b/src/Sections/Humans.Budget/Views/BudgetAdmin/YearDetail.cshtml index 54a7c8770c..6eb45151d5 100644 --- a/src/Sections/Humans.Budget/Views/BudgetAdmin/YearDetail.cshtml +++ b/src/Sections/Humans.Budget/Views/BudgetAdmin/YearDetail.cshtml @@ -170,9 +170,15 @@ Ticketing } -
- Budget: @groupBudget.ToEuro() - Actual: @groupActual.ToEuro() +
+ + Budget + @groupBudget.ToEuro() + + + Actual + @groupActual.ToEuro() +
@@ -369,6 +375,8 @@ : catActual - catBudget; var remainingLabel = isExpenseBudget ? "Remaining" : (displayRemaining >= 0 ? "Over target" : "Under target"); var budgetLabel = isExpenseBudget ? "Planned spend" : "Income target"; + // Null when Holded booked nothing here: the row only exists for a non-zero actual. + var holded = Model.HoldedActualsByCategory.GetValueOrDefault(cat.Id);

@@ -378,19 +386,25 @@ @cat.Name @cat.ExpenditureType

-
- @budgetLabel: @displayBudget.ToEuro() - · - Actual: @displayActual.ToEuro() - · - - @remainingLabel: @Math.Abs(displayRemaining).ToEuro() +
+ + @budgetLabel + @displayBudget.ToEuro() - @{ var holdedActual = Model.HoldedActualsByCategory.GetValueOrDefault(cat.Id); } - @if (holdedActual != 0) + + Actual + @displayActual.ToEuro() + + + @remainingLabel + @Math.Abs(displayRemaining).ToEuro() + + @if (holded is not null) { - · - Holded: @Math.Abs(holdedActual).ToEuro() + + Holded + @Math.Abs(holded.Actual).ToEuro() + }
@@ -401,7 +415,7 @@ @if (cat.LineItems.Any()) {
- +
@@ -452,6 +466,48 @@ No line items yet. } + @if (holded is not null && holded.Docs.Count > 0) + { +
+
+ Holded documents (@holded.Docs.Count) + + + Total + @Math.Abs(holded.Actual).ToEuro() + + +
+
+
Description
+ + + + + + + + + + @foreach (var doc in holded.Docs) + { + + + + + + + } + +
DocumentSupplierDateAmount
+ + @(string.IsNullOrWhiteSpace(doc.DocNumber) ? doc.HoldedDocId : doc.DocNumber) + + + @doc.ContactName@doc.Date.ToDate()@doc.Total.ToEuro()
+
+ + }
Manage Line Items diff --git a/src/Sections/Humans.Budget/Views/_ViewImports.cshtml b/src/Sections/Humans.Budget/Views/_ViewImports.cshtml index 81baf20ebd..8e08dd36f4 100644 --- a/src/Sections/Humans.Budget/Views/_ViewImports.cshtml +++ b/src/Sections/Humans.Budget/Views/_ViewImports.cshtml @@ -7,6 +7,7 @@ @using Humans.Budget.Controllers @using Humans.Budget.Models @using Humans.Budget.Services +@using Humans.Finance.Contracts @using Humans.Base @using Humans.Base.Models @using Microsoft.Extensions.Localization diff --git a/src/Sections/Humans.Finance.Contracts/HoldedDtos.cs b/src/Sections/Humans.Finance.Contracts/HoldedDtos.cs index 586d9698e8..fda9a6b7e8 100644 --- a/src/Sections/Humans.Finance.Contracts/HoldedDtos.cs +++ b/src/Sections/Humans.Finance.Contracts/HoldedDtos.cs @@ -9,7 +9,16 @@ public sealed record HoldedProvisioningRow( public sealed record HoldedProvisioningPlan( IReadOnlyList Rows, int NextNumber); -public sealed record HoldedActualRow(Guid BudgetCategoryId, decimal Actual); +/// One budget category's Holded actual, plus the approved purchase docs it sums. +/// Docs is what the figure is made of — the year page renders it under the category so a +/// wrong total can be traced to the document that caused it. +public sealed record HoldedActualRow( + Guid BudgetCategoryId, decimal Actual, IReadOnlyList Docs); + +/// One approved Holded purchase doc behind a category's actual. +public sealed record HoldedActualDoc( + string HoldedDocId, string DocNumber, string ContactName, LocalDate Date, + decimal Total, string HoldedUrl); public sealed record HoldedUnmatchedRow( string HoldedDocId, string DocNumber, string ContactName, decimal Total, diff --git a/src/Sections/Humans.Finance/Docs/Finance.md b/src/Sections/Humans.Finance/Docs/Finance.md index f205886256..f6fb1312d5 100644 --- a/src/Sections/Humans.Finance/Docs/Finance.md +++ b/src/Sections/Humans.Finance/Docs/Finance.md @@ -207,6 +207,7 @@ Every `/Finance/*` route is gated on `PolicyNames.FinanceAdminOrAdmin`, declared - Attribution order: **Account** (booked line account id) → **Tag** (normalized, dash-free) → **Unmatched**. First match wins. - Tags are normalized: lowercase, all non-alphanumeric characters stripped (Holded strips separators like dashes from tag values). - Provisioning is additive only, and nothing retires a map entry today: `IsActive` is set `true` on insert and never flipped, so an orphaned row stays active. Holded accounts are never deleted. +- `GetActualsForYearAsync` returns the per-category total **and the approved docs it sums** (`HoldedActualRow.Docs`, newest first). Budget's year page renders them under the category so a wrong Holded figure can be traced to the document behind it; a draft excluded from the total is absent from the list too. - `HoldedExpenseDoc.Total` is included in category-level actuals only when `IsApproved = true` — set on sync as `doc.IsDraft == false` (`Service.MapDoc`). Actuals are doc-derived rather than ledger-derived because the budget pages are gross/IVA-inclusive while a 629 balance is net, and ledger lines exist for drafts Holded has not approved. - Holded API key read from env var `HOLDED_API_KEY_V2` only — never `appsettings.json`. - The member ↔ creditor-account link resolves through the Holded contact's `supplierRecord.num` field, never by name matching. It is attempted **exactly once**, best-effort, during outbox processing after the payable exists (`ExpenseReportService` → `IHoldedClient.GetContactAsync`); a failure or a null `num` is logged, the null link is stored, and the outbox event is still marked processed so a created doc is never stranded as permanently-failed. **There is no automatic retry** — `SyncCreditorLedgerAsync` imports daybook lines but never re-resolves the contact — so after an initial miss the member stays unlinked until someone runs `POST /Finance/Creditors/Bind`, or a later report from the same member resolves it and backfills the member-level binding (nobodies-collective/Humans#972). `ListCreditorAccountsAsync` returns exactly these unresolved bindings as the `Unresolved` half of its result — they have no account row to sit on, so the account list alone cannot show them — and they render in their own card on `/Finance/Creditors`, making the manual step discoverable rather than silent. diff --git a/src/Sections/Humans.Finance/Services/Service.cs b/src/Sections/Humans.Finance/Services/Service.cs index 2b1bebcd04..55267a5dac 100644 --- a/src/Sections/Humans.Finance/Services/Service.cs +++ b/src/Sections/Humans.Finance/Services/Service.cs @@ -290,7 +290,14 @@ public async Task> GetActualsForYearAsync( return docs .Where(d => d.IsApproved == true && d.BudgetCategoryId is not null) .GroupBy(d => d.BudgetCategoryId!.Value) - .Select(g => new HoldedActualRow(g.Key, g.Sum(d => d.Total))) + .Select(g => new HoldedActualRow( + g.Key, + g.Sum(d => d.Total), + g.OrderByDescending(d => d.Date) + .ThenBy(d => d.DocNumber, StringComparer.Ordinal) + .Select(d => new HoldedActualDoc( + d.HoldedDocId, d.DocNumber, d.ContactName, d.Date, d.Total, HoldedDocUrl(d.HoldedDocId))) + .ToList())) .Where(r => r.Actual != 0m) .ToList(); } @@ -307,10 +314,13 @@ public async Task> GetUnmatchedAsync(Cancellat d.ContactName, d.Total, ReasonFor(d), - $"https://app.holded.com/purchases/{d.HoldedDocId}")) + HoldedDocUrl(d.HoldedDocId))) .ToList(); } + private static string HoldedDocUrl(string holdedDocId) => + $"https://app.holded.com/purchases/{holdedDocId}"; + public async Task GetHoldedAccountIdForCategoryAsync( Guid budgetCategoryId, CancellationToken ct = default) { diff --git a/tests/Humans.Finance.Tests/ServiceTests.cs b/tests/Humans.Finance.Tests/ServiceTests.cs index 99b1c6023d..b93ad1521f 100644 --- a/tests/Humans.Finance.Tests/ServiceTests.cs +++ b/tests/Humans.Finance.Tests/ServiceTests.cs @@ -75,7 +75,34 @@ public async Task GetActualsForYear_SumsGrossDocTotals_ApprovedOnly() var rows = await MakeService().GetActualsForYearAsync(2026, Xunit.TestContext.Current.CancellationToken); - rows.Should().ContainSingle().Which.Should().Be(new HoldedActualRow(cat, 181.50m)); + var row = rows.Should().ContainSingle().Which; + row.BudgetCategoryId.Should().Be(cat); + row.Actual.Should().Be(181.50m); + } + + [HumansFact] + public async Task GetActualsForYear_CarriesTheDocsBehindTheTotal_NewestFirst() + { + var cat = Guid.NewGuid(); + _repo.GetMatchedForYearAsync(2026, Arg.Any()).Returns(new List + { + new() { HoldedDocId = "d1", DocNumber = "PUR-1", ContactName = "Acme", + Date = new LocalDate(2026, 3, 1), Total = 121m, IsApproved = true }, + new() { HoldedDocId = "d2", DocNumber = "PUR-2", ContactName = "Beta", + Date = new LocalDate(2026, 5, 1), Total = 60.50m, IsApproved = true }, + new() { HoldedDocId = "d3", DocNumber = "PUR-3", ContactName = "Draft Co", + Date = new LocalDate(2026, 6, 1), Total = 999m, IsApproved = false }, + }.Select(d => { d.BudgetCategoryId = cat; return d; }).ToList()); + + var row = (await MakeService().GetActualsForYearAsync(2026, Xunit.TestContext.Current.CancellationToken)) + .Should().ContainSingle().Which; + + // Only what the total is made of — the draft is excluded from both. + row.Docs.Select(d => d.DocNumber).Should().Equal("PUR-2", "PUR-1"); + row.Docs.Sum(d => d.Total).Should().Be(row.Actual); + row.Docs[0].HoldedUrl.Should().Be("https://app.holded.com/purchases/d2"); + row.Docs[0].ContactName.Should().Be("Beta"); + row.Docs[0].Date.Should().Be(new LocalDate(2026, 5, 1)); } // ─── GetProvisioningPlan ────────────────────────────────────────────────────── From 07ec58828dae3466702c28aa496bd77ca61c2d83 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 11:48:14 +0000 Subject: [PATCH 2/2] Drop the orphan Finance.Contracts import from Budget's _ViewImports No Budget view names a Humans.Finance.Contracts type: YearDetail reaches HoldedActualRow through `var holded = ...GetValueOrDefault(cat.Id)`, so the import only added an orphan using to every generated Budget view (code-review-rules.md:107). Codex's other two findings declined in-thread: the zero-net-actual case needs offsetting signed totals Finance never ingests, and the figure strip's text-nowrap predates this diff. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SR1xAMAXmCep675JvCSnoh Review-round: 1 --- src/Sections/Humans.Budget/Views/_ViewImports.cshtml | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Sections/Humans.Budget/Views/_ViewImports.cshtml b/src/Sections/Humans.Budget/Views/_ViewImports.cshtml index 8e08dd36f4..81baf20ebd 100644 --- a/src/Sections/Humans.Budget/Views/_ViewImports.cshtml +++ b/src/Sections/Humans.Budget/Views/_ViewImports.cshtml @@ -7,7 +7,6 @@ @using Humans.Budget.Controllers @using Humans.Budget.Models @using Humans.Budget.Services -@using Humans.Finance.Contracts @using Humans.Base @using Humans.Base.Models @using Microsoft.Extensions.Localization