Tau.Acuvim/portal/src/Tau.Acuvim.Portal/Services/TariffValidator.cs
Diseri Pearson e17921a122 Add portal: customer-facing white-labeled monitoring stack
New top-level portal/ project, peer to console/ and firmware/. Delivers a
.NET 10 + React 18 + TimescaleDB + Grafana stack, one container set per
customer behind Traefik. Built in 12 phases per FrontEndPrompt spec; no
changes to existing console or firmware.

Backend (src/Tau.Acuvim.Portal/):
- .NET 10 minimal API, Serilog, ASP.NET Identity (cookie auth, lockout).
- Single AppDbContext with identity / app / monitoring schemas.
- MigrateAsync + TimescaleBootstrapper (idempotent hypertable creation)
  + IdentityBootstrapper (seeded admin + branding) on startup.
- Pure CostCalculator + DB-backed RateService for tariffs (effective-dated,
  TOU periods, VAT, fixed charges, per-municipality timezone).
- BrandingService with logo upload to mounted volume.
- Time-series ingest + bucketed query services (time_bucket aggregates,
  ON CONFLICT for idempotent re-delivery).
- ConfigOverviewService with redaction-by-construction (passwords never in
  payload).
- DataProtection keys persisted to /data/keys volume for cookie survival
  across container restarts.

Frontend (frontend/):
- React 18 + TypeScript + Vite + Ant Design 5 + TanStack Query.
- BrandingProvider + ThemedRoot for live re-themed white-labelling.
- RequireAuth / RequireRole guards.
- Pages: Login, Dashboard, Dashboards (embedded Grafana), Sites (admin),
  Settings tabs (Branding / Rates / Users / Grafana / App config).

Infra:
- Dev (docker-compose.yml) and prod (docker-compose.prod.yml) compose
  files. Three services per customer; Traefik subdomain + same-origin
  /grafana path-prefix routing wired with labels.
- Grafana 11 with provisioned timescaledb datasource (uid pinned) and
  starter power-overview.json dashboard with device template variable.
- Compose project name documented as lowercase (Compose v2 requirement).

Tests (tests/Tau.Acuvim.Portal.Tests/):
- xUnit, 40 tests. Covers CostCalculator (period match, TZ, overlap,
  VAT, fixed), ConnectionStringResolver (all 4 precedence branches incl.
  Production refusal), TariffValidator, DayOfWeekFlag.
- All passing locally against .NET 10.

Docs:
- README.md (onboarding + 11 spec sections), OPERATIONS.md (per-customer
  provisioning, secret rotation, backup, troubleshooting), TESTING.md
  (manual integration scenarios, frontend test scaffolding recipe).

Production safety guards:
- Refuses to start if Authentication:DefaultAdminPassword is unchanged
  default in Production.
- Refuses to start if Database:AutoProvisionLocalTimescaleDb=true in
  Production.
- Prod Grafana ships with anonymous off and auth mode unset (three
  options documented in README Security) so iframe refuses to load
  until a deliberate prod auth choice is made.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:30:30 +02:00

32 lines
1.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Tau.Acuvim.Portal.DTOs;
namespace Tau.Acuvim.Portal.Services;
// Pure validator for UpsertTariffRequest. Returns null when valid, otherwise an error string.
public static class TariffValidator
{
public static string? Validate(UpsertTariffRequest req)
{
if (req is null) return "Request body is required.";
if (string.IsNullOrWhiteSpace(req.Name)) return "Tariff name is required.";
if (req.EffectiveTo.HasValue && req.EffectiveTo.Value < req.EffectiveFrom)
return "EffectiveTo must be on or after EffectiveFrom.";
if (req.DefaultRatePerKwh < 0) return "DefaultRatePerKwh must be non-negative.";
if (req.FixedMonthlyCharge < 0) return "FixedMonthlyCharge must be non-negative.";
if (req.VatPercentage < 0 || req.VatPercentage > 100) return "VatPercentage must be 0100.";
foreach (var p in req.Periods)
{
if (string.IsNullOrWhiteSpace(p.Name)) return "Period name is required.";
if (p.DaysOfWeek == 0) return $"Period '{p.Name}' must have at least one day.";
if (!TimeOnly.TryParse(p.StartTime, out var start) || !TimeOnly.TryParse(p.EndTime, out var end))
return $"Period '{p.Name}' has invalid times (HH:mm).";
if (start >= end)
return $"Period '{p.Name}' StartTime must be before EndTime (no midnight wrap; split the window).";
if (p.RatePerKwh < 0)
return $"Period '{p.Name}' RatePerKwh must be non-negative.";
}
return null;
}
}