4 Commits

Author SHA1 Message Date
774d5acbd2 feat: better debug logs
All checks were successful
Go / build (pull_request) Successful in 14s
2025-12-03 21:26:54 +01:00
7913e6b900 feat: tests 2025-11-09 16:39:29 +01:00
e57185b057 feat: custom template 2025-11-09 13:04:23 +01:00
8adff6ade2 feat: use tailwind css 2025-11-06 21:36:57 +01:00
19 changed files with 584 additions and 343 deletions

3
.gitignore vendored
View File

@@ -1,2 +1,5 @@
config.json config.json
lou-taylor.json lou-taylor.json
index.html
invoice.pdf

View File

@@ -25,6 +25,22 @@ Alternatively, you can use Docker for a containerized environment:
docker-compose up --build docker-compose up --build
``` ```
## Writing Tests
All tests are written using the go standard library and are table driven.
In this application no libraries like testify are used. Mocks are written in
the test file using this schema:
```go
type Mock struct {
Foo func (string) string
}
func (m Mock) Foo(in string) string {
return m.Foo(in)
}
```
## Running Tests ## Running Tests
To run all tests for the project, use the following command: To run all tests for the project, use the following command:

6
go.mod
View File

@@ -6,22 +6,26 @@ require (
code.gitea.io/sdk/gitea v0.21.0 code.gitea.io/sdk/gitea v0.21.0
github.com/caarlos0/env/v10 v10.0.0 github.com/caarlos0/env/v10 v10.0.0
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a
github.com/itzg/go-flagsfiller v1.16.0
github.com/jedib0t/go-pretty/v6 v6.6.8 github.com/jedib0t/go-pretty/v6 v6.6.8
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/starwalkn/gotenberg-go-client/v8 v8.11.0 github.com/starwalkn/gotenberg-go-client/v8 v8.11.0
github.com/itzg/go-flagsfiller v1.16.0
) )
require ( require (
github.com/42wim/httpsig v1.2.3 // indirect github.com/42wim/httpsig v1.2.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/go-version v1.7.0 // indirect
github.com/iancoleman/strcase v0.3.0 // indirect github.com/iancoleman/strcase v0.3.0 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/stretchr/objx v0.5.2 // indirect
golang.org/x/crypto v0.41.0 // indirect golang.org/x/crypto v0.41.0 // indirect
golang.org/x/sys v0.35.0 // indirect golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect golang.org/x/text v0.28.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

6
go.sum
View File

@@ -31,8 +31,12 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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 h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/starwalkn/gotenberg-go-client/v8 v8.11.0 h1:9hKCcoWegZ0yrZ0tEl5gKlMyH2YraYO0R7zDyL0ZL/U= 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/starwalkn/gotenberg-go-client/v8 v8.11.0/go.mod h1:5q9nAJ3/lub4hSCT6QYl8cOjnfQ/B+spEzC4TAPVXd8=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -55,5 +59,7 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 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= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -31,6 +31,7 @@ func (s Service) createInvoice(w http.ResponseWriter, r *http.Request) {
opts := invoice.DefaultOptions opts := invoice.DefaultOptions
opts.Mindur = time.Duration(req.DurationThreshold) opts.Mindur = time.Duration(req.DurationThreshold)
opts.CustomTemplate = req.CustomTemplate
invoice, report, err := s.invoice.Generate(req.Creditor, &req.Debtor, req.HourlyRate, repos, &opts) invoice, report, err := s.invoice.Generate(req.Creditor, &req.Debtor, req.HourlyRate, repos, &opts)
if err != nil { if err != nil {
s.sendErr(w, http.StatusInternalServerError, "internal server error") s.sendErr(w, http.StatusInternalServerError, "internal server error")
@@ -71,6 +72,7 @@ func (s Service) sendInvoice(w http.ResponseWriter, r *http.Request) {
opts := invoice.DefaultOptions opts := invoice.DefaultOptions
opts.Mindur = time.Duration(req.Invoice.DurationThreshold) opts.Mindur = time.Duration(req.Invoice.DurationThreshold)
opts.CustomTemplate = req.Invoice.CustomTemplate
invoice, report, err := s.invoice.Generate(req.Invoice.Creditor, &req.Invoice.Debtor, req.Invoice.HourlyRate, repos, &opts) invoice, report, err := s.invoice.Generate(req.Invoice.Creditor, &req.Invoice.Debtor, req.Invoice.HourlyRate, repos, &opts)
if err != nil { if err != nil {
s.sendErr(w, http.StatusInternalServerError, "error while processing invoice:", err) s.sendErr(w, http.StatusInternalServerError, "error while processing invoice:", err)

View File

@@ -65,14 +65,14 @@ func (s SendReq) ToEMail() email.Mail {
// MockInvoiceService mocks the invoice.Service interface // MockInvoiceService mocks the invoice.Service interface
type MockInvoiceService struct { type MockInvoiceService struct {
GenerateFunc func(creditor model.Entity, debtor *model.Entity, hourlyRate float64, repos []invoice.Repo, opts *invoice.Options) (io.ReadCloser, *report.Report, error) GenerateFunc func(creditor model.Entity, debtor *model.Entity, hourlyRate float64, repos []invoice.Repo, opts *invoice.Options) (io.ReadCloser, report.Report, error)
} }
func (m *MockInvoiceService) Generate(creditor model.Entity, debtor *model.Entity, hourlyRate float64, repos []invoice.Repo, opts *invoice.Options) (io.ReadCloser, *report.Report, error) { func (m *MockInvoiceService) Generate(creditor model.Entity, debtor *model.Entity, hourlyRate float64, repos []invoice.Repo, opts *invoice.Options) (io.ReadCloser, report.Report, error) {
if m.GenerateFunc != nil { if m.GenerateFunc != nil {
return m.GenerateFunc(creditor, debtor, hourlyRate, repos, opts) return m.GenerateFunc(creditor, debtor, hourlyRate, repos, opts)
} }
return nil, &report.Report{}, nil return nil, report.Report{}, nil
} }
// MockEmailService mocks the email.Service interface // MockEmailService mocks the email.Service interface
@@ -94,7 +94,7 @@ func TestCreateInvoice(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
requestBody InvoiceReq requestBody InvoiceReq
mockGenerate func(creditor model.Entity, debtor *model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error) mockGenerate func(creditor model.Entity, debtor *model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, report.Report, error)
expectedStatus int expectedStatus int
expectedBody string // For error messages or specific content expectedBody string // For error messages or specific content
}{ }{
@@ -107,7 +107,7 @@ func TestCreateInvoice(t *testing.T) {
HourlyRate: 100, HourlyRate: 100,
Repos: []string{"owner/repo1"}, Repos: []string{"owner/repo1"},
}, },
mockGenerate: func(creditor model.Entity, debtor *model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error) { mockGenerate: func(creditor model.Entity, debtor *model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, report.Report, error) {
pdfContent := "mock PDF content" pdfContent := "mock PDF content"
// Create a report with a positive total duration // Create a report with a positive total duration
mockReport := report.New([]issue.Issue{{Duration: time.Hour}}, model.Entity{}, &model.Entity{}, 0) mockReport := report.New([]issue.Issue{{Duration: time.Hour}}, model.Entity{}, &model.Entity{}, 0)
@@ -138,8 +138,8 @@ func TestCreateInvoice(t *testing.T) {
HourlyRate: 100, HourlyRate: 100,
Repos: []string{"owner/repo1"}, Repos: []string{"owner/repo1"},
}, },
mockGenerate: func(creditor model.Entity, debtor *model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error) { mockGenerate: func(creditor model.Entity, debtor *model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, report.Report, error) {
return nil, nil, errors.New("failed to generate invoice") return nil, report.Report{}, errors.New("failed to generate invoice")
}, },
expectedStatus: http.StatusInternalServerError, expectedStatus: http.StatusInternalServerError,
expectedBody: "internal server error", expectedBody: "internal server error",
@@ -153,7 +153,7 @@ func TestCreateInvoice(t *testing.T) {
HourlyRate: 100, HourlyRate: 100,
Repos: []string{"owner/repo1"}, Repos: []string{"owner/repo1"},
}, },
mockGenerate: func(creditor model.Entity, debtor *model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error) { mockGenerate: func(creditor model.Entity, debtor *model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, report.Report, error) {
// Create a report with zero total duration // Create a report with zero total duration
mockReport := report.New([]issue.Issue{}, model.Entity{}, &model.Entity{}, 0) mockReport := report.New([]issue.Issue{}, model.Entity{}, &model.Entity{}, 0)
return io.NopCloser(bytes.NewReader([]byte("mock PDF content"))), mockReport, nil return io.NopCloser(bytes.NewReader([]byte("mock PDF content"))), mockReport, nil
@@ -166,7 +166,7 @@ func TestCreateInvoice(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
mockInvoiceService := &MockInvoiceService{ mockInvoiceService := &MockInvoiceService{
GenerateFunc: func(creditor model.Entity, debtor *model.Entity, hourlyRate float64, repos []invoice.Repo, opts *invoice.Options) (io.ReadCloser, *report.Report, error) { GenerateFunc: func(creditor model.Entity, debtor *model.Entity, hourlyRate float64, repos []invoice.Repo, opts *invoice.Options) (io.ReadCloser, report.Report, error) {
if opts == nil { if opts == nil {
opts = &invoice.DefaultOptions opts = &invoice.DefaultOptions
} }
@@ -201,7 +201,7 @@ func TestSendInvoice(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
requestBody SendReq requestBody SendReq
mockGenerate func(creditor model.Entity, debtor *model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error) mockGenerate func(creditor model.Entity, debtor *model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, report.Report, error)
mockSend func(mail email.Mail) error mockSend func(mail email.Mail) error
expectedStatus int expectedStatus int
expectedBody string expectedBody string
@@ -220,7 +220,7 @@ func TestSendInvoice(t *testing.T) {
Repos: []string{"owner/repo1"}, Repos: []string{"owner/repo1"},
}, },
}, },
mockGenerate: func(creditor model.Entity, debtor *model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error) { mockGenerate: func(creditor model.Entity, debtor *model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, report.Report, error) {
pdfContent := "mock PDF content for send" pdfContent := "mock PDF content for send"
mockReport := report.New([]issue.Issue{{Duration: time.Hour}}, model.Entity{}, &model.Entity{}, 0) mockReport := report.New([]issue.Issue{{Duration: time.Hour}}, model.Entity{}, &model.Entity{}, 0)
return io.NopCloser(bytes.NewReader([]byte(pdfContent))), mockReport, nil return io.NopCloser(bytes.NewReader([]byte(pdfContent))), mockReport, nil
@@ -245,8 +245,8 @@ func TestSendInvoice(t *testing.T) {
Repos: []string{"owner/repo1"}, Repos: []string{"owner/repo1"},
}, },
}, },
mockGenerate: func(creditor model.Entity, debtor *model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error) { mockGenerate: func(creditor model.Entity, debtor *model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, report.Report, error) {
return nil, nil, errors.New("failed to generate invoice for send") return nil, report.Report{}, errors.New("failed to generate invoice for send")
}, },
mockSend: nil, // Not called mockSend: nil, // Not called
expectedStatus: http.StatusInternalServerError, expectedStatus: http.StatusInternalServerError,
@@ -266,7 +266,7 @@ func TestSendInvoice(t *testing.T) {
Repos: []string{"owner/repo1"}, Repos: []string{"owner/repo1"},
}, },
}, },
mockGenerate: func(creditor model.Entity, debtor *model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error) { mockGenerate: func(creditor model.Entity, debtor *model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, report.Report, error) {
pdfContent := "mock PDF content for send" pdfContent := "mock PDF content for send"
mockReport := report.New([]issue.Issue{{Duration: time.Hour}}, model.Entity{}, &model.Entity{}, 0) mockReport := report.New([]issue.Issue{{Duration: time.Hour}}, model.Entity{}, &model.Entity{}, 0)
return io.NopCloser(bytes.NewReader([]byte(pdfContent))), mockReport, nil return io.NopCloser(bytes.NewReader([]byte(pdfContent))), mockReport, nil
@@ -291,7 +291,7 @@ func TestSendInvoice(t *testing.T) {
Repos: []string{"owner/repo1"}, Repos: []string{"owner/repo1"},
}, },
}, },
mockGenerate: func(creditor model.Entity, debtor *model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error) { mockGenerate: func(creditor model.Entity, debtor *model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, report.Report, error) {
mockReport := report.New([]issue.Issue{}, model.Entity{}, &model.Entity{}, 0) mockReport := report.New([]issue.Issue{}, model.Entity{}, &model.Entity{}, 0)
return io.NopCloser(bytes.NewReader([]byte("mock PDF content"))), mockReport, nil return io.NopCloser(bytes.NewReader([]byte("mock PDF content"))), mockReport, nil
}, },
@@ -303,7 +303,7 @@ func TestSendInvoice(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
mockInvoiceService := &MockInvoiceService{ mockInvoiceService := &MockInvoiceService{
GenerateFunc: func(creditor model.Entity, debtor *model.Entity, hourlyRate float64, repos []invoice.Repo, opts *invoice.Options) (io.ReadCloser, *report.Report, error) { GenerateFunc: func(creditor model.Entity, debtor *model.Entity, hourlyRate float64, repos []invoice.Repo, opts *invoice.Options) (io.ReadCloser, report.Report, error) {
if opts == nil { if opts == nil {
opts = &invoice.DefaultOptions opts = &invoice.DefaultOptions
} }

View File

@@ -18,6 +18,7 @@ type invoiceReq struct {
DurationThreshold jtype.Duration `json:"durationThreshold"` DurationThreshold jtype.Duration `json:"durationThreshold"`
HourlyRate float64 `json:"hourlyRate"` HourlyRate float64 `json:"hourlyRate"`
Repos []string `json:"repositories"` Repos []string `json:"repositories"`
CustomTemplate string `json:"template,omitempty"`
} }
func (i invoiceReq) GetRepos() (repos []invoice.Repo, err error) { func (i invoiceReq) GetRepos() (repos []invoice.Repo, err error) {

View File

@@ -21,7 +21,7 @@ func New(log *slog.Logger, invoice invoicer, mail mailer) *Service {
} }
type invoicer interface { type invoicer interface {
Generate(creditor model.Entity, deptor *model.Entity, rate float64, repos []invoice.Repo, opts *invoice.Options) (document io.ReadCloser, report *report.Report, err error) Generate(creditor model.Entity, deptor *model.Entity, rate float64, repos []invoice.Repo, opts *invoice.Options) (document io.ReadCloser, report report.Report, err error)
} }
type mailer interface { type mailer interface {

View File

@@ -9,7 +9,7 @@ import (
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/report" "git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/report"
) )
func (s *Service) Generate(creditor model.Entity, deptor *model.Entity, rate float64, repos []Repo, config *Options) (document io.ReadCloser, r *report.Report, err error) { func (s *Service) Generate(creditor model.Entity, debtor *model.Entity, rate float64, repos []Repo, config *Options) (document io.ReadCloser, r report.Report, err error) {
if config == nil { if config == nil {
config = &DefaultOptions config = &DefaultOptions
} }
@@ -26,20 +26,33 @@ func (s *Service) Generate(creditor model.Entity, deptor *model.Entity, rate flo
}, },
) )
if err != nil { if err != nil {
return nil, nil, err return nil, r, err
} }
is = append(is, iss...) is = append(is, iss...)
} }
is = filter(is, config.IssueFilter) {
issueURLs := make([]string, 0, len(is))
for _, issue := range is {
issueURLs = append(issueURLs, issue.HTMLURL)
}
s.log.Debug("loaded all issues", "issueURLs", issueURLs)
}
is = s.filterIssues(is, config.IssueFilter)
issues := issue.FromGiteas(is, config.Mindur) issues := issue.FromGiteas(is, config.Mindur)
r = report.New( r = report.New(
issues, issues,
creditor, creditor,
deptor, debtor,
rate, rate,
) )
if len(config.CustomTemplate) > 1 {
r = r.WithTemplate(config.CustomTemplate)
}
html, err := r.ToHTML() html, err := r.ToHTML()
if err != nil { if err != nil {
return document, r, err return document, r, err
@@ -49,12 +62,14 @@ func (s *Service) Generate(creditor model.Entity, deptor *model.Entity, rate flo
return document, r, err return document, r, err
} }
func filter[T any](slice []T, ok func(T) bool) []T { func (s *Service) filterIssues(slice []*gitea.Issue, ok func(*gitea.Issue) bool) []*gitea.Issue {
out := make([]T, 0, len(slice)) out := make([]*gitea.Issue, 0, len(slice))
for _, item := range slice { for _, issue := range slice {
if ok(item) { if ok(issue) {
out = append(out, item) out = append(out, issue)
} else {
s.log.Debug("filter out issue", "issueURL", issue.HTMLURL)
} }
} }

163
pkg/invoice/invoice_test.go Normal file
View File

@@ -0,0 +1,163 @@
package invoice
import (
"bytes"
"errors"
"io"
"log/slog"
"strings"
"testing"
"time"
"code.gitea.io/sdk/gitea"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/model"
)
// MockGiteaClient is a mock implementation of the GiteaClient interface.
type MockGiteaClient struct {
ListRepoIssuesFunc func(owner, repo string, opt gitea.ListIssueOption) ([]*gitea.Issue, *gitea.Response, error)
}
func (m *MockGiteaClient) ListRepoIssues(owner, repo string, opt gitea.ListIssueOption) ([]*gitea.Issue, *gitea.Response, error) {
if m.ListRepoIssuesFunc != nil {
return m.ListRepoIssuesFunc(owner, repo, opt)
}
return nil, nil, errors.New("ListRepoIssuesFunc not implemented")
}
// MockPdfGenerator is a mock implementation of the PdfGenerator interface.
type MockPdfGenerator struct {
HtmlToPdfFunc func(html string) (io.ReadCloser, error)
}
func (m *MockPdfGenerator) HtmlToPdf(html string) (io.ReadCloser, error) {
if m.HtmlToPdfFunc != nil {
return m.HtmlToPdfFunc(html)
}
return nil, errors.New("HtmlToPdfFunc not implemented")
}
func TestGenerate(t *testing.T) {
creditor := model.Entity{
Name: "creditor",
}
debtor := model.Entity{
Name: "deptor",
}
rate := 100.0
repos := []Repo{
{Owner: "owner", Repo: "repo"},
}
testCases := []struct {
name string
setupMocks func(*MockGiteaClient, *MockPdfGenerator)
config *Options
expectedError string
}{
{
name: "successful generation",
setupMocks: func(g *MockGiteaClient, p *MockPdfGenerator) {
g.ListRepoIssuesFunc = func(owner, repo string, opt gitea.ListIssueOption) ([]*gitea.Issue, *gitea.Response, error) {
return []*gitea.Issue{
{ID: 1, Title: "Test Issue", Body: "```info\nduration: 1h\n```"},
}, &gitea.Response{}, nil
}
p.HtmlToPdfFunc = func(html string) (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader([]byte("pdf"))), nil
}
},
config: &DefaultOptions,
expectedError: "",
},
{
name: "gitea error",
setupMocks: func(g *MockGiteaClient, p *MockPdfGenerator) {
g.ListRepoIssuesFunc = func(owner, repo string, opt gitea.ListIssueOption) ([]*gitea.Issue, *gitea.Response, error) {
return nil, nil, errors.New("gitea error")
}
},
config: &DefaultOptions,
expectedError: "gitea error",
},
{
name: "pdf error",
setupMocks: func(g *MockGiteaClient, p *MockPdfGenerator) {
g.ListRepoIssuesFunc = func(owner, repo string, opt gitea.ListIssueOption) ([]*gitea.Issue, *gitea.Response, error) {
return []*gitea.Issue{
{ID: 1, Title: "Test Issue", Body: "```info\nduration: 1h\n```"},
}, &gitea.Response{}, nil
}
p.HtmlToPdfFunc = func(html string) (io.ReadCloser, error) {
return nil, errors.New("pdf error")
}
},
config: &DefaultOptions,
expectedError: "pdf error",
},
{
name: "no issues",
setupMocks: func(g *MockGiteaClient, p *MockPdfGenerator) {
g.ListRepoIssuesFunc = func(owner, repo string, opt gitea.ListIssueOption) ([]*gitea.Issue, *gitea.Response, error) {
return []*gitea.Issue{}, &gitea.Response{}, nil
}
p.HtmlToPdfFunc = func(html string) (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader([]byte("pdf"))), nil
}
},
config: &DefaultOptions,
expectedError: "",
},
{
name: "custom template",
setupMocks: func(g *MockGiteaClient, p *MockPdfGenerator) {
g.ListRepoIssuesFunc = func(owner, repo string, opt gitea.ListIssueOption) ([]*gitea.Issue, *gitea.Response, error) {
return []*gitea.Issue{
{ID: 1, Title: "Test Issue", Body: "```info\nduration: 1h\n```"},
}, &gitea.Response{}, nil
}
p.HtmlToPdfFunc = func(html string) (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader([]byte("pdf"))), nil
}
},
config: &Options{
Mindur: time.Minute * 15,
Since: time.Now().AddDate(0, -1, 0),
Before: time.Now(),
IssueState: gitea.StateClosed,
IssueFilter: func(i *gitea.Issue) bool { return true },
CustomTemplate: "custom template",
},
expectedError: "",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
giteaClient := new(MockGiteaClient)
pdfGenerator := new(MockPdfGenerator)
tc.setupMocks(giteaClient, pdfGenerator)
service := New(slog.Default(), giteaClient, pdfGenerator)
doc, _, err := service.Generate(creditor, &debtor, rate, repos, tc.config)
if tc.expectedError != "" {
if err == nil {
t.Fatalf("expected an error, but got none")
}
if !strings.Contains(err.Error(), tc.expectedError) {
t.Errorf("expected error to contain '%s', but got '%s'", tc.expectedError, err.Error())
}
} else {
if err != nil {
t.Fatalf("expected no error, but got: %v", err)
}
if doc == nil {
t.Fatal("expected a document, but got nil")
}
}
})
}
}

View File

@@ -1,86 +1,141 @@
@page { .markdown h1 {
size: A4; font-size: 2.25em; /* relative to base 12pt */
padding: 2cm; font-weight: 700; /* font-bold */
} margin-top: 1.5em;
@page:first { margin-bottom: 1em;
padding: 0;
}
@media print {
.page,
.page-break {
break-after: page;
}
} }
.first-page { .markdown h2 {
margin-left: 2cm; font-size: 1.875em;
margin-right: 2cm; font-weight: 600;
margin-top: 1.25em;
margin-bottom: 0.75em;
} }
body { .markdown h3 {
font-family: sans-serif; font-size: 1.5em;
font-size: 12pt; font-weight: 600;
color: #333; margin-top: 1em;
}
section {
margin-bottom: 3em;
}
p {
margin-top: 0.25em;
margin-bottom: 0.5em; margin-bottom: 0.5em;
} }
header { .markdown h4 {
display: flex; font-size: 1.25em;
justify-content: space-between; font-weight: 500;
margin-bottom: 40px; margin-top: 0.75em;
margin-bottom: 0.5em;
}
h2 { .markdown p {
margin-bottom: 1em;
}
.markdown a {
color: #2563eb;
text-decoration: none;
}
.markdown a:hover {
text-decoration: underline;
}
.markdown strong {
font-weight: 600;
color: #111827;
}
.markdown em {
font-style: italic;
}
.markdown code {
background-color: #f3f4f6;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
font-size: 0.875em; /* relative to base 12pt */
padding: 0.125em 0.25em;
border-radius: 0.25em;
}
.markdown pre {
background-color: #111827;
color: #f3f4f6;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
font-size: 0.875em;
padding: 1em;
border-radius: 0.5em;
overflow-x: auto;
margin-bottom: 1em;
}
.markdown blockquote {
border-left: 4px solid #d1d5db;
padding-left: 1em;
font-style: italic;
color: #4b5563;
margin-bottom: 1em;
}
.markdown ul {
list-style-type: disc;
padding-left: 1.5em;
margin-bottom: 1em;
margin-top: 0; margin-top: 0;
}
}
h1 {
margin: 0;
font-size: 2em;
}
h2 {
margin-top: 0.5em;
} }
table { .markdown ol {
list-style-type: decimal;
padding-left: 1.5em;
margin-bottom: 1em;
margin-top: 0;
}
.markdown li {
margin-bottom: 0.25em;
line-height: 1.625;
}
.markdown hr {
border: none;
border-top: 1px solid #d1d5db;
margin: 1.5em 0;
}
.markdown img {
max-width: 100%;
border-radius: 0.5em;
margin: 1em 0;
}
.markdown table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin-bottom: 20px; margin: 1em 0;
} font-size: 0.875em; /* relative to base 12pt */
table,
th,
td {
border: 1px solid #ccc;
}
th,
td {
padding: 0.25em 0.5em;
text-align: left;
}
th {
background-color: #f2f2f2;
} }
article { .markdown th,
margin-bottom: 20px; .markdown td {
border: 1px solid #d1d5db;
padding: 0.5em 0.25em;
} }
.issue-title { .markdown th {
display: flex; background-color: #f3f4f6;
justify-content: space-between; font-weight: 600;
align-items: end; }
margin-top: 2em; /* Nested lists */
margin-bottom: 0.5em; .markdown ul ul {
list-style-type: circle;
h3, padding-left: 1.5em;
p { margin-top: 0.25em;
margin: 0; }
}
.markdown ol ol {
list-style-type: lower-alpha;
padding-left: 1.5em;
margin-top: 0.25em;
} }

File diff suppressed because one or more lines are too long

View File

@@ -5,93 +5,138 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Rechnung vom {{ .Date | date }}</title> <title>Rechnung vom {{ .Date | date }}</title>
<!-- <link href="css/style.css" rel="stylesheet" /> --> <!-- <link href="css/style.css" rel="stylesheet" /> -->
<script>
{{ .Tailwind }}
</script>
<style>
@page {
size: A4;
padding: 2cm;
}
@page:first {
padding: 0;
}
@media print {
.page,
.page-break {
break-after: page;
}
}
</style>
<style> <style>
{{ .Style }} {{ .Style }}
</style> </style>
<style>
{{.Invoice.CSS}}
</style>
</head> </head>
<body> <body class="font-sans text-gray-800 text-[12pt]">
<header class="first-page" style="margin-top: 2cm"> <!-- Header -->
<header class="mx-[2cm] flex justify-between mb-10 mt-8">
<div class="company"> <div class="company">
<h2>{{ .Company.Name }}</h2> <h2 class="text-2xl font-semibold">{{ .Company.Name }}</h2>
<p> <p class="mt-2">
{{ .Company.Address.Street}} {{.Company.Address.Number}} <br /> {{ .Company.Address.Street }} {{ .Company.Address.Number }} <br />
{{ .Company.Address.ZIPCode }} {{ .Company.Address.Place }} <br /> {{ .Company.Address.ZIPCode }} {{ .Company.Address.Place }} <br />
{{ .Company.Contact }} {{ .Company.Contact }}
</p> </p>
<p></p>
</div> </div>
<div class="invoice-info"> <div class="invoice-info text-right">
<p> <p>
<strong>Rechnung:</strong> {{ .Invoice.Reference }} <br /> <span class="font-semibold">Rechnung:</span> {{ .Invoice.Reference }}
<strong>Datum:</strong> {{ .Date | date }} <br /> <br />
<span class="font-semibold">Datum:</span> {{ .Date | date }} <br />
</p> </p>
</div> </div>
</header> </header>
<section class="client first-page">
<h2>Rechnung an:</h2> <!-- Client Info -->
<section class="client mx-[2cm] mb-12">
<h2 class="text-xl font-semibold mb-2">Rechnung an:</h2>
<p> <p>
{{ .Client.Name }} <br /> {{ .Client.Name }} <br />
{{ .Client.Address.Street}} {{.Client.Address.Number}} <br /> {{ .Client.Address.Street }} {{ .Client.Address.Number }} <br />
{{ .Client.Address.ZIPCode }} {{ .Client.Address.Place }} <br /> {{ .Client.Address.ZIPCode }} {{ .Client.Address.Place }} <br />
{{ .Client.Contact }} {{ .Client.Contact }}
</p> </p>
</section> </section>
<section class="page p1 first-page"> <!-- Invoice Table -->
<article> <section class="page mb-12">
<table> <article class="mx-[2cm] overflow-x-auto">
<thead> <table class="w-full border border-gray-300 border-collapse mb-5">
<thead class="bg-gray-200">
<tr> <tr>
<th style="min-width: 3.5em">FID</th> <th
<th>Name</th> class="border border-gray-300 px-2 py-0.5 min-w-[3.5em] text-left"
<th>Aufwand</th> >
<th style="min-width: 5.5em">Preis</th> FID
</th>
<th class="border border-gray-300 px-2 py-0.5 text-left">Name</th>
<th class="border border-gray-300 px-2 py-0.5 text-left">
Aufwand
</th>
<th
class="border border-gray-300 px-2 py-0.5 min-w-[5.5em] text-left"
>
Preis
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{ range .Issues }} {{ range .Issues }}
<tr> <tr>
<td style="font-family: monospace">{{ .Shorthand }}</td> <td class="border border-gray-300 px-2 py-0.5 font-mono">
<td>{{ .Title }}</td> {{ .Shorthand }}
<td>{{ .Duration | duration }}</td> </td>
<td>{{ .Duration | price }} CHF</td> <td class="border border-gray-300 px-2 py-0.5">{{ .Title }}</td>
<td class="border border-gray-300 px-2 py-0.5">
{{ .Duration | duration }}
</td>
<td class="border border-gray-300 px-2 py-0.5">
{{ .Duration | price }} CHF
</td>
</tr> </tr>
{{ end }} {{ end }}
</tbody> </tbody>
<tfoot> <tfoot>
<tr> <tr>
<th colspan="2" style="text-align: right">Summe:</th> <th
colspan="2"
<td>{{ .Total | duration }}</td> class="border border-gray-300 px-2 py-0.5 text-right"
<td>{{ .Total | price}}</td> >
Summe:
</th>
<td class="border border-gray-300 px-2 py-0.5">
{{ .Total | duration }}
</td>
<td class="border border-gray-300 px-2 py-0.5">
{{ .Total | price }}
</td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
</article> </article>
{{ .Invoice.HTML }} {{ .Invoice.HTML }}
</section> </section>
<section>
<h2 style="margin-top: 0">Details zu den Features</h2>
<!-- Issue Details -->
<section>
<h2 class="font-semibold mb-4 text-2xl">Details zu den Features</h2>
{{ range .Issues }} {{ range .Issues }}
<article> <article class="mb-6">
<div class="issue-title"> <div class="flex justify-between items-end mb-2">
<h3> <h3 class="font-semibold text-xl pt-5">
<a href="{{ .HTMLURL }}" style="font-family: monospace" <a
href="{{ .HTMLURL }}"
class="font-mono text-blue-600 hover:underline"
>{{ .Shorthand }}</a >{{ .Shorthand }}</a
>: {{ .Title }} >: {{ .Title }}
</h3> </h3>
<p> <p class="text-sm text-gray-600">
{{ if .Closed }} Fertiggestellt: {{ .Closed | time }} {{else}} not {{ if .Closed }} Fertiggestellt: {{ .Closed | time }} {{ else }} not
closed {{ end }} closed {{ end }}
</p> </p>
</div> </div>
{{ .CleanBody | md | oh 3 }} <div class="text-sm markdown">{{ .CleanBody | md | oh 3 }}</div>
</article> </article>
{{ end }} {{ end }}
</section> </section>

View File

@@ -1,111 +0,0 @@
.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;
}
}
}

View File

@@ -1,70 +1,96 @@
<article class="c6ee15365-8f47-4dab-8bee-2a56a7916c57"> <article
<div class="separator-h">{{ .Scissors }}</div> class="absolute bottom-0 left-0 w-full grid [grid-template-columns:240px_1px_1fr] [grid-template-rows:1px_1fr]"
<div class="receiver"> >
<div class="payment-details"> <!-- Horizontal Separator -->
<div> <div
<h2>Empfangsschein</h2> class="separator-h row-start-1 col-span-3 border-t border-black relative"
<h4 style="margin-top: 0">Konto / Zahlbar an</h4> >
<p> <div class="absolute left-10 top-[-52px] rotate-[-90deg]">
{{ .ReceiverIBAN }} <br /> {{ .Scissors }}
{{ .ReceiverName }} <br />
{{ .ReceiverStreet}} {{.ReceiverNumber}} <br />
{{ .ReceiverZIPCode }} {{ .ReceiverPlace }}
</p>
</div> </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>
<div class="qr-section-info"> <!-- Receiver Section -->
<h4>Währung</h4> <div class="receiver row-start-2 col-start-1 text-[12pt] p-4">
<h4>Betrag</h4> <h2 class="m-0 mb-2 text-2xl font-bold">Empfangsschein</h2>
<p>CHF</p> <div class="payment-details text-[0.9rem] leading-[1.4]">
<p>{{ .Amount }}</p> <h4 class="mt-0 mb-[0.1em] font-bold">Konto / Zahlbar an</h4>
</div>
</div>
<div class="payment-details">
<div>
<h4 style="margin-top: 0">Konto / Zahlbar an</h4>
<p> <p>
{{ .ReceiverIBAN }} <br /> {{ .ReceiverIBAN }} <br />
{{ .ReceiverName }} <br /> {{ .ReceiverName }} <br />
{{ .ReceiverStreet}} {{.ReceiverNumber}} <br /> {{ .ReceiverStreet }} {{ .ReceiverNumber }} <br />
{{ .ReceiverZIPCode }} {{ .ReceiverPlace }} {{ .ReceiverZIPCode }} {{ .ReceiverPlace }}
</p> </p>
</div>
<div> <h4 class="mt-2 mb-[0.1em] font-bold">Referenz</h4>
<h4>Referenz</h4>
<p>{{ .Reference }}</p> <p>{{ .Reference }}</p>
</div>
<div> <h4 class="mt-2 mb-[0.1em] font-bold">Zahlbar durch</h4>
<h4>Zahlbar durch</h4>
<p> <p>
{{ .PayeeName }} <br /> {{ .PayeeName }} <br />
{{ .PayeeStreet}} {{.PayeeNumber}} <br /> {{ .PayeeStreet }} {{ .PayeeNumber }} <br />
{{ .PayeeZIPCode }} {{ .PayeePlace }} {{ .PayeeZIPCode }} {{ .PayeePlace }}
</p> </p>
</div> </div>
</div> </div>
<!-- Vertical Separator -->
<div
class="separator-v row-start-2 col-start-2 border-r border-black relative"
>
<div class="-ml-8">{{ .Scissors }}</div>
</div>
<!-- Payer Section -->
<div
class="payer row-start-2 col-start-3 grid [grid-template-columns:250px_1fr] [grid-template-rows:2em_1fr] p-4 gap-4 w-full"
>
<h2 class="row-start-1 col-span-1 m-0 text-2xl font-bold">Zahlteil</h2>
<!-- QR Code Section -->
<div class="qr-code row-start-2 col-start-1 flex flex-col justify-between">
<div class="qr-section-img relative w-full">
<img src="{{ .GetQR }}" class="w-full" />
<div
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[1] scale-36"
>
<!-- cross gets replaced with svg-->
{{ .Cross }}
</div>
</div>
<div
class="qr-section-info grid grid-cols-2 grid-rows-2 justify-items-center"
>
<h4 class="m-0 font-bold">Währung</h4>
<h4 class="m-0 font-bold">Betrag</h4>
<p class="m-0">CHF</p>
<p class="m-0">{{ .Amount }}</p>
</div>
</div>
<!-- Payment Details -->
<div
class="payment-details row-start-2 col-start-2 text-[0.9rem] leading-[1.4]"
>
<h4 class="mt-0 mb-[0.1em] font-bold">Konto / Zahlbar an</h4>
<p>
{{ .ReceiverIBAN }} <br />
{{ .ReceiverName }} <br />
{{ .ReceiverStreet }} {{ .ReceiverNumber }} <br />
{{ .ReceiverZIPCode }} {{ .ReceiverPlace }}
</p>
<h4 class="mt-2 mb-[0.1em] font-bold">Referenz</h4>
<p>{{ .Reference }}</p>
<h4 class="mt-2 mb-[0.1em] font-bold">Zahlbar durch</h4>
<p>
{{ .PayeeName }} <br />
{{ .PayeeStreet }} {{ .PayeeNumber }} <br />
{{ .PayeeZIPCode }} {{ .PayeePlace }}
</p>
</div>
</div> </div>
</article> </article>

View File

@@ -16,19 +16,12 @@ var scissors template.HTML
//go:embed assets/template.html //go:embed assets/template.html
var htmlTemplate string var htmlTemplate string
//go:embed assets/style.css
var style template.CSS
type tmpler struct { type tmpler struct {
Invoice Invoice
Cross template.HTML Cross template.HTML
Scissors template.HTML Scissors template.HTML
} }
func (i Invoice) CSS() template.CSS {
return style
}
func (i Invoice) HTML() (html template.HTML, err error) { func (i Invoice) HTML() (html template.HTML, err error) {
tmpl := template.Must(template.New("invoice").Parse(htmlTemplate)) tmpl := template.Must(template.New("invoice").Parse(htmlTemplate))

View File

@@ -3,11 +3,16 @@ package report
import ( import (
"time" "time"
_ "embed"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/issue" "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/model"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/report/qrbill" "git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/report/qrbill"
) )
//go:embed assets/template.html
var htmlTemplate string
type Report struct { type Report struct {
Date time.Time Date time.Time
Issues []issue.Issue Issues []issue.Issue
@@ -15,17 +20,24 @@ type Report struct {
Rate float64 Rate float64
Company model.Entity Company model.Entity
Client *model.Entity Client *model.Entity
template string
} }
func New(issues []issue.Issue, company model.Entity, client *model.Entity, rate float64) *Report { func New(issues []issue.Issue, company model.Entity, client *model.Entity, rate float64) Report {
r := &Report{ r := Report{
Date: time.Now(), Date: time.Now(),
Issues: issues, Issues: issues,
Rate: rate, Rate: rate,
Company: company, Company: company,
Client: client, Client: client,
template: htmlTemplate,
} }
r.Invoice = qrbill.New(r.applyRate(r.Total()), r.Company, r.Client) r.Invoice = qrbill.New(r.applyRate(r.Total()), r.Company, r.Client)
return r return r
} }
func (r Report) WithTemplate(template string) Report {
r.template = template
return r
}

View File

@@ -9,14 +9,15 @@ import (
"time" "time"
) )
//go:embed assets/template.html
var htmlTemplate string
//go:embed assets/style.css //go:embed assets/style.css
var style template.CSS var style template.CSS
//go:embed assets/tailwind.js
var tailwind template.JS
type tmpler struct { type tmpler struct {
Report Report
Tailwind template.JS
Style template.CSS Style template.CSS
} }
@@ -29,11 +30,11 @@ func (r Report) ToHTML() (html string, err error) {
"time": fmtDateTime, "time": fmtDateTime,
"date": fmtDate, "date": fmtDate,
"duration": fmtDuration, "duration": fmtDuration,
}).Parse(htmlTemplate)) }).Parse(r.template))
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
err = tmpl.Execute(buf, tmpler{r, style}) err = tmpl.Execute(buf, tmpler{r, tailwind, style})
if err != nil { if err != nil {
return html, err return html, err
} }

View File

@@ -16,6 +16,7 @@ var DefaultOptions = Options{
IssueFilter: func(i *gitea.Issue) bool { IssueFilter: func(i *gitea.Issue) bool {
return i.Closed != nil && i.Closed.After(time.Now().AddDate(0, -1, 0)) return i.Closed != nil && i.Closed.After(time.Now().AddDate(0, -1, 0))
}, },
CustomTemplate: "",
} }
type Options struct { type Options struct {
@@ -24,6 +25,7 @@ type Options struct {
Before time.Time Before time.Time
IssueState gitea.StateType IssueState gitea.StateType
IssueFilter func(i *gitea.Issue) bool IssueFilter func(i *gitea.Issue) bool
CustomTemplate string // if the length of the CustomTemplate is longer than 0, it get's used
} }
type Repo struct { type Repo struct {