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
|
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{},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ import (
|
|||||||
|
|
||||||
// Exported versions of the request structs for testing
|
// Exported versions of the request structs for testing
|
||||||
type InvoiceReq struct {
|
type InvoiceReq struct {
|
||||||
Debtor model.Entity `json:"debtor"`
|
Debtor model.Entity `json:"debtor"`
|
||||||
Creditor model.Entity `json:"creditor"`
|
Creditor model.Entity `json:"creditor"`
|
||||||
DurationThreshold string `json:"durationThreshold"` // Changed to string
|
DurationThreshold string `json:"durationThreshold"` // Changed to string
|
||||||
HourlyRate float64 `json:"hourlyRate"`
|
HourlyRate float64 `json:"hourlyRate"`
|
||||||
Repos []string `json:"repositories"`
|
Repos []string `json:"repositories"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i InvoiceReq) GetRepos() (repos []invoice.Repo, err error) {
|
func (i InvoiceReq) GetRepos() (repos []invoice.Repo, err error) {
|
||||||
@@ -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,23 +94,23 @@ 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
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "successful invoice creation",
|
name: "successful invoice creation",
|
||||||
requestBody: InvoiceReq{
|
requestBody: InvoiceReq{
|
||||||
Creditor: model.Entity{Name: "Creditor"},
|
Creditor: model.Entity{Name: "Creditor"},
|
||||||
Debtor: model.Entity{Name: "Debtor"},
|
Debtor: model.Entity{Name: "Debtor"},
|
||||||
DurationThreshold: "1h", // Changed to string
|
DurationThreshold: "1h", // Changed to string
|
||||||
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,
|
||||||
@@ -119,26 +119,26 @@ func TestCreateInvoice(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "invalid request body",
|
name: "invalid request body",
|
||||||
requestBody: InvoiceReq{ // Malformed request body
|
requestBody: InvoiceReq{ // Malformed request body
|
||||||
Creditor: model.Entity{Name: "Creditor"},
|
Creditor: model.Entity{Name: "Creditor"},
|
||||||
Debtor: model.Entity{Name: "Debtor"},
|
Debtor: model.Entity{Name: "Debtor"},
|
||||||
DurationThreshold: "invalid", // Changed to string
|
DurationThreshold: "invalid", // Changed to string
|
||||||
HourlyRate: 100,
|
HourlyRate: 100,
|
||||||
Repos: []string{"owner/repo1"},
|
Repos: []string{"owner/repo1"},
|
||||||
},
|
},
|
||||||
mockGenerate: nil, // Not called for invalid body
|
mockGenerate: nil, // Not called for invalid body
|
||||||
expectedStatus: http.StatusBadRequest,
|
expectedStatus: http.StatusBadRequest,
|
||||||
expectedBody: "cannot read body", // Partial match for error message
|
expectedBody: "cannot read body", // Partial match for error message
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invoice generation error",
|
name: "invoice generation error",
|
||||||
requestBody: InvoiceReq{
|
requestBody: InvoiceReq{
|
||||||
Creditor: model.Entity{Name: "Creditor"},
|
Creditor: model.Entity{Name: "Creditor"},
|
||||||
Debtor: model.Entity{Name: "Debtor"},
|
Debtor: model.Entity{Name: "Debtor"},
|
||||||
DurationThreshold: "1h", // Changed to string
|
DurationThreshold: "1h", // Changed to string
|
||||||
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,
|
||||||
@@ -147,15 +147,15 @@ func TestCreateInvoice(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "no suitable issues to be billed",
|
name: "no suitable issues to be billed",
|
||||||
requestBody: InvoiceReq{
|
requestBody: InvoiceReq{
|
||||||
Creditor: model.Entity{Name: "Creditor"},
|
Creditor: model.Entity{Name: "Creditor"},
|
||||||
Debtor: model.Entity{Name: "Debtor"},
|
Debtor: model.Entity{Name: "Debtor"},
|
||||||
DurationThreshold: "1h", // Changed to string
|
DurationThreshold: "1h", // Changed to string
|
||||||
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
|
||||||
@@ -208,16 +208,16 @@ func TestSendInvoice(t *testing.T) {
|
|||||||
Subject: "Test Invoice",
|
Subject: "Test Invoice",
|
||||||
Body: "Here is your invoice.",
|
Body: "Here is your invoice.",
|
||||||
Invoice: InvoiceReq{
|
Invoice: InvoiceReq{
|
||||||
Creditor: model.Entity{Name: "Creditor"},
|
Creditor: model.Entity{Name: "Creditor"},
|
||||||
Debtor: model.Entity{Name: "Debtor"},
|
Debtor: model.Entity{Name: "Debtor"},
|
||||||
DurationThreshold: "1h", // Changed to string
|
DurationThreshold: "1h", // Changed to string
|
||||||
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 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 {
|
||||||
@@ -233,14 +233,14 @@ func TestSendInvoice(t *testing.T) {
|
|||||||
Subject: "Test Invoice",
|
Subject: "Test Invoice",
|
||||||
Body: "Here is your invoice.",
|
Body: "Here is your invoice.",
|
||||||
Invoice: InvoiceReq{
|
Invoice: InvoiceReq{
|
||||||
Creditor: model.Entity{Name: "Creditor"},
|
Creditor: model.Entity{Name: "Creditor"},
|
||||||
Debtor: model.Entity{Name: "Debtor"},
|
Debtor: model.Entity{Name: "Debtor"},
|
||||||
DurationThreshold: "1h", // Changed to string
|
DurationThreshold: "1h", // Changed to string
|
||||||
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 for send")
|
return nil, nil, errors.New("failed to generate invoice for send")
|
||||||
},
|
},
|
||||||
mockSend: nil, // Not called
|
mockSend: nil, // Not called
|
||||||
@@ -254,16 +254,16 @@ func TestSendInvoice(t *testing.T) {
|
|||||||
Subject: "Test Invoice",
|
Subject: "Test Invoice",
|
||||||
Body: "Here is your invoice.",
|
Body: "Here is your invoice.",
|
||||||
Invoice: InvoiceReq{
|
Invoice: InvoiceReq{
|
||||||
Creditor: model.Entity{Name: "Creditor"},
|
Creditor: model.Entity{Name: "Creditor"},
|
||||||
Debtor: model.Entity{Name: "Debtor"},
|
Debtor: model.Entity{Name: "Debtor"},
|
||||||
DurationThreshold: "1h", // Changed to string
|
DurationThreshold: "1h", // Changed to string
|
||||||
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 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 {
|
||||||
@@ -279,15 +279,15 @@ func TestSendInvoice(t *testing.T) {
|
|||||||
Subject: "Test Invoice",
|
Subject: "Test Invoice",
|
||||||
Body: "Here is your invoice.",
|
Body: "Here is your invoice.",
|
||||||
Invoice: InvoiceReq{
|
Invoice: InvoiceReq{
|
||||||
Creditor: model.Entity{Name: "Creditor"},
|
Creditor: model.Entity{Name: "Creditor"},
|
||||||
Debtor: model.Entity{Name: "Debtor"},
|
Debtor: model.Entity{Name: "Debtor"},
|
||||||
DurationThreshold: "1h", // Changed to string
|
DurationThreshold: "1h", // Changed to string
|
||||||
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) {
|
||||||
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,
|
||||||
|
|||||||
@@ -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
BIN
invoice.pdf
Normal file
Binary file not shown.
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user