feat(release): beelloo v0.1
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
*.#*
|
||||||
|
_bin
|
||||||
55
AGENTS.md
Normal file
55
AGENTS.md
Normal file
@ -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.
|
||||||
23
Makefile
Executable file
23
Makefile
Executable file
@ -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
|
||||||
98
README.md
Normal file
98
README.md
Normal file
@ -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 <file.md>`
|
||||||
|
|
||||||
|
Creates a scaffold Markdown file with the expected structure.
|
||||||
|
|
||||||
|
### `beelloo build <file.md> [--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 <file.md> [--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
|
||||||
|
```
|
||||||
11
cmd/beelloo/main.go
Normal file
11
cmd/beelloo/main.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"beelloo/internal/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
os.Exit(cli.Run(os.Args[1:], os.Stdout, os.Stderr))
|
||||||
|
}
|
||||||
33
cmd/genhtml/main.go
Normal file
33
cmd/genhtml/main.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module beelloo
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require github.com/russross/blackfriday/v2 v2.1.0
|
||||||
2
go.sum
Normal file
2
go.sum
Normal file
@ -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=
|
||||||
142
internal/cli/run.go
Normal file
142
internal/cli/run.go
Normal file
@ -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 <file.md>")
|
||||||
|
fmt.Fprintln(w, " beelloo build <file.md> [--css=style.css]")
|
||||||
|
fmt.Fprintln(w, " beelloo serve <file.md> [--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))
|
||||||
|
}
|
||||||
56
internal/cli/run_test.go
Normal file
56
internal/cli/run_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
67
internal/invoice/money.go
Normal file
67
internal/invoice/money.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
26
internal/invoice/money_test.go
Normal file
26
internal/invoice/money_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
229
internal/invoice/parse.go
Normal file
229
internal/invoice/parse.go
Normal file
@ -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
|
||||||
|
}
|
||||||
48
internal/invoice/parse_test.go
Normal file
48
internal/invoice/parse_test.go
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
70
internal/invoice/quantity.go
Normal file
70
internal/invoice/quantity.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
52
internal/invoice/quantity_test.go
Normal file
52
internal/invoice/quantity_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
34
internal/invoice/scaffold.go
Normal file
34
internal/invoice/scaffold.go
Normal file
@ -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:
|
||||||
|
`
|
||||||
57
internal/invoice/types.go
Normal file
57
internal/invoice/types.go
Normal file
@ -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))
|
||||||
|
}
|
||||||
98
internal/invoice/validate.go
Normal file
98
internal/invoice/validate.go
Normal file
@ -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
|
||||||
|
}
|
||||||
103
internal/render/render.go
Normal file
103
internal/render/render.go
Normal file
@ -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")
|
||||||
|
}
|
||||||
38
internal/render/render_test.go
Normal file
38
internal/render/render_test.go
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
175
internal/render/style.css
Normal file
175
internal/render/style.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
83
internal/render/template.html
Normal file
83
internal/render/template.html
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Facture {{.Invoice.Number}}</title>
|
||||||
|
{{if .CSSHref}}
|
||||||
|
<link rel="stylesheet" href="{{.CSSHref}}">
|
||||||
|
{{else}}
|
||||||
|
<style>
|
||||||
|
{{.CSS}}
|
||||||
|
</style>
|
||||||
|
{{end}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<header class="header">
|
||||||
|
<div class="seller">
|
||||||
|
<div class="seller-name">{{.Seller.Name}}</div>
|
||||||
|
{{range .Seller.Address}}<div class="seller-line">{{.}}</div>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="buyer">
|
||||||
|
<div class="buyer-name">{{.Buyer.Name}}</div>
|
||||||
|
{{range .Buyer.Address}}<div class="buyer-line">{{.}}</div>{{end}}
|
||||||
|
{{if .Buyer.SIRET}}<div class="buyer-line">SIRET : {{.Buyer.SIRET}}</div>{{end}}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="meta">
|
||||||
|
<div class="invoice-number">Facture n° {{.Invoice.Number}}</div>
|
||||||
|
<div class="invoice-date">{{.Invoice.Location}}, le {{.InvoiceDate}}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="object">
|
||||||
|
<div class="object-title">Objet : {{.Invoice.Subject}}</div>
|
||||||
|
<div class="object-desc">{{.InvoiceDescription}}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="table-section">
|
||||||
|
<table class="items">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-designation">Désignation</th>
|
||||||
|
<th class="col-price">Prix unitaire</th>
|
||||||
|
<th class="col-qty">Quantité</th>
|
||||||
|
<th class="col-amount">Montant (EUR)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Items}}
|
||||||
|
<tr>
|
||||||
|
<td class="col-designation">{{.Designation}}</td>
|
||||||
|
<td class="col-price">{{.UnitPrice}}</td>
|
||||||
|
<td class="col-qty">{{.Quantity}}</td>
|
||||||
|
<td class="col-amount">{{.Amount}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="3" class="total-label">Total HT</td>
|
||||||
|
<td class="total-value">{{.TotalExclTax}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="vat-note">TVA non applicable, art 293 B du CGI</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="payment-title">Paiement souhaité par virement bancaire :</div>
|
||||||
|
<div class="payment-detail"><strong>Nom associé au compte bancaire</strong> : {{.Payment.Holder}}</div>
|
||||||
|
<div class="payment-detail"><strong>IBAN N°</strong> : {{.Payment.IBAN}}</div>
|
||||||
|
<div class="seller-contact">
|
||||||
|
<span class="contact-item">{{.Seller.Name}}</span>
|
||||||
|
{{if .Seller.Email}}<span class="contact-item">E-mail : {{.Seller.Email}}</span>{{end}}
|
||||||
|
{{if .Seller.Phone}}<span class="contact-item">Téléphone : {{.Seller.Phone}}</span>{{end}}
|
||||||
|
{{if .Seller.SIRET}}<span class="contact-item">SIRET : {{.Seller.SIRET}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
119
internal/server/server.go
Normal file
119
internal/server/server.go
Normal file
@ -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
|
||||||
|
}
|
||||||
258
testdata/sample.html
vendored
Normal file
258
testdata/sample.html
vendored
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Facture 20250407</title>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<header class="header">
|
||||||
|
<div class="seller">
|
||||||
|
<div class="seller-name">Alice Example</div>
|
||||||
|
<div class="seller-line">10 Rue de Test</div><div class="seller-line">75000 Paris</div>
|
||||||
|
</div>
|
||||||
|
<div class="buyer">
|
||||||
|
<div class="buyer-name">Example Corp</div>
|
||||||
|
<div class="buyer-line">Example Corp SAS</div><div class="buyer-line">99 Avenue Exemple</div><div class="buyer-line">69000 Lyon</div>
|
||||||
|
<div class="buyer-line">SIRET : 98765432100034</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="meta">
|
||||||
|
<div class="invoice-number">Facture n° 20250407</div>
|
||||||
|
<div class="invoice-date">Paris, le 10/02/2026</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="object">
|
||||||
|
<div class="object-title">Objet : Facture pour prestations de service</div>
|
||||||
|
<div class="object-desc"><p>Prestations informatiques</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="table-section">
|
||||||
|
<table class="items">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-designation">Désignation</th>
|
||||||
|
<th class="col-price">Prix unitaire</th>
|
||||||
|
<th class="col-qty">Quantité</th>
|
||||||
|
<th class="col-amount">Montant (EUR)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td class="col-designation"><p>Forfait IT<br>- Audit technique<br>- Mise a jour dependances<br>- Correction bugs</p>
|
||||||
|
</td>
|
||||||
|
<td class="col-price">1175.00</td>
|
||||||
|
<td class="col-qty">1</td>
|
||||||
|
<td class="col-amount">1175.00</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="3" class="total-label">Total HT</td>
|
||||||
|
<td class="total-value">1175.00</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="vat-note">TVA non applicable, art 293 B du CGI</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="payment-title">Paiement souhaité par virement bancaire :</div>
|
||||||
|
<div class="payment-detail"><strong>Nom associé au compte bancaire</strong> : Alice Example</div>
|
||||||
|
<div class="payment-detail"><strong>IBAN N°</strong> : FR00 0000 0000 0000 0000 0000 000</div>
|
||||||
|
<div class="seller-contact">
|
||||||
|
<span class="contact-item">Alice Example</span>
|
||||||
|
<span class="contact-item">E-mail : alice@example.com</span>
|
||||||
|
<span class="contact-item">Téléphone : 01 02 03 04 05</span>
|
||||||
|
<span class="contact-item">SIRET : 12345678900012</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
34
testdata/sample.md
vendored
Normal file
34
testdata/sample.md
vendored
Normal file
@ -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<br>- Audit technique<br>- Mise a jour dependances<br>- Correction bugs | 1175 | 1 |
|
||||||
|
|
||||||
|
## Payment
|
||||||
|
Holder: Alice Example
|
||||||
|
IBAN: FR00 0000 0000 0000 0000 0000 000
|
||||||
Reference in New Issue
Block a user