5 Commits

Author SHA1 Message Date
74d81e4271 chore: bump go version
All checks were successful
Go / build (push) Successful in 54s
Release / publish (push) Successful in 1m4s
2025-11-04 21:17:13 +01:00
e5169ee0c4 feat: better flags
All checks were successful
Go / build (push) Successful in 38s
2025-11-04 21:15:05 +01:00
cfbb475a42 feat: more options
Some checks failed
Go / build (push) Failing after 1m16s
2025-11-04 20:16:44 +01:00
8f5ae15ef0 feat: cli app 2025-11-04 19:17:50 +01:00
7d160d5f59 feat: reastructure and create commands
All checks were successful
Go / build (push) Successful in 57s
2025-09-16 21:14:39 +02:00
30 changed files with 811 additions and 803 deletions

View File

@@ -18,7 +18,7 @@ jobs:
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v4 uses: actions/setup-go@v4
with: with:
go-version: "1.24.5" go-version: "1.25.3"
- name: Build - name: Build
run: go build -v ./... run: go build -v ./...

1
.gitignore vendored
View File

@@ -1 +1,2 @@
config.json config.json
lou-taylor.json

77
AGENTS.md Normal file
View File

@@ -0,0 +1,77 @@
# About This Project
This project is used to create invoices from Gitea issues and send them to customers. It can be operated via a CLI or a REST API.
# Development Workflow
## Dependencies
This project requires a running instance of Gotenberg for PDF generation. You can start it with Docker:
```sh
docker run --rm -p 3030:3000 gotenberg/gotenberg:8
```
## Running the Application
The project contains two main applications:
- **API Server:** `go run ./cmd/invoiceapi/main.go`
- **CLI Tool:** `go run ./cmd/invoicer/main.go <command>`
Alternatively, you can use Docker for a containerized environment:
```sh
docker-compose up --build
```
## Running Tests
To run all tests for the project, use the following command:
```sh
go test ./...
```
## Code Formatting
To format the code according to the project's standards, run:
```sh
go fmt ./...
```
# Project Conventions
## Commit Messages
This project follows the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification. Please ensure your commit messages adhere to this format.
## Dependencies and Libraries
- Prefer the Go standard library over external dependencies whenever possible.
- Do not add any third-party testing libraries. All tests should use the built-in `testing` package.
# Directory Structure
This section provides an overview of the project's directory structure to guide you on where to place new code.
- **/cmd**: Main application entry points. Each subdirectory is a separate executable.
- `invoiceapi`: The REST API server.
- `invoicer`: The command-line interface (CLI) tool.
- **/internal**: Contains all the private application and business logic. Code in this directory is not meant to be imported by other projects.
- `api`: Defines the API layer, including HTTP handlers, routes, and request/response models.
- `config`: Handles loading and parsing of application configuration.
- `email`: Logic for sending emails.
- `pdf`: Contains the logic for generating PDF documents, acting as a client for a service like Gotenberg.
- **/pkg**: Contains shared libraries that are okay to be imported by other projects.
- `invoice`: The core domain logic for creating and managing invoices. If you are adding business logic related to invoices, it likely belongs here.
## Where to Put New Code
- **New reusable library:** If you are creating a new, self-contained library that could be used by other projects, create a new directory inside `/pkg`.
- **New invoice-related feature:** If you are extending the core invoice functionality, add it to the appropriate module within `/pkg/invoice`.
- **New internal logic:** For features specific to the API or CLI that are not reusable libraries, add a new module inside `/internal`.
- **New executable:** If you are creating a new binary (e.g., a worker or another tool), create a new directory inside `/cmd`.

View File

@@ -1,4 +1,4 @@
FROM golang:1.24 as build FROM golang:1.25 as build
WORKDIR /app WORKDIR /app

55
cmd/invoicer/README.md Normal file
View 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.

44
cmd/invoicer/cmd.go Normal file
View File

@@ -0,0 +1,44 @@
package main
import (
"flag"
"log"
"os"
"github.com/itzg/go-flagsfiller"
)
type cmd struct {
cmd func([]string, any)
config any
fs *flag.FlagSet
}
func (c *cmd) Register(name string) {
c.fs = flag.NewFlagSet(name, flag.ExitOnError)
if c.config != nil {
filler := flagsfiller.New()
err := filler.Fill(c.fs, c.config)
if err != nil {
log.Fatal(err)
}
}
}
func (c *cmd) Run(name string) {
c.fs.Parse(os.Args[2:])
c.cmd(c.fs.Args(), c.config)
}
var commands = map[string]*cmd{
"request": {
request,
nil,
nil,
},
"create": {
create,
&createFlags{},
nil,
},
}

113
cmd/invoicer/create.go Normal file
View File

@@ -0,0 +1,113 @@
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"os"
"time"
"code.gitea.io/sdk/gitea"
"git.schreifuchs.ch/lou-taylor/accounting/internal/email"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice"
)
type createFlags struct {
Email bool `flag:"email" help:"send invoice by email"`
Output string `flag:"o" help:"output file"`
Label string `flag:"l" help:"filters for issues with the label"`
}
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
}
opts := invoice.DefaultOptions
if flags.Label != "" {
opts.IssueState = gitea.StateAll
opts.IssueFilter = func(i *gitea.Issue) bool {
for _, l := range i.Labels {
if l.Name == flags.Label {
return true
}
}
return false
}
}
opts.Since = time.Now().AddDate(-1, 0, 0)
opts.Mindur = time.Duration(req.DurationThreshold)
invoice, report, err := invoicer.Generate(req.Creditor, req.Debtor, req.HourlyRate, repos, &opts)
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
}

25
cmd/invoicer/main.go Normal file
View File

@@ -0,0 +1,25 @@
package main
import (
"flag"
"os"
)
func main() {
for n, c := range commands {
c.Register(n)
}
if len(os.Args) < 2 {
flag.PrintDefaults()
return
}
for n, c := range commands {
if os.Args[1] == n {
c.Run(n)
return
}
}
flag.PrintDefaults()
}

79
cmd/invoicer/request.go Normal file
View File

@@ -0,0 +1,79 @@
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"git.schreifuchs.ch/lou-taylor/accounting/internal/ask"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/model"
)
type invoiceRequest struct {
Repos []string
Creditor model.Entity
Debtor *model.Entity
MailTo string
DurationThreshold time.Duration
HourlyRate float64
}
func (i invoiceRequest) 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, err
}
repos = append(repos, invoice.Repo{
Owner: parts[0],
Repo: parts[1],
})
}
return repos, err
}
func request(arguments []string, _ any) {
if len(arguments) < 1 {
fmt.Println("please specify output file")
return
}
q := invoiceRequest{}
q.Repos = ask.StringSlice("Please list your repositories", 1, -1, 1, -1)
q.Creditor.Name = ask.String("Creditor Name (first & last name or company)", 1, -1)
q.Creditor.Contact = ask.String("Creditor Contact (if for company: Contact person)", 1, -1)
q.Creditor.Address.Country = ask.String("Creditor Country", 1, -1)
q.Creditor.Address.Place = ask.String("Creditor City", 1, -1)
q.Creditor.Address.ZIPCode = ask.String("Creditor postcode", 1, -1)
q.Creditor.Address.Street = ask.String("Creditor Street", 1, -1)
q.Creditor.Address.Number = ask.String("Creditor house number", 1, -1)
q.Creditor.IBAN = ask.String("Creditor IBAN like: CH93 0076 2011 6238 5295 7", 26, 26)
if ask.Boolean("Do you want to specify a debtor", true) {
q.Debtor = &model.Entity{}
q.Debtor.Name = ask.String("Debtor Name (first & last name or company)", 1, -1)
q.Debtor.Contact = ask.String("Debtor Contact (if for company: Contact person)", 1, -1)
q.Debtor.Address.Country = ask.String("Debtor Country", 1, -1)
q.Debtor.Address.Place = ask.String("Debtor City", 1, -1)
q.Debtor.Address.ZIPCode = ask.String("Debtor postcode", 1, -1)
q.Debtor.Address.Street = ask.String("Debtor Street", 1, -1)
q.Debtor.Address.Number = ask.String("Debtor Number", 1, -1)
q.MailTo = ask.String("Debtor mail address (leave empty to omit)", 0, -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)
file, err := os.Create(arguments[0])
if err != nil {
fmt.Printf("can't open file: %s %v", arguments[0], err)
return
}
defer file.Close()
json.NewEncoder(file).Encode(&q)
}

63
cmd/invoicer/services.go Normal file
View 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.Fatalf("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.Fatalf("could not connect to gotenberg: %v", err)
return invoicer, mailer
}
mailer, err = email.New(cfg.Email)
if err != nil {
log.Fatalf("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
View 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
}

4
go.mod
View File

@@ -1,6 +1,6 @@
module git.schreifuchs.ch/lou-taylor/accounting module git.schreifuchs.ch/lou-taylor/accounting
go 1.24.5 go 1.25.3
require ( require (
code.gitea.io/sdk/gitea v0.21.0 code.gitea.io/sdk/gitea v0.21.0
@@ -10,6 +10,7 @@ require (
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/starwalkn/gotenberg-go-client/v8 v8.11.0 github.com/starwalkn/gotenberg-go-client/v8 v8.11.0
github.com/itzg/go-flagsfiller v1.16.0
) )
require ( require (
@@ -17,6 +18,7 @@ require (
github.com/davidmz/go-pageant v1.0.2 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/go-version v1.7.0 // indirect
github.com/iancoleman/strcase v0.3.0 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/crypto v0.41.0 // indirect golang.org/x/crypto v0.41.0 // indirect

4
go.sum
View File

@@ -14,6 +14,10 @@ github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI=
github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/itzg/go-flagsfiller v1.16.0 h1:YNwjLzFIeFzZpctT2RiN8T5qxiGrCX33bGSwtN6OSAA=
github.com/itzg/go-flagsfiller v1.16.0/go.mod h1:XmllPPi99O7vXTG9wa/Hzmhnkv6BXBF1W57ifbQTVs4=
github.com/jedib0t/go-pretty/v6 v6.6.8 h1:JnnzQeRz2bACBobIaa/r+nqjvws4yEhcmaZ4n1QzsEc= github.com/jedib0t/go-pretty/v6 v6.6.8 h1:JnnzQeRz2bACBobIaa/r+nqjvws4yEhcmaZ4n1QzsEc=
github.com/jedib0t/go-pretty/v6 v6.6.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/jedib0t/go-pretty/v6 v6.6.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA= github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=

View File

@@ -1,709 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Rechnung vom 24.08.2025</title>
<style>
@page {
size: A4;
padding: 2cm;
}
@page:first {
padding: 0;
}
@media print {
.page,
.page-break {
break-after: page;
}
}
.first-page {
margin-left: 2cm;
margin-right: 2cm;
}
body {
font-family: sans-serif;
font-size: 12pt;
color: #333;
}
section {
margin-bottom: 3em;
}
p {
margin-top: 0.25em;
margin-bottom: 0.5em;
}
header {
display: flex;
justify-content: space-between;
margin-bottom: 40px;
h2 {
margin-top: 0;
}
}
h1 {
margin: 0;
font-size: 2em;
}
h2 {
margin-top: 0.5em;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
table,
th,
td {
border: 1px solid #ccc;
}
th,
td {
padding: 0.25em 0.5em;
text-align: left;
}
th {
background-color: #f2f2f2;
}
article {
margin-bottom: 20px;
}
.issue-title {
display: flex;
justify-content: space-between;
align-items: end;
margin-top: 2em;
margin-bottom: 0.5em;
h3,
p {
margin: 0;
}
}
</style>
<style>
.c6ee15365-8f47-4dab-8bee-2a56a7916c57 {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
display: grid;
grid-template-columns: 240px 1px 1fr;
grid-template-rows: 1px 1fr;
.separator-h {
grid-row: 1;
grid-column: 1 / span 3;
border-top: 1px solid black;
svg {
transform: rotate(-90deg);
margin-top: -52px;
}
}
.separator-v {
grid-row: 2;
grid-column: 2;
border-right: 1px solid black;
svg {
margin-left: -32px;
}
}
.receiver {
grid-row: 2;
grid-column: 1;
font-size: 12pt;
padding: 1em;
h2 {
margin: 0;
margin-bottom: 0.5em;
}
h4 {
margin-bottom: 0.1em;
}
}
.payer {
grid-row: 2;
grid-column: 3;
display: grid;
grid-template-columns: 250px 1fr;
grid-template-rows: 2em 1fr;
padding: 1em;
gap: 1em;
width: 100%;
h2 {
grid-row: 1;
grid-column: 1 / 2;
margin: 0;
}
h4 {
margin-bottom: 0.1em;
}
.qr-code {
grid-row: 2;
grid-column: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
.qr-section-img {
position: relative;
width: 100%;
img {
width: 100%;
}
svg {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1;
width: 40px;
}
}
.qr-section-info {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
justify-items: center;
p,
h4 {
margin: 0;
}
}
}
.payment-details {
grid-row: 2;
grid-column: 2;
font-size: 0.9rem;
line-height: 1.4;
}
}
}
</style>
</head>
<body>
<header class="first-page" style="margin-top: 2cm">
<div class="company">
<h2>schreifuchs.ch</h2>
<p>
Kilchbergerweg 1 <br />
Zollikofen <br />
Niklas Breitenstein
</p>
<p></p>
</div>
<div class="invoice-info">
<p>
<strong>Rechnung:</strong> 20 25082 41117 00284 67114 43342 <br />
<strong>Datum:</strong> 24.08.2025 <br />
</p>
</div>
</header>
<section class="client first-page">
<h2>Rechnung an:</h2>
<p>
Lou Taylor <br />
Alpenstrasse 22 <br />
Huttwil <br />
Loana Groux
</p>
</section>
<section class="page p1 first-page">
<article>
<table>
<thead>
<tr>
<th style="min-width: 3.5em">FID</th>
<th>Name</th>
<th>Aufwand</th>
<th style="min-width: 5.5em">Preis</th>
</tr>
</thead>
<tbody>
<tr>
<td style="font-family: monospace">API-6</td>
<td>Multilingual</td>
<td>2.50 h</td>
<td>40.00 CHF</td>
</tr>
<tr>
<td style="font-family: monospace">ACC-7</td>
<td>asdfasdf</td>
<td>1.72 h</td>
<td>27.45 CHF</td>
</tr>
<tr>
<td style="font-family: monospace">ACC-6</td>
<td>ss</td>
<td>1.72 h</td>
<td>27.45 CHF</td>
</tr>
<tr>
<td style="font-family: monospace">ACC-5</td>
<td>a</td>
<td>1.72 h</td>
<td>27.45 CHF</td>
</tr>
<tr>
<td style="font-family: monospace">ACC-4</td>
<td>Hello ```info duration: 1h 43min ``` # adökfjaösldkjflaa ## ASDFADS ### adllglggl</td>
<td>1.72 h</td>
<td>27.45 CHF</td>
</tr>
<tr>
<td style="font-family: monospace">ACC-3</td>
<td>ss</td>
<td>1.72 h</td>
<td>27.45 CHF</td>
</tr>
</tbody>
<tfoot>
<tr>
<th colspan="2" style="text-align: right">Summe:</th>
<td>11.08 h</td>
<td>177.35</td>
</tr>
</tfoot>
</table>
</article>
<article class="c6ee15365-8f47-4dab-8bee-2a56a7916c57">
<div class="separator-h"><?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="64"
height="106.131"
viewBox="0 0 16.933333 28.080496"
version="1.1"
id="svg851"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
sodipodi:docname="scissors.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96">
<defs
id="defs845" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="2.6582031"
inkscape:cx="-135.5963"
inkscape:cy="106.75833"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:window-width="1920"
inkscape:window-height="1080"
inkscape:window-x="86"
inkscape:window-y="117"
inkscape:window-maximized="0" />
<metadata
id="metadata848">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-52.847684,-108.31232)">
<text
y="-57.540855"
x="117.23324"
class="st2 st3"
id="text18"
style="font-size:11.17903996px;font-family:ZapfDingbatsITC;stroke-width:0.26458332"
transform="rotate(90)"></text>
</g>
</svg>
</div>
<div class="receiver">
<div class="payment-details">
<div>
<h2>Empfangsschein</h2>
<h4 style="margin-top: 0">Konto / Zahlbar an</h4>
<p>
CH06 0079 0042 5877 0443 7 <br />
schreifuchs.ch <br />
Kilchbergerweg 1 <br />
Zollikofen
</p>
</div>
<div>
<h4>Referenz</h4>
<p>20 25082 41117 00284 67114 43342</p>
</div>
<div>
<h4>Zahlbar durch</h4>
<p>
Lou Taylor <br />
Alpenstrasse 22 <br />
Huttwil
</p>
</div>
</div>
</div>
<div class="separator-v"><?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="64"
height="106.131"
viewBox="0 0 16.933333 28.080496"
version="1.1"
id="svg851"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
sodipodi:docname="scissors.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96">
<defs
id="defs845" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="2.6582031"
inkscape:cx="-135.5963"
inkscape:cy="106.75833"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:window-width="1920"
inkscape:window-height="1080"
inkscape:window-x="86"
inkscape:window-y="117"
inkscape:window-maximized="0" />
<metadata
id="metadata848">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-52.847684,-108.31232)">
<text
y="-57.540855"
x="117.23324"
class="st2 st3"
id="text18"
style="font-size:11.17903996px;font-family:ZapfDingbatsITC;stroke-width:0.26458332"
transform="rotate(90)"></text>
</g>
</svg>
</div>
<div class="payer">
<h2>Zahlteil</h2>
<div class="qr-code">
<div class="qr-section-img">
<image src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAIAAQMAAADOtka5AAAABlBMVEX///8AAABVwtN&#43;AAAFNElEQVR42uydMZLySgyERREQcgSO4qPB0TgKRyDcwGW9stRqybsEL/2LdrRrzEeimpFbLY15Xpv5Y/H3ZT3l/z/nzcyWl13yo9fV15Ob2e19cXezBV8UQIAEPMzMzpu5P83iw7v72&#43;a1fxSA/Xqb7aTllR8JIAAAC&#43;LuHk/td5eMzYg7Owfydf2xHfDEHdtjcw9SAQQ4AmLd&#43;onHn5Z/5AIGdgB6bRNAgM&#43;AuOLx2BLXeBwb4IMhuS97AgjwCYC9MbfEJN08wu3uL6s7b3yvnvm7uQrw3YCRre9L2v/&#43;41O6L8D3AmZKfstM/P68Ie2KSMSSdom1LfZG&#43;3gJ8NWAlA3ybW9fpTKTssv&#43;1H1KC/yR4/4pgAABiLgrkcnxbleZVMtOe0rljocj/PLXTAABAjCFqCQFIK/aAONObIl72m7xs72kCSBArlupiD8iErnZlWxwzi0xVzv3/CiTrFUAAQoQkViFFKPsjWJLigSIu1Q083uZiLkLIAAAlaRnIaVkp1abVsQmhKjcP0NIQJYmgABZtL297Vwp&#43;dQGssZSO&#43;HFCchIxI8IIEAA4sptEiKBMyU3S9nJsX9mto4L738CCLAZXuBib3QfskG&#43;/0Wxd0Qrqnb42XhGAAEA2JOszJsq7p6VdlnGHWp0WMlAwjuiAAIQcKXrBN/LnZB/9N5orLHUwwII0PYBL7NAmk0Qm7hjWO18a4tcvgie3QUQgIDXWMCoDfTeePL2ymWStfX6ZwIIcK5sPfWDR9VYUj&#43;Ypbk7FKleyR60YQogQNrhIhJpfytnU6rmEKLcX1ZCwvWYdgkgQJZIxtseFrCyyEE2SGmhyy&#43;oFoMkgAApWtK1xCvyd6ZdtIGzjjeTLAEEMBTrKrjuE/JDQ9wKIaoUzfewqGwmgAAljbNGNwy5NpBDdjqsdrBBCSDAybklHnwEJU3VajcNKd7lFxpZBBAgrlmRqxrvevJulesFDB0q&#43;VHUWAQQoGwkXhU56AclZMbaVj1zXMmwbWIhFECA2c8UVzVW2s17AfOtbJgpNpSigMYCAQQAgAlUtzF1q1y9HHarU3VhdiQKIICj6fZgn2z7m9feyA6nqrHY0iNPBBAgCyn9uMPZdGUX7phCQWflMH27AAKc2bLdb3urDR9K&#43;ihzk6Qild&#43;jVVwAAQydtoiyMiv1OCUqCs4u3vbKVRuKAAJUjaWsAfAoZZTZQVGoOyj2JjKzLQEEmB6B1i&#43;NJLgG2lkQqtWhICOAAA0o2ZJzuZC298hAG9HaGf1JAAEIGDMnMDyAhhROyqme3fEMei4FEKB6vnMu4EigOFepxyqzi7f84FaDKgUQwH1avB/LHBXAQd3rYXgJ7rSfTgABMI2EXt058pbFuss6Ry6N2Se/2lAE&#43;HYAJaUx&#43;7ZkS/caFWD2IVv/NcZCgK8G9Hxuvv&#43;x2FKN3cOQYlOswtEBAgjQEyZYWqHa1BOX/9To0OpUPhQBBGCHZS9XxlMpesBbVYZbbLA&#43;zEQAAWowCRTNvydO0NkEH0GZvnlOhQACdESdObJ0zi717h6omQGYxsSzvFwAAQbArJsGGGVcyUar7mg1uA4figBfDzicQVHTTKuxEjthA9hqUGKDAAKMSV09hDJLK9XP3V7dXtJKR6&#43;MTAABTm6HkwN5vgTPUCqLANe29qFwHooAAkwAzzJlPxOHKDPJon0AVbtDJAogQI0D5OxkP7ztfThvuY/HEUCAbR5mMnp2235ysFiyn8l9VF0EEKAHdSO4euQkLp6lXMO8lzGFYihZAvzjgP8CAAD//4aIC6aSJNHQAAAAAElFTkSuQmCC"></image>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 20.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="Ebene_2"
x="0px"
y="0px"
viewBox="0 0 128 128"
xml:space="preserve"
sodipodi:docname="ch-cross.svg"
width="128"
height="128"
inkscape:export-filename="/home/irvin/Work/repos/swiss-qr-invoice/assets/raw/ch-cross.png"
inkscape:export-xdpi="232.22856"
inkscape:export-ydpi="232.22856"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
style="shape-rendering:crispEdges"><metadata
id="metadata17"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
id="defs15" /><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1668"
inkscape:window-height="1021"
id="namedview13"
showgrid="false"
inkscape:document-units="px"
units="px"
inkscape:zoom="2.8284271"
inkscape:cx="-38.018193"
inkscape:cy="61.405051"
inkscape:window-x="0"
inkscape:window-y="31"
inkscape:window-maximized="0"
inkscape:current-layer="Ebene_2"><inkscape:grid
type="xygrid"
id="grid916" /></sodipodi:namedview>
<style
type="text/css"
id="style2">
.st0{fill:#FFFFFF;}
.st1{fill:none;stroke:#FFFFFF;stroke-width:1.4357;stroke-miterlimit:10;}
</style>
<g
id="g4736"
transform="matrix(4.8380969,0,0,4.8380389,-1.9834927e-5,9.0008541e-4)"><polygon
transform="matrix(1.3598365,0,0,1.3598365,-0.23403522,-0.23403522)"
style="fill:#000000;fill-opacity:1;stroke-width:0.73538256"
id="polygon4"
points="0.7,0.7 0.7,1.6 0.7,18.3 0.7,19.1 1.6,19.1 18.3,19.1 19.1,19.1 19.1,18.3 19.1,1.6 19.1,0.7 18.3,0.7 1.6,0.7 " /><rect
style="fill:#ffffff;fill-opacity:1;stroke-width:1"
id="rect6"
height="14.958201"
width="4.4874606"
class="st0"
y="5.2231627"
x="11.034758" /><rect
style="fill:#fffff9;fill-opacity:1;stroke-width:1"
id="rect8"
height="4.4874606"
width="14.958201"
class="st0"
y="10.526525"
x="5.7313952" /><polygon
transform="matrix(1.3598207,0,0,1.3598361,-0.2338788,-0.23403126)"
style="fill:none;stroke:#ffffff;stroke-width:1.05601561;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
id="polygon10"
points="1.6,0.7 0.7,0.7 0.7,1.6 0.7,18.3 0.7,19.1 1.6,19.1 18.3,19.1 19.1,19.1 19.1,18.3 19.1,1.6 19.1,0.7 18.3,0.7 "
class="st1" /></g>
</svg>
</div>
<div class="qr-section-info">
<h4>Währung</h4>
<h4>Betrag</h4>
<p>CHF</p>
<p>177.35</p>
</div>
</div>
<div class="payment-details">
<div>
<h4 style="margin-top: 0">Konto / Zahlbar an</h4>
<p>
CH06 0079 0042 5877 0443 7 <br />
schreifuchs.ch <br />
Kilchbergerweg 1 <br />
Zollikofen
</p>
</div>
<div>
<h4>Referenz</h4>
<p>20 25082 41117 00284 67114 43342</p>
</div>
<div>
<h4>Zahlbar durch</h4>
<p>
Lou Taylor <br />
Alpenstrasse 22 <br />
Huttwil
</p>
</div>
</div>
</div>
</article>
</section>
<section>
<h2 style="margin-top: 0">Details zu den Features</h2>
<article>
<div class="issue-title">
<h3>
<span style="font-family: monospace">API-6</span>: Multilingual
</h3>
<p>Fertiggestellt: 21.08.2025 18:05</p>
</div>
<p>The application must support English and German.</p>
<h5 id="todo-s">TODO&rsquo;s</h5>
<ul>
<li><input type="checkbox" checked disabled> Add multiple languages to Pages</li>
<li><input type="checkbox" checked disabled> Add multiple languages to Events</li>
</ul>
</article>
<article>
<div class="issue-title">
<h3>
<span style="font-family: monospace">ACC-7</span>: asdfasdf
</h3>
<p>Fertiggestellt: 23.08.2025 14:56</p>
</div>
<h4 id="adökfjaösldkjflaa">adökfjaösldkjflaa</h4>
<h5 id="asdfads">ASDFADS</h5>
<h6 id="adllglggl">adllglggl</h6>
</article>
<article>
<div class="issue-title">
<h3>
<span style="font-family: monospace">ACC-6</span>: ss
</h3>
<p>Fertiggestellt: 23.08.2025 14:56</p>
</div>
<h4 id="adökfjaösldkjflaa">adökfjaösldkjflaa</h4>
<h5 id="asdfads">ASDFADS</h5>
<h6 id="adllglggl">adllglggl</h6>
</article>
<article>
<div class="issue-title">
<h3>
<span style="font-family: monospace">ACC-5</span>: a
</h3>
<p>Fertiggestellt: 23.08.2025 14:56</p>
</div>
<h4 id="adökfjaösldkjflaa">adökfjaösldkjflaa</h4>
<h5 id="asdfads">ASDFADS</h5>
<h6 id="adllglggl">adllglggl</h6>
</article>
<article>
<div class="issue-title">
<h3>
<span style="font-family: monospace">ACC-4</span>: Hello ```info duration: 1h 43min ``` # adökfjaösldkjflaa ## ASDFADS ### adllglggl
</h3>
<p>Fertiggestellt: 23.08.2025 14:56</p>
</div>
<h4 id="adökfjaösldkjflaa">adökfjaösldkjflaa</h4>
<h5 id="asdfads">ASDFADS</h5>
<h6 id="adllglggl">adllglggl</h6>
</article>
<article>
<div class="issue-title">
<h3>
<span style="font-family: monospace">ACC-3</span>: ss
</h3>
<p>Fertiggestellt: 23.08.2025 14:55</p>
</div>
<h4 id="adökfjaösldkjflaa">adökfjaösldkjflaa</h4>
<h5 id="asdfads">ASDFADS</h5>
<h6 id="adllglggl">adllglggl</h6>
</article>
</section>
</body>
</html>

BIN
index.pdf

Binary file not shown.

View File

@@ -9,6 +9,7 @@ import (
"time" "time"
"git.schreifuchs.ch/lou-taylor/accounting/internal/email" "git.schreifuchs.ch/lou-taylor/accounting/internal/email"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice"
) )
const bufSize = 1024 * 1024 // 1Mib const bufSize = 1024 * 1024 // 1Mib
@@ -28,7 +29,9 @@ 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) opts := invoice.DefaultOptions
opts.Mindur = time.Duration(req.DurationThreshold)
invoice, report, err := s.invoice.Generate(req.Creditor, &req.Debtor, req.HourlyRate, repos, &opts)
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,11 +68,15 @@ 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)
opts := invoice.DefaultOptions
opts.Mindur = time.Duration(req.Invoice.DurationThreshold)
invoice, report, err := s.invoice.Generate(req.Invoice.Creditor, &req.Invoice.Debtor, req.Invoice.HourlyRate, repos, &opts)
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
} }
// if no time has to be billed aka if bill for 0 CHF // if no time has to be billed aka if bill for 0 CHF
if report.Total() <= time.Duration(0) { if report.Total() <= time.Duration(0) {
s.sendErr(w, http.StatusNotFound, "no suitable issues to be billed") s.sendErr(w, http.StatusNotFound, "no suitable issues to be billed")

View File

@@ -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,12 +65,12 @@ 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, hourlyRate float64, repos []invoice.Repo, opts *invoice.Options) (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, hourlyRate float64, repos []invoice.Repo, opts *invoice.Options) (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, hourlyRate, repos, opts)
} }
return nil, &report.Report{}, nil return nil, &report.Report{}, nil
} }
@@ -94,7 +94,7 @@ 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
}{ }{
@@ -107,10 +107,10 @@ func TestCreateInvoice(t *testing.T) {
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,
@@ -138,7 +138,7 @@ func TestCreateInvoice(t *testing.T) {
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,
@@ -153,9 +153,9 @@ func TestCreateInvoice(t *testing.T) {
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,
@@ -166,7 +166,12 @@ func TestCreateInvoice(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
mockInvoiceService := &MockInvoiceService{ mockInvoiceService := &MockInvoiceService{
GenerateFunc: tt.mockGenerate, GenerateFunc: func(creditor model.Entity, debtor *model.Entity, hourlyRate float64, repos []invoice.Repo, opts *invoice.Options) (io.ReadCloser, *report.Report, error) {
if opts == nil {
opts = &invoice.DefaultOptions
}
return tt.mockGenerate(creditor, debtor, opts.Mindur, hourlyRate, repos)
},
} }
service := Service{invoice: mockInvoiceService, log: dummyLogger} // Pass the dummy logger service := Service{invoice: mockInvoiceService, log: dummyLogger} // Pass the dummy logger
@@ -196,7 +201,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
@@ -215,9 +220,9 @@ func TestSendInvoice(t *testing.T) {
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 {
@@ -240,7 +245,7 @@ func TestSendInvoice(t *testing.T) {
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
@@ -261,9 +266,9 @@ func TestSendInvoice(t *testing.T) {
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 {
@@ -286,8 +291,8 @@ func TestSendInvoice(t *testing.T) {
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,
@@ -298,7 +303,12 @@ func TestSendInvoice(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
mockInvoiceService := &MockInvoiceService{ mockInvoiceService := &MockInvoiceService{
GenerateFunc: tt.mockGenerate, GenerateFunc: func(creditor model.Entity, debtor *model.Entity, hourlyRate float64, repos []invoice.Repo, opts *invoice.Options) (io.ReadCloser, *report.Report, error) {
if opts == nil {
opts = &invoice.DefaultOptions
}
return tt.mockGenerate(creditor, debtor, opts.Mindur, hourlyRate, repos)
},
} }
mockEmailService := &MockEmailService{ mockEmailService := &MockEmailService{
SendFunc: tt.mockSend, SendFunc: tt.mockSend,

View File

@@ -25,14 +25,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 {

View File

@@ -3,7 +3,6 @@ package httpinvoce
import ( import (
"io" "io"
"log/slog" "log/slog"
"time"
"git.schreifuchs.ch/lou-taylor/accounting/internal/email" "git.schreifuchs.ch/lou-taylor/accounting/internal/email"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice" "git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice"
@@ -22,7 +21,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, rate float64, repos []invoice.Repo, opts *invoice.Options) (document io.ReadCloser, report *report.Report, err error)
} }
type mailer interface { type mailer interface {

View File

@@ -8,7 +8,7 @@ import (
"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/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/internal/pdf"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice" "git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice"
) )

93
internal/ask/ask.go Normal file
View File

@@ -0,0 +1,93 @@
package ask
import (
"bufio"
"fmt"
"os"
"strings"
)
func raw[T any](query string, to byte, validate func(string) (v T, msg string)) T {
fmt.Println(query)
reader := bufio.NewReader(os.Stdin)
for {
text, _ := reader.ReadString(to)
v, msg := validate(strings.TrimSpace(text[:len(text)-1]))
if msg == "" {
return v
}
fmt.Println(msg)
}
}
func String(query string, min, max int) string {
return raw(query, '\n', func(s string) (v string, msg string) {
v = s
if min >= 0 && len(v) < min {
msg = fmt.Sprintf("your input is to short. Minimum length is %d", min)
return v, msg
}
if max >= 0 && len(v) > max {
msg = fmt.Sprintf("your input is to long. Maximum length is %d", min)
return v, msg
}
return v, msg
})
}
func StringSlice(query string, min, max, smin, smax int) []string {
return raw(query, ';', func(s string) (v []string, msg string) {
splits := strings.Split(s, ",")
v = make([]string, 0, len(splits))
for _, split := range splits {
v = append(v, strings.TrimSpace(split))
}
if min >= 0 && len(v) < min {
msg = fmt.Sprintf("your input is to short. Minimum length is %d", min)
}
if max >= 0 && len(v) > max {
msg = fmt.Sprintf("your input is to long. Maximum length is %d", max)
}
for i, s := range v {
if smin >= 0 && len(s) < smin {
msg = fmt.Sprintf("your is value at index %d is to short. Minimum length is %d", i, smin)
}
if smax >= 0 && len(s) > smax {
msg = fmt.Sprintf("your is value at index %d is to long. Maximum length is %d", i, smax)
}
}
return v, msg
})
}
func Boolean(query string, def bool) bool {
if def {
query += " (Y/n)"
} else {
query += " (y/N)"
}
return raw(query, '\n', func(s string) (v bool, msg string) {
s = strings.ToLower(s)
if s == "" {
v = def
return v, msg
}
if s == "y" {
v = true
return v, msg
}
if s == "n" {
v = false
return v, msg
}
msg = `your answer must be "y" "n" or enter for default`
return v, msg
})
}

25
internal/ask/duration.go Normal file
View File

@@ -0,0 +1,25 @@
package ask
import (
"fmt"
"time"
)
func Duration(query string, min, max time.Duration) time.Duration {
return raw(query, '\n', func(s string) (v time.Duration, msg string) {
v, err := time.ParseDuration(s)
if err != nil {
msg = "please enter a number"
return v, msg
}
if min >= 0 && v < min {
msg = fmt.Sprintf("your number must be bigger than %d", min)
return v, msg
}
if max >= 0 && v > max {
msg = fmt.Sprintf("your number must be smaller than %d", min)
return v, msg
}
return v, msg
})
}

44
internal/ask/number.go Normal file
View File

@@ -0,0 +1,44 @@
package ask
import (
"fmt"
"strconv"
)
func Int(query string, min, max int) int {
return raw(query, '\n', func(s string) (v int, msg string) {
v, err := strconv.Atoi(s)
if err != nil {
msg = "please enter a number"
return v, msg
}
if min >= 0 && v < min {
msg = fmt.Sprintf("your number must be bigger than %d", min)
return v, msg
}
if max >= 0 && v > max {
msg = fmt.Sprintf("your number must be smaller than %d", max)
return v, msg
}
return v, msg
})
}
func Float64(query string, min, max float64) float64 {
return raw(query, '\n', func(s string) (v float64, msg string) {
v, err := strconv.ParseFloat(s, 64)
if err != nil {
msg = "please enter a number"
return v, msg
}
if min >= 0 && v < min {
msg = fmt.Sprintf("your number must be bigger than %g", min)
return v, msg
}
if max >= 0 && v > max {
msg = fmt.Sprintf("your number must be smaller than %g", max)
return v, msg
}
return v, msg
})
}

View File

@@ -2,7 +2,6 @@ package invoice
import ( import (
"io" "io"
"time"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/issue" "git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/issue"
@@ -10,7 +9,10 @@ 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, rate float64, repos []Repo, config *Options) (document io.ReadCloser, r *report.Report, err error) {
if config == nil {
config = &DefaultOptions
}
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(
@@ -18,9 +20,9 @@ func (s *Service) Generate(creditor, deptor model.Entity, mindur time.Duration,
repo.Repo, repo.Repo,
gitea.ListIssueOption{ gitea.ListIssueOption{
ListOptions: gitea.ListOptions{Page: 0, PageSize: 99999}, ListOptions: gitea.ListOptions{Page: 0, PageSize: 99999},
Since: time.Now().AddDate(0, -1, 0), Since: config.Since,
Before: time.Now(), Before: config.Before,
State: gitea.StateClosed, State: config.IssueState,
}, },
) )
if err != nil { if err != nil {
@@ -30,13 +32,8 @@ func (s *Service) Generate(creditor, deptor model.Entity, mindur time.Duration,
is = append(is, iss...) is = append(is, iss...)
} }
is = filter( is = filter(is, config.IssueFilter)
is, issues := issue.FromGiteas(is, config.Mindur)
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( r = report.New(
issues, issues,
creditor, creditor,
@@ -45,11 +42,11 @@ func (s *Service) Generate(creditor, deptor model.Entity, mindur time.Duration,
) )
html, err := r.ToHTML() html, err := r.ToHTML()
if err != nil { if err != nil {
return return document, r, err
} }
document, err = s.pdf.HtmlToPdf(html) document, err = s.pdf.HtmlToPdf(html)
return return document, r, err
} }
func filter[T any](slice []T, ok func(T) bool) []T { func filter[T any](slice []T, ok func(T) bool) []T {

View File

@@ -32,7 +32,11 @@ func FromGiteas(is []*gitea.Issue, mindur time.Duration) []Issue {
func FromGitea(i gitea.Issue) Issue { func FromGitea(i gitea.Issue) Issue {
issue := Issue{Issue: i} issue := Issue{Issue: i}
issue.Duration, _ = ExtractDuration(i.Body) var err error
issue.Duration, err = ExtractDuration(i.Body)
if err != nil {
issue.Duration = time.Second * 0
}
return issue return issue
} }
@@ -43,7 +47,7 @@ func ExtractDuration(text string) (duration time.Duration, err error) {
block := reBlock.FindStringSubmatch(text) block := reBlock.FindStringSubmatch(text)
if len(block) < 2 { if len(block) < 2 {
err = fmt.Errorf("no info block found") err = fmt.Errorf("no info block found")
return return duration, err
} }
// Now extract the duration line from inside that block // Now extract the duration line from inside that block
@@ -51,7 +55,7 @@ func ExtractDuration(text string) (duration time.Duration, err error) {
match := reDuration.FindStringSubmatch(block[1]) match := reDuration.FindStringSubmatch(block[1])
if len(match) < 2 { if len(match) < 2 {
err = fmt.Errorf("no duration found inside info block") err = fmt.Errorf("no duration found inside info block")
return return duration, err
} }
dur := strings.TrimSpace(match[1]) dur := strings.TrimSpace(match[1])
dur = strings.ReplaceAll(dur, "min", "m") dur = strings.ReplaceAll(dur, "min", "m")

View File

@@ -82,10 +82,14 @@
<article> <article>
<div class="issue-title"> <div class="issue-title">
<h3> <h3>
<span style="font-family: monospace">{{ .Shorthand }}</span>: {{ <a href="{{ .HTMLURL }}" style="font-family: monospace"
.Title }} >{{ .Shorthand }}</a
>: {{ .Title }}
</h3> </h3>
<p>Fertiggestellt: {{ .Closed | time }}</p> <p>
{{ if .Closed }} Fertiggestellt: {{ .Closed | time }} {{else}} not
closed {{ end }}
</p>
</div> </div>
{{ .CleanBody | md | oh 3 }} {{ .CleanBody | md | oh 3 }}
</article> </article>

View File

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

View File

@@ -35,11 +35,11 @@ func (r Report) ToHTML() (html string, err error) {
err = tmpl.Execute(buf, tmpler{r, style}) err = tmpl.Execute(buf, tmpler{r, style})
if err != nil { if err != nil {
return return html, err
} }
html = buf.String() html = buf.String()
return return html, err
} }
func (r Report) applyRate(dur time.Duration) string { func (r Report) applyRate(dur time.Duration) string {

View File

@@ -3,10 +3,29 @@ package invoice
import ( import (
"io" "io"
"log/slog" "log/slog"
"time"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
) )
var DefaultOptions = Options{
Mindur: time.Minute * 15,
Since: time.Now().AddDate(0, -1, 0),
Before: time.Now(),
IssueState: gitea.StateClosed,
IssueFilter: func(i *gitea.Issue) bool {
return i.Closed != nil && i.Closed.After(time.Now().AddDate(0, -1, 0))
},
}
type Options struct {
Mindur time.Duration
Since time.Time
Before time.Time
IssueState gitea.StateType
IssueFilter func(i *gitea.Issue) bool
}
type Repo struct { type Repo struct {
Owner string `json:"owner"` Owner string `json:"owner"`
Repo string `json:"repo"` Repo string `json:"repo"`