diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 898b3a4..baa948c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,3 +67,29 @@ jobs: - run: npm ci - run: npx playwright install --with-deps chromium - run: npm run genomgang + + # Plattformstjänsten mot en riktig Postgres. + # + # Den här sviten fanns redan och kördes aldrig, vilket lät två fel leva + # i backenden: en append-only-trigger som gjorde det omöjligt att skriva + # ärendets härledda gallringsdatum — så varje avslut föll med 500 — och + # en fordonshistorik som sökte i klartext efter en identifierare som + # krypteras i vila, och därför svarade tomt på varje fordon. Med 200. + # + # Bägge kräver en databas för att synas. Enhetstester med attrapper hade + # inte kunnat hitta någotdera, och gjorde det inte heller. + plattform: + runs-on: ubuntu-latest + defaults: + run: + working-directory: felsokning/services/plattform + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: sudo apt-get update && sudo apt-get install -y postgresql + - run: npm ci + # Skriptet startar sin egen Postgres med initdb och behöver därför + # köra som root; -E env PATH behåller runnerns node. + - run: sudo -E env "PATH=$PATH" bash integrationstest.sh diff --git a/felsokning/app/src/felsokning/__tests__/fakturering.test.ts b/felsokning/app/src/felsokning/__tests__/fakturering.test.ts index 77f3208..a138e22 100644 --- a/felsokning/app/src/felsokning/__tests__/fakturering.test.ts +++ b/felsokning/app/src/felsokning/__tests__/fakturering.test.ts @@ -9,8 +9,10 @@ import { PRISLISTA, fakturabeteckning, fakturarader, + fakturastatus, fakturera, formateraBelopp, + granskaPeriod, kreditera, manaderMellan, } from "../../../../services/gemensam/fakturering.mjs"; @@ -110,6 +112,50 @@ describe("en utfärdad faktura ändras aldrig", () => { }); }); +describe("statusen är en projektion, inte ett fält", () => { + // Följden av att fakturaraden är oföränderlig: "betald" kan inte + // skrivas ovanpå "utfärdad" utan måste härledas ur händelserna. + it("en faktura utan händelser är utfärdad", () => { + expect(fakturastatus([])).toBe("utfardad"); + expect(fakturastatus()).toBe("utfardad"); + }); + + it("en betalningshändelse gör den betald", () => { + expect(fakturastatus([{ typ: "betald" }])).toBe("betald"); + }); + + it("kreditering väger tyngst, även efter betalning", () => { + // En krediterad faktura som hunnit bli betald är fortfarande + // krediterad — det är återbetalningen som är kvar, och den frågan + // besvaras inte av ett statusfält. + expect(fakturastatus([{ typ: "betald" }, { typ: "krediterad" }])).toBe("krediterad"); + expect(fakturastatus([{ typ: "krediterad" }, { typ: "betald" }])).toBe("krediterad"); + }); +}); + +describe("perioden granskas innan något härleds ur den", () => { + it("en rimlig period passerar", () => { + expect(granskaPeriod({ fran: "2026-01-01", till: "2026-12-31" })).toBeNull(); + }); + + it("en bakvänd period avvisas i stället för att ge noll månader", () => { + // Utan den här spärren blir resultatet en faktura UTAN användarrad, + // vilket ser rimligt ut. Tyst fel är värre än avslag. + expect(granskaPeriod({ fran: "2026-12-31", till: "2026-01-01" })).toMatch(/slutar före/); + }); + + it("saknade eller trasiga datum avvisas", () => { + expect(granskaPeriod(null)).toBeTruthy(); + expect(granskaPeriod({ fran: "2026-01-01" })).toBeTruthy(); + expect(granskaPeriod({ fran: "i januari", till: "2026-12-31" })).toBeTruthy(); + expect(granskaPeriod({ fran: "2026-1-1", till: "2026-12-31" })).toBeTruthy(); + }); + + it("ett skrivfel i årtalet blir inte en faktura på nittio år", () => { + expect(granskaPeriod({ fran: "2026-01-01", till: "2126-12-31" })).toMatch(/längre än fem år/); + }); +}); + describe("belopp skrivs så att de går att jämföra", () => { it("två decimaler och hårt mellanslag som tusentalsavskiljare", () => { // Hårt mellanslag, uttryckligen: ett belopp får inte brytas över en diff --git a/felsokning/docs/QUALITY-AUDIT-2.md b/felsokning/docs/QUALITY-AUDIT-2.md index 6afceda..59d1389 100644 --- a/felsokning/docs/QUALITY-AUDIT-2.md +++ b/felsokning/docs/QUALITY-AUDIT-2.md @@ -506,6 +506,29 @@ walkthrough would quietly start testing a different application. | Rev 1 · m-6 | Manual accessibility review | Automated tooling finds malformation, not usability. | | Rev 2 · m-9 | The portal mock | A product decision about what the portal is for. | +### Two backend defects found by running the suite that was never run + +Building the invoicing endpoints meant running `integrationstest.sh` — the +platform service against a real Postgres. It had never been part of CI. It +failed on the second assertion, on code untouched by that work: + +| # | Defect | Why it survived | +| --- | --- | --- | +| **C-7** | The `arenden_append_only` trigger forbade **all** `UPDATE` on `felsokning_arenden`. But two of its columns are derived *after* creation: the retention date, set at close from the case type, and the blinded vehicle index, written when the object is identified. So closing a case made the server attempt an update, the database refused, and the whole sync failed with `500` — *after* the quality gate had already passed the case. | No test exercised close against a real database. The unit suite uses the in-memory projection, where no trigger exists. | +| **C-8** | Vehicle history matched `handelse->'objekt'->>'identifierare'` in cleartext. Identifiers are encrypted at rest under crypto-shredding, so the comparison could never match: history answered **empty for every vehicle, with `200`**. The blinded index exists for exactly this query and was never wired into it. | An empty result is indistinguishable from "no previous cases" unless a test writes two cases and demands two back. The integration test did — and never ran. | + +C-7 is repaired by making the protection column-wise instead of total: identity, +ownership and origin are still immutable, deletion is still impossible, but the +fields the system derives itself may be written. C-8 is repaired by querying the +blinded index. Both now have assertions, and the suite runs in CI as the +`plattform` job. + +The pattern is the same one M-7 exposed, one layer down. A guarantee nothing +executes is not a guarantee. Both defects were in the *backend of the product's +central promise* — that a closed case is a durable record — and both were +invisible from a green unit suite, because neither could fail without a +database. + ### Re-audit verdict The finding that decided this revision — C-5 — is closed at the point where it diff --git a/felsokning/infra/postgres-init.sql b/felsokning/infra/postgres-init.sql index d5ab251..fb271e8 100644 --- a/felsokning/infra/postgres-init.sql +++ b/felsokning/infra/postgres-init.sql @@ -127,10 +127,40 @@ create trigger handelser_append_only before update or delete on felsokning_handelser for each row execute function forbjud_andring(); +-- Ärenderaden är inte en logg utan ett omslag, och två av dess kolumner +-- är HÄRLEDDA av systemet efteråt: gallringsdatumet sätts vid avslut +-- utifrån ärendetypen, och det blindade fordonsindexet skrivs när +-- objektet identifieras. +-- +-- Den tidigare triggern förbjöd all update och gjorde därmed avslut +-- omöjligt: servern försökte sätta gallras_efter, databasen sa nej, och +-- hela synken föll med 500. Kvalitetsgrinden hade redan godkänt ärendet +-- — det var lagringen som vägrade, efteråt, av ett skäl som inte hade +-- med ärendet att göra. +-- +-- Skyddet är därför kolumnvis i stället för totalt: identitet, tillhörighet +-- och ursprung kan inte ändras, radering är fortfarande omöjlig, men de +-- fält systemet självt härleder får skrivas. +create or replace function skydda_arende() returns trigger +language plpgsql as $$ +begin + if TG_OP = 'DELETE' then + raise exception 'Ärenden kan inte raderas'; + end if; + if (new.id, new.organisation_id, new.nummer, new.skapad, new.delningskod, + new.metodik_id, new.skapad_av, new.insatt) + is distinct from + (old.id, old.organisation_id, old.nummer, old.skapad, old.delningskod, + old.metodik_id, old.skapad_av, old.insatt) then + raise exception 'Ärendets historik kan inte ändras — endast härledda fält får sättas'; + end if; + return new; +end $$; + drop trigger if exists arenden_append_only on felsokning_arenden; create trigger arenden_append_only before update or delete on felsokning_arenden - for each row execute function forbjud_andring(); + for each row execute function skydda_arende(); -- Live Share-delningar: återkallbara länkar med behörighetsnivå. -- (Åtkomststyrning, inte journal — därför ingen append-only-trigger: @@ -248,3 +278,65 @@ create table if not exists prenumerationer ( senaste_status text ); create index if not exists prenumerationer_org on prenumerationer (organisation_id) where aktiv; + +-- ---- Fakturering (ALVA-PROC-0001) -------------------------------------- +-- +-- En utfärdad faktura ändras aldrig. Det är inte en ambition utan en +-- egenskap i schemat: samma append-only-trigger som skyddar +-- händelseloggen skyddar fakturaraden. +-- +-- Det får en följd som är lätt att missa. "Betald" kan då inte vara en +-- kolumn som uppdateras — en betalning är en HÄNDELSE som inträffar +-- efter utfärdandet, och statusen är en projektion av de händelserna. +-- Precis som ärendets tillstånd inte lagras utan härleds ur loggen. +-- +-- Beloppet lagras som det räknades fram, i öre, tillsammans med hela +-- underlaget. Skulle prislistan ändras nästa år står den gamla fakturan +-- kvar oförändrad, med de rader och de priser som faktiskt gällde. +create table if not exists fakturor ( + id uuid primary key default gen_random_uuid(), + organisation_id uuid not null references organisationer(id), + -- Löpnumret är gemensamt för hela installationen och utan luckor: + -- ALVA är utfärdaren, organisationerna är mottagare. Se + -- nästaFakturanummer() i server.mjs för hur luckor undviks. + nummer bigint not null unique, + beteckning text not null unique, + utfardad date not null, + forfaller date not null, + valuta text not null, + totalt bigint not null, + -- Krediterar en tidigare faktura. Null för en vanlig faktura. + krediterar uuid references fakturor(id), + -- Hela det härledda dokumentet, fruset vid utfärdandet: rader, + -- underlag, à-priser, moms. Den som läser den om två år ska inte + -- behöva systemet för att förstå den. + dokument jsonb not null, + skapad timestamptz not null default now() +); +create index if not exists fakturor_org_idx on fakturor (organisation_id, nummer desc); + +drop trigger if exists fakturor_append_only on fakturor; +create trigger fakturor_append_only + before update or delete on fakturor + for each row execute function forbjud_andring(); + +-- Vad som hänt med en utfärdad faktura. Append-only av samma skäl som +-- allt annat som utgör underlag: en registrerad betalning som kan +-- backas bort tyst är inte ett underlag, den är en anteckning. +create table if not exists fakturahandelser ( + id uuid primary key default gen_random_uuid(), + faktura_id uuid not null references fakturor(id), + typ text not null check (typ in ('betald', 'krediterad')), + intraffade date not null, + -- Betalningsreferens, kreditorsak. Fritext, men aldrig tom. + uppgift text not null, + registrerad_av text not null, + insatt timestamptz not null default now() +); +create index if not exists fakturahandelser_faktura_idx + on fakturahandelser (faktura_id, insatt); + +drop trigger if exists fakturahandelser_append_only on fakturahandelser; +create trigger fakturahandelser_append_only + before update or delete on fakturahandelser + for each row execute function forbjud_andring(); diff --git a/felsokning/services/gemensam/fakturering.mjs b/felsokning/services/gemensam/fakturering.mjs index 8e137ec..ba1c5ef 100644 --- a/felsokning/services/gemensam/fakturering.mjs +++ b/felsokning/services/gemensam/fakturering.mjs @@ -61,6 +61,51 @@ export const PRISLISTA = { /** Fakturans tillstånd. Ordningen är enkelriktad utom kredit. */ export const FAKTURASTATUS = ["utfardad", "betald", "krediterad"]; +/** + * Statusen är en projektion, inte en kolumn. + * + * Följden av att en utfärdad faktura aldrig ändras: "betald" kan inte + * skrivas ovanpå "utfärdad". Betalningen är en händelse som inträffar + * efteråt, och tillståndet härleds ur händelserna — samma förhållande + * som mellan ärendets tillstånd och dess logg. + * + * Kreditering väger tyngst. En krediterad faktura som också hunnit bli + * betald är fortfarande krediterad; det är återbetalningen som är kvar + * att göra, och den frågan besvaras inte av ett statusfält. + * + * @param poster [{ typ: "betald" | "krediterad", ... }] + */ +export function fakturastatus(poster = []) { + if (poster.some((p) => p?.typ === "krediterad")) return "krediterad"; + if (poster.some((p) => p?.typ === "betald")) return "betald"; + return "utfardad"; +} + +/** + * Granskar en fakturaperiod innan något härleds ur den. + * + * En bakvänd period ger noll månader i manaderMellan(), vilket tyst + * skulle bli en faktura utan användarrad i stället för ett fel. Den + * sortens tystnad är värre än ett avslag: fakturan ser rimlig ut. + * + * Returnerar en felsträng, eller null när perioden duger. + */ +export function granskaPeriod(period) { + const datum = (v) => typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v) && !Number.isNaN(Date.parse(v)); + if (!period || !datum(period.fran) || !datum(period.till)) { + return "Perioden kräver fran och till som datum (ÅÅÅÅ-MM-DD)."; + } + if (new Date(period.till) < new Date(period.fran)) { + return "Perioden slutar före den börjar."; + } + // Ett tak finns för att ett skrivfel i årtalet inte ska bli en faktura + // på nittio år. Fem år rymmer varje rimlig licensperiod. + if (manaderMellan(period.fran, period.till) > 60) { + return "Perioden är längre än fem år — kontrollera datumen."; + } + return null; +} + /** * Beräknar fakturarader ur organisationens tillstånd. * diff --git a/felsokning/services/plattform/fakturering.mjs b/felsokning/services/plattform/fakturering.mjs new file mode 120000 index 0000000..369e063 --- /dev/null +++ b/felsokning/services/plattform/fakturering.mjs @@ -0,0 +1 @@ +../gemensam/fakturering.mjs \ No newline at end of file diff --git a/felsokning/services/plattform/integrationstest.sh b/felsokning/services/plattform/integrationstest.sh index 98c9551..527140d 100755 --- a/felsokning/services/plattform/integrationstest.sh +++ b/felsokning/services/plattform/integrationstest.sh @@ -178,6 +178,29 @@ curl -s -X POST "$BAS/api/arenden/arende-test3/handelser" -H "Authorization: Bea {"id":"f2","tidpunkt":"2026-08-03T10:01:00Z","anvandare":"Johan","handelse":{"typ":"objekt_identifierat","objekt":{"typ":"Personbil","identifierare":"xyz999","identifieringsmetod":"Regnr","beskrivning":"VW Golf 2023"}}}, {"id":"f3","tidpunkt":"2026-08-03T10:02:00Z","anvandare":"Johan","handelse":{"typ":"felorsak","avvikelse":"Vattenpumpen läcker vid axeltätningen.","orsaker":["Normalt slitage","Ålder"],"underlag":["Foto"],"sakerhet":"hog","atgard":"Byt vattenpump."}} ]}' >/dev/null +# Det blindade fordonsindexet är en HÄRLEDD kolumn på ärenderaden, och +# den skrivs efter att ärendet skapats. En trigger som förbjöd all update +# gjorde den skrivningen omöjlig och lät hela synken falla med 500 — +# osynligt här, eftersom svaret kastades bort. Statuskoden granskas nu. +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden/arende-test2/handelser" \ + -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d '{"handelser":[{"id":"f1b","tidpunkt":"2026-08-03T09:03:00Z","anvandare":"Anna","handelse":{"typ":"observation","text":"Fortsatt läckage vid axeltätningen."}}]}') +kontroll "synk av händelser lyckas" "$KOD" "200" + +# Skyddet är kolumnvis, inte bortplockat: identiteten är fortfarande låst. +if PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \ + -qc "update felsokning_arenden set nummer = 999 where id='arende-test2'" 2>/dev/null; then + echo "✗ ärendets identitet går att ändra"; exit 1 +else + echo "✓ ärendets identitet är fortfarande oföränderlig" +fi +if PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \ + -qc "delete from felsokning_arenden where id='arende-test2'" 2>/dev/null; then + echo "✗ ärenden går att radera"; exit 1 +else + echo "✓ ärenden går fortfarande inte att radera" +fi + HIST=$(curl -s "$BAS/api/fordon/XYZ999/historik" -H "Authorization: Bearer $TOKEN_J") kontroll "fordonshistoriken hittar båda ärendena (case-okänsligt)" "$(echo "$HIST" | falt .arenden.length)" "2" kontroll "historiken bär felorsakerna" "$(echo "$HIST" | falt '.arenden.flatMap(a=>a.felorsaker).length')" "1" @@ -476,4 +499,141 @@ case "$SPEC" in *) echo "✗ OpenAPI-specen saknas"; exit 1 ;; esac +# 12. Fakturering (ALVA-PROC-0001) +# +# Två egenskaper avgör om det här är ett bokföringsunderlag eller en +# anteckning: att beloppet HÄRLEDS ur organisationens tillstånd i stället +# för att tas emot, och att en utfärdad faktura aldrig ändras. Bägge +# prövas nedan, tillsammans med gränsen mellan kund och utfärdare. + +ORG_A=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \ + -tAc "select id from organisationer where namn='Verkstad A'") +PERIOD='{"fran":"2026-01-01","till":"2026-12-31"}' + +# Utan nyckel i miljön kan ingen faktura utfärdas — fallerar stängt. +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/fakturor" -H 'Content-Type: application/json' \ + -H 'X-Fakturering: gissning' -d "{\"organisation_id\":\"$ORG_A\",\"period\":$PERIOD}") +kontroll "utan konfigurerad utfärdarnyckel utfärdas ingenting" "$KOD" "403" + +# Starta om med utfärdarnyckel konfigurerad +kill "$SERVER_PID" 2>/dev/null || true +wait "$SERVER_PID" 2>/dev/null || true +DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \ + JWT_SECRET=integrationshemlighet PORT=$APPPORT \ + INTEGRATION_NYCKEL=$(node -pe "require('crypto').randomBytes(32).toString('hex')") \ + FAKTURERING_NYCKEL=utfardarnyckel \ + node server.mjs & +SERVER_PID=$! +sleep 1 + +# Kundens administratör är inte utfärdare, hur inloggad den än är. +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/fakturor" \ + -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d "{\"organisation_id\":\"$ORG_A\",\"period\":$PERIOD}") +kontroll "kundens admin kan inte utfärda sin egen faktura" "$KOD" "403" + +# Fel nyckel duger inte heller. +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/fakturor" -H 'Content-Type: application/json' \ + -H 'X-Fakturering: fel-nyckel-samma-langd' -d "{\"organisation_id\":\"$ORG_A\",\"period\":$PERIOD}") +kontroll "fel utfärdarnyckel avvisas" "$KOD" "403" + +# Beloppet tas aldrig emot. Ett anrop som försöker sätta det avvisas +# högt i stället för att tigas ihjäl. +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/fakturor" -H 'Content-Type: application/json' \ + -H 'X-Fakturering: utfardarnyckel' \ + -d "{\"organisation_id\":\"$ORG_A\",\"period\":$PERIOD,\"totalt\":1}") +kontroll "angivet belopp avvisas" "$KOD" "400" + +# En bakvänd period ger annars noll månader och en faktura som SER +# rimlig ut — värre än ett avslag. +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/fakturor" -H 'Content-Type: application/json' \ + -H 'X-Fakturering: utfardarnyckel' \ + -d "{\"organisation_id\":\"$ORG_A\",\"period\":{\"fran\":\"2026-12-31\",\"till\":\"2026-01-01\"}}") +kontroll "bakvänd period avvisas" "$KOD" "400" + +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/fakturor" -H 'Content-Type: application/json' \ + -H 'X-Fakturering: utfardarnyckel' \ + -d "{\"organisation_id\":\"00000000-0000-0000-0000-000000000000\",\"period\":$PERIOD}") +kontroll "okänd organisation avvisas" "$KOD" "404" + +# Utfärda på riktigt. +FAKTURA=$(curl -s -X POST "$BAS/api/fakturor" -H 'Content-Type: application/json' \ + -H 'X-Fakturering: utfardarnyckel' -d "{\"organisation_id\":\"$ORG_A\",\"period\":$PERIOD,\"utfardad\":\"2026-01-15\"}") +FAKTURA_ID=$(echo "$FAKTURA" | falt .id) +kontroll "beteckningen följer nomenklaturen" "$(echo "$FAKTURA" | falt .beteckning)" "ALVA-INV-0001" +kontroll "förfallodagen följer betalningsvillkoret" "$(echo "$FAKTURA" | falt .forfaller)" "2026-02-14" + +# Kärnan: summan ska vara densamma som modulen räknar fram ur DATABASENS +# egna aktiva konton. Ett hårdkodat facit hade bara testat aritmetiken; +# det här testar att beloppet verkligen härleds ur tillståndet. +AKTIVA=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \ + -tAc "select count(*) from anvandare where organisation_id='$ORG_A' and aktiv") +VANTAT=$(node -e " + import('./fakturering.mjs').then(({ fakturera }) => { + const f = fakturera({ nummer: 1, org: { namn: 'Verkstad A', moduler: [] }, aktiva: $AKTIVA, + period: { fran: '2026-01-01', till: '2026-12-31' }, utfardad: '2026-01-15' }); + console.log(f.totalt); + }); +") +kontroll "summan härleds ur organisationens aktiva konton" "$(echo "$FAKTURA" | falt .totalt)" "$VANTAT" + +# Kunden läser sina egna fakturor, och bara sina egna. +LISTA=$(curl -s "$BAS/api/fakturor" -H "Authorization: Bearer $TOKEN_A") +kontroll "kunden ser sin faktura" "$(echo "$LISTA" | falt .fakturor.length)" "1" +kontroll "statusen är utfärdad" "$(echo "$LISTA" | falt '.fakturor[0].status')" "utfardad" +kontroll "org B ser inga fakturor" "$(curl -s "$BAS/api/fakturor" -H "Authorization: Bearer $TOKEN_B" | falt .fakturor.length)" "0" +KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/fakturor" -H "Authorization: Bearer $TOKEN_J") +kontroll "tekniker når inte kommersiella uppgifter" "$KOD" "403" + +# Oföränderligheten är en egenskap i databasen, inte en ambition i koden. +if PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \ + -qc "update fakturor set totalt = 0 where id='$FAKTURA_ID'" 2>/dev/null; then + echo "✗ fakturor går att ändra i databasen"; exit 1 +else + echo "✓ databastriggern avvisar ändring av utfärdad faktura" +fi + +# Betalning: registreras av en människa, med en referens som går att +# spåra, och skrivs som en händelse — aldrig som en uppdatering. +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/fakturor/$FAKTURA_ID/betald" \ + -H 'X-Fakturering: utfardarnyckel' -H 'Content-Type: application/json' -d '{}') +kontroll "betalning utan referens avvisas" "$KOD" "400" +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/fakturor/$FAKTURA_ID/betald" \ + -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"referens":"BG 123"}') +kontroll "kunden kan inte bokföra sin egen betalning" "$KOD" "403" + +curl -s -X POST "$BAS/api/fakturor/$FAKTURA_ID/betald" -H 'X-Fakturering: utfardarnyckel' \ + -H 'Content-Type: application/json' -d '{"referens":"BG 5402-1178","betaldatum":"2026-02-03"}' >/dev/null +STATUS=$(curl -s "$BAS/api/fakturor" -H "Authorization: Bearer $TOKEN_A" | falt '.fakturor[0].status') +kontroll "statusen härleds ur betalningshändelsen" "$STATUS" "betald" +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/fakturor/$FAKTURA_ID/betald" \ + -H 'X-Fakturering: utfardarnyckel' -H 'Content-Type: application/json' -d '{"referens":"BG 5402-1178"}') +kontroll "en betald faktura betalas inte igen" "$KOD" "409" + +# Rättelse: originalet rörs inte, en kreditfaktura pekar tillbaka. +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/fakturor/$FAKTURA_ID/kreditera" \ + -H 'X-Fakturering: utfardarnyckel' -H 'Content-Type: application/json' -d '{"orsak":"fel"}') +kontroll "kreditering utan granskbart skäl avvisas" "$KOD" "400" +KREDIT=$(curl -s -X POST "$BAS/api/fakturor/$FAKTURA_ID/kreditera" -H 'X-Fakturering: utfardarnyckel' \ + -H 'Content-Type: application/json' \ + -d '{"orsak":"Antalet aktiva användare var felaktigt vid utfärdandet.","utfardad":"2026-02-10"}') +kontroll "kreditfakturan får nästa nummer" "$(echo "$KREDIT" | falt .beteckning)" "ALVA-INV-0002" +kontroll "kreditfakturan pekar tillbaka" "$(echo "$KREDIT" | falt .krediterar)" "ALVA-INV-0001" +kontroll "kreditfakturan har omvänt tecken" "$(echo "$KREDIT" | falt .totalt)" "-$VANTAT" + +LISTA=$(curl -s "$BAS/api/fakturor" -H "Authorization: Bearer $TOKEN_A") +kontroll "originalet står kvar med sitt belopp" \ + "$(echo "$LISTA" | falt '.fakturor.find(f => f.beteckning === "ALVA-INV-0001").totalt')" "$VANTAT" +kontroll "originalets status är nu krediterad" \ + "$(echo "$LISTA" | falt '.fakturor.find(f => f.beteckning === "ALVA-INV-0001").status')" "krediterad" +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/fakturor/$FAKTURA_ID/kreditera" \ + -H 'X-Fakturering: utfardarnyckel' -H 'Content-Type: application/json' \ + -d '{"orsak":"Samma skäl en gång till, vilket inte ska gå."}') +kontroll "en krediterad faktura krediteras inte igen" "$KOD" "409" + +# Nummerserien är utan luckor — annars är den inte ett underlag. +LUCKOR=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \ + -tAc "select (select max(nummer) from fakturor) - (select count(*) from fakturor)") +kontroll "nummerserien är utan luckor" "$LUCKOR" "0" + echo "Integrationstest: allt grönt" diff --git a/felsokning/services/plattform/openapi.yaml b/felsokning/services/plattform/openapi.yaml index 6639e0b..10684ad 100644 --- a/felsokning/services/plattform/openapi.yaml +++ b/felsokning/services/plattform/openapi.yaml @@ -28,6 +28,7 @@ tags: - name: AI - name: Integrationer - name: Drift + - name: Fakturering paths: /halsa: @@ -1186,6 +1187,136 @@ paths: "200": { description: Sparat } "403": { description: Kräver arbetsledare eller administratör } + # ---- Fakturering (ALVA-PROC-0001) ------------------------------------ + # + # Två parter, inte en. Organisationen LÄSER sina fakturor; utfärdaren + # skapar dem och registrerar betalning. Utfärdaren är inte en användare + # i någon organisation och identifieras med en egen nyckel + # (X-Fakturering) — en kund ska inte kunna bokföra sin egen betalning. + # + # Ingen betalleverantör är inblandad och inga kortuppgifter hanteras. + /api/fakturor: + get: + tags: [Fakturering] + summary: Organisationens egna fakturor + description: > + Kräver rollen `admin`. Statusen är en projektion av + fakturahändelserna, inte ett lagrat fält — en utfärdad faktura + ändras aldrig. + security: [{ bearerAuth: [] }] + responses: + "200": + description: Fakturor, senast utfärdad först. + content: + application/json: + schema: + type: object + properties: + fakturor: + type: array + items: { $ref: "#/components/schemas/Faktura" } + "401": { $ref: "#/components/responses/Fel" } + "403": { $ref: "#/components/responses/Fel" } + post: + tags: [Fakturering] + summary: Utfärda en faktura + description: > + Kräver utfärdarens nyckel i `X-Fakturering`. Beloppet kan inte + anges: det härleds ur organisationens faktiska tillstånd — antalet + aktiva konton, licensperioden, påslagna moduler. Ett anrop som + ändå innehåller `rader`, `totalt` eller `belopp` avvisas med 400. + security: [{ utfardarNyckel: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [organisation_id, period] + properties: + organisation_id: { type: string, format: uuid } + period: + type: object + required: [fran, till] + properties: + fran: { type: string, format: date } + till: { type: string, format: date } + utfardad: + type: string + format: date + description: Standard är dagens datum. + responses: + "201": + description: Fakturan, fullständig och oföränderlig. + content: + application/json: + schema: { $ref: "#/components/schemas/Faktura" } + "400": { $ref: "#/components/responses/Fel" } + "403": { $ref: "#/components/responses/Fel" } + "404": { $ref: "#/components/responses/Fel" } + + /api/fakturor/{fakturaId}/betald: + post: + tags: [Fakturering] + summary: Registrera betalning + description: > + Kräver utfärdarens nyckel. Ingen uppdatering sker — betalningen + skrivs som en egen händelse, och statusen härleds ur den. + Plattformen registrerar aldrig en betalning av sig själv. + security: [{ utfardarNyckel: [] }] + parameters: + - { name: fakturaId, in: path, required: true, schema: { type: string, format: uuid } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [referens] + properties: + referens: + type: string + description: Betalningsreferens, minst 3 tecken — annars går betalningen inte att spåra. + betaldatum: { type: string, format: date } + responses: + "200": { description: Registrerad. } + "400": { $ref: "#/components/responses/Fel" } + "403": { $ref: "#/components/responses/Fel" } + "404": { $ref: "#/components/responses/Fel" } + "409": { description: Fakturan är redan betald eller krediterad. } + + /api/fakturor/{fakturaId}/kreditera: + post: + tags: [Fakturering] + summary: Kreditera en faktura + description: > + Kräver utfärdarens nyckel. En felaktig faktura rättas inte — den + bemöts av en kreditfaktura som pekar tillbaka på den, med omvänt + tecken och ett granskbart skäl (minst 10 tecken). + security: [{ utfardarNyckel: [] }] + parameters: + - { name: fakturaId, in: path, required: true, schema: { type: string, format: uuid } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [orsak] + properties: + orsak: { type: string, minLength: 10 } + utfardad: { type: string, format: date } + responses: + "201": + description: Kreditfakturan. + content: + application/json: + schema: { $ref: "#/components/schemas/Faktura" } + "400": { $ref: "#/components/responses/Fel" } + "403": { $ref: "#/components/responses/Fel" } + "404": { $ref: "#/components/responses/Fel" } + "409": { description: Fakturan är redan krediterad. } + components: securitySchemes: bearerAuth: @@ -1196,6 +1327,17 @@ components: HS256-JWT från /api/auth/logga-in eller /api/auth/registrera. Anspråk: sub (användar-id), namn, org (organisations-id), roll, iat, exp (12 timmar). + utfardarNyckel: + type: apiKey + in: header + name: X-Fakturering + description: > + Utfärdarens nyckel (FAKTURERING_NYCKEL). Fakturering är en + relation mellan installationen och kunden — ingen av rollerna i + en verkstad är motpart i det avtalet, så en organisations + administratör kan varken utfärda sin egen faktura eller bokföra + den som betald. Saknas nyckeln i miljön kan ingen faktura + utfärdas alls: faktureringen fallerar stängt. responses: Inloggad: description: Inloggad — token + kontouppgifter. @@ -1221,6 +1363,47 @@ components: type: object properties: error: { type: string } + Faktura: + type: object + description: > + Belopp i minsta valutaenhet (öre). Varje rad bär sitt underlag — + var antalet kommer ifrån — så att summan går att granska utan att + fråga någon. `status` är härledd, inte lagrad. + properties: + id: { type: string, format: uuid } + beteckning: { type: string, example: ALVA-INV-0001 } + organisation: { type: string } + period: + type: object + properties: + fran: { type: string, format: date } + till: { type: string, format: date } + utfardad: { type: string, format: date } + forfaller: { type: string, format: date } + valuta: { type: string, example: SEK } + rader: + type: array + items: + type: object + properties: + benamning: { type: string } + underlag: { type: string } + antal: { type: integer } + enhet: { type: string } + apris: { type: integer } + belopp: { type: integer } + netto: { type: integer } + momssats: { type: number } + moms: { type: integer } + totalt: { type: integer } + krediterar: + type: string + nullable: true + description: Beteckningen på den faktura denna kreditfaktura rättar. + status: + type: string + enum: [utfardad, betald, krediterad] + betalningssatt: { type: string } Roll: type: string enum: [tekniker, arbetsledare, admin] diff --git a/felsokning/services/plattform/server.mjs b/felsokning/services/plattform/server.mjs index 59328fe..2845136 100644 --- a/felsokning/services/plattform/server.mjs +++ b/felsokning/services/plattform/server.mjs @@ -16,6 +16,7 @@ // TILLATNA_URSPRUNG kommaseparerade ursprung för CORS (utelämnad = "*") // TILLAT_INTERNA_UPPSLAG "true" tillåter leverantörsuppslag mot privata nät // ECM_REGLER_FIL / INTEGRATIONER_FIL sökvägar till utbytbar konfiguration +// FAKTURERING_NYCKEL utfärdarens nyckel; utan den kan ingen faktura utfärdas // PORT default 8080 import { createServer } from "node:http"; @@ -33,6 +34,7 @@ import { ALLA_METODIKER } from "./metodiker.mjs"; import { oversikt as statistikOversikt } from "./statistik.mjs"; import { KATEGORIER, UTGAENDE, protokollTillHandelser, signeraLeverans } from "./integration.mjs"; import { enrading, sammanfatta } from "./sammanfattning.mjs"; +import { fakturabeteckning, fakturastatus, fakturera, granskaPeriod, kreditera } from "./fakturering.mjs"; import { MASKERAT, gallringsdatum, @@ -104,6 +106,29 @@ const MAX_BILAGA = 32 * 1024 * 1024; const TOKEN_LIVSTID_S = 12 * 60 * 60; const ROLLER = ["tekniker", "arbetsledare", "admin"]; +// ---- Utfärdaren av fakturor ------------------------------------------- +// +// En organisations administratör får INTE utfärda sin egen faktura, och +// definitivt inte bokföra den som betald. Rollerna i systemet +// (tekniker/arbetsledare/admin) beskriver arbetet i en verkstad — ingen +// av dem är motpart i avtalet. +// +// Utfärdaren är den som driver installationen, och den identifieras med +// en egen nyckel i stället för med ett konto. Saknas nyckeln kan ingen +// faktura utfärdas alls: fakturering fallerar stängt, av samma skäl som +// ett ärende utan evidens inte kan avslutas. +const FAKTURERING_NYCKEL = process.env.FAKTURERING_NYCKEL ?? ""; + +function arUtfardare(req) { + if (!FAKTURERING_NYCKEL) return false; + const given = String(req.headers["x-fakturering"] ?? ""); + const a = Buffer.from(given); + const b = Buffer.from(FAKTURERING_NYCKEL); + // Jämförelsen är tidskonstant, och längden jämförs först eftersom + // timingSafeEqual kastar på olika längd. + return a.length === b.length && timingSafeEqual(a, b); +} + const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 }); // Poolens uttömning är den vanligaste orsaken till att allt blir @@ -520,6 +545,49 @@ async function kontoGiltigt(anspr) { return (anspr.tv ?? 0) === rad.rows[0].token_version; } +// ---- Fakturanummer utan luckor ---------------------------------------- +// +// En sequence hade varit billigare, men den lämnar luckor: numret dras +// även när transaktionen rullas tillbaka. En nummerserie med hål i är +// inte ett bokföringsunderlag, den är en lista. +// +// Låset serialiserar utfärdandet mot alla repliker — inte bara mot den +// här processen, vilket är skillnaden mot att räkna i minnet. Fakturor +// utfärdas sällan, så kostnaden är teoretisk. +async function medFakturanummer(arbete) { + const klient = await pool.connect(); + try { + await klient.query("begin"); + await klient.query("lock table fakturor in exclusive mode"); + const rad = await klient.query(`select coalesce(max(nummer), 0) + 1 as nasta from fakturor`); + const resultat = await arbete(klient, Number(rad.rows[0].nasta)); + await klient.query("commit"); + return resultat; + } catch (fel) { + await klient.query("rollback").catch(() => {}); + throw fel; + } finally { + klient.release(); + } +} + +// Fakturan med sitt härledda tillstånd. Statusen finns inte i tabellen +// och kan inte finnas där: raden är oföränderlig. +async function fakturamedStatus(id) { + const rad = await pool.query( + `select f.id, f.organisation_id, f.beteckning, f.dokument, + coalesce(json_agg(json_build_object('typ', h.typ)) + filter (where h.id is not null), '[]') as handelser + from fakturor f + left join fakturahandelser h on h.faktura_id = f.id + where f.id = $1 + group by f.id`, + [id], + ); + if (rad.rowCount === 0) return null; + return { ...rad.rows[0], status: fakturastatus(rad.rows[0].handelser) }; +} + // Verifierar att ärendet tillhör användarens organisation. async function arendeIOrg(arendeId, organisationId) { const rader = await pool.query( @@ -924,6 +992,153 @@ export function skapaServer() { return svara(res, 200, { ok: true }); } + // ---- Fakturering: utfärdarens sida (ALVA-PROC-0001) ------------- + // + // Ligger före inloggningsspärren därför att utfärdaren inte är en + // användare i någon organisation. Nyckeln är hela behörigheten, och + // varje väg nedan kräver den innan den läser något. + if (req.method === "POST" && vag === "/api/fakturor") { + if (!arUtfardare(req)) return svara(res, 403, { error: "Kräver utfärdarens nyckel." }); + const kropp = await lasKropp(req); + const { organisation_id, period, utfardad } = kropp; + + // Beloppet tas aldrig emot. Kommer det ändå är det ett tecken på + // att anroparen tror sig kunna sätta det, och det ska avvisas + // högt i stället för att tigas ihjäl — samma hållning som mot + // okända fält i händelseschemat. + const forbjudna = ["rader", "netto", "moms", "totalt", "belopp", "status"].filter((n) => n in kropp); + if (forbjudna.length > 0) { + return svara(res, 400, { + error: `Beloppet härleds ur organisationens tillstånd och kan inte anges (${forbjudna.join(", ")}).`, + }); + } + + const periodfel = granskaPeriod(period); + if (periodfel) return svara(res, 400, { error: periodfel }); + const utfardadDag = utfardad ?? new Date().toISOString().slice(0, 10); + if (Number.isNaN(Date.parse(utfardadDag))) return svara(res, 400, { error: "Utfärdandedatum är ogiltigt." }); + + const org = await pool.query(`select id, namn, installningar from organisationer where id = $1`, [ + organisation_id, + ]); + if (org.rowCount === 0) return svara(res, 404, { error: "Organisationen finns inte." }); + + // Underlaget hämtas ur organisationens faktiska tillstånd, inte + // ur anropet: de konton som verkligen kan logga in, och de + // moduler som verkligen är påslagna. En summa som härleds kan + // inte säga emot verkligheten. + const aktiva = await pool.query( + `select count(*)::int as antal from anvandare where organisation_id = $1 and aktiv`, + [organisation_id], + ); + const installningar = org.rows[0].installningar ?? {}; + + const faktura = await medFakturanummer(async (klient, nummer) => { + const dokument = fakturera({ + nummer, + org: { namn: org.rows[0].namn, moduler: installningar.moduler ?? [] }, + aktiva: aktiva.rows[0].antal, + period, + utfardad: utfardadDag, + }); + const rad = await klient.query( + `insert into fakturor (organisation_id, nummer, beteckning, utfardad, forfaller, valuta, totalt, dokument) + values ($1, $2, $3, $4, $5, $6, $7, $8) returning id`, + [ + organisation_id, + nummer, + dokument.beteckning, + dokument.utfardad, + dokument.forfaller, + dokument.valuta, + dokument.totalt, + JSON.stringify(dokument), + ], + ); + return { id: rad.rows[0].id, ...dokument }; + }); + logga("info", "faktura utfärdad", { beteckning: faktura.beteckning, organisation: organisation_id }); + return svara(res, 201, faktura); + } + + // Betalning registreras av en människa när pengarna kommit in. + // Systemet påstår aldrig av sig självt att något är betalt. + // + // Ingen update: fakturaraden är oföränderlig. Betalningen är en + // egen post, och statusen härleds ur posterna. + const betaldVag = vag.match(/^\/api\/fakturor\/([0-9a-fA-F-]{36})\/betald$/); + if (req.method === "POST" && betaldVag) { + if (!arUtfardare(req)) return svara(res, 403, { error: "Kräver utfärdarens nyckel." }); + const { betaldatum, referens } = await lasKropp(req); + if (typeof referens !== "string" || referens.trim().length < 3) { + return svara(res, 400, { error: "En betalningsreferens krävs — annars går betalningen inte att spåra." }); + } + const dag = betaldatum ?? new Date().toISOString().slice(0, 10); + if (Number.isNaN(Date.parse(dag))) return svara(res, 400, { error: "Betaldatum är ogiltigt." }); + + const nuvarande = await fakturamedStatus(betaldVag[1]); + if (!nuvarande) return svara(res, 404, { error: "Fakturan finns inte." }); + // Enkelriktat: en betald faktura betalas inte igen, och en + // krediterad faktura betalas inte alls. + if (nuvarande.status !== "utfardad") { + return svara(res, 409, { error: `Fakturan är redan ${nuvarande.status}.` }); + } + await pool.query( + `insert into fakturahandelser (faktura_id, typ, intraffade, uppgift, registrerad_av) + values ($1, 'betald', $2, $3, $4)`, + [betaldVag[1], dag, referens.trim(), "Utfärdaren"], + ); + return svara(res, 200, { beteckning: nuvarande.beteckning, status: "betald" }); + } + + // Rättelse. En utfärdad faktura ändras aldrig — den bemöts av en + // kreditfaktura som pekar tillbaka, och den kräver ett skäl. + const krediteraVag = vag.match(/^\/api\/fakturor\/([0-9a-fA-F-]{36})\/kreditera$/); + if (req.method === "POST" && krediteraVag) { + if (!arUtfardare(req)) return svara(res, 403, { error: "Kräver utfärdarens nyckel." }); + const { orsak, utfardad } = await lasKropp(req); + const dag = utfardad ?? new Date().toISOString().slice(0, 10); + if (Number.isNaN(Date.parse(dag))) return svara(res, 400, { error: "Utfärdandedatum är ogiltigt." }); + + const nuvarande = await fakturamedStatus(krediteraVag[1]); + if (!nuvarande) return svara(res, 404, { error: "Fakturan finns inte." }); + if (nuvarande.status === "krediterad") return svara(res, 409, { error: "Fakturan är redan krediterad." }); + + // Skälet granskas av modulen, inte här: kravet på ett granskbart + // varför hör till faktureringen, inte till transportlagret. + const { kredit, fel } = kreditera(nuvarande.dokument, { nummer: 0, utfardad: dag, orsak }); + if (fel) return svara(res, 400, { error: fel }); + + const skapad = await medFakturanummer(async (klient, nummer) => { + const dokument = { ...kredit, beteckning: fakturabeteckning(nummer) }; + const rad = await klient.query( + `insert into fakturor (organisation_id, nummer, beteckning, utfardad, forfaller, valuta, totalt, krediterar, dokument) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9) returning id`, + [ + nuvarande.organisation_id, + nummer, + dokument.beteckning, + dokument.utfardad, + dokument.forfaller, + dokument.valuta, + dokument.totalt, + nuvarande.id, + JSON.stringify(dokument), + ], + ); + // Krediteringen noteras också på originalet, så dess status + // följer med utan att raden rörs. + await klient.query( + `insert into fakturahandelser (faktura_id, typ, intraffade, uppgift, registrerad_av) + values ($1, 'krediterad', $2, $3, $4)`, + [nuvarande.id, dag, orsak.trim(), "Utfärdaren"], + ); + return { id: rad.rows[0].id, ...dokument }; + }); + logga("info", "faktura krediterad", { krediterar: nuvarande.beteckning, ny: skapad.beteckning }); + return svara(res, 201, skapad); + } + // -- Skyddade endpoints (organisationsknutna) -- const anspr = kravAuth(req, hemlighet); if (!anspr?.org) return svara(res, 401, { error: "Inloggning krävs." }); @@ -931,6 +1146,39 @@ export function skapaServer() { return svara(res, 401, { error: "Sessionen gäller inte längre — logga in på nytt." }); } + // Organisationens egna fakturor. Läsning, aldrig mer: kunden ser + // vad den ska betala och varför, men utfärdar inget och bokför + // inget. Kommersiella uppgifter — därför administratör. + if (req.method === "GET" && vag === "/api/fakturor") { + if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." }); + const rader = await pool.query( + `select f.id, f.beteckning, f.utfardad, f.forfaller, f.valuta, f.totalt, + k.beteckning as krediterar, f.dokument, + coalesce(json_agg(json_build_object('typ', h.typ, 'intraffade', h.intraffade)) + filter (where h.id is not null), '[]') as handelser + from fakturor f + left join fakturor k on k.id = f.krediterar + left join fakturahandelser h on h.faktura_id = f.id + where f.organisation_id = $1 + group by f.id, k.beteckning + order by f.nummer desc`, + [anspr.org], + ); + return svara(res, 200, { + fakturor: rader.rows.map(({ handelser, dokument, ...f }) => ({ + ...f, + // Statusen lagras inte, den härleds. Se fakturastatus(). + status: fakturastatus(handelser), + rader: dokument.rader, + netto: dokument.netto, + moms: dokument.moms, + momssats: dokument.momssats, + period: dokument.period, + betalningssatt: dokument.betalningssatt, + })), + }); + } + // Användarhantering: endast systemadministratör, endast egen org. if (vag === "/api/anvandare") { // Läsning: admin + arbetsledare (behövs för omfördelning). @@ -1238,15 +1486,16 @@ export function skapaServer() { from felsokning_arenden a join felsokning_handelser h on h.arende_id = a.id where a.organisation_id = $1 - and a.id in ( - select arende_id from felsokning_handelser - where handelse->>'typ' = 'objekt_identifierat' - and upper(handelse->'objekt'->>'identifierare') = $2 - ) + and a.identifierare_index = $2 group by a.id order by a.skapad desc limit 20`, - [anspr.org, ident], + // Sökningen går mot det BLINDADE indexet, inte mot loggen. + // Identifieraren är krypterad i vila (krypto-shredding), så en + // jämförelse mot handelse->'objekt'->>'identifierare' kunde + // aldrig träffa — historiken svarade tomt på varje fordon, och + // gjorde det med 200. Indexet finns just för den här frågan. + [anspr.org, blindaIdentifierare(ident)], ); return svara(res, 200, { arenden: rader.rows }); }