feat: cli app
This commit is contained in:
55
cmd/invoicer/README.md
Normal file
55
cmd/invoicer/README.md
Normal 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.
|
||||
@@ -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{},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
63
cmd/invoicer/services.go
Normal 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
52
cmd/invoicer/setup.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user