@mark2form/catalog
v0.4.0
Published
De productcatalogus van Mark2Form: producten, modi, slugs, uitvoerformaten, staffels per modus, de btw-regel, de proefperiode, het jaarplan en de welkomstactie.
Readme
@mark2form/catalog
Wat Mark2Form verkoopt, als data: categorieën, producten, slugs,
uitvoerformaten, de staffels per product, de btw-regel, de proefperiode, het jaarplan en de welkomstactie. Klein, nul dependencies, gepubliceerd
op npm (public scope @mark2form).
Structuurplan §5.5. De staffels zelf zijn §9.1.
Waarom dit package bestaat
Een prijs staat op drie plekken: de prijspagina van de site, het portaal en straks de factuur. Drie plekken die hetzelfde moeten zeggen, dus één bron (§3 principe 3) — een prijs die op de site anders staat dan op de factuur is geen weergavefout maar een geschil.
Nul dependencies is een eis en geen toeval: mark2form-website importeert dit
build-time om een prijstabel te renderen. Zo'n site hoort daarvoor geen
schema-bibliotheek te installeren. Dit package hangt dus ook niet aan
@mark2form/contracts.
De twee lagen
Eigenaarsbesluit Pascal, 21-9-2026:
Neon Studio de CATEGORIE: hier beheer je je instellingen
├── Neon Logo product, eigen staffel
└── Neon Tekst product, eigen staffelDie twee lagen zaten er al; ze heetten alleen anders. De bovenlaag heette
"product" — PRODUCT_IDS, PRODUCTEN — en heet nu CATEGORIE_IDS /
CATEGORIEEN; de oude namen blijven als @deprecated bestaan en zijn exact
dezelfde bindingen. De onderlaag heet in code nog Modus, omdat dat de as is
waarop gefactureerd wordt: STAFFELS is erop gesleuteld,
DownloadCompleted.modus draagt de waarde over de lijn en download_ledger
heeft er een kolom van. Dat woord hernoemen raakt een opgeslagen payload-veld
op het geldpad en is dus een eigen besluit.
De ids blijven neon, logo en tekst. Een id staat in de database en in
elke outbox-rij; een naam staat op een scherm. De categorie met id neon heet
dus "Neon Studio", en dat is geen inconsistentie maar het verschil tussen een
sleutel en een naam.
Een tweede categorie is een rij in CATEGORIEEN plus rijen in MODI en
STAFFELS, geen herbouw. Eén regel daarbij: productids zijn globaal uniek,
want de staffel en de factuurregel dragen alleen het product-id en niet de
categorie. categorieVanModus() bewaakt dat elk product bij precies één
categorie hoort.
Consumeren
// in de monorepo
{ "dependencies": { "@mark2form/catalog": "workspace:*" } }# erbuiten (de marketingsite)
npm install @mark2form/catalogIn de werkkopie wijst package.json naar ./src/..., in de tarball via
publishConfig naar ./dist/... (ESM + .d.ts, tsup). Smal importeren mag:
@mark2form/catalog/staffels, /producten, /talen, /proef, /jaarplan,
/welkomstactie.
import { BTW_REGEL, euroUitCenten, maandbedragCenten, staffelRijen } from "@mark2form/catalog";
maandbedragCenten("logo", 42); // 10000
euroUitCenten(maandbedragCenten("logo", 42)); // "€ 100"
staffelRijen("tekst"); // [{ van: 1, tot: 10, bedragCenten: 500 }, …]
BTW_REGEL.percentage; // 21De staffels
Eén ding telt: een gedownload productiebestand, per modus, per kalendermaand (Europe/Amsterdam). Herdownload van dezelfde ontwerprevisie in dezelfde maand telt niet opnieuw, ook niet in een ander formaat; een nieuwe revisie wel. Retries, mislukte downloads en demo's tellen nooit. Traces, vision-calls, adviesgesprekken en design-hulp tellen nooit voor de factuur.
| Downloads/maand | Neon Logo | Neon Tekst | |---|---|---| | 0 | € 0 | € 0 | | 1–10 | € 25 | € 5 | | 11–30 | € 50 | € 10 | | 31–75 | € 100 | € 20 | | 76–149 | € 175 | € 35 | | 150+ | € 250 | € 50 |
Volume, niet cumulatief: je betaalt per modus precies één bedrag voor de maand — dat van de staffel waarin je uitkomt. Boven 150 blijft dat bedrag gelijk; er is geen toeslag, en dat is een keuze: een maker die groeit moet niet bang hoeven zijn voor zijn eigen succes. Geen minimum, geen vaste maandfee.
Alle bedragen staan in centen en zijn exclusief btw. De btw-regel hoort
één keer paginabreed te staan (§4.3), niet zes keer achter een prijs — daarvoor
is BTW_REGEL.
staffelVoor en maandbedragCenten gooien op een onbekende modus of een
onbruikbaar aantal. Dat is met opzet: een stille terugval zou een rekenfout
stroomopwaarts als "€ 0" op de prijspagina zetten en niemand die het merkt.
Liever een build die faalt.
De proefperiode
Besluit Pascal, 23-9-2026:
| Feit | Waarde | Betekenis |
|---|---|---|
| dagen | 7 | zo lang duurt de proef |
| betaalmethodeNodig | false | de proef start zonder betaalgegevens |
| downloads | false | productiebestanden downloaden kan alleen met een abonnement |
| maxGebruikers | 1 | collega's uitnodigen kan pas met een betaald abonnement |
| liveOpEigenSite | false | tijdens de proef alleen zelf testen in het portaal |
| naAfloopZonderBetaling | "uit" | na de proef zonder betaling gaat de configurator helemaal uit |
import { PROEF } from "@mark2form/catalog/proef";
PROEF.dagen; // 7
PROEF.naAfloopZonderBetaling; // "uit"Dit is de VORM van de proef, niet de stand van een klant. Of een bedrijf nu in
zijn proef zit leidt het portaal af (toegang sinds + PROEF.dagen, en het
abonnement); de runtime ziet dat als de claim download_blokkade in het
embed-token (@mark2form/contracts/embed-jwt). De site, het portaal en de
runtime lezen het aantal dagen hier — een site die iets anders belooft dan de
grendel doet, is een klant die zich bedrogen voelt.
Het jaarplan
Besluit Pascal, 23-9-2026. Per modus een jaarplan naast het maandplan
(M2F_PLANNEN = ["maand", "jaar"]):
| Feit | Waarde | Betekenis |
|---|---|---|
| kortingProcent | 10 | 10% korting op élke staffelprijs; je betaalt nog steeds naar gebruik |
| looptijdMaanden | 12 | het jaar ligt 12 maanden vast |
| betaling | "maandelijks" | per maand afgerekend, niet een jaar vooruit |
| verlengen | "automatisch" | daarna telkens 12 maanden erbij; opzeggen = niet verlengen |
| herinneringDagenVooraf | 30 | de herinneringsmail gaat 30 dagen vóór het einde uit |
| minimumPerMaand | "geen" | nog open. "eerste-staffel": een maand zonder downloads kost de eerste staffel met korting |
| naOpzeggen | "maandelijks" | nog open. na een opgezegd jaar door op de maandprijzen; "stoppen": beëindigen |
| Downloads/maand | Neon Logo (jaar) | Neon Tekst (jaar) | |---|---|---| | 1–10 | € 22,50 | € 4,50 | | 11–30 | € 45 | € 9 | | 31–75 | € 90 | € 18 | | 76–149 | € 157,50 | € 31,50 | | 150+ | € 225 | € 45 |
import { JAARPLAN, jaarMaandbedragCenten, staffelRijenVoorPlan } from "@mark2form/catalog/jaarplan";
jaarMaandbedragCenten("logo", 42); // 9000
staffelRijenVoorPlan("tekst", "jaar"); // [{ van: 1, tot: 10, bedragCenten: 450 }, …]
staffelRijenVoorPlan("tekst", "maand"); // hetzelfde als staffelRijen("tekst")
JAARPLAN.minimumPerMaand; // "geen"De jaarbedragen worden afgeleid uit STAFFELS (jaarStaffels), niet
overgetypt; een korting die geen hele centen oplevert gooit in plaats van af te
ronden. jaarMaandbedragCenten en staffelsVoorPlan volgen dezelfde
gooi-regels als staffelVoor, en een onbekend plan is een fout, nooit stil het
maandplan. Of een klant NU een jaarplan heeft en tot wanneer, is toestand: dat
staat op subscriptions (m2f_plan, looptijd_tot, verlengen,
herinnerd_op) en wordt beheerd in @mark2form/billing.
De opzegregel staat hier wél, omdat het scherm, de opzegknop en de worker
precies dezelfde moeten volgen. looptijd_tot is een EXCLUSIEF einde; opzeggen
(of het terugdraaien) kan tot en met 23:59:59 Europe/Amsterdam van de
kalenderdag ervóór:
import { laatsteWijzigdag, magVerlengenWijzigen } from "@mark2form/catalog/jaarplan";
const tot = new Date("2027-10-01T10:00:00Z");
laatsteWijzigdag(tot); // "2027-09-30"
magVerlengenWijzigen(tot, new Date("2027-09-30T21:59:59Z")); // true (23:59:59 in Amsterdam)
magVerlengenWijzigen(tot, new Date("2027-09-30T22:00:00Z")); // false (1 oktober 00:00)De welkomstactie
Besluit Pascal, 23-9-2026 (WENS-9), letterlijk: "Nieuwe klanten die tijdens hun proefweek of tot 7 dagen erna een abonnement afsluiten, krijgen 20% korting op de eerste factuur, bij maandelijks én bij het jaarplan. Eén keer per bedrijf, niet te combineren met andere acties."
| Feit | Waarde | Betekenis |
|---|---|---|
| id | "welkom-20" | de sleutel in acties_gebruik (migratie 0084); niet hernoemen bij een nieuw percentage |
| procent | 20 | korting op de eerste échte factuur (de eerste met een bedrag) |
| vensterNaProefDagen | 7 | zo lang na de proef telt afsluiten nog |
| eenmaligPerBedrijf | true | de database dwingt het ook af: unique (bedrijf_id, actie) |
| combineerbaar | false | staat er al een andere korting op, dan vervalt de actie |
Het venster is start proef + PROEF.dagen + 7 dagen, in de Amsterdamse
dag: de laatste dag is de Amsterdamse kalenderdag waarop
gestart_op + 14 × 24 uur valt, en die dag telt helemaal mee. De proef telt in
blokken van 24 uur, net als het portaal het einde van de proef uitrekent.
import { komtInAanmerking, welkomstDagenOver, welkomstVenster, WELKOMSTACTIE } from "@mark2form/catalog/welkomstactie";
const start = new Date("2026-10-01T13:00:00Z"); // 1 oktober, 15:00 in Amsterdam
welkomstVenster(start); // { eersteDag: "2026-10-01", laatsteDag: "2026-10-15" }
welkomstDagenOver("2026-10-15", new Date("2026-10-11T10:00:00Z")); // 5 — "Nog 5 dagen"
komtInAanmerking({ proefGestartOp: start, nu: new Date("2026-10-15T21:59:59Z"), alGebruikt: false, andereActie: false }); // "ja"
komtInAanmerking({ proefGestartOp: start, nu: new Date("2026-10-15T22:00:00Z"), alGebruikt: false, andereActie: false }); // "buiten-venster"
WELKOMSTACTIE.procent; // 20komtInAanmerking geeft "ja" of de reden waarom niet, in deze vaste
volgorde: geen-proef → buiten-venster → al-gebruikt → andere-actie. Hij
gooit op een onbruikbare datum en op een stand die geen boolean is — een
onbekende stand is geen "nee".
Of een bedrijf de actie al kreeg en op welke factuur, is toestand: dat staat in
acties_gebruik en wordt beheerd in @mark2form/billing/welkomstactie. Daar
staat ook HOE de korting op de factuur komt: niet als coupon op het abonnement
(die zou op de startfactuur van € 0 opgaan), maar op de eerste échte factuur,
bij de webhook invoice.created.
De talen
De eerste taalset is NL/EN/DE/FR/ES/IT/PL (besluit 13-9, §4.3), gebouwd in de volgorde NL/EN → DE/FR → ES/IT/PL. Een taal gaat pas open als de hele reis erin gecontroleerd is — site, auth, portaal, runtime, hulp, foutmeldingen én mails.
Alle zeven sleutels bestaan daarom vanaf dag één. Wat nog niet vertaald is
draagt de Engelse tekst en staat in terugval, zodat een site kan tonen dat
iets nog niet vertaald is in plaats van te doen alsof het dat wel is:
import { leesTekst, MODI } from "@mark2form/catalog";
leesTekst(MODI.tekst.naam, "nl"); // { waarde: "Neon Tekst", isTerugval: false }
leesTekst(MODI.tekst.naam, "pl"); // { waarde: "Neon Text", isTerugval: true }Een eigennaam (Neon Studio, Neon Logo, DXF) staat nooit in terugval:
die is in het Pools niet onvertaald, hij is gewoon hetzelfde woord. "Neon Tekst"
is dat wél ("Neon Text", "Neon Texte") en draagt dus wel een terugval — die
asymmetrie is eerlijkheid, geen slordigheid. Een echte vertaling
toevoegen is één regel metVertalingen(...), en haalt die taal meteen uit de
terugval.
Versiebeleid en publiceren
Semver via Changesets, net als @mark2form/contracts: een product, modus,
formaat of taal erbij is een minor; een prijs die verandert, een id dat
hernoemt of een veld dat verdwijnt is een major — daar hangt een factuur
aan.
Publiceren gaat niet met de hand: merge naar main → de release-workflow opent
"chore(release): versies bijwerken" → Pascal mergt die → dán pas publish.
pnpm --filter @mark2form/catalog test # vitest
pnpm --filter @mark2form/catalog typecheck # tsc --noEmit
pnpm --filter @mark2form/catalog build # tsup → dist/