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
+2
View File
@@ -27,6 +27,7 @@ import AlvaLoggaIn from "./pages/alva/LoggaIn";
import AlvaPortal from "./pages/alva/Portal"; import AlvaPortal from "./pages/alva/Portal";
import AlvaKunskapskallor from "./pages/alva/Kunskapskallor"; import AlvaKunskapskallor from "./pages/alva/Kunskapskallor";
import AlvaAnalys from "./pages/alva/Analys"; import AlvaAnalys from "./pages/alva/Analys";
import AlvaFakturor from "./pages/alva/Fakturor";
import AlvaIntegration from "./pages/alva/Integration"; import AlvaIntegration from "./pages/alva/Integration";
import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner"; import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner";
@@ -68,6 +69,7 @@ function AppContent() {
<Route path="/alva/portal/kunskapskallor" element={<AlvaKunskapskallor />} /> <Route path="/alva/portal/kunskapskallor" element={<AlvaKunskapskallor />} />
<Route path="/alva/portal/analys" element={<AlvaAnalys />} /> <Route path="/alva/portal/analys" element={<AlvaAnalys />} />
<Route path="/alva/portal/integration" element={<AlvaIntegration />} /> <Route path="/alva/portal/integration" element={<AlvaIntegration />} />
<Route path="/alva/portal/fakturor" element={<AlvaFakturor />} />
<Route path="/felsokning" element={<Arendelista />} /> <Route path="/felsokning" element={<Arendelista />} />
<Route path="/felsokning/nytt" element={<NyttArende />} /> <Route path="/felsokning/nytt" element={<NyttArende />} />
<Route path="/felsokning/arende/:id" element={<ArendeSida />} /> <Route path="/felsokning/arende/:id" element={<ArendeSida />} />
+3 -1
View File
@@ -99,7 +99,8 @@ export type Objektklass =
| "ORG" // en organisation | "ORG" // en organisation
| "USER" // en användare | "USER" // en användare
| "SRC" // en kunskapskälla | "SRC" // en kunskapskälla
| "REP"; // en rapport | "REP" // en rapport
| "INV"; // en faktura
const SIFFROR: Record<Objektklass, number> = { const SIFFROR: Record<Objektklass, number> = {
CASE: 5, CASE: 5,
@@ -112,6 +113,7 @@ const SIFFROR: Record<Objektklass, number> = {
USER: 4, USER: 4,
SRC: 3, SRC: 3,
REP: 4, REP: 4,
INV: 4,
}; };
/** ALVA-CASE-91821 · ALVA-PROC-0042 · ALVA-RULE-120 */ /** ALVA-CASE-91821 · ALVA-PROC-0042 · ALVA-RULE-120 */
@@ -310,3 +310,43 @@ describe("demonstrationsmärkningen är entydig", () => {
expect(sida("LoggaIn.tsx")).not.toContain("Real authentication exists in the platform service"); expect(sida("LoggaIn.tsx")).not.toContain("Real authentication exists in the platform service");
}); });
}); });
// ---- ALVA tar inte emot betalningar ------------------------------------
//
// `@stripe/stripe-js` injicerar sitt skript i sidan redan vid import,
// inte när någon ska betala. ALVA laddade därför Stripe på varje
// sidvisning trots att produkten faktureras och aldrig tar emot en
// betalning — och bakom artefaktens CSP blockerades anropet och syntes
// som ett fel i konsolen.
//
// `/pure` är samma modul utan sidoeffekten. Testet låser den, eftersom
// en `import { loadStripe } from "@stripe/stripe-js"` någonstans räcker
// för att skjuta upp skriptet igen.
describe("ingen betalleverantör laddas", () => {
const stripe = readFileSync("src/lib/stripe.ts", "utf8");
it("stripe-modulen importeras utan sidoeffekt", () => {
expect(stripe).toContain('from "@stripe/stripe-js/pure"');
// Ett värdeimport från huvudmodulen skulle återinföra skriptladdningen.
expect(stripe).not.toMatch(/import \{[^}]*loadStripe[^}]*\} from "@stripe\/stripe-js"/);
});
it("ingen ALVA-yta rör en betalleverantör", () => {
for (const [namn, kod] of filer) {
expect(kod.toLowerCase(), namn).not.toContain("stripe");
}
});
it("fakturamodellen finns och tar inte emot betalningar", async () => {
const m = await import("../../../../services/gemensam/fakturering.mjs");
const f = m.fakturera({
nummer: 1,
org: { namn: "Verkstad Nord AB", moduler: [] },
aktiva: 3,
period: { fran: "2026-01-01", till: "2026-12-31" },
utfardad: "2026-01-15",
});
expect(f.betalningssatt).toMatch(/No online payment/i);
expect(Object.keys(m)).not.toContain("betala");
});
});
@@ -0,0 +1,134 @@
// @vitest-environment node
// ALVA-PROC-0001 · Fakturan härleds, den skrivs inte in.
//
// Testerna nedan handlar mindre om aritmetik än om två egenskaper som
// avgör om en faktura går att granska två år senare: att varje rad bär
// sitt underlag, och att en utfärdad faktura aldrig ändras.
import { describe, expect, it } from "vitest";
import {
PRISLISTA,
fakturabeteckning,
fakturarader,
fakturera,
formateraBelopp,
kreditera,
manaderMellan,
} from "../../../../services/gemensam/fakturering.mjs";
const ORG = { namn: "Verkstad Nord AB", moduler: ["forsakringsrapportering"] };
const PERIOD = { fran: "2026-01-01", till: "2026-12-31" };
describe("perioden räknas i hela månader", () => {
it("ett kalenderår är tolv månader", () => {
expect(manaderMellan("2026-01-01", "2026-12-31")).toBe(12);
});
it("en enda månad är en, inte noll", () => {
expect(manaderMellan("2026-03-01", "2026-03-31")).toBe(1);
});
it("en bakvänd eller trasig period ger noll i stället för ett negativt belopp", () => {
expect(manaderMellan("2026-12-31", "2026-01-01")).toBe(0);
expect(manaderMellan("inte ett datum", "2026-01-01")).toBe(0);
});
});
describe("raderna bär sitt underlag", () => {
it("varje rad säger var antalet kommer ifrån", () => {
const rader = fakturarader(ORG, 12, PERIOD);
for (const r of rader) {
expect(r.underlag, r.benamning).toBeTruthy();
expect(r.belopp).toBe(r.antal * r.apris);
}
});
it("användarraden räknar aktiva konton gånger månader", () => {
const rad = fakturarader(ORG, 12, PERIOD).find((r) => r.benamning === "User licenses");
expect(rad.antal).toBe(12 * 12);
expect(rad.underlag).toContain("12 active users");
expect(rad.underlag).toContain("12 months");
});
it("en organisation utan aktiva användare får ingen användarrad — inte en nollrad", () => {
const rader = fakturarader(ORG, 0, PERIOD);
expect(rader.some((r) => r.benamning === "User licenses")).toBe(false);
});
it("bara påslagna moduler faktureras, och okända moduler tigs ihjäl", () => {
const rader = fakturarader({ ...ORG, moduler: ["forsakringsrapportering", "hittepa"] }, 1, PERIOD);
expect(rader.filter((r) => r.enhet === "module/year")).toHaveLength(1);
});
});
describe("summan går ihop", () => {
const f = fakturera({ nummer: 1, org: ORG, aktiva: 12, period: PERIOD, utfardad: "2026-01-15" });
it("nettot är summan av raderna", () => {
expect(f.netto).toBe(f.rader.reduce((s, r) => s + r.belopp, 0));
});
it("momsen räknas på nettot, inte per rad", () => {
// Radvis avrundning ger ören som inte stämmer mot totalen.
expect(f.moms).toBe(Math.round(f.netto * PRISLISTA.momssats));
expect(f.totalt).toBe(f.netto + f.moms);
});
it("förfallodagen följer betalningsvillkoret", () => {
expect(f.forfaller).toBe("2026-02-14"); // 15 jan + 30 dagar
});
it("beteckningen följer nomenklaturen", () => {
expect(f.beteckning).toBe("ALVA-INV-0001");
expect(fakturabeteckning(1234)).toBe("ALVA-INV-1234");
});
it("fakturan säger uttryckligen att ingen onlinebetalning tas emot", () => {
expect(f.betalningssatt).toMatch(/No online payment/i);
});
});
describe("en utfärdad faktura ändras aldrig", () => {
const f = fakturera({ nummer: 1, org: ORG, aktiva: 12, period: PERIOD, utfardad: "2026-01-15" });
it("krediteringen är en egen post som pekar tillbaka", () => {
const { kredit } = kreditera(f, {
nummer: 2,
utfardad: "2026-02-01",
orsak: "Antalet aktiva användare var felaktigt vid utfärdandet.",
});
expect(kredit.krediterar).toBe(f.beteckning);
expect(kredit.beteckning).toBe("ALVA-INV-0002");
expect(kredit.totalt).toBe(-f.totalt);
// Originalet är orört.
expect(f.status).toBe("utfardad");
expect(f.totalt).toBeGreaterThan(0);
});
it("en kreditering utan skäl avvisas", () => {
expect(kreditera(f, { nummer: 2, utfardad: "2026-02-01", orsak: "" }).fel).toBeTruthy();
expect(kreditera(f, { nummer: 2, utfardad: "2026-02-01", orsak: "fel" }).fel).toBeTruthy();
});
});
describe("belopp skrivs så att de går att jämföra", () => {
it("två decimaler och hårt mellanslag som tusentalsavskiljare", () => {
// Hårt mellanslag, uttryckligen: ett belopp får inte brytas över en
// radbrytning, och skillnaden mot ett vanligt mellanslag ska synas i
// testet i stället för att ligga osynlig i en sträng.
const NBSP = "\u00A0";
expect(formateraBelopp(2_400_000)).toBe(`24${NBSP}000,00 SEK`);
expect(formateraBelopp(39_000)).toBe("390,00 SEK");
expect(formateraBelopp(2_400_000)).not.toContain(" 000");
});
it("negativa belopp märks med minustecken, inte parentes", () => {
expect(formateraBelopp(-2_400_000)).toMatch(/^/);
});
it("räknar i öre — inga flyttalsfel i totalen", () => {
const f = fakturera({ nummer: 1, org: ORG, aktiva: 7, period: PERIOD, utfardad: "2026-01-15" });
expect(Number.isInteger(f.netto)).toBe(true);
expect(Number.isInteger(f.moms)).toBe(true);
expect(Number.isInteger(f.totalt)).toBe(true);
});
});
+13 -1
View File
@@ -1,4 +1,16 @@
import { loadStripe, Stripe } from "@stripe/stripe-js"; // `@stripe/stripe-js` injicerar sitt skript i sidan redan vid IMPORT,
// inte när någon ska betala. Följden var att ALVA — som varken har eller
// vill ha en betalleverantör — laddade Stripe på varje sidvisning, och
// bakom artefaktens CSP blockerades anropet och syntes som ett fel i
// konsolen på en produkt som inte tar emot betalningar alls.
//
// `/pure` är samma modul utan den sidoeffekten: skriptet hämtas först om
// och när loadStripe faktiskt anropas. ALVA anropar det aldrig — där
// faktureras det, och fakturan skapas i systemet
// (services/gemensam/fakturering.mjs). Butikens kassa fungerar som
// tidigare, men bara när någon öppnar den.
import type { Stripe } from "@stripe/stripe-js";
import { loadStripe } from "@stripe/stripe-js/pure";
export type StripeEnv = "sandbox" | "live"; export type StripeEnv = "sandbox" | "live";
+181
View File
@@ -0,0 +1,181 @@
// ALVA-PROC-0001 · Fakturor.
//
// Fakturan skapas i systemet, inte hos en betalleverantör. Det finns
// ingen kassa, ingen kortinmatning och ingen prenumeration att säga upp:
// ansökan granskas, faktura utfärdas, betalning registreras av en
// människa, organisationen aktiveras.
//
// Vyn visar därför två saker och inget mer: vad organisationen betalar
// för, och varför beloppet blev det det blev. Varje rad bär sitt
// underlag — antalet aktiva användare är inte en uppskattning utan de
// konton som faktiskt kan logga in.
import { useMemo } from "react";
import {
PRISLISTA,
fakturera,
formateraBelopp,
} from "../../../../services/gemensam/fakturering.mjs";
import { Block, Demonstration, Etikett, FARG, Rubrik, Statusmärke, Tabell } from "@/alva/komponenter";
import { Ram } from "./Ram";
interface Faktura {
beteckning: string;
organisation: string;
period: { fran: string; till: string };
utfardad: string;
forfaller: string;
valuta: string;
rader: { benamning: string; underlag: string; antal: number; enhet: string; apris: number; belopp: number }[];
netto: number;
moms: number;
momssats: number;
totalt: number;
status: string;
betalningssatt: string;
}
// Exempelorganisationen. Siffrorna nedan är indata, inte resultat —
// fakturan under dem räknas fram av samma modul som servern använder.
const ORGANISATION = {
namn: "Verkstad Nord AB",
moduler: ["forsakringsrapportering"],
};
const AKTIVA_ANVANDARE = 12;
const PERIOD = { fran: "2026-01-01", till: "2026-12-31" };
const STATUSAV: Record<string, "passed" | "pending" | "not_applicable"> = {
betald: "passed",
utfardad: "pending",
krediterad: "not_applicable",
};
export default function Fakturor() {
const faktura = useMemo(
() =>
fakturera({
nummer: 1,
org: ORGANISATION,
aktiva: AKTIVA_ANVANDARE,
period: PERIOD,
utfardad: "2026-01-15",
}) as Faktura,
[],
);
return (
<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}>Invoices</Rubrik>
</div>
<p className="mb-8 max-w-[680px] text-[14px] leading-[22px]" style={{ color: FARG.steel }}>
ALVA is invoiced, not purchased online. No card details are handled and no payment provider is
involved. An invoice is derived from the organization&rsquo;s own state its licence period, its
active accounts, its enabled modules so the amount cannot disagree with what the platform
actually holds.
</p>
<Demonstration>
The organization below is an example, and the price list is a placeholder to be set per market.
The invoice itself is not: it is computed by the same module the server uses, from the inputs
shown.
</Demonstration>
<Block rubrik="Invoice" beteckning={faktura.beteckning}>
<div className="grid gap-6 sm:grid-cols-2">
<dl className="text-[13px] leading-[22px]">
{[
["Organization", faktura.organisation],
["Period", `${faktura.period.fran} ${faktura.period.till}`],
["Issued", faktura.utfardad],
["Due", faktura.forfaller],
].map(([etikett, varde]) => (
<div key={etikett} className="flex justify-between border-b py-2" style={{ borderColor: FARG.lightSteel }}>
<dt style={{ color: FARG.steel }}>{etikett}</dt>
<dd className="font-mono">{varde}</dd>
</div>
))}
</dl>
<div>
<Etikett>Status</Etikett>
<div className="mt-2">
<Statusmärke status={STATUSAV[faktura.status] ?? "pending"} />
</div>
<p className="mt-6 text-[12px] leading-[18px]" style={{ color: FARG.steel }}>
{faktura.betalningssatt} Payment is registered by an administrator when the funds arrive
the platform never records an invoice as paid on its own.
</p>
</div>
</div>
</Block>
<Block rubrik="Lines" beteckning="ALVA-PROC-0001">
<Tabell
kolumner={["Item", "Basis", "Quantity", "Unit price", "Amount"]}
rader={faktura.rader.map((r) => [
r.benamning,
<span key="u" className="text-[12px]" style={{ color: FARG.steel }}>
{r.underlag}
</span>,
<span key="a" className="font-mono">
{r.antal} {r.enhet}
</span>,
<span key="p" className="font-mono">
{formateraBelopp(r.apris, faktura.valuta)}
</span>,
<span key="b" className="font-mono">
{formateraBelopp(r.belopp, faktura.valuta)}
</span>,
])}
/>
<dl className="mt-6 ml-auto max-w-[320px] text-[13px] leading-[22px]">
{[
["Net", faktura.netto],
[`VAT ${Math.round(faktura.momssats * 100)} %`, faktura.moms],
].map(([etikett, belopp]) => (
<div key={etikett as string} className="flex justify-between border-b py-2" style={{ borderColor: FARG.lightSteel }}>
<dt style={{ color: FARG.steel }}>{etikett}</dt>
<dd className="font-mono">{formateraBelopp(belopp as number, faktura.valuta)}</dd>
</div>
))}
<div className="flex justify-between border-b-2 py-2" style={{ borderColor: FARG.graphite }}>
<dt className="font-semibold">Total</dt>
<dd className="font-mono font-semibold">{formateraBelopp(faktura.totalt, faktura.valuta)}</dd>
</div>
</dl>
</Block>
<Block rubrik="Price list" beteckning="ALVA-SPEC-030">
<p className="mb-4 text-[13px] leading-[20px]" style={{ color: FARG.steel }}>
One list, one place. Change a figure here and every invoice issued afterwards follows
invoices already issued do not, because an issued invoice is never edited. A correction is a
credit note that points back at it, and it requires a stated reason.
</p>
<Tabell
kolumner={["Component", "Basis", "Price"]}
rader={[
["Platform license", "Per organization, annually", formateraBelopp(PRISLISTA.plattform_ar)],
["User licenses", "Per active user, monthly", formateraBelopp(PRISLISTA.anvandare_manad)],
...Object.values(PRISLISTA.moduler).map((m) => [
(m as { namn: string }).namn,
"Enterprise module, annually",
formateraBelopp((m as { ar: number }).ar),
]),
].map((rad) => [
rad[0],
<span key="b" className="text-[12px]" style={{ color: FARG.steel }}>
{rad[1]}
</span>,
<span key="p" className="font-mono">
{rad[2]}
</span>,
])}
/>
</Block>
</div>
</Ram>
);
}
+1
View File
@@ -20,6 +20,7 @@ const PORTAL = [
{ till: "/alva/portal/analys", text: "Analysis" }, { till: "/alva/portal/analys", text: "Analysis" },
{ till: "/alva/portal/kunskapskallor", text: "Knowledge sources" }, { till: "/alva/portal/kunskapskallor", text: "Knowledge sources" },
{ till: "/alva/portal/integration", text: "Integration" }, { till: "/alva/portal/integration", text: "Integration" },
{ till: "/alva/portal/fakturor", text: "Invoices" },
{ till: "/felsokning", text: "Diagnostics" }, { till: "/felsokning", text: "Diagnostics" },
]; ];
@@ -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}`;
}