├── go.mod ├── README.md └── main.go /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/phillip-england/imk 2 | 3 | go 1.23.3 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # imk (Invoice Maker) 2 | Invoice maker is a tool I use to generate invoices for work. I manage a bunch of digital receipts, and wanted a way to streamline generating invoices. 3 | 4 | I upload all my receipts to Google Drive, being sure to follow naming conventions, and then when I am ready to generate an invoice, I download the directory from Google Drive and run imk on it. 5 | 6 | ## Installation 7 | ```bash 8 | go install github.com/phillip-england/imk@latest 9 | ``` 10 | 11 | ## Usage 12 | ```bash 13 | imk [TARGET_DIR] [OUT.txt FILE] [INVOICE NAME] 14 | # imk ./pdfs out.txt "UTICA #2 EOM" 15 | ``` 16 | 17 | ## Naming Conventions 18 | imk expects your files to be named a certain way. Here is the format: 19 | ```bash 20 | [DATE]-[VENDOR]-[COST]-[DESCRIPTION]-[CATEGORY]-[BUSINESS].pdf 21 | # 040325-target-32.89-sharpies-office-utica.pdf 22 | ``` 23 | 24 | Thanks much 😀 25 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | //========================= 4 | // IMPORTS 5 | //========================= 6 | 7 | import ( 8 | "fmt" 9 | "io/fs" 10 | "math" 11 | "os" 12 | "path/filepath" 13 | "strconv" 14 | "strings" 15 | ) 16 | 17 | //========================= 18 | // MAIN 19 | //========================= 20 | 21 | func main() { 22 | dir, err := GetArg(1) 23 | if err != nil { 24 | panic(err) 25 | } 26 | 27 | if !DirExists(dir) { 28 | panic(fmt.Errorf("%s directory does not exist", dir)) 29 | } 30 | 31 | mp := make(map[string]DollarAmount) 32 | receipts := []ReceiptPdf{} 33 | 34 | err = filepath.Walk(dir, func(path string, info fs.FileInfo, err error) error { 35 | if info.IsDir() { 36 | return nil 37 | } 38 | if filepath.Ext(path) != ".pdf" { 39 | return nil 40 | } 41 | fileName := strings.TrimSuffix(filepath.Base(path), ".pdf") 42 | r, err := NewReceiptPdfFromFileName(fileName) 43 | if err != nil { 44 | return err 45 | } 46 | cost, err := r.GetCost() 47 | if err != nil { 48 | return err 49 | } 50 | category := r.GetCategory() 51 | if !IsKeyInMap(mp, category) { 52 | mp[category] = cost 53 | } else { 54 | mp[category] = mp[category].Add(cost) 55 | } 56 | receipts = append(receipts, r) 57 | return nil 58 | }) 59 | if err != nil { 60 | panic(err) 61 | } 62 | 63 | outPath, err := GetArg(2) 64 | if err != nil { 65 | panic(err) 66 | } 67 | 68 | invoiceName, err := GetArg(3) 69 | if err != nil { 70 | panic(err) 71 | } 72 | 73 | receiptMap := make(map[string][]ReceiptPdf) 74 | for _, r := range receipts { 75 | category := r.GetCategory() 76 | receiptMap[category] = append(receiptMap[category], r) 77 | } 78 | 79 | var output strings.Builder 80 | 81 | output.WriteString(strings.ToUpper(invoiceName) + "\n") 82 | 83 | grandTotal := DollarAmount{} 84 | for _, amt := range mp { 85 | grandTotal = grandTotal.Add(amt) 86 | } 87 | output.WriteString("TOTAL: " + grandTotal.String() + "\n\n") 88 | 89 | for category, total := range mp { 90 | output.WriteString(fmt.Sprintf("%s %s:\n", strings.ToUpper(category), total.String())) 91 | for _, receipt := range receiptMap[category] { 92 | output.WriteString("\t" + receipt.String() + "\n") 93 | } 94 | output.WriteString("\n\n") 95 | } 96 | 97 | err = os.WriteFile(outPath, []byte(output.String()), 0644) 98 | if err != nil { 99 | panic(fmt.Errorf("failed to write output file: %w", err)) 100 | } 101 | 102 | fmt.Println("Wrote output to", outPath) 103 | } 104 | 105 | //========================= 106 | // RECEIPT PDF 107 | //========================= 108 | 109 | type ReceiptPdf struct { 110 | FileName string 111 | Parts []string 112 | } 113 | 114 | func NewReceiptPdfFromFileName(fileName string) (ReceiptPdf, error) { 115 | r := &ReceiptPdf{FileName: fileName} 116 | r.Parts = strings.Split(fileName, "-") 117 | if len(r.Parts) != 6 { 118 | return *r, fmt.Errorf("each PdfReceipt requires 6 filename parts split by '-', like:\n041525-target-29.87-desc-category-store.pdf") 119 | } 120 | return *r, nil 121 | } 122 | 123 | func (r *ReceiptPdf) GetCost() (DollarAmount, error) { 124 | return NewDollarAmountFromString(r.Parts[2]) 125 | } 126 | 127 | func (r *ReceiptPdf) GetCategory() string { 128 | return r.Parts[4] 129 | } 130 | 131 | func (r *ReceiptPdf) String() string { 132 | return fmt.Sprintf("[%s] [%s] [%s] => %s", r.Parts[0], r.Parts[1], r.Parts[3], r.Parts[2]) 133 | } 134 | 135 | //========================= 136 | // UTILS 137 | //========================= 138 | 139 | func GetArg(i int) (string, error) { 140 | if len(os.Args) > i { 141 | return os.Args[i], nil 142 | } 143 | return "", fmt.Errorf("arg of index '%d' does not exist", i) 144 | } 145 | 146 | func DirExists(path string) bool { 147 | info, err := os.Stat(path) 148 | if os.IsNotExist(err) { 149 | return false 150 | } 151 | return info.IsDir() 152 | } 153 | 154 | func ParseToFloat(s string) (float64, error) { 155 | s = strings.TrimSpace(s) 156 | return strconv.ParseFloat(s, 64) 157 | } 158 | 159 | func IsKeyInMap[K comparable, V any](m map[K]V, key K) bool { 160 | _, ok := m[key] 161 | return ok 162 | } 163 | 164 | func RoundUpToTwoDecimals(f float64) float64 { 165 | return math.Ceil(f*100) / 100 166 | } 167 | 168 | //========================= 169 | // DOLLAR AMOUNT 170 | //========================= 171 | 172 | type DollarAmount struct { 173 | Dollars int 174 | Cents int 175 | } 176 | 177 | func NewDollarAmountFromString(s string) (DollarAmount, error) { 178 | s = strings.TrimSpace(s) 179 | f, err := strconv.ParseFloat(s, 64) 180 | if err != nil { 181 | return DollarAmount{}, err 182 | } 183 | totalCents := int(math.Round(f * 100)) 184 | return DollarAmount{ 185 | Dollars: totalCents / 100, 186 | Cents: totalCents % 100, 187 | }, nil 188 | } 189 | 190 | func (d DollarAmount) Add(other DollarAmount) DollarAmount { 191 | totalCents := d.TotalCents() + other.TotalCents() 192 | return DollarAmount{ 193 | Dollars: totalCents / 100, 194 | Cents: totalCents % 100, 195 | } 196 | } 197 | 198 | func (d DollarAmount) TotalCents() int { 199 | return d.Dollars*100 + d.Cents 200 | } 201 | 202 | func (d DollarAmount) ToFloat() float64 { 203 | return float64(d.TotalCents()) / 100 204 | } 205 | 206 | func (d DollarAmount) String() string { 207 | return fmt.Sprintf("$%d.%02d", d.Dollars, d.Cents) 208 | } 209 | --------------------------------------------------------------------------------