================================================================================
BrightMeasurementsController BrightInvoicesController - Summary & Response Structures
File: SonWinCommonAPI/Controllers/BrightMeasurementsControllerBrightInvoicesController.cs
================================================================================
...
--------------------------------------------------------------------------------
BrightMeasurementsController exposes Exposes a single read-only endpoint that returns a customer's energy consumption readings
for a single service (metering point) over a date range, aggregated to a chosen
time resolution, shaped into the "Bright" measurement contract.
Any thrown exception, goes through logs the error (internal trace file + SonWin BLOG)
and returns HTTP 500 with the exception message in the body.
The controller has FOUR endpoints (monthly, daily, hourly, 15-minute). They are
identical except for ONE thing: each one calls the SAME service method but passes a different.MeasurementsResolution value:
- GET /bright/measurements -> MeasurementsResolution.Monthly ("month")
- GET /bright/measurementDays -> MeasurementsResolution.Daily ("day")
- GET /bright/measurementHours -> MeasurementsResolution.Hourly ("hour")
- GET /bright/measurements15min -> MeasurementsResolution.Quarterly ("15min")
The resolution decides ONLY how the raw 15-minute readings are bucketed/summed
before being returned; every endpoint returns the SAME BrightMeasurementReading
shape.
IMPORTANT - HOW AGGREGATION WORKS:
The repository ALWAYS reads quarter-hour (15-minute) rows from the database. The service then:
- Fills in any missing 15-minute slot in [DateFrom, DateTo) with KWh = 0,so the series is gap-free.
- For Quarterly: returns the 15-minute series as-is.
- For Hourly: sums the 15-minute rows into hour buckets.
- For Daily: sums into LOCAL-DAY buckets (Europe/Copenhagen, DST-aware).
- For Monthly: sums into LOCAL-MONTH buckets (Europe/Copenhagen, DST-aware).
Daily/Monthly bucket boundaries are computed in Europe/Copenhagen local time but the timestamps on each returned bucket are still emitted in UTC (the first reading of the bucket keeps its original UTC Date).
invoices within a date range.
The domain is SonWin billing: invoice header rows live in Sonlinc.AKOND, enriched with supply type (AUDEBFORS), PBSI payment rows (also in AKOND), and a "has a rendered PDF" existence check (AKONDDOC -> BDOC).
The result is mapped into the Bright-facing BrightInvoice model.
SonWin is a BALANCE-FORWARD ledger: payments are not matched per-invoice.
The "remaining amount" and the payment status are both derived from the running account balance (løbende saldo) as of each invoice line, computed in SQL.
DEV info
When running on local machine bypassing the Swagger UI:
Visibility/filtering is configuration-driven. Each filter toggle is resolved as "local appsettings config wins, otherwise defer to the company's BCHEC flag" (Sonlinc.BCHED, cached in memory).
The SQL is assembled from pure-SQL fragments so only the active clauses appear in the executed statement.
--------------------------------------------------------------------------------
METHODS
------------------------------------
METHODS
--------------------------------------------------------------------------------
All four methods share the same parameters, validation, error handling and
response type. They differ only in route and the resolution passed to the
service (see WHAT THE CONTROLLER DOES). Common signature:
Params:
- CustomerID (required)
- ServiceID (required)
- DateFrom (required - utc)
- DateTo (required - utc)
- (CancellationToken ct)
All passed via [FromQuery].
Returns:
- 200 OK -> BrightMeasurementReading
- 400 BadRequest (declared; see note below)
- 401 Unauthorized
Methods:
1) GetMeasurements
Route: GET /bright/measurements
...
1) GetInvoices
Route: GET accounts/{accountId}/invoices
Auth: [Authorize] (class-level); [ApiController]
Params:
- [FromRoute] string accountId
- [FromQuery] DateTime dateFrom
- [FromQuery] DateTime dateTo
- [FromQuery] int? limit (defaults to 100 in the repository)
- CancellationToken ct
Body: none (GET)
Returns:
- 200 OK -> IEnumerable<BrightInvoice>
- 400 BadRequest -> DECLARED, never produced (catch commented out)
- 401 Unauthorized -> produced by [Authorize] framework, not by code
- 404 NotFound -> DECLARED, never produced (catch commented out)
- 500 -> any thrown exception (validation, SQL, mapping)
What it does:
Returns Validates the customer and the input range, resolves the company's readings for the service, summed into local-month
buckets across [DateFrom, DateTo).
2) GetMeasurementDays
Route: GET /bright/measurementDays
Resolution passed: MeasurementsResolution.Daily -> Resolution = "day"
What it does:
Returns the readings summed into local-day buckets.
3) GetMeasurementHours
Route: GET /bright/measurementHours
Resolution passed: MeasurementsResolution.Hourly -> Resolution = "hour"
What it does:
Returns the readings summed into hour buckets.
4) GetMeasurements15min
Route: GET /bright/measurements15min
Resolution passed: MeasurementsResolution.Quarterly -> Resolution = "15min"
What it does:
Returns the raw 15-minute readings (gap-filled), no further aggregation.
Validation that affects the result (ALL surface as HTTP 500 with a message;
each message is wrapped as "The operation [GetMeasurements] could not be
completed due to the following validation errors: ..."):
- Customer does not exist → (looks the customer up via account search).
- - Missing CustomerID -> "Missing parameter 'CustomerId'."
- - Missing ServiceID -> "Missing parameter 'ServiceId'."
- - Missing DateFrom -> "Missing parameter 'DateFrom'." (a DateTime equal to default/01-01-0001 counts as missing)
- - Missing DateTo -> "Missing parameter 'DateTo'."
- - DateFrom >= DateTo -> "'DateFrom' must be before 'DateTo'."
- - A reading missing a date -> "Reading at index {i} is missing a date value." (data-integrity guard)
- - Reading for inactive customer -> "Readings found for inactive customer"
- - Reading for inactive install. -> "Readings found for inactive installation"
- - Metering point mismatch -> "Wrong data. Meteringpoint not matching found in collection from database"
- - DateFrom/DateTo Kind = Local -> "DateTime is in an incorect KIND..." :
(thrown by DateTimeHelper when building
the response StartDate/EndDate; model
binding normally yields Unspecified kind,
which is allowed.)
invoice visibility filters, runs the list query, and maps each row to a BrightInvoice.
Returns the (possibly empty) list with 200.
Validation that affects the result (all failures THROW -> surface as HTTP 500):
Validation that affects the result (all surface as HTTP 500 with a message):
- 0 customer rows -> "No customer found with Id '{id}'."
- 1 customer rows -> "Unique active customer could not be identified. Id: '{id}'."
- row has blank CustomerId -> "The customer returned did not have any CustomerId."
- returned id != requested -> "Returned customer '{row}' does not match requested '{id}'."
- MitId deactivated -> "The customer with Id '{id}' does not have an active MitId registration." (skipped if Settings.IgnoreMitIdStatus)
InvoicesValidator.ValidateGetInvoicesInput (invoked as "GetInvoices"):
- blank customerId -> "Missing parameter 'AccountId'."
- dateFrom out of SQL range -> "Missing or invalid parameter 'DateFrom'.
- Provide a date between 1753-01-01 and 9999-12-31 (e.g. 2000-01-01)."
- dateTo out of SQL range -> "Missing or invalid parameter 'DateTo'.
- Provide a date between 1753-01-01 and 9999-12-31 (e.g. 2030-12-31)."
- dateFrom > dateTo -> "Parameter outside of allowed range:
- 'DateFrom' must be earlier than or equal to 'DateTo'."
- limit < 1 -> "Parameter outside of allowed range: 'Limit' must be 1 or greater."
(A missing/unparseable date query param binds to DateTime.MinValue, which is below SQL Server's datetime min; the range check catches this up front to avoid a cryptic SQL error.)
--------------------------------------------------------------------------------
FILTER RESOLUTION (InvoiceListFilters)
---------------------------------
RESPONSE STRUCTURE (BrightMeasurementReading)
--------------------------------------------------------------------------------
Returned as a single JSON OBJECT (BrightMeasurementReading), not an array.
...
Resolved in BrightInvoiceRepository.GetByCustomerAsync as "local appsettings config (Settings.Invoices:*) ?? company BCHEC flag".
The three UDSMARK visibility variants are independent and compose. Each active flag appends its own SQL clause; inactive flags add nothing (no runtime OR-gates).
- ExcludeParked = Settings.InvoiceExcludeParked ?? BCHEC VISEJPARKEREDE -> AND (UDSMARK IS NULL OR UDSMARK <= 25) (hide UDSMARK > 25)
- CheckUdsmark = Settings.InvoiceCheckUdsmark ?? BCHEC CHECKUDSMARK -> AND (UDSMARK IS NULL OR = 0 OR BETWEEN 2 AND 25) (also drops UDSMARK = 1)
- ShowOnlyDelivered = Settings.InvoiceShowOnlyDelivered ?? BCHEC VISKUNUDSKREVNE -> UDSMARK <= 25 + SNEX/DSEND delivery check (DELIVERYSTATE IN (5,9))
- ExcludeFutureDated = Settings.InvoiceExcludeFutureDated ?? BCHEC EJFREMTID -> AND BILAGSDATO <= @Today
- ExcludeAfregnTypes = Settings.InvoiceExcludeAfregnTypes (non-empty) ?? BCHEC W11_W12EJAFRTYP -> AND AFREGNTYPE NOT IN @ExcludeAfregnTypes
- OnlyRendered = !Settings.InvoiceShowAlsoInvoicesWithoutPdf (default true) -> AND EXISTS (AKONDDOC -> BDOC, DOCTYPE=1, DATALENGTH(PAYLOAD)>0, MIMETYPE LIKE @PdfMimeType) → @PdfMimeType defaults to application/pdf
Always-applied (Base query) filters, independent of the toggles above:
- FIRMANR = @CompanyId, KUNDENR = @CustomerId
- TARIFART IN ('A-TOT','A-FAK') (header rows only; S-TOT excluded)
- (SWIBASVIS IS NULL OR SWIBASVIS = 1) (exclude SWIB-internal, non-visible)
- (SAMLREGNINGNR IS NULL OR SAMLREGNINGNR <= 0) (hide bills folded into a collective invoice)
- BILAGSDATO BETWEEN @DateFrom AND @DateTo
- ORDER BY BILAGSDATO DESC, REGNINGNR DESC, SWCOUNT DESC
- OFFSET 0 ROWS FETCH NEXT @Limit ROWS ONLY (@Limit = limit ?? 100)
--------------------------------------------------------------------------------
RESPONSE STRUCTURE (IEnumerable<BrightInvoice>)
--------------------------------------------------------------------------------
The endpoint returns a JSON array of invoice objects. Each object contains a nested `Info` array of supplementary key/value (or title/value) entries.
| Code Block |
|---|
[
{
"Id": <value>, // Composite: $"{InstNr}-{ForbnNr}-{UdebNr}-{Id}" = AKOND.INSTNR-FORBNR-UDEBNR-REGNINGNR
"ServiceId": <value>, // HARDCODED NULL. Previous: AUDEBFORS.FORSYNINGSART (+AFREGNTYPE) via ForsyningsartMapper.
El-only join => in practice "consumption_trade" or null (a supply type, not an identifier — omitted; see limitations).
"InvoiceDate": <value>, // Data: AKOND.BILAGSDATO (DateTimeOffset, UTC offset 0) | null
"DueDate": <value>, // Data: AKOND.FORFDATO (DateTimeOffset, UTC offset 0) | null
"Period": <value>, // Derived from StartDate/EndDate month-span: "1month"/"2month"/"3month"/"6month"/"12month"
(defaults to "1month" when dates missing or other span)
"StartDate": <value>, // Data: AKOND.DATOFRA (DateTimeOffset, UTC offset 0) | null
"EndDate": <value>, // Data: AKOND.DATOTIL (DateTimeOffset, UTC offset 0) | null
"RemainingAmount": <value>,// Data: running account saldo (SUM of AKOND.KR up to this line) - NOT a per-invoice remaining
figure.
"TotalAmount": <value>, // Data: AKOND.KR (decimal) | null
"InvoiceStatus": <value>, // Derived (see status logic below): one of cancelled / credited / paid / overdue / unpaid
"InvoiceType": <value>, // Derived: "pdf" when a rendered PDF (non-empty BDOC.PAYLOAD with matching MIMETYPE) exists,
else "missing"
// Flat supplementary list; mostly DUPLICATES the structured fields above, as strings/dates.
// Every entry carries BOTH Key AND Title (EGCommon emits null for whichever is unset).
"Info": [
{ "Key": "invoiceNumber", "Title": "Invoice number", "Value": <Id> }, // Data: AKOND.REGNINGNR (string)
{ "Key": "invoiceDate", "Title": "Invoice date", "Value": <InvoiceDate> },// Data: AKOND.BILAGSDATO
(DateTimeOffset|null)
{ "Key": "dueDate", "Title": "Due date", "Value": <DueDate> }, // Data: AKOND.FORFDATO
(DateTimeOffset|null)
{ "Key": "totalAmount", "Title": "Total amount", "Value": <TotalAmount> },// Data: AKOND.KR .ToString() | null
{ "Key": "period", "Title": "Period", "Value": <Period> }, // Derived period string
{ "Key": "remainingAmount", "Title": "Remaining amount", "Value": <Saldo> }, // Data: running saldo .ToString() |
null
{ "Key": "status", "Title": "Status", "Value": <InvoiceStatus> },// Derived status string
{ "Key": "description", "Title": "Description", "Value": <Tekst> }, // Data: AKOND.TEKST
{ "Key": "tekst", "Title": "Tekst", "Value": <Tekst> } // Data: AKOND.TEKST (duplicate of
description)
// NOTE: "ocr" key is NOT emitted (source field not yet identified - TODO)
]
}
] |
Field origin detail (top-level BrightInvoice):
- Id <- composite "{InstNr}-{ForbnNr}-{UdebNr}-{Id}" (Id = REGNINGNR)
- ServiceId <- ForsyningsartMapper.MapToBrightServiceType(ForsyningsArt, AfregnType)
- DueDate <- AKOND.FORFDATO
- InvoiceDate <- AKOND.BILAGSDATO
- Period <- MapPeriod(DATOFRA, DATOTIL)
- StartDate <- AKOND.DATOFRA
- EndDate <- AKOND.DATOTIL
- RemainingAmount <- AKOND.KR - PBSI PaidAmount (null unless both present)
- TotalAmount <- AKOND.KR
- InvoiceStatus <- MapInvoiceStatus(row).GetDisplayName()
- InvoiceType <- MapInvoiceType(row) ("pdf" if HasPdf else "missing")
- Info <- list assembled in the mapper (see above)
INVOICE STATUS LOGIC (MapInvoiceStatus, first match wins):
1. KORTSTATUS = 99 -> "cancelled" (confirmed cancelled)
2. TotalAmount (KR) < 0 -> "credited" (credit note)
3. UDLIGNDATO set OR running saldo <= 0 -> "paid" (account square through this line; balance-forward)
4. DueDate (FORFDATO) in the past -> "overdue"
5. otherwise -> "unpaid"
('partly_paid' is intentionally never produced.)
PERIOD LOGIC (MapPeriod): month span = (EndDate - StartDate) in whole months;
- 2→"2month"
- 3→"3month"
- 6→"6month"
- 12→"12month"
- anything else (incl. missing dates or 1-month) -> "1month".
(PBSI rows are negative), ISNULL(...,0). Not exposed as its own response field — only feeds RemainingAmount and the status derivation.