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

-
+
- + - - - - + + {{ range .Issues }} - + - - - {{ end }} -
FIDFID NameZeitaufwandStundensatzPreis CHFFertiggestelltAufwandPreis
{{ .Index }}{{ .Shorthand }} {{ .Title }} {{ .Duration | duration }}16 CHF/h {{ .Duration | price }} CHF{{ .Closed | time }}
- -
- + - - - -
Gesamtbetrag:{{ .Total }} CHF
-
-
-
-
- -
- - {{ .ChCross }} -
+ Summe: - -
-
-
-

Konto / Zahlbar an

-

- {{ .Company.IBAN }}
- {{ .Company.Contact }}
- {{ .Company.Address.Street}} {{.Company.Address.Number}}
- {{ .Company.Address.ZIPCode }} {{ .Company.Address.Place }} -

-
-
-

Referenz

-

-
-
-

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 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + 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 }} +
+ + +
+
+
+

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 }