diff --git a/felsokning/app/e2e/genomgang.mjs b/felsokning/app/e2e/genomgang.mjs index b6d4b1b..db2a3a6 100644 --- a/felsokning/app/e2e/genomgang.mjs +++ b/felsokning/app/e2e/genomgang.mjs @@ -481,6 +481,7 @@ try { "/alva/portal/kunskapskallor", "/alva/portal/integration", "/alva/portal/fakturor", + "/alva/portal/abonnemang", "/alva/portal/garantier", "/alva/portal/forsakring", "/alva/portal/support", diff --git a/felsokning/app/e2e/portalsparr.mjs b/felsokning/app/e2e/portalsparr.mjs index 46eca1f..1680698 100644 --- a/felsokning/app/e2e/portalsparr.mjs +++ b/felsokning/app/e2e/portalsparr.mjs @@ -35,6 +35,7 @@ const VYER = [ "/alva/portal/kunskapskallor", "/alva/portal/integration", "/alva/portal/fakturor", + "/alva/portal/abonnemang", "/alva/portal/garantier", "/alva/portal/forsakring", "/alva/portal/support", diff --git a/felsokning/app/src/App.tsx b/felsokning/app/src/App.tsx index ebc0623..69409a9 100644 --- a/felsokning/app/src/App.tsx +++ b/felsokning/app/src/App.tsx @@ -29,6 +29,7 @@ import AlvaKunskapskallor from "./pages/alva/Kunskapskallor"; import AlvaAnalys from "./pages/alva/Analys"; import AlvaFakturor from "./pages/alva/Fakturor"; import AlvaIntegration from "./pages/alva/Integration"; +import AlvaAbonnemang from "./pages/alva/Abonnemang"; import AlvaGarantier from "./pages/alva/Garantier"; import AlvaImpressum from "./pages/alva/Impressum"; import AlvaUtgavor from "./pages/alva/Utgavor"; @@ -83,6 +84,7 @@ function AppContent() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/felsokning/app/src/alva/komponenter.tsx b/felsokning/app/src/alva/komponenter.tsx index 3cfc8ce..ec9cc41 100644 --- a/felsokning/app/src/alva/komponenter.tsx +++ b/felsokning/app/src/alva/komponenter.tsx @@ -30,6 +30,18 @@ export const FARG = { background: "#F6F7F8", blue: "#005CA9", white: "#FFFFFF", + + // ---- Allvarlighetsgrad ---------------------------------------------- + // + // Semantik, inte accent. Blått är ytans enda accent och ska förbli det; + // de här två säger något annat — att något är förfallet respektive + // stoppat — och den skillnaden får inte bäras av enbart text. + // + // Oxidtonerna är de som redan används i felsökningsgränssnittet för + // återkallat och spärrat. Att införa nya nyanser för samma betydelse + // hade gett systemet två språk för samma sak. + varning: "#8A5A00", + stoppat: "#8B1A1A", } as const; // ---- Typografi -------------------------------------------------------- diff --git a/felsokning/app/src/felsokning/__tests__/abonnemang.test.ts b/felsokning/app/src/felsokning/__tests__/abonnemang.test.ts new file mode 100644 index 0000000..6393787 --- /dev/null +++ b/felsokning/app/src/felsokning/__tests__/abonnemang.test.ts @@ -0,0 +1,224 @@ +// @vitest-environment node +// ALVA-PROC-0002 · Abonnemang. +// +// Det som prövas hårdast här är LÅSNINGENS GRÄNS. Att stänga nya ärenden +// vid utebliven betalning är rimligt; att göra historiken oåtkomlig är +// det inte, eftersom den är verkstadens underlag i en tvist som gäller +// deras kund — en tredje part utan del i vår obetalda faktura. +// +// Det är en regel som är lätt att tumma på under press, och därför +// prövas den från flera håll. +import { describe, expect, it } from "vitest"; +import { + NIVAER, + RESPITDAGAR, + abonnemangstillstand, + behorighet, + besked, + forfallenFakturering, + nastaPeriod, + nivaFor, +} from "../../../../services/gemensam/abonnemang.mjs"; +import { fakturaPdf } from "../../../../services/gemensam/fakturapdf.mjs"; +import { fakturera } from "../../../../services/gemensam/fakturering.mjs"; + +const NU = new Date("2026-08-06T12:00:00Z"); +const dagarSedan = (n: number) => new Date(NU.getTime() - n * 86_400_000).toISOString().slice(0, 10); + +describe("låsningen rör aldrig historiken", () => { + it("läsning och export är sanna i ALLA tillstånd", () => { + for (const t of ["aktiv", "varning", "last"]) { + const b = behorighet(t); + expect(b.lasa, t).toBe(true); + expect(b.exportera, t).toBe(true); + } + }); + + it("låst stoppar nya ärenden och dokumentation, men inget mer", () => { + const b = behorighet("last"); + expect(b.nyttArende).toBe(false); + expect(b.dokumentera).toBe(false); + expect(b.avsluta).toBe(false); + expect(b.lasa).toBe(true); + expect(b.exportera).toBe(true); + }); + + it("beskedet säger uttryckligen att underlaget tillhör kundens kund", () => { + const text = besked({ tillstand: "last", aldsta: "ALVA-INV-0007", dagarKvar: 0 }); + expect(text).toMatch(/belongs to your customer/i); + }); +}); + +describe("tillståndet härleds ur fakturorna", () => { + it("inga förfallna fakturor är aktivt", () => { + expect(abonnemangstillstand([], NU).tillstand).toBe("aktiv"); + expect( + abonnemangstillstand([{ beteckning: "A", forfaller: dagarSedan(-5), totalt: 1000, status: "utfardad" }], NU) + .tillstand, + ).toBe("aktiv"); + }); + + it("en betald faktura kan aldrig utlösa varning", () => { + expect( + abonnemangstillstand([{ beteckning: "A", forfaller: dagarSedan(60), totalt: 1000, status: "betald" }], NU) + .tillstand, + ).toBe("aktiv"); + }); + + it("en NOLLFAKTURA låser aldrig någon", () => { + // Free-nivån fakturerar noll. En obetald nollfaktura är inte en skuld + // utan en bokföringspost, och att låsa någon för den vore absurt. + expect( + abonnemangstillstand([{ beteckning: "A", forfaller: dagarSedan(90), totalt: 0, status: "utfardad" }], NU) + .tillstand, + ).toBe("aktiv"); + }); + + it("förfallen men inom respit ger varning med nedräkning", () => { + const s = abonnemangstillstand( + [{ beteckning: "ALVA-INV-0007", forfaller: dagarSedan(4), totalt: 5000, status: "utfardad" }], + NU, + ); + expect(s.tillstand).toBe("varning"); + expect(s.dagarKvar).toBe(RESPITDAGAR - 4); + expect(besked(s)).toContain(String(RESPITDAGAR - 4)); + }); + + it("efter respiten låses det", () => { + const s = abonnemangstillstand( + [{ beteckning: "A", forfaller: dagarSedan(RESPITDAGAR + 1), totalt: 5000, status: "utfardad" }], + NU, + ); + expect(s.tillstand).toBe("last"); + expect(s.dagarKvar).toBe(0); + }); + + it("den ÄLDSTA förfallna styr nedräkningen", () => { + // Betalar kunden den senaste men inte den första har respiten ändå + // löpt sedan den första — annars kunde man skjuta upp låsningen i + // evighet genom att betala varannan faktura. + const s = abonnemangstillstand( + [ + { beteckning: "GAMMAL", forfaller: dagarSedan(30), totalt: 5000, status: "utfardad" }, + { beteckning: "NY", forfaller: dagarSedan(1), totalt: 5000, status: "utfardad" }, + ], + NU, + ); + expect(s.tillstand).toBe("last"); + expect(s.aldsta).toBe("GAMMAL"); + expect(s.belopp).toBe(10_000); + }); + + it("ett aktivt abonnemang får inget besked — tystnad är rätt svar", () => { + expect(besked({ tillstand: "aktiv" })).toBeNull(); + }); +}); + +describe("nivåerna", () => { + it("Free är gratis och begränsad till två konton", () => { + const f = nivaFor("free"); + expect(f.basavgift).toBe(0); + expect(f.anvandaravgift).toBe(0); + expect(f.maxAnvandare).toBe(2); + }); + + it("Free har hela metoden — begränsningen ligger i skalan, inte i kvaliteten", () => { + // En gratisnivå som ger en sämre METOD vore en annan produkt. + const f = nivaFor("free"); + expect(f.ingar.join(" ")).toMatch(/complete ALVA methodology/i); + expect(f.ingar.join(" ")).toMatch(/evidence report/i); + }); + + it("Enterprise har inget listpris i stället för ett påhittat", () => { + const e = nivaFor("enterprise"); + expect(e.basavgift).toBeNull(); + expect(e.anvandaravgift).toBeNull(); + }); + + it("okänd nivå faller tillbaka på Free, aldrig på en betald", () => { + expect(nivaFor("hittepa").id).toBe("free"); + expect(nivaFor(undefined).id).toBe("free"); + }); + + it("varje nivå säger både vad som ingår och vad som inte gör det", () => { + for (const n of NIVAER as { id: string; ingar: string[]; saknas: string[] }[]) { + expect(n.ingar.length, n.id).toBeGreaterThan(0); + if (n.id !== "enterprise") expect(n.saknas.length, n.id).toBeGreaterThan(0); + } + }); +}); + +describe("perioden räknas från registreringsdagen", () => { + it("första perioden börjar den dag man registrerade sig", () => { + // En organisation som registrerar sig den 20:e ska inte få en faktura + // på elva dagar som sin första upplevelse av produkten. + const p = nastaPeriod("2026-03-20T09:00:00Z"); + expect(p.fran).toBe("2026-03-20"); + expect(p.till).toBe("2026-04-19"); + }); + + it("nästa period börjar en månad efter den förra", () => { + const p = nastaPeriod("2026-03-20T09:00:00Z", "2026-03-20"); + expect(p.fran).toBe("2026-04-20"); + }); + + it("en period som inte börjat faktureras inte", () => { + expect(forfallenFakturering("2026-03-20", "2026-08-20", NU)).toBeNull(); + }); + + it("en period som börjat faktureras", () => { + expect(forfallenFakturering("2026-03-20", "2026-06-20", NU)).not.toBeNull(); + }); + + it("ett trasigt datum ger null i stället för en påhittad period", () => { + expect(nastaPeriod("inte ett datum")).toBeNull(); + }); +}); + +describe("fakturan som PDF, utan beroenden", () => { + const faktura = fakturera({ + nummer: 1, + org: { namn: "Verkstad Nord AB", moduler: [] }, + aktiva: 3, + period: { fran: "2026-01-01", till: "2026-01-31" }, + utfardad: "2026-01-15", + }); + + it("är en giltig PDF med korsreferenstabell", () => { + const pdf = fakturaPdf(faktura); + const text = pdf.toString("latin1"); + expect(text.startsWith("%PDF-1.4")).toBe(true); + expect(text).toContain("startxref"); + expect(text.trimEnd().endsWith("%%EOF")).toBe(true); + }); + + it("byteoffsetarna i xref pekar på objekten", () => { + // Den enda delen av formatet som inte förlåter slarv: en läsare som + // inte hittar objektet visar ingenting alls, inte en trasig sida. + const text = fakturaPdf(faktura).toString("latin1"); + const offsetar = [...text.matchAll(/^(\d{10}) 00000 n $/gm)].map((m) => Number(m[1])); + expect(offsetar.length).toBe(6); + offsetar.forEach((o, i) => { + expect(text.slice(o).startsWith(`${i + 1} 0 obj`), `objekt ${i + 1}`).toBe(true); + }); + }); + + it("bär beteckningen och totalen", () => { + const text = fakturaPdf(faktura).toString("latin1"); + expect(text).toContain("ALVA-INV-0001"); + expect(text).toContain("Verkstad Nord AB"); + }); + + it("svenska tecken överlever, och det som inte ryms ersätts hellre än blir kråkfötter", () => { + const med = fakturaPdf({ ...faktura, organisation: "Verkstad Öst — Ängelholm 中文" }); + const text = med.toString("latin1"); + expect(text).toContain("Verkstad Öst - Ängelholm .."); + }); + + it("tomma avsändaruppgifter utelämnas i stället för att skrivas som osatta", () => { + // En faktura med "— ej satt" i adressfältet är sämre än en utan + // adressfält: den påstår att någon fyllt i det. + const text = fakturaPdf(faktura, { foretagsnamn: "", adress: undefined }).toString("latin1"); + expect(text).not.toMatch(/ej satt|not set|undefined/); + }); +}); diff --git a/felsokning/app/src/felsokning/__tests__/alva-yta.test.ts b/felsokning/app/src/felsokning/__tests__/alva-yta.test.ts index 850aa1f..8b91a7a 100644 --- a/felsokning/app/src/felsokning/__tests__/alva-yta.test.ts +++ b/felsokning/app/src/felsokning/__tests__/alva-yta.test.ts @@ -52,9 +52,29 @@ describe("ALVA-ytan följer designsystemet", () => { expect(emojier.filter((t) => !["✓", "○", "□", "→"].includes(t))).toEqual([]); }); + // Paletten LÄSES ur FARG i stället för att räknas upp här. + // + // Den stod tidigare som en andra lista i testet, vilket är samma + // dubblering som QUALITET M-7 handlade om: två uttryck för samma regel + // driver isär, och den som lade till en färg fick välja mellan att + // uppdatera testet eller att låta bli. + const palett = (() => { + const kod = readFileSync("src/alva/komponenter.tsx", "utf8"); + const block = kod.slice(kod.indexOf("export const FARG"), kod.indexOf("} as const;")); + return (block.match(/#[0-9A-Fa-f]{3,8}\b/g) ?? []).map((f) => f.toUpperCase()); + })(); + + it("paletten är liten nog att vara en palett", () => { + // Att läsa listan ur källan gör den självuppfyllande för + // komponenter.tsx. Taket är motvikten: en palett som växer förbi + // åtta värden är inte längre en palett utan en färglåda, och då ska + // någon behöva ändra det här talet och förklara varför. + expect(palett.length).toBeLessThanOrEqual(8); + expect(palett).toContain("#005CA9"); + }); + it.each(filer)("%s använder bara färger ur paletten", (_namn, kod) => { const k = utanKommentarer(kod); - const palett = ["#1B1E22", "#4D5662", "#D7DCE2", "#F6F7F8", "#005CA9", "#FFFFFF"]; const funna = (k.match(/#[0-9A-Fa-f]{3,8}\b/g) ?? []).map((f) => f.toUpperCase()); expect(funna.filter((f) => !palett.includes(f))).toEqual([]); }); diff --git a/felsokning/app/src/felsokning/plattform.ts b/felsokning/app/src/felsokning/plattform.ts index daef20e..af99b66 100644 --- a/felsokning/app/src/felsokning/plattform.ts +++ b/felsokning/app/src/felsokning/plattform.ts @@ -276,6 +276,51 @@ export async function anmalSupport(anmalan: { return { beteckning: data.beteckning ?? "" }; } +// Abonnemang (ALVA-PROC-0002). Tillståndet härleds på servern ur +// organisationens förfallna fakturor — det lagras inte. +export interface Abonnemang { + niva: string; + nivanamn: string; + tillstand: "aktiv" | "varning" | "last"; + dagarKvar: number | null; + aldsta: string | null; + besked: string | null; + fakturaepost: string | null; + registrerad: string; + senast_fakturerad: string | null; + behorighet: { lasa: boolean; exportera: boolean; nyttArende: boolean; dokumentera: boolean; avsluta: boolean }; +} + +export async function hamtaAbonnemang(): Promise { + const res = await plattformFetch("/api/abonnemang"); + if (!res.ok) throw new Error(`Fel ${res.status}`); + return (await res.json()) as Abonnemang; +} + +export async function sattNiva(niva: string): Promise { + const res = await plattformFetch("/api/abonnemang/niva", { method: "POST", body: JSON.stringify({ niva }) }); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error ?? `Fel ${res.status}`); + } +} + +export async function sattFakturaepost(epost: string): Promise { + const res = await plattformFetch("/api/abonnemang/fakturaepost", { + method: "POST", + body: JSON.stringify({ epost }), + }); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error ?? `Fel ${res.status}`); + } +} + +/** Fakturans PDF. Kunden hämtar sitt eget underlag själv. */ +export function fakturaPdfUrl(id: string): string { + return `${PLATTFORM_URL}/api/fakturor/${id}/pdf`; +} + // Märkesspecifika kopplingar. Uppgifterna lagras krypterat på servern // och returneras alltid maskerade — klienten ser aldrig hemligheterna. export interface LeverantorsFalt { diff --git a/felsokning/app/src/pages/alva/Abonnemang.tsx b/felsokning/app/src/pages/alva/Abonnemang.tsx new file mode 100644 index 0000000..8f6dcaa --- /dev/null +++ b/felsokning/app/src/pages/alva/Abonnemang.tsx @@ -0,0 +1,241 @@ +// ALVA-PROC-0002 · Abonnemang. +// +// Vyn har en sak att göra rätt före allt annat: om något är obetalt ska +// beskedet stå överst, säga vad som händer och när, och säga att läsning +// och export aldrig påverkas. Ett varningsbesked som gömmer sig under +// prisplanerna är inget besked. + +import { useEffect, useState } from "react"; +import { NIVAER, RESPITDAGAR } from "../../../../services/gemensam/abonnemang.mjs"; +import { formateraBelopp } from "../../../../services/gemensam/fakturering.mjs"; +import { hamtaAbonnemang, plattformAktiv, sattFakturaepost, sattNiva, type Abonnemang as AbonnemangsData } from "@/felsokning/plattform"; +import { Block, Demonstration, Etikett, FARG, Knapp, Rubrik, Tabell, Textfalt } from "@/alva/komponenter"; +import { Ram } from "./Ram"; + +interface Niva { + id: string; + namn: string; + basavgift: number | null; + anvandaravgift: number | null; + maxAnvandare: number | null; + text: string; + ingar: string[]; + saknas: string[]; +} + +const EXEMPEL: AbonnemangsData = { + niva: "free", + nivanamn: "Free", + tillstand: "aktiv", + dagarKvar: null, + aldsta: null, + besked: null, + fakturaepost: null, + registrerad: "2026-08-01T09:00:00Z", + senast_fakturerad: null, + behorighet: { lasa: true, exportera: true, nyttArende: true, dokumentera: true, avsluta: true }, +}; + +export default function Abonnemang() { + const skarpt = plattformAktiv(); + const [data, setData] = useState(null); + const [fel, setFel] = useState(""); + const [epost, setEpost] = useState(""); + const [sparat, setSparat] = useState(""); + + const ladda = () => + hamtaAbonnemang() + .then((a) => { + setData(a); + setEpost(a.fakturaepost ?? ""); + }) + .catch((orsak) => setFel(orsak instanceof Error ? orsak.message : String(orsak))); + + useEffect(() => { + if (skarpt) void ladda(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [skarpt]); + + const a = skarpt ? data : EXEMPEL; + + return ( + +
+ Commercial +
+ Subscription +
+

+ The account starts on Free and stays there until someone chooses otherwise. No card, no + up-front payment, no trial that quietly starts costing money. An invoice is issued at + registration — at zero — because it establishes the series and shows exactly what is not + being charged for. +

+ + {!skarpt && ( + + The state below is an example. Connected to a platform instance this page shows the + organization’s own subscription. + + )} + + {fel && ( + +

+ Subscription could not be retrieved: {fel} +

+
+ )} + + {/* Beskedet överst. Ett varningsbesked under prisplanerna är + inget besked. */} + {a?.besked && ( +
+

+ {a.tillstand === "last" ? "Locked" : `${a.dagarKvar} days remaining`} +

+

{a.besked}

+
+ )} + + {a && ( + + [ + + {e} + , + + {v} + , + ])} + /> + + )} + + +

+ Enter an address to have the PDF sent there, or leave it empty and collect invoices from + the Invoices page. Both routes lead to the same document — email is an addition, not the + only way to your own record. +

+
+ +
+ { + setSparat(""); + try { + await sattFakturaepost(epost); + await ladda(); + setSparat(epost ? "Saved." : "Cleared — invoices are collected from the portal."); + } catch (orsak) { + setSparat(orsak instanceof Error ? orsak.message : String(orsak)); + } + }} + > + Save + +
+ {sparat && ( +

+ {sparat} +

+ )} +
+
+ + +
+ {(NIVAER as Niva[]).map((n) => { + const vald = a?.niva === n.id; + return ( +
+
+ {n.namn} + {vald && ( + + Current + + )} +
+

+ {n.basavgift === null + ? "By agreement" + : n.basavgift === 0 + ? "No charge" + : `${formateraBelopp(n.basavgift)} / month + ${formateraBelopp(n.anvandaravgift ?? 0)} per active account`} +

+

+ {n.text} +

+
    + {n.ingar.map((i) => ( +
  • + {i} +
  • + ))} + {n.saknas.map((i) => ( +
  • + Not included: {i} +
  • + ))} +
+ {skarpt && !vald && ( +
+ { + await sattNiva(n.id).catch(() => {}); + await ladda(); + }} + > + Choose {n.namn} + +
+ )} +
+ ); + })} +
+
+ + {/* Regeln som inte är förhandlingsbar, och som därför står som + egen ruta i stället för som en rad i finstilten. */} + +

+ We trust our customers. There is no card on file and nothing is blocked before someone has + been told. A past-due invoice starts a visible countdown of {RESPITDAGAR} days, after which + new cases cannot be started. +

+

+ Reading and export are never affected — in any state, for any reason. +

+

+ That is not generosity. A case log is your evidence in a warranty or insurance dispute + concerning your customer — a third party with no part in an invoice between us. + Making that record unreachable would be using someone else’s legal position as + leverage. +

+
+
+
+ ); +} diff --git a/felsokning/app/src/pages/alva/Ram.tsx b/felsokning/app/src/pages/alva/Ram.tsx index bcecfb1..83a9ea9 100644 --- a/felsokning/app/src/pages/alva/Ram.tsx +++ b/felsokning/app/src/pages/alva/Ram.tsx @@ -24,6 +24,7 @@ const PORTAL = [ { till: "/alva/portal/garantier", text: "Warranty" }, { till: "/alva/portal/forsakring", text: "Insurance" }, { till: "/alva/portal/support", text: "Support" }, + { till: "/alva/portal/abonnemang", text: "Subscription" }, { till: "/alva/portal/fakturor", text: "Invoices" }, { till: "/felsokning", text: "Diagnostics" }, ]; diff --git a/felsokning/docs/ALVA-SYSTEMBESKRIVNING.md b/felsokning/docs/ALVA-SYSTEMBESKRIVNING.md index 5852b2e..fea927e 100644 --- a/felsokning/docs/ALVA-SYSTEMBESKRIVNING.md +++ b/felsokning/docs/ALVA-SYSTEMBESKRIVNING.md @@ -151,6 +151,15 @@ ett register över svenska bolags noterade gränser med avläsningsdatum. Poster ärende, metodik, plattformsversion, spår-id — och identifierande fält kan inte smugglas med. Anmälan är oföränderlig, statusen en projektion. +**Abonnemang (ALVA-PROC-0002).** Kontot startar på Free och stannar där tills +någon väljer annat — ingen provperiod som tyst börjar kosta. Faktura utfärdas vid +registreringen (på noll) och sedan månadsvis av ett eget jobb, idempotent på +perioden. Fyra nivåer: Free, Standard, Premium, Enterprise (per avtal, utan +listpris). Basavgift plus per aktiv användare. PDF genereras utan beroenden — +formatet skrivs för hand. Vid utebliven betalning: synlig nedräkning på 14 dagar, +sedan låsning av nya ärenden. **Läsning och export påverkas aldrig, i något +tillstånd** — loggen är verkstadens underlag i en tvist som gäller deras kund. + **Fakturering (ALVA-PROC-0001).** Fakturan härleds ur organisationens tillstånd. Utfärdad faktura ändras aldrig; betalning är en händelse. Nummerserie utan luckor. Ingen betalleverantör. @@ -239,7 +248,6 @@ portalspärren), `plattform` (157 integrationskontroller mot riktig Postgres). | Punkt | Läge | |---|---| -| **Abonnemang och prisplaner** | **Ej byggt.** Modell och vyer för fakturering finns; prenumeration, gratisstart, månadsgenerering, PDF-utskick, nivåer och nedräkning till spärr finns inte. | | **TÜV T-3, restdel** | Kuverteringen stänger "nyckeln bredvid chiffertexten", inte backupfönstret. En backup tagen före en radering plus huvudnyckeln återställer uppgifterna. Kräver nyckel per subjekt i KMS. | | **T-12** | Ingen react-router-version är fri från bägge avvikelserna; den som bärs gäller RSC-läge som inte används. | | **Terraform** | CronJob och hemlighetskoppling skrivna men **ej applicerade** — ingen terraform-binär i miljön. | @@ -269,8 +277,10 @@ sina gränser. rapporten är genomarbetade, prövade mot riktig databas och riktigt gränssnitt, och har överlevt tre revisioner som letade fel på allvar. -**Kommersiellt är den ofärdig.** Fakturering finns som mekanism men det finns -ingen abonnemangsmodell, ingen prisplan i drift, ingen kundhantering över tid. +**Kommersiellt finns nu hela kedjan** — registrering, gratisnivå, månadsfaktura, +PDF, nivåbyte, betalningspåminnelse och spärr. Prislistan är platshållare och +ska sättas per marknad; utskicket av PDF per e-post har ingen avsändartjänst +konfigurerad, så kunden hämtar den från portalen tills en sådan finns. **Juridiskt är strukturen på plats men inte fylld.** Impressum, dataskydd, tillgänglighet och tvistlösning har sina platser och sina rättsliga grunder; @@ -280,8 +290,7 @@ skrivna. **Driftsmässigt är den beskriven men inte driftsatt.** Terraform beskriver hela klustret; ingenting har applicerats mot ett riktigt AWS-konto. -Rimligaste nästa steg, i ordning: abonnemangsmodellen · applicera Terraform mot -en riktig miljö · fyll Impressum · skriv biträdesavtal och DPIA · stäng T-3 med +Rimligaste nästa steg, i ordning: applicera Terraform mot en riktig miljö · fyll Impressum · skriv biträdesavtal och DPIA · stäng T-3 med KMS. --- diff --git a/felsokning/infra/postgres-init.sql b/felsokning/infra/postgres-init.sql index 8d1eb4e..1c1b09e 100644 --- a/felsokning/infra/postgres-init.sql +++ b/felsokning/infra/postgres-init.sql @@ -471,3 +471,52 @@ drop trigger if exists supportinlagg_append_only on supportinlagg; create trigger supportinlagg_append_only before update or delete on supportinlagg for each row execute function forbjud_andring(); + +-- ---- Abonnemang (ALVA-PROC-0002) --------------------------------------- +-- +-- 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. +-- +-- Nivån och fakturamejlen är HÄNDELSER, inte kolumner: 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. +create table if not exists abonnemang ( + organisation_id uuid primary key references organisationer(id), + registrerad timestamptz not null default now(), + -- Härledd: sista period som fakturerats. Skrivs av månadsjobbet. + senast_fakturerad date +); + +create or replace function skydda_abonnemang() returns trigger +language plpgsql as $$ +begin + if TG_OP = 'DELETE' then + raise exception 'Ett abonnemang kan inte raderas — historiken hör till fakturaunderlaget'; + end if; + if (new.organisation_id, new.registrerad) is distinct from (old.organisation_id, old.registrerad) then + raise exception 'Abonnemangets identitet och startdatum kan inte ändras'; + end if; + return new; +end $$; + +drop trigger if exists abonnemang_skydd on abonnemang; +create trigger abonnemang_skydd + before update or delete on abonnemang + for each row execute function skydda_abonnemang(); + +create table if not exists abonnemangshandelser ( + id uuid primary key default gen_random_uuid(), + organisation_id uuid not null references organisationer(id), + typ text not null check (typ in ('niva', 'fakturaepost')), + varde text not null, + satt_av uuid references anvandare(id), + skapad timestamptz not null default now() +); +create index if not exists abonnemangshandelser_idx + on abonnemangshandelser (organisation_id, skapad); + +drop trigger if exists abonnemangshandelser_append_only on abonnemangshandelser; +create trigger abonnemangshandelser_append_only + before update or delete on abonnemangshandelser + for each row execute function forbjud_andring(); diff --git a/felsokning/infra/terraform/30-plattform.tf b/felsokning/infra/terraform/30-plattform.tf index 36e2f5a..4be5b73 100644 --- a/felsokning/infra/terraform/30-plattform.tf +++ b/felsokning/infra/terraform/30-plattform.tf @@ -284,3 +284,80 @@ resource "kubernetes_cron_job_v1" "gallring" { } } } + +# ---- Månadsfaktureringen (ALVA-PROC-0002) ------------------------------ +# +# Egen körning 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 — andra körningen samma dygn fakturerar +# ingenting. Det är viktigare än det låter: ett fakturajobb som +# dubbelfakturerar vid en omstart är värre än ett som inte kör alls. +resource "kubernetes_cron_job_v1" "manadsfakturering" { + metadata { + name = "manadsfakturering" + namespace = kubernetes_namespace_v1.denna.metadata[0].name + labels = merge(local.etiketter, { app = "manadsfakturering" }) + } + + spec { + # Dagligen. Perioden avgör vem som faktureras, inte schemat — en + # organisation som registrerade sig den 20:e faktureras den 20:e. + schedule = "40 3 * * *" + concurrency_policy = "Forbid" + successful_jobs_history_limit = 7 + failed_jobs_history_limit = 7 + starting_deadline_seconds = 3600 + + job_template { + metadata { + labels = merge(local.etiketter, { app = "manadsfakturering" }) + } + + spec { + backoff_limit = 2 + + template { + metadata { + labels = merge(local.etiketter, { app = "manadsfakturering" }) + } + + spec { + restart_policy = "Never" + service_account_name = kubernetes_service_account_v1.plattform.metadata[0].name + + security_context { + run_as_non_root = true + + seccomp_profile { + type = "RuntimeDefault" + } + } + + container { + name = "manadsfakturering" + image = local.tjanster.plattform.bild + command = ["node", "manadsfakturering.mjs"] + + env_from { + secret_ref { + name = "felsokning-hemligheter" + } + } + + resources { + requests = { + cpu = "50m" + memory = "128Mi" + } + limits = { + memory = "256Mi" + } + } + } + } + } + } + } + } +} diff --git a/felsokning/services/gemensam/abonnemang.mjs b/felsokning/services/gemensam/abonnemang.mjs new file mode 100644 index 0000000..fb245bf --- /dev/null +++ b/felsokning/services/gemensam/abonnemang.mjs @@ -0,0 +1,211 @@ +// ALVA-PROC-0002 · Abonnemang. +// +// ---- Hållningen --------------------------------------------------------- +// +// Vi litar på kunden. Det är inte en artighet utan ett designbeslut med +// följder: inget kort krävs, ingen förskottsbetalning, ingen spärr innan +// någon fått veta att något är obetalt. Kunden är en verkstad med +// anställda och kunder i sin tur — den försvinner inte över natten, och +// att behandla den som om den kunde det gör produkten sämre för alla som +// betalar i tid. +// +// Vid utebliven betalning varnar vi, räknar ned synligt, och låser sedan. +// +// ---- Vad en låsning ALDRIG får göra ------------------------------------- +// +// Låsning stänger nya ärenden. Den rör inte historiken, och den får inte +// göra det. +// +// Skälet är inte generositet. Ärendeloggen är verkstadens underlag i en +// garanti- eller försäkringstvist som gäller DERAS kund — en tredje part +// som inte har någon del i vår obetalda faktura. Att göra det underlaget +// oåtkomligt vore att använda någon annans rättsliga ställning som +// påtryckningsmedel. Läsning och export förblir därför öppna i låst läge, +// och det är inte förhandlingsbart. +// +// ---- Gratis är en nivå, inte en provperiod ------------------------------ +// +// 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 som 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. + +import { PRISLISTA } from "./fakturering.mjs"; + +/** Dagar efter förfallodagen innan låsning. Synlig nedräkning hela tiden. */ +export const RESPITDAGAR = 14; + +/** + * Nivåerna. + * + * `basavgift` och `anvandaravgift` är i öre, som resten av faktureringen. + * Värdena är PLATSHÅLLARE på samma villkor som PRISLISTA — mekanismen är + * oberoende av dem. + */ +export const NIVAER = [ + { + id: "free", + namn: "Free", + basavgift: 0, + anvandaravgift: 0, + maxAnvandare: 2, + text: "Full method, full evidence model, full report. Two accounts.", + ingar: [ + "The complete ALVA methodology and quality gate", + "Append-only case log and evidence report", + "Two active accounts", + ], + saknas: ["Fleet analytics", "Outgoing integrations", "Knowledge source connectors"], + }, + { + id: "standard", + namn: "Standard", + basavgift: PRISLISTA.plattform_ar / 12, + anvandaravgift: PRISLISTA.anvandare_manad, + maxAnvandare: null, + text: "Platform fee plus a fee per active account.", + ingar: [ + "Everything in Free, without the account limit", + "Fleet analytics and fault-cause statistics", + "Live Share with customer, partner and internal levels", + "Instrument register with calibration tracking", + ], + saknas: ["Outgoing integrations", "Knowledge source connectors"], + }, + { + id: "premium", + namn: "Premium", + basavgift: (PRISLISTA.plattform_ar / 12) * 2, + anvandaravgift: PRISLISTA.anvandare_manad, + maxAnvandare: null, + text: "For workshops that report into someone else's system.", + ingar: [ + "Everything in Standard", + "Outgoing integrations and event subscriptions", + "Protocol ingest from external test equipment", + "Knowledge source connectors", + ], + saknas: ["Dedicated instance", "Custom methodology authoring"], + }, + { + id: "enterprise", + namn: "Enterprise", + basavgift: null, // Avtalas. Inte "kontakta oss" som prissignal — det finns inget listpris. + anvandaravgift: null, + maxAnvandare: null, + text: "Chains and fleets. Priced per agreement because the scope is.", + ingar: [ + "Everything in Premium", + "Own methodologies and own rule package", + "Dedicated instance, own key management", + "Signed rule package and audit support", + ], + saknas: [], + }, +]; + +export const nivaFor = (id) => NIVAER.find((n) => n.id === id) ?? NIVAER[0]; + +/** Abonnemangets tillstånd. Härlett, aldrig lagrat. */ +export const TILLSTAND = ["aktiv", "varning", "last"]; + +/** + * Tillståndet, härlett ur fakturorna. + * + * Tre utfall och inget fjärde: + * + * aktiv Inget är förfallet. + * varning Något är förfallet men respiten löper. Nedräkningen visas. + * last Respiten är slut. + * + * En faktura på noll kronor kan inte försätta någon i varning. Free-nivån + * fakturerar noll, och en obetald nollfaktura är inte en skuld — den är + * en bokföringspost. Att låsa någon för den vore absurt, och det är + * precis den sortens absurditet som uppstår när tillståndet räknas fram + * utan att någon tänkt på specialfallet. + * + * @param fakturor [{ forfaller, totalt, status }] + * @param nu tidpunkt; injicerad för testbarhet + */ +export function abonnemangstillstand(fakturor = [], nu = new Date()) { + const obetalda = fakturor.filter( + (f) => f.status === "utfardad" && Number(f.totalt) > 0 && new Date(f.forfaller) < nu, + ); + if (obetalda.length === 0) return { tillstand: "aktiv", dagarKvar: null, aldsta: null }; + + // Den äldsta förfallna styr. Betalar kunden den senaste men inte den + // första har respiten ändå löpt sedan den första. + const aldsta = obetalda.reduce((a, b) => (new Date(a.forfaller) < new Date(b.forfaller) ? a : b)); + const forfalloDygn = Math.floor((nu - new Date(aldsta.forfaller)) / 86_400_000); + const dagarKvar = RESPITDAGAR - forfalloDygn; + + return { + tillstand: dagarKvar > 0 ? "varning" : "last", + dagarKvar: Math.max(0, dagarKvar), + aldsta: aldsta.beteckning ?? null, + belopp: obetalda.reduce((s, f) => s + Number(f.totalt), 0), + }; +} + +/** + * Vad ett tillstånd tillåter. + * + * Läsning och export är sanna i ALLA tillstånd. Se filhuvudet — det är + * verkstadens kunds underlag, inte verkstadens, och inte vårt. + */ +export function behorighet(tillstand) { + const last = tillstand === "last"; + return { + lasa: true, + exportera: true, + nyttArende: !last, + dokumentera: !last, + avsluta: !last, + }; +} + +/** + * Beskedet till kunden. Rak text, ingen förhandling, inget hot. + * + * Statusspråket gäller även här: ett konstaterande och vad som händer + * härnäst. + */ +export function besked(status) { + if (status.tillstand === "aktiv") return null; + if (status.tillstand === "varning") { + return ( + `Invoice ${status.aldsta} is past due. New cases can be started for ${status.dagarKvar} more ` + + `day${status.dagarKvar === 1 ? "" : "s"}. Reading and export are never affected.` + ); + } + return ( + `Invoice ${status.aldsta} is past due and the grace period has ended. New cases cannot be started. ` + + "Existing cases remain readable and exportable — that record belongs to your customer, not to us." + ); +} + +/** + * Nästa fakturaperiod. + * + * Månadsvis från registreringsdagen, inte från den första i månaden: en + * organisation som registrerar sig den 20:e ska inte få en faktura på + * elva dagar som sin första upplevelse av produkten. + */ +export function nastaPeriod(registrerad, senastFakturerad = null) { + const start = new Date(senastFakturerad ?? registrerad); + if (Number.isNaN(start.getTime())) return null; + const fran = new Date(start); + if (senastFakturerad) fran.setMonth(fran.getMonth() + 1); + const till = new Date(fran); + till.setMonth(till.getMonth() + 1); + till.setDate(till.getDate() - 1); + return { fran: fran.toISOString().slice(0, 10), till: till.toISOString().slice(0, 10) }; +} + +/** Är det dags att fakturera perioden? */ +export function forfallenFakturering(registrerad, senastFakturerad, nu = new Date()) { + const period = nastaPeriod(registrerad, senastFakturerad); + if (!period) return null; + return new Date(period.fran) <= nu ? period : null; +} diff --git a/felsokning/services/gemensam/fakturapdf.mjs b/felsokning/services/gemensam/fakturapdf.mjs new file mode 100644 index 0000000..a5ea39b --- /dev/null +++ b/felsokning/services/gemensam/fakturapdf.mjs @@ -0,0 +1,200 @@ +// ALVA-PROC-0001 · Fakturan som PDF. +// +// ---- Varför den är skriven för hand ------------------------------------- +// +// Kravet var inga beroenden. En PDF-generator drar in ett bibliotek som +// drar in fem till, och varje sådant är en försörjningskedja vi inte +// granskar — för att rita text i två spalter. +// +// PDF är ett textformat. En ensidig faktura med inbyggda typsnitt är +// omkring tvåhundra rader, och de raderna går att läsa och förstå av den +// som ska lita på dem. Det är samma resonemang som gjorde +// plattformstjänsten ramverkslös. +// +// ---- Vad som INTE görs --------------------------------------------------- +// +// Inga bilder, ingen egen typografi, ingen radbrytning av godtycklig +// bredd. De 14 basfonterna finns i varje PDF-läsare och behöver inte +// bäddas in; Helvetica räcker för en faktura, och en faktura som ser +// tråkig ut är fortfarande en faktura. +// +// WinAnsi täcker svenska tecken. Det som ligger utanför ersätts hellre än +// att ge en trasig glyf — en faktura med kråkfötter ser förfalskad ut. + +/** Sidmått i punkter. A4. */ +const BREDD = 595.28; +const HOJD = 841.89; +const MARGINAL = 56; + +/** Tecken PDF:ens strängsyntax måste få undantagna. */ +const flykta = (s) => String(s).replace(/([\\()])/g, "\\$1"); + +/** + * WinAnsi-kodning av det vi faktiskt använder. + * + * Latin-1 räcker till 0xFF och täcker åäöÅÄÖ. Utanför det ersätts + * tecknet — hellre en punkt än en trasig glyf. + */ +function winansi(text) { + let ut = ""; + for (const tecken of String(text)) { + const k = tecken.codePointAt(0); + if (k === 0x2013 || k === 0x2014) ut += "-"; // tankstreck + else if (k === 0x00a0) ut += " "; // hårt mellanslag + else if (k === 0x2212) ut += "-"; // minustecken + else if (k <= 0xff) ut += tecken; + else ut += "."; + } + return ut; +} + +/** En textinstruktion i innehållsströmmen. */ +function text(x, y, storlek, innehall, font = "F1") { + return `BT /${font} ${storlek} Tf 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm (${flykta(winansi(innehall))}) Tj ET\n`; +} + +function linje(x1, y1, x2, y2, tjocklek = 0.5) { + return `${tjocklek} w ${x1.toFixed(2)} ${y1.toFixed(2)} m ${x2.toFixed(2)} ${y2.toFixed(2)} l S\n`; +} + +/** + * Bygger fakturans PDF. + * + * @param faktura dokumentet ur fakturering.mjs + * @param avsandare valfria uppgifter om utfärdaren (ALVA-SPEC-050) + * @returns Buffer + */ +export function fakturaPdf(faktura, avsandare = {}) { + const rader = []; + let y = HOJD - MARGINAL; + + const skriv = (storlek, innehall, font = "F1", hopp = null) => { + rader.push(text(MARGINAL, y, storlek, innehall, font)); + y -= hopp ?? storlek + 6; + }; + + // ---- Huvud ------------------------------------------------------------ + skriv(20, "ALVA", "F2", 28); + skriv(9, "GUIDED DIAGNOSTIC PLATFORM", "F1", 24); + + skriv(15, `Invoice ${faktura.beteckning}`, "F2", 22); + + // ---- Parter och datum ------------------------------------------------- + const uppgifter = [ + ["Organization", faktura.organisation], + ["Period", `${faktura.period.fran} - ${faktura.period.till}`], + ["Issued", faktura.utfardad], + ["Due", faktura.forfaller], + ]; + for (const [etikett, varde] of uppgifter) { + rader.push(text(MARGINAL, y, 9, etikett, "F1")); + rader.push(text(MARGINAL + 120, y, 9, String(varde), "F2")); + y -= 14; + } + y -= 10; + + // ---- Rader ------------------------------------------------------------ + rader.push(linje(MARGINAL, y, BREDD - MARGINAL, y, 1)); + y -= 16; + for (const [x, etikett] of [ + [MARGINAL, "ITEM"], + [MARGINAL + 250, "QTY"], + [MARGINAL + 330, "UNIT PRICE"], + [BREDD - MARGINAL - 80, "AMOUNT"], + ]) { + rader.push(text(x, y, 8, etikett, "F2")); + } + y -= 6; + rader.push(linje(MARGINAL, y, BREDD - MARGINAL, y)); + y -= 18; + + const belopp = (ore) => `${(Math.abs(ore) / 100).toFixed(2)}${ore < 0 ? "-" : ""} ${faktura.valuta}`; + + for (const r of faktura.rader) { + rader.push(text(MARGINAL, y, 10, r.benamning, "F2")); + rader.push(text(MARGINAL + 250, y, 10, `${r.antal}`)); + rader.push(text(MARGINAL + 330, y, 10, belopp(r.apris))); + rader.push(text(BREDD - MARGINAL - 80, y, 10, belopp(r.belopp))); + y -= 13; + // Underlaget under raden: varje rad ska bära var antalet kommer ifrån, + // och det gäller på papper lika mycket som på skärm. + rader.push(text(MARGINAL + 8, y, 8, r.underlag)); + y -= 18; + } + + rader.push(linje(MARGINAL, y, BREDD - MARGINAL, y)); + y -= 18; + + // ---- Summering -------------------------------------------------------- + for (const [etikett, varde, fet] of [ + ["Net", belopp(faktura.netto), false], + [`VAT ${Math.round(faktura.momssats * 100)} %`, belopp(faktura.moms), false], + ["Total", belopp(faktura.totalt), true], + ]) { + rader.push(text(MARGINAL + 330, y, fet ? 11 : 10, etikett, fet ? "F2" : "F1")); + rader.push(text(BREDD - MARGINAL - 80, y, fet ? 11 : 10, varde, fet ? "F2" : "F1")); + y -= fet ? 20 : 15; + } + + // ---- Betalning -------------------------------------------------------- + y -= 10; + rader.push(linje(MARGINAL, y, BREDD - MARGINAL, y)); + y -= 18; + skriv(9, faktura.betalningssatt, "F2", 14); + skriv(8, "Payment is registered by an administrator when the funds arrive.", "F1", 20); + + if (faktura.krediterar) { + skriv(9, `Credit note for ${faktura.krediterar}`, "F2", 14); + if (faktura.orsak) skriv(8, faktura.orsak, "F1", 20); + } + + // ---- Utfärdarens uppgifter ------------------------------------------- + // + // Tomma fält utelämnas. En faktura med "— ej satt" i adressfältet är + // sämre än en utan adressfält: den påstår att någon fyllt i det. + const fot = [avsandare.foretagsnamn, avsandare.adress, avsandare.momsnummer, avsandare.epost].filter(Boolean); + let fy = MARGINAL + 40; + for (const rad of fot.reverse()) { + rader.push(text(MARGINAL, fy, 8, rad)); + fy += 11; + } + rader.push(text(BREDD - MARGINAL - 120, MARGINAL, 8, faktura.version ?? "ALVA-PROC-0001")); + + return montera(rader.join("")); +} + +/** + * Sätter ihop PDF-filen med korrekt korsreferenstabell. + * + * Byteoffsetarna i xref måste stämma på byten. Det är den enda delen av + * formatet som inte förlåter slarv — en läsare som inte hittar objektet + * visar ingenting alls, inte en trasig sida. + */ +function montera(innehall) { + const objekt = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${BREDD.toFixed(2)} ${HOJD.toFixed(2)}] ` + + "/Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> /Contents 4 0 R >>", + null, // 4: innehållsströmmen, sätts nedan + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>", + ]; + + const strom = Buffer.from(innehall, "latin1"); + objekt[3] = `<< /Length ${strom.length} >>\nstream\n${innehall}endstream`; + + let pdf = "%PDF-1.4\n"; + const offsetar = []; + objekt.forEach((kropp, i) => { + offsetar.push(Buffer.byteLength(pdf, "latin1")); + pdf += `${i + 1} 0 obj\n${kropp}\nendobj\n`; + }); + + const xrefOffset = Buffer.byteLength(pdf, "latin1"); + pdf += `xref\n0 ${objekt.length + 1}\n0000000000 65535 f \n`; + for (const o of offsetar) pdf += `${String(o).padStart(10, "0")} 00000 n \n`; + pdf += `trailer\n<< /Size ${objekt.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + + return Buffer.from(pdf, "latin1"); +} diff --git a/felsokning/services/plattform/abonnemang.mjs b/felsokning/services/plattform/abonnemang.mjs new file mode 120000 index 0000000..8dbf2e1 --- /dev/null +++ b/felsokning/services/plattform/abonnemang.mjs @@ -0,0 +1 @@ +../gemensam/abonnemang.mjs \ No newline at end of file diff --git a/felsokning/services/plattform/fakturapdf.mjs b/felsokning/services/plattform/fakturapdf.mjs new file mode 120000 index 0000000..ef1cf3f --- /dev/null +++ b/felsokning/services/plattform/fakturapdf.mjs @@ -0,0 +1 @@ +../gemensam/fakturapdf.mjs \ No newline at end of file diff --git a/felsokning/services/plattform/integrationstest.sh b/felsokning/services/plattform/integrationstest.sh index edf70a3..2b2f583 100755 --- a/felsokning/services/plattform/integrationstest.sh +++ b/felsokning/services/plattform/integrationstest.sh @@ -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" diff --git a/felsokning/services/plattform/manadsfakturering.mjs b/felsokning/services/plattform/manadsfakturering.mjs new file mode 100644 index 0000000..986a1d4 --- /dev/null +++ b/felsokning/services/plattform/manadsfakturering.mjs @@ -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(); + } +} diff --git a/felsokning/services/plattform/server.mjs b/felsokning/services/plattform/server.mjs index 49ab1ed..76d5c21 100644 --- a/felsokning/services/plattform/server.mjs +++ b/felsokning/services/plattform/server.mjs @@ -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." });