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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
<Route path="/alva/portal/analys" element={<Portalvakt><AlvaAnalys /></Portalvakt>} />
|
||||
<Route path="/alva/portal/integration" element={<Portalvakt><AlvaIntegration /></Portalvakt>} />
|
||||
<Route path="/alva/portal/fakturor" element={<Portalvakt><AlvaFakturor /></Portalvakt>} />
|
||||
<Route path="/alva/portal/abonnemang" element={<Portalvakt><AlvaAbonnemang /></Portalvakt>} />
|
||||
<Route path="/alva/portal/garantier" element={<Portalvakt><AlvaGarantier /></Portalvakt>} />
|
||||
<Route path="/alva/portal/forsakring" element={<Portalvakt><AlvaForsakring /></Portalvakt>} />
|
||||
<Route path="/alva/portal/support" element={<Portalvakt><AlvaSupport /></Portalvakt>} />
|
||||
|
||||
@@ -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 --------------------------------------------------------
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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<Abonnemang> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 {
|
||||
|
||||
@@ -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<AbonnemangsData | null>(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 (
|
||||
<Ram portal>
|
||||
<div className="mx-auto max-w-[1040px] px-6 py-12">
|
||||
<Etikett>Commercial</Etikett>
|
||||
<div className="mt-2 mb-2">
|
||||
<Rubrik niva={1}>Subscription</Rubrik>
|
||||
</div>
|
||||
<p className="mb-8 max-w-[680px] text-[14px] leading-[22px]" style={{ color: FARG.steel }}>
|
||||
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.
|
||||
</p>
|
||||
|
||||
{!skarpt && (
|
||||
<Demonstration>
|
||||
The state below is an example. Connected to a platform instance this page shows the
|
||||
organization’s own subscription.
|
||||
</Demonstration>
|
||||
)}
|
||||
|
||||
{fel && (
|
||||
<Block rubrik="Status" beteckning="ALVA-PROC-0002">
|
||||
<p className="text-[13px]" style={{ color: FARG.steel }}>
|
||||
Subscription could not be retrieved: {fel}
|
||||
</p>
|
||||
</Block>
|
||||
)}
|
||||
|
||||
{/* Beskedet överst. Ett varningsbesked under prisplanerna är
|
||||
inget besked. */}
|
||||
{a?.besked && (
|
||||
<div
|
||||
className="mb-8 border-l-4 px-6 py-4"
|
||||
style={{
|
||||
borderColor: a.tillstand === "last" ? FARG.stoppat : FARG.varning,
|
||||
background: FARG.white,
|
||||
}}
|
||||
>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.08em]" style={{ color: FARG.steel }}>
|
||||
{a.tillstand === "last" ? "Locked" : `${a.dagarKvar} days remaining`}
|
||||
</p>
|
||||
<p className="mt-2 max-w-[680px] text-[14px] leading-[23px]">{a.besked}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{a && (
|
||||
<Block rubrik="Current" beteckning="ALVA-PROC-0002">
|
||||
<Tabell
|
||||
kolumner={["Field", "Value"]}
|
||||
rader={[
|
||||
["Plan", a.nivanamn],
|
||||
["State", a.tillstand === "aktiv" ? "Active" : a.tillstand === "varning" ? "Past due" : "Locked"],
|
||||
["Started", (a.registrerad ?? "").slice(0, 10)],
|
||||
["Last invoiced", a.senast_fakturerad ?? "Not yet"],
|
||||
["Invoice email", a.fakturaepost || "Not set — invoices are collected from the portal"],
|
||||
].map(([e, v]) => [
|
||||
<span key="e" className="font-semibold">
|
||||
{e}
|
||||
</span>,
|
||||
<span key="v" className="font-mono text-[12px]">
|
||||
{v}
|
||||
</span>,
|
||||
])}
|
||||
/>
|
||||
</Block>
|
||||
)}
|
||||
|
||||
<Block rubrik="Invoice delivery" beteckning="ALVA-PROC-0001">
|
||||
<p className="mb-4 max-w-[680px] text-[13px] leading-[21px]" style={{ color: FARG.steel }}>
|
||||
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.
|
||||
</p>
|
||||
<div className="grid max-w-[420px] gap-4">
|
||||
<Textfalt label="Invoice email" varde={epost} andra={setEpost} typ="email" platshallare="faktura@verkstad.se" />
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Knapp
|
||||
onClick={async () => {
|
||||
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
|
||||
</Knapp>
|
||||
</div>
|
||||
{sparat && (
|
||||
<p className="text-[13px]" style={{ color: FARG.steel }}>
|
||||
{sparat}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Block>
|
||||
|
||||
<Block rubrik="Plans" beteckning="ALVA-PROC-0002">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{(NIVAER as Niva[]).map((n) => {
|
||||
const vald = a?.niva === n.id;
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
className="border p-6"
|
||||
style={{ borderColor: vald ? FARG.graphite : FARG.lightSteel, background: FARG.white }}
|
||||
>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<span className="text-[16px] font-semibold">{n.namn}</span>
|
||||
{vald && (
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.08em]" style={{ color: FARG.blue }}>
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 font-mono text-[13px]">
|
||||
{n.basavgift === null
|
||||
? "By agreement"
|
||||
: n.basavgift === 0
|
||||
? "No charge"
|
||||
: `${formateraBelopp(n.basavgift)} / month + ${formateraBelopp(n.anvandaravgift ?? 0)} per active account`}
|
||||
</p>
|
||||
<p className="mt-2 text-[13px] leading-[21px]" style={{ color: FARG.steel }}>
|
||||
{n.text}
|
||||
</p>
|
||||
<ul className="mt-4 grid gap-2">
|
||||
{n.ingar.map((i) => (
|
||||
<li key={i} className="text-[12px] leading-[19px]">
|
||||
{i}
|
||||
</li>
|
||||
))}
|
||||
{n.saknas.map((i) => (
|
||||
<li key={i} className="text-[12px] leading-[19px]" style={{ color: FARG.steel }}>
|
||||
Not included: {i}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{skarpt && !vald && (
|
||||
<div className="mt-6">
|
||||
<Knapp
|
||||
variant="sekundar"
|
||||
onClick={async () => {
|
||||
await sattNiva(n.id).catch(() => {});
|
||||
await ladda();
|
||||
}}
|
||||
>
|
||||
Choose {n.namn}
|
||||
</Knapp>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Block>
|
||||
|
||||
{/* 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. */}
|
||||
<Block rubrik="If an invoice goes unpaid" beteckning="ALVA-PROC-0002">
|
||||
<p className="max-w-[680px] text-[14px] leading-[23px]">
|
||||
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.
|
||||
</p>
|
||||
<p className="mt-4 max-w-[680px] text-[14px] leading-[23px] font-semibold">
|
||||
Reading and export are never affected — in any state, for any reason.
|
||||
</p>
|
||||
<p className="mt-2 max-w-[680px] text-[13px] leading-[21px]" style={{ color: FARG.steel }}>
|
||||
That is not generosity. A case log is your evidence in a warranty or insurance dispute
|
||||
concerning <em>your</em> 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.
|
||||
</p>
|
||||
</Block>
|
||||
</div>
|
||||
</Ram>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
../gemensam/abonnemang.mjs
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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." });
|
||||
|
||||
Reference in New Issue
Block a user