04e0a1e8ec
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
258 lines
9.2 KiB
JavaScript
258 lines
9.2 KiB
JavaScript
// 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}`;
|
||
}
|