diff --git a/felsokning/app/src/App.tsx b/felsokning/app/src/App.tsx index a8304ed..2552f7f 100644 --- a/felsokning/app/src/App.tsx +++ b/felsokning/app/src/App.tsx @@ -27,6 +27,7 @@ import AlvaLoggaIn from "./pages/alva/LoggaIn"; import AlvaPortal from "./pages/alva/Portal"; 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 { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner"; @@ -68,6 +69,7 @@ function AppContent() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/felsokning/app/src/alva/system.ts b/felsokning/app/src/alva/system.ts index 1f960c5..274660b 100644 --- a/felsokning/app/src/alva/system.ts +++ b/felsokning/app/src/alva/system.ts @@ -99,7 +99,8 @@ export type Objektklass = | "ORG" // en organisation | "USER" // en användare | "SRC" // en kunskapskälla - | "REP"; // en rapport + | "REP" // en rapport + | "INV"; // en faktura const SIFFROR: Record = { CASE: 5, @@ -112,6 +113,7 @@ const SIFFROR: Record = { USER: 4, SRC: 3, REP: 4, + INV: 4, }; /** ALVA-CASE-91821 · ALVA-PROC-0042 · ALVA-RULE-120 */ diff --git a/felsokning/app/src/felsokning/__tests__/alva-yta.test.ts b/felsokning/app/src/felsokning/__tests__/alva-yta.test.ts index 72e4758..85eabc0 100644 --- a/felsokning/app/src/felsokning/__tests__/alva-yta.test.ts +++ b/felsokning/app/src/felsokning/__tests__/alva-yta.test.ts @@ -310,3 +310,43 @@ describe("demonstrationsmärkningen är entydig", () => { expect(sida("LoggaIn.tsx")).not.toContain("Real authentication exists in the platform service"); }); }); + +// ---- ALVA tar inte emot betalningar ------------------------------------ +// +// `@stripe/stripe-js` injicerar sitt skript i sidan redan vid import, +// inte när någon ska betala. ALVA laddade därför Stripe på varje +// sidvisning trots att produkten faktureras och aldrig tar emot en +// betalning — och bakom artefaktens CSP blockerades anropet och syntes +// som ett fel i konsolen. +// +// `/pure` är samma modul utan sidoeffekten. Testet låser den, eftersom +// en `import { loadStripe } from "@stripe/stripe-js"` någonstans räcker +// för att skjuta upp skriptet igen. +describe("ingen betalleverantör laddas", () => { + const stripe = readFileSync("src/lib/stripe.ts", "utf8"); + + it("stripe-modulen importeras utan sidoeffekt", () => { + expect(stripe).toContain('from "@stripe/stripe-js/pure"'); + // Ett värdeimport från huvudmodulen skulle återinföra skriptladdningen. + expect(stripe).not.toMatch(/import \{[^}]*loadStripe[^}]*\} from "@stripe\/stripe-js"/); + }); + + it("ingen ALVA-yta rör en betalleverantör", () => { + for (const [namn, kod] of filer) { + expect(kod.toLowerCase(), namn).not.toContain("stripe"); + } + }); + + it("fakturamodellen finns och tar inte emot betalningar", async () => { + const m = await import("../../../../services/gemensam/fakturering.mjs"); + const f = m.fakturera({ + nummer: 1, + org: { namn: "Verkstad Nord AB", moduler: [] }, + aktiva: 3, + period: { fran: "2026-01-01", till: "2026-12-31" }, + utfardad: "2026-01-15", + }); + expect(f.betalningssatt).toMatch(/No online payment/i); + expect(Object.keys(m)).not.toContain("betala"); + }); +}); diff --git a/felsokning/app/src/felsokning/__tests__/fakturering.test.ts b/felsokning/app/src/felsokning/__tests__/fakturering.test.ts new file mode 100644 index 0000000..77f3208 --- /dev/null +++ b/felsokning/app/src/felsokning/__tests__/fakturering.test.ts @@ -0,0 +1,134 @@ +// @vitest-environment node +// ALVA-PROC-0001 · Fakturan härleds, den skrivs inte in. +// +// Testerna nedan handlar mindre om aritmetik än om två egenskaper som +// avgör om en faktura går att granska två år senare: att varje rad bär +// sitt underlag, och att en utfärdad faktura aldrig ändras. +import { describe, expect, it } from "vitest"; +import { + PRISLISTA, + fakturabeteckning, + fakturarader, + fakturera, + formateraBelopp, + kreditera, + manaderMellan, +} from "../../../../services/gemensam/fakturering.mjs"; + +const ORG = { namn: "Verkstad Nord AB", moduler: ["forsakringsrapportering"] }; +const PERIOD = { fran: "2026-01-01", till: "2026-12-31" }; + +describe("perioden räknas i hela månader", () => { + it("ett kalenderår är tolv månader", () => { + expect(manaderMellan("2026-01-01", "2026-12-31")).toBe(12); + }); + + it("en enda månad är en, inte noll", () => { + expect(manaderMellan("2026-03-01", "2026-03-31")).toBe(1); + }); + + it("en bakvänd eller trasig period ger noll i stället för ett negativt belopp", () => { + expect(manaderMellan("2026-12-31", "2026-01-01")).toBe(0); + expect(manaderMellan("inte ett datum", "2026-01-01")).toBe(0); + }); +}); + +describe("raderna bär sitt underlag", () => { + it("varje rad säger var antalet kommer ifrån", () => { + const rader = fakturarader(ORG, 12, PERIOD); + for (const r of rader) { + expect(r.underlag, r.benamning).toBeTruthy(); + expect(r.belopp).toBe(r.antal * r.apris); + } + }); + + it("användarraden räknar aktiva konton gånger månader", () => { + const rad = fakturarader(ORG, 12, PERIOD).find((r) => r.benamning === "User licenses"); + expect(rad.antal).toBe(12 * 12); + expect(rad.underlag).toContain("12 active users"); + expect(rad.underlag).toContain("12 months"); + }); + + it("en organisation utan aktiva användare får ingen användarrad — inte en nollrad", () => { + const rader = fakturarader(ORG, 0, PERIOD); + expect(rader.some((r) => r.benamning === "User licenses")).toBe(false); + }); + + it("bara påslagna moduler faktureras, och okända moduler tigs ihjäl", () => { + const rader = fakturarader({ ...ORG, moduler: ["forsakringsrapportering", "hittepa"] }, 1, PERIOD); + expect(rader.filter((r) => r.enhet === "module/year")).toHaveLength(1); + }); +}); + +describe("summan går ihop", () => { + const f = fakturera({ nummer: 1, org: ORG, aktiva: 12, period: PERIOD, utfardad: "2026-01-15" }); + + it("nettot är summan av raderna", () => { + expect(f.netto).toBe(f.rader.reduce((s, r) => s + r.belopp, 0)); + }); + + it("momsen räknas på nettot, inte per rad", () => { + // Radvis avrundning ger ören som inte stämmer mot totalen. + expect(f.moms).toBe(Math.round(f.netto * PRISLISTA.momssats)); + expect(f.totalt).toBe(f.netto + f.moms); + }); + + it("förfallodagen följer betalningsvillkoret", () => { + expect(f.forfaller).toBe("2026-02-14"); // 15 jan + 30 dagar + }); + + it("beteckningen följer nomenklaturen", () => { + expect(f.beteckning).toBe("ALVA-INV-0001"); + expect(fakturabeteckning(1234)).toBe("ALVA-INV-1234"); + }); + + it("fakturan säger uttryckligen att ingen onlinebetalning tas emot", () => { + expect(f.betalningssatt).toMatch(/No online payment/i); + }); +}); + +describe("en utfärdad faktura ändras aldrig", () => { + const f = fakturera({ nummer: 1, org: ORG, aktiva: 12, period: PERIOD, utfardad: "2026-01-15" }); + + it("krediteringen är en egen post som pekar tillbaka", () => { + const { kredit } = kreditera(f, { + nummer: 2, + utfardad: "2026-02-01", + orsak: "Antalet aktiva användare var felaktigt vid utfärdandet.", + }); + expect(kredit.krediterar).toBe(f.beteckning); + expect(kredit.beteckning).toBe("ALVA-INV-0002"); + expect(kredit.totalt).toBe(-f.totalt); + // Originalet är orört. + expect(f.status).toBe("utfardad"); + expect(f.totalt).toBeGreaterThan(0); + }); + + it("en kreditering utan skäl avvisas", () => { + expect(kreditera(f, { nummer: 2, utfardad: "2026-02-01", orsak: "" }).fel).toBeTruthy(); + expect(kreditera(f, { nummer: 2, utfardad: "2026-02-01", orsak: "fel" }).fel).toBeTruthy(); + }); +}); + +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 + // radbrytning, och skillnaden mot ett vanligt mellanslag ska synas i + // testet i stället för att ligga osynlig i en sträng. + const NBSP = "\u00A0"; + expect(formateraBelopp(2_400_000)).toBe(`24${NBSP}000,00 SEK`); + expect(formateraBelopp(39_000)).toBe("390,00 SEK"); + expect(formateraBelopp(2_400_000)).not.toContain(" 000"); + }); + + it("negativa belopp märks med minustecken, inte parentes", () => { + expect(formateraBelopp(-2_400_000)).toMatch(/^−/); + }); + + it("räknar i öre — inga flyttalsfel i totalen", () => { + const f = fakturera({ nummer: 1, org: ORG, aktiva: 7, period: PERIOD, utfardad: "2026-01-15" }); + expect(Number.isInteger(f.netto)).toBe(true); + expect(Number.isInteger(f.moms)).toBe(true); + expect(Number.isInteger(f.totalt)).toBe(true); + }); +}); diff --git a/felsokning/app/src/lib/stripe.ts b/felsokning/app/src/lib/stripe.ts index afec5f9..dbec176 100644 --- a/felsokning/app/src/lib/stripe.ts +++ b/felsokning/app/src/lib/stripe.ts @@ -1,4 +1,16 @@ -import { loadStripe, Stripe } from "@stripe/stripe-js"; +// `@stripe/stripe-js` injicerar sitt skript i sidan redan vid IMPORT, +// inte när någon ska betala. Följden var att ALVA — som varken har eller +// vill ha en betalleverantör — laddade Stripe på varje sidvisning, och +// bakom artefaktens CSP blockerades anropet och syntes som ett fel i +// konsolen på en produkt som inte tar emot betalningar alls. +// +// `/pure` är samma modul utan den sidoeffekten: skriptet hämtas först om +// och när loadStripe faktiskt anropas. ALVA anropar det aldrig — där +// faktureras det, och fakturan skapas i systemet +// (services/gemensam/fakturering.mjs). Butikens kassa fungerar som +// tidigare, men bara när någon öppnar den. +import type { Stripe } from "@stripe/stripe-js"; +import { loadStripe } from "@stripe/stripe-js/pure"; export type StripeEnv = "sandbox" | "live"; diff --git a/felsokning/app/src/pages/alva/Fakturor.tsx b/felsokning/app/src/pages/alva/Fakturor.tsx new file mode 100644 index 0000000..9964ded --- /dev/null +++ b/felsokning/app/src/pages/alva/Fakturor.tsx @@ -0,0 +1,181 @@ +// ALVA-PROC-0001 · Fakturor. +// +// Fakturan skapas i systemet, inte hos en betalleverantör. Det finns +// ingen kassa, ingen kortinmatning och ingen prenumeration att säga upp: +// ansökan granskas, faktura utfärdas, betalning registreras av en +// människa, organisationen aktiveras. +// +// Vyn visar därför två saker och inget mer: vad organisationen betalar +// för, och varför beloppet blev det det blev. Varje rad bär sitt +// underlag — antalet aktiva användare är inte en uppskattning utan de +// konton som faktiskt kan logga in. + +import { useMemo } from "react"; +import { + PRISLISTA, + fakturera, + formateraBelopp, +} from "../../../../services/gemensam/fakturering.mjs"; +import { Block, Demonstration, Etikett, FARG, Rubrik, Statusmärke, Tabell } from "@/alva/komponenter"; +import { Ram } from "./Ram"; + +interface Faktura { + beteckning: string; + organisation: string; + period: { fran: string; till: string }; + utfardad: string; + forfaller: string; + valuta: string; + rader: { benamning: string; underlag: string; antal: number; enhet: string; apris: number; belopp: number }[]; + netto: number; + moms: number; + momssats: number; + totalt: number; + status: string; + betalningssatt: string; +} + +// Exempelorganisationen. Siffrorna nedan är indata, inte resultat — +// fakturan under dem räknas fram av samma modul som servern använder. +const ORGANISATION = { + namn: "Verkstad Nord AB", + moduler: ["forsakringsrapportering"], +}; +const AKTIVA_ANVANDARE = 12; +const PERIOD = { fran: "2026-01-01", till: "2026-12-31" }; + +const STATUSAV: Record = { + betald: "passed", + utfardad: "pending", + krediterad: "not_applicable", +}; + +export default function Fakturor() { + const faktura = useMemo( + () => + fakturera({ + nummer: 1, + org: ORGANISATION, + aktiva: AKTIVA_ANVANDARE, + period: PERIOD, + utfardad: "2026-01-15", + }) as Faktura, + [], + ); + + return ( + +
+ Commercial +
+ Invoices +
+

+ ALVA is invoiced, not purchased online. No card details are handled and no payment provider is + involved. An invoice is derived from the organization’s own state — its licence period, its + active accounts, its enabled modules — so the amount cannot disagree with what the platform + actually holds. +

+ + + The organization below is an example, and the price list is a placeholder to be set per market. + The invoice itself is not: it is computed by the same module the server uses, from the inputs + shown. + + + +
+
+ {[ + ["Organization", faktura.organisation], + ["Period", `${faktura.period.fran} – ${faktura.period.till}`], + ["Issued", faktura.utfardad], + ["Due", faktura.forfaller], + ].map(([etikett, varde]) => ( +
+
{etikett}
+
{varde}
+
+ ))} +
+
+ Status +
+ +
+

+ {faktura.betalningssatt} Payment is registered by an administrator when the funds arrive — + the platform never records an invoice as paid on its own. +

+
+
+
+ + + [ + r.benamning, + + {r.underlag} + , + + {r.antal} {r.enhet} + , + + {formateraBelopp(r.apris, faktura.valuta)} + , + + {formateraBelopp(r.belopp, faktura.valuta)} + , + ])} + /> + +
+ {[ + ["Net", faktura.netto], + [`VAT ${Math.round(faktura.momssats * 100)} %`, faktura.moms], + ].map(([etikett, belopp]) => ( +
+
{etikett}
+
{formateraBelopp(belopp as number, faktura.valuta)}
+
+ ))} +
+
Total
+
{formateraBelopp(faktura.totalt, faktura.valuta)}
+
+
+
+ + +

+ One list, one place. Change a figure here and every invoice issued afterwards follows — + invoices already issued do not, because an issued invoice is never edited. A correction is a + credit note that points back at it, and it requires a stated reason. +

+ [ + (m as { namn: string }).namn, + "Enterprise module, annually", + formateraBelopp((m as { ar: number }).ar), + ]), + ].map((rad) => [ + rad[0], + + {rad[1]} + , + + {rad[2]} + , + ])} + /> +
+
+
+ ); +} diff --git a/felsokning/app/src/pages/alva/Ram.tsx b/felsokning/app/src/pages/alva/Ram.tsx index 049e7cb..5489128 100644 --- a/felsokning/app/src/pages/alva/Ram.tsx +++ b/felsokning/app/src/pages/alva/Ram.tsx @@ -20,6 +20,7 @@ const PORTAL = [ { till: "/alva/portal/analys", text: "Analysis" }, { till: "/alva/portal/kunskapskallor", text: "Knowledge sources" }, { till: "/alva/portal/integration", text: "Integration" }, + { till: "/alva/portal/fakturor", text: "Invoices" }, { till: "/felsokning", text: "Diagnostics" }, ]; diff --git a/felsokning/services/gemensam/fakturering.mjs b/felsokning/services/gemensam/fakturering.mjs new file mode 100644 index 0000000..8e137ec --- /dev/null +++ b/felsokning/services/gemensam/fakturering.mjs @@ -0,0 +1,212 @@ +// ALVA-PROC-0001 · Fakturering. +// +// ---- Varför fakturan skapas här och inte hos en betalleverantör ------- +// +// ALVA säljs inte över disk. Aktiveringssekvensen på webbplatsen säger +// det redan: ansökan granskas, faktura utfärdas, betalning registreras, +// organisationen aktiveras. Det finns ingen kassa att gå igenom, och +// därför ingen betalleverantör att fråga vad kunden ska betala. +// +// Det betyder att beloppet måste komma någonstans ifrån, och det enda +// hållbara stället är organisationens faktiska tillstånd: hur många +// aktiva användare den har, vilken licensperiod som gäller, vilka +// moduler som är påslagna. En summa någon skriver in för hand kan säga +// emot verkligheten. En summa som härleds kan inte det. +// +// Samma resonemang som sammanfattningen (ALVA-PROC-0030): härledd, inte +// inmatad. Det är också det som gör fakturan granskbar i efterhand — +// underlaget står i raderna, inte i någons minne. +// +// ---- Vad som INTE finns här ------------------------------------------- +// +// Ingen betalningshantering, inga kortuppgifter, ingen extern tjänst. +// Betalning registreras av en administratör när pengarna kommit in. +// Systemet påstår aldrig att något är betalt utan att en människa sagt +// det, av samma skäl som en kontroll aldrig är verifierad utan evidens. +// +// ---- Oföränderlighet --------------------------------------------------- +// +// En utfärdad faktura ändras aldrig. Blev den fel utfärdas en +// kreditfaktura som pekar på den, och en ny. Det är samma regel som för +// händelseloggen: en felaktig post rättas inte, den bemöts. + +/** + * Prislistan. + * + * Beloppen är i minsta valutaenhet (öre) för att undvika flyttalsfel — + * en faktura får aldrig avrunda fel åt något håll. + * + * VÄRDENA NEDAN ÄR PLATSHÅLLARE och ska sättas av produktägaren per + * marknad. Mekanismen är oberoende av dem: byt siffror, och varje + * kommande faktura följer med. + */ +export const PRISLISTA = { + valuta: "SEK", + /** Plattformslicens, per organisation och år. */ + plattform_ar: 2_400_000, + /** Användarlicens, per aktiv användare och månad. */ + anvandare_manad: 39_000, + /** Tillval, per modul och år. */ + moduler: { + forsakringsrapportering: { namn: "Insurance reporting", ar: 600_000 }, + flottanalys: { namn: "Fleet analytics", ar: 960_000 }, + kunskapskallor_oem: { namn: "OEM knowledge sources", ar: 1_440_000 }, + }, + /** Mervärdesskatt. 25 % är svensk normalskattesats. */ + momssats: 0.25, + /** Betalningsvillkor i dagar. */ + betalningsvillkor: 30, +}; + +/** Fakturans tillstånd. Ordningen är enkelriktad utom kredit. */ +export const FAKTURASTATUS = ["utfardad", "betald", "krediterad"]; + +/** + * Beräknar fakturarader ur organisationens tillstånd. + * + * Varje rad bär sitt underlag — antal, à-pris och vad antalet kommer + * ifrån. En granskare ska kunna se varför summan blev den den blev utan + * att fråga någon. + * + * @param org { namn, licensstart, moduler: string[] } + * @param aktiva antal aktiva användare i organisationen + * @param period { fran: "2026-01-01", till: "2026-12-31" } + * @param prislista valfri; standard är PRISLISTA + */ +export function fakturarader(org, aktiva, period, prislista = PRISLISTA) { + const rader = []; + const månader = manaderMellan(period.fran, period.till); + + rader.push({ + benamning: "Platform license", + underlag: `${org?.namn ?? "Organization"} · ${period.fran} – ${period.till}`, + antal: 1, + enhet: "organization/year", + apris: prislista.plattform_ar, + belopp: prislista.plattform_ar, + }); + + if (aktiva > 0 && månader > 0) { + rader.push({ + benamning: "User licenses", + // Antalet är inte en uppskattning: det är de konton som faktiskt + // kan logga in när fakturan utfärdas. + underlag: `${aktiva} active users × ${månader} months`, + antal: aktiva * månader, + enhet: "user/month", + apris: prislista.anvandare_manad, + belopp: aktiva * månader * prislista.anvandare_manad, + }); + } + + for (const nyckel of org?.moduler ?? []) { + const modul = prislista.moduler[nyckel]; + if (!modul) continue; + rader.push({ + benamning: modul.namn, + underlag: `Enterprise module · ${period.fran} – ${period.till}`, + antal: 1, + enhet: "module/year", + apris: modul.ar, + belopp: modul.ar, + }); + } + + return rader; +} + +/** + * Sätter ihop en faktura. + * + * Returnerar ett objekt som är fullständigt i sig själv: raderna, summan, + * momsen, förfallodagen och vad allt härleddes ur. Den som läser den om + * två år ska inte behöva systemet för att förstå den. + */ +export function fakturera({ nummer, org, aktiva, period, utfardad, prislista = PRISLISTA }) { + const rader = fakturarader(org, aktiva, period, prislista); + const netto = rader.reduce((summa, r) => summa + r.belopp, 0); + // Momsen räknas på nettosumman, inte per rad: radvis avrundning ger + // ören som inte stämmer mot totalen. + const moms = Math.round(netto * prislista.momssats); + + return { + version: "ALVA-PROC-0001", + beteckning: fakturabeteckning(nummer), + organisation: org?.namn ?? "Organization", + period, + utfardad, + forfaller: adderaDagar(utfardad, prislista.betalningsvillkor), + valuta: prislista.valuta, + rader, + netto, + momssats: prislista.momssats, + moms, + totalt: netto + moms, + status: "utfardad", + betalningssatt: "Invoice. No online payment is accepted.", + }; +} + +/** + * Kreditering. + * + * En utfärdad faktura ändras aldrig. Krediteringen är en egen post som + * pekar tillbaka, med samma rader och omvänt tecken, och den kräver ett + * skäl — av samma anledning som ett avslut kräver ett varför. + */ +export function kreditera(faktura, { nummer, utfardad, orsak }) { + if (!orsak || orsak.trim().length < 10) { + return { fel: "En kreditering kräver ett skäl som går att granska i efterhand." }; + } + return { + kredit: { + ...faktura, + beteckning: fakturabeteckning(nummer), + utfardad, + krediterar: faktura.beteckning, + orsak: orsak.trim(), + rader: faktura.rader.map((r) => ({ ...r, antal: -r.antal, belopp: -r.belopp })), + netto: -faktura.netto, + moms: -faktura.moms, + totalt: -faktura.totalt, + status: "krediterad", + }, + }; +} + +/** ALVA-INV-0001 */ +export const fakturabeteckning = (nummer) => `ALVA-INV-${String(nummer).padStart(4, "0")}`; + +/** Hela månader mellan två datum, inklusive startmånaden. */ +export function manaderMellan(fran, till) { + const a = new Date(fran); + const b = new Date(till); + if (Number.isNaN(a.getTime()) || Number.isNaN(b.getTime()) || b < a) return 0; + return (b.getFullYear() - a.getFullYear()) * 12 + (b.getMonth() - a.getMonth()) + 1; +} + +function adderaDagar(datum, dagar) { + const d = new Date(datum); + if (Number.isNaN(d.getTime())) return datum; + d.setDate(d.getDate() + dagar); + return d.toISOString().slice(0, 10); +} + +/** + * Belopp för läsning. Öre in, sträng ut. + * + * Fast antal decimaler och hårt mellanslag som tusentalsavskiljare — + * belopp jämförs oftare än de läses, precis som beteckningar. + */ +/** Hårt mellanslag: ett belopp får aldrig brytas över en radbrytning. */ +const TUSENTAL = "\u00A0"; + +export function formateraBelopp(ore, valuta = PRISLISTA.valuta) { + const negativt = ore < 0; + const kronor = Math.abs(ore) / 100; + const text = kronor + .toFixed(2) + .replace(".", ",") + .replace(/\B(?=(\d{3})+(?!\d))/g, TUSENTAL); + return `${negativt ? "−" : ""}${text} ${valuta}`; +}