diff --git a/internal/api/httpinvoce/controller.go b/internal/api/httpinvoce/controller.go index 302f149..7d27d40 100644 --- a/internal/api/httpinvoce/controller.go +++ b/internal/api/httpinvoce/controller.go @@ -31,6 +31,7 @@ func (s Service) createInvoice(w http.ResponseWriter, r *http.Request) { opts := invoice.DefaultOptions opts.Mindur = time.Duration(req.DurationThreshold) + opts.CustomTemplate = req.CustomTemplate invoice, report, err := s.invoice.Generate(req.Creditor, &req.Debtor, req.HourlyRate, repos, &opts) if err != nil { 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.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) if err != nil { s.sendErr(w, http.StatusInternalServerError, "error while processing invoice:", err) diff --git a/internal/api/httpinvoce/controller_test.go b/internal/api/httpinvoce/controller_test.go index 7335ec7..a4a5f8a 100644 --- a/internal/api/httpinvoce/controller_test.go +++ b/internal/api/httpinvoce/controller_test.go @@ -65,14 +65,14 @@ func (s SendReq) ToEMail() email.Mail { // MockInvoiceService mocks the invoice.Service interface 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 { return m.GenerateFunc(creditor, debtor, hourlyRate, repos, opts) } - return nil, &report.Report{}, nil + return nil, report.Report{}, nil } // MockEmailService mocks the email.Service interface @@ -94,7 +94,7 @@ func TestCreateInvoice(t *testing.T) { tests := []struct { name string 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 expectedBody string // For error messages or specific content }{ @@ -107,7 +107,7 @@ func TestCreateInvoice(t *testing.T) { HourlyRate: 100, 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" // Create a report with a positive total duration mockReport := report.New([]issue.Issue{{Duration: time.Hour}}, model.Entity{}, &model.Entity{}, 0) @@ -138,8 +138,8 @@ func TestCreateInvoice(t *testing.T) { HourlyRate: 100, 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) { - return nil, nil, errors.New("failed to generate invoice") + mockGenerate: func(creditor model.Entity, debtor *model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, report.Report, error) { + return nil, report.Report{}, errors.New("failed to generate invoice") }, expectedStatus: http.StatusInternalServerError, expectedBody: "internal server error", @@ -153,7 +153,7 @@ func TestCreateInvoice(t *testing.T) { HourlyRate: 100, 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 mockReport := report.New([]issue.Issue{}, model.Entity{}, &model.Entity{}, 0) return io.NopCloser(bytes.NewReader([]byte("mock PDF content"))), mockReport, nil @@ -166,7 +166,7 @@ func TestCreateInvoice(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { 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 { opts = &invoice.DefaultOptions } @@ -201,7 +201,7 @@ func TestSendInvoice(t *testing.T) { tests := []struct { name string 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 expectedStatus int expectedBody string @@ -220,7 +220,7 @@ func TestSendInvoice(t *testing.T) { 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" mockReport := report.New([]issue.Issue{{Duration: time.Hour}}, model.Entity{}, &model.Entity{}, 0) return io.NopCloser(bytes.NewReader([]byte(pdfContent))), mockReport, nil @@ -245,8 +245,8 @@ func TestSendInvoice(t *testing.T) { 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) { - return nil, nil, errors.New("failed to generate invoice for send") + mockGenerate: func(creditor model.Entity, debtor *model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, report.Report, error) { + return nil, report.Report{}, errors.New("failed to generate invoice for send") }, mockSend: nil, // Not called expectedStatus: http.StatusInternalServerError, @@ -266,7 +266,7 @@ func TestSendInvoice(t *testing.T) { 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" mockReport := report.New([]issue.Issue{{Duration: time.Hour}}, model.Entity{}, &model.Entity{}, 0) return io.NopCloser(bytes.NewReader([]byte(pdfContent))), mockReport, nil @@ -291,7 +291,7 @@ func TestSendInvoice(t *testing.T) { 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) return io.NopCloser(bytes.NewReader([]byte("mock PDF content"))), mockReport, nil }, @@ -303,7 +303,7 @@ func TestSendInvoice(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { 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 { opts = &invoice.DefaultOptions } diff --git a/internal/api/httpinvoce/model.go b/internal/api/httpinvoce/model.go index 6312c21..bb29981 100644 --- a/internal/api/httpinvoce/model.go +++ b/internal/api/httpinvoce/model.go @@ -18,6 +18,7 @@ type invoiceReq struct { DurationThreshold jtype.Duration `json:"durationThreshold"` HourlyRate float64 `json:"hourlyRate"` Repos []string `json:"repositories"` + CustomTemplate string `json:"template,omitempty"` } func (i invoiceReq) GetRepos() (repos []invoice.Repo, err error) { diff --git a/internal/api/httpinvoce/resource.go b/internal/api/httpinvoce/resource.go index 0d8c69b..cf4b823 100644 --- a/internal/api/httpinvoce/resource.go +++ b/internal/api/httpinvoce/resource.go @@ -21,7 +21,7 @@ func New(log *slog.Logger, invoice invoicer, mail mailer) *Service { } 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 { diff --git a/pkg/invoice/invoice.go b/pkg/invoice/invoice.go index 65e34d3..e707da0 100644 --- a/pkg/invoice/invoice.go +++ b/pkg/invoice/invoice.go @@ -9,7 +9,7 @@ import ( "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, deptor *model.Entity, rate float64, repos []Repo, config *Options) (document io.ReadCloser, r report.Report, err error) { if config == nil { config = &DefaultOptions } @@ -26,7 +26,7 @@ func (s *Service) Generate(creditor model.Entity, deptor *model.Entity, rate flo }, ) if err != nil { - return nil, nil, err + return nil, r, err } is = append(is, iss...) @@ -40,6 +40,10 @@ func (s *Service) Generate(creditor model.Entity, deptor *model.Entity, rate flo deptor, rate, ) + + if len(config.CustomTemplate) > 1 { + r = r.WithTemplate(config.CustomTemplate) + } html, err := r.ToHTML() if err != nil { return document, r, err diff --git a/pkg/invoice/report/assets/style.css b/pkg/invoice/report/assets/style.css index 760fdc8..5d3aa36 100644 --- a/pkg/invoice/report/assets/style.css +++ b/pkg/invoice/report/assets/style.css @@ -1,6 +1,6 @@ .markdown h1 { - font-size: 2.25em; /* relative to base 12pt */ - font-weight: 700; /* font-bold */ + font-size: 2.25em; /* relative to base 12pt */ + font-weight: 700; /* font-bold */ margin-top: 1.5em; margin-bottom: 1em; } @@ -49,8 +49,10 @@ .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 */ + 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; } @@ -58,7 +60,9 @@ .markdown pre { background-color: #111827; color: #f3f4f6; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; font-size: 0.875em; padding: 1em; border-radius: 0.5em; @@ -135,5 +139,3 @@ padding-left: 1.5em; margin-top: 0.25em; } - - diff --git a/pkg/invoice/report/resource.go b/pkg/invoice/report/resource.go index c918351..4741cef 100644 --- a/pkg/invoice/report/resource.go +++ b/pkg/invoice/report/resource.go @@ -3,29 +3,41 @@ package report import ( "time" + _ "embed" + "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" ) +//go:embed assets/template.html +var htmlTemplate string + type Report struct { - Date time.Time - Issues []issue.Issue - Invoice qrbill.Invoice - Rate float64 - Company model.Entity - Client *model.Entity + Date time.Time + Issues []issue.Issue + Invoice qrbill.Invoice + Rate float64 + Company model.Entity + Client *model.Entity + template string } -func New(issues []issue.Issue, company model.Entity, client *model.Entity, rate float64) *Report { - r := &Report{ - Date: time.Now(), - Issues: issues, - Rate: rate, - Company: company, - Client: client, +func New(issues []issue.Issue, company model.Entity, client *model.Entity, rate float64) Report { + r := Report{ + Date: time.Now(), + Issues: issues, + Rate: rate, + Company: company, + Client: client, + template: htmlTemplate, } r.Invoice = qrbill.New(r.applyRate(r.Total()), r.Company, r.Client) return r } + +func (r Report) WithTemplate(template string) Report { + r.template = template + return r +} diff --git a/pkg/invoice/report/template.go b/pkg/invoice/report/template.go index 24d0407..679f50e 100644 --- a/pkg/invoice/report/template.go +++ b/pkg/invoice/report/template.go @@ -9,9 +9,6 @@ import ( "time" ) -//go:embed assets/template.html -var htmlTemplate string - //go:embed assets/style.css var style template.CSS @@ -33,7 +30,7 @@ func (r Report) ToHTML() (html string, err error) { "time": fmtDateTime, "date": fmtDate, "duration": fmtDuration, - }).Parse(htmlTemplate)) + }).Parse(r.template)) buf := new(bytes.Buffer) diff --git a/pkg/invoice/resource.go b/pkg/invoice/resource.go index 1073067..ba6de29 100644 --- a/pkg/invoice/resource.go +++ b/pkg/invoice/resource.go @@ -16,14 +16,16 @@ var DefaultOptions = Options{ IssueFilter: func(i *gitea.Issue) bool { return i.Closed != nil && i.Closed.After(time.Now().AddDate(0, -1, 0)) }, + CustomTemplate: "", } type Options struct { - Mindur time.Duration - Since time.Time - Before time.Time - IssueState gitea.StateType - IssueFilter func(i *gitea.Issue) bool + Mindur time.Duration + Since time.Time + Before time.Time + IssueState gitea.StateType + 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 {