Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74d81e4271 | |||
| e5169ee0c4 | |||
| cfbb475a42 | |||
| 8f5ae15ef0 | |||
| 7d160d5f59 |
@@ -18,7 +18,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: "1.24.5"
|
||||
go-version: "1.25.3"
|
||||
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
config.json
|
||||
lou-taylor.json
|
||||
77
AGENTS.md
Normal file
77
AGENTS.md
Normal 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`.
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.24 as build
|
||||
FROM golang:1.25 as build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
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.
|
||||
44
cmd/invoicer/cmd.go
Normal file
44
cmd/invoicer/cmd.go
Normal 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
113
cmd/invoicer/create.go
Normal 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
25
cmd/invoicer/main.go
Normal 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
79
cmd/invoicer/request.go
Normal 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
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.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
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
|
||||
}
|
||||
4
go.mod
4
go.mod
@@ -1,6 +1,6 @@
|
||||
module git.schreifuchs.ch/lou-taylor/accounting
|
||||
|
||||
go 1.24.5
|
||||
go 1.25.3
|
||||
|
||||
require (
|
||||
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/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/starwalkn/gotenberg-go-client/v8 v8.11.0
|
||||
github.com/itzg/go-flagsfiller v1.16.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -17,6 +18,7 @@ require (
|
||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||
github.com/go-fed/httpsig v1.1.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/rivo/uniseg v0.4.7 // indirect
|
||||
golang.org/x/crypto v0.41.0 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@@ -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/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||
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/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
|
||||
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
|
||||
|
||||
709
index.html
709
index.html
@@ -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+AAAFNElEQVR42uydMZLySgyERREQcgSO4qPB0TgKRyDcwGW9stRqybsEL/2LdrRrzEeimpFbLY15Xpv5Y/H3ZT3l/z/nzcyWl13yo9fV15Ob2e19cXezBV8UQIAEPMzMzpu5P83iw7v72+a1fxSA/Xqb7aTllR8JIAAAC+LuHk/td5eMzYg7Owfydf2xHfDEHdtjcw9SAQQ4AmLd+onHn5Z/5AIGdgB6bRNAgM+AuOLx2BLXeBwb4IMhuS97AgjwCYC9MbfEJN08wu3uL6s7b3yvnvm7uQrw3YCRre9L2v/+41O6L8D3AmZKfstM/P68Ie2KSMSSdom1LfZG+3gJ8NWAlA3ybW9fpTKTssv+1H1KC/yR4/4pgAABiLgrkcnxbleZVMtOe0rljocj/PLXTAABAjCFqCQFIK/aAONObIl72m7xs72kCSBArlupiD8iErnZlWxwzi0xVzv3/CiTrFUAAQoQkViFFKPsjWJLigSIu1Q083uZiLkLIAAAlaRnIaVkp1abVsQmhKjcP0NIQJYmgABZtL297Vwp+dQGssZSO+HFCchIxI8IIEAA4sptEiKBMyU3S9nJsX9mto4L738CCLAZXuBib3QfskG+/0Wxd0Qrqnb42XhGAAEA2JOszJsq7p6VdlnGHWp0WMlAwjuiAAIQcKXrBN/LnZB/9N5orLHUwwII0PYBL7NAmk0Qm7hjWO18a4tcvgie3QUQgIDXWMCoDfTeePL2ymWStfX6ZwIIcK5sPfWDR9VYUj+Ypbk7FKleyR60YQogQNrhIhJpfytnU6rmEKLcX1ZCwvWYdgkgQJZIxtseFrCyyEE2SGmhyy+oFoMkgAApWtK1xCvyd6ZdtIGzjjeTLAEEMBTrKrjuE/JDQ9wKIaoUzfewqGwmgAAljbNGNwy5NpBDdjqsdrBBCSDAybklHnwEJU3VajcNKd7lFxpZBBAgrlmRqxrvevJulesFDB0q+VHUWAQQoGwkXhU56AclZMbaVj1zXMmwbWIhFECA2c8UVzVW2s17AfOtbJgpNpSigMYCAQQAgAlUtzF1q1y9HHarU3VhdiQKIICj6fZgn2z7m9feyA6nqrHY0iNPBBAgCyn9uMPZdGUX7phCQWflMH27AAKc2bLdb3urDR9K+ihzk6Qild+jVVwAAQydtoiyMiv1OCUqCs4u3vbKVRuKAAJUjaWsAfAoZZTZQVGoOyj2JjKzLQEEmB6B1i+NJLgG2lkQqtWhICOAAA0o2ZJzuZC298hAG9HaGf1JAAEIGDMnMDyAhhROyqme3fEMei4FEKB6vnMu4EigOFepxyqzi7f84FaDKgUQwH1avB/LHBXAQd3rYXgJ7rSfTgABMI2EXt058pbFuss6Ry6N2Se/2lAE+HYAJaUx+7ZkS/caFWD2IVv/NcZCgK8G9Hxuvv+x2FKN3cOQYlOswtEBAgjQEyZYWqHa1BOX/9To0OpUPhQBBGCHZS9XxlMpesBbVYZbbLA+zEQAAWowCRTNvydO0NkEH0GZvnlOhQACdESdObJ0zi717h6omQGYxsSzvFwAAQbArJsGGGVcyUar7mg1uA4figBfDzicQVHTTKuxEjthA9hqUGKDAAKMSV09hDJLK9XP3V7dXtJKR6+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’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>
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.schreifuchs.ch/lou-taylor/accounting/internal/email"
|
||||
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice"
|
||||
)
|
||||
|
||||
const bufSize = 1024 * 1024 // 1Mib
|
||||
@@ -28,7 +29,9 @@ 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)
|
||||
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 {
|
||||
s.sendErr(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
@@ -65,11 +68,15 @@ 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)
|
||||
|
||||
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 {
|
||||
s.sendErr(w, http.StatusInternalServerError, "error while processing invoice:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// if no time has to be billed aka if bill for 0 CHF
|
||||
if report.Total() <= time.Duration(0) {
|
||||
s.sendErr(w, http.StatusNotFound, "no suitable issues to be billed")
|
||||
|
||||
@@ -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,12 +65,12 @@ 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, 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 {
|
||||
return m.GenerateFunc(creditor, debtor, durationThreshold, hourlyRate, repos)
|
||||
return m.GenerateFunc(creditor, debtor, hourlyRate, repos, opts)
|
||||
}
|
||||
return nil, &report.Report{}, nil
|
||||
}
|
||||
@@ -94,7 +94,7 @@ 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
|
||||
}{
|
||||
@@ -107,10 +107,10 @@ func TestCreateInvoice(t *testing.T) {
|
||||
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,
|
||||
@@ -138,7 +138,7 @@ func TestCreateInvoice(t *testing.T) {
|
||||
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,
|
||||
@@ -153,9 +153,9 @@ func TestCreateInvoice(t *testing.T) {
|
||||
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,
|
||||
@@ -166,7 +166,12 @@ func TestCreateInvoice(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
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
|
||||
|
||||
@@ -196,7 +201,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
|
||||
@@ -215,9 +220,9 @@ func TestSendInvoice(t *testing.T) {
|
||||
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 {
|
||||
@@ -240,7 +245,7 @@ func TestSendInvoice(t *testing.T) {
|
||||
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
|
||||
@@ -261,9 +266,9 @@ func TestSendInvoice(t *testing.T) {
|
||||
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 {
|
||||
@@ -286,8 +291,8 @@ func TestSendInvoice(t *testing.T) {
|
||||
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,
|
||||
@@ -298,7 +303,12 @@ func TestSendInvoice(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
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{
|
||||
SendFunc: tt.mockSend,
|
||||
|
||||
@@ -25,14 +25,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 {
|
||||
|
||||
@@ -3,7 +3,6 @@ package httpinvoce
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.schreifuchs.ch/lou-taylor/accounting/internal/email"
|
||||
"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 {
|
||||
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 {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"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/pdf"
|
||||
"git.schreifuchs.ch/lou-taylor/accounting/internal/pdf"
|
||||
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice"
|
||||
)
|
||||
|
||||
|
||||
93
internal/ask/ask.go
Normal file
93
internal/ask/ask.go
Normal 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
25
internal/ask/duration.go
Normal 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
44
internal/ask/number.go
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package invoice
|
||||
|
||||
import (
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"git.schreifuchs.ch/lou-taylor/accounting/pkg/invoice/issue"
|
||||
@@ -10,7 +9,10 @@ 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, rate float64, repos []Repo, config *Options) (document io.ReadCloser, r *report.Report, err error) {
|
||||
if config == nil {
|
||||
config = &DefaultOptions
|
||||
}
|
||||
var is []*gitea.Issue
|
||||
for _, repo := range repos {
|
||||
iss, _, err := s.gitea.ListRepoIssues(
|
||||
@@ -18,9 +20,9 @@ func (s *Service) Generate(creditor, deptor model.Entity, mindur time.Duration,
|
||||
repo.Repo,
|
||||
gitea.ListIssueOption{
|
||||
ListOptions: gitea.ListOptions{Page: 0, PageSize: 99999},
|
||||
Since: time.Now().AddDate(0, -1, 0),
|
||||
Before: time.Now(),
|
||||
State: gitea.StateClosed,
|
||||
Since: config.Since,
|
||||
Before: config.Before,
|
||||
State: config.IssueState,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -30,13 +32,8 @@ func (s *Service) Generate(creditor, deptor model.Entity, mindur time.Duration,
|
||||
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)
|
||||
is = filter(is, config.IssueFilter)
|
||||
issues := issue.FromGiteas(is, config.Mindur)
|
||||
r = report.New(
|
||||
issues,
|
||||
creditor,
|
||||
@@ -45,11 +42,11 @@ func (s *Service) Generate(creditor, deptor model.Entity, mindur time.Duration,
|
||||
)
|
||||
html, err := r.ToHTML()
|
||||
if err != nil {
|
||||
return
|
||||
return document, r, err
|
||||
}
|
||||
|
||||
document, err = s.pdf.HtmlToPdf(html)
|
||||
return
|
||||
return document, r, err
|
||||
}
|
||||
|
||||
func filter[T any](slice []T, ok func(T) bool) []T {
|
||||
|
||||
@@ -32,7 +32,11 @@ func FromGiteas(is []*gitea.Issue, mindur time.Duration) []Issue {
|
||||
func FromGitea(i gitea.Issue) Issue {
|
||||
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
|
||||
}
|
||||
@@ -43,7 +47,7 @@ func ExtractDuration(text string) (duration time.Duration, err error) {
|
||||
block := reBlock.FindStringSubmatch(text)
|
||||
if len(block) < 2 {
|
||||
err = fmt.Errorf("no info block found")
|
||||
return
|
||||
return duration, err
|
||||
}
|
||||
|
||||
// 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])
|
||||
if len(match) < 2 {
|
||||
err = fmt.Errorf("no duration found inside info block")
|
||||
return
|
||||
return duration, err
|
||||
}
|
||||
dur := strings.TrimSpace(match[1])
|
||||
dur = strings.ReplaceAll(dur, "min", "m")
|
||||
|
||||
@@ -82,10 +82,14 @@
|
||||
<article>
|
||||
<div class="issue-title">
|
||||
<h3>
|
||||
<span style="font-family: monospace">{{ .Shorthand }}</span>: {{
|
||||
.Title }}
|
||||
<a href="{{ .HTMLURL }}" style="font-family: monospace"
|
||||
>{{ .Shorthand }}</a
|
||||
>: {{ .Title }}
|
||||
</h3>
|
||||
<p>Fertiggestellt: {{ .Closed | time }}</p>
|
||||
<p>
|
||||
{{ if .Closed }} Fertiggestellt: {{ .Closed | time }} {{else}} not
|
||||
closed {{ end }}
|
||||
</p>
|
||||
</div>
|
||||
{{ .CleanBody | md | oh 3 }}
|
||||
</article>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -35,11 +35,11 @@ func (r Report) ToHTML() (html string, err error) {
|
||||
|
||||
err = tmpl.Execute(buf, tmpler{r, style})
|
||||
if err != nil {
|
||||
return
|
||||
return html, err
|
||||
}
|
||||
|
||||
html = buf.String()
|
||||
return
|
||||
return html, err
|
||||
}
|
||||
|
||||
func (r Report) applyRate(dur time.Duration) string {
|
||||
|
||||
@@ -3,10 +3,29 @@ package invoice
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"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 {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
|
||||
Reference in New Issue
Block a user