================================================================================
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).
--------------------------------------------------------------------------------
METHODS
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.
The controller requires authentication ([Authorize] at class level). It declares
200/400/401/404 response types, but in practice only 200 (success), 401 (missing/
invalid auth, enforced by the framework) and 500 (any thrown exception) are
actually produced — see the SCOPE LIMITATIONS and the status-code notes below.
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.
IMPORTANT SCOPE LIMITATIONS:
- ALL errors surface as HTTP 500. The catch blocks for ArgumentException (400)
and KeyNotFoundException (404) are commented out in the controller; only the
generic `catch (Exception) -> HandleError` remains, which returns 500 with
the raw exception message. The declared 400/404 ProducesResponseType codes
are therefore never produced by controller logic.
- The 404 "customer not found" path in the service (repository.CustomerExistsAsync)
is commented out (dead). Customer existence is instead validated via
commonValidator.ValidateCustomer (account-search validator) which THROWS on
failure -> surfaces as 500, not 404.
- `ServiceId` (Bright service type) is only mapped for a subset of supply types:
El -> consumption_trade, Vand -> water, Varme -> heating (or cooling when
AFREGNTYPE='KØL'), Antenne -> tv, Bredbånd -> broadband, Reno -> waste,
Gas -> gas_trade. Grid/production variants (consumption_grid, production_grid,
production_trade, gas_grid) are TODOs and return null. All other supply types
and a null supply type also return null.
- `InvoiceStatus` never returns several enum values: collection, reminder,
deferred_with_interest, deferred_without_interest, investigation, paid_out.
The KORTSTATUS values that would drive these are not yet identified (TODOs in
MapInvoiceStatus).
- `InvoiceType` only ever returns "pdf" or "missing"; "html" is never produced.
- The `ocr` Info key is defined in the enum but NEVER emitted (source field in
SonWin not yet identified).
- The "description" Info entry and the "Tekst" Info entry carry the SAME value
(r.Tekst) — duplicated under two different labels.
- Supply-type join (AUDEBFORS) is hard-filtered to FORSYNINGSART = El only;
other supply types are not resolved on that join ("extend when confirmed").
Call chain:
BrightInvoicesController.GetInvoices
-> BrightInvoiceService.GetByCustomerAsync (validate + query + map)
- commonValidator.ValidateCustomer(customerId) (account lookup; throws on miss)
- InvoicesValidator.ValidateGetInvoicesInput(...) (input range checks; throws)
- repository.GetByCustomerAsync(...) (resolve filters + run SQL)
- BchecCache.EnsureLoaded(db) (lazy one-time BCHED load)
- resolve InvoiceListFilters (local config ?? BCHEC)
- InvoiceSql.GetByCustomer(filters) (assemble fragment SQL)
- map each DbInvoice row -> BrightInvoice (+ Info list)
(SQL lives in SonWinCommonAPI/Sql/InvoiceSql.cs; source is Sonlinc.AKOND a,
LEFT JOIN Sonlinc.AUDEBFORS ads (El only), OUTER APPLY PBSI payment sum from
Sonlinc.AKOND, plus EXISTS over Sonlinc.AKONDDOC -> Sonlinc.BDOC for HasPdf.)
-------------------------------------------------------------------------------------
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
Resolution passed: MeasurementsResolution.Monthly -> Resolution = "month"
What it does:
Returns the customer'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.
---------------------
METHODS
--------------------------------------------------------------------------------
1) GetInvoices3) GetMeasurementHours
Route: GET accounts/bright{accountId}/measurementHoursinvoices
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.)
--------------------------------------------------------------------------------
RESPONSE STRUCTURE (BrightMeasurementReading)
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:
Validates the customer and the input range, resolves the company's 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):
commonValidator.ValidateCustomer (via AccountValidator.ValidateAccountGetOutput,
invoked as "BrightInvoiceService.GetByCustomerAsync"):
- 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.)
NOTE on status codes:
The two domain catches in the controller are commented out
("we make use of the internalerror500 for all errors inside the api"), so a
bad date or an unknown customer returns 500 (not 400/404). The validation
messages above are wrapped by ValidationHelper into:
"The operation [{invokingMethodName}] could not be completed due to the
following validation errors: \n <message(s)>"
and that wrapped text becomes the 500 body.
---------------------------------------------------------------------------------------------------------------
Returned as a single JSON OBJECT (BrightMeasurementReading), not an array.
...
------------------------------
FILTER RESOLUTION (InvoiceListFilters)
--------------------------------------------------------------------------------
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)
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 (no wrapper). Each object
contains a nested `Info` array of supplementary key/value (or title/value) entries.
| Code Block |
|---|
[
{
"Id": "<InstNr>-<ForbnNr>-<UdebNr>-<Id>", // Data: composite of AKOND.INSTNR + FORBNR + UDEBNR + REGNINGNR (REGNINGNR cast to varchar AS Id)
"ServiceId": "consumption_trade", // Data: AUDEBFORS.FORSYNINGSART (+ AKOND.AFREGNTYPE for KØL) via ForsyningsartMapper; null for unmapped/unknown/null supply types
"DueDate": "2026-01-31T00:00:00+00:00", // Data: AKOND.FORFDATO (DateTimeOffset, UTC offset 0); null if NULL
"InvoiceDate": "2026-01-01T00:00:00+00:00", // Data: AKOND.BILAGSDATO (DateTimeOffset, UTC offset 0); null if NULL
"Period": "1month", // Data: derived from AKOND.DATOFRA/DATOTIL month span (1/2/3/6/12month); defaults "1month" if either date null or span unmatched
"StartDate": "2026-01-01T00:00:00+00:00", // Data: AKOND.DATOFRA (DateTimeOffset, UTC offset 0); null if NULL
"EndDate": "2026-01-31T00:00:00+00:00", // Data: AKOND.DATOTIL (DateTimeOffset, UTC offset 0); null if NULL
"RemainingAmount": 0.00, // Data: AKOND.KR (TotalAmount) - PBSI PaidAmount; null unless BOTH present
"TotalAmount": 1953.12, // Data: AKOND.KR
"InvoiceStatus": "paid", // Data: derived (see MapInvoiceStatus); one of unpaid/paid/partly_paid/overdue/cancelled/credited only
"InvoiceType": "pdf", // Data: derived from HasPdf EXISTS check -> "pdf" or "missing" ("html" never produced)
"Info": [
{ "Key": "invoiceNumber", "Value": "<REGNINGNR>" }, // Data: AKOND.REGNINGNR (as Id) — NOTE: raw value, NOT the composite top-level Id
{ "Key": "invoiceDate", "Value": "<offset>" }, // Data: AKOND.BILAGSDATO (DateTimeOffset) or null
{ "Key": "dueDate", "Value": "<offset>" }, // Data: AKOND.FORFDATO (DateTimeOffset) or null
{ "Key": "totalAmount", "Value": "1953.12" }, // Data: AKOND.KR (.ToString(), so a STRING here vs decimal at top level)
{ "Key": "period", "Value": "1month" }, // Data: same derivation as top-level Period
{ "Key": "remainingAmount", "Value": "0.00" }, // Data: TotalAmount - PaidAmount (.ToString(); null unless both present)
{ "Key": "status", "Value": "paid" }, // Data: same derivation as top-level InvoiceStatus
{ "Key": "description", "Value": "<Tekst>" }, // Data: AKOND.TEKST
{ "Title": "Tekst", "Value": "<Tekst>" } // Data: AKOND.TEKST (DUPLICATE of description; uses Title (untranslated) instead of Key)
// "ocr" -> Not mapped (TODO: source field in SonWin not yet identified)
]
}
] |
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)
InvoiceStatus derivation (MapInvoiceStatus), in order:
1. KORTSTATUS == 99 -> "cancelled"
2. TotalAmount (KR) < 0 -> "credited"
3. SettlementDate (UDLIGNDATO) set -> "paid" (regardless of PaidAmount)
4. PaidAmount >= TotalAmount -> "paid"
5. DueDate < today (and not fully paid)-> "overdue"
6. PaidAmount > 0 -> "partly_paid"
7. otherwise -> "unpaid"
(collection / reminder / deferred_* / investigation / paid_out are never returned — TODO.)
PaidAmount source: OUTER APPLY over Sonlinc.AKOND where TARIFART='PBSI' for the
same INSTNR+FORBNR+UDEBNR+REGNINGNR+FIRMANR; SUM(KR*ANTAL)*-1 (PBSI rows are
negative), ISNULL(...,0). Not exposed as its own response field — only feeds
RemainingAmount and the status derivation.
DbInvoice columns selected but NOT surfaced as their own response field:
- AfregnType (AKOND.AFREGNTYPE) used only by ServiceId (KØL -> cooling)
- KortStatus (AKOND.KORTSTATUS) used only by status (== 99 -> cancelled)
- KortType (AKOND.KORTTYPE) selected, currently unused in mapping
- SettlementDate (AKOND.UDLIGNDATO) used only by status
- CollectiveBillNr (AKOND.SAMLREGNINGNR) selected; also used as a WHERE filter
- HasPdf (EXISTS check) used only by InvoiceType
- PaidAmount (PBSI sum) used by RemainingAmount + status
...