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:
@@ -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" },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user