diff options
-rw-r--r-- | go.mod | 7 | ||||
-rw-r--r-- | go.sum | 26 | ||||
-rw-r--r-- | internal/commands/report.go | 207 | ||||
-rw-r--r-- | internal/commands/root.go | 1 | ||||
-rw-r--r-- | internal/commands/set.go | 333 | ||||
-rw-r--r-- | internal/database/queries.sql | 101 | ||||
-rw-r--r-- | internal/database/schema.sql | 20 | ||||
-rw-r--r-- | internal/queries/models.go | 18 | ||||
-rw-r--r-- | internal/queries/queries.sql.go | 365 | ||||
-rw-r--r-- | internal/reports/daterange.go | 108 | ||||
-rw-r--r-- | internal/reports/invoice.go | 239 | ||||
-rw-r--r-- | internal/reports/pdf.go | 131 | ||||
-rw-r--r-- | internal/reports/pdf_test.go | 122 | ||||
-rw-r--r-- | internal/reports/testdata/invoice_test_data.json | 27 | ||||
-rw-r--r-- | templates/embeds.go | 7 | ||||
-rw-r--r-- | templates/invoice.typ | 148 |
16 files changed, 1854 insertions, 6 deletions
@@ -3,18 +3,21 @@ module punchcard go 1.24.4 require ( + github.com/spf13/cobra v1.9.1 + modernc.org/sqlite v1.38.2 +) + +require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/spf13/cobra v1.9.1 // indirect github.com/spf13/pflag v1.0.6 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/sys v0.34.0 // indirect modernc.org/libc v1.66.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.38.2 // indirect ) @@ -1,6 +1,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -18,16 +20,40 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= +modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= +modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/commands/report.go b/internal/commands/report.go index eaa1b49..25e0483 100644 --- a/internal/commands/report.go +++ b/internal/commands/report.go @@ -1,7 +1,15 @@ package commands import ( + "context" + "database/sql" "fmt" + "path/filepath" + + punchctx "punchcard/internal/context" + "punchcard/internal/database" + "punchcard/internal/queries" + "punchcard/internal/reports" "github.com/spf13/cobra" ) @@ -20,15 +28,206 @@ func NewReportCmd() *cobra.Command { } func NewReportInvoiceCmd() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "invoice", Short: "Generate a PDF invoice", - Long: "Generate a PDF invoice from tracked time.", + Long: `Generate a PDF invoice from tracked time. Either --client or --project must be specified. + +Examples: + # Generate invoice for last month (default) + punch report invoice -c "Acme Corp" + + # Generate invoice for last week + punch report invoice -c "Acme Corp" -d "last week" + + # Generate invoice for custom date range + punch report invoice -c "Acme Corp" -d "2025-06-01 to 2025-06-30" + + # Generate invoice for specific project + punch report invoice -p "Website Redesign" -d "2025-01-01 to 2025-01-31"`, RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Invoice generation (placeholder)") - return nil + return runInvoiceCommand(cmd, args) }, } + + cmd.Flags().StringP("client", "c", "", "Generate invoice for specific client") + cmd.Flags().StringP("project", "p", "", "Generate invoice for specific project") + cmd.Flags().StringP("dates", "d", "last month", "Date range ('last week', 'last month', or 'YYYY-MM-DD to YYYY-MM-DD')") + cmd.Flags().StringP("output", "o", "", "Output file path (default: auto-generated filename)") + + return cmd +} + +func runInvoiceCommand(cmd *cobra.Command, args []string) error { + // Get flag values + clientName, _ := cmd.Flags().GetString("client") + projectName, _ := cmd.Flags().GetString("project") + dateStr, _ := cmd.Flags().GetString("dates") + outputPath, _ := cmd.Flags().GetString("output") + + // Validate flags + if clientName == "" && projectName == "" { + return fmt.Errorf("either --client or --project must be specified") + } + if clientName != "" && projectName != "" { + return fmt.Errorf("--client and --project are mutually exclusive") + } + + // Parse date range + dateRange, err := reports.ParseDateRange(dateStr) + if err != nil { + return fmt.Errorf("invalid date range: %w", err) + } + + // Get database connection + q := punchctx.GetDB(cmd.Context()) + if q == nil { + var err error + q, err = database.GetDB() + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + } + + // Generate invoice based on client or project + var invoiceData *reports.InvoiceData + if clientName != "" { + invoiceData, err = generateClientInvoice(q, clientName, dateRange) + } else { + invoiceData, err = generateProjectInvoice(q, projectName, dateRange) + } + if err != nil { + return err + } + + // Generate output filename if not specified + if outputPath == "" { + outputPath = reports.GenerateDefaultInvoiceFilename(invoiceData.ClientName, invoiceData.ProjectName, dateRange) + } + + // Convert to absolute path + outputPath, err = filepath.Abs(outputPath) + if err != nil { + return fmt.Errorf("failed to resolve output path: %w", err) + } + + // Generate PDF + err = reports.GenerateInvoicePDF(invoiceData, outputPath) + if err != nil { + return fmt.Errorf("failed to generate invoice PDF: %w", err) + } + + if _, err := q.CreateInvoice(cmd.Context(), queries.CreateInvoiceParams{ + Year: int64(invoiceData.DateRange.Start.Year()), + Month: int64(invoiceData.DateRange.Start.Month()), + Number: invoiceData.InvoiceNumber, + ClientID: invoiceData.ClientID, + TotalAmount: int64(invoiceData.TotalAmount * 100), + }); err != nil { + return fmt.Errorf("failed to record invoice in database: %w", err) + } + + fmt.Printf("Invoice generated successfully: %s\n", outputPath) + fmt.Printf("Total hours: %.2f\n", invoiceData.TotalHours) + fmt.Printf("Total amount: $%.2f\n", invoiceData.TotalAmount) + + return nil +} + +func generateClientInvoice(q *queries.Queries, clientName string, dateRange reports.DateRange) (*reports.InvoiceData, error) { + // Find client + client, err := findClient(context.Background(), q, clientName) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("client not found: %s", clientName) + } + return nil, fmt.Errorf("failed to find client: %w", err) + } + + // Get contractor data + contractor, err := q.GetContractor(context.Background()) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("no contractor information found - please add contractor details first") + } + return nil, fmt.Errorf("failed to get contractor information: %w", err) + } + + highestNumber, err := q.GetHighestInvoiceNumber(context.Background(), queries.GetHighestInvoiceNumberParams{ + Year: int64(dateRange.Start.Year()), + Month: int64(dateRange.Start.Month()), + }) + if err != nil { + return nil, fmt.Errorf("failed to get highest invoice number: %w", err) + } + + // Get invoice data + entries, err := q.GetInvoiceDataByClient(context.Background(), queries.GetInvoiceDataByClientParams{ + ClientID: client.ID, + StartTime: dateRange.Start, + EndTime: dateRange.End, + }) + if err != nil { + return nil, fmt.Errorf("failed to get invoice data: %w", err) + } + + if len(entries) == 0 { + return nil, fmt.Errorf("no completed time entries found for client %s in the specified date range", client.Name) + } + + return reports.GenerateInvoiceData(entries, client.ID, client.Name, "", contractor, highestNumber+1, dateRange) +} + +func generateProjectInvoice(q *queries.Queries, projectName string, dateRange reports.DateRange) (*reports.InvoiceData, error) { + // We need to find the project, but we need the client info too + // Let's first find all projects with this name + project, err := findProject(context.Background(), q, projectName) + if err != nil { + return nil, fmt.Errorf("failed to find project: %w", err) + } + + // Get client info + clients, err := q.FindClient(context.Background(), queries.FindClientParams{ + ID: project.ClientID, + Name: "", + }) + if err != nil || len(clients) == 0 { + return nil, fmt.Errorf("failed to find client for project") + } + client := clients[0] + + // Get contractor data + contractor, err := q.GetContractor(context.Background()) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("no contractor information found - please add contractor details first") + } + return nil, fmt.Errorf("failed to get contractor information: %w", err) + } + + highestNumber, err := q.GetHighestInvoiceNumber(context.Background(), queries.GetHighestInvoiceNumberParams{ + Year: int64(dateRange.Start.Year()), + Month: int64(dateRange.Start.Month()), + }) + if err != nil { + return nil, fmt.Errorf("failed to get highest invoice number: %w", err) + } + + // Get invoice data + entries, err := q.GetInvoiceDataByProject(context.Background(), queries.GetInvoiceDataByProjectParams{ + ProjectID: project.ID, + StartTime: dateRange.Start, + EndTime: dateRange.End, + }) + if err != nil { + return nil, fmt.Errorf("failed to get invoice data: %w", err) + } + + if len(entries) == 0 { + return nil, fmt.Errorf("no completed time entries found for project %s in the specified date range", projectName) + } + + return reports.GenerateInvoiceData(entries, client.ID, client.Name, projectName, contractor, highestNumber+1, dateRange) } func NewReportTimesheetCmd() *cobra.Command { diff --git a/internal/commands/root.go b/internal/commands/root.go index 6c400ee..04f1203 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -24,6 +24,7 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(NewStatusCmd()) cmd.AddCommand(NewImportCmd()) cmd.AddCommand(NewReportCmd()) + cmd.AddCommand(NewSetCmd()) return cmd } diff --git a/internal/commands/set.go b/internal/commands/set.go new file mode 100644 index 0000000..32f3b96 --- /dev/null +++ b/internal/commands/set.go @@ -0,0 +1,333 @@ +package commands + +import ( + "context" + "database/sql" + "fmt" + "strconv" + "strings" + + punchctx "punchcard/internal/context" + "punchcard/internal/database" + "punchcard/internal/queries" + + "github.com/spf13/cobra" +) + +func NewSetCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "set [key=value ...]", + Short: "Set configuration values for clients, projects, or contractor info", + Long: `Set configuration values using key=value pairs. + +Examples: + # Set contractor information (no flags) + punch set name="John Doe" label="Software Engineer" email="john@example.com" + + # Set client information + punch set -c "Acme Corp" name="Acme Corporation" email="billing@acme.com" hourly-rate=150.00 + + # Set project information + punch set -p "Website Redesign" name="Website Redesign v2" hourly-rate=180.00 + +Valid keys: + - With no flags (contractor): name, label, email + - With -c/--client: name, email, hourly-rate (in dollars) + - With -p/--project: name, hourly-rate (in dollars)`, + RunE: func(cmd *cobra.Command, args []string) error { + return runSetCommand(cmd, args) + }, + } + + cmd.Flags().StringP("client", "c", "", "Set values for specified client") + cmd.Flags().StringP("project", "p", "", "Set values for specified project") + cmd.MarkFlagsMutuallyExclusive("client", "project") + + return cmd +} + +func runSetCommand(cmd *cobra.Command, args []string) error { + // Get flag values + clientName, _ := cmd.Flags().GetString("client") + projectName, _ := cmd.Flags().GetString("project") + + // Parse key=value pairs + updates, err := parseKeyValuePairs(args) + if err != nil { + return err + } + + if len(updates) == 0 { + return fmt.Errorf("no key=value pairs provided") + } + + // Get database connection + q := punchctx.GetDB(cmd.Context()) + if q == nil { + var err error + q, err = database.GetDB() + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + } + + // Route to appropriate handler + if clientName != "" { + return setClientValues(q, clientName, updates) + } else if projectName != "" { + return setProjectValues(q, projectName, updates) + } else { + return setContractorValues(q, updates) + } +} + +func parseKeyValuePairs(args []string) (map[string]string, error) { + updates := make(map[string]string) + + for _, arg := range args { + parts := strings.SplitN(arg, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid key=value pair: %s", arg) + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + if key == "" { + return nil, fmt.Errorf("empty key in pair: %s", arg) + } + + updates[key] = value + } + + return updates, nil +} + +func setClientValues(q *queries.Queries, clientName string, updates map[string]string) error { + // Validate keys + validKeys := map[string]bool{"name": true, "email": true, "hourly-rate": true} + for key := range updates { + if !validKeys[key] { + return fmt.Errorf("invalid key '%s' for client. Valid keys: name, email, hourly-rate", key) + } + } + + // Find the client + client, err := findClient(context.Background(), q, clientName) + if err != nil { + if err == sql.ErrNoRows { + return fmt.Errorf("client not found: %s", clientName) + } + return fmt.Errorf("failed to find client: %w", err) + } + + // Prepare update values (start with current values) + newName := client.Name + newEmail := client.Email + newBillableRate := client.BillableRate + + // Apply updates + for key, value := range updates { + switch key { + case "name": + newName = value + case "email": + if value == "" { + newEmail = sql.NullString{Valid: false} + } else { + newEmail = sql.NullString{String: value, Valid: true} + } + case "hourly-rate": + if value == "" { + newBillableRate = sql.NullInt64{Valid: false} + } else { + rateFloat, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("invalid hourly-rate value '%s': must be a number (in dollars)", value) + } + if rateFloat < 0 { + return fmt.Errorf("hourly-rate must be non-negative") + } + // Convert dollars to cents + rateCents := int64(rateFloat * 100) + newBillableRate = sql.NullInt64{Int64: rateCents, Valid: true} + } + } + } + + // Update the client + updated, err := q.UpdateClient(context.Background(), queries.UpdateClientParams{ + ID: client.ID, + Name: newName, + Email: newEmail, + BillableRate: newBillableRate, + }) + if err != nil { + return fmt.Errorf("failed to update client: %w", err) + } + + fmt.Printf("Updated client '%s':\n", clientName) + fmt.Printf(" name: %s\n", updated.Name) + if updated.Email.Valid { + fmt.Printf(" email: %s\n", updated.Email.String) + } else { + fmt.Printf(" email: (not set)\n") + } + if updated.BillableRate.Valid { + fmt.Printf(" billable_rate: %d cents ($%.2f/hour)\n", updated.BillableRate.Int64, float64(updated.BillableRate.Int64)/100.0) + } else { + fmt.Printf(" billable_rate: (not set)\n") + } + + return nil +} + +func setProjectValues(q *queries.Queries, projectName string, updates map[string]string) error { + // Validate keys + validKeys := map[string]bool{"name": true, "hourly-rate": true} + for key := range updates { + if !validKeys[key] { + return fmt.Errorf("invalid key '%s' for project. Valid keys: name, hourly-rate", key) + } + } + + // Find the project + project, err := findProject(context.Background(), q, projectName) + if err != nil { + return fmt.Errorf("failed to find project: %w", err) + } + + // Prepare update values (start with current values) + newName := project.Name + newBillableRate := project.BillableRate + + // Apply updates + for key, value := range updates { + switch key { + case "name": + newName = value + case "hourly-rate": + if value == "" { + newBillableRate = sql.NullInt64{Valid: false} + } else { + rateFloat, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("invalid hourly-rate value '%s': must be a number (in dollars)", value) + } + if rateFloat < 0 { + return fmt.Errorf("hourly-rate must be non-negative") + } + // Convert dollars to cents + rateCents := int64(rateFloat * 100) + newBillableRate = sql.NullInt64{Int64: rateCents, Valid: true} + } + } + } + + // Update the project + updated, err := q.UpdateProject(context.Background(), queries.UpdateProjectParams{ + ID: project.ID, + Name: newName, + BillableRate: newBillableRate, + }) + if err != nil { + return fmt.Errorf("failed to update project: %w", err) + } + + fmt.Printf("Updated project '%s':\n", projectName) + fmt.Printf(" name: %s\n", updated.Name) + if updated.BillableRate.Valid { + fmt.Printf(" billable_rate: %d cents ($%.2f/hour)\n", updated.BillableRate.Int64, float64(updated.BillableRate.Int64)/100.0) + } else { + fmt.Printf(" billable_rate: (not set)\n") + } + + return nil +} + +func setContractorValues(q *queries.Queries, updates map[string]string) error { + // Validate keys + validKeys := map[string]bool{"name": true, "label": true, "email": true} + for key := range updates { + if !validKeys[key] { + return fmt.Errorf("invalid key '%s' for contractor. Valid keys: name, label, email", key) + } + } + + // Try to get existing contractor + contractor, err := q.GetContractor(context.Background()) + + var newName, newLabel, newEmail string + + if err == sql.ErrNoRows { + // No contractor exists, we'll create one + // Set default values + newName = "" + newLabel = "" + newEmail = "" + } else if err != nil { + return fmt.Errorf("failed to get contractor information: %w", err) + } else { + // Contractor exists, start with current values + newName = contractor.Name + newLabel = contractor.Label + newEmail = contractor.Email + } + + // Apply updates + for key, value := range updates { + switch key { + case "name": + newName = value + case "label": + newLabel = value + case "email": + newEmail = value + } + } + + // Validate required fields + if newName == "" { + return fmt.Errorf("contractor name cannot be empty") + } + if newLabel == "" { + return fmt.Errorf("contractor label cannot be empty") + } + if newEmail == "" { + return fmt.Errorf("contractor email cannot be empty") + } + + // Create or update contractor + if err == sql.ErrNoRows { + // Create new contractor + created, err := q.CreateContractor(context.Background(), queries.CreateContractorParams{ + Name: newName, + Label: newLabel, + Email: newEmail, + }) + if err != nil { + return fmt.Errorf("failed to create contractor: %w", err) + } + fmt.Printf("Created contractor:\n") + fmt.Printf(" name: %s\n", created.Name) + fmt.Printf(" label: %s\n", created.Label) + fmt.Printf(" email: %s\n", created.Email) + } else { + // Update existing contractor + updated, err := q.UpdateContractor(context.Background(), queries.UpdateContractorParams{ + Name: newName, + Label: newLabel, + Email: newEmail, + }) + if err != nil { + return fmt.Errorf("failed to update contractor: %w", err) + } + fmt.Printf("Updated contractor:\n") + fmt.Printf(" name: %s\n", updated.Name) + fmt.Printf(" label: %s\n", updated.Label) + fmt.Printf(" email: %s\n", updated.Email) + } + + return nil +} + diff --git a/internal/database/queries.sql b/internal/database/queries.sql index 073574a..32114a6 100644 --- a/internal/database/queries.sql +++ b/internal/database/queries.sql @@ -130,3 +130,104 @@ values ( ) ) returning *; + +-- name: GetInvoiceDataByClient :many +select + te.id as time_entry_id, + te.start_time, + te.end_time, + te.description, + te.billable_rate as entry_billable_rate, + c.id as client_id, + c.name as client_name, + c.billable_rate as client_billable_rate, + p.id as project_id, + p.name as project_name, + p.billable_rate as project_billable_rate, + cast(round((julianday(te.end_time) - julianday(te.start_time)) * 24 * 60 * 60) as integer) as duration_seconds, + case + when te.billable_rate is not null then 'entry' + when p.billable_rate is not null then 'project' + else 'client' + end as rate_source +from time_entry te +join client c on te.client_id = c.id +left join project p on te.project_id = p.id +where c.id = @client_id + and te.start_time >= @start_time + and te.start_time <= @end_time + and te.end_time is not null +order by te.start_time; + +-- name: GetInvoiceDataByProject :many +select + te.id as time_entry_id, + te.start_time, + te.end_time, + te.description, + te.billable_rate as entry_billable_rate, + c.id as client_id, + c.name as client_name, + c.billable_rate as client_billable_rate, + p.id as project_id, + p.name as project_name, + p.billable_rate as project_billable_rate, + cast( + case + when te.end_time is null then + (julianday('now', 'utc') - julianday(te.start_time)) * 24 * 60 * 60 + else + (julianday(te.end_time) - julianday(te.start_time)) * 24 * 60 * 60 + end as integer + ) as duration_seconds, + case + when te.billable_rate is not null then 'entry' + when p.billable_rate is not null then 'project' + else 'client' + end as rate_source +from time_entry te +join client c on te.client_id = c.id +join project p on te.project_id = p.id +where p.id = @project_id + and te.start_time >= @start_time + and te.start_time <= @end_time + and te.end_time is not null +order by te.start_time; + +-- name: GetContractor :one +select * from contractor +order by id +limit 1; + +-- name: CreateContractor :one +insert into contractor (name, label, email) +values (@name, @label, @email) +returning *; + +-- name: UpdateContractor :one +update contractor +set name = @name, label = @label, email = @email +where id = (select id from contractor order by id limit 1) +returning *; + +-- name: UpdateClient :one +update client +set name = @name, email = @email, billable_rate = @billable_rate +where id = @id +returning *; + +-- name: UpdateProject :one +update project +set name = @name, billable_rate = @billable_rate +where id = @id +returning *; + +-- name: GetHighestInvoiceNumber :one +select cast(coalesce(max(number), 0) as integer) as max_number +from invoice +where year = @year and month = @month; + +-- name: CreateInvoice :one +insert into invoice (year, month, number, client_id, total_amount) +values (@year, @month, @number, @client_id, @total_amount) +returning *; diff --git a/internal/database/schema.sql b/internal/database/schema.sql index a4483e1..84f4f02 100644 --- a/internal/database/schema.sql +++ b/internal/database/schema.sql @@ -15,6 +15,26 @@ create table if not exists project ( foreign key (client_id) references client(id) ); +create table if not exists contractor ( + id integer primary key autoincrement, + name text not null, + label text not null, + email text not null, + created_at datetime default current_timestamp +); + +create table if not exists invoice ( + id integer primary key autoincrement, + year integer not null, + month integer not null, + number integer not null, + client_id integer not null, + total_amount integer not null, + created_at datetime default current_timestamp, + unique(year, month, number), + foreign key (client_id) references client(id) +); + create table if not exists time_entry ( id integer primary key autoincrement, start_time datetime not null, diff --git a/internal/queries/models.go b/internal/queries/models.go index 1257397..b42de02 100644 --- a/internal/queries/models.go +++ b/internal/queries/models.go @@ -17,6 +17,24 @@ type Client struct { CreatedAt sql.NullTime } +type Contractor struct { + ID int64 + Name string + Label string + Email string + CreatedAt sql.NullTime +} + +type Invoice struct { + ID int64 + Year int64 + Month int64 + Number int64 + ClientID int64 + TotalAmount int64 + CreatedAt sql.NullTime +} + type Project struct { ID int64 Name string diff --git a/internal/queries/queries.sql.go b/internal/queries/queries.sql.go index 1bd8ec1..e70de22 100644 --- a/internal/queries/queries.sql.go +++ b/internal/queries/queries.sql.go @@ -8,6 +8,7 @@ package queries import ( "context" "database/sql" + "time" ) const createClient = `-- name: CreateClient :one @@ -35,6 +36,66 @@ func (q *Queries) CreateClient(ctx context.Context, arg CreateClientParams) (Cli return i, err } +const createContractor = `-- name: CreateContractor :one +insert into contractor (name, label, email) +values (?1, ?2, ?3) +returning id, name, label, email, created_at +` + +type CreateContractorParams struct { + Name string + Label string + Email string +} + +func (q *Queries) CreateContractor(ctx context.Context, arg CreateContractorParams) (Contractor, error) { + row := q.db.QueryRowContext(ctx, createContractor, arg.Name, arg.Label, arg.Email) + var i Contractor + err := row.Scan( + &i.ID, + &i.Name, + &i.Label, + &i.Email, + &i.CreatedAt, + ) + return i, err +} + +const createInvoice = `-- name: CreateInvoice :one +insert into invoice (year, month, number, client_id, total_amount) +values (?1, ?2, ?3, ?4, ?5) +returning id, year, month, number, client_id, total_amount, created_at +` + +type CreateInvoiceParams struct { + Year int64 + Month int64 + Number int64 + ClientID int64 + TotalAmount int64 +} + +func (q *Queries) CreateInvoice(ctx context.Context, arg CreateInvoiceParams) (Invoice, error) { + row := q.db.QueryRowContext(ctx, createInvoice, + arg.Year, + arg.Month, + arg.Number, + arg.ClientID, + arg.TotalAmount, + ) + var i Invoice + err := row.Scan( + &i.ID, + &i.Year, + &i.Month, + &i.Number, + &i.ClientID, + &i.TotalAmount, + &i.CreatedAt, + ) + return i, err +} + const createProject = `-- name: CreateProject :one insert into project (name, client_id, billable_rate) values (?1, ?2, ?3) @@ -270,6 +331,226 @@ func (q *Queries) GetClientByName(ctx context.Context, name string) (Client, err return i, err } +const getContractor = `-- name: GetContractor :one +select id, name, label, email, created_at from contractor +order by id +limit 1 +` + +func (q *Queries) GetContractor(ctx context.Context) (Contractor, error) { + row := q.db.QueryRowContext(ctx, getContractor) + var i Contractor + err := row.Scan( + &i.ID, + &i.Name, + &i.Label, + &i.Email, + &i.CreatedAt, + ) + return i, err +} + +const getHighestInvoiceNumber = `-- name: GetHighestInvoiceNumber :one +select cast(coalesce(max(number), 0) as integer) as max_number +from invoice +where year = ?1 and month = ?2 +` + +type GetHighestInvoiceNumberParams struct { + Year int64 + Month int64 +} + +func (q *Queries) GetHighestInvoiceNumber(ctx context.Context, arg GetHighestInvoiceNumberParams) (int64, error) { + row := q.db.QueryRowContext(ctx, getHighestInvoiceNumber, arg.Year, arg.Month) + var max_number int64 + err := row.Scan(&max_number) + return max_number, err +} + +const getInvoiceDataByClient = `-- name: GetInvoiceDataByClient :many +select + te.id as time_entry_id, + te.start_time, + te.end_time, + te.description, + te.billable_rate as entry_billable_rate, + c.id as client_id, + c.name as client_name, + c.billable_rate as client_billable_rate, + p.id as project_id, + p.name as project_name, + p.billable_rate as project_billable_rate, + cast(round((julianday(te.end_time) - julianday(te.start_time)) * 24 * 60 * 60) as integer) as duration_seconds, + case + when te.billable_rate is not null then 'entry' + when p.billable_rate is not null then 'project' + else 'client' + end as rate_source +from time_entry te +join client c on te.client_id = c.id +left join project p on te.project_id = p.id +where c.id = ?1 + and te.start_time >= ?2 + and te.start_time <= ?3 + and te.end_time is not null +order by te.start_time +` + +type GetInvoiceDataByClientParams struct { + ClientID int64 + StartTime time.Time + EndTime time.Time +} + +type GetInvoiceDataByClientRow struct { + TimeEntryID int64 + StartTime time.Time + EndTime sql.NullTime + Description sql.NullString + EntryBillableRate sql.NullInt64 + ClientID int64 + ClientName string + ClientBillableRate sql.NullInt64 + ProjectID sql.NullInt64 + ProjectName sql.NullString + ProjectBillableRate sql.NullInt64 + DurationSeconds int64 + RateSource string +} + +func (q *Queries) GetInvoiceDataByClient(ctx context.Context, arg GetInvoiceDataByClientParams) ([]GetInvoiceDataByClientRow, error) { + rows, err := q.db.QueryContext(ctx, getInvoiceDataByClient, arg.ClientID, arg.StartTime, arg.EndTime) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetInvoiceDataByClientRow + for rows.Next() { + var i GetInvoiceDataByClientRow + if err := rows.Scan( + &i.TimeEntryID, + &i.StartTime, + &i.EndTime, + &i.Description, + &i.EntryBillableRate, + &i.ClientID, + &i.ClientName, + &i.ClientBillableRate, + &i.ProjectID, + &i.ProjectName, + &i.ProjectBillableRate, + &i.DurationSeconds, + &i.RateSource, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getInvoiceDataByProject = `-- name: GetInvoiceDataByProject :many +select + te.id as time_entry_id, + te.start_time, + te.end_time, + te.description, + te.billable_rate as entry_billable_rate, + c.id as client_id, + c.name as client_name, + c.billable_rate as client_billable_rate, + p.id as project_id, + p.name as project_name, + p.billable_rate as project_billable_rate, + cast( + case + when te.end_time is null then + (julianday('now', 'utc') - julianday(te.start_time)) * 24 * 60 * 60 + else + (julianday(te.end_time) - julianday(te.start_time)) * 24 * 60 * 60 + end as integer + ) as duration_seconds, + case + when te.billable_rate is not null then 'entry' + when p.billable_rate is not null then 'project' + else 'client' + end as rate_source +from time_entry te +join client c on te.client_id = c.id +join project p on te.project_id = p.id +where p.id = ?1 + and te.start_time >= ?2 + and te.start_time <= ?3 + and te.end_time is not null +order by te.start_time +` + +type GetInvoiceDataByProjectParams struct { + ProjectID int64 + StartTime time.Time + EndTime time.Time +} + +type GetInvoiceDataByProjectRow struct { + TimeEntryID int64 + StartTime time.Time + EndTime sql.NullTime + Description sql.NullString + EntryBillableRate sql.NullInt64 + ClientID int64 + ClientName string + ClientBillableRate sql.NullInt64 + ProjectID int64 + ProjectName string + ProjectBillableRate sql.NullInt64 + DurationSeconds int64 + RateSource string +} + +func (q *Queries) GetInvoiceDataByProject(ctx context.Context, arg GetInvoiceDataByProjectParams) ([]GetInvoiceDataByProjectRow, error) { + rows, err := q.db.QueryContext(ctx, getInvoiceDataByProject, arg.ProjectID, arg.StartTime, arg.EndTime) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetInvoiceDataByProjectRow + for rows.Next() { + var i GetInvoiceDataByProjectRow + if err := rows.Scan( + &i.TimeEntryID, + &i.StartTime, + &i.EndTime, + &i.Description, + &i.EntryBillableRate, + &i.ClientID, + &i.ClientName, + &i.ClientBillableRate, + &i.ProjectID, + &i.ProjectName, + &i.ProjectBillableRate, + &i.DurationSeconds, + &i.RateSource, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getMonthSummaryByProject = `-- name: GetMonthSummaryByProject :many select p.id as project_id, @@ -539,3 +820,87 @@ func (q *Queries) StopTimeEntry(ctx context.Context) (TimeEntry, error) { ) return i, err } + +const updateClient = `-- name: UpdateClient :one +update client +set name = ?1, email = ?2, billable_rate = ?3 +where id = ?4 +returning id, name, email, billable_rate, created_at +` + +type UpdateClientParams struct { + Name string + Email sql.NullString + BillableRate sql.NullInt64 + ID int64 +} + +func (q *Queries) UpdateClient(ctx context.Context, arg UpdateClientParams) (Client, error) { + row := q.db.QueryRowContext(ctx, updateClient, + arg.Name, + arg.Email, + arg.BillableRate, + arg.ID, + ) + var i Client + err := row.Scan( + &i.ID, + &i.Name, + &i.Email, + &i.BillableRate, + &i.CreatedAt, + ) + return i, err +} + +const updateContractor = `-- name: UpdateContractor :one +update contractor +set name = ?1, label = ?2, email = ?3 +where id = (select id from contractor order by id limit 1) +returning id, name, label, email, created_at +` + +type UpdateContractorParams struct { + Name string + Label string + Email string +} + +func (q *Queries) UpdateContractor(ctx context.Context, arg UpdateContractorParams) (Contractor, error) { + row := q.db.QueryRowContext(ctx, updateContractor, arg.Name, arg.Label, arg.Email) + var i Contractor + err := row.Scan( + &i.ID, + &i.Name, + &i.Label, + &i.Email, + &i.CreatedAt, + ) + return i, err +} + +const updateProject = `-- name: UpdateProject :one +update project +set name = ?1, billable_rate = ?2 +where id = ?3 +returning id, name, client_id, billable_rate, created_at +` + +type UpdateProjectParams struct { + Name string + BillableRate sql.NullInt64 + ID int64 +} + +func (q *Queries) UpdateProject(ctx context.Context, arg UpdateProjectParams) (Project, error) { + row := q.db.QueryRowContext(ctx, updateProject, arg.Name, arg.BillableRate, arg.ID) + var i Project + err := row.Scan( + &i.ID, + &i.Name, + &i.ClientID, + &i.BillableRate, + &i.CreatedAt, + ) + return i, err +} diff --git a/internal/reports/daterange.go b/internal/reports/daterange.go new file mode 100644 index 0000000..3478615 --- /dev/null +++ b/internal/reports/daterange.go @@ -0,0 +1,108 @@ +package reports + +import ( + "fmt" + "strings" + "time" +) + +type DateRange struct { + Start time.Time + End time.Time +} + +func ParseDateRange(dateStr string) (DateRange, error) { + dateStr = strings.TrimSpace(dateStr) + now := time.Now().UTC() + + // Check for predefined ranges (case-insensitive) + lowerDateStr := strings.ToLower(dateStr) + switch lowerDateStr { + case "last week": + return getLastWeek(now), nil + case "last month": + return getLastMonth(now), nil + } + + // Check for custom date range format: "YYYY-MM-DD to YYYY-MM-DD" + if strings.Contains(dateStr, " to ") { + return parseCustomDateRange(dateStr) + } + + return DateRange{}, fmt.Errorf("unsupported date range: %s (supported: 'last week', 'last month', or 'YYYY-MM-DD to YYYY-MM-DD')", dateStr) +} + +func parseCustomDateRange(dateStr string) (DateRange, error) { + parts := strings.Split(dateStr, " to ") + if len(parts) != 2 { + return DateRange{}, fmt.Errorf("invalid date range format: expected 'YYYY-MM-DD to YYYY-MM-DD'") + } + + startStr := strings.TrimSpace(parts[0]) + endStr := strings.TrimSpace(parts[1]) + + // Parse start date + startDate, err := time.Parse("2006-01-02", startStr) + if err != nil { + return DateRange{}, fmt.Errorf("invalid start date '%s': expected YYYY-MM-DD format", startStr) + } + + // Parse end date + endDate, err := time.Parse("2006-01-02", endStr) + if err != nil { + return DateRange{}, fmt.Errorf("invalid end date '%s': expected YYYY-MM-DD format", endStr) + } + + // Validate that start date is before or equal to end date + if startDate.After(endDate) { + return DateRange{}, fmt.Errorf("start date '%s' must be before or equal to end date '%s'", startStr, endStr) + } + + // Convert to UTC and set times appropriately + // Start date: beginning of day (00:00:00) + startUTC := time.Date(startDate.Year(), startDate.Month(), startDate.Day(), 0, 0, 0, 0, time.UTC) + + // End date: end of day (23:59:59.999999999) + endUTC := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 23, 59, 59, 999999999, time.UTC) + + return DateRange{ + Start: startUTC, + End: endUTC, + }, nil +} + +func getLastWeek(now time.Time) DateRange { + // Find the start of current week (Monday) + weekday := int(now.Weekday()) + if weekday == 0 { // Sunday + weekday = 7 + } + + // Start of current week + currentWeekStart := now.AddDate(0, 0, -(weekday-1)).Truncate(24 * time.Hour) + + // Last week is the week before current week + lastWeekStart := currentWeekStart.AddDate(0, 0, -7) + lastWeekEnd := currentWeekStart.Add(-time.Nanosecond) + + return DateRange{ + Start: lastWeekStart, + End: lastWeekEnd, + } +} + +func getLastMonth(now time.Time) DateRange { + // Start of current month + currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) + + // Last month start + lastMonthStart := currentMonthStart.AddDate(0, -1, 0) + + // Last month end (last nanosecond of last month) + lastMonthEnd := currentMonthStart.Add(-time.Nanosecond) + + return DateRange{ + Start: lastMonthStart, + End: lastMonthEnd, + } +}
\ No newline at end of file diff --git a/internal/reports/invoice.go b/internal/reports/invoice.go new file mode 100644 index 0000000..73235d5 --- /dev/null +++ b/internal/reports/invoice.go @@ -0,0 +1,239 @@ +package reports + +import ( + "database/sql" + "fmt" + "time" + + "punchcard/internal/queries" +) + +type InvoiceData struct { + ClientID int64 + ClientName string + ProjectName string + ContractorName string + ContractorLabel string + ContractorEmail string + InvoiceNumber int64 + DateRange DateRange + LineItems []LineItem + TotalHours float64 + TotalAmount float64 + GeneratedDate time.Time +} + +type LineItem struct { + Description string `json:"description"` + Hours float64 `json:"hours"` + Rate float64 `json:"rate"` + Amount float64 `json:"amount"` +} + +type RateSource string + +const ( + RateSourceEntry RateSource = "entry" + RateSourceProject RateSource = "project" + RateSourceClient RateSource = "client" +) + +type timeEntryData struct { + TimeEntryID int64 + StartTime time.Time + EndTime sql.NullTime + Description sql.NullString + EntryBillableRate sql.NullInt64 + ClientID int64 + ClientName string + ClientBillableRate sql.NullInt64 + ProjectID sql.NullInt64 + ProjectName sql.NullString + ProjectBillableRate sql.NullInt64 + DurationSeconds int64 + RateSource string +} + +func GenerateInvoiceData( + entries interface{}, + clientID int64, + clientName, + projectName string, + contractor queries.Contractor, + number int64, + dateRange DateRange, +) (*InvoiceData, error) { + var timeEntries []timeEntryData + + switch e := entries.(type) { + case []queries.GetInvoiceDataByClientRow: + for _, entry := range e { + timeEntries = append(timeEntries, timeEntryData{ + TimeEntryID: entry.TimeEntryID, + StartTime: entry.StartTime, + EndTime: entry.EndTime, + Description: entry.Description, + EntryBillableRate: entry.EntryBillableRate, + ClientID: entry.ClientID, + ClientName: entry.ClientName, + ClientBillableRate: entry.ClientBillableRate, + ProjectID: entry.ProjectID, + ProjectName: entry.ProjectName, + ProjectBillableRate: entry.ProjectBillableRate, + DurationSeconds: entry.DurationSeconds, + RateSource: entry.RateSource, + }) + } + case []queries.GetInvoiceDataByProjectRow: + for _, entry := range e { + timeEntries = append(timeEntries, timeEntryData{ + TimeEntryID: entry.TimeEntryID, + StartTime: entry.StartTime, + EndTime: entry.EndTime, + Description: entry.Description, + EntryBillableRate: entry.EntryBillableRate, + ClientID: entry.ClientID, + ClientName: entry.ClientName, + ClientBillableRate: entry.ClientBillableRate, + ProjectID: sql.NullInt64{Int64: entry.ProjectID, Valid: true}, + ProjectName: sql.NullString{String: entry.ProjectName, Valid: true}, + ProjectBillableRate: entry.ProjectBillableRate, + DurationSeconds: entry.DurationSeconds, + RateSource: entry.RateSource, + }) + } + default: + return nil, fmt.Errorf("unsupported entry type") + } + + lineItems := groupTimeEntriesIntoLineItems(timeEntries) + + totalHours := 0.0 + totalAmount := 0.0 + for _, item := range lineItems { + totalHours += item.Hours + totalAmount += item.Amount + } + + invoice := &InvoiceData{ + ClientID: clientID, + ClientName: clientName, + ProjectName: projectName, + ContractorName: contractor.Name, + ContractorLabel: contractor.Label, + ContractorEmail: contractor.Email, + InvoiceNumber: number, + DateRange: dateRange, + LineItems: lineItems, + TotalHours: totalHours, + TotalAmount: totalAmount, + GeneratedDate: time.Now().UTC(), + } + + return invoice, nil +} + +func groupTimeEntriesIntoLineItems(entries []timeEntryData) []LineItem { + var lineItems []LineItem + + // Group 1: Entries with overridden rates + entryRateGroups := make(map[int64][]timeEntryData) + + // Group 2: Entries using project rates + projectRateGroups := make(map[int64][]timeEntryData) + + // Group 3: Entries using client rates + clientRateGroups := make(map[int64][]timeEntryData) + + for _, entry := range entries { + switch RateSource(entry.RateSource) { + case RateSourceEntry: + rate := entry.EntryBillableRate.Int64 + entryRateGroups[rate] = append(entryRateGroups[rate], entry) + case RateSourceProject: + if entry.ProjectID.Valid { + projectRateGroups[entry.ProjectID.Int64] = append(projectRateGroups[entry.ProjectID.Int64], entry) + } + case RateSourceClient: + clientRateGroups[entry.ClientID] = append(clientRateGroups[entry.ClientID], entry) + } + } + + // Process overridden rates first + for rate, entries := range entryRateGroups { + if len(entries) > 0 { + lineItem := createLineItem(entries, rate, "Custom rate work") + lineItems = append(lineItems, lineItem) + } + } + + // Process project rates + for _, entries := range projectRateGroups { + if len(entries) > 0 { + projectName := "Unknown Project" + rateCents := int64(0) + if entries[0].ProjectName.Valid { + projectName = entries[0].ProjectName.String + } + if entries[0].ProjectBillableRate.Valid { + rateCents = entries[0].ProjectBillableRate.Int64 + } + + lineItem := createLineItem(entries, rateCents, projectName) + lineItems = append(lineItems, lineItem) + } + } + + // Process client rates + for _, entries := range clientRateGroups { + if len(entries) > 0 { + clientName := entries[0].ClientName + rateCents := int64(0) + if entries[0].ClientBillableRate.Valid { + rateCents = entries[0].ClientBillableRate.Int64 + } + + lineItem := createLineItem(entries, rateCents, fmt.Sprintf("General work - %s", clientName)) + lineItems = append(lineItems, lineItem) + } + } + + return lineItems +} + +func createLineItem(entries []timeEntryData, hourlyRateCents int64, description string) LineItem { + totalSeconds := int64(0) + for _, entry := range entries { + totalSeconds += entry.DurationSeconds + } + + // Calculate whole hours and remaining seconds using integer arithmetic + wholeHours := totalSeconds / 3600 + remainingSeconds := totalSeconds % 3600 + + // Calculate amount for whole hours (integer arithmetic, result in cents) + wholeHoursAmountCents := wholeHours * hourlyRateCents + + // Calculate amount for partial hour, rounded down to the minute + // First convert remaining seconds to whole minutes (truncate seconds) + partialMinutes := remainingSeconds / 60 + // Calculate amount for those whole minutes (integer arithmetic) + partialHourAmountCents := partialMinutes * hourlyRateCents / 60 + + // Total amount in cents, then convert to dollars + totalAmountCents := wholeHoursAmountCents + partialHourAmountCents + amount := float64(totalAmountCents) / 100.0 + + // Convert total seconds to hours for display + hours := float64(totalSeconds) / 3600.0 + + // Convert rate to dollars for display + hourlyRate := float64(hourlyRateCents) / 100.0 + + return LineItem{ + Description: description, + Hours: hours, + Rate: hourlyRate, + Amount: amount, + } +} diff --git a/internal/reports/pdf.go b/internal/reports/pdf.go new file mode 100644 index 0000000..96630cf --- /dev/null +++ b/internal/reports/pdf.go @@ -0,0 +1,131 @@ +package reports + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + "punchcard/internal/queries" + "punchcard/templates" +) + +// RecordInvoice records the invoice in the database after successful generation +func RecordInvoice(q *queries.Queries, year, month, number, clientID, totalAmountCents int64) error { + _, err := q.CreateInvoice(context.Background(), queries.CreateInvoiceParams{ + Year: year, + Month: month, + Number: number, + ClientID: clientID, + TotalAmount: totalAmountCents, + }) + if err != nil { + return fmt.Errorf("failed to record invoice in database: %w", err) + } + return nil +} + +// InvoiceJSONData represents the data structure for the JSON file that Typst will consume +type InvoiceJSONData struct { + ClientName string `json:"client_name"` + ProjectName string `json:"project_name"` + DateRangeStart string `json:"date_range_start"` + DateRangeEnd string `json:"date_range_end"` + GeneratedDate string `json:"generated_date"` + InvoiceNumber string `json:"invoice_number"` + ContractorName string `json:"contractor_name"` + ContractorLabel string `json:"contractor_label"` + ContractorEmail string `json:"contractor_email"` + LineItems []LineItem `json:"line_items"` + TotalHours float64 `json:"total_hours"` + TotalAmount float64 `json:"total_amount"` +} + +func GenerateInvoicePDF(invoiceData *InvoiceData, outputPath string) error { + // Check if Typst is installed + if err := checkTypstInstalled(); err != nil { + return err + } + + // Create temporary directory for template and data files + tempDir, err := os.MkdirTemp("", "punchcard-invoice") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tempDir) + + // Create JSON data for Typst template + jsonData := InvoiceJSONData{ + ClientName: invoiceData.ClientName, + ProjectName: invoiceData.ProjectName, + DateRangeStart: invoiceData.DateRange.Start.Format("2006-01-02"), + DateRangeEnd: invoiceData.DateRange.End.Format("2006-01-02"), + GeneratedDate: invoiceData.GeneratedDate.Format("2006-01-02"), + InvoiceNumber: fmt.Sprintf("%04d-%02d-%03d", + invoiceData.DateRange.Start.Year(), + invoiceData.DateRange.Start.Month(), + invoiceData.InvoiceNumber, + ), + ContractorName: invoiceData.ContractorName, + ContractorLabel: invoiceData.ContractorLabel, + ContractorEmail: invoiceData.ContractorEmail, + LineItems: invoiceData.LineItems, + TotalHours: invoiceData.TotalHours, + TotalAmount: invoiceData.TotalAmount, + } + + // Write JSON data file + dataFile := filepath.Join(tempDir, "data.json") + jsonBytes, err := json.MarshalIndent(jsonData, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON data: %w", err) + } + + if err := os.WriteFile(dataFile, jsonBytes, 0o644); err != nil { + return fmt.Errorf("failed to write JSON data file: %w", err) + } + + // Write Typst template file + typstFile := filepath.Join(tempDir, "invoice.typ") + if err := os.WriteFile(typstFile, []byte(templates.InvoiceTemplate), 0o644); err != nil { + return fmt.Errorf("failed to write Typst template file: %w", err) + } + + // Generate PDF using Typst + cmd := exec.Command("typst", "compile", typstFile, outputPath) + cmd.Dir = tempDir // Set working directory so Typst can find data.json + + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to generate PDF: %w\nTypst output: %s", err, string(output)) + } + + return nil +} + +func checkTypstInstalled() error { + _, err := exec.LookPath("typst") + if err != nil { + return fmt.Errorf("typst is not installed or not in PATH. Please install Typst from https://typst.org/") + } + return nil +} + +func GenerateDefaultInvoiceFilename(clientName, projectName string, dateRange DateRange) string { + var name string + if projectName != "" { + name = fmt.Sprintf("%s_%s", clientName, projectName) + } else { + name = clientName + } + + // Replace spaces and special characters + name = filepath.Base(name) + + dateStr := dateRange.Start.Format("2006-01") + timestamp := time.Now().Format("20060102_150405") + + return fmt.Sprintf("invoice_%s_%s_%s.pdf", name, dateStr, timestamp) +} diff --git a/internal/reports/pdf_test.go b/internal/reports/pdf_test.go new file mode 100644 index 0000000..e1c4020 --- /dev/null +++ b/internal/reports/pdf_test.go @@ -0,0 +1,122 @@ +package reports + +import ( + "os" + "os/exec" + "path/filepath" + "punchcard/templates" + "testing" + "time" +) + +// Helper function for tests +func mustParseDate(dateStr string) time.Time { + t, err := time.Parse("2006-01-02", dateStr) + if err != nil { + panic(err) + } + return t +} + +func TestTypstTemplateCompilation(t *testing.T) { + // Check if Typst is installed + if err := checkTypstInstalled(); err != nil { + t.Skip("Typst is not installed, skipping template compilation test") + } + + // Create temporary directory + tempDir, err := os.MkdirTemp("", "punchcard-test") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + // Copy test data to temp directory + testDataPath := filepath.Join("testdata", "invoice_test_data.json") + testData, err := os.ReadFile(testDataPath) + if err != nil { + t.Fatalf("Failed to read test data: %v", err) + } + + dataFile := filepath.Join(tempDir, "data.json") + if err := os.WriteFile(dataFile, testData, 0644); err != nil { + t.Fatalf("Failed to write test data file: %v", err) + } + + // Write Typst template to temp directory + typstFile := filepath.Join(tempDir, "invoice.typ") + if err := os.WriteFile(typstFile, []byte(templates.InvoiceTemplate), 0644); err != nil { + t.Fatalf("Failed to write Typst template: %v", err) + } + + // Compile with Typst + outputPDF := filepath.Join(tempDir, "test-invoice.pdf") + cmd := exec.Command("typst", "compile", typstFile, outputPDF) + cmd.Dir = tempDir + + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("Typst compilation failed: %v\nOutput: %s", err, string(output)) + } + + // Verify PDF was created + if _, err := os.Stat(outputPDF); os.IsNotExist(err) { + t.Fatalf("PDF file was not created") + } + + t.Logf("Successfully compiled Typst template to PDF") +} + +func TestGenerateInvoicePDF(t *testing.T) { + // Check if Typst is installed + if err := checkTypstInstalled(); err != nil { + t.Skip("Typst is not installed, skipping PDF generation test") + } + + // Create test invoice data + invoiceData := &InvoiceData{ + ClientName: "Test Client Co.", + ProjectName: "Test Project", + DateRange: DateRange{ + Start: mustParseDate("2025-07-01"), + End: mustParseDate("2025-07-31"), + }, + LineItems: []LineItem{ + { + Description: "Software development", + Hours: 8.5, + Rate: 150.0, + Amount: 1275.0, + }, + { + Description: "Code review", + Hours: 1.5, + Rate: 150.0, + Amount: 225.0, + }, + }, + TotalHours: 10.0, + TotalAmount: 1500.0, + GeneratedDate: mustParseDate("2025-08-04"), + } + + // Create temporary output file + tempDir, err := os.MkdirTemp("", "punchcard-pdf-test") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + outputPath := filepath.Join(tempDir, "test-invoice.pdf") + + // Generate PDF + if err := GenerateInvoicePDF(invoiceData, outputPath); err != nil { + t.Fatalf("Failed to generate invoice PDF: %v", err) + } + + // Verify PDF was created + if _, err := os.Stat(outputPath); os.IsNotExist(err) { + t.Fatalf("PDF file was not created at %s", outputPath) + } + + t.Logf("Successfully generated invoice PDF at %s", outputPath) +} diff --git a/internal/reports/testdata/invoice_test_data.json b/internal/reports/testdata/invoice_test_data.json new file mode 100644 index 0000000..19ae7cb --- /dev/null +++ b/internal/reports/testdata/invoice_test_data.json @@ -0,0 +1,27 @@ +{ + "client_name": "Test Client", + "project_name": "Test Project", + "date_range_start": "2025-07-01", + "date_range_end": "2025-07-31", + "generated_date": "2025-08-04", + "invoice_number": "2025-07-001", + "line_items": [ + { + "description": "Development work", + "hours": 8.5, + "rate": 150.0, + "amount": 1275.0 + }, + { + "description": "Code review and testing", + "hours": 2.25, + "rate": 150.0, + "amount": 337.5 + } + ], + "total_hours": 10.75, + "total_amount": 1612.5, + "consultant_name": "Travis Parker", + "consultant_label": "Software Development", + "consultant_email": "travis.parker@gmail.com", +} diff --git a/templates/embeds.go b/templates/embeds.go new file mode 100644 index 0000000..394a136 --- /dev/null +++ b/templates/embeds.go @@ -0,0 +1,7 @@ +package templates + +import _ "embed" + +// InvoiceTemplate contains the Typst invoice template +//go:embed invoice.typ +var InvoiceTemplate string
\ No newline at end of file diff --git a/templates/invoice.typ b/templates/invoice.typ new file mode 100644 index 0000000..7dacc59 --- /dev/null +++ b/templates/invoice.typ @@ -0,0 +1,148 @@ +#set page(margin: (top: 0.75in, bottom: 1in, left: 1in, right: 1in)) +#set text(font: ("EB Garamond", "Georgia"), size: 10pt) +#set par(leading: 0.65em) + +// Load invoice data from JSON file +#let data = json("data.json") + +// Helper function to format hours as HH:MM +#let format-hours(hours) = { + let total-minutes = calc.round(hours * 60) + let h = calc.floor(total-minutes / 60) + let m = calc.rem(total-minutes, 60) + str(h) + ":" + if m < 10 { "0" + str(m) } else { str(m) } +} + +// Helper function to format currency with thousands separator and cents +#let format-currency(amount) = { + let dollars = calc.floor(amount) + let cents = calc.round((amount - dollars) * 100) + + // Convert to string and add thousands separators + let dollar-str = str(dollars) + let len = dollar-str.len() + + let formatted = "" + for i in range(len) { + let digit = dollar-str.at(i) + formatted += digit + let remaining = len - i - 1 + if remaining > 0 and calc.rem(remaining, 3) == 0 { + formatted += "," + } + } + + "$" + formatted + "." + if cents < 10 { "0" + str(cents) } else { str(cents) } +} + +// Professional header with company info +#let professional-header() = { + // Company header + align(left)[ + #text(size: 9pt, fill: gray)[ + #text(weight: "bold")[#data.contractor_name] • #data.contractor_label • #data.contractor_email + ] + ] + + v(3em) + + // Invoice title and number + grid( + columns: (1fr, auto), + align(left)[ + #text(size: 28pt, weight: "bold")[Invoice] + ], + align(right)[ + #text(size: 11pt)[ + #text(weight: "bold")[Invoice \##data.invoice_number] \ + #data.generated_date + ] + ] + ) + + v(2.5em) +} + +#let client-info-section() = { + grid( + columns: (1fr, 1fr), + gutter: 3em, + // Bill To section + [ + #text(size: 9pt, fill: gray)[BILL TO] + #v(0.5em) + #text(size: 12pt, weight: "bold")[#data.client_name] + #if data.project_name != "" [ + #v(0.3em) + #text(size: 10pt)[Project: #data.project_name] + ] + ], + // Invoice details + align(right)[ + #text(size: 9pt, fill: gray)[INVOICE PERIOD] + #v(0.5em) + #text(size: 10pt)[#data.date_range_start to #data.date_range_end] + ] + ) + + v(2.5em) +} + +#let professional-table() = { + table( + columns: (1fr, auto, auto, auto), + stroke: (x, y) => if y == 0 or y == 1 { (bottom: 0.8pt + black) } else { none }, + inset: (x: 8pt, y: 12pt), + align: (left, center, center, right), + column-gutter: 12pt, + + // Header + table.cell(fill: rgb("#f8f9fa"))[#text(weight: "bold", size: 9pt)[DESCRIPTION]], + table.cell(fill: rgb("#f8f9fa"))[#text(weight: "bold", size: 9pt)[HOURS]], + table.cell(fill: rgb("#f8f9fa"))[#text(weight: "bold", size: 9pt)[RATE]], + table.cell(fill: rgb("#f8f9fa"))[#text(weight: "bold", size: 9pt)[AMOUNT]], + + // Line items + ..data.line_items.map(item => ( + text(size: 10pt)[#item.description], + text(size: 10pt)[#format-hours(item.hours)], + text(size: 10pt)[#format-currency(item.rate)], + text(size: 10pt, weight: "medium")[#format-currency(item.amount)] + )).flatten() + ) +} + +#let invoice-summary() = { + v(1.5em) + + // Subtotal and total section + align(right, + table( + columns: (auto, auto), + stroke: none, + inset: (x: 12pt, y: 6pt), + align: (right, right), + + [#text(size: 10pt)[Total Hours:]], [#text(size: 10pt)[#format-hours(data.total_hours)]], + table.hline(stroke: 0.5pt), + [#text(size: 12pt, weight: "bold")[Total Amount:]], [#text(size: 12pt, weight: "bold")[#format-currency(data.total_amount)]] + ) + ) +} + +#let payment-terms() = { + v(3em) + + [ + #text(size: 9pt, fill: gray)[ + *Payment Terms:* Net 30 days. Please remit payment within 30 days of invoice date. + ] + ] +} + +// Main invoice layout +#professional-header() +#client-info-section() +#professional-table() +#invoice-summary() +//#payment-terms() |