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
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func (s Service) createInvoice(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
s.sendErr(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
@@ -65,7 +65,7 @@ func (s Service) sendInvoice(w http.ResponseWriter, r *http.Request) {
|
||||
s.sendErr(w, 500, err.Error())
|
||||
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 {
|
||||
s.sendErr(w, http.StatusInternalServerError, "error while processing invoice:", err)
|
||||
return
|
||||
|
||||
@@ -22,11 +22,11 @@ import (
|
||||
|
||||
// Exported versions of the request structs for testing
|
||||
type InvoiceReq struct {
|
||||
Debtor model.Entity `json:"debtor"`
|
||||
Creditor model.Entity `json:"creditor"`
|
||||
DurationThreshold string `json:"durationThreshold"` // Changed to string
|
||||
HourlyRate float64 `json:"hourlyRate"`
|
||||
Repos []string `json:"repositories"`
|
||||
Debtor model.Entity `json:"debtor"`
|
||||
Creditor model.Entity `json:"creditor"`
|
||||
DurationThreshold string `json:"durationThreshold"` // Changed to string
|
||||
HourlyRate float64 `json:"hourlyRate"`
|
||||
Repos []string `json:"repositories"`
|
||||
}
|
||||
|
||||
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, "/")
|
||||
if len(parts) != 2 {
|
||||
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{
|
||||
Owner: parts[0],
|
||||
Repo: parts[1],
|
||||
})
|
||||
}
|
||||
return
|
||||
return repos, err
|
||||
}
|
||||
|
||||
type SendReq struct {
|
||||
@@ -65,10 +65,10 @@ func (s SendReq) ToEMail() email.Mail {
|
||||
|
||||
// MockInvoiceService mocks the invoice.Service interface
|
||||
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 {
|
||||
return m.GenerateFunc(creditor, debtor, durationThreshold, hourlyRate, repos)
|
||||
}
|
||||
@@ -94,23 +94,23 @@ func TestCreateInvoice(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
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
|
||||
expectedBody string // For error messages or specific content
|
||||
}{
|
||||
{
|
||||
name: "successful invoice creation",
|
||||
requestBody: InvoiceReq{
|
||||
Creditor: model.Entity{Name: "Creditor"},
|
||||
Debtor: model.Entity{Name: "Debtor"},
|
||||
Creditor: model.Entity{Name: "Creditor"},
|
||||
Debtor: model.Entity{Name: "Debtor"},
|
||||
DurationThreshold: "1h", // Changed to string
|
||||
HourlyRate: 100,
|
||||
Repos: []string{"owner/repo1"},
|
||||
HourlyRate: 100,
|
||||
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"
|
||||
// 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
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
@@ -119,26 +119,26 @@ func TestCreateInvoice(t *testing.T) {
|
||||
{
|
||||
name: "invalid request body",
|
||||
requestBody: InvoiceReq{ // Malformed request body
|
||||
Creditor: model.Entity{Name: "Creditor"},
|
||||
Debtor: model.Entity{Name: "Debtor"},
|
||||
Creditor: model.Entity{Name: "Creditor"},
|
||||
Debtor: model.Entity{Name: "Debtor"},
|
||||
DurationThreshold: "invalid", // Changed to string
|
||||
HourlyRate: 100,
|
||||
Repos: []string{"owner/repo1"},
|
||||
HourlyRate: 100,
|
||||
Repos: []string{"owner/repo1"},
|
||||
},
|
||||
mockGenerate: nil, // Not called for invalid body
|
||||
mockGenerate: nil, // Not called for invalid body
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedBody: "cannot read body", // Partial match for error message
|
||||
},
|
||||
{
|
||||
name: "invoice generation error",
|
||||
requestBody: InvoiceReq{
|
||||
Creditor: model.Entity{Name: "Creditor"},
|
||||
Debtor: model.Entity{Name: "Debtor"},
|
||||
Creditor: model.Entity{Name: "Creditor"},
|
||||
Debtor: model.Entity{Name: "Debtor"},
|
||||
DurationThreshold: "1h", // Changed to string
|
||||
HourlyRate: 100,
|
||||
Repos: []string{"owner/repo1"},
|
||||
HourlyRate: 100,
|
||||
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")
|
||||
},
|
||||
expectedStatus: http.StatusInternalServerError,
|
||||
@@ -147,15 +147,15 @@ func TestCreateInvoice(t *testing.T) {
|
||||
{
|
||||
name: "no suitable issues to be billed",
|
||||
requestBody: InvoiceReq{
|
||||
Creditor: model.Entity{Name: "Creditor"},
|
||||
Debtor: model.Entity{Name: "Debtor"},
|
||||
Creditor: model.Entity{Name: "Creditor"},
|
||||
Debtor: model.Entity{Name: "Debtor"},
|
||||
DurationThreshold: "1h", // Changed to string
|
||||
HourlyRate: 100,
|
||||
Repos: []string{"owner/repo1"},
|
||||
HourlyRate: 100,
|
||||
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
|
||||
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
|
||||
},
|
||||
expectedStatus: http.StatusNotFound,
|
||||
@@ -196,7 +196,7 @@ func TestSendInvoice(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
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
|
||||
expectedStatus int
|
||||
expectedBody string
|
||||
@@ -208,16 +208,16 @@ func TestSendInvoice(t *testing.T) {
|
||||
Subject: "Test Invoice",
|
||||
Body: "Here is your invoice.",
|
||||
Invoice: InvoiceReq{
|
||||
Creditor: model.Entity{Name: "Creditor"},
|
||||
Debtor: model.Entity{Name: "Debtor"},
|
||||
Creditor: model.Entity{Name: "Creditor"},
|
||||
Debtor: model.Entity{Name: "Debtor"},
|
||||
DurationThreshold: "1h", // Changed to string
|
||||
HourlyRate: 100,
|
||||
Repos: []string{"owner/repo1"},
|
||||
HourlyRate: 100,
|
||||
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"
|
||||
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
|
||||
},
|
||||
mockSend: func(mail email.Mail) error {
|
||||
@@ -233,14 +233,14 @@ func TestSendInvoice(t *testing.T) {
|
||||
Subject: "Test Invoice",
|
||||
Body: "Here is your invoice.",
|
||||
Invoice: InvoiceReq{
|
||||
Creditor: model.Entity{Name: "Creditor"},
|
||||
Debtor: model.Entity{Name: "Debtor"},
|
||||
Creditor: model.Entity{Name: "Creditor"},
|
||||
Debtor: model.Entity{Name: "Debtor"},
|
||||
DurationThreshold: "1h", // Changed to string
|
||||
HourlyRate: 100,
|
||||
Repos: []string{"owner/repo1"},
|
||||
HourlyRate: 100,
|
||||
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")
|
||||
},
|
||||
mockSend: nil, // Not called
|
||||
@@ -254,16 +254,16 @@ func TestSendInvoice(t *testing.T) {
|
||||
Subject: "Test Invoice",
|
||||
Body: "Here is your invoice.",
|
||||
Invoice: InvoiceReq{
|
||||
Creditor: model.Entity{Name: "Creditor"},
|
||||
Debtor: model.Entity{Name: "Debtor"},
|
||||
Creditor: model.Entity{Name: "Creditor"},
|
||||
Debtor: model.Entity{Name: "Debtor"},
|
||||
DurationThreshold: "1h", // Changed to string
|
||||
HourlyRate: 100,
|
||||
Repos: []string{"owner/repo1"},
|
||||
HourlyRate: 100,
|
||||
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"
|
||||
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
|
||||
},
|
||||
mockSend: func(mail email.Mail) error {
|
||||
@@ -279,15 +279,15 @@ func TestSendInvoice(t *testing.T) {
|
||||
Subject: "Test Invoice",
|
||||
Body: "Here is your invoice.",
|
||||
Invoice: InvoiceReq{
|
||||
Creditor: model.Entity{Name: "Creditor"},
|
||||
Debtor: model.Entity{Name: "Debtor"},
|
||||
Creditor: model.Entity{Name: "Creditor"},
|
||||
Debtor: model.Entity{Name: "Debtor"},
|
||||
DurationThreshold: "1h", // Changed to string
|
||||
HourlyRate: 100,
|
||||
Repos: []string{"owner/repo1"},
|
||||
HourlyRate: 100,
|
||||
Repos: []string{"owner/repo1"},
|
||||
},
|
||||
},
|
||||
mockGenerate: func(creditor, 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)
|
||||
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
|
||||
},
|
||||
expectedStatus: http.StatusNotFound,
|
||||
|
||||
@@ -22,7 +22,7 @@ func New(log *slog.Logger, invoice invoicer, mail mailer) *Service {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
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
|
||||
for _, repo := range repos {
|
||||
iss, _, err := s.gitea.ListRepoIssues(
|
||||
|
||||
@@ -14,10 +14,10 @@ type Report struct {
|
||||
Invoice qrbill.Invoice
|
||||
Rate float64
|
||||
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{
|
||||
Date: time.Now(),
|
||||
Issues: issues,
|
||||
@@ -25,7 +25,7 @@ func New(issues []issue.Issue, company, client model.Entity, rate float64) *Repo
|
||||
Company: company,
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user