feat(api): fix POST /invoice

This commit is contained in:
2025-08-26 22:40:49 +02:00
parent 788571162d
commit 794558a007
12 changed files with 181 additions and 84 deletions

17
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,17 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch Package",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/invoiceapi",
"cwd": "${workspaceFolder}"
}
]
}

View File

@@ -1 +1,65 @@
docker run --rm -p 3030:3000 gotenberg/gotenberg:8 docker run --rm -p 3030:3000 gotenberg/gotenberg:8
## Configuration
This application is configured using a `config.json` file in the root of the project. You can use the `config.json.example` file as a starting point.
```json
{
"email": {
"smtp": {
"host": "smtp.example.com",
"port": 587,
"username": "user",
"password": "password"
},
"from": "from@example.com"
},
"pdf": {
"hostname": "http://localhost:3030"
},
"gitea": {
"url": "https://gitea.example.com",
"token": "your-gitea-token"
}
}
```
## API Endpoints
### POST /invoice
Creates a new invoice.
```bash
curl -X POST -H "Content-Type: application/json" -d '{
"debtor": {
"name": "John Doe",
"Address": {
"street": "Musterstrasse",
"number": "1",
"zipCode": "1234",
"place": "Musterstadt",
"country": "CH"
},
"contact": "john.doe@example.com"
},
"creditor": {
"name": "Jane Doe",
"Address": {
"street": "Beispielweg",
"number": "2",
"zipCode": "5678",
"place": "Beispielhausen",
"country": "CH"
},
"contact": "jane.doe@example.com",
"iban": "CH1234567890123456789"
},
"durationThreshold": "1h",
"hourlyRate": 100,
"repositories": [
"lou-taylor/accounting"
]
}' http://localhost:8080/invoice
```

View File

@@ -5,22 +5,15 @@ import (
"os" "os"
"git.schreifuchs.ch/lou-taylor/accounting/internal/api" "git.schreifuchs.ch/lou-taylor/accounting/internal/api"
"git.schreifuchs.ch/lou-taylor/accounting/internal/config"
) )
func main() { func main() {
// var cfg invoice.Config cfg, err := config.Load("config.json")
// file, err := os.Open("config.json") if err != nil {
// if err != nil { panic(err)
// panic(err) }
// }
// defer file.Close() log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
// decoder := json.NewDecoder(file) api.Start(log, ":8080", cfg)
// err = decoder.Decode(&cfg)
// if err != nil {
// panic(err)
// }
//
// invoice.Generate(cfg)
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
api.Start(log, ":8080")
} }

View File

@@ -1,60 +1,18 @@
{ {
"gitea_url": "https://git.schreifuchs.ch", "email": {
"gitea_token": "",
"repos": [
{
"owner": "lou-taylor",
"repo": "lou-taylor-web"
},
{
"owner": "lou-taylor",
"repo": "lou-taylor-api"
},
{
"owner": "lou-taylor",
"repo": "accounting"
}
],
"min_duration": "15m",
"hourly": 16,
"from_entity": {
"name": "schreifuchs.ch",
"iban": "",
"address": {
"street": "",
"number": "",
"zip_code": "",
"place": "",
"country": ""
},
"contact": ""
},
"to_entity": {
"name": "",
"address": {
"street": "",
"number": "",
"zip_code": "",
"place": "",
"country": ""
},
"contact": "Loana Groux"
},
"pdf_generator_url": "http://localhost:3030",
"mailer": {
"smtp": { "smtp": {
"host": "mail.your-server.de", "host": "smtp.example.com",
"port": "465", "port": "587",
"user": "", "username": "user",
"password": "" "password": "password"
}, },
"from": "" "from": "from@example.com"
}, },
"mail": { "pdf": {
"to": "", "hostname": "http://localhost:3030"
"subject": "",
"body": ""
}, },
"mail_bcc": [""] "gitea": {
"url": "https://gitea.example.com",
"token": "your-gitea-token"
}
} }

View File

@@ -5,11 +5,13 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"time" "time"
"git.schreifuchs.ch/lou-taylor/accounting/internal/config"
) )
func Start(log *slog.Logger, address string) error { func Start(log *slog.Logger, address string, cfg *config.Config) error {
mux := http.NewServeMux() mux := http.NewServeMux()
RegisterRoutes(log, mux) RegisterRoutes(log, mux, cfg)
s := &http.Server{ s := &http.Server{
Addr: address, Addr: address,

View File

@@ -29,7 +29,7 @@ func (s Service) createInvoice(w http.ResponseWriter, r *http.Request) {
return return
} }
invoice, err := s.invoice.Generate(req.Creditor, req.Debtor, req.DurationThreshold, req.HourlyRate, repos) invoice, err := s.invoice.Generate(req.Creditor, req.Debtor, time.Duration(req.DurationThreshold), req.HourlyRate, repos)
if err != nil { if err != nil {
log.Println("error while processing invoice:", err) log.Println("error while processing invoice:", err)
fmt.Fprint(w, "internal server error") fmt.Fprint(w, "internal server error")
@@ -63,7 +63,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, err := s.invoice.Generate(req.Invoice.Creditor, req.Invoice.Debtor, req.Invoice.DurationThreshold, req.Invoice.HourlyRate, repos) invoice, 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

@@ -5,9 +5,9 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"strings" "strings"
"time"
"git.schreifuchs.ch/lou-taylor/accounting/internal/email" "git.schreifuchs.ch/lou-taylor/accounting/internal/email"
"git.schreifuchs.ch/lou-taylor/accounting/internal/jtype"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice" "git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/model" "git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/model"
) )
@@ -15,7 +15,7 @@ import (
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 time.Duration `json:"durationThreshold"` DurationThreshold jtype.Duration `json:"durationThreshold"`
HourlyRate float64 `json:"hourlyRate"` HourlyRate float64 `json:"hourlyRate"`
Repos []string `json:"repositories"` Repos []string `json:"repositories"`
} }

View File

@@ -4,6 +4,8 @@ import (
"net/http" "net/http"
) )
// RegisterRoutes registers the HTTP routes for the invoice service.
func (s Service) RegisterRoutes(mux *http.ServeMux) { func (s Service) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /invoice", s.createInvoice) mux.HandleFunc("POST /invoice", s.createInvoice)
mux.HandleFunc("POST /invoice/send", s.sendInvoice)
} }

View File

@@ -6,26 +6,27 @@ import (
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"git.schreifuchs.ch/lou-taylor/accounting/internal/api/httpinvoce" "git.schreifuchs.ch/lou-taylor/accounting/internal/api/httpinvoce"
"git.schreifuchs.ch/lou-taylor/accounting/internal/config"
"git.schreifuchs.ch/lou-taylor/accounting/internal/email" "git.schreifuchs.ch/lou-taylor/accounting/internal/email"
"git.schreifuchs.ch/lou-taylor/accounting/pdf" "git.schreifuchs.ch/lou-taylor/accounting/pdf"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice" "git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice"
) )
func RegisterRoutes(log *slog.Logger, mux *http.ServeMux) error { func RegisterRoutes(log *slog.Logger, mux *http.ServeMux, cfg *config.Config) error {
gotenberg, err := pdf.New("http://localhost:3030") gotenberg, err := pdf.New(cfg.PDF.Hostname)
if err != nil { if err != nil {
panic(err) panic(err)
} }
giteaC, err := gitea.NewClient( giteaC, err := gitea.NewClient(
"https://git.schreifuchs.ch", cfg.Gitea.URL,
gitea.SetToken("6a8ea8f9de039b0950c634bfea40c6f97f94b06b"), gitea.SetToken(cfg.Gitea.Token),
) )
if err != nil { if err != nil {
panic(err) panic(err)
} }
invoicer := invoice.New(log, giteaC, gotenberg) invoicer := invoice.New(log, giteaC, gotenberg)
mailer, err := email.New(email.Config{}) mailer, err := email.New(cfg.Email)
if err != nil { if err != nil {
return err return err
} }

43
internal/config/config.go Normal file
View File

@@ -0,0 +1,43 @@
package config
import (
"encoding/json"
"os"
"git.schreifuchs.ch/lou-taylor/accounting/internal/email"
)
// PDF holds the configuration for the PDF generator.
type PDF struct {
Hostname string `json:"hostname"`
}
// Gitea holds the configuration for the Gitea client.
type Gitea struct {
URL string `json:"url"`
Token string `json:"token"`
}
// Config holds the configuration for the entire application.
type Config struct {
Email email.Config `json:"email"`
PDF PDF `json:"pdf"`
Gitea Gitea `json:"gitea"`
}
// Load loads the configuration from a JSON file.
func Load(path string) (*Config, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
cfg := &Config{}
decoder := json.NewDecoder(file)
if err := decoder.Decode(cfg); err != nil {
return nil, err
}
return cfg, nil
}

View File

@@ -0,0 +1,17 @@
package jtype
import (
"strings"
"time"
)
type Duration time.Duration
func (d *Duration) UnmarshalJSON(b []byte) error {
dur, err := time.ParseDuration(strings.ReplaceAll(string(b), `"`, ""))
if err != nil {
return err
}
*d = Duration(dur)
return nil
}

View File

@@ -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
} }