feat(invoice): add send route

This commit is contained in:
2025-08-26 20:30:48 +02:00
parent 958979c62b
commit 788571162d
35 changed files with 451 additions and 193 deletions

26
cmd/invoiceapi/main.go Normal file
View File

@@ -0,0 +1,26 @@
package main
import (
"log/slog"
"os"
"git.schreifuchs.ch/lou-taylor/accounting/internal/api"
)
func main() {
// var cfg invoice.Config
// file, err := os.Open("config.json")
// if err != nil {
// panic(err)
// }
// defer file.Close()
// decoder := json.NewDecoder(file)
// 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,36 +0,0 @@
package main
import (
"encoding/json"
"os"
"git.schreifuchs.ch/lou-taylor/accounting/mailer"
"git.schreifuchs.ch/lou-taylor/accounting/model"
)
type Config struct {
GiteaURL string `json:"gitea_url"`
GiteaToken string `json:"gitea_token"`
Repos []Repo `json:"repos"`
MinDuration Duration `json:"min_duration"`
Hourly float64 `json:"hourly"`
FromEntity model.Entity `json:"from_entity"`
ToEntity model.Entity `json:"to_entity"`
PdfGeneratorURL string `json:"pdf_generator_url"`
Mailer mailer.Config `json:"mailer"`
Mail mailer.Mail `json:"mail"`
MailBcc []string `json:"mail_bcc"`
}
func LoadConfig(path string) (Config, error) {
var cfg Config
file, err := os.Open(path)
if err != nil {
return cfg, err
}
defer file.Close()
decoder := json.NewDecoder(file)
err = decoder.Decode(&cfg)
return cfg, err
}

23
internal/api/api.go Normal file
View File

@@ -0,0 +1,23 @@
package api
import (
"fmt"
"log/slog"
"net/http"
"time"
)
func Start(log *slog.Logger, address string) error {
mux := http.NewServeMux()
RegisterRoutes(log, mux)
s := &http.Server{
Addr: address,
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
}
log.Info(fmt.Sprintf("Start API on %s", address))
return s.ListenAndServe()
}

View File

@@ -0,0 +1,99 @@
package httpinvoce
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"time"
"git.schreifuchs.ch/lou-taylor/accounting/internal/email"
)
const bufSize = 1024 * 1024 // 1Mib
func (s Service) createInvoice(w http.ResponseWriter, r *http.Request) {
var req invoiceReq
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
s.sendErrf(w, http.StatusBadRequest, "cannot read body: %v", err)
return
}
repos, err := req.GetRepos()
if err != nil {
s.sendErr(w, 500, err.Error())
return
}
invoice, err := s.invoice.Generate(req.Creditor, req.Debtor, req.DurationThreshold, req.HourlyRate, repos)
if err != nil {
log.Println("error while processing invoice:", err)
fmt.Fprint(w, "internal server error")
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-type", "application/pdf")
w.Header().Set(
"Content-Disposition",
fmt.Sprintf("attachment; filename=\"%s\"", invoiceName()),
)
_, err = io.Copy(w, invoice)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
}
func (s Service) sendInvoice(w http.ResponseWriter, r *http.Request) {
var req sendReq
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
s.sendErrf(w, http.StatusBadRequest, "cannot read body: %v", err)
return
}
repos, err := req.Invoice.GetRepos()
if err != nil {
s.sendErr(w, 500, err.Error())
return
}
invoice, err := s.invoice.Generate(req.Invoice.Creditor, req.Invoice.Debtor, req.Invoice.DurationThreshold, req.Invoice.HourlyRate, repos)
if err != nil {
s.sendErr(w, http.StatusInternalServerError, "error while processing invoice:", err)
return
}
invoiceData, err := io.ReadAll(invoice)
if err != nil {
s.sendErrf(w, http.StatusInternalServerError, "error while creating pdf: %v", err)
return
}
mail := req.ToEMail()
mail.Attachments = append(mail.Attachments, email.Attachment{
Name: invoiceName(),
MimeType: "application/pdf",
Content: bytes.NewReader(invoiceData),
})
err = s.mail.Send(mail)
if err != nil {
s.sendErrf(w, http.StatusInternalServerError, "error while sending mail: %v", err)
return
}
_, err = w.Write(invoiceData)
if err != nil {
s.sendErr(w, http.StatusInternalServerError, "")
}
}
func invoiceName() string {
return fmt.Sprintf("%s_invoice.pdf", time.Now().Format("20060102"))
}

View File

@@ -0,0 +1,69 @@
package httpinvoce
import (
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"git.schreifuchs.ch/lou-taylor/accounting/internal/email"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/model"
)
type invoiceReq struct {
Debtor model.Entity `json:"debtor"`
Creditor model.Entity `json:"creditor"`
DurationThreshold time.Duration `json:"durationThreshold"`
HourlyRate float64 `json:"hourlyRate"`
Repos []string `json:"repositories"`
}
func (i invoiceReq) GetRepos() (repos []invoice.Repo, err error) {
for i, repo := range i.Repos {
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
}
repos = append(repos, invoice.Repo{
Owner: parts[0],
Repo: parts[1],
})
}
return
}
type sendReq struct {
To []string `json:"to"`
Cc []string `json:"cc"`
Bcc []string `json:"bcc"`
Subject string `json:"subjec"`
Body string `json:"body"`
Invoice invoiceReq `json:"invoice"`
}
func (s sendReq) ToEMail() email.Mail {
return email.Mail{
To: s.To,
Cc: s.Cc,
Bcc: s.Bcc,
Subject: s.Subject,
Body: s.Body,
}
}
func (s *Service) sendErrf(w http.ResponseWriter, statusCode int, format string, a ...any) {
msg := fmt.Sprintf(format, a...)
s.log.Error(msg, slog.Any("statusCode", statusCode))
w.Write([]byte(msg))
w.WriteHeader(statusCode)
}
func (s *Service) sendErr(w http.ResponseWriter, statusCode int, a ...any) {
msg := fmt.Sprint(a...)
s.log.Error(msg, slog.Any("statusCode", statusCode))
w.Write([]byte(msg))
w.WriteHeader(statusCode)
}

View File

@@ -0,0 +1,29 @@
package httpinvoce
import (
"io"
"log/slog"
"time"
"git.schreifuchs.ch/lou-taylor/accounting/internal/email"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/model"
)
type Service struct {
log *slog.Logger
invoice invoicer
mail mailer
}
func New(log *slog.Logger, invoice invoicer, mail mailer) *Service {
return &Service{log: log, invoice: invoice, mail: mail}
}
type invoicer interface {
Generate(creditor, deptor model.Entity, mindur time.Duration, rate float64, repos []invoice.Repo) (document io.ReadCloser, err error)
}
type mailer interface {
Send(m email.Mail) (err error)
}

View File

@@ -0,0 +1,9 @@
package httpinvoce
import (
"net/http"
)
func (s Service) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /invoice", s.createInvoice)
}

35
internal/api/routes.go Normal file
View File

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

View File

@@ -1,9 +1,11 @@
package mailer
package email
import "io"
type Mail struct {
TO string
To []string
Cc []string
Bcc []string
Subject string
Body string
Attachments []Attachment

View File

@@ -1,4 +1,4 @@
package mailer
package email
import (
"crypto/tls"

View File

@@ -1,4 +1,4 @@
package mailer
package email
import (
"crypto/tls"
@@ -7,10 +7,11 @@ import (
"github.com/jordan-wright/email"
)
func (s Service) Send(m Mail, bcc ...string) (err error) {
func (s Service) Send(m Mail) (err error) {
e := email.NewEmail()
e.To = []string{m.TO}
e.Bcc = bcc
e.To = m.To
e.Cc = m.Cc
e.Bcc = m.Bcc
e.From = s.from
e.Subject = m.Subject
e.Text = []byte(m.Body)

114
main.go
View File

@@ -1,106 +1,22 @@
package main
import (
"time"
"code.gitea.io/sdk/gitea"
"git.schreifuchs.ch/lou-taylor/accounting/issue"
"git.schreifuchs.ch/lou-taylor/accounting/mailer"
"git.schreifuchs.ch/lou-taylor/accounting/pdf"
"git.schreifuchs.ch/lou-taylor/accounting/report"
"git.schreifuchs.ch/lou-taylor/accounting/internal/api"
)
type Repo struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
}
func main() {
cfg, err := LoadConfig("config.json")
if err != nil {
panic(err)
}
client, err := gitea.NewClient(
cfg.GiteaURL,
gitea.SetToken(cfg.GiteaToken),
)
if err != nil {
panic(err)
}
var is []*gitea.Issue
for _, repo := range cfg.Repos {
iss, _, err := client.ListRepoIssues(
repo.Owner,
repo.Repo,
gitea.ListIssueOption{
ListOptions: gitea.ListOptions{Page: 0, PageSize: 99999},
Since: time.Now().AddDate(0, -1, 0),
Before: time.Now(),
State: gitea.StateClosed,
},
)
if err != nil {
panic(err)
}
is = append(is, iss...)
}
is = Filter(
is,
func(i *gitea.Issue) bool {
return i.Closed != nil && i.Closed.After(time.Now().AddDate(0, -1, 0))
},
)
issues := issue.FromGiteas(is, time.Duration(cfg.MinDuration))
r := report.New(
issues,
cfg.FromEntity,
cfg.ToEntity,
cfg.Hourly,
)
html := r.ToHTML()
pdfs, err := pdf.New(cfg.PdfGeneratorURL)
if err != nil {
panic(err)
}
document, err := pdfs.HtmlToPdf(html)
if err != nil {
panic(err)
}
mlr, err := mailer.New(cfg.Mailer)
if err != nil {
panic(err)
}
mail := cfg.Mail
mail.Attachments = []mailer.Attachment{
{
Name: "invoice.pdf",
MimeType: "pdf",
Content: document,
},
}
err = mlr.Send(mail)
if err != nil {
panic(err)
}
}
func Filter[T any](slice []T, ok func(T) bool) []T {
out := make([]T, 0, len(slice))
for _, item := range slice {
if ok(item) {
out = append(out, item)
}
}
return out
// var cfg invoice.Config
// file, err := os.Open("config.json")
// if err != nil {
// panic(err)
// }
// defer file.Close()
// decoder := json.NewDecoder(file)
// err = decoder.Decode(&cfg)
// if err != nil {
// panic(err)
// }
//
// invoice.Generate(cfg)
api.Start(":8080")
}

View File

@@ -1,15 +0,0 @@
package model
type Entity struct {
Name string
Address Address
Contact string
IBAN string
}
type Address struct {
Street string
Number string
ZIPCode string
Place string
Country string
}

View File

@@ -13,12 +13,13 @@ type Service struct {
gotenberg *gotenberg.Client
}
func New(hostname string) (service Service, err error) {
func New(hostname string) (service *Service, err error) {
service = &Service{}
service.gotenberg, err = gotenberg.NewClient(hostname, http.DefaultClient)
return
}
func (s Service) HtmlToPdf(html string) (pdf io.ReadCloser, err error) {
func (s *Service) HtmlToPdf(html string) (pdf io.ReadCloser, err error) {
index, err := document.FromString("index.html", html)
if err != nil {
return
@@ -32,6 +33,9 @@ func (s Service) HtmlToPdf(html string) (pdf io.ReadCloser, err error) {
req.EmulatePrintMediaType()
resp, err := s.gotenberg.Send(context.Background(), req)
if err != nil {
return
}
pdf = resp.Body
return

65
pkg/invoice/invoice.go Normal file
View File

@@ -0,0 +1,65 @@
package invoice
import (
"io"
"time"
"code.gitea.io/sdk/gitea"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/issue"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/model"
"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, err error) {
var is []*gitea.Issue
for _, repo := range repos {
iss, _, err := s.gitea.ListRepoIssues(
repo.Owner,
repo.Repo,
gitea.ListIssueOption{
ListOptions: gitea.ListOptions{Page: 0, PageSize: 99999},
Since: time.Now().AddDate(0, -1, 0),
Before: time.Now(),
State: gitea.StateClosed,
},
)
if err != nil {
return nil, err
}
is = append(is, iss...)
}
is = filter(
is,
func(i *gitea.Issue) bool {
return i.Closed != nil && i.Closed.After(time.Now().AddDate(0, -1, 0))
},
)
issues := issue.FromGiteas(is, mindur)
r := report.New(
issues,
creditor,
deptor,
rate,
)
html, err := r.ToHTML()
if err != nil {
return
}
document, err = s.pdf.HtmlToPdf(html)
return
}
func filter[T any](slice []T, ok func(T) bool) []T {
out := make([]T, 0, len(slice))
for _, item := range slice {
if ok(item) {
out = append(out, item)
}
}
return out
}

View File

@@ -0,0 +1,15 @@
package model
type Entity struct {
Name string `json:"name"`
Address Address `json:"Address"`
Contact string `json:"contact"`
IBAN string `json:"iban,omitempty"`
}
type Address struct {
Street string `json:"street"`
Number string `json:"number"`
ZIPCode string `json:"zipCode"`
Place string `json:"place"`
Country string `json:"country"`
}

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,4 +1,4 @@
package invoice
package qrbill
import (
"bytes"

View File

@@ -1,4 +1,4 @@
package invoice
package qrbill
import (
"bytes"
@@ -7,7 +7,7 @@ import (
"html/template"
"strings"
"git.schreifuchs.ch/lou-taylor/accounting/model"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/model"
"github.com/skip2/go-qrcode"
)

View File

@@ -1,4 +1,4 @@
package invoice
package qrbill
import (
"crypto/rand"

View File

@@ -3,15 +3,15 @@ package report
import (
"time"
"git.schreifuchs.ch/lou-taylor/accounting/issue"
"git.schreifuchs.ch/lou-taylor/accounting/model"
"git.schreifuchs.ch/lou-taylor/accounting/report/invoice"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/issue"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/model"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/report/qrbill"
)
type Report struct {
Date time.Time
Issues []issue.Issue
Invoice invoice.Invoice
Invoice qrbill.Invoice
Rate float64
Company model.Entity
Client model.Entity
@@ -25,7 +25,7 @@ func New(issues []issue.Issue, company, client model.Entity, rate float64) *Repo
Company: company,
Client: client,
}
r.Invoice = invoice.New(r.applyRate(r.Total()), r.Company, &r.Client)
// r.Invoice = qrbill.New(r.applyRate(r.Total()), r.Company, &r.Client)
return r
}

View File

@@ -20,7 +20,7 @@ type tmpler struct {
Style template.CSS
}
func (r Report) ToHTML() string {
func (r Report) ToHTML() (html string, err error) {
tmpl := template.Must(
template.New("report").Funcs(template.FuncMap{
"md": mdToHTML,
@@ -32,11 +32,14 @@ func (r Report) ToHTML() string {
}).Parse(htmlTemplate))
buf := new(bytes.Buffer)
err := tmpl.Execute(buf, tmpler{r, style})
err = tmpl.Execute(buf, tmpler{r, style})
if err != nil {
panic(err)
return
}
return buf.String()
html = buf.String()
return
}
func (r Report) applyRate(dur time.Duration) string {

34
pkg/invoice/resource.go Normal file
View File

@@ -0,0 +1,34 @@
package invoice
import (
"io"
"log/slog"
"code.gitea.io/sdk/gitea"
)
type Repo struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
}
type Service struct {
log *slog.Logger
gitea giteaClient
pdf pdfGenerator
}
func New(log *slog.Logger, gitea giteaClient, pdf pdfGenerator) *Service {
return &Service{
log: log,
gitea: gitea,
pdf: pdf,
}
}
type giteaClient interface {
ListRepoIssues(owner, repo string, opt gitea.ListIssueOption) ([]*gitea.Issue, *gitea.Response, error)
}
type pdfGenerator interface {
HtmlToPdf(html string) (pdf io.ReadCloser, err error)
}

View File

@@ -1,21 +0,0 @@
package main
import (
"encoding/json"
"time"
)
type Duration time.Duration
func (d *Duration) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
tmp, err := time.ParseDuration(s)
if err != nil {
return err
}
*d = Duration(tmp)
return nil
}