================================================================================
BrightInvoicesController - Summary & Response Structures
File: SonWinCommonAPI/Controllers/BrightInvoicesController.cs
================================================================================
--------------------------------------------------------------------------------
WHAT THE CONTROLLER DOES
--------------------------------------------------------------------------------
Exposes a single read-only endpoint that returns a customer's 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)
- BchecCache.EnsureLoaded(db) (lazy one-time BCHED load)
- resolve InvoiceListFilters (local config ?? BCHEC)
- InvoiceSql.GetByCustomer(filters) (assemble fragment SQL)
(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.)
--------------------------------------------------------------------------------
METHODS
--------------------------------------------------------------------------------
1) GetInvoices
Route: GET accounts/{accountId}/invoices
Auth: [Authorize] (class-level); [ApiController]
Params:
Body: none (GET)
Returns:
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.
--------------------------------------------------------------------------------
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.
[
{
"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