diff --git a/README.md b/README.md new file mode 100644 index 0000000..a5d8a83 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +docker run --rm -p 3030:3000 gotenberg/gotenberg:8 diff --git a/go.mod b/go.mod index f9e115d..3b2ac3d 100644 --- a/go.mod +++ b/go.mod @@ -5,18 +5,20 @@ go 1.24.5 require ( code.gitea.io/sdk/gitea v0.21.0 github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a + github.com/google/uuid v1.6.0 github.com/jedib0t/go-pretty/v6 v6.6.8 - golang.org/x/net v0.21.0 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + github.com/starwalkn/gotenberg-go-client/v8 v8.11.0 ) require ( - github.com/42wim/httpsig v1.2.2 // indirect + github.com/42wim/httpsig v1.2.3 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/go-fed/httpsig v1.1.0 // indirect github.com/hashicorp/go-version v1.7.0 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/rivo/uniseg v0.4.7 // indirect - golang.org/x/crypto v0.33.0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.22.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect ) diff --git a/go.sum b/go.sum index 1077c1f..b66aa7d 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ code.gitea.io/sdk/gitea v0.21.0 h1:69n6oz6kEVHRo1+APQQyizkhrZrLsTLXey9142pfkD4= code.gitea.io/sdk/gitea v0.21.0/go.mod h1:tnBjVhuKJCn8ibdyyhvUyxrR1Ca2KHEoTWoukNhXQPA= -github.com/42wim/httpsig v1.2.2 h1:ofAYoHUNs/MJOLqQ8hIxeyz2QxOz8qdSVvp3PX/oPgA= -github.com/42wim/httpsig v1.2.2/go.mod h1:P/UYo7ytNBFwc+dg35IubuAUIs8zj5zzFIgUCEl55WY= +github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= +github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= @@ -10,6 +10,8 @@ github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A= github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/jedib0t/go-pretty/v6 v6.6.8 h1:JnnzQeRz2bACBobIaa/r+nqjvws4yEhcmaZ4n1QzsEc= @@ -21,29 +23,31 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/starwalkn/gotenberg-go-client/v8 v8.11.0 h1:9hKCcoWegZ0yrZ0tEl5gKlMyH2YraYO0R7zDyL0ZL/U= +github.com/starwalkn/gotenberg-go-client/v8 v8.11.0/go.mod h1:5q9nAJ3/lub4hSCT6QYl8cOjnfQ/B+spEzC4TAPVXd8= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= -golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= -golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/index.html b/index.html index 5e9c371..2372a62 100644 Binary files a/index.html and b/index.html differ diff --git a/index.pdf b/index.pdf new file mode 100644 index 0000000..079398a Binary files /dev/null and b/index.pdf differ diff --git a/invoice/invoice.go b/invoice/invoice.go new file mode 100644 index 0000000..839c3c9 --- /dev/null +++ b/invoice/invoice.go @@ -0,0 +1,118 @@ +package invoice + +import ( + "bytes" + "encoding/base64" + "fmt" + "html/template" + "strings" + + "github.com/skip2/go-qrcode" +) + +// Invoice contains all necessary information for the generation of an invoice. +type Invoice struct { + ReceiverIBAN string `yaml:"receiver_iban" default:"CH44 3199 9123 0008 8901 2"` + IsQrIBAN bool `yaml:"is_qr_iban" default:"true"` + ReceiverName string `yaml:"receiver_name" default:"Robert Schneider AG"` + ReceiverStreet string `yaml:"receiver_street" default:"Rue du Lac"` + ReceiverNumber string `yaml:"receiver_number" default:"12"` + ReceiverZIPCode string `yaml:"receiver_zip_code" default:"2501"` + ReceiverPlace string `yaml:"receiver_place" default:"Biel"` + ReceiverCountry string `yaml:"receiver_country" default:"CH"` + PayeeName string `yaml:"payee_name" default:"Pia-Maria Rutschmann-Schnyder"` + PayeeStreet string `yaml:"payee_street" default:"Grosse Marktgasse"` + PayeeNumber string `yaml:"payee_number" default:"28"` + PayeeZIPCode string `yaml:"payee_zip_code" default:"9400"` + PayeePlace string `yaml:"payee_place" default:"Rorschach"` + PayeeCountry string `yaml:"payee_country" default:"CH"` + Reference string `yaml:"reference" default:"21 00000 00003 13947 14300 09017"` + AdditionalInfo string `yaml:"additional_info" default:"Rechnung Nr. 3139 für Gartenarbeiten"` + Amount string `yaml:"amount" default:"3 949.75"` + Currency string `yaml:"currency" default:"CHF"` +} + +func (i Invoice) GetQR() (src string, err error) { + content, err := i.qrContent() + if err != nil { + return + } + qr, err := qrcode.New(content, qrcode.Medium) + if err != nil { + return + } + qr.DisableBorder = true + png, err := qr.PNG(512) + if err != nil { + return + } + b64 := base64.StdEncoding.EncodeToString(png) + src = fmt.Sprintf(`data:image/png;base64,%s`, b64) + return +} + +// noPayee returns true if no fields of the payee are set +func (i Invoice) noPayee() bool { + return i.PayeeName == "" && i.PayeeStreet == "" && i.PayeeZIPCode == "" && i.PayeePlace == "" +} + +func (i Invoice) qrContent() (string, error) { + qrTpl := `SPC\r +0200\r +1\r +{{ .iban }}\r +S\r +{{ .inv.ReceiverName }}\r +{{ .inv.ReceiverStreet }}\r +{{ .inv.ReceiverNumber }}\r +{{ .inv.ReceiverZIPCode }}\r +{{ .inv.ReceiverPlace }}\r +{{ .inv.ReceiverCountry }}\r +\r +\r +\r +\r +\r +\r +\r +{{ .amount }}\r +{{ .inv.Currency }}\r +{{ .payeeAdrType }}\r +{{ .inv.PayeeName }}\r +{{ .inv.PayeeStreet }}\r +{{ .inv.PayeeNumber }}\r +{{ .inv.PayeeZIPCode }}\r +{{ .inv.PayeePlace }}\r +{{ .inv.PayeeCountry }}\r +{{ .refType }}\r +{{ .reference }}\r +{{ .inv.AdditionalInfo }}\r +EPD +` + refType := "QRR" + if !i.IsQrIBAN { + refType = "SCOR" + } + payeeAdrType := "S" + if i.noPayee() { + payeeAdrType = "" + } + data := map[string]any{ + "inv": i, + "iban": strings.ReplaceAll(i.ReceiverIBAN, " ", ""), + "amount": strings.ReplaceAll(i.Amount, " ", ""), + "payeeAdrType": payeeAdrType, + "reference": strings.ReplaceAll(i.Reference, " ", ""), + "refType": refType, + } + tpl, err := template.New("qr-content").Parse(qrTpl) + if err != nil { + return "", fmt.Errorf("error while creating qr template: %s", err) + } + var buf bytes.Buffer + if err := tpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("error while applying data to qr template: %s", err) + } + rsl := strings.ReplaceAll(buf.String(), "\\r", "\r") + return rsl, nil +} diff --git a/main.go b/main.go index cbcc919..336165f 100644 --- a/main.go +++ b/main.go @@ -1,11 +1,13 @@ package main import ( - "fmt" + "io" + "os" "time" "code.gitea.io/sdk/gitea" "git.schreifuchs.ch/lou-taylor/accounting/issue" + "git.schreifuchs.ch/lou-taylor/accounting/pdf" "git.schreifuchs.ch/lou-taylor/accounting/report" ) @@ -27,7 +29,7 @@ func main() { for _, repo := range []Repo{ {"lou-taylor", "lou-taylor-web"}, {"lou-taylor", "lou-taylor-api"}, - // {"lou-taylor", "accounting"}, + {"lou-taylor", "accounting"}, } { iss, _, err := client.ListRepoIssues( repo.owner, @@ -53,8 +55,44 @@ func main() { }, ) issues := issue.FromGiteas(is, time.Minute*15) - r := report.Report{Issues: issues} - fmt.Print(r.ToHTML()) + r := report.New( + issues, + report.Entity{ + Name: "schreifuchs.ch", + IBAN: "CH06 0079 0042 5877 0443 7", + Address: report.Address{ + Street: "Kilchbergerweg", + Number: "1", + ZIPCode: "3052", + Place: "Zollikofen", + Country: "Schweiz", + }, + Contact: "Niklas Breitenstein", + }, + report.Entity{ + Name: "Lou Taylor", + Address: report.Address{ + Street: "Alpenstrasse", + Number: "22", + ZIPCode: "4950", + Place: "Huttwil", + Country: "Schweiz", + }, + Contact: "Loana Groux", + }, + 16, + ) + html := r.ToHTML() + + // fmt.Print(html) + pdfs, err := pdf.New("http://localhost:3030") + if err != nil { + panic(err) + } + + ducument, err := pdfs.HtmlToPdf(html) + + io.Copy(os.Stdout, ducument) } func Filter[T any](slice []T, ok func(T) bool) []T { diff --git a/pdf/resource.go b/pdf/resource.go new file mode 100644 index 0000000..42419c1 --- /dev/null +++ b/pdf/resource.go @@ -0,0 +1,44 @@ +package pdf + +import ( + "context" + "io" + "net/http" + + "github.com/starwalkn/gotenberg-go-client/v8" + "github.com/starwalkn/gotenberg-go-client/v8/document" +) + +type Service struct { + gotenberg *gotenberg.Client +} + +func New(hostname string) (service Service, err error) { + service.gotenberg, err = gotenberg.NewClient(hostname, http.DefaultClient) + return +} + +func (s Service) HtmlToPdf(html string) (pdf io.ReadCloser, err error) { + index, err := document.FromString("index.html", html) + if err != nil { + return + } + req := gotenberg.NewHTMLRequest(index) + req.PaperSize(gotenberg.A4) + req.Margins(gotenberg.PageMargins{ + Top: 0.5, + Bottom: 0.5, + Left: 0.5, + Right: 0.6, + Unit: gotenberg.IN, + }) + + // Skips the IDLE events for faster PDF conversion. + req.SkipNetworkIdleEvent(true) + req.EmulatePrintMediaType() + + resp, err := s.gotenberg.Send(context.Background(), req) + pdf = resp.Body + + return +} diff --git a/report/html.go b/report/html.go index 6acd0b4..f182c51 100644 --- a/report/html.go +++ b/report/html.go @@ -38,5 +38,6 @@ func offsetHTags(amount int, html template.HTML) template.HTML { return fmt.Sprintf("<%sh%d%s>", closingSlash, newLevel, attrs) }) + return template.HTML(result) } diff --git a/report/invoice.go b/report/invoice.go new file mode 100644 index 0000000..ef15ae0 --- /dev/null +++ b/report/invoice.go @@ -0,0 +1,25 @@ +package report + +import ( + "html/template" + + "git.schreifuchs.ch/lou-taylor/accounting/invoice" +) + +func (r Report) QRInvoice() template.URL { + i := invoice.Invoice{ + ReceiverIBAN: r.Company.IBAN, + IsQrIBAN: false, + ReceiverName: r.Company.Name, + ReceiverStreet: r.Company.Address.Street, + ReceiverNumber: r.Company.Address.Number, + ReceiverZIPCode: r.Company.Address.ZIPCode, + ReceiverPlace: r.Company.Address.Place, + ReceiverCountry: r.Company.Address.Country, + Reference: "21 00000 00003 13947 14300 09017", + Amount: "25", + Currency: "CHF", + } + src, _ := i.GetQR() + return template.URL(src) +} diff --git a/report/resource.go b/report/resource.go index a72d051..319ffb0 100644 --- a/report/resource.go +++ b/report/resource.go @@ -5,11 +5,39 @@ import ( "time" "git.schreifuchs.ch/lou-taylor/accounting/issue" + "github.com/google/uuid" ) -type Report struct { - Date time.Time - Issues []issue.Issue - Style template.CSS - Total string +func New(issues []issue.Issue, company, client Entity, rate float64) *Report { + return &Report{ + Date: time.Now(), + Issues: issues, + Rate: rate, + Company: company, + Client: client, + ID: uuid.NewString(), + } +} + +type Report struct { + Date time.Time + Issues []issue.Issue + Style template.CSS + Rate float64 + ID string + Company Entity + Client Entity +} +type Entity struct { + Name string + Address Address + Contact string + IBAN string +} +type Address struct { + Street string + Number string + ZIPCode string + Place string + Country string } diff --git a/report/style.css b/report/style.css index f00b460..b116261 100644 --- a/report/style.css +++ b/report/style.css @@ -14,6 +14,10 @@ header { display: flex; justify-content: space-between; margin-bottom: 40px; + + h2 { + margin-top: 0; + } } h1 { margin: 0; @@ -70,3 +74,49 @@ footer { padding: 5px 10px; text-align: right; } + +@media print { + .page, + .page-break { + break-after: page; + } +} +.qr-section { + position: absolute; + bottom: 0; + + display: grid; + grid-template-columns: 250px 1fr; + grid-template-rows: 1fr; + gap: 1em; + + width: 100%; + /* border-top: 2px solid #000; */ + /* padding-top: 1rem; */ + + h4 { + margin-bottom: 0.1em; + } + + .qr-code { + display: flex; + flex-direction: column; + justify-content: space-between; + + img { + width: 100%; + } + + div { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + justify-items: center; + } + } + + .payment-details { + font-size: 0.9rem; + line-height: 1.4; + } +} diff --git a/report/template.go b/report/template.go index dc7233f..f1c4a8c 100644 --- a/report/template.go +++ b/report/template.go @@ -5,6 +5,8 @@ import ( _ "embed" "fmt" "html/template" + "math" + "strings" "time" ) @@ -19,27 +21,46 @@ func (r Report) ToHTML() string { template.New("report").Funcs(template.FuncMap{ "md": mdToHTML, "oh": offsetHTags, - "price": applyRate, - "time": fmtTime, + "price": r.applyRate, + "time": fmtDateTime, + "date": fmtDate, "duration": fmtDuration, + "brakes": func(text string) template.HTML { + return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "
")) + }, }).Parse(htmlTemplate)) r.Style = style buf := new(bytes.Buffer) - tmpl.Execute(buf, r) + _ = tmpl.Execute(buf, r) return buf.String() } -func applyRate(dur time.Duration) string { - cost := dur.Hours() * 16 +func (r Report) applyRate(dur time.Duration) string { + cost := dur.Hours() * r.Rate + cost = math.Round(cost/0.05) * 0.05 return fmt.Sprintf("%.2f", cost) } -func fmtTime(t time.Time) string { +func fmtDateTime(t time.Time) string { return t.Format("02.08.2006 15:04") } +func fmtDate(t time.Time) string { + return t.Format("02.08.2006") +} + func fmtDuration(d time.Duration) string { return fmt.Sprintf("%.2f h", d.Hours()) } + +func (r Report) Total() string { + dur := time.Duration(0) + + for _, i := range r.Issues { + dur += i.Duration + } + + return r.applyRate(dur) +} diff --git a/report/template.html b/report/template.html index 0ba9960..4842f1d 100644 --- a/report/template.html +++ b/report/template.html @@ -3,7 +3,7 @@ - Rechnung vom {{ .Date }} + Rechnung vom {{ .Date | date }}