This commit is contained in:
2026-09-10 06:37:23 -05:00
parent 3ae5125d0e
commit 763616df26
2 changed files with 93 additions and 55 deletions
+78 -42
View File
@@ -3,16 +3,32 @@ package invoicetxt
import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
var (
// DateLayout is used for formatting time.Time into invoice.txt date format and vice-versa.
DateLayout = time.DateOnly
addonTagRx = regexp.MustCompile(`(^|\s+)([\w-]+):(\S+)`) // Match additional tags date: '... due:2012-12-12 ...'
contextRx = regexp.MustCompile(`(^|\s+)@(\S+)`) // Match contexts: '@Context ...' or '... @Context ...'
projectRx = regexp.MustCompile(`(^|\s+)\+(\S+)`) // Match projects: '+Project...' or '... +Project ...')
)
// An invoice.txt entry looks like:
// <invoiceId> <hours> <rate> [retainerHours] [retainerRate] [retainerRollover] <contexts> <projects> <tags>
// <invoiceId> <date-sent> <hours> <rate> [retainerHours] [retainerRate] [retainerRollover] <contexts> <projects> <tags>
// And once it's paid, it looks like:
// x <invoiceId> <date-sent> <date-paid> <hours> <rate> [retainerHours] [retainerRate] [retainerRollover] <contexts> <projects> <tags>
type Invoice struct {
ID int `json:"id"` // Invoice id
Original string `json:"original"` // original raw invoice text
Paid bool `json:"paid"`
ID string `json:"id"` // Invoice id
DateSent time.Time `json:"sentDate"`
DatePaid time.Time `json:"paidDate"`
Hours float64 `json:"hours"`
Rate float64 `json:"rate"`
RetainerHours float64 `json:"retainerHours,omitempty"`
@@ -31,48 +47,56 @@ func ParseInvoice(text string) (*Invoice, error) {
}
invoice.Original = strings.Trim(text, "\t\n\r")
parts := getParts(invoice.Original)
id, err := strconv.Atoi(parts[0])
if err != nil {
return nil, fmt.Errorf("Error parsing invoice id: %w", err)
if parts[0] == "x" {
invoice.Paid = true
parts = parts[1:]
}
invoice.ID = id
hr, err := strconv.ParseFloat(parts[1], 64)
if err != nil {
return nil, fmt.Errorf("Error parsing hours: %w", err)
invoice.ID, parts = parts[0], parts[1:]
if invoice.DateSent, err = time.Parse(DateLayout, parts[0]); err != nil {
return nil, fmt.Errorf("unable to parse datesent: %w", err)
}
invoice.Hours = hr
rt, err := strconv.ParseFloat(parts[2], 64)
if err != nil {
return nil, fmt.Errorf("Error parsing rate: %w", err)
}
invoice.Rate = rt
lpI := 3
var hitS bool
for i := 3; i < len(parts); i++ {
if partIsSpecial(parts[i]) {
hitS = true
parts = parts[1:]
if invoice.Paid {
if invoice.DatePaid, err = time.Parse(DateLayout, parts[0]); err != nil {
return nil, fmt.Errorf("invoice marked paid, but unable to parse datepaid: %w", err)
}
if !hitS {
wrk, err := strconv.ParseFloat(parts[i], 64)
switch i {
case 3: // retainer hours
if err != nil {
return nil, fmt.Errorf("Error parsing retainer hours: %w", err)
}
invoice.RetainerHours = wrk
case 4: // retainer rate
if err != nil {
return nil, fmt.Errorf("Error parsing retainer rate: %w", err)
}
invoice.RetainerRate = wrk
case 5: // retainer rollover
if err != nil {
return nil, fmt.Errorf("Error parsing retainer rollover: %w", err)
}
invoice.RetainerRollover = wrk
}
continue
parts = parts[1:]
}
hr, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
return nil, fmt.Errorf("error parsing hours (%s): %w", parts[0], err)
}
invoice.Hours, parts = hr, parts[1:]
rt, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
return nil, fmt.Errorf("error parsing rate (%s): %w", parts[0], err)
}
invoice.Rate, parts = rt, parts[1:]
if !partIsSpecial(parts[0]) {
// If we're not in the 'specials' yet, we must have retainer info
retHr, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
return nil, fmt.Errorf("looks like we should have retainer info, but error parsing (%s): %w", parts[0], err)
}
invoice.RetainerHours, parts = retHr, parts[1:]
retRt, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
return nil, fmt.Errorf("error parsing retainer rate (%s): %w", parts[0], err)
}
invoice.RetainerRate, parts = retRt, parts[1:]
retRoll, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
return nil, fmt.Errorf("error parsing retainer rollover (%s): %w", parts[0], err)
}
invoice.RetainerRollover, parts = retRoll, parts[1:]
}
for i := 0; i < len(parts); i++ {
// We're in the 'special' parts
switch parts[i][0] {
case '@': // context
@@ -96,7 +120,7 @@ func partIsSpecial(pt string) bool {
if len(pt) == 0 {
return false
}
return pt[0] == '#' || pt[0] == '@' || strings.Contains(pts, ":")
return pt[0] == '#' || pt[0] == '@' || strings.Contains(pt, ":")
}
// getParts parses the text from 'text' pulling out each part.
@@ -188,6 +212,18 @@ func (invoice Invoice) String() string {
return fmt.Sprintf("%s %s", text, sTxt)
}
func (invoice *Invoice) HasRetainer() bool {
return invoice.RetainerRate > 0
}
func (invoice *Invoice) GetAmount() float64 {
return (invoice.GetOverageHours() * invoice.Rate) + invoice.RetainerRate
}
func (invoice *Invoice) GetOverageHours() float64 {
return invoice.Hours - invoice.RetainerHours - invoice.RetainerRollover
}
func (invoice *Invoice) HasContext(context string) bool {
for _, v := range invoice.Contexts {
if v == context {
@@ -245,7 +281,7 @@ func (invoice *Invoice) GetTagsString() string {
}
sort.Strings(keys)
for _, key := range keys {
text = fmt.Sprintf("%s %s:%s", text, key, invoice.AdditionnalTags[key])
text = fmt.Sprintf("%s %s:%s", text, key, invoice.AdditionalTags[key])
}
return text
}