Skip to content

[Fact 05] Factuur-PDF, opvolgende nummering & btw-overzicht #150

Description

@MiniMaxi-user

Onderdeel van epic #145 (Facturatie-module). Bouwt voort op Fact 01 (#146:
Invoice/InvoiceLine + enums InvoiceStatus/VatRate, invoiceNumber String? @unique,
invoiceDate DateTime?), Fact 02 (#147: autorisatie/inzage + getSignedUrlVoorFactuur
en getSignedUrlVoorFactuurPdf, bucket-constante FACTUUR_PDF_BUCKET), Fact 03 (#148:
concept-factuur + berekeningen.ts met berekenFactuurTotalen/btw per tarief) en Fact 04
(#149: regels voorvullen uit contract). Spiegelt bewust de bestaande contract-PDF-stack.

User Story

Als staleigenaar of stalmedewerker (OWNER/STAFF)
wil ik een concept-factuur definitief kunnen maken tot een nette PDF in huisstijl, met een
uniek, opvolgend factuurnummer (per stal, per jaargebonden reeks) en een btw-overzicht per
tarief

zodat ik de factuur kan uitreiken aan de ontvanger, het nummer voldoet aan de
boekhoud-/btw-eisen (lopende reeks zonder gaten) en de ontvanger de PDF kan inzien via dezelfde
afgeschermde signed-URL-route als bij Fact 02.

Context

Fact 03/04 leveren een concept-factuur met (handmatige of uit het contract voorgevulde)
InvoiceLine-regels en server-side berekende, gedenormaliseerde totalen (subtotal,
vatAmount, total) met btw per tarief gegroepeerd (berekenFactuurTotalen, btw-mapping
NUL/LAAG/HOOG = 0/9/21%). Een concept heeft bewust nog geen invoiceNumber en geen PDF.

Deze story sluit de keten opstellen tot definitief maken af. Bij het definitief maken wordt:

  1. een uniek, opvolgend factuurnummer toegekend (per stal, jaargebonden reeks; pas bij
    definitief maken, nooit op een concept, conform [Fact 01] Datamodel & migratie — facturatie-kern #146/[Facturatie] Epic: Facturatie-module #145);
  2. de factuurdatum (invoiceDate) vastgezet wanneer die nog leeg is;
  3. een PDF in huisstijl gegenereerd met afzender (stal + logo), ontvanger, regels en een
    btw-overzicht per tarief, en opgeslagen in Supabase Storage;
  4. de PDF gekoppeld zodat inzage via signed URL loopt langs de bestaande Fact 02-helpers
    (getSignedUrlVoorFactuur / getSignedUrlVoorFactuurPdf).

De contract-PDF-stack is al vastgelegd en moet exact als blauwdruk dienen (zie project memory en
src/features/contracten/pdf.ts): @react-pdf/renderer met renderToBuffer; een
*PdfDocument.tsx-component in huisstijl; een pure data-bouwer *Data.ts; een prive
Supabase-bucket met inzage uitsluitend via signed URL (korte TTL); en een document-koppelmodel met
storagePath zoals ContractDocument. Geen headless browser of nieuwe PDF-lib.

Fact 02 heeft de inzage-helper al klaargezet: getSignedUrlVoorFactuurPdf(storagePath) en
getSignedUrlVoorFactuur(userId, invoiceId, storagePath) nemen het storagePath als parameter
(Fact 02 gokte bewust geen opslagmodel). Deze story vult dat in: het persisteren van het pad en
het doorgeven ervan aan de Fact 02-helper.

Scope

Binnen scope:

  • Opslagmodel voor de factuur-PDF (Prisma + migratie), spiegelt ContractDocument. Voorstel:
    een nieuw model InvoiceDocument (invoiceId naar Invoice, onDelete: Cascade;
    storagePath String; createdAt; index op invoiceId) + back-relation
    documents InvoiceDocument[] op Invoice. Zo blijft de inzage gelijk aan de contract-stack
    (laatste document wint) en sluit het aan op de bestaande, parameter-gestuurde Fact 02-helper.
    (Schemawijziging mag zonder vooraf overleg, zie project memory.)
  • Jaargebonden, race-safe nummerreeks (Prisma + migratie), een teller-model. Voorstel:
    InvoiceNumberSequence met @@unique([stableId, year]) en een lastNumber Int. Het ophogen
    gebeurt atomair binnen dezelfde prisma.$transaction als het toekennen van het
    factuurnummer, zodat parallelle definitief-maak-acties geen dubbel/overgeslagen nummer kunnen
    produceren. De DB-@unique op Invoice.invoiceNumber is het laatste vangnet.
  • Factuurnummer-formaat, een leesbaar, sorteerbaar, jaargebonden nummer per stal. Voorstel:
    YYYY-NNNN (bv. 2026-0001), met de teller die per stal per jaar bij 1 begint. Het nummer
    wordt als string in Invoice.invoiceNumber opgeslagen (bestaand veld).
  • Server-action "factuur definitief maken" in src/features/facturen/actions.ts (bv.
    maakFactuurDefinitief(invoiceId)):
    • dwingt assertCanManageInvoice(userId, invoiceId) af (Fact 02);
    • weigert wanneer de factuur niet (meer) CONCEPT is (geen dubbel nummeren) en wanneer er
      0 regels zijn (een lege factuur kan niet definitief);
    • kent binnen een $transaction het volgende nummer toe (teller ophogen + schrijven), zet
      invoiceDate (indien leeg) op vandaag en zet de status naar VERZONDEN (zie open punt);
    • genereert daarna de PDF, slaat die op en doet revalidatePath.
  • PDF-generatie + opslag in nieuwe src/features/facturen/-bestanden (bv. pdf.ts +
    FactuurPdfDocument.tsx + factuurPdfData.ts), gemodelleerd naar de contract-stack:
    • renderFactuurPdfBuffer(...) rendert in-memory via @react-pdf/renderer;
    • genereerEnSlaFactuurPdfOp(invoiceId) schrijft de buffer naar FACTUUR_PDF_BUCKET (idempotente
      bucket-provisioning, prive) en maakt een InvoiceDocument-rij met storagePath;
    • de PDF toont: afzender (stalnaam, -adres, logo via getStableLogoDataUrl), ontvanger
      (factuurgegevens uit OwnerBusinessProfile, met val-terug op User.name/email en het reguliere
      adres wanneer er geen afwijkend factuuradres is), factuurnummer, factuurdatum en
      vervaldatum, de regels (omschrijving, aantal, stuksprijs excl. btw, btw-tarief,
      regelbedrag), een btw-overzicht per tarief (grondslag + btw-bedrag per 0/9/21%) en
      subtotaal / totale btw / totaal incl. btw, allemaal uit berekenFactuurTotalen (Fact 03).
  • Inzage via signed URL, koppel het opgeslagen storagePath aan de bestaande Fact 02-helper:
    een query/wrapper die het laatste InvoiceDocument.storagePath ophaalt en doorgeeft aan
    getSignedUrlVoorFactuur(userId, invoiceId, storagePath). De leesrechten blijven exact die van
    Fact 02 (ontvanger: eigen + niet-CONCEPT; stalrol: eigen stal).
  • UI-trigger op de bestaande bewerk-route (/stal/facturen/[id]/bewerken): een Nederlandse knop
    "Factuur definitief maken", alleen actief bij een CONCEPT-factuur met minimaal een regel; na
    afloop het toegekende factuurnummer + een knop "PDF openen" (signed URL). Geen nieuw scherm en
    geen sidebar-navigatie-item (overzicht + navigatie = Fact 07).
  • Huisstijl & taal: Nederlandse teksten, design-tokens uit src/styles/globals.css (navy,
    cream, goud), Cormorant Garamond + Inter, Velaro-logo als fallback, exact zoals de contract-PDF.
    Geen tailwind.config.ts, geen nieuwe kleuren/fonts.

Buiten scope (latere/andere Fact-stories):

  • Betaalwijze & SEPA-incassomachtiging (IBAN/mandaat op de PDF), Fact 06.
  • Betaalstatus-overgangen, daadwerkelijk verzenden (e-mail/notificatie), herinneringen en het
    facturatie-overzicht + sidebar-navigatie
    , Fact 07. Deze story zet status op VERZONDEN als
    markering van definitief/uitgereikt, maar bouwt geen verzendkanaal of overzicht.
  • Btw-tarieflogica / voorvullen, Fact 04 ([Fact 04] Factuurregels voorvullen uit het contract (stalling + lease) #149, al af). De PDF gebruikt de bestaande
    berekenFactuurTotalen; geen nieuwe btw-regels.
  • Wijzigingen aan de Fact 02-autorisatielaag (wordt hergebruikt) en aan OwnerBusinessProfile
    (wordt uitgelezen, niet gewijzigd). Een KvK/btw-nummer van de stal toevoegen valt buiten scope
    (zie open punt).
  • Creditfacturen, factuur-PDF-versiebeheer, herfacturatie of het wijzigen van een reeds
    definitieve factuur.
    Een definitieve factuur is read-only (mutatie-acties uit Fact 03 weigeren
    al buiten CONCEPT).
  • Automatische periodieke facturatie-runs, buiten de epic-scope ([Facturatie] Epic: Facturatie-module #145: handmatig/triggerbaar).

Acceptatiecriteria

  • Als een OWNER/STAFF een CONCEPT-factuur met minimaal een regel definitief maakt, dan
    krijgt de factuur een uniek, opvolgend invoiceNumber, wordt invoiceDate (indien leeg) op
    vandaag gezet en wordt de status VERZONDEN.
  • Als een concept-factuur 0 regels heeft of niet (meer) CONCEPT is, dan wordt
    het definitief maken server-side geweigerd met een nette Nederlandse melding (geen dubbel
    nummeren).
  • De factuurnummers vormen per stal een lopende, jaargebonden reeks zonder gaten of
    duplicaten
    (bv. 2026-0001, 2026-0002, ...; bij jaarwissel begint de reeks opnieuw bij
    1). Concept-facturen krijgen nooit een nummer.
  • Als twee definitief-maak-acties voor dezelfde stal (nagenoeg) gelijktijdig lopen, dan
    krijgt elke factuur een uniek nummer (race-safe: atomaire teller binnen een transactie; de
    @unique op Invoice.invoiceNumber is het DB-vangnet).
  • De gegenereerde PDF toont: afzender (stalnaam, -adres, stallogo of Velaro-fallback),
    ontvanger (factuurgegevens uit OwnerBusinessProfile met val-terug), factuurnummer,
    factuurdatum en vervaldatum, alle regels (omschrijving, aantal, stuksprijs excl. btw,
    btw-tarief, regelbedrag) en de totalen.
  • De PDF bevat een btw-overzicht per tarief (grondslag + btw-bedrag per voorkomend tarief
    0/9/21%) plus subtotaal (excl. btw), totale btw en totaal (incl. btw), exact gelijk aan
    subtotal/vatAmount/total uit berekenFactuurTotalen.
  • De PDF wordt opgeslagen in Supabase Storage (prive bucket FACTUUR_PDF_BUCKET,
    idempotente provisioning) en gekoppeld via het opslagmodel (InvoiceDocument.storagePath).
  • Als inzage in de factuur-PDF wordt gevraagd, dan loopt dit via
    getSignedUrlVoorFactuur (Fact 02) op het opgeslagen storagePath: de autorisatie wordt eerst
    afgedwongen (ontvanger: eigen + niet-CONCEPT; stalrol: eigen stal) en daarna een signed URL met
    korte TTL gegenereerd; is er geen PDF, dan null.
  • De PDF gebruikt de huisstijl (navy/cream/goud-tokens, Cormorant + Inter) en is
    Nederlandstalig; er is geen nieuwe PDF-lib en geen tailwind.config.ts geintroduceerd, de
    contract-PDF-stack (@react-pdf/renderer, Supabase Storage, document-model) is gespiegeld.
  • npx prisma migrate dev draait schoon (alleen nieuwe modellen/relatievelden, bestaande data
    ongemoeid); npx prisma generate en npx tsc --noEmit slagen; de PDF-/nummerlogica zit in
    src/features/facturen/.

Technische notities

  • Spiegel de contract-PDF-stack (src/features/contracten/pdf.ts, ContractPdfDocument.tsx,
    pdfData.ts; pdfMerge.ts is niet nodig, facturen worden niet met bijlagen samengevoegd):
    renderToBuffer(createElement(FactuurPdfDocument, { data })) als pure render; bouwFactuurPdfData
    als IO-vrije, test-vriendelijke data-bouwer; ensureFactuurPdfBucket idempotent
    (getBucket/createBucket({ public: false }), race op already-exists negeren) via
    createAdminClient(); stallogo via getStableLogoDataUrl(stableId), null geeft Velaro-logo.
  • Bedragen/btw uit de bestaande helper: gebruik berekenFactuurTotalen (Fact 03,
    berekeningen.ts) voor regels, btwGroepen, subtotal, vatAmount, total; gebruik
    formatEuro en VAT_RATE_LABEL/VAT_RATE_PERCENTAGE voor de weergave. Niet opnieuw met floats
    rekenen (CLAUDE.md / Fact 01-conventie).
  • Race-safe nummering, voorgestelde aanpak binnen een prisma.$transaction: upsert op
    InvoiceNumberSequence met key (stableId, year); bij bestaan lastNumber: { increment: 1 }, bij
    ontbreken lastNumber: 1; de geretourneerde lastNumber is het volgnummer, geformatteerd als
    YYYY-NNNN (padStart 4). Schrijf in dezelfde transactie invoiceNumber, invoiceDate en
    status = VERZONDEN. De @unique op Invoice.invoiceNumber blijft de harde DB-garantie; vang een
    unieke-constraint-fout netjes af. (Alternatief: SELECT FOR UPDATE of een Postgres-sequence; de
    upsert-increment is de eenvoudigste sluitende variant.) Genereer/sla de PDF op na de geslaagde
    transactie (de PDF heeft het nummer nodig).
  • Velden (bestaand, Fact 01): Invoice.invoiceNumber String? @unique en invoiceDate DateTime?
    zijn al aanwezig, deze story vult ze en voegt ze niet toe. dueDate wordt door Fact 03 gezet en
    hier alleen getoond.
  • Inzage: geef getSignedUrlVoorFactuur het storagePath van het laatste InvoiceDocument
    (newest-first), net als getSignedUrlVoorContract het laatste ContractDocument pakt. Voeg
    eventueel een dunne wrapper toe die het pad opzoekt en doorgeeft; dupliceer de autorisatie niet.
  • Geen nieuw scherm/navigatie: de actie en de PDF-link leven op de bestaande
    /stal/facturen/[id]/bewerken-route; het navigatie-item "Facturen" en het overzicht horen bij
    Fact 07 (gecontroleerd: SidebarClient.tsx heeft nog geen Facturen-item en er is geen ander
    factuurscherm, dus geen duplicatie).
  • Prisma CLI draait via npx prisma in C:\Claude\velaro en leest .env. Schemawijzigingen mogen
    zonder vooraf overleg (project memory); houd het strikt binnen deze scope.

Open vragen

Geen blokkerende open vragen. De onderstaande keuzes zijn als onderbouwd voorstel verwerkt; de
bouwer mag bij sterke voorkeur afwijken zolang de acceptatiecriteria gehaald worden:

  • Status bij definitief maken = VERZONDEN. Het bord ([Facturatie] Epic: Facturatie-module #145/[Fact 01] Datamodel & migratie — facturatie-kern #146) zegt nummer vastzetten bij
    verzenden, niet bij concept, maar Fact 07 bezit de bredere status-/verzendworkflow. Om geen
    circulaire afhankelijkheid te creeren kent deze story het nummer toe bij definitief maken en
    zet de status op VERZONDEN (de enige niet-CONCEPT status die uitgereikt representeert). Fact 07
    bouwt het feitelijke verzendkanaal (e-mail/notificatie), betaalstatus (BETAALD/VERVALLEN) en
    herinneringen hierop voort.
  • Factuurnummer-formaat YYYY-NNNN per stal per jaar. Leesbaar, sorteerbaar en gangbaar
    (jaargebonden reeks). Een ander, consistent formaat mag, mits de reeks per stal lopend en uniek
    blijft.
  • Opslagmodel InvoiceDocument (i.p.v. een pdfPath-kolom op Invoice). Gekozen om de
    contract-stack exact te spiegelen en de bestaande, parameter-gestuurde Fact 02-inzage-helper te
    voeden. Een enkel pdfPath String?-veld op Invoice is een eenvoudiger alternatief; beide
    voldoen aan de criteria.
  • Afzender-KvK/btw-nummer ontbreekt op Stable. OwnerBusinessProfile (ontvanger) heeft
    KvK/btw; het Stable-model (afzender) niet. De PDF toont daarom de beschikbare stalgegevens
    (naam/adres/logo). Een KvK/btw-veld op de stal toevoegen is een aparte, kleine uitbreiding;
    voorstel: niet in deze story afdwingen (buiten scope) zodat Fact 05 niet uitloopt, eventueel als
    losse follow-up. Niet blokkerend voor de kern (nummering + PDF + btw-overzicht + inzage).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions