From 0dc9eda240beb7a7a19f08aba857ac7fad597720 Mon Sep 17 00:00:00 2001 From: adminoo Date: Wed, 11 Feb 2026 11:09:45 +0100 Subject: [PATCH] feat(release): beelloo v0.1 --- .gitignore | 2 + AGENTS.md | 55 +++++++ Makefile | 23 +++ README.md | 98 ++++++++++++ cmd/beelloo/main.go | 11 ++ cmd/genhtml/main.go | 33 ++++ go.mod | 5 + go.sum | 2 + internal/cli/run.go | 142 ++++++++++++++++ internal/cli/run_test.go | 56 +++++++ internal/invoice/money.go | 67 ++++++++ internal/invoice/money_test.go | 26 +++ internal/invoice/parse.go | 229 ++++++++++++++++++++++++++ internal/invoice/parse_test.go | 48 ++++++ internal/invoice/quantity.go | 70 ++++++++ internal/invoice/quantity_test.go | 52 ++++++ internal/invoice/scaffold.go | 34 ++++ internal/invoice/types.go | 57 +++++++ internal/invoice/validate.go | 98 ++++++++++++ internal/render/render.go | 103 ++++++++++++ internal/render/render_test.go | 38 +++++ internal/render/style.css | 175 ++++++++++++++++++++ internal/render/template.html | 83 ++++++++++ internal/server/server.go | 119 ++++++++++++++ testdata/sample.html | 258 ++++++++++++++++++++++++++++++ testdata/sample.md | 34 ++++ 26 files changed, 1918 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100755 Makefile create mode 100644 README.md create mode 100644 cmd/beelloo/main.go create mode 100644 cmd/genhtml/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/cli/run.go create mode 100644 internal/cli/run_test.go create mode 100644 internal/invoice/money.go create mode 100644 internal/invoice/money_test.go create mode 100644 internal/invoice/parse.go create mode 100644 internal/invoice/parse_test.go create mode 100644 internal/invoice/quantity.go create mode 100644 internal/invoice/quantity_test.go create mode 100644 internal/invoice/scaffold.go create mode 100644 internal/invoice/types.go create mode 100644 internal/invoice/validate.go create mode 100644 internal/render/render.go create mode 100644 internal/render/render_test.go create mode 100644 internal/render/style.css create mode 100644 internal/render/template.html create mode 100644 internal/server/server.go create mode 100644 testdata/sample.html create mode 100644 testdata/sample.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4c4a6a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.#* +_bin \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ab232cc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,55 @@ +# Project rules for agents (read first) + +This repository is building `beelloo`, a cli tool to build an invoice from a markdown file. + +## General description of the project + +Beelloo provides a way to generate simple invoices from a markdown file with a defined structure. The markdown is converted to an HTML file styled in CSS. The CSS rules make the generated file fit to be printed on A4 documents. + +## Usage +- The invoices are emitted for French buyers. No need to care for translation and foreign law for now. +- The design must match the one of the current LaTeX template (provided in the sample `.tex` invoice and `.input` folder) + +- The tool is to be used like this: + - Optionally, calling `beelloo new document.md` generates a new "scaffold" document the user can fill in with the right information + - The user writes the information in the structured markdown file. + - `beelloo build document.md` validates the document and either: + - return an error if something is missing or wrongly structured + - generate an HTML file + - `beelloo serve document.md` renders the document and serves it on an HTTP endpoint for live preview + - The user visits the endpoint and "prints" the document from their web browser into a PDF. + +## Document format +Generated invoices have the following sections: +A header with: + - Seller identity and address (Name, address) + - Buyer identity address (Name, address, SIRET if needed) + - Invoice ID + - Invoice object (ex: Facture pour prestation de service) +A main body with: + - List of "Billable" tasks with: + - Designation + - Unit price + - quantity + - Amount + - Total Without VAT + - A mention regarding VAT: "TVA non applicable, art 293 B du CGI" +A footer with: + - The mention: "Paiement souhaité par virement bancaire" + - Name of the bank account holder + - IBAN + +## Choice of technology +- Plain HTML/CSS for the generated document +- The cli tool must be using Go +- Markdown to HTML conversion should use the `blackfriday` library unless something else is required + +## Core Constraints +- Support: Linux and FreeBSD (shouldn't be a problem with Go cross-compilation) +- UX: Comprehensive verb and command line parameters +- Testing: develop via **TDD** (tests drive design; behavior changes require tests) +- Unit and functional tests: fast, hermetic, run via `go test ./...` +- Use golden files when needed + +## What to do when unsure +- Ask for clarification before adding major dependencies or widening scope. diff --git a/Makefile b/Makefile new file mode 100755 index 0000000..8359c6c --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +prog_name = beelloo + +build: + mkdir -p _bin + go build -o _bin/$(prog_name) cmd/$(prog_name)/main.go + +install: + cp _bin/$(prog_name) ~/.local/bin/ + +test: + go test -v -cover ./... + +regen-golden: + GOCACHE=/tmp/go-build GOPATH=/tmp/go go run ./cmd/genhtml + +run: + go run main.go + +freebsd: + mkdir -p _bin + GOOS=freebsd GOARCH=amd64 go build -o _bin/$(prog_name)-freebsd cmd/main.go + +all: build install diff --git a/README.md b/README.md new file mode 100644 index 0000000..1c9d24c --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# beelloo + +`beelloo` is a small CLI tool that turns a structured Markdown invoice into print-ready HTML for A4, with a live preview server for fast iteration. + +## Install + +Build locally (latest Go version must be installed on the machine): + +```bash +make build +``` + +The binary is written to `_bin/beelloo`. + +## Commands + +### `beelloo new ` + +Creates a scaffold Markdown file with the expected structure. + +### `beelloo build [--css=path]` + +Parses and validates the Markdown, then writes a static HTML file next to it. + +Example: + +```bash +beelloo build invoice.md +``` + +This will produce `invoice.html`. + +`--css` overrides the default embedded CSS for the build output (CSS is inlined into the HTML file). + +### `beelloo serve [--addr=127.0.0.1:0] [--css=path]` + +Starts an HTTP server that renders the Markdown on every request. This is intended for live preview while editing the Markdown and CSS. + +By default, `serve` reads CSS from `internal/render/style.css`. You can override it with `--css` to point at your own file. The CSS is served as `/style.css` and refreshed on each browser reload. + +Example: + +```bash +beelloo serve invoice.md --css=internal/render/style.css +``` + +## Markdown Format + +The Markdown keys are in English, while the rendered invoice stays in French. + +```markdown +# Invoice + +## Seller +Name: Alice Example +Address: + 10 Rue de Test + 75000 Paris +Email: alice@example.com +Phone: 01 02 03 04 05 +SIRET: 12345678900012 + +## Buyer +Name: Example Corp +Address: + Example Corp SAS + 99 Avenue Exemple + 69000 Lyon +SIRET: 98765432100034 + +## Invoice +Number: 20250407 +Subject: Facture pour prestations de service +Location: Paris +Date: 2026-02-10 +Description: Prestations informatiques + +## Items +| Designation | Unit price | Quantity | +| --- | --- | --- | +| Forfait IT | 1175 | 1 | + +## Payment +Holder: Alice Example +IBAN: FR00 0000 0000 0000 0000 0000 000 +``` + +## Tests + +```bash +make test +``` + +## Regenerating Golden HTML + +```bash +make regen-golden +``` diff --git a/cmd/beelloo/main.go b/cmd/beelloo/main.go new file mode 100644 index 0000000..769b18d --- /dev/null +++ b/cmd/beelloo/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + "beelloo/internal/cli" +) + +func main() { + os.Exit(cli.Run(os.Args[1:], os.Stdout, os.Stderr)) +} diff --git a/cmd/genhtml/main.go b/cmd/genhtml/main.go new file mode 100644 index 0000000..877dea6 --- /dev/null +++ b/cmd/genhtml/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "os" + + "beelloo/internal/invoice" + "beelloo/internal/render" +) + +func main() { + file, err := os.Open("/data/SRC/beelloo/testdata/sample.md") + if err != nil { + panic(err) + } + defer file.Close() + + doc, err := invoice.ParseMarkdown(file) + if err != nil { + panic(err) + } + if err := invoice.Validate(&doc); err != nil { + panic(err) + } + + html, err := render.RenderHTML(doc) + if err != nil { + panic(err) + } + + if err := os.WriteFile("/data/SRC/beelloo/testdata/sample.html", []byte(html), 0644); err != nil { + panic(err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6829329 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module beelloo + +go 1.22 + +require github.com/russross/blackfriday/v2 v2.1.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..502a072 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/internal/cli/run.go b/internal/cli/run.go new file mode 100644 index 0000000..831fa4f --- /dev/null +++ b/internal/cli/run.go @@ -0,0 +1,142 @@ +package cli + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "beelloo/internal/invoice" + "beelloo/internal/render" + "beelloo/internal/server" +) + +func Run(args []string, out io.Writer, errOut io.Writer) int { + if len(args) == 0 { + usage(errOut) + return 2 + } + switch args[0] { + case "new": + return runNew(args[1:], out, errOut) + case "emit": + return runBuild(args[1:], out, errOut) + case "build": + return runBuild(args[1:], out, errOut) + case "serve": + return runServe(args[1:], out, errOut) + case "-h", "--help", "help": + usage(out) + return 0 + default: + fmt.Fprintf(errOut, "unknown command: %s\n", args[0]) + usage(errOut) + return 2 + } +} + +func usage(w io.Writer) { + fmt.Fprintln(w, "Usage:") + fmt.Fprintln(w, " beelloo new ") + fmt.Fprintln(w, " beelloo build [--css=style.css]") + fmt.Fprintln(w, " beelloo serve [--addr=127.0.0.1:0] [--css=style.css]") +} + +func runNew(args []string, out io.Writer, errOut io.Writer) int { + if len(args) != 1 { + fmt.Fprintln(errOut, "new requires a single file path") + return 2 + } + path := args[0] + if err := os.WriteFile(path, []byte(invoice.ScaffoldMarkdown), 0644); err != nil { + fmt.Fprintf(errOut, "failed to write scaffold: %v\n", err) + return 1 + } + fmt.Fprintf(out, "scaffold written to %s\n", path) + return 0 +} + +func runBuild(args []string, out io.Writer, errOut io.Writer) int { + if len(args) < 1 { + fmt.Fprintln(errOut, "build requires a markdown file path") + return 2 + } + path := args[0] + cssPath := "" + for _, arg := range args[1:] { + if strings.HasPrefix(arg, "--css=") { + cssPath = strings.TrimPrefix(arg, "--css=") + } + } + + file, err := os.Open(path) + if err != nil { + fmt.Fprintf(errOut, "failed to open %s: %v\n", path, err) + return 1 + } + defer file.Close() + + doc, err := invoice.ParseMarkdown(file) + if err != nil { + fmt.Fprintf(errOut, "parse error: %v\n", err) + return 1 + } + if err := invoice.Validate(&doc); err != nil { + fmt.Fprintf(errOut, "validation error: %v\n", err) + return 1 + } + + html, err := renderWithCSS(doc, cssPath) + if err != nil { + fmt.Fprintf(errOut, "render error: %v\n", err) + return 1 + } + + outputPath := strings.TrimSuffix(path, filepath.Ext(path)) + ".html" + if err := os.WriteFile(outputPath, []byte(html), 0644); err != nil { + fmt.Fprintf(errOut, "failed to write html: %v\n", err) + return 1 + } + fmt.Fprintf(out, "HTML generated at %s\n", outputPath) + return 0 +} + +func runServe(args []string, out io.Writer, errOut io.Writer) int { + if len(args) < 1 { + fmt.Fprintln(errOut, "serve requires a markdown file path") + return 2 + } + path := args[0] + addr := "127.0.0.1:0" + cssPath := "" + for _, arg := range args[1:] { + if strings.HasPrefix(arg, "--addr=") { + addr = strings.TrimPrefix(arg, "--addr=") + } + if strings.HasPrefix(arg, "--css=") { + cssPath = strings.TrimPrefix(arg, "--css=") + } + } + err := server.Serve(server.Config{ + Addr: addr, + MDPath: path, + CSSPath: cssPath, + }, out, errOut) + if err != nil { + fmt.Fprintf(errOut, "%v\n", err) + return 1 + } + return 0 +} + +func renderWithCSS(doc invoice.Document, cssPath string) (string, error) { + if cssPath == "" { + return render.RenderHTML(doc) + } + css, err := os.ReadFile(cssPath) + if err != nil { + return "", err + } + return render.RenderHTMLWithCSS(doc, string(css)) +} diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go new file mode 100644 index 0000000..d009c3c --- /dev/null +++ b/internal/cli/run_test.go @@ -0,0 +1,56 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "beelloo/internal/invoice" +) + +func TestRunNewCreatesScaffold(t *testing.T) { + var out bytes.Buffer + var errOut bytes.Buffer + tmp := t.TempDir() + path := filepath.Join(tmp, "invoice.md") + + code := Run([]string{"new", path}, &out, &errOut) + if code != 0 { + t.Fatalf("expected exit code 0, got %d: %s", code, errOut.String()) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read scaffold: %v", err) + } + if string(data) != invoice.ScaffoldMarkdown { + t.Fatalf("scaffold content mismatch") + } +} + +func TestRunBuildErrors(t *testing.T) { + var out bytes.Buffer + var errOut bytes.Buffer + code := Run([]string{"build"}, &out, &errOut) + if code != 2 { + t.Fatalf("expected exit code 2, got %d", code) + } + code = Run([]string{"build", "missing.md"}, &out, &errOut) + if code != 1 { + t.Fatalf("expected exit code 1 for missing file, got %d", code) + } +} + +func TestRunBuildValidationError(t *testing.T) { + var out bytes.Buffer + var errOut bytes.Buffer + tmp := t.TempDir() + path := filepath.Join(tmp, "bad.md") + if err := os.WriteFile(path, []byte("# Facture\n\n## Vendeur\nNom: \n"), 0644); err != nil { + t.Fatalf("write bad markdown: %v", err) + } + code := Run([]string{"build", path}, &out, &errOut) + if code != 1 { + t.Fatalf("expected exit code 1, got %d", code) + } +} diff --git a/internal/invoice/money.go b/internal/invoice/money.go new file mode 100644 index 0000000..2423024 --- /dev/null +++ b/internal/invoice/money.go @@ -0,0 +1,67 @@ +package invoice + +import ( + "errors" + "fmt" + "strings" +) + +func ParseMoney(input string) (Money, error) { + clean := strings.TrimSpace(input) + if clean == "" { + return 0, errors.New("empty money value") + } + sign := int64(1) + if strings.HasPrefix(clean, "-") { + sign = -1 + clean = strings.TrimSpace(strings.TrimPrefix(clean, "-")) + } + clean = strings.ReplaceAll(clean, " ", "") + clean = strings.ReplaceAll(clean, ",", ".") + if strings.HasPrefix(clean, ".") || strings.HasSuffix(clean, ".") { + return 0, fmt.Errorf("invalid money value: %q", input) + } + parts := strings.Split(clean, ".") + if len(parts) > 2 { + return 0, fmt.Errorf("invalid money value: %q", input) + } + whole := parts[0] + frac := "" + if len(parts) == 2 { + frac = parts[1] + } + if whole == "" { + whole = "0" + } + for _, r := range whole { + if r < '0' || r > '9' { + return 0, fmt.Errorf("invalid money value: %q", input) + } + } + for _, r := range frac { + if r < '0' || r > '9' { + return 0, fmt.Errorf("invalid money value: %q", input) + } + } + if len(frac) > 2 { + return 0, fmt.Errorf("invalid money value: %q", input) + } + for len(frac) < 2 { + frac += "0" + } + var wholeValue int64 + for _, r := range whole { + wholeValue = wholeValue*10 + int64(r-'0') + } + var fracValue int64 + for _, r := range frac { + fracValue = fracValue*10 + int64(r-'0') + } + return Money(sign * (wholeValue*100 + fracValue)), nil +} + +func formatCents(cents int64) string { + whole := cents / 100 + frac := cents % 100 + return fmt.Sprintf("%d.%02d", whole, frac) +} diff --git a/internal/invoice/money_test.go b/internal/invoice/money_test.go new file mode 100644 index 0000000..b776d46 --- /dev/null +++ b/internal/invoice/money_test.go @@ -0,0 +1,26 @@ +package invoice + +import "testing" + +func TestParseMoney(t *testing.T) { + cases := []struct { + input string + want Money + }{ + {"1175", 117500}, + {"1175.5", 117550}, + {"1175,50", 117550}, + {"0.01", 1}, + {"-15", -1500}, + {"-0.5", -50}, + } + for _, tc := range cases { + got, err := ParseMoney(tc.input) + if err != nil { + t.Fatalf("ParseMoney(%q) unexpected error: %v", tc.input, err) + } + if got != tc.want { + t.Fatalf("ParseMoney(%q)=%v want %v", tc.input, got, tc.want) + } + } +} diff --git a/internal/invoice/parse.go b/internal/invoice/parse.go new file mode 100644 index 0000000..b0e6421 --- /dev/null +++ b/internal/invoice/parse.go @@ -0,0 +1,229 @@ +package invoice + +import ( + "bufio" + "errors" + "fmt" + "io" + "strings" + "time" +) + +func ParseMarkdown(r io.Reader) (Document, error) { + var doc Document + var section string + var inAddress bool + var tableHeader []string + scanner := bufio.NewScanner(r) + lineNo := 0 + for scanner.Scan() { + lineNo++ + line := strings.TrimRight(scanner.Text(), "\r") + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") && strings.HasPrefix(trimmed, "## ") { + section = strings.TrimSpace(strings.TrimPrefix(trimmed, "## ")) + inAddress = false + if strings.EqualFold(section, "Prestations") { + tableHeader = nil + } + continue + } + if trimmed == "" { + inAddress = false + continue + } + + switch strings.ToLower(section) { + case "seller": + if ok := parsePartyLine(&doc.Seller, line, &inAddress); ok { + continue + } + case "buyer": + if ok := parsePartyLine(&doc.Buyer, line, &inAddress); ok { + continue + } + case "invoice": + key, value, ok := splitKeyValue(line) + if !ok { + return doc, fmt.Errorf("line %d: expected key/value in Invoice section", lineNo) + } + switch normalizeKey(key) { + case "number": + doc.Invoice.Number = value + case "subject": + doc.Invoice.Subject = value + case "location": + doc.Invoice.Location = value + case "date": + parsed, err := time.Parse("2006-01-02", value) + if err != nil { + return doc, fmt.Errorf("line %d: invalid date %q", lineNo, value) + } + doc.Invoice.Date = parsed + case "description": + doc.Invoice.Description = value + default: + return doc, fmt.Errorf("line %d: unknown invoice field %q", lineNo, key) + } + case "items": + if !strings.Contains(trimmed, "|") { + return doc, fmt.Errorf("line %d: expected table row in Items section", lineNo) + } + cells := splitTableRow(line) + if len(cells) == 0 { + continue + } + if tableHeader == nil { + tableHeader = cells + continue + } + if isSeparatorRow(cells) { + continue + } + item, err := parseItemRow(tableHeader, cells) + if err != nil { + return doc, fmt.Errorf("line %d: %w", lineNo, err) + } + doc.Items = append(doc.Items, item) + case "payment": + key, value, ok := splitKeyValue(line) + if !ok { + return doc, fmt.Errorf("line %d: expected key/value in Payment section", lineNo) + } + switch normalizeKey(key) { + case "holder": + doc.Payment.Holder = value + case "iban": + doc.Payment.IBAN = value + default: + return doc, fmt.Errorf("line %d: unknown payment field %q", lineNo, key) + } + default: + continue + } + } + if err := scanner.Err(); err != nil { + return doc, err + } + return doc, nil +} + +func parsePartyLine(party *Party, line string, inAddress *bool) bool { + if *inAddress && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) { + party.Address = append(party.Address, strings.TrimSpace(line)) + return true + } + key, value, ok := splitKeyValue(line) + if !ok { + return false + } + switch normalizeKey(key) { + case "name": + party.Name = value + *inAddress = false + return true + case "address": + *inAddress = true + if value != "" { + party.Address = append(party.Address, value) + } + return true + case "siret": + party.SIRET = value + *inAddress = false + return true + case "email": + party.Email = value + *inAddress = false + return true + case "phone": + party.Phone = value + *inAddress = false + return true + default: + return false + } +} + +func splitKeyValue(line string) (string, string, bool) { + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + return "", "", false + } + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + return key, value, true +} + +func normalizeKey(key string) string { + k := strings.ToLower(strings.TrimSpace(key)) + replacer := strings.NewReplacer("é", "e", "è", "e", "ê", "e", "à", "a", "ç", "c") + return replacer.Replace(k) +} + +func splitTableRow(line string) []string { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "|") { + return nil + } + trimmed = strings.TrimPrefix(trimmed, "|") + trimmed = strings.TrimSuffix(trimmed, "|") + parts := strings.Split(trimmed, "|") + var cells []string + for _, part := range parts { + cells = append(cells, strings.TrimSpace(part)) + } + return cells +} + +func isSeparatorRow(cells []string) bool { + for _, cell := range cells { + if cell == "" { + continue + } + for _, r := range cell { + if r != '-' { + return false + } + } + } + return true +} + +func parseItemRow(header, row []string) (Item, error) { + if len(row) < len(header) { + return Item{}, errors.New("row has fewer columns than header") + } + idxDesignation := -1 + idxPrice := -1 + idxQty := -1 + for i, col := range header { + n := normalizeKey(col) + switch { + case strings.Contains(n, "designation"): + idxDesignation = i + case strings.Contains(n, "unitprice"), strings.Contains(n, "unit price"), strings.Contains(n, "price"), strings.Contains(n, "prix"): + idxPrice = i + case strings.Contains(n, "quantity"), strings.Contains(n, "quantite"), strings.Contains(n, "qty"): + idxQty = i + } + } + if idxDesignation == -1 || idxPrice == -1 || idxQty == -1 { + return Item{}, errors.New("table header must include Designation, Unit price, Quantity") + } + item := Item{Designation: row[idxDesignation]} + if item.Designation == "" { + return Item{}, errors.New("empty designation") + } + price, err := ParseMoney(row[idxPrice]) + if err != nil { + return Item{}, fmt.Errorf("invalid unit price: %w", err) + } + item.UnitPrice = price + qty, err := ParseQuantity(row[idxQty]) + if err != nil { + return Item{}, fmt.Errorf("invalid quantity: %w", err) + } + item.Quantity = qty + return item, nil +} diff --git a/internal/invoice/parse_test.go b/internal/invoice/parse_test.go new file mode 100644 index 0000000..34a678e --- /dev/null +++ b/internal/invoice/parse_test.go @@ -0,0 +1,48 @@ +package invoice + +import ( + "os" + "strings" + "testing" +) + +func TestParseMarkdownSample(t *testing.T) { + file, err := os.Open("../../testdata/sample.md") + if err != nil { + t.Fatalf("open sample: %v", err) + } + defer file.Close() + + doc, err := ParseMarkdown(file) + if err != nil { + t.Fatalf("ParseMarkdown: %v", err) + } + if doc.Seller.Name != "Alice Example" { + t.Fatalf("seller name mismatch: %q", doc.Seller.Name) + } + if doc.Seller.Email != "alice@example.com" { + t.Fatalf("seller email mismatch: %q", doc.Seller.Email) + } + if doc.Seller.Phone != "01 02 03 04 05" { + t.Fatalf("seller phone mismatch: %q", doc.Seller.Phone) + } + if doc.Buyer.Name != "Example Corp" { + t.Fatalf("buyer name mismatch: %q", doc.Buyer.Name) + } + if doc.Invoice.Number != "20250407" { + t.Fatalf("invoice number mismatch: %q", doc.Invoice.Number) + } + if len(doc.Items) != 1 { + t.Fatalf("expected 1 item, got %d", len(doc.Items)) + } + if !strings.HasPrefix(doc.Items[0].Designation, "Forfait IT") { + t.Fatalf("designation mismatch: %q", doc.Items[0].Designation) + } +} + +func TestValidateMissingFields(t *testing.T) { + doc := Document{} + if err := Validate(&doc); err == nil { + t.Fatalf("expected validation error") + } +} diff --git a/internal/invoice/quantity.go b/internal/invoice/quantity.go new file mode 100644 index 0000000..21f2d49 --- /dev/null +++ b/internal/invoice/quantity.go @@ -0,0 +1,70 @@ +package invoice + +import ( + "errors" + "fmt" + "strings" +) + +// Quantity stores hundredths (e.g. 0.5 => 50). +func ParseQuantity(input string) (Quantity, error) { + clean := strings.TrimSpace(input) + if clean == "" { + return 0, errors.New("empty quantity") + } + clean = strings.ReplaceAll(clean, " ", "") + clean = strings.ReplaceAll(clean, ",", ".") + if strings.HasPrefix(clean, ".") || strings.HasSuffix(clean, ".") { + return 0, fmt.Errorf("invalid quantity: %q", input) + } + parts := strings.Split(clean, ".") + if len(parts) > 2 { + return 0, fmt.Errorf("invalid quantity: %q", input) + } + whole := parts[0] + frac := "" + if len(parts) == 2 { + frac = parts[1] + } + if whole == "" { + whole = "0" + } + for _, r := range whole { + if r < '0' || r > '9' { + return 0, fmt.Errorf("invalid quantity: %q", input) + } + } + for _, r := range frac { + if r < '0' || r > '9' { + return 0, fmt.Errorf("invalid quantity: %q", input) + } + } + if len(frac) > 2 { + return 0, fmt.Errorf("invalid quantity: %q", input) + } + for len(frac) < 2 { + frac += "0" + } + var wholeValue int64 + for _, r := range whole { + wholeValue = wholeValue*10 + int64(r-'0') + } + var fracValue int64 + for _, r := range frac { + fracValue = fracValue*10 + int64(r-'0') + } + return Quantity(wholeValue*100 + fracValue), nil +} + +func FormatQuantity(q Quantity) string { + value := int64(q) + whole := value / 100 + frac := value % 100 + if frac == 0 { + return fmt.Sprintf("%d", whole) + } + if frac%10 == 0 { + return fmt.Sprintf("%d.%d", whole, frac/10) + } + return fmt.Sprintf("%d.%02d", whole, frac) +} diff --git a/internal/invoice/quantity_test.go b/internal/invoice/quantity_test.go new file mode 100644 index 0000000..caf062a --- /dev/null +++ b/internal/invoice/quantity_test.go @@ -0,0 +1,52 @@ +package invoice + +import "testing" + +func TestParseQuantity(t *testing.T) { + cases := []struct { + input string + want Quantity + }{ + {"1", 100}, + {"0.5", 50}, + {"2,25", 225}, + {"10.00", 1000}, + } + for _, tc := range cases { + got, err := ParseQuantity(tc.input) + if err != nil { + t.Fatalf("ParseQuantity(%q) unexpected error: %v", tc.input, err) + } + if got != tc.want { + t.Fatalf("ParseQuantity(%q)=%v want %v", tc.input, got, tc.want) + } + } +} + +func TestFormatQuantity(t *testing.T) { + cases := []struct { + input Quantity + want string + }{ + {100, "1"}, + {50, "0.5"}, + {225, "2.25"}, + {1010, "10.1"}, + } + for _, tc := range cases { + got := FormatQuantity(tc.input) + if got != tc.want { + t.Fatalf("FormatQuantity(%v)=%q want %q", tc.input, got, tc.want) + } + } +} + +func TestLineAmountNegative(t *testing.T) { + item := Item{ + UnitPrice: -29500, + Quantity: 100, + } + if got := LineAmount(item); got != -29500 { + t.Fatalf("LineAmount negative mismatch: %v", got) + } +} diff --git a/internal/invoice/scaffold.go b/internal/invoice/scaffold.go new file mode 100644 index 0000000..014febf --- /dev/null +++ b/internal/invoice/scaffold.go @@ -0,0 +1,34 @@ +package invoice + +const ScaffoldMarkdown = `# Invoice + +## Seller +Name: +Address: + +Email: +Phone: +SIRET: + +## Buyer +Name: +Address: + +SIRET: + +## Invoice +Number: +Subject: Facture pour prestations de service +Location: +Date: 2025-04-07 +Description: + +## Items +| Designation | Unit price | Quantity | +| --- | --- | --- | +| | | | + +## Payment +Holder: +IBAN: +` diff --git a/internal/invoice/types.go b/internal/invoice/types.go new file mode 100644 index 0000000..0d5786b --- /dev/null +++ b/internal/invoice/types.go @@ -0,0 +1,57 @@ +package invoice + +import "time" + +type Document struct { + Seller Party + Buyer Party + Invoice InvoiceInfo + Items []Item + Payment PaymentInfo + Totals Totals +} + +type Party struct { + Name string + Address []string + SIRET string + Email string + Phone string +} + +type InvoiceInfo struct { + Number string + Subject string + Location string + Date time.Time + Description string +} + +type Item struct { + Designation string + UnitPrice Money + Quantity Quantity +} + +type PaymentInfo struct { + Holder string + IBAN string +} + +type Totals struct { + TotalExclTax Money +} + +type Money int64 + +type Quantity int64 + +func (m Money) String() string { + sign := "" + value := m + if value < 0 { + sign = "-" + value = -value + } + return sign + formatCents(int64(value)) +} diff --git a/internal/invoice/validate.go b/internal/invoice/validate.go new file mode 100644 index 0000000..1323f6c --- /dev/null +++ b/internal/invoice/validate.go @@ -0,0 +1,98 @@ +package invoice + +import ( + "errors" + "fmt" +) + +func Validate(doc *Document) error { + var problems []string + if doc.Seller.Name == "" { + problems = append(problems, "missing vendeur nom") + } + if len(doc.Seller.Address) == 0 { + problems = append(problems, "missing vendeur adresse") + } + if doc.Seller.Email == "" { + problems = append(problems, "missing vendeur email") + } + if doc.Seller.Phone == "" { + problems = append(problems, "missing vendeur telephone") + } + if doc.Buyer.Name == "" { + problems = append(problems, "missing acheteur nom") + } + if len(doc.Buyer.Address) == 0 { + problems = append(problems, "missing acheteur adresse") + } + if doc.Invoice.Number == "" { + problems = append(problems, "missing facture numero") + } + if doc.Invoice.Subject == "" { + problems = append(problems, "missing facture objet") + } + if doc.Invoice.Location == "" { + problems = append(problems, "missing facture lieu") + } + if doc.Invoice.Date.IsZero() { + problems = append(problems, "missing facture date") + } + if doc.Invoice.Description == "" { + problems = append(problems, "missing facture description") + } + if len(doc.Items) == 0 { + problems = append(problems, "missing prestations") + } + if doc.Payment.Holder == "" { + problems = append(problems, "missing paiement titulaire") + } + if doc.Payment.IBAN == "" { + problems = append(problems, "missing paiement IBAN") + } + if len(problems) > 0 { + return errors.New(stringsJoin(problems, "; ")) + } + return nil +} + +func ComputeTotals(doc *Document) { + var total Money + for _, item := range doc.Items { + line := LineAmount(item) + total += line + } + doc.Totals.TotalExclTax = total +} + +func stringsJoin(values []string, sep string) string { + if len(values) == 0 { + return "" + } + out := values[0] + for i := 1; i < len(values); i++ { + out += sep + values[i] + } + return out +} + +func FormatMoney(m Money) string { + return m.String() +} + +func LineAmount(item Item) Money { + // Quantity is stored in hundredths. Round to nearest cent. + cents := int64(item.UnitPrice) + qty := int64(item.Quantity) + product := cents * qty + if product >= 0 { + return Money((product + 50) / 100) + } + return Money((product - 50) / 100) +} + +func RequireNoError(err error, msg string) error { + if err != nil { + return fmt.Errorf("%s: %w", msg, err) + } + return nil +} diff --git a/internal/render/render.go b/internal/render/render.go new file mode 100644 index 0000000..b6683cd --- /dev/null +++ b/internal/render/render.go @@ -0,0 +1,103 @@ +package render + +import ( + "bytes" + "embed" + "html/template" + "time" + + "github.com/russross/blackfriday/v2" + + "beelloo/internal/invoice" +) + +//go:embed template.html style.css +var assets embed.FS + +type renderItem struct { + Designation template.HTML + UnitPrice string + Quantity string + Amount string +} + +type renderData struct { + Seller invoice.Party + Buyer invoice.Party + Invoice invoice.InvoiceInfo + InvoiceDate string + InvoiceDescription template.HTML + Items []renderItem + TotalExclTax string + Payment invoice.PaymentInfo + CSS template.CSS + CSSHref string +} + +func RenderHTML(doc invoice.Document) (string, error) { + cssBytes, err := assets.ReadFile("style.css") + if err != nil { + return "", err + } + return RenderHTMLWithCSS(doc, string(cssBytes)) +} + +func RenderHTMLWithCSS(doc invoice.Document, css string) (string, error) { + return renderHTML(doc, css, "") +} + +func RenderHTMLWithCSSLink(doc invoice.Document, href string) (string, error) { + return renderHTML(doc, "", href) +} + +func renderHTML(doc invoice.Document, css string, href string) (string, error) { + invoice.ComputeTotals(&doc) + tmplBytes, err := assets.ReadFile("template.html") + if err != nil { + return "", err + } + tmpl, err := template.New("invoice").Parse(string(tmplBytes)) + if err != nil { + return "", err + } + data := renderData{ + Seller: doc.Seller, + Buyer: doc.Buyer, + Invoice: doc.Invoice, + InvoiceDate: formatDate(doc.Invoice.Date), + InvoiceDescription: markdownHTML(doc.Invoice.Description), + TotalExclTax: invoice.FormatMoney(doc.Totals.TotalExclTax), + Payment: doc.Payment, + CSS: template.CSS(css), + CSSHref: href, + } + for _, item := range doc.Items { + data.Items = append(data.Items, renderItem{ + Designation: markdownHTML(item.Designation), + UnitPrice: invoice.FormatMoney(item.UnitPrice), + Quantity: invoice.FormatQuantity(item.Quantity), + Amount: invoice.FormatMoney(invoice.LineAmount(item)), + }) + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", err + } + return buf.String(), nil +} + +func DefaultCSS() ([]byte, error) { + return assets.ReadFile("style.css") +} + +func markdownHTML(input string) template.HTML { + output := blackfriday.Run([]byte(input)) + return template.HTML(output) +} + +func formatDate(date time.Time) string { + if date.IsZero() { + return "" + } + return date.Format("02/01/2006") +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go new file mode 100644 index 0000000..d5c41d7 --- /dev/null +++ b/internal/render/render_test.go @@ -0,0 +1,38 @@ +package render + +import ( + "os" + "testing" + + "beelloo/internal/invoice" +) + +func TestRenderHTMLGolden(t *testing.T) { + file, err := os.Open("../../testdata/sample.md") + if err != nil { + t.Fatalf("open sample: %v", err) + } + defer file.Close() + + doc, err := invoice.ParseMarkdown(file) + if err != nil { + t.Fatalf("ParseMarkdown: %v", err) + } + if err := invoice.Validate(&doc); err != nil { + t.Fatalf("Validate: %v", err) + } + + html, err := RenderHTML(doc) + if err != nil { + t.Fatalf("RenderHTML: %v", err) + } + + goldenPath := "../../testdata/sample.html" + golden, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("read golden: %v", err) + } + if string(golden) != html { + t.Fatalf("render output did not match golden file") + } +} diff --git a/internal/render/style.css b/internal/render/style.css new file mode 100644 index 0000000..6e0445e --- /dev/null +++ b/internal/render/style.css @@ -0,0 +1,175 @@ +@page { + size: A4; + margin: 2cm 1cm; +} + +* { + box-sizing: border-box; +} + +@media print { + body { + margin: 0; + } + + .page { + padding: 0; + } +} + +@media screen { + body { + margin: 0 auto; + width: 21cm; + } + .page { + padding: 20mm; + } +} + +@media all { + + body { + font-family: "TeX Gyre Pagella", "Palatino Linotype", "Book Antiqua", Palatino, serif; + font-size: 12pt; + color: #111; + background: #fff; + } + + .page { + min-height: calc(297mm - 4cm); + display: flex; + flex-direction: column; + } + + .header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 12mm; + } + + .seller-name, + .buyer-name { + font-weight: bold; + font-size: 12.5pt; + margin-bottom: 3mm; + } + + .seller-line, + .buyer-line { + margin-bottom: 2mm; + } + + .buyer { + text-align: left; + max-width: 42%; + } + + .meta { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 12mm; + } + + .invoice-number { + font-weight: bold; + } + + .object { + margin-bottom: 20mm; + } + + .object-title { + font-weight: bold; + margin-bottom: 3mm; + } + + .object-desc p { + margin: 0; + } + + .table-section { + margin-bottom: 15mm; + } + + .items { + width: 100%; + border-collapse: collapse; + font-size: 11pt; + } + + .items th { + padding: 3mm 2mm 2mm 2mm; + border-bottom: 1px solid #666; + } + + .items td { + padding: 3mm 2mm; + vertical-align: top; + } + + .col-price, + .col-qty, + .col-amount { + text-align: right; + width: 18%; + } + + .col-designation { + width: 46%; + } + + .col-designation p { + margin: 0; + } + + .total-row td { + border-top: 1px solid #666; + padding-top: 4mm; + } + + .total-label { + text-align: right; + font-weight: bold; + } + + .total-value { + text-align: right; + font-weight: bold; + } + + .vat-note { + padding-top: 2mm; + font-size: 10.5pt; + } + + .footer { + margin-top: auto; + padding-top: 12mm; + text-align: center; + } + + .payment-title { + text-align: left; + margin-bottom: 6mm; + } + + .payment-detail { + margin-bottom: 2mm; + } + + .seller-contact { + font-size: 10.5pt; + margin-top: 12mm; + } + + .contact-item + .contact-item::before { + content: " — "; + } + + .payment-detail { + margin-bottom: 2mm; + } +} diff --git a/internal/render/template.html b/internal/render/template.html new file mode 100644 index 0000000..d2c19db --- /dev/null +++ b/internal/render/template.html @@ -0,0 +1,83 @@ + + + + + Facture {{.Invoice.Number}} + {{if .CSSHref}} + + {{else}} + + {{end}} + + +
+
+
+
{{.Seller.Name}}
+ {{range .Seller.Address}}
{{.}}
{{end}} +
+
+
{{.Buyer.Name}}
+ {{range .Buyer.Address}}
{{.}}
{{end}} + {{if .Buyer.SIRET}}
SIRET : {{.Buyer.SIRET}}
{{end}} +
+
+ +
+
Facture n° {{.Invoice.Number}}
+
{{.Invoice.Location}}, le {{.InvoiceDate}}
+
+ +
+
Objet : {{.Invoice.Subject}}
+
{{.InvoiceDescription}}
+
+ +
+ + + + + + + + + + + {{range .Items}} + + + + + + + {{end}} + + + + + + + + + + +
DésignationPrix unitaireQuantitéMontant (EUR)
{{.Designation}}{{.UnitPrice}}{{.Quantity}}{{.Amount}}
Total HT{{.TotalExclTax}}
TVA non applicable, art 293 B du CGI
+
+ +
+
Paiement souhaité par virement bancaire :
+
Nom associé au compte bancaire : {{.Payment.Holder}}
+
IBAN N° : {{.Payment.IBAN}}
+
+ {{.Seller.Name}} + {{if .Seller.Email}}E-mail : {{.Seller.Email}}{{end}} + {{if .Seller.Phone}}Téléphone : {{.Seller.Phone}}{{end}} + {{if .Seller.SIRET}}SIRET : {{.Seller.SIRET}}{{end}} +
+
+
+ + diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..cc466ae --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,119 @@ +package server + +import ( + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "time" + + "beelloo/internal/invoice" + "beelloo/internal/render" +) + +type Config struct { + Addr string + MDPath string + CSSPath string +} + +type Result struct { + Listener net.Listener +} + +func Serve(cfg Config, out, errOut io.Writer) error { + if cfg.Addr == "" { + cfg.Addr = "127.0.0.1:0" + } + cssPath, err := resolveCSSPath(cfg.MDPath, cfg.CSSPath) + if err != nil { + return err + } + cfg.CSSPath = cssPath + + listener, err := net.Listen("tcp", cfg.Addr) + if err != nil { + return fmt.Errorf("failed to listen on %s: %w", cfg.Addr, err) + } + + mux := http.NewServeMux() + mux.HandleFunc("/style.css", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + if cfg.CSSPath != "" { + http.ServeFile(w, r, cfg.CSSPath) + return + } + css, err := render.DefaultCSS() + if err != nil { + http.Error(w, "failed to load default css", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/css; charset=utf-8") + _, _ = w.Write(css) + }) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + file, err := os.Open(cfg.MDPath) + if err != nil { + http.Error(w, "failed to open markdown", http.StatusInternalServerError) + return + } + defer file.Close() + + doc, err := invoice.ParseMarkdown(file) + if err != nil { + http.Error(w, "parse error: "+err.Error(), http.StatusBadRequest) + return + } + if err := invoice.Validate(&doc); err != nil { + http.Error(w, "validation error: "+err.Error(), http.StatusBadRequest) + return + } + + html, err := render.RenderHTMLWithCSSLink(doc, fmt.Sprintf("/style.css?v=%d", time.Now().UnixNano())) + if err != nil { + http.Error(w, "render error: "+err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(html)) + }) + + server := &http.Server{Handler: mux} + if cfg.CSSPath != "" { + fmt.Fprintf(out, "Using CSS: %s\n", cfg.CSSPath) + } else { + fmt.Fprintln(out, "Using embedded CSS") + } + fmt.Fprintf(out, "Serving on http://%s/\n", listener.Addr().String()) + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + return fmt.Errorf("server error: %w", err) + } + return nil +} + +func resolveCSSPath(mdPath, override string) (string, error) { + if override != "" { + if _, err := os.Stat(override); err != nil { + return "", fmt.Errorf("failed to stat css file %s: %w", override, err) + } + return override, nil + } + if mdPath == "" { + return "", nil + } + mdDir := filepath.Dir(mdPath) + candidates := []string{ + filepath.Join(mdDir, "style.css"), + "style.css", + } + for _, path := range candidates { + if _, err := os.Stat(path); err == nil { + return path, nil + } + } + return "", nil +} diff --git a/testdata/sample.html b/testdata/sample.html new file mode 100644 index 0000000..2c42fdf --- /dev/null +++ b/testdata/sample.html @@ -0,0 +1,258 @@ + + + + + Facture 20250407 + + + + + +
+
+
+
Alice Example
+
10 Rue de Test
75000 Paris
+
+
+
Example Corp
+
Example Corp SAS
99 Avenue Exemple
69000 Lyon
+
SIRET : 98765432100034
+
+
+ +
+
Facture n° 20250407
+
Paris, le 10/02/2026
+
+ +
+
Objet : Facture pour prestations de service
+

Prestations informatiques

+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DésignationPrix unitaireQuantitéMontant (EUR)

Forfait IT
- Audit technique
- Mise a jour dependances
- Correction bugs

+
1175.0011175.00
Total HT1175.00
TVA non applicable, art 293 B du CGI
+
+ +
+
Paiement souhaité par virement bancaire :
+
Nom associé au compte bancaire : Alice Example
+
IBAN N° : FR00 0000 0000 0000 0000 0000 000
+
+ Alice Example + E-mail : alice@example.com + Téléphone : 01 02 03 04 05 + SIRET : 12345678900012 +
+
+
+ + diff --git a/testdata/sample.md b/testdata/sample.md new file mode 100644 index 0000000..ca3a5fd --- /dev/null +++ b/testdata/sample.md @@ -0,0 +1,34 @@ +# Invoice + +## Seller +Name: Alice Example +Address: + 10 Rue de Test + 75000 Paris +Email: alice@example.com +Phone: 01 02 03 04 05 +SIRET: 12345678900012 + +## Buyer +Name: Example Corp +Address: + Example Corp SAS + 99 Avenue Exemple + 69000 Lyon +SIRET: 98765432100034 + +## Invoice +Number: 20250407 +Subject: Facture pour prestations de service +Location: Paris +Date: 2026-02-10 +Description: Prestations informatiques + +## Items +| Designation | Unit price | Quantity | +| --- | --- | --- | +| Forfait IT
- Audit technique
- Mise a jour dependances
- Correction bugs | 1175 | 1 | + +## Payment +Holder: Alice Example +IBAN: FR00 0000 0000 0000 0000 0000 000