diff --git a/index.html b/index.html
index 2372a62..95779d2 100644
Binary files a/index.html and b/index.html differ
diff --git a/index.pdf b/index.pdf
index 079398a..9e6e111 100644
Binary files a/index.pdf and b/index.pdf differ
diff --git a/issue/resource.go b/issue/resource.go
index 0fc9154..a5e50eb 100644
--- a/issue/resource.go
+++ b/issue/resource.go
@@ -1,6 +1,9 @@
package issue
import (
+ "fmt"
+ "regexp"
+ "strings"
"time"
"code.gitea.io/sdk/gitea"
@@ -10,3 +13,23 @@ 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])
+}
diff --git a/main.go b/main.go
index 336165f..2b0b78f 100644
--- a/main.go
+++ b/main.go
@@ -7,6 +7,7 @@ import (
"code.gitea.io/sdk/gitea"
"git.schreifuchs.ch/lou-taylor/accounting/issue"
+ "git.schreifuchs.ch/lou-taylor/accounting/model"
"git.schreifuchs.ch/lou-taylor/accounting/pdf"
"git.schreifuchs.ch/lou-taylor/accounting/report"
)
@@ -57,10 +58,10 @@ func main() {
issues := issue.FromGiteas(is, time.Minute*15)
r := report.New(
issues,
- report.Entity{
+ model.Entity{
Name: "schreifuchs.ch",
IBAN: "CH06 0079 0042 5877 0443 7",
- Address: report.Address{
+ Address: model.Address{
Street: "Kilchbergerweg",
Number: "1",
ZIPCode: "3052",
@@ -69,9 +70,9 @@ func main() {
},
Contact: "Niklas Breitenstein",
},
- report.Entity{
+ model.Entity{
Name: "Lou Taylor",
- Address: report.Address{
+ Address: model.Address{
Street: "Alpenstrasse",
Number: "22",
ZIPCode: "4950",
@@ -84,6 +85,14 @@ func main() {
)
html := r.ToHTML()
+ file, err := os.Create("index.html")
+ if err != nil {
+ panic(err)
+ }
+ defer file.Close()
+
+ file.Write([]byte(html))
+
// fmt.Print(html)
pdfs, err := pdf.New("http://localhost:3030")
if err != nil {
diff --git a/model/model.go b/model/model.go
new file mode 100644
index 0000000..160f9b6
--- /dev/null
+++ b/model/model.go
@@ -0,0 +1,15 @@
+package model
+
+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/pdf/resource.go b/pdf/resource.go
index 42419c1..76075e4 100644
--- a/pdf/resource.go
+++ b/pdf/resource.go
@@ -25,14 +25,15 @@ func (s Service) HtmlToPdf(html string) (pdf io.ReadCloser, err error) {
}
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,
- })
+ // req.Margins(gotenberg.PageMargins{
+ // Top: 0.5,
+ // Bottom: 0.5,
+ // Left: 0.5,
+ // Right: 0.6,
+ // Unit: gotenberg.IN,
+ // })
+ req.Margins(gotenberg.NoMargins)
// Skips the IDLE events for faster PDF conversion.
req.SkipNetworkIdleEvent(true)
req.EmulatePrintMediaType()
diff --git a/report/assets/style.css b/report/assets/style.css
index 4f3b35f..32ef334 100644
--- a/report/assets/style.css
+++ b/report/assets/style.css
@@ -1,8 +1,28 @@
+@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;
- margin: 40px;
+ font-size: 12pt;
color: #333;
}
+
section {
margin-bottom: 3em;
}
@@ -10,6 +30,7 @@ p {
margin-top: 0.25em;
margin-bottom: 0.5em;
}
+
header {
display: flex;
justify-content: space-between;
@@ -23,10 +44,10 @@ h1 {
margin: 0;
font-size: 2em;
}
-.company,
-.client {
- margin-bottom: 20px;
+h2 {
+ margin-top: 0.5em;
}
+
table {
width: 100%;
border-collapse: collapse;
@@ -39,96 +60,27 @@ td {
}
th,
td {
- padding: 10px;
+ padding: 0.25em 0.5em;
text-align: left;
}
th {
background-color: #f2f2f2;
}
-tfoot td {
- font-weight: bold;
-}
-h2 {
- margin-top: 40px;
-}
+
article {
margin-bottom: 20px;
}
-footer {
- margin-top: 40px;
- font-size: 0.9em;
- text-align: center;
- color: #666;
-}
-.totals {
- float: right;
- width: 300px;
- margin-top: -15px;
-}
-.totals table {
- border: none;
- margin: 0;
-}
-.totals td {
- border: none;
- 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;
-
- .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;
- }
- }
-
- .payment-details {
- font-size: 0.9rem;
- line-height: 1.4;
+.issue-title {
+ display: flex;
+ justify-content: space-between;
+ align-items: end;
+
+ margin-top: 2em;
+ margin-bottom: 0.5em;
+
+ h3,
+ p {
+ margin: 0;
}
}
diff --git a/report/assets/template.html b/report/assets/template.html
index 2e8a1d1..07e0b67 100644
--- a/report/assets/template.html
+++ b/report/assets/template.html
@@ -8,9 +8,13 @@
+
-
+
{{ .Company.Name }}
@@ -23,12 +27,12 @@
- Rechnung: {{ .ID }}
+ Rechnung: {{ .Invoice.Reference }}
Datum: {{ .Date | date }}
-
+
Rechnung an:
{{ .Client.Name }}
@@ -38,90 +42,52 @@
-
+
- | FID |
+ FID |
Name |
- Zeitaufwand |
- Stundensatz |
- Preis CHF |
- Fertiggestellt |
+ Aufwand |
+ Preis |
{{ range .Issues }}
- | {{ .Index }} |
+ {{ .Shorthand }} |
{{ .Title }} |
{{ .Duration | duration }} |
- 16 CHF/h |
{{ .Duration | price }} CHF |
-
- {{ .Closed | time }} |
{{ end }}
-
-
-
-
+
- | Gesamtbetrag: |
- {{ .Total }} CHF |
-
-
-
-
-
-
-
-
-
- {{ .ChCross }}
-
+
Summe: |
-
-
Währung
-
Betrag
-
CHF
-
{{ .Total}}
-
-
-
-
-
Konto / Zahlbar an
-
- {{ .Company.IBAN }}
- {{ .Company.Contact }}
- {{ .Company.Address.Street}} {{.Company.Address.Number}}
- {{ .Company.Address.ZIPCode }} {{ .Company.Address.Place }}
-
-
-
-
-
Zahlbar durch
-
- {{ .Client.Contact }}
- {{ .Client.Address.Street}} {{.Client.Address.Number}}
- {{ .Client.Address.ZIPCode }} {{ .Client.Address.Place }}
-
-
-
+ {{ .Total | duration }} |
+ {{ .Total | price}} |
+
+
+
+ {{ .Invoice.HTML }}
- Details zu den Features
+ Details zu den Features
{{ range .Issues }}
- {{ .Index }}: {{ .Title }}
- {{ .Body | md | oh 3 }}
+
+
+ {{ .Shorthand }}: {{
+ .Title }}
+
+
Fertiggestellt: {{ .Closed | time }}
+
+ {{ .CleanBody | md | oh 3 }}
{{ end }}
diff --git a/report/invoice.go b/report/invoice.go
deleted file mode 100644
index ef15ae0..0000000
--- a/report/invoice.go
+++ /dev/null
@@ -1,25 +0,0 @@
-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/assets/ch-cross.svg b/report/invoice/assets/ch-cross.svg
similarity index 100%
rename from report/assets/ch-cross.svg
rename to report/invoice/assets/ch-cross.svg
diff --git a/report/invoice/assets/scissors.svg b/report/invoice/assets/scissors.svg
new file mode 100644
index 0000000..e250417
--- /dev/null
+++ b/report/invoice/assets/scissors.svg
@@ -0,0 +1,71 @@
+
+
+
+
diff --git a/report/invoice/assets/style.css b/report/invoice/assets/style.css
new file mode 100644
index 0000000..4a04cdb
--- /dev/null
+++ b/report/invoice/assets/style.css
@@ -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;
+ }
+ }
+}
diff --git a/report/invoice/assets/template.html b/report/invoice/assets/template.html
new file mode 100644
index 0000000..ddbdccc
--- /dev/null
+++ b/report/invoice/assets/template.html
@@ -0,0 +1,70 @@
+
+ {{ .Scissors }}
+
+
+
+
Empfangsschein
+
Konto / Zahlbar an
+
+ {{ .ReceiverIBAN }}
+ {{ .ReceiverName }}
+ {{ .ReceiverStreet}} {{.ReceiverNumber}}
+ {{ .ReceiverZIPCode }} {{ .ReceiverPlace }}
+
+
+
+
Referenz
+
{{ .Reference }}
+
+
+
Zahlbar durch
+
+ {{ .PayeeName }}
+ {{ .PayeeStreet}} {{.PayeeNumber}}
+ {{ .PayeeZIPCode }} {{ .PayeePlace }}
+
+
+
+
+ {{ .Scissors }}
+
+
Zahlteil
+
+
+
+
+ {{ .Cross }}
+
+
+
+
Währung
+
Betrag
+
CHF
+
{{ .Amount }}
+
+
+
+
+
Konto / Zahlbar an
+
+ {{ .ReceiverIBAN }}
+ {{ .ReceiverName }}
+ {{ .ReceiverStreet}} {{.ReceiverNumber}}
+ {{ .ReceiverZIPCode }} {{ .ReceiverPlace }}
+
+
+
+
Referenz
+
{{ .Reference }}
+
+
+
Zahlbar durch
+
+ {{ .PayeeName }}
+ {{ .PayeeStreet}} {{.PayeeNumber}}
+ {{ .PayeeZIPCode }} {{ .PayeePlace }}
+
+
+
+
+
diff --git a/report/invoice/html.go b/report/invoice/html.go
new file mode 100644
index 0000000..5e28dac
--- /dev/null
+++ b/report/invoice/html.go
@@ -0,0 +1,42 @@
+package invoice
+
+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
+}
diff --git a/invoice/invoice.go b/report/invoice/invoice.go
similarity index 77%
rename from invoice/invoice.go
rename to report/invoice/invoice.go
index 839c3c9..c1790ea 100644
--- a/invoice/invoice.go
+++ b/report/invoice/invoice.go
@@ -7,6 +7,7 @@ import (
"html/template"
"strings"
+ "git.schreifuchs.ch/lou-taylor/accounting/model"
"github.com/skip2/go-qrcode"
)
@@ -32,7 +33,32 @@ type Invoice struct {
Currency string `yaml:"currency" default:"CHF"`
}
-func (i Invoice) GetQR() (src string, err error) {
+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
@@ -47,7 +73,7 @@ func (i Invoice) GetQR() (src string, err error) {
return
}
b64 := base64.StdEncoding.EncodeToString(png)
- src = fmt.Sprintf(`data:image/png;base64,%s`, b64)
+ src = template.URL(fmt.Sprintf(`data:image/png;base64,%s`, b64))
return
}
diff --git a/report/invoice/reference.go b/report/invoice/reference.go
new file mode 100644
index 0000000..7d88399
--- /dev/null
+++ b/report/invoice/reference.go
@@ -0,0 +1,68 @@
+package invoice
+
+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
+}
diff --git a/report/resource.go b/report/resource.go
index 319ffb0..50a20b1 100644
--- a/report/resource.go
+++ b/report/resource.go
@@ -1,43 +1,31 @@
package report
import (
- "html/template"
"time"
"git.schreifuchs.ch/lou-taylor/accounting/issue"
- "github.com/google/uuid"
+ "git.schreifuchs.ch/lou-taylor/accounting/model"
+ "git.schreifuchs.ch/lou-taylor/accounting/report/invoice"
)
-func New(issues []issue.Issue, company, client Entity, rate float64) *Report {
- return &Report{
+type Report struct {
+ Date time.Time
+ Issues []issue.Issue
+ Invoice invoice.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,
- ID: uuid.NewString(),
}
-}
+ r.Invoice = invoice.New(r.applyRate(r.Total()), r.Company, &r.Client)
-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
+ return r
}
diff --git a/report/template.go b/report/template.go
index 2f69335..a7d0be7 100644
--- a/report/template.go
+++ b/report/template.go
@@ -6,7 +6,6 @@ import (
"fmt"
"html/template"
"math"
- "strings"
"time"
)
@@ -16,11 +15,9 @@ var htmlTemplate string
//go:embed assets/style.css
var style template.CSS
-//go:embed assets/ch-cross.svg
-var chCross template.HTML
-
-func (r Report) ChCross() template.HTML {
- return chCross
+type tmpler struct {
+ Report
+ Style template.CSS
}
func (r Report) ToHTML() string {
@@ -32,15 +29,13 @@ func (r Report) ToHTML() string {
"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)
+ err := tmpl.Execute(buf, tmpler{r, style})
+ if err != nil {
+ panic(err)
+ }
return buf.String()
}
@@ -62,12 +57,12 @@ func fmtDuration(d time.Duration) string {
return fmt.Sprintf("%.2f h", d.Hours())
}
-func (r Report) Total() string {
+func (r Report) Total() time.Duration {
dur := time.Duration(0)
for _, i := range r.Issues {
dur += i.Duration
}
- return r.applyRate(dur)
+ return dur
}