Fakturering i systemet — och ingen betalleverantör

ALVA säljs inte över disk. Aktiveringssekvensen på webbplatsen har hela
tiden sagt det: ansökan granskas, faktura utfärdas, betalning
registreras, organisationen aktiveras. Ändå laddade sidan Stripe.

Stripe. `@stripe/stripe-js` injicerar sitt skript redan vid import, inte
när någon ska betala, så ALVA hämtade en betalleverantör vid varje
sidvisning trots att produkten aldrig tar emot en betalning. Bakom
artefaktens CSP blockerades anropet och syntes som ett fel i konsolen på
ett system som inte har en kassa. Importen går nu mot `/pure`, som är
samma modul utan sidoeffekten. Butikens kassa fungerar som förut, men
bara när någon faktiskt öppnar den. Externa begäranden på ALVA-ytan:
noll.

Faktureringen. Beloppet måste komma någonstans ifrån, och det enda
hållbara stället är organisationens faktiska tillstånd: aktiva konton,
licensperiod, påslagna moduler. En summa någon skriver in kan säga emot
verkligheten — en härledd kan inte det. Samma resonemang som
sammanfattningen. Varje rad bär sitt underlag, så en granskare ser varför
summan blev den den blev utan att fråga någon.

Beloppen räknas i öre, momsen på nettot och inte per rad, och en utfärdad
faktura ändras aldrig: en felaktig faktura krediteras med en post som
pekar tillbaka och kräver ett skäl. Samma regel som för händelseloggen.

Prislistan ligger på ett ställe och är märkt som platshållare — den ska
sättas per marknad. Mekanismen är oberoende av siffrorna.

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.

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