feat(invoice): add send route
This commit is contained in:
65
pkg/invoice/invoice.go
Normal file
65
pkg/invoice/invoice.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package invoice
|
||||
|
||||
import (
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/issue"
|
||||
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/model"
|
||||
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/report"
|
||||
)
|
||||
|
||||
func (s *Service) Generate(creditor, deptor model.Entity, mindur time.Duration, rate float64, repos []Repo) (document io.ReadCloser, err error) {
|
||||
var is []*gitea.Issue
|
||||
for _, repo := range repos {
|
||||
iss, _, err := s.gitea.ListRepoIssues(
|
||||
repo.Owner,
|
||||
repo.Repo,
|
||||
gitea.ListIssueOption{
|
||||
ListOptions: gitea.ListOptions{Page: 0, PageSize: 99999},
|
||||
Since: time.Now().AddDate(0, -1, 0),
|
||||
Before: time.Now(),
|
||||
State: gitea.StateClosed,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
is = append(is, iss...)
|
||||
}
|
||||
|
||||
is = filter(
|
||||
is,
|
||||
func(i *gitea.Issue) bool {
|
||||
return i.Closed != nil && i.Closed.After(time.Now().AddDate(0, -1, 0))
|
||||
},
|
||||
)
|
||||
issues := issue.FromGiteas(is, mindur)
|
||||
r := report.New(
|
||||
issues,
|
||||
creditor,
|
||||
deptor,
|
||||
rate,
|
||||
)
|
||||
html, err := r.ToHTML()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
document, err = s.pdf.HtmlToPdf(html)
|
||||
return
|
||||
}
|
||||
|
||||
func filter[T any](slice []T, ok func(T) bool) []T {
|
||||
out := make([]T, 0, len(slice))
|
||||
|
||||
for _, item := range slice {
|
||||
if ok(item) {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
76
pkg/invoice/issue/issue.go
Normal file
76
pkg/invoice/issue/issue.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
)
|
||||
|
||||
func FromGiteas(is []*gitea.Issue, mindur time.Duration) []Issue {
|
||||
issues := make([]Issue, 0, len(is))
|
||||
|
||||
for _, i := range is {
|
||||
if i == nil {
|
||||
continue
|
||||
}
|
||||
issue := FromGitea(*i)
|
||||
|
||||
if issue.Duration < mindur {
|
||||
continue
|
||||
}
|
||||
|
||||
issues = append(issues, issue)
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func FromGitea(i gitea.Issue) Issue {
|
||||
issue := Issue{Issue: i}
|
||||
|
||||
issue.Duration, _ = ExtractDuration(i.Body)
|
||||
|
||||
return issue
|
||||
}
|
||||
|
||||
func ExtractDuration(text string) (duration time.Duration, err error) {
|
||||
// First capture only the content inside ```info ... ```
|
||||
reBlock := regexp.MustCompile("(?s)```info(.*?)```")
|
||||
block := reBlock.FindStringSubmatch(text)
|
||||
if len(block) < 2 {
|
||||
err = fmt.Errorf("no info block found")
|
||||
return
|
||||
}
|
||||
|
||||
// Now extract the duration line from inside that block
|
||||
reDuration := regexp.MustCompile(`duration:\s*([0-9hmin\s]+)`)
|
||||
match := reDuration.FindStringSubmatch(block[1])
|
||||
if len(match) < 2 {
|
||||
err = fmt.Errorf("no duration found inside info block")
|
||||
return
|
||||
}
|
||||
dur := strings.TrimSpace(match[1])
|
||||
dur = strings.ReplaceAll(dur, "min", "m")
|
||||
dur = strings.ReplaceAll(dur, " ", "") // remove spaces
|
||||
|
||||
return time.ParseDuration(dur)
|
||||
}
|
||||
|
||||
func Print(issues []Issue) {
|
||||
t := table.NewWriter()
|
||||
t.SetOutputMirror(os.Stdout)
|
||||
t.AppendHeader(table.Row{"#", "Title", "Body", "Duration", "Finished by"})
|
||||
|
||||
for i, iss := range issues {
|
||||
if i != 0 {
|
||||
t.AppendSeparator()
|
||||
}
|
||||
t.AppendRow(table.Row{iss.Index, iss.Title, iss.Body, iss.Duration, iss.Closed})
|
||||
}
|
||||
|
||||
t.Render()
|
||||
}
|
||||
35
pkg/invoice/issue/resource.go
Normal file
35
pkg/invoice/issue/resource.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
)
|
||||
|
||||
type Issue struct {
|
||||
gitea.Issue
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
func (i Issue) Shorthand() string {
|
||||
str := i.Repository.Name
|
||||
if strings.Contains(str, "-") {
|
||||
s := strings.Split(str, "-")
|
||||
str = s[len(s)-1]
|
||||
} else if len(str) > 3 {
|
||||
str = str[:3]
|
||||
}
|
||||
return fmt.Sprintf("%s-%d", strings.ToUpper(str), i.Index)
|
||||
}
|
||||
|
||||
func (i Issue) CleanBody() string {
|
||||
reBlock := regexp.MustCompile("(?s)```info(.*?)```(.*)")
|
||||
block := reBlock.FindStringSubmatch(i.Body)
|
||||
if len(block) < 3 {
|
||||
return i.Body
|
||||
}
|
||||
return strings.TrimSpace(block[2])
|
||||
}
|
||||
15
pkg/invoice/model/model.go
Normal file
15
pkg/invoice/model/model.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package model
|
||||
|
||||
type Entity struct {
|
||||
Name string `json:"name"`
|
||||
Address Address `json:"Address"`
|
||||
Contact string `json:"contact"`
|
||||
IBAN string `json:"iban,omitempty"`
|
||||
}
|
||||
type Address struct {
|
||||
Street string `json:"street"`
|
||||
Number string `json:"number"`
|
||||
ZIPCode string `json:"zipCode"`
|
||||
Place string `json:"place"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
86
pkg/invoice/report/assets/style.css
Normal file
86
pkg/invoice/report/assets/style.css
Normal file
@@ -0,0 +1,86 @@
|
||||
@page {
|
||||
size: A4;
|
||||
padding: 2cm;
|
||||
}
|
||||
@page:first {
|
||||
padding: 0;
|
||||
}
|
||||
@media print {
|
||||
.page,
|
||||
.page-break {
|
||||
break-after: page;
|
||||
}
|
||||
}
|
||||
|
||||
.first-page {
|
||||
margin-left: 2cm;
|
||||
margin-right: 2cm;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
font-size: 12pt;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 3em;
|
||||
}
|
||||
p {
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 40px;
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 2em;
|
||||
}
|
||||
h2 {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
table,
|
||||
th,
|
||||
td {
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 0.25em 0.5em;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
|
||||
article {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.issue-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
|
||||
margin-top: 2em;
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
95
pkg/invoice/report/assets/template.html
Normal file
95
pkg/invoice/report/assets/template.html
Normal file
@@ -0,0 +1,95 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Rechnung vom {{ .Date | date }}</title>
|
||||
<!-- <link href="css/style.css" rel="stylesheet" /> -->
|
||||
<style>
|
||||
{{ .Style }}
|
||||
</style>
|
||||
<style>
|
||||
|
||||
{{.Invoice.CSS}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="first-page" style="margin-top: 2cm">
|
||||
<div class="company">
|
||||
<h2>{{ .Company.Name }}</h2>
|
||||
<p>
|
||||
{{ .Company.Address.Street}} {{.Company.Address.Number}} <br />
|
||||
{{ .Company.Address.ZIPCode }} {{ .Company.Address.Place }} <br />
|
||||
{{ .Company.Contact }}
|
||||
</p>
|
||||
|
||||
<p></p>
|
||||
</div>
|
||||
<div class="invoice-info">
|
||||
<p>
|
||||
<strong>Rechnung:</strong> {{ .Invoice.Reference }} <br />
|
||||
<strong>Datum:</strong> {{ .Date | date }} <br />
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<section class="client first-page">
|
||||
<h2>Rechnung an:</h2>
|
||||
<p>
|
||||
{{ .Client.Name }} <br />
|
||||
{{ .Client.Address.Street}} {{.Client.Address.Number}} <br />
|
||||
{{ .Client.Address.ZIPCode }} {{ .Client.Address.Place }} <br />
|
||||
{{ .Client.Contact }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="page p1 first-page">
|
||||
<article>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="min-width: 3.5em">FID</th>
|
||||
<th>Name</th>
|
||||
<th>Aufwand</th>
|
||||
<th style="min-width: 5.5em">Preis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Issues }}
|
||||
<tr>
|
||||
<td style="font-family: monospace">{{ .Shorthand }}</td>
|
||||
<td>{{ .Title }}</td>
|
||||
<td>{{ .Duration | duration }}</td>
|
||||
<td>{{ .Duration | price }} CHF</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th colspan="2" style="text-align: right">Summe:</th>
|
||||
|
||||
<td>{{ .Total | duration }}</td>
|
||||
<td>{{ .Total | price}}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</article>
|
||||
{{ .Invoice.HTML }}
|
||||
</section>
|
||||
<section>
|
||||
<h2 style="margin-top: 0">Details zu den Features</h2>
|
||||
|
||||
{{ range .Issues }}
|
||||
<article>
|
||||
<div class="issue-title">
|
||||
<h3>
|
||||
<span style="font-family: monospace">{{ .Shorthand }}</span>: {{
|
||||
.Title }}
|
||||
</h3>
|
||||
<p>Fertiggestellt: {{ .Closed | time }}</p>
|
||||
</div>
|
||||
{{ .CleanBody | md | oh 3 }}
|
||||
</article>
|
||||
{{ end }}
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
43
pkg/invoice/report/html.go
Normal file
43
pkg/invoice/report/html.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// OffsetHTags offsets all <h1>-<h6> tags in the HTML by the given amount.
|
||||
// If the resulting heading level exceeds 6, it is capped at 6.
|
||||
func offsetHTags(amount int, html template.HTML) template.HTML {
|
||||
// Regular expression to match opening and closing h tags, e.g., <h1> or </h2>
|
||||
re := regexp.MustCompile(`(?i)<(/?)h([1-6])(\s[^>]*)?>`)
|
||||
|
||||
// Replace all matches
|
||||
result := re.ReplaceAllStringFunc(string(html), func(tag string) string {
|
||||
matches := re.FindStringSubmatch(tag)
|
||||
if len(matches) < 4 {
|
||||
return tag
|
||||
}
|
||||
|
||||
closingSlash := matches[1] // "/" if closing tag
|
||||
levelStr := matches[2] // heading level
|
||||
attrs := matches[3] // attributes like ' class="foo"'
|
||||
|
||||
level, err := strconv.Atoi(levelStr)
|
||||
if err != nil {
|
||||
return tag
|
||||
}
|
||||
|
||||
newLevel := level + amount
|
||||
if newLevel < 1 {
|
||||
newLevel = 1
|
||||
} else if newLevel > 6 {
|
||||
newLevel = 6
|
||||
}
|
||||
|
||||
return fmt.Sprintf("<%sh%d%s>", closingSlash, newLevel, attrs)
|
||||
})
|
||||
|
||||
return template.HTML(result)
|
||||
}
|
||||
79
pkg/invoice/report/html_test.go
Normal file
79
pkg/invoice/report/html_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOffsetHTags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
html string
|
||||
amount int
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple offset",
|
||||
html: `<h1>Main</h1><h2>Sub</h2>`,
|
||||
amount: 2,
|
||||
expected: `<h3>Main</h3><h4>Sub</h4>`,
|
||||
},
|
||||
{
|
||||
name: "offset beyond h6",
|
||||
html: `<h5>Almost max</h5><h6>Max</h6>`,
|
||||
amount: 3,
|
||||
expected: `<h6>Almost max</h6><h6>Max</h6>`,
|
||||
},
|
||||
{
|
||||
name: "negative offset",
|
||||
html: `<h2>Heading</h2><h3>Subheading</h3>`,
|
||||
amount: -2,
|
||||
expected: `<h1>Heading</h1><h1>Subheading</h1>`,
|
||||
},
|
||||
{
|
||||
name: "no h tags",
|
||||
html: `<p>Paragraph</p>`,
|
||||
amount: 2,
|
||||
expected: `<p>Paragraph</p>`,
|
||||
},
|
||||
{
|
||||
name: "mixed content",
|
||||
html: `<h1>Title</h1><div><h2>Inner</h2></div>`,
|
||||
amount: 1,
|
||||
expected: `<h2>Title</h2><div><h3>Inner</h3></div>`,
|
||||
},
|
||||
{
|
||||
name: "real one",
|
||||
html: `<p>Hello</p>
|
||||
|
||||
<pre><code class="language-info">duration: 1h 43min
|
||||
</code></pre>
|
||||
|
||||
<h1 id="adökfjaösldkjflaa">adökfjaösldkjflaa</h1>
|
||||
|
||||
<h2 id="asdfads">ASDFADS</h2>
|
||||
|
||||
<h3 id="adllglggl">adllglggl</h3>`,
|
||||
amount: 3,
|
||||
expected: `<p>Hello</p>
|
||||
|
||||
<pre><code class="language-info">duration: 1h 43min
|
||||
</code></pre>
|
||||
|
||||
<h4 id="adökfjaösldkjflaa">adökfjaösldkjflaa</h4>
|
||||
|
||||
<h5 id="asdfads">ASDFADS</h5>
|
||||
|
||||
<h6 id="adllglggl">adllglggl</h6>`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := offsetHTags(tt.amount, template.HTML(tt.html))
|
||||
if string(got) != tt.expected {
|
||||
t.Errorf("OffsetHTags() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
49
pkg/invoice/report/markdown.go
Normal file
49
pkg/invoice/report/markdown.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/html"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
)
|
||||
|
||||
func mdToHTML(md string) template.HTML {
|
||||
md = markdownCheckboxesToHTML(md)
|
||||
|
||||
// create markdown parser with extensions
|
||||
extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
|
||||
p := parser.NewWithExtensions(extensions)
|
||||
doc := p.Parse([]byte(md))
|
||||
|
||||
// create HTML renderer with extensions
|
||||
htmlFlags := html.CommonFlags | html.HrefTargetBlank
|
||||
opts := html.RendererOptions{Flags: htmlFlags}
|
||||
renderer := html.NewRenderer(opts)
|
||||
|
||||
return template.HTML(markdown.Render(doc, renderer))
|
||||
}
|
||||
|
||||
// markdownCheckboxesToHTML keeps a Markdown list but replaces checkboxes with <input>.
|
||||
func markdownCheckboxesToHTML(markdown string) string {
|
||||
lines := strings.Split(markdown, "\n")
|
||||
checkboxPattern := regexp.MustCompile(`^(\s*[-*]\s+)\[( |x|X)\][\p{Zs}\s]*(.*)$`)
|
||||
|
||||
for i, line := range lines {
|
||||
if matches := checkboxPattern.FindStringSubmatch(line); matches != nil {
|
||||
prefix := matches[1] // "- " or "* "
|
||||
checked := strings.ToLower(matches[2]) == "x"
|
||||
content := matches[3] // task text
|
||||
if checked {
|
||||
lines[i] = fmt.Sprintf(`%s<input type="checkbox" checked disabled> %s`, prefix, content)
|
||||
} else {
|
||||
lines[i] = fmt.Sprintf(`%s<input type="checkbox" disabled> %s`, prefix, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
27
pkg/invoice/report/markdown_test.go
Normal file
27
pkg/invoice/report/markdown_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMarkdownCheckboxesToHTML(t *testing.T) {
|
||||
input := `
|
||||
The application must support English and German.
|
||||
|
||||
## TODO's
|
||||
- [x] Add multiple languages to Pages
|
||||
- [x] Add multiple languages to Events`
|
||||
|
||||
expected := `
|
||||
The application must support English and German.
|
||||
|
||||
## TODO's
|
||||
- <input type="checkbox" checked disabled> Add multiple languages to Pages
|
||||
- <input type="checkbox" checked disabled> Add multiple languages to Events`
|
||||
|
||||
output := markdownCheckboxesToHTML(input)
|
||||
|
||||
if output != expected {
|
||||
t.Errorf("unexpected output:\nGot:\n%s\n\nExpected:\n%s", output, expected)
|
||||
}
|
||||
}
|
||||
88
pkg/invoice/report/qrbill/assets/ch-cross.svg
Normal file
88
pkg/invoice/report/qrbill/assets/ch-cross.svg
Normal file
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Generator: Adobe Illustrator 20.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
version="1.1"
|
||||
id="Ebene_2"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 128 128"
|
||||
xml:space="preserve"
|
||||
sodipodi:docname="ch-cross.svg"
|
||||
width="128"
|
||||
height="128"
|
||||
inkscape:export-filename="/home/irvin/Work/repos/swiss-qr-invoice/assets/raw/ch-cross.png"
|
||||
inkscape:export-xdpi="232.22856"
|
||||
inkscape:export-ydpi="232.22856"
|
||||
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
|
||||
style="shape-rendering:crispEdges"><metadata
|
||||
id="metadata17"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||
id="defs15" /><sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1668"
|
||||
inkscape:window-height="1021"
|
||||
id="namedview13"
|
||||
showgrid="false"
|
||||
inkscape:document-units="px"
|
||||
units="px"
|
||||
inkscape:zoom="2.8284271"
|
||||
inkscape:cx="-38.018193"
|
||||
inkscape:cy="61.405051"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="31"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="Ebene_2"><inkscape:grid
|
||||
type="xygrid"
|
||||
id="grid916" /></sodipodi:namedview>
|
||||
<style
|
||||
type="text/css"
|
||||
id="style2">
|
||||
.st0{fill:#FFFFFF;}
|
||||
.st1{fill:none;stroke:#FFFFFF;stroke-width:1.4357;stroke-miterlimit:10;}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
<g
|
||||
id="g4736"
|
||||
transform="matrix(4.8380969,0,0,4.8380389,-1.9834927e-5,9.0008541e-4)"><polygon
|
||||
transform="matrix(1.3598365,0,0,1.3598365,-0.23403522,-0.23403522)"
|
||||
style="fill:#000000;fill-opacity:1;stroke-width:0.73538256"
|
||||
id="polygon4"
|
||||
points="0.7,0.7 0.7,1.6 0.7,18.3 0.7,19.1 1.6,19.1 18.3,19.1 19.1,19.1 19.1,18.3 19.1,1.6 19.1,0.7 18.3,0.7 1.6,0.7 " /><rect
|
||||
style="fill:#ffffff;fill-opacity:1;stroke-width:1"
|
||||
id="rect6"
|
||||
height="14.958201"
|
||||
width="4.4874606"
|
||||
class="st0"
|
||||
y="5.2231627"
|
||||
x="11.034758" /><rect
|
||||
style="fill:#fffff9;fill-opacity:1;stroke-width:1"
|
||||
id="rect8"
|
||||
height="4.4874606"
|
||||
width="14.958201"
|
||||
class="st0"
|
||||
y="10.526525"
|
||||
x="5.7313952" /><polygon
|
||||
transform="matrix(1.3598207,0,0,1.3598361,-0.2338788,-0.23403126)"
|
||||
style="fill:none;stroke:#ffffff;stroke-width:1.05601561;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="polygon10"
|
||||
points="1.6,0.7 0.7,0.7 0.7,1.6 0.7,18.3 0.7,19.1 1.6,19.1 18.3,19.1 19.1,19.1 19.1,18.3 19.1,1.6 19.1,0.7 18.3,0.7 "
|
||||
class="st1" /></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
71
pkg/invoice/report/qrbill/assets/scissors.svg
Normal file
71
pkg/invoice/report/qrbill/assets/scissors.svg
Normal file
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="64"
|
||||
height="106.131"
|
||||
viewBox="0 0 16.933333 28.080496"
|
||||
version="1.1"
|
||||
id="svg851"
|
||||
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
|
||||
sodipodi:docname="scissors.svg"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96">
|
||||
<defs
|
||||
id="defs845" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="2.6582031"
|
||||
inkscape:cx="-135.5963"
|
||||
inkscape:cy="106.75833"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1080"
|
||||
inkscape:window-x="86"
|
||||
inkscape:window-y="117"
|
||||
inkscape:window-maximized="0" />
|
||||
<metadata
|
||||
id="metadata848">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-52.847684,-108.31232)">
|
||||
<text
|
||||
y="-57.540855"
|
||||
x="117.23324"
|
||||
class="st2 st3"
|
||||
id="text18"
|
||||
style="font-size:11.17903996px;font-family:ZapfDingbatsITC;stroke-width:0.26458332"
|
||||
transform="rotate(90)">✂</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
111
pkg/invoice/report/qrbill/assets/style.css
Normal file
111
pkg/invoice/report/qrbill/assets/style.css
Normal file
@@ -0,0 +1,111 @@
|
||||
.c6ee15365-8f47-4dab-8bee-2a56a7916c57 {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1px 1fr;
|
||||
grid-template-rows: 1px 1fr;
|
||||
|
||||
.separator-h {
|
||||
grid-row: 1;
|
||||
grid-column: 1 / span 3;
|
||||
|
||||
border-top: 1px solid black;
|
||||
svg {
|
||||
transform: rotate(-90deg);
|
||||
margin-top: -52px;
|
||||
}
|
||||
}
|
||||
|
||||
.separator-v {
|
||||
grid-row: 2;
|
||||
grid-column: 2;
|
||||
|
||||
border-right: 1px solid black;
|
||||
svg {
|
||||
margin-left: -32px;
|
||||
}
|
||||
}
|
||||
|
||||
.receiver {
|
||||
grid-row: 2;
|
||||
grid-column: 1;
|
||||
|
||||
font-size: 12pt;
|
||||
padding: 1em;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
h4 {
|
||||
margin-bottom: 0.1em;
|
||||
}
|
||||
}
|
||||
|
||||
.payer {
|
||||
grid-row: 2;
|
||||
grid-column: 3;
|
||||
display: grid;
|
||||
grid-template-columns: 250px 1fr;
|
||||
grid-template-rows: 2em 1fr;
|
||||
padding: 1em;
|
||||
gap: 1em;
|
||||
|
||||
width: 100%;
|
||||
|
||||
h2 {
|
||||
grid-row: 1;
|
||||
grid-column: 1 / 2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-bottom: 0.1em;
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
grid-row: 2;
|
||||
grid-column: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
.qr-section-img {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
svg {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1;
|
||||
width: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.qr-section-info {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
justify-items: center;
|
||||
|
||||
p,
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.payment-details {
|
||||
grid-row: 2;
|
||||
grid-column: 2;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
70
pkg/invoice/report/qrbill/assets/template.html
Normal file
70
pkg/invoice/report/qrbill/assets/template.html
Normal file
@@ -0,0 +1,70 @@
|
||||
<article class="c6ee15365-8f47-4dab-8bee-2a56a7916c57">
|
||||
<div class="separator-h">{{ .Scissors }}</div>
|
||||
<div class="receiver">
|
||||
<div class="payment-details">
|
||||
<div>
|
||||
<h2>Empfangsschein</h2>
|
||||
<h4 style="margin-top: 0">Konto / Zahlbar an</h4>
|
||||
<p>
|
||||
{{ .ReceiverIBAN }} <br />
|
||||
{{ .ReceiverName }} <br />
|
||||
{{ .ReceiverStreet}} {{.ReceiverNumber}} <br />
|
||||
{{ .ReceiverZIPCode }} {{ .ReceiverPlace }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Referenz</h4>
|
||||
<p>{{ .Reference }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Zahlbar durch</h4>
|
||||
<p>
|
||||
{{ .PayeeName }} <br />
|
||||
{{ .PayeeStreet}} {{.PayeeNumber}} <br />
|
||||
{{ .PayeeZIPCode }} {{ .PayeePlace }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="separator-v">{{ .Scissors }}</div>
|
||||
<div class="payer">
|
||||
<h2>Zahlteil</h2>
|
||||
<div class="qr-code">
|
||||
<!-- Replace with your QR code image or generated SVG -->
|
||||
<div class="qr-section-img">
|
||||
<image src="{{ .GetQR }}"></image>
|
||||
{{ .Cross }}
|
||||
</div>
|
||||
|
||||
<div class="qr-section-info">
|
||||
<h4>Währung</h4>
|
||||
<h4>Betrag</h4>
|
||||
<p>CHF</p>
|
||||
<p>{{ .Amount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="payment-details">
|
||||
<div>
|
||||
<h4 style="margin-top: 0">Konto / Zahlbar an</h4>
|
||||
<p>
|
||||
{{ .ReceiverIBAN }} <br />
|
||||
{{ .ReceiverName }} <br />
|
||||
{{ .ReceiverStreet}} {{.ReceiverNumber}} <br />
|
||||
{{ .ReceiverZIPCode }} {{ .ReceiverPlace }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Referenz</h4>
|
||||
<p>{{ .Reference }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Zahlbar durch</h4>
|
||||
<p>
|
||||
{{ .PayeeName }} <br />
|
||||
{{ .PayeeStreet}} {{.PayeeNumber}} <br />
|
||||
{{ .PayeeZIPCode }} {{ .PayeePlace }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
42
pkg/invoice/report/qrbill/html.go
Normal file
42
pkg/invoice/report/qrbill/html.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package qrbill
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed assets/ch-cross.svg
|
||||
var chCross template.HTML
|
||||
|
||||
//go:embed assets/scissors.svg
|
||||
var scissors template.HTML
|
||||
|
||||
//go:embed assets/template.html
|
||||
var htmlTemplate string
|
||||
|
||||
//go:embed assets/style.css
|
||||
var style template.CSS
|
||||
|
||||
type tmpler struct {
|
||||
Invoice
|
||||
Cross template.HTML
|
||||
Scissors template.HTML
|
||||
}
|
||||
|
||||
func (i Invoice) CSS() template.CSS {
|
||||
return style
|
||||
}
|
||||
|
||||
func (i Invoice) HTML() (html template.HTML, err error) {
|
||||
tmpl := template.Must(template.New("invoice").Parse(htmlTemplate))
|
||||
|
||||
data := tmpler{i, chCross, scissors}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
err = tmpl.Execute(buf, data)
|
||||
html = template.HTML(buf.String())
|
||||
|
||||
return
|
||||
}
|
||||
144
pkg/invoice/report/qrbill/invoice.go
Normal file
144
pkg/invoice/report/qrbill/invoice.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package qrbill
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/model"
|
||||
"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 New(amount string, receiver model.Entity, payee *model.Entity) Invoice {
|
||||
i := Invoice{
|
||||
ReceiverIBAN: receiver.IBAN,
|
||||
IsQrIBAN: false,
|
||||
ReceiverName: receiver.Name,
|
||||
ReceiverStreet: receiver.Address.Street,
|
||||
ReceiverNumber: receiver.Address.Number,
|
||||
ReceiverZIPCode: receiver.Address.ZIPCode,
|
||||
ReceiverPlace: receiver.Address.Place,
|
||||
ReceiverCountry: receiver.Address.Country,
|
||||
Reference: generateReference(),
|
||||
Amount: amount,
|
||||
Currency: "CHF",
|
||||
}
|
||||
if payee != nil {
|
||||
i.PayeeName = payee.Name
|
||||
i.PayeeStreet = payee.Address.Street
|
||||
i.PayeeNumber = payee.Address.Number
|
||||
i.PayeeZIPCode = payee.Address.ZIPCode
|
||||
i.PayeePlace = payee.Address.Place
|
||||
i.PayeeCountry = payee.Address.Country
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func (i Invoice) GetQR() (src template.URL, 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 = template.URL(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
|
||||
}
|
||||
68
pkg/invoice/report/qrbill/reference.go
Normal file
68
pkg/invoice/report/qrbill/reference.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package qrbill
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func generateReference() string {
|
||||
// 1) Build the 26-digit payload from time + random suffix
|
||||
now := time.Now().UTC()
|
||||
ts := now.Format("20060102150405") // YYYYMMDDHHMMSS -> 14 chars
|
||||
ns := now.Nanosecond() // 0..999999999
|
||||
// format nanoseconds as 9 digits, zero padded
|
||||
nsStr := fmt.Sprintf("%09d", ns)
|
||||
|
||||
// 3-digit random suffix (000..999) to reach 26 digits
|
||||
randBytes := make([]byte, 2) // we'll read 2 bytes and map to 0..999
|
||||
rand.Read(randBytes)
|
||||
|
||||
// convert to number 0..65535 then mod 1000
|
||||
rnum := int(randBytes[0])<<8 | int(randBytes[1])
|
||||
rnum = rnum % 1000
|
||||
randStr := fmt.Sprintf("%03d", rnum)
|
||||
|
||||
// compose 26-digit payload: 14 + 9 + 3 = 26
|
||||
payload := ts + nsStr + randStr
|
||||
|
||||
// Safety: ensure payload is 26 digits (should always be true)
|
||||
if len(payload) != 26 {
|
||||
panic(fmt.Errorf("internal error: payload length %d != 26", len(payload)))
|
||||
}
|
||||
|
||||
// 2) Compute Modulo-10 recursive check digit
|
||||
check := mod10RecursiveChecksum(payload)
|
||||
|
||||
// 3) Build full 27-digit reference
|
||||
full := payload + fmt.Sprintf("%d", check)
|
||||
|
||||
// 4) Format with grouping: 2-5-5-5-5-5
|
||||
formatted := fmt.Sprintf("%s %s %s %s %s %s",
|
||||
full[0:2],
|
||||
full[2:7],
|
||||
full[7:12],
|
||||
full[12:17],
|
||||
full[17:22],
|
||||
full[22:27],
|
||||
)
|
||||
return formatted
|
||||
}
|
||||
|
||||
// mod10RecursiveChecksum computes the Mod10 recursive check digit for a numeric string.
|
||||
// Implements the table-based recursive mod10 described in Swiss Annex B / ISR implementations.
|
||||
func mod10RecursiveChecksum(digits string) int {
|
||||
// table as used in ISR/QR implementations
|
||||
arrTable := [10]int{0, 9, 4, 6, 8, 2, 7, 1, 3, 5}
|
||||
carry := 0
|
||||
for i := 0; i < len(digits); i++ {
|
||||
ch := digits[i]
|
||||
// assume digits are ASCII '0'..'9'
|
||||
n := int(ch - '0')
|
||||
// update carry
|
||||
idx := (carry + n) % 10
|
||||
carry = arrTable[idx]
|
||||
}
|
||||
// final check digit
|
||||
return (10 - carry) % 10
|
||||
}
|
||||
31
pkg/invoice/report/resource.go
Normal file
31
pkg/invoice/report/resource.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/issue"
|
||||
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/model"
|
||||
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/report/qrbill"
|
||||
)
|
||||
|
||||
type Report struct {
|
||||
Date time.Time
|
||||
Issues []issue.Issue
|
||||
Invoice qrbill.Invoice
|
||||
Rate float64
|
||||
Company model.Entity
|
||||
Client model.Entity
|
||||
}
|
||||
|
||||
func New(issues []issue.Issue, company, client model.Entity, rate float64) *Report {
|
||||
r := &Report{
|
||||
Date: time.Now(),
|
||||
Issues: issues,
|
||||
Rate: rate,
|
||||
Company: company,
|
||||
Client: client,
|
||||
}
|
||||
// r.Invoice = qrbill.New(r.applyRate(r.Total()), r.Company, &r.Client)
|
||||
|
||||
return r
|
||||
}
|
||||
71
pkg/invoice/report/template.go
Normal file
71
pkg/invoice/report/template.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed assets/template.html
|
||||
var htmlTemplate string
|
||||
|
||||
//go:embed assets/style.css
|
||||
var style template.CSS
|
||||
|
||||
type tmpler struct {
|
||||
Report
|
||||
Style template.CSS
|
||||
}
|
||||
|
||||
func (r Report) ToHTML() (html string, err error) {
|
||||
tmpl := template.Must(
|
||||
template.New("report").Funcs(template.FuncMap{
|
||||
"md": mdToHTML,
|
||||
"oh": offsetHTags,
|
||||
"price": r.applyRate,
|
||||
"time": fmtDateTime,
|
||||
"date": fmtDate,
|
||||
"duration": fmtDuration,
|
||||
}).Parse(htmlTemplate))
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
err = tmpl.Execute(buf, tmpler{r, style})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
html = buf.String()
|
||||
return
|
||||
}
|
||||
|
||||
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 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() time.Duration {
|
||||
dur := time.Duration(0)
|
||||
|
||||
for _, i := range r.Issues {
|
||||
dur += i.Duration
|
||||
}
|
||||
|
||||
return dur
|
||||
}
|
||||
34
pkg/invoice/resource.go
Normal file
34
pkg/invoice/resource.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package invoice
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
log *slog.Logger
|
||||
gitea giteaClient
|
||||
pdf pdfGenerator
|
||||
}
|
||||
|
||||
func New(log *slog.Logger, gitea giteaClient, pdf pdfGenerator) *Service {
|
||||
return &Service{
|
||||
log: log,
|
||||
gitea: gitea,
|
||||
pdf: pdf,
|
||||
}
|
||||
}
|
||||
|
||||
type giteaClient interface {
|
||||
ListRepoIssues(owner, repo string, opt gitea.ListIssueOption) ([]*gitea.Issue, *gitea.Response, error)
|
||||
}
|
||||
type pdfGenerator interface {
|
||||
HtmlToPdf(html string) (pdf io.ReadCloser, err error)
|
||||
}
|
||||
Reference in New Issue
Block a user