ALVA deployment config
Guidad Felsökning / test-och-bygg (push) Has been cancelled
Guidad Felsökning / tjanster (push) Has been cancelled
Guidad Felsökning / terraform (push) Has been cancelled
Guidad Felsökning / leverantorskedja (push) Has been cancelled
Guidad Felsökning / aterstallning (push) Has been cancelled
Guidad Felsökning / publicera (push) Has been cancelled
Guidad Felsökning / driftsatt (push) Has been cancelled

This commit is contained in:
Bernt
2026-08-08 13:12:13 +00:00
parent 9872f3bf47
commit 04e0a1e8ec
66 changed files with 16749 additions and 4 deletions
@@ -0,0 +1,257 @@
// 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"];
/**
* Statusen är en projektion, inte en kolumn.
*
* Följden av att en utfärdad faktura aldrig ändras: "betald" kan inte
* skrivas ovanpå "utfärdad". Betalningen är en händelse som inträffar
* efteråt, och tillståndet härleds ur händelserna — samma förhållande
* som mellan ärendets tillstånd och dess logg.
*
* Kreditering väger tyngst. En krediterad faktura som också hunnit bli
* betald är fortfarande krediterad; det är återbetalningen som är kvar
* att göra, och den frågan besvaras inte av ett statusfält.
*
* @param poster [{ typ: "betald" | "krediterad", ... }]
*/
export function fakturastatus(poster = []) {
if (poster.some((p) => p?.typ === "krediterad")) return "krediterad";
if (poster.some((p) => p?.typ === "betald")) return "betald";
return "utfardad";
}
/**
* Granskar en fakturaperiod innan något härleds ur den.
*
* En bakvänd period ger noll månader i manaderMellan(), vilket tyst
* skulle bli en faktura utan användarrad i stället för ett fel. Den
* sortens tystnad är värre än ett avslag: fakturan ser rimlig ut.
*
* Returnerar en felsträng, eller null när perioden duger.
*/
export function granskaPeriod(period) {
const datum = (v) => typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v) && !Number.isNaN(Date.parse(v));
if (!period || !datum(period.fran) || !datum(period.till)) {
return "Perioden kräver fran och till som datum (ÅÅÅÅ-MM-DD).";
}
if (new Date(period.till) < new Date(period.fran)) {
return "Perioden slutar före den börjar.";
}
// Ett tak finns för att ett skrivfel i årtalet inte ska bli en faktura
// på nittio år. Fem år rymmer varje rimlig licensperiod.
if (manaderMellan(period.fran, period.till) > 60) {
return "Perioden är längre än fem år — kontrollera datumen.";
}
return null;
}
/**
* Beräknar fakturarader ur organisationens tillstånd.
*
* 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}`;
}