Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/Humans.Web/wwwroot/css/site.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 <h2>, so the display serif leaked into
the numbers and <small> 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);
Comment thread
peterdrier marked this conversation as resolved.
}

.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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -589,12 +589,12 @@ private async Task<FinanceOverviewViewModel> BuildFinanceOverviewAsync(BudgetYea
}
}

IReadOnlyDictionary<Guid, decimal> holdedActuals = new Dictionary<Guid, decimal>();
IReadOnlyDictionary<Guid, HoldedActualRow> holdedActuals = new Dictionary<Guid, HoldedActualRow>();
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
Expand Down
2 changes: 1 addition & 1 deletion src/Sections/Humans.Budget/Docs/Budget.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ Stored as string via `HasConversion<string>()`.
- `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<BudgetDbContext>`) 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 `<vc:>` 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.
Expand Down
8 changes: 5 additions & 3 deletions src/Sections/Humans.Budget/Models/BudgetViewModels.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Humans.Teams.Contracts;
using Humans.Budget.Contracts;
using Humans.Finance.Contracts;
using Humans.Budget.Services;
using NodaTime;

Expand All @@ -17,9 +18,10 @@ internal sealed class FinanceOverviewViewModel
public required IReadOnlyList<BudgetSlice> IncomeSlices { get; init; }
public required IReadOnlyList<BudgetSlice> ExpenseSlices { get; init; }

/// <summary>Holded actual spend per budget category (keyed by BudgetCategoryId).</summary>
public IReadOnlyDictionary<Guid, decimal> HoldedActualsByCategory { get; init; } =
new Dictionary<Guid, decimal>();
/// <summary>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.</summary>
public IReadOnlyDictionary<Guid, HoldedActualRow> HoldedActualsByCategory { get; init; } =
new Dictionary<Guid, HoldedActualRow>();
}

internal sealed class TicketingProjectionUpdateForm
Expand Down
86 changes: 71 additions & 15 deletions src/Sections/Humans.Budget/Views/BudgetAdmin/YearDetail.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,15 @@
<span class="badge bg-warning text-dark ms-1">Ticketing</span>
}
</div>
<div class="text-end text-nowrap">
<span class="text-muted small">Budget:</span> <strong>@groupBudget.ToEuro()</strong>
<span class="text-muted small ms-2">Actual:</span> <strong class="@(groupActual >= 0 ? "text-success" : "text-danger")">@groupActual.ToEuro()</strong>
<div class="text-end text-nowrap budget-figures">
<span class="budget-figure">
<span class="budget-figure-label">Budget</span>
<span class="budget-figure-value">@groupBudget.ToEuro()</span>
</span>
<span class="budget-figure">
<span class="budget-figure-label">Actual</span>
<span class="budget-figure-value @(groupActual >= 0 ? "text-success" : "text-danger")">@groupActual.ToEuro()</span>
</span>
</div>
</div>
</button>
Expand Down Expand Up @@ -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);

<div class="accordion-item border-0 border-bottom">
<h2 class="accordion-header">
Expand All @@ -378,19 +386,25 @@
@cat.Name
<span class="badge bg-@(cat.ExpenditureType == ExpenditureType.CapEx ? "primary" : "secondary") ms-1">@cat.ExpenditureType</span>
</div>
<div class="text-end text-nowrap small">
<span class="text-muted">@budgetLabel:</span> @displayBudget.ToEuro()
<span class="mx-1">&middot;</span>
<span class="text-muted">Actual:</span> @displayActual.ToEuro()
<span class="mx-1">&middot;</span>
<span class="@(displayRemaining >= 0 ? "text-success" : "text-danger")">
@remainingLabel: @Math.Abs(displayRemaining).ToEuro()
<div class="text-end text-nowrap budget-figures">
<span class="budget-figure">
<span class="budget-figure-label">@budgetLabel</span>
<span class="budget-figure-value">@displayBudget.ToEuro()</span>
</span>
@{ var holdedActual = Model.HoldedActualsByCategory.GetValueOrDefault(cat.Id); }
@if (holdedActual != 0)
<span class="budget-figure">
<span class="budget-figure-label">Actual</span>
<span class="budget-figure-value">@displayActual.ToEuro()</span>
</span>
<span class="budget-figure">
<span class="budget-figure-label">@remainingLabel</span>
<span class="budget-figure-value @(displayRemaining >= 0 ? "text-success" : "text-danger")">@Math.Abs(displayRemaining).ToEuro()</span>
</span>
@if (holded is not null)
{
<span class="mx-1">&middot;</span>
<span class="text-muted">Holded:</span> <span class="text-info">@Math.Abs(holdedActual).ToEuro()</span>
<span class="budget-figure">
<span class="budget-figure-label">Holded</span>
<span class="budget-figure-value budget-figure-holded">@Math.Abs(holded.Actual).ToEuro()</span>
</span>
}
</div>
</div>
Expand All @@ -401,7 +415,7 @@
@if (cat.LineItems.Any())
{
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<table class="table table-sm table-hover mb-0 budget-table">
<thead class="table-light">
<tr>
<th class="ps-4">Description</th>
Expand Down Expand Up @@ -452,6 +466,48 @@
No line items yet.
</div>
}
@if (holded is not null && holded.Docs.Count > 0)
{
<div class="border-top">
<div class="px-4 py-2 budget-subhead d-flex justify-content-between align-items-center">
<span>Holded documents (@holded.Docs.Count)</span>
<span class="budget-figures">
<span class="budget-figure">
<span class="budget-figure-label">Total</span>
<span class="budget-figure-value budget-figure-holded">@Math.Abs(holded.Actual).ToEuro()</span>
</span>
</span>
</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0 budget-table">
<thead class="table-light">
<tr>
<th class="ps-4">Document</th>
<th>Supplier</th>
<th>Date</th>
<th class="text-end pe-4">Amount</th>
</tr>
</thead>
<tbody>
@foreach (var doc in holded.Docs)
{
<tr>
<td class="ps-4">
<a href="@doc.HoldedUrl" target="_blank" rel="noopener">
@(string.IsNullOrWhiteSpace(doc.DocNumber) ? doc.HoldedDocId : doc.DocNumber)
<i class="fa-solid fa-arrow-up-right-from-square ms-1 small"></i>
</a>
</td>
<td>@doc.ContactName</td>
<td>@doc.Date.ToDate()</td>
<td class="text-end pe-4">@doc.Total.ToEuro()</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<div class="p-2 ps-4 border-top bg-light">
<a asp-action="CategoryDetail" asp-route-id="@cat.Id" class="btn btn-outline-primary btn-sm">
<i class="fa-solid fa-pen me-1"></i>Manage Line Items
Expand Down
11 changes: 10 additions & 1 deletion src/Sections/Humans.Finance.Contracts/HoldedDtos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,16 @@ public sealed record HoldedProvisioningRow(
public sealed record HoldedProvisioningPlan(
IReadOnlyList<HoldedProvisioningRow> Rows, int NextNumber);

public sealed record HoldedActualRow(Guid BudgetCategoryId, decimal Actual);
/// <summary>One budget category's Holded actual, plus the approved purchase docs it sums.
/// <c>Docs</c> 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.</summary>
public sealed record HoldedActualRow(
Guid BudgetCategoryId, decimal Actual, IReadOnlyList<HoldedActualDoc> Docs);

/// <summary>One approved Holded purchase doc behind a category's actual.</summary>
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,
Expand Down
1 change: 1 addition & 0 deletions src/Sections/Humans.Finance/Docs/Finance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading