feat: cli app

This commit is contained in:
2025-11-04 19:17:50 +01:00
parent 7d160d5f59
commit 8f5ae15ef0
14 changed files with 357 additions and 94 deletions

55
cmd/invoicer/README.md Normal file
View File

@@ -0,0 +1,55 @@
# Invoicer CLI
This command-line tool is used to generate invoices based on GitHub issues.
## Configuration
To use the Invoicer CLI, you first need to create a configuration file. This file contains all the necessary information to generate an invoice, such as creditor and debtor details, the repositories to scan for issues, and the hourly rate.
To generate the configuration file, run the following command:
```bash
go run . config <output_file_path>
```
This will start an interactive prompt that will guide you through creating the configuration file. The generated file will be saved to the specified output path.
### Configuration Options
The interactive prompt will ask for the following information:
- **Repositories**: A list of Gitea repositories to scan for issues (e.g., `owner/repo`).
- **Creditor Information**:
- Name (first & last name or company)
- Contact (if for a company: contact person)
- Address (Country, City, Postcode, Street, House Number)
- IBAN
- **Debtor Information (Optional)**:
- Name (first & last name or company)
- Contact (if for a company: contact person)
- Address (Country, City, Postcode, Street, House Number)
- Email Address
- **Duration Threshold**: The minimum duration for an issue to be billed.
- **Hourly Rate**: The price per hour in your currency.
## Creating an Invoice
You are able to generate invoices using the configuration file created with the `config` command.
To create an invoice, run the following command:
```bash
go run . create <path_to_config_file>
```
This will generate a PDF invoice based on the information in the configuration file. The invoice will be saved to the current directory.
### Sending an Invoice by Email
To send the invoice by email, you can use the `--email` flag:
```bash
go run . create <path_to_config_file> --email
```
This will send the invoice to the debtor's email address specified in the configuration file.

View File

@@ -1,17 +1,39 @@
package main package main
import (
"flag"
"log"
"os"
"github.com/itzg/go-flagsfiller"
)
type cmd struct { type cmd struct {
cmd func(any) cmd func([]string, any)
config any config any
} }
func (c *cmd) Run() { func (c *cmd) Run() {
c.cmd(c.config) if c.config != nil {
flag.Parse()
filler := flagsfiller.New()
err := filler.Fill(flag.CommandLine, c.config)
if err != nil {
log.Fatal(err)
}
c.cmd(flag.Args()[1:], c.config)
} else {
c.cmd(os.Args[2:], c.config)
}
} }
var commands = map[string]cmd{ var commands = map[string]cmd{
"config": { "request": {
config, request,
nil, nil,
}, },
"create": {
create,
&createFlags{},
},
} }

View File

@@ -1,5 +1,92 @@
package main package main
// TODO: This file contained broken and incomplete code that prevented the project from building. import (
// It has been temporarily disabled to allow tests and builds to pass. "encoding/json"
// The original logic needs to be reviewed and rewritten. "fmt"
"io"
"log"
"os"
"time"
"git.schreifuchs.ch/lou-taylor/accounting/internal/email"
)
type createFlags struct {
Email bool `flag:"email" help:"send invoice by email"`
Output string `flag:"o" help:"output file"`
}
func create(arguments []string, c any) {
flags, ok := c.(*createFlags)
if !ok {
panic("invalid config injected")
}
req := parseRequest(arguments)
cfg, log, invoicer, mailer := inject()
repos, err := req.GetRepos()
if err != nil {
fmt.Printf("could not get repos: %v", err)
return
}
invoice, report, err := invoicer.Generate(req.Creditor, req.Debtor, time.Duration(req.DurationThreshold), req.HourlyRate, repos)
if err != nil {
log.Error(fmt.Sprintf("Error while creating invoice: %v", err))
}
// if no time has to be billed aka if bill for 0 CHF
if report.Total() <= time.Duration(0) {
log.Info("no suitable issues to be billed")
return
}
if flags.Email {
mail := email.Mail{
To: []string{req.MailTo},
Subject: fmt.Sprintf("Invoice from %s", cfg.Email.From),
Attachments: []email.Attachment{{Name: "invoice.pdf", MimeType: "application/pdf", Content: invoice}},
}
err := mailer.Send(mail)
if err != nil {
log.Error(fmt.Sprintf("Error while sending mail: %v", err))
os.Exit(1)
}
return
}
if len(flags.Output) > 0 {
file, err := os.Create(flags.Output)
if err != nil {
log.Error(fmt.Sprintf("Error opening output file: %v", err))
os.Exit(1)
}
defer file.Close()
_, err = io.Copy(file, invoice)
if err != nil {
log.Error(fmt.Sprintf("Error while writing to output file: %v", err))
os.Exit(1)
}
return
}
io.Copy(os.Stdout, invoice)
}
func parseRequest(arguments []string) *invoiceRequest {
if len(arguments) < 1 {
fmt.Println("please specify request file")
os.Exit(1)
}
file, err := os.Open(arguments[0])
if err != nil {
log.Fatalf("can't open file: %s %v", arguments[0], err)
}
defer file.Close()
req := &invoiceRequest{}
json.NewDecoder(file).Decode(req)
return req
}

View File

@@ -1,11 +1,8 @@
package main package main
import ( import (
"flag" "fmt"
"log"
"os" "os"
"github.com/itzg/go-flagsfiller"
) )
func main() { func main() {
@@ -13,25 +10,11 @@ func main() {
return return
} }
var cmd *cmd
for n, c := range commands { for n, c := range commands {
if os.Args[1] == n { if os.Args[1] == n {
cmd = &c c.Run()
break break
} }
} }
if cmd == nil { fmt.Println("cmd not found")
return
}
if cmd.config != nil {
filler := flagsfiller.New()
err := filler.Fill(flag.CommandLine, cmd.config)
if err != nil {
log.Fatal(err)
}
}
cmd.Run()
} }

View File

@@ -36,9 +36,10 @@ func (i invoiceRequest) GetRepos() (repos []invoice.Repo, err error) {
return repos, err return repos, err
} }
func config(_ any) { func request(arguments []string, _ any) {
if len(os.Args) < 3 { if len(arguments) < 1 {
fmt.Println("please specify output file") fmt.Println("please specify output file")
return
} }
q := invoiceRequest{} q := invoiceRequest{}
@@ -67,9 +68,9 @@ func config(_ any) {
q.DurationThreshold = ask.Duration("Minimum duration for a issue to be billed", time.Duration(0), time.Duration(-1)) q.DurationThreshold = ask.Duration("Minimum duration for a issue to be billed", time.Duration(0), time.Duration(-1))
q.HourlyRate = ask.Float64("Price per hour in CHF", 0, -1) q.HourlyRate = ask.Float64("Price per hour in CHF", 0, -1)
file, err := os.Create(os.Args[2]) file, err := os.Create(arguments[0])
if err != nil { if err != nil {
fmt.Printf("can't open file: %s %v", os.Args[2], err) fmt.Printf("can't open file: %s %v", arguments[0], err)
return return
} }
defer file.Close() defer file.Close()

63
cmd/invoicer/services.go Normal file
View File

@@ -0,0 +1,63 @@
package main
import (
"log"
"log/slog"
"os"
"path"
"code.gitea.io/sdk/gitea"
"git.schreifuchs.ch/lou-taylor/accounting/internal/config"
"git.schreifuchs.ch/lou-taylor/accounting/internal/email"
"git.schreifuchs.ch/lou-taylor/accounting/internal/pdf"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice"
)
func getConfig() (cfg *config.Config) {
cfg, err := config.Load("config.json")
if err == nil {
return cfg
}
cfg, err = config.Load(path.Join(os.Getenv("HOME"), ".config/invoicer/config.json"))
if err == nil {
return cfg
}
cfg, err = config.Load("/etc/invoicer/config.json")
if err == nil {
return cfg
}
log.Fatal("no cli config found")
return cfg
}
func getServices() (invoicer *invoice.Service, mailer *email.Service) {
cfg := getConfig()
logr := slog.New(slog.NewTextHandler(os.Stdout, nil))
giteaC, err := gitea.NewClient(
cfg.Gitea.URL,
gitea.SetToken(cfg.Gitea.Token),
)
if err != nil {
log.Fatal("could not connect to gitea: %v", err)
return invoicer, mailer
}
gotenberg, err := pdf.New(cfg.PDF.Hostname)
if err != nil {
panic(err)
}
if err != nil {
log.Fatal("could not connect to gotenberg: %v", err)
return invoicer, mailer
}
mailer, err = email.New(cfg.Email)
if err != nil {
log.Fatal("could not create mailer: %v", err)
return invoicer, mailer
}
invoicer = invoice.New(logr, giteaC, gotenberg)
return invoicer, mailer
}

52
cmd/invoicer/setup.go Normal file
View File

@@ -0,0 +1,52 @@
package main
import (
"fmt"
"log/slog"
"os"
"path"
"code.gitea.io/sdk/gitea"
"git.schreifuchs.ch/lou-taylor/accounting/internal/config"
"git.schreifuchs.ch/lou-taylor/accounting/internal/email"
"git.schreifuchs.ch/lou-taylor/accounting/internal/pdf"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice"
)
func inject() (cfg *config.Config, log *slog.Logger, invoicer *invoice.Service, mailer *email.Service) {
log = slog.New(slog.NewTextHandler(os.Stdout, nil))
cfgDir, err := os.UserConfigDir()
if err != nil {
log.Error("Unable to load config dir")
os.Exit(1)
}
cfg, err = config.Load(path.Join(cfgDir, "invoicer/config.json"))
if err != nil {
log.Error(fmt.Sprintf("Unable to parse config: %v", err))
os.Exit(1)
}
giteaC, err := gitea.NewClient(
cfg.Gitea.URL,
gitea.SetToken(cfg.Gitea.Token),
)
if err != nil {
log.Error(fmt.Sprintf("Unable connect to gitea: %v", err))
os.Exit(2)
}
gotenberg, err := pdf.New(cfg.PDF.Hostname)
if err != nil {
log.Error(fmt.Sprintf("Unable connect to gotenberg: %v", err))
os.Exit(3)
}
mailer, err = email.New(cfg.Email)
if err != nil {
log.Error(fmt.Sprintf("Unable setup mailer: %v", err))
os.Exit(4)
}
invoicer = invoice.New(log, giteaC, gotenberg)
return cfg, log, invoicer, mailer
}

View File

@@ -28,7 +28,7 @@ func (s Service) createInvoice(w http.ResponseWriter, r *http.Request) {
return return
} }
invoice, report, err := s.invoice.Generate(req.Creditor, req.Debtor, time.Duration(req.DurationThreshold), req.HourlyRate, repos) invoice, report, err := s.invoice.Generate(req.Creditor, &req.Debtor, time.Duration(req.DurationThreshold), req.HourlyRate, repos)
if err != nil { if err != nil {
s.sendErr(w, http.StatusInternalServerError, "internal server error") s.sendErr(w, http.StatusInternalServerError, "internal server error")
return return
@@ -65,7 +65,7 @@ func (s Service) sendInvoice(w http.ResponseWriter, r *http.Request) {
s.sendErr(w, 500, err.Error()) s.sendErr(w, 500, err.Error())
return return
} }
invoice, report, err := s.invoice.Generate(req.Invoice.Creditor, req.Invoice.Debtor, time.Duration(req.Invoice.DurationThreshold), req.Invoice.HourlyRate, repos) invoice, report, err := s.invoice.Generate(req.Invoice.Creditor, &req.Invoice.Debtor, time.Duration(req.Invoice.DurationThreshold), req.Invoice.HourlyRate, repos)
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)
return return

View File

@@ -34,14 +34,14 @@ func (i InvoiceReq) GetRepos() (repos []invoice.Repo, err error) {
parts := strings.Split(repo, "/") parts := strings.Split(repo, "/")
if len(parts) != 2 { if len(parts) != 2 {
err = fmt.Errorf("cannot read body: repo with index %d does not split into 2 parts", i) err = fmt.Errorf("cannot read body: repo with index %d does not split into 2 parts", i)
return return repos, err
} }
repos = append(repos, invoice.Repo{ repos = append(repos, invoice.Repo{
Owner: parts[0], Owner: parts[0],
Repo: parts[1], Repo: parts[1],
}) })
} }
return return repos, err
} }
type SendReq struct { type SendReq struct {
@@ -65,10 +65,10 @@ 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, debtor model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error) GenerateFunc func(creditor model.Entity, debtor *model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error)
} }
func (m *MockInvoiceService) Generate(creditor, debtor model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error) { func (m *MockInvoiceService) Generate(creditor model.Entity, debtor *model.Entity, durationThreshold time.Duration, hourlyRate float64, repos []invoice.Repo) (io.ReadCloser, *report.Report, error) {
if m.GenerateFunc != nil { if m.GenerateFunc != nil {
return m.GenerateFunc(creditor, debtor, durationThreshold, hourlyRate, repos) return m.GenerateFunc(creditor, debtor, durationThreshold, hourlyRate, repos)
} }
@@ -94,7 +94,7 @@ func TestCreateInvoice(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
requestBody InvoiceReq requestBody InvoiceReq
mockGenerate func(creditor, 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,10 +107,10 @@ func TestCreateInvoice(t *testing.T) {
HourlyRate: 100, HourlyRate: 100,
Repos: []string{"owner/repo1"}, Repos: []string{"owner/repo1"},
}, },
mockGenerate: func(creditor, 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)
return io.NopCloser(bytes.NewReader([]byte(pdfContent))), mockReport, nil return io.NopCloser(bytes.NewReader([]byte(pdfContent))), mockReport, nil
}, },
expectedStatus: http.StatusOK, expectedStatus: http.StatusOK,
@@ -138,7 +138,7 @@ func TestCreateInvoice(t *testing.T) {
HourlyRate: 100, HourlyRate: 100,
Repos: []string{"owner/repo1"}, Repos: []string{"owner/repo1"},
}, },
mockGenerate: func(creditor, 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, nil, errors.New("failed to generate invoice")
}, },
expectedStatus: http.StatusInternalServerError, expectedStatus: http.StatusInternalServerError,
@@ -153,9 +153,9 @@ func TestCreateInvoice(t *testing.T) {
HourlyRate: 100, HourlyRate: 100,
Repos: []string{"owner/repo1"}, Repos: []string{"owner/repo1"},
}, },
mockGenerate: func(creditor, 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
}, },
expectedStatus: http.StatusNotFound, expectedStatus: http.StatusNotFound,
@@ -196,7 +196,7 @@ func TestSendInvoice(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
requestBody SendReq requestBody SendReq
mockGenerate func(creditor, 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
@@ -215,9 +215,9 @@ func TestSendInvoice(t *testing.T) {
Repos: []string{"owner/repo1"}, Repos: []string{"owner/repo1"},
}, },
}, },
mockGenerate: func(creditor, 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
}, },
mockSend: func(mail email.Mail) error { mockSend: func(mail email.Mail) error {
@@ -240,7 +240,7 @@ func TestSendInvoice(t *testing.T) {
Repos: []string{"owner/repo1"}, Repos: []string{"owner/repo1"},
}, },
}, },
mockGenerate: func(creditor, 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, nil, errors.New("failed to generate invoice for send")
}, },
mockSend: nil, // Not called mockSend: nil, // Not called
@@ -261,9 +261,9 @@ func TestSendInvoice(t *testing.T) {
Repos: []string{"owner/repo1"}, Repos: []string{"owner/repo1"},
}, },
}, },
mockGenerate: func(creditor, 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
}, },
mockSend: func(mail email.Mail) error { mockSend: func(mail email.Mail) error {
@@ -286,8 +286,8 @@ func TestSendInvoice(t *testing.T) {
Repos: []string{"owner/repo1"}, Repos: []string{"owner/repo1"},
}, },
}, },
mockGenerate: func(creditor, 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
}, },
expectedStatus: http.StatusNotFound, expectedStatus: http.StatusNotFound,

View File

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

BIN
invoice.pdf Normal file

Binary file not shown.

BIN
invoicer Executable file

Binary file not shown.

View File

@@ -10,7 +10,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, deptor model.Entity, mindur time.Duration, rate float64, repos []Repo) (document io.ReadCloser, r *report.Report, err error) { func (s *Service) Generate(creditor model.Entity, deptor *model.Entity, mindur time.Duration, rate float64, repos []Repo) (document io.ReadCloser, r *report.Report, err error) {
var is []*gitea.Issue var is []*gitea.Issue
for _, repo := range repos { for _, repo := range repos {
iss, _, err := s.gitea.ListRepoIssues( iss, _, err := s.gitea.ListRepoIssues(

View File

@@ -14,10 +14,10 @@ type Report struct {
Invoice qrbill.Invoice Invoice qrbill.Invoice
Rate float64 Rate float64
Company model.Entity Company model.Entity
Client model.Entity Client *model.Entity
} }
func New(issues []issue.Issue, company, 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,
@@ -25,7 +25,7 @@ func New(issues []issue.Issue, company, client model.Entity, rate float64) *Repo
Company: company, Company: company,
Client: client, Client: client,
} }
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
} }