feat(release): beelloo v0.1
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user