feat(release): beelloo v0.1

This commit is contained in:
2026-02-11 11:09:45 +01:00
parent 75ad1e7cee
commit 0dc9eda240
26 changed files with 1918 additions and 0 deletions

103
internal/render/render.go Normal file
View 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")
}

View 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
View 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;
}
}

View 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>