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
import (
"flag"
"log"
"os"
"github.com/itzg/go-flagsfiller"
)
type cmd struct {
cmd func(any)
cmd func([]string, any)
config any
}
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{
"config": {
config,
"request": {
request,
nil,
},
"create": {
create,
&createFlags{},
},
}

View File

@@ -1,5 +1,92 @@
package main
// TODO: This file contained broken and incomplete code that prevented the project from building.
// It has been temporarily disabled to allow tests and builds to pass.
// The original logic needs to be reviewed and rewritten.
import (
"encoding/json"
"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
import (
"flag"
"log"
"fmt"
"os"
"github.com/itzg/go-flagsfiller"
)
func main() {
@@ -13,25 +10,11 @@ func main() {
return
}
var cmd *cmd
for n, c := range commands {
if os.Args[1] == n {
cmd = &c
c.Run()
break
}
}
if cmd == nil {
return
}
if cmd.config != nil {
filler := flagsfiller.New()
err := filler.Fill(flag.CommandLine, cmd.config)
if err != nil {
log.Fatal(err)
}
}
cmd.Run()
fmt.Println("cmd not found")
}

View File

@@ -36,9 +36,10 @@ func (i invoiceRequest) GetRepos() (repos []invoice.Repo, err error) {
return repos, err
}
func config(_ any) {
if len(os.Args) < 3 {
func request(arguments []string, _ any) {
if len(arguments) < 1 {
fmt.Println("please specify output file")
return
}
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.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 {
fmt.Printf("can't open file: %s %v", os.Args[2], err)
fmt.Printf("can't open file: %s %v", arguments[0], err)
return
}
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
}