4 Commits

Author SHA1 Message Date
d5f94845e8 feat: better debug logs
All checks were successful
Go / build (pull_request) Successful in 1m11s
2025-12-03 20:45:48 +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 582 additions and 343 deletions

5
.gitignore vendored
View File

@@ -1,2 +1,5 @@
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
```
## 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
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
github.com/caarlos0/env/v10 v10.0.0
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/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/starwalkn/gotenberg-go-client/v8 v8.11.0
github.com/itzg/go-flagsfiller v1.16.0
)
require (
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/go-fed/httpsig v1.1.0 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/iancoleman/strcase v0.3.0 // 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/stretchr/objx v0.5.2 // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/sys v0.35.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/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/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/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/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
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/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.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)

View File

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

View File

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

View File

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

View File

@@ -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
}
@@ -20,20 +20,28 @@ func (s *Service) Generate(creditor model.Entity, deptor *model.Entity, rate flo
repo.Repo,
gitea.ListIssueOption{
ListOptions: gitea.ListOptions{Page: 0, PageSize: 99999},
Type: gitea.IssueTypeIssue,
Since: config.Since,
Before: config.Before,
State: config.IssueState,
},
)
if err != nil {
return nil, nil, err
return nil, r, err
}
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)
r = report.New(
issues,
@@ -41,6 +49,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
@@ -50,12 +62,14 @@ func (s *Service) Generate(creditor model.Entity, deptor *model.Entity, rate flo
return document, r, err
}
func filter[T any](slice []T, ok func(T) bool) []T {
out := make([]T, 0, len(slice))
func (s *Service) filterIssues(slice []*gitea.Issue, ok func(*gitea.Issue) bool) []*gitea.Issue {
out := make([]*gitea.Issue, 0, len(slice))
for _, item := range slice {
if ok(item) {
out = append(out, item)
for _, issue := range slice {
if ok(issue) {
out = append(out, issue)
} else {
s.log.Debug("filter out issue", "issueURL", issue.HTMLURL)
}
}

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

@@ -0,0 +1,162 @@
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",
}
deptor := 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, &deptor, 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 {
size: A4;
padding: 2cm;
}
@page:first {
padding: 0;
}
@media print {
.page,
.page-break {
break-after: page;
}
.markdown h1 {
font-size: 2.25em; /* relative to base 12pt */
font-weight: 700; /* font-bold */
margin-top: 1.5em;
margin-bottom: 1em;
}
.first-page {
margin-left: 2cm;
margin-right: 2cm;
.markdown h2 {
font-size: 1.875em;
font-weight: 600;
margin-top: 1.25em;
margin-bottom: 0.75em;
}
body {
font-family: sans-serif;
font-size: 12pt;
color: #333;
}
section {
margin-bottom: 3em;
}
p {
margin-top: 0.25em;
.markdown h3 {
font-size: 1.5em;
font-weight: 600;
margin-top: 1em;
margin-bottom: 0.5em;
}
header {
display: flex;
justify-content: space-between;
margin-bottom: 40px;
h2 {
margin-top: 0;
}
}
h1 {
margin: 0;
font-size: 2em;
}
h2 {
margin-top: 0.5em;
.markdown h4 {
font-size: 1.25em;
font-weight: 500;
margin-top: 0.75em;
margin-bottom: 0.5em;
}
table {
.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;
}
.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%;
border-collapse: collapse;
margin-bottom: 20px;
}
table,
th,
td {
border: 1px solid #ccc;
}
th,
td {
padding: 0.25em 0.5em;
text-align: left;
}
th {
background-color: #f2f2f2;
margin: 1em 0;
font-size: 0.875em; /* relative to base 12pt */
}
article {
margin-bottom: 20px;
.markdown th,
.markdown td {
border: 1px solid #d1d5db;
padding: 0.5em 0.25em;
}
.issue-title {
display: flex;
justify-content: space-between;
align-items: end;
margin-top: 2em;
margin-bottom: 0.5em;
h3,
p {
margin: 0;
}
.markdown th {
background-color: #f3f4f6;
font-weight: 600;
}
/* Nested lists */
.markdown ul ul {
list-style-type: circle;
padding-left: 1.5em;
margin-top: 0.25em;
}
.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" />
<title>Rechnung vom {{ .Date | date }}</title>
<!-- <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>
{{.Invoice.CSS}}
</style>
</head>
<body>
<header class="first-page" style="margin-top: 2cm">
<body class="font-sans text-gray-800 text-[12pt]">
<!-- Header -->
<header class="mx-[2cm] flex justify-between mb-10 mt-8">
<div class="company">
<h2>{{ .Company.Name }}</h2>
<p>
{{ .Company.Address.Street}} {{.Company.Address.Number}} <br />
<h2 class="text-2xl font-semibold">{{ .Company.Name }}</h2>
<p class="mt-2">
{{ .Company.Address.Street }} {{ .Company.Address.Number }} <br />
{{ .Company.Address.ZIPCode }} {{ .Company.Address.Place }} <br />
{{ .Company.Contact }}
</p>
<p></p>
</div>
<div class="invoice-info">
<div class="invoice-info text-right">
<p>
<strong>Rechnung:</strong> {{ .Invoice.Reference }} <br />
<strong>Datum:</strong> {{ .Date | date }} <br />
<span class="font-semibold">Rechnung:</span> {{ .Invoice.Reference }}
<br />
<span class="font-semibold">Datum:</span> {{ .Date | date }} <br />
</p>
</div>
</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>
{{ .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.Contact }}
</p>
</section>
<section class="page p1 first-page">
<article>
<table>
<thead>
<!-- Invoice Table -->
<section class="page mb-12">
<article class="mx-[2cm] overflow-x-auto">
<table class="w-full border border-gray-300 border-collapse mb-5">
<thead class="bg-gray-200">
<tr>
<th style="min-width: 3.5em">FID</th>
<th>Name</th>
<th>Aufwand</th>
<th style="min-width: 5.5em">Preis</th>
<th
class="border border-gray-300 px-2 py-0.5 min-w-[3.5em] text-left"
>
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>
</thead>
<tbody>
{{ range .Issues }}
<tr>
<td style="font-family: monospace">{{ .Shorthand }}</td>
<td>{{ .Title }}</td>
<td>{{ .Duration | duration }}</td>
<td>{{ .Duration | price }} CHF</td>
<td class="border border-gray-300 px-2 py-0.5 font-mono">
{{ .Shorthand }}
</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>
{{ end }}
</tbody>
<tfoot>
<tr>
<th colspan="2" style="text-align: right">Summe:</th>
<td>{{ .Total | duration }}</td>
<td>{{ .Total | price}}</td>
<th
colspan="2"
class="border border-gray-300 px-2 py-0.5 text-right"
>
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>
</tfoot>
</table>
</article>
{{ .Invoice.HTML }}
</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 }}
<article>
<div class="issue-title">
<h3>
<a href="{{ .HTMLURL }}" style="font-family: monospace"
<article class="mb-6">
<div class="flex justify-between items-end mb-2">
<h3 class="font-semibold text-xl pt-5">
<a
href="{{ .HTMLURL }}"
class="font-mono text-blue-600 hover:underline"
>{{ .Shorthand }}</a
>: {{ .Title }}
</h3>
<p>
{{ if .Closed }} Fertiggestellt: {{ .Closed | time }} {{else}} not
<p class="text-sm text-gray-600">
{{ if .Closed }} Fertiggestellt: {{ .Closed | time }} {{ else }} not
closed {{ end }}
</p>
</div>
{{ .CleanBody | md | oh 3 }}
<div class="text-sm markdown">{{ .CleanBody | md | oh 3 }}</div>
</article>
{{ end }}
</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">
<div class="separator-h">{{ .Scissors }}</div>
<div class="receiver">
<div class="payment-details">
<div>
<h2>Empfangsschein</h2>
<h4 style="margin-top: 0">Konto / Zahlbar an</h4>
<p>
{{ .ReceiverIBAN }} <br />
{{ .ReceiverName }} <br />
{{ .ReceiverStreet}} {{.ReceiverNumber}} <br />
{{ .ReceiverZIPCode }} {{ .ReceiverPlace }}
</p>
</div>
<div>
<h4>Referenz</h4>
<p>{{ .Reference }}</p>
</div>
<div>
<h4>Zahlbar durch</h4>
<p>
{{ .PayeeName }} <br />
{{ .PayeeStreet}} {{.PayeeNumber}} <br />
{{ .PayeeZIPCode }} {{ .PayeePlace }}
</p>
</div>
<article
class="absolute bottom-0 left-0 w-full grid [grid-template-columns:240px_1px_1fr] [grid-template-rows:1px_1fr]"
>
<!-- Horizontal Separator -->
<div
class="separator-h row-start-1 col-span-3 border-t border-black relative"
>
<div class="absolute left-10 top-[-52px] rotate-[-90deg]">
{{ .Scissors }}
</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 }}
<!-- Receiver Section -->
<div class="receiver row-start-2 col-start-1 text-[12pt] p-4">
<h2 class="m-0 mb-2 text-2xl font-bold">Empfangsschein</h2>
<div class="payment-details 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>
<!-- 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">
<h4>Währung</h4>
<h4>Betrag</h4>
<p>CHF</p>
<p>{{ .Amount }}</p>
<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>
<div class="payment-details">
<div>
<h4 style="margin-top: 0">Konto / Zahlbar an</h4>
<p>
{{ .ReceiverIBAN }} <br />
{{ .ReceiverName }} <br />
{{ .ReceiverStreet}} {{.ReceiverNumber}} <br />
{{ .ReceiverZIPCode }} {{ .ReceiverPlace }}
</p>
</div>
<div>
<h4>Referenz</h4>
<p>{{ .Reference }}</p>
</div>
<div>
<h4>Zahlbar durch</h4>
<p>
{{ .PayeeName }} <br />
{{ .PayeeStreet}} {{.PayeeNumber}} <br />
{{ .PayeeZIPCode }} {{ .PayeePlace }}
</p>
</div>
<!-- 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>
</article>

View File

@@ -16,19 +16,12 @@ 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))

View File

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

View File

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

View File

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