.gitignore 🔗
@@ -1 +1,4 @@
*.pdf
+*.json
+*.yaml
+*.yml
Rose Thatcher and Maas Lalani created
* Added JSON support for input data
* data from files
* Data flags override imported files again
* Merge conflicts
* Cleaned string parsing
---------
Co-authored-by: Maas Lalani <maas@lalani.dev>
.gitignore | 3 +
README.md | 15 ++++++
go.mod | 8 +-
go.sum | 8 +-
import.go | 73 +++++++++++++++++++++++++++++++++
main.go | 122 +++++++++++++++++++++++++++++++++++--------------------
pdf.go | 6 +-
7 files changed, 178 insertions(+), 57 deletions(-)
@@ -1 +1,4 @@
*.pdf
+*.json
+*.yaml
+*.yml
@@ -24,7 +24,7 @@ open invoice.pdf
<img width="574" alt="Example invoice" src="https://github.com/maaslalani/nap/assets/42545625/13153de2-dfa1-41e6-a18e-4d3a5cea5b74">
-Save repeated information with environment variables:
+Save repeated information with environment variables or in an overridable JSON file:
```bash
export INVOICE_LOGO=/path/to/image.png
@@ -34,6 +34,16 @@ export INVOICE_TAX=0.13
export INVOICE_RATE=25
```
+```json
+{
+ "logo": "/path/to/image.png",
+ "from": "Dream, Inc.",
+ "to": "Imagine, Inc.",
+ "tax": 0.13,
+ "rates": 25
+}
+```
+
Generate new invoice:
```bash
@@ -43,6 +53,9 @@ invoice generate \
--note "For debugging purposes." \
--output duck-invoice.pdf
```
+```bash
+invoice generate --import path/to/data.json --output duck-invoice.pdf
+```
### Custom Templates
@@ -1,11 +1,12 @@
module github.com/maaslalani/invoice
-go 1.19
+go 1.20
require (
github.com/signintech/gopdf v0.18.0
github.com/spf13/cobra v1.7.0
github.com/spf13/viper v1.16.0
+ gopkg.in/yaml.v3 v3.0.1
)
require (
@@ -22,8 +23,7 @@ require (
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.4.2 // indirect
- golang.org/x/sys v0.9.0 // indirect
- golang.org/x/text v0.10.0 // indirect
+ golang.org/x/sys v0.8.0 // indirect
+ golang.org/x/text v0.9.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
- gopkg.in/yaml.v3 v3.0.1 // indirect
)
@@ -316,8 +316,8 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
-golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -327,8 +327,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58=
-golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -0,0 +1,73 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+ "strings"
+
+ "github.com/spf13/pflag"
+ "gopkg.in/yaml.v3"
+)
+
+func importData(path string, structure *Invoice, flags *pflag.FlagSet) error {
+ fileText, err := os.ReadFile(path)
+ if err != nil {
+ return fmt.Errorf("unable to read file")
+ }
+
+ var b []byte
+ var byteBuffer [][]byte
+ flags.Visit(func(f *pflag.Flag) {
+ if f.Value.Type() != "string" {
+ b = []byte(fmt.Sprintf(`{"%s":%s}`, f.Name, f.Value))
+ } else {
+ b = []byte(fmt.Sprintf(`{"%s":"%s"}`, f.Name, f.Value))
+ }
+ byteBuffer = append(byteBuffer, b)
+ })
+
+ if strings.HasSuffix(path, ".json") {
+ err = importJson(fileText, structure)
+ } else if strings.HasSuffix(path, ".yaml") || strings.HasSuffix(path, ".yml") {
+ err = importYaml(fileText, structure)
+
+ } else {
+ return fmt.Errorf("unsupported file type")
+ }
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for _, bytes := range byteBuffer {
+ err = importJson(bytes, structure)
+ if err != nil {
+ log.Fatal(err)
+ }
+ }
+
+ return err
+}
+
+func importJson(text []byte, structure *Invoice) error {
+ if !json.Valid(text) {
+ return fmt.Errorf("json file not correctly formatted")
+ }
+
+ err := json.Unmarshal(text, structure)
+ if err != nil {
+ return fmt.Errorf("json file not correctly formatted")
+ }
+
+ return nil
+}
+
+func importYaml(text []byte, structure *Invoice) error {
+ err := yaml.Unmarshal(text, structure)
+ if err != nil {
+ return fmt.Errorf("yaml file not correctly formatted")
+ }
+
+ return nil
+}
@@ -19,49 +19,73 @@ var interFont []byte
//go:embed Inter-Bold.ttf
var interBoldFont []byte
-var (
- id string
- title string
+type Invoice struct {
+ Id string `json:"id" yaml:"id"`
+ Title string `json:"title" yaml:"title"`
+
+ Logo string `json:"logo" yaml:"logo"`
+ From string `json:"from" yaml:"from"`
+ To string `json:"to" yaml:"to"`
+ Date string `json:"date" yaml:"date"`
+ Due string `json:"due" yaml:"due"`
+
+ Items []string `json:"items" yaml:"items"`
+ Quantities []int `json:"quantities" yaml:"quantities"`
+ Rates []float64 `json:"rates" yaml:"rates"`
- logo string
- from string
- to string
- date string
- due string
+ Tax float64 `json:"tax" yaml:"tax"`
+ Discount float64 `json:"discount" yaml:"discount"`
+ Currency string `json:"currency" yaml:"currency"`
- items []string
- quantities []int
- rates []float64
+ Note string `json:"note" yaml:"note"`
+}
- tax float64
- discount float64
- currency string
+func DefaultInvoice() Invoice {
+ return Invoice{
+ Id: time.Now().Format("20060102"),
+ Title: "INVOICE",
+ Rates: []float64{25},
+ Quantities: []int{2},
+ Items: []string{"Paper Cranes"},
+ From: "Project Folded, Inc.",
+ To: "Untitled Corporation, Inc.",
+ Date: time.Now().Format("Jan 02, 2006"),
+ Due: time.Now().AddDate(0, 0, 14).Format("Jan 02, 2006"),
+ Tax: 0,
+ Discount: 0,
+ Currency: "USD",
+ }
+}
- note string
- output string
+var (
+ importPath string
+ output string
+ file = Invoice{}
+ defaultInvoice = DefaultInvoice()
)
func init() {
viper.AutomaticEnv()
- generateCmd.Flags().StringVar(&id, "id", time.Now().Format("20060102"), "ID")
- generateCmd.Flags().StringVar(&title, "title", "INVOICE", "Title")
+ generateCmd.Flags().StringVar(&importPath, "import", "", "Imported file (.json/.yaml)")
+ generateCmd.Flags().StringVar(&file.Id, "id", time.Now().Format("20060102"), "ID")
+ generateCmd.Flags().StringVar(&file.Title, "title", "INVOICE", "Title")
- generateCmd.Flags().Float64SliceVarP(&rates, "rate", "r", []float64{25}, "Rates")
- generateCmd.Flags().IntSliceVarP(&quantities, "quantity", "q", []int{2}, "Quantities")
- generateCmd.Flags().StringSliceVarP(&items, "item", "i", []string{"Paper Cranes"}, "Items")
+ generateCmd.Flags().Float64SliceVarP(&file.Rates, "rate", "r", defaultInvoice.Rates, "Rates")
+ generateCmd.Flags().IntSliceVarP(&file.Quantities, "quantity", "q", defaultInvoice.Quantities, "Quantities")
+ generateCmd.Flags().StringSliceVarP(&file.Items, "item", "i", defaultInvoice.Items, "Items")
- generateCmd.Flags().StringVarP(&logo, "logo", "l", "", "Company logo")
- generateCmd.Flags().StringVarP(&from, "from", "f", "Project Folded, Inc.", "Issuing company")
- generateCmd.Flags().StringVarP(&to, "to", "t", "Untitled Corporation, Inc.", "Recipient company")
- generateCmd.Flags().StringVar(&date, "date", time.Now().Format("Jan 02, 2006"), "Date")
- generateCmd.Flags().StringVar(&due, "due", time.Now().AddDate(0, 0, 14).Format("Jan 02, 2006"), "Payment due date")
+ generateCmd.Flags().StringVarP(&file.Logo, "logo", "l", defaultInvoice.Logo, "Company logo")
+ generateCmd.Flags().StringVarP(&file.From, "from", "f", defaultInvoice.From, "Issuing company")
+ generateCmd.Flags().StringVarP(&file.To, "to", "t", defaultInvoice.To, "Recipient company")
+ generateCmd.Flags().StringVar(&file.Date, "date", defaultInvoice.Date, "Date")
+ generateCmd.Flags().StringVar(&file.Due, "due", defaultInvoice.Due, "Payment due date")
- generateCmd.Flags().Float64Var(&tax, "tax", 0, "Tax")
- generateCmd.Flags().Float64VarP(&discount, "discount", "d", 0.0, "Discount")
- generateCmd.Flags().StringVarP(¤cy, "currency", "c", "USD", "Currency")
+ generateCmd.Flags().Float64Var(&file.Tax, "tax", defaultInvoice.Tax, "Tax")
+ generateCmd.Flags().Float64VarP(&file.Discount, "discount", "d", defaultInvoice.Discount, "Discount")
+ generateCmd.Flags().StringVarP(&file.Currency, "currency", "c", defaultInvoice.Currency, "Currency")
- generateCmd.Flags().StringVarP(¬e, "note", "n", "", "Note")
+ generateCmd.Flags().StringVarP(&file.Note, "note", "n", "", "Note")
generateCmd.Flags().StringVarP(&output, "output", "o", "invoice.pdf", "Output file (.pdf)")
flag.Parse()
@@ -78,6 +102,14 @@ var generateCmd = &cobra.Command{
Short: "Generate an invoice",
Long: `Generate an invoice`,
RunE: func(cmd *cobra.Command, args []string) error {
+
+ if importPath != "" {
+ err := importData(importPath, &file, cmd.Flags())
+ if err != nil {
+ fmt.Println(err)
+ }
+ }
+
pdf := gopdf.GoPdf{}
pdf.Start(gopdf.Config{
PageSize: *gopdf.PageSizeA4,
@@ -94,33 +126,33 @@ var generateCmd = &cobra.Command{
return err
}
- writeLogo(&pdf, logo, from)
- writeTitle(&pdf, title, id, date)
- writeBillTo(&pdf, to)
+ writeLogo(&pdf, file.Logo, file.From)
+ writeTitle(&pdf, file.Title, file.Id, file.Date)
+ writeBillTo(&pdf, file.To)
writeHeaderRow(&pdf)
subtotal := 0.0
- for i := range items {
+ for i := range file.Items {
q := 1
- if len(quantities) > i {
- q = quantities[i]
+ if len(file.Quantities) > i {
+ q = file.Quantities[i]
}
r := 0.0
- if len(rates) > i {
- r = rates[i]
+ if len(file.Rates) > i {
+ r = file.Rates[i]
}
- writeRow(&pdf, items[i], q, r)
+ writeRow(&pdf, file.Items[i], q, r)
subtotal += float64(q) * r
}
- if note != "" {
- writeNotes(&pdf, note)
+ if file.Note != "" {
+ writeNotes(&pdf, file.Note)
}
- writeTotals(&pdf, subtotal, subtotal*tax, subtotal*discount)
- if due != "" {
- writeDueDate(&pdf, due)
+ writeTotals(&pdf, subtotal, subtotal*file.Tax, subtotal*file.Discount)
+ if file.Due != "" {
+ writeDueDate(&pdf, file.Due)
}
- writeFooter(&pdf, id)
+ writeFooter(&pdf, file.Id)
output = strings.TrimSuffix(output, ".pdf") + ".pdf"
err = pdf.WritePdf(output)
if err != nil {
@@ -125,9 +125,9 @@ func writeRow(pdf *gopdf.GoPdf, item string, quantity int, rate float64) {
pdf.SetX(quantityColumnOffset)
_ = pdf.Cell(nil, strconv.Itoa(quantity))
pdf.SetX(rateColumnOffset)
- _ = pdf.Cell(nil, currencySymbols[currency]+strconv.FormatFloat(rate, 'f', 2, 64))
+ _ = pdf.Cell(nil, currencySymbols[file.Currency]+strconv.FormatFloat(rate, 'f', 2, 64))
pdf.SetX(amountColumnOffset)
- _ = pdf.Cell(nil, currencySymbols[currency]+amount)
+ _ = pdf.Cell(nil, currencySymbols[file.Currency]+amount)
pdf.Br(24)
}
@@ -155,7 +155,7 @@ func writeTotal(pdf *gopdf.GoPdf, label string, total float64) {
if label == totalLabel {
_ = pdf.SetFont("Inter-Bold", "", 11.5)
}
- _ = pdf.Cell(nil, currencySymbols[currency]+strconv.FormatFloat(total, 'f', 2, 64))
+ _ = pdf.Cell(nil, currencySymbols[file.Currency]+strconv.FormatFloat(total, 'f', 2, 64))
pdf.Br(24)
}