...
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 inSonlincin 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.
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.
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.)--------------------------------------------------------------------------------
METHODS
--------------------------------------------------------------------------------
...
Body: none (GET)
Returns:
- 200 OK 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 500 -> any thrown exception (validation, SQL, mapping)
...
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.
...
Validation that affects the result (all failures THROW -> surface as HTTP 500):
commonValidator.ValidateCustomer (via AccountValidator.ValidateAccountGetOutput,
invoked as "BrightInvoiceService.GetByCustomerAsync"):
...
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
between 1753-01-01 and 9999-12-31 (e.g. 2000-01-01)."
...
- -> "Missing or invalid parameter '
...
- DateFrom'.
- Provide a date
...
- between 1753-01-01 and 9999-12-31 (e.g.
...
- 2000-
...
- 01-
...
- 01)."
- 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)>"
- 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.) and that wrapped text becomes the 500 body.
--------------------------------------------------------------------------------
...
Resolved in BrightInvoiceRepository.GetByCustomerAsync asas "local appsettings config (Settings.Invoices:*) ?? company BCHEC flag".
The threeUDSMARK 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)
--------------------------------------------------------------------------------
...
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": <value>, "<InstNr>-<ForbnNr>-<UdebNr>-<Id>", // Data: composite of AKOND.INSTNR + FORBNR + UDEBNR + REGNINGNR (REGNINGNR cast to varchar AS Id) // Composite: $"{InstNr}-{ForbnNr}-{UdebNr}-{Id}" = AKOND.INSTNR-FORBNR-UDEBNR-REGNINGNR "ServiceId": "consumption_trade"<value>, // HARDCODED // DataNULL. Previous: 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 AFREGNTYPE) via ForsyningsartMapper. El-only join => in practice "consumption_trade" or null (a supply type, not an identifier — omitted; see limitations). "InvoiceDate": "2026-01-01T00:00:00+00:00", <value>, // Data: AKOND.BILAGSDATO (DateTimeOffset, UTC offset 0); | null if NULL "PeriodDueDate": <value>, "1month", // Data: AKOND.FORFDATO (DateTimeOffset, UTC offset 0) | null "Period": <value>, // Data:Derived derived from AKOND.DATOFRAStartDate/DATOTILEndDate month -span: (1/2/3/6/12month); defaults"1month"/"2month"/"3month"/"6month"/"12month" (defaults to "1month" ifwhen eitherdates datemissing nullor orother span unmatched "StartDate":) "2026-01-01T00:00:00+00:00","StartDate": <value>, // Data: AKOND.DATOFRA (DateTimeOffset, UTC offset 0); | null if NULL "EndDate": "2026-01-31T00:00:00+00:00", <value>, // Data: AKOND.DATOTIL (DateTimeOffset, UTC offset 0); | null if NULL "RemainingAmount": 0.00<value>,// Data: running account saldo (SUM of AKOND.KR up to this line) - NOT a per-invoice remaining figure. "TotalAmount": <value>, // Data: AKOND.KR (TotalAmountdecimal) - PBSI PaidAmount; null unless BOTH present | null "TotalAmountInvoiceStatus": 1953.12<value>, // Derived (see status logic below): one of cancelled / credited / paid / overdue / unpaid "InvoiceType": <value>, // DataDerived: AKOND.KR "InvoiceStatus": "paid","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 // Data: derived (see MapInvoiceStatus); one of unpaid/paid/partly_paid/overdue/cancelled/credited only "InvoiceType":Title (EGCommon emits null for whichever is unset). "pdfInfo",: [ { "Key": "invoiceNumber", "Title": "Invoice number", "Value": <Id> }, // Data: derived from HasPdf EXISTS check -> "pdf" or "missing" ("html" never produced) "Info": [ AKOND.REGNINGNR (string) { "Key": "invoiceDate", { "KeyTitle": "invoiceNumberInvoice date", "Value": "<REGNINGNR>"<InvoiceDate> }, // Data: AKOND.REGNINGNRBILAGSDATO (as Id) — NOTE: raw value, NOT the composite top-level Id DateTimeOffset|null) { "Key": "dueDate", { "KeyTitle": "invoiceDateDue date", "Value": "<offset>"<DueDate> }, // Data: AKOND.BILAGSDATOFORFDATO (DateTimeOffset|null) or null { "Key": "dueDatetotalAmount", "ValueTitle": "<offset>Total amount", }, "Value": <TotalAmount> },// Data: AKOND.FORFDATOKR .ToString(DateTimeOffset) or| null { "Key": "totalAmountperiod", "ValueTitle": "1953.12Period", }, // Data"Value": AKOND.KR (.ToString()<Period> }, so a STRING here// vsDerived decimalperiod atstring top level) { "Key": "periodremainingAmount", "Title": "Remaining amount", "Value": "1month"<Saldo> }, // Data: running same derivation as top-level Period saldo .ToString() | null { "Key": "remainingAmountstatus", "Value "Title": "0.00Status" }, // Data"Value": TotalAmount - PaidAmount (.ToString(); null unless both present) <InvoiceStatus> },// Derived status string { "Key": "statusdescription", "Title": "Description", "Value": "paid"<Tekst> }, // Data: sameAKOND.TEKST derivation as top-level InvoiceStatus { "Key": "description", "Value": "<Tekst>" }tekst", // Data: AKOND.TEKST { "Title": "Tekst", "Value": "<Tekst>" } // Data: AKOND.TEKST (DUPLICATEduplicate of description; uses Title (untranslated) instead of Key) ) // NOTE: "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)
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 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"cancelled" (confirmed cancelled)
7. otherwise 2. TotalAmount (KR) < 0 -> "credited" (credit note)
3. UDLIGNDATO set OR running saldo <= 0 -> "unpaidpaid"
(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.
(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.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