288 lines
8.1 KiB
Go
288 lines
8.1 KiB
Go
// Package invoicetxt implements structs ad routines for working with invoice.txt files
|
|
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> <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 {
|
|
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"`
|
|
RetainerRate float64 `json:"retainerRate,omitempty"`
|
|
RetainerRollover float64 `json:"retainerRollover,omitempty"`
|
|
Contexts []string `json:"contexts"`
|
|
Projects []string `json:"projects"`
|
|
AdditionalTags map[string]string `json:"additionalTags"`
|
|
}
|
|
|
|
// ParseInvoice parses the input test string into an Invoice struct
|
|
func ParseInvoice(text string) (*Invoice, error) {
|
|
var err error
|
|
invoice := Invoice{
|
|
AdditionalTags: make(map[string]string),
|
|
}
|
|
invoice.Original = strings.Trim(text, "\t\n\r")
|
|
parts := getParts(invoice.Original)
|
|
if parts[0] == "x" {
|
|
invoice.Paid = true
|
|
parts = parts[1:]
|
|
}
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
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
|
|
invoice.Contexts = append(invoice.Contexts, parts[i][1:])
|
|
case '+': // project
|
|
invoice.Projects = append(invoice.Projects, parts[i][1:])
|
|
default:
|
|
if strings.Contains(parts[i], ":") {
|
|
// Additional Tags
|
|
tagPts := strings.Split(parts[i], ":")
|
|
if tagPts[0] != "" && tagPts[1] != "" {
|
|
invoice.AdditionalTags[tagPts[0]] = tagPts[1]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return &invoice, err
|
|
}
|
|
|
|
func partIsSpecial(pt string) bool {
|
|
if len(pt) == 0 {
|
|
return false
|
|
}
|
|
return pt[0] == '#' || pt[0] == '@' || strings.Contains(pt, ":")
|
|
}
|
|
|
|
// getParts parses the text from 'text' pulling out each part.
|
|
// Generally it just splits everything on spaces, except if we're in a double-quote
|
|
func getParts(text string) []string {
|
|
var ret []string
|
|
var wrk string
|
|
quoteStart := -1
|
|
for i := range text {
|
|
if quoteStart >= 0 {
|
|
if text[i] == '"' {
|
|
quoteStart = -1
|
|
ret = append(ret, wrk)
|
|
} else {
|
|
wrk = wrk + string(text[i])
|
|
}
|
|
} else {
|
|
switch text[i] {
|
|
case '"':
|
|
quoteStart = i
|
|
case ' ':
|
|
if len(wrk) > 0 {
|
|
ret = append(ret, wrk)
|
|
}
|
|
wrk = ""
|
|
default:
|
|
wrk = wrk + string(text[i])
|
|
}
|
|
}
|
|
}
|
|
if len(wrk) > 0 {
|
|
ret = append(ret, wrk)
|
|
}
|
|
return ret
|
|
}
|
|
|
|
// String returns a complete invoice string in invoice.txt format.
|
|
//
|
|
// Contexts, ProjectTags, and Additional Tags are alphabetically sorted,
|
|
// and appended at the end in the following order:
|
|
// Contexts, ProjectTags, Tags
|
|
//
|
|
// For example:
|
|
// 315 32.5 100 25 90 @personal +invoicetxt rate:100 retainer:25 retainer_rate:95 retainer_rollover:5
|
|
func (invoice Invoice) String() string {
|
|
text := fmt.Sprintf("%d %.2f %.2f", invoice.ID, invoice.Hours, invoice.Rate)
|
|
var sTxt string
|
|
if len(invoice.Contexts) > 0 {
|
|
sort.Strings(invoice.Contexts)
|
|
for _, c := range invoice.Contexts {
|
|
sTxt = fmt.Sprintf("%s @%s", c)
|
|
}
|
|
}
|
|
if len(invoice.Projects) > 0 {
|
|
sort.Strings(invoice.Projects)
|
|
for _, p := range invoice.Projects {
|
|
sTxt = fmt.Sprintf("%s +%s", p)
|
|
}
|
|
}
|
|
if len(invoice.AdditionalTags) > 0 {
|
|
keys := make([]string, 0, len(invoice.AdditionalTags))
|
|
for key := range invoice.AdditionalTags {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
v := invoice.AdditionalTags[key]
|
|
if strings.Contains(key, " ") {
|
|
key = fmt.Sprintf("\"%s\"", key)
|
|
}
|
|
if strings.Contains(v, " ") {
|
|
v = fmt.Sprintf("\"%s\"", v)
|
|
}
|
|
sTxt = fmt.Sprintf("%s %s:%s", sTxt, key, v)
|
|
}
|
|
}
|
|
if invoice.RetainerHours == 0 {
|
|
return fmt.Sprintf("%s %s", text, sTxt)
|
|
}
|
|
text = fmt.Sprintf("%s %.2f", text, invoice.RetainerHours)
|
|
if invoice.RetainerRate == 0 {
|
|
return fmt.Sprintf("%s %s", text, sTxt)
|
|
}
|
|
text = fmt.Sprintf("%s %.2f", text, invoice.RetainerRate)
|
|
if invoice.RetainerRollover == 0 {
|
|
return fmt.Sprintf("%s %s", text, sTxt)
|
|
}
|
|
text = fmt.Sprintf("%s %.2f", text, invoice.RetainerRollover)
|
|
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 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (invoice *Invoice) GetContextsString() string {
|
|
var text string
|
|
sort.Strings(invoice.Contexts)
|
|
for _, context := range invoice.Contexts {
|
|
text = fmt.Sprintf("%s %s", context)
|
|
}
|
|
return text
|
|
}
|
|
|
|
func (invoice *Invoice) HasProject(project string) bool {
|
|
for _, v := range invoice.Projects {
|
|
if v == project {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (invoice *Invoice) GetProjectsString() string {
|
|
var text string
|
|
sort.Strings(invoice.Projects)
|
|
for _, project := range invoice.Projects {
|
|
text = fmt.Sprintf("%s %s", project)
|
|
}
|
|
return text
|
|
}
|
|
|
|
func (invoice *Invoice) SetTag(name, val string) {
|
|
invoice.AdditionalTags[name] = val
|
|
}
|
|
|
|
func (invoice *Invoice) HasTag(name string) bool {
|
|
_, ok := invoice.AdditionalTags[name]
|
|
return ok
|
|
}
|
|
|
|
func (invoice *Invoice) GetTag(name string) string {
|
|
return invoice.AdditionalTags[name]
|
|
}
|
|
|
|
func (invoice *Invoice) GetTagsString() string {
|
|
var text string
|
|
keys := make([]string, 0, len(invoice.AdditionalTags))
|
|
for key := range invoice.AdditionalTags {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
text = fmt.Sprintf("%s %s:%s", text, key, invoice.AdditionalTags[key])
|
|
}
|
|
return text
|
|
}
|