Abonnemang: gratis start, månadsfaktura, och en låsning som aldrig rör historiken

Kontot startar på Free och stannar där tills någon väljer annat. En
provperiod som tyst börjar kosta är samma sorts fälla produkten finns för
att undvika. Fakturan skapas ändå vid registreringen — på noll kronor —
därför att den etablerar serien och visar kunden exakt vad den inte
betalar för.

Fyra nivåer: Free (hela metoden, två konton), Standard, Premium,
Enterprise. Enterprise har inget listpris i stället för ett påhittat.
Basavgift plus per aktiv användare, och nivån är en HÄNDELSE — vilken
nivå som gällde när en faktura utfärdades måste gå att svara på i
efterhand, och en kolumn som skrivits över kan inte svara.

Månadsjobbet är idempotent på perioden: kör det två gånger samma dygn och
den andra körningen fakturerar ingenting. Ett fakturajobb som
dubbelfakturerar vid en omstart är värre än ett som inte kör alls.
Perioden räknas från registreringsdagen, inte från den första i månaden —
den som registrerar sig den 20:e ska inte få en faktura på elva dagar som
sin första upplevelse av produkten.

PDF utan beroenden. Kravet var inga beroenden, och ett PDF-bibliotek drar
in fem till för att rita text i två spalter. PDF är ett textformat;
generatorn är tvåhundra rader som går att läsa. Renderad och verifierad i
Chromium. Kunden hämtar fakturan själv eller anger en adress — bägge
vägar leder till samma dokument, och e-post är ett tillägg, inte den enda
vägen till sitt eget underlag.

---- Låsningen och dess gräns ------------------------------------------

Vi litar på kunden: inget kort, ingen förskottsbetalning, ingen spärr
innan någon fått veta. Förfallen faktura ger synlig nedräkning i 14 dagar
och sedan stopp för nya ärenden.

Men läsning och export är sanna i ALLA tillstånd, och det är inte
generositet. Ärendeloggen är verkstadens underlag i en garanti- eller
försäkringstvist som gäller DERAS kund — en tredje part utan del i vår
obetalda faktura. Att göra det oåtkomligt vore att använda någon annans
rättsliga ställning som påtryckningsmedel. Prövat från flera håll,
eftersom det är en regel som är lätt att tumma på under press.

En nollfaktura kan aldrig låsa någon. Free fakturerar noll, och en
obetald nollfaktura är en bokföringspost, inte en skuld.

---- Tre fynd som testerna grävde fram ----------------------------------

1. En kuverterad nyckel gjorde HELA organisationens övriga ärenden
   oläsbara när huvudnyckeln saknades — nycklarFor laddar alla nycklar,
   och en enda som inte gick att öppna kastade. En delvis migrerad
   organisation blev alltså helt stum av ett fel som gällde ett ärende.
   Nu hoppas den över, med varning i loggen, och fälten maskeras — vilket
   dessutom är rätt utfall i sak: det är precis vad krypto-shredding
   lämnar efter sig.

2. Integrationstestet försökte backdatera abonnemangets registrering och
   stoppades av min egen trigger. Att testet fick böja sig för regeln och
   inte tvärtom är poängen med regeln.

3. Palettestet räknade upp färgerna som en andra lista — samma dubblering
   som M-7 handlade om. Det läser dem nu ur FARG, med ett tak på åtta
   värden som motvikt: en palett som växer förbi det är en färglåda.

491 enhetstester · 179 integrationskontroller · genomgången 4/4 ·
portalspärren 18/18.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-06 15:55:13 +00:00
parent 86558120e4
commit 5579006e79
19 changed files with 1479 additions and 10 deletions
+1
View File
@@ -0,0 +1 @@
../gemensam/abonnemang.mjs
+1
View File
@@ -0,0 +1 @@
../gemensam/fakturapdf.mjs
@@ -845,14 +845,27 @@ DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \
JWT_SECRET=integrationshemlighet PORT=$APPPORT node server.mjs >/dev/null 2>&1 &
SERVER_PID=$!
sleep 1
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden/arende-kuvert/handelser" -H "Authorization: Bearer $TOKEN_A")
kontroll "utan huvudnyckel lamnas inget ut" "$KOD" "500"
# Utan huvudnyckeln MASKERAS uppgiften — anropet kraschar inte.
#
# Forsta versionen av det har testet krävde 500, och det gick igenom
# eftersom oppnaKuvert kastade. Men eftersom nycklarFor laddar HELA
# organisationens nycklar gjorde en enda kuverterad nyckel varje ANNAT
# arende i organisationen olasbart — en delvis migrerad organisation blev
# helt stum av ett fel som bara gallde ett arende.
#
# Maskering ar dessutom ratt utfall i sak: det ar precis vad
# krypto-shredding lamnar efter sig.
SVAR=$(curl -s "$BAS/api/arenden/arende-kuvert/handelser" -H "Authorization: Bearer $TOKEN_A")
kontroll "utan huvudnyckel maskeras uppgiften" \
"$(echo "$SVAR" | falt '.handelser[0].handelse.objekt.identifierare')" "[raderat på begäran]"
kontroll "men anropet gar fortfarande igenom" "$(echo "$SVAR" | falt .handelser.length)" "1"
# 19. Support och felanmälan (ALVA-PROC-0050)
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 SUPPORT_NYCKEL=supportnyckel node server.mjs &
JWT_SECRET=integrationshemlighet PORT=$APPPORT SUPPORT_NYCKEL=supportnyckel \
FAKTURERING_NYCKEL=utfardarnyckel node server.mjs &
SERVER_PID=$!
sleep 1
@@ -906,4 +919,94 @@ else
echo "✓ supportarenden gar inte att skriva om"
fi
# 20. Abonnemang (ALVA-PROC-0002)
AB=$(curl -s "$BAS/api/abonnemang" -H "Authorization: Bearer $TOKEN_A")
kontroll "registrering startar pa Free" "$(echo "$AB" | falt .niva)" "free"
kontroll "nystartat abonnemang ar aktivt" "$(echo "$AB" | falt .tillstand)" "aktiv"
kontroll "lasning ar tillaten" "$(echo "$AB" | falt .behorighet.lasa)" "true"
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/abonnemang/niva" \
-H "Authorization: Bearer $TOKEN_J" -H 'Content-Type: application/json' -d '{"niva":"premium"}')
kontroll "tekniker kan inte byta niva" "$KOD" "403"
curl -s -X POST "$BAS/api/abonnemang/niva" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' -d '{"niva":"standard"}' >/dev/null
kontroll "admin byter niva" "$(curl -s "$BAS/api/abonnemang" -H "Authorization: Bearer $TOKEN_A" | falt .niva)" "standard"
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/abonnemang/fakturaepost" \
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"epost":"inte en adress"}')
kontroll "ogiltig fakturaepost avvisas" "$KOD" "400"
curl -s -X POST "$BAS/api/abonnemang/fakturaepost" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' -d '{"epost":"faktura@verkstadnord.se"}' >/dev/null
kontroll "fakturaepost sparas" \
"$(curl -s "$BAS/api/abonnemang" -H "Authorization: Bearer $TOKEN_A" | falt .fakturaepost)" "faktura@verkstadnord.se"
# Tom adress ar ett giltigt val: kunden hamtar fakturan sjalv.
curl -s -X POST "$BAS/api/abonnemang/fakturaepost" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' -d '{"epost":""}' >/dev/null
kontroll "tom fakturaepost ar tillaten" \
"$(curl -s "$BAS/api/abonnemang" -H "Authorization: Bearer $TOKEN_A" | falt '.fakturaepost')" ""
# Nivan ar en HANDELSE — historiken finns kvar.
NIVAER=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
-tAc "select count(*) from abonnemangshandelser h join organisationer o on o.id = h.organisation_id
where h.typ='niva' and o.namn='Verkstad A'")
kontroll "nivabytet lamnade spar" "$NIVAER" "2"
# 21. Fakturan som PDF, utan beroenden
FAKTURA_PDF_ID=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
-tAc "select id from fakturor order by nummer limit 1")
curl -s "$BAS/api/fakturor/$FAKTURA_PDF_ID/pdf" -H "Authorization: Bearer $TOKEN_A" -o /tmp/f.pdf
kontroll "PDF-huvudet stammer" "$(head -c 8 /tmp/f.pdf)" "%PDF-1.4"
case "$(tail -c 6 /tmp/f.pdf)" in
*"%%EOF"*) echo "✓ PDF avslutas korrekt" ;;
*) echo "✗ PDF saknar EOF"; exit 1 ;;
esac
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/fakturor/$FAKTURA_PDF_ID/pdf" -H "Authorization: Bearer $TOKEN_B")
kontroll "org B kommer inte at org A:s faktura-PDF" "$KOD" "404"
# 22. Manadsfaktureringen ar idempotent pa perioden
# `registrerad` gar inte att andra — triggern skyddar startdatumet, och
# den fangade det har testet nar det forsokte backdatera. Perioden flyttas
# i stallet via senast_fakturerad, som ar en HARLEDD kolumn och darfor
# skrivbar. Att testet fick boja sig for regeln och inte tvartom ar
# poangen med regeln.
PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
-qc "update abonnemang set senast_fakturerad = current_date - 60"
DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \
node manadsfakturering.mjs > /tmp/mf1.json
ANTAL1=$(cat /tmp/mf1.json | falt .antal)
if [ "$ANTAL1" -ge 1 ]; then echo "✓ manadsjobbet fakturerade $ANTAL1"; else echo "✗ manadsjobbet fakturerade inget"; exit 1; fi
DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \
node manadsfakturering.mjs > /tmp/mf2.json
kontroll "andra korningen dubbelfakturerar inte" "$(cat /tmp/mf2.json | falt .antal)" "0"
# 23. Spärren: last abonnemang stoppar nya arenden men aldrig lasning
# Fakturor ar oforanderliga, sa forfallet kan inte backas in i en
# befintlig. Det utfardas i stallet en faktura med gammalt
# utfardandedatum — forfallodagen harleds ur det, och blir da passerad.
ORG_A_AB=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
-tAc "select id from organisationer where namn='Verkstad A'")
curl -s -X POST "$BAS/api/fakturor" -H 'Content-Type: application/json' -H 'X-Fakturering: utfardarnyckel' \
-d "{\"organisation_id\":\"$ORG_A_AB\",\"period\":{\"fran\":\"2025-01-01\",\"till\":\"2025-01-31\"},\"utfardad\":\"2025-01-01\"}" >/dev/null
AB=$(curl -s "$BAS/api/abonnemang" -H "Authorization: Bearer $TOKEN_A")
kontroll "en langt forfallen faktura laser abonnemanget" "$(echo "$AB" | falt .tillstand)" "last"
kontroll "beskedet namner fakturan" "$(echo "$AB" | falt '.besked.includes("ALVA-INV") ? "ja" : "nej"')" "ja"
# Sparren stoppar nya arenden.
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' -d '{"id":"arende-last","nummer":99,"skapad":"2026-08-06T10:00:00Z"}')
kontroll "last abonnemang stoppar nya arenden" "$KOD" "402"
# Men ALDRIG lasningen. Det ar regeln som inte far tummas pa: loggen ar
# verkstadens underlag i en tvist som galler DERAS kund.
kontroll "lasning ar sann aven i last lage" "$(echo "$AB" | falt .behorighet.lasa)" "true"
kontroll "export ar sann aven i last lage" "$(echo "$AB" | falt .behorighet.exportera)" "true"
# Statuskoden, inte kroppen: det som ska bevisas ar att lasningen slapps
# igenom i last lage, och en kropp som tolkas fel skulle dolja det.
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A")
kontroll "historiken ar fortfarande lasbar i last lage" "$KOD" "200"
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/fakturor" -H "Authorization: Bearer $TOKEN_A")
kontroll "fakturorna gar att lasa i last lage" "$KOD" "200"
echo "Integrationstest: allt grönt"
@@ -0,0 +1,118 @@
// ALVA-PROC-0002 · Månatlig fakturering.
//
// Kör som eget jobb, inte i webbtjänstens process — av samma skäl som
// gallringen: en körning som avbryts mitt i ska kunna köras om, och den
// ska synas som en egen körning i driften.
//
// Jobbet är idempotent på perioden. Kör det två gånger samma dygn och den
// andra körningen fakturerar ingenting, eftersom `senast_fakturerad`
// redan flyttats. Det är viktigare än det låter: ett fakturajobb som
// dubbelfakturerar vid en omstart är värre än ett som inte kör alls.
//
// node manadsfakturering.mjs verkställer
// node manadsfakturering.mjs --torrkor räknar bara
import pg from "pg";
import { fakturera } from "./fakturering.mjs";
import { forfallenFakturering, nivaFor } from "./abonnemang.mjs";
/**
* Fakturerar alla organisationer vars period löpt ut.
*
* Nivån hämtas ur abonnemangshändelserna — inte ur en kolumn — så att en
* faktura alltid speglar den nivå som gällde när den utfärdades.
*/
export async function fakturerManaden(pool, { nu = new Date(), torrkor = false } = {}) {
const organisationer = await pool.query(
`select a.organisation_id, a.registrerad, a.senast_fakturerad, o.namn, o.installningar,
(select varde from abonnemangshandelser
where organisation_id = a.organisation_id and typ = 'niva'
order by skapad desc limit 1) as niva
from abonnemang a join organisationer o on o.id = a.organisation_id`,
);
const utfardade = [];
for (const rad of organisationer.rows) {
const period = forfallenFakturering(rad.registrerad, rad.senast_fakturerad, nu);
if (!period) continue;
const niva = nivaFor(rad.niva ?? "free");
// Enterprise prissätts per avtal och faktureras inte av jobbet — ett
// påhittat belopp vore värre än ingen faktura.
if (niva.basavgift === null) continue;
const aktiva = await pool.query(
`select count(*)::int as antal from anvandare where organisation_id = $1 and aktiv`,
[rad.organisation_id],
);
// Prislistan byggs ur NIVÅN, inte ur den globala listan: det är
// abonnemanget kunden valt som avgör vad en användare kostar.
const prislista = {
valuta: "SEK",
plattform_ar: niva.basavgift * 12,
anvandare_manad: niva.anvandaravgift,
moduler: {},
momssats: 0.25,
betalningsvillkor: 30,
};
if (torrkor) {
utfardade.push({ organisation: rad.namn, period, niva: niva.id, torrkor: true });
continue;
}
const klient = await pool.connect();
try {
await klient.query("begin");
await klient.query("lock table fakturor in exclusive mode");
const nasta = await klient.query(`select coalesce(max(nummer), 0) + 1 as n from fakturor`);
const nummer = Number(nasta.rows[0].n);
const dokument = fakturera({
nummer,
org: { namn: rad.namn, moduler: [] },
aktiva: aktiva.rows[0].antal,
period,
utfardad: nu.toISOString().slice(0, 10),
prislista,
});
await klient.query(
`insert into fakturor (organisation_id, nummer, beteckning, utfardad, forfaller, valuta, totalt, dokument)
values ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
rad.organisation_id,
nummer,
dokument.beteckning,
dokument.utfardad,
dokument.forfaller,
dokument.valuta,
dokument.totalt,
JSON.stringify(dokument),
],
);
await klient.query(`update abonnemang set senast_fakturerad = $2 where organisation_id = $1`, [
rad.organisation_id,
period.fran,
]);
await klient.query("commit");
utfardade.push({ organisation: rad.namn, beteckning: dokument.beteckning, totalt: dokument.totalt, niva: niva.id });
} catch (fel) {
await klient.query("rollback").catch(() => {});
throw fel;
} finally {
klient.release();
}
}
return { antal: utfardade.length, utfardade, torrkor };
}
if (import.meta.url === `file://${process.argv[1]}`) {
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 2 });
try {
const utfall = await fakturerManaden(pool, { torrkor: process.argv.includes("--torrkor") });
console.log(JSON.stringify({ nivå: "info", meddelande: "månadsfakturering", tid: new Date().toISOString(), ...utfall }));
} finally {
await pool.end();
}
}
+154 -1
View File
@@ -39,6 +39,8 @@ import { KATEGORIER, UTGAENDE, protokollTillHandelser, signeraLeverans } from ".
import { enrading, sammanfatta } from "./sammanfattning.mjs";
import { fakturabeteckning, fakturastatus, fakturera, granskaPeriod, kreditera } from "./fakturering.mjs";
import { STATUSAR, granskaAnmalan, sammanhang as byggSammanhang, supportbeteckning, supportstatus } from "./support.mjs";
import { NIVAER, abonnemangstillstand, behorighet, besked, forfallenFakturering, nivaFor } from "./abonnemang.mjs";
import { fakturaPdf } from "./fakturapdf.mjs";
import {
MASKERAT,
gallringsdatum,
@@ -573,7 +575,38 @@ async function nycklarFor(orgId) {
`select id, nyckel from personnycklar where organisation_id = $1`,
[orgId],
);
return new Map(rader.rows.map((r) => [r.id, oppnaKuvert(r.nyckel)]));
// En nyckel som inte går att öppna hoppas över — den fäller inte de
// andra.
//
// Fyndet kom ur integrationstestet: `oppnaKuvert` kastade, och eftersom
// den här funktionen laddar HELA organisationens nycklar gjorde en enda
// kuverterad nyckel varje annat ärende i organisationen oläsbart när
// huvudnyckeln saknades. En delvis migrerad organisation blev alltså
// helt stum, av ett fel som bara gällde ett ärende.
//
// Att hoppa över är dessutom rätt utfall i sak: en nyckel som inte går
// att öppna är precis vad krypto-shredding lämnar efter sig, och
// öppnaHändelse maskerar då fälten — vilket är vad som ska hända.
//
// Men tyst får det inte vara. Saknad huvudnyckel är ett
// konfigurationsfel, och ett sådant ska synas i driften.
const nycklar = new Map();
let ejOppnade = 0;
for (const r of rader.rows) {
try {
nycklar.set(r.id, oppnaKuvert(r.nyckel));
} catch {
ejOppnade += 1;
}
}
if (ejOppnade > 0) {
logga("varning", "personnycklar kunde inte öppnas", {
organisation: orgId,
antal: ejOppnade,
konsekvens: "Berörda fält maskeras. Kontrollera PERSONNYCKEL_HUVUD.",
});
}
return nycklar;
}
/**
@@ -703,6 +736,49 @@ async function medSupportnummer(arbete) {
}
}
/**
* Abonnemangets nuvarande nivå och tillstånd.
*
* Nivån är senaste nivåhändelsen; saknas den är den Free. Tillståndet
* härleds ur organisationens obetalda, förfallna fakturor.
*/
async function abonnemanget(orgId, nu = new Date()) {
const rad = await pool.query(
`select a.registrerad, a.senast_fakturerad,
(select varde from abonnemangshandelser
where organisation_id = a.organisation_id and typ = 'niva'
order by skapad desc limit 1) as niva,
(select varde from abonnemangshandelser
where organisation_id = a.organisation_id and typ = 'fakturaepost'
order by skapad desc limit 1) as fakturaepost
from abonnemang a where a.organisation_id = $1`,
[orgId],
);
if (rad.rowCount === 0) return null;
const fakturor = await pool.query(
`select f.beteckning, f.forfaller, f.totalt,
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.organisation_id = $1 group by f.id`,
[orgId],
);
const status = abonnemangstillstand(
fakturor.rows.map((f) => ({ ...f, status: fakturastatus(f.handelser) })),
nu,
);
const niva = nivaFor(rad.rows[0].niva ?? "free");
return {
...rad.rows[0],
niva: niva.id,
nivanamn: niva.namn,
...status,
behorighet: behorighet(status.tillstand),
besked: besked(status),
};
}
// 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) {
@@ -1007,6 +1083,17 @@ export function skapaServer() {
await klientDb.query("rollback");
return svara(res, 409, { error: "E-postadressen är redan registrerad." });
}
// Abonnemanget startar på Free vid registreringen. Ingen provperiod
// som tyst börjar kosta — kontot stannar på Free tills någon väljer
// annat (ALVA-PROC-0002).
await klientDb.query(
`insert into abonnemang (organisation_id) values ($1) on conflict do nothing`,
[org.rows[0].id],
);
await klientDb.query(
`insert into abonnemangshandelser (organisation_id, typ, varde) values ($1, 'niva', 'free')`,
[org.rows[0].id],
);
await klientDb.query("commit");
return loggaIn(res, { ...rad.rows[0], org_namn: org.rows[0].namn }, hemlighet);
} catch (fel) {
@@ -1336,6 +1423,64 @@ export function skapaServer() {
return svara(res, 401, { error: "Sessionen gäller inte längre — logga in på nytt." });
}
// ---- Abonnemang (ALVA-PROC-0002) --------------------------------
if (req.method === "GET" && vag === "/api/abonnemang") {
const a = await abonnemanget(anspr.org);
if (!a) return svara(res, 404, { error: "Inget abonnemang är registrerat." });
return svara(res, 200, { ...a, nivaer: NIVAER });
}
if (req.method === "POST" && vag === "/api/abonnemang/niva") {
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
const { niva } = await lasKropp(req);
if (!NIVAER.some((n) => n.id === niva)) return svara(res, 400, { error: "Okänd nivå." });
// Nivån är en HÄNDELSE. Vilken nivå som gällde när en faktura
// utfärdades måste gå att svara på i efterhand, och en kolumn som
// skrivits över kan inte svara.
await pool.query(
`insert into abonnemangshandelser (organisation_id, typ, varde, satt_av) values ($1, 'niva', $2, $3)`,
[anspr.org, niva, anspr.sub],
);
logga("info", "abonnemangsnivå ändrad", { organisation: anspr.org, niva });
return svara(res, 200, { ok: true, niva });
}
if (req.method === "POST" && vag === "/api/abonnemang/fakturaepost") {
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
const { epost } = await lasKropp(req);
// Tom sträng är ett giltigt val: kunden hämtar fakturan själv.
const rensad = String(epost ?? "").trim();
if (rensad && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(rensad)) {
return svara(res, 400, { error: "Ogiltig e-postadress." });
}
await pool.query(
`insert into abonnemangshandelser (organisation_id, typ, varde, satt_av) values ($1, 'fakturaepost', $2, $3)`,
[anspr.org, rensad, anspr.sub],
);
return svara(res, 200, { ok: true, epost: rensad });
}
// Fakturan som PDF. Kunden hämtar den själv — utskicket per e-post
// är ett tillägg, inte den enda vägen till sitt eget underlag.
const pdfVag = vag.match(/^\/api\/fakturor\/([0-9a-fA-F-]{36})\/pdf$/);
if (req.method === "GET" && pdfVag) {
const rad = await pool.query(
`select organisation_id, beteckning, dokument from fakturor where id = $1`,
[pdfVag[1]],
);
if (rad.rowCount === 0 || rad.rows[0].organisation_id !== anspr.org) {
return svara(res, 404, { error: "Fakturan är inte tillgänglig." });
}
const pdf = fakturaPdf(rad.rows[0].dokument);
res.writeHead(200, {
"Content-Type": "application/pdf",
"Content-Length": pdf.length,
"Content-Disposition": `attachment; filename="${rad.rows[0].beteckning}.pdf"`,
...korsHuvuden(res),
});
return res.end(pdf);
}
// ---- Support och felanmälan (ALVA-PROC-0050) -------------------
//
// ALLA inloggade får anmäla, inklusive teknikern. En tröskel här
@@ -1870,6 +2015,14 @@ export function skapaServer() {
}
if (req.method === "POST" && vag === "/api/arenden") {
// Låst abonnemang stoppar NYA ärenden. Det rör aldrig historiken —
// se abonnemang.mjs: ärendeloggen är verkstadens underlag i en
// tvist som gäller DERAS kund, och den tredje parten har ingen del
// i vår obetalda faktura.
const ab = await abonnemanget(anspr.org);
if (ab && !ab.behorighet.nyttArende) {
return svara(res, 402, { error: ab.besked, tillstand: ab.tillstand });
}
const { id, nummer, skapad, delningskod, metodikId } = await lasKropp(req);
if (typeof id !== "string" || typeof nummer !== "number" || !skapad) {
return svara(res, 400, { error: "Ogiltigt ärende." });