diff options
author | T <t@tjp.lol> | 2025-08-04 09:49:52 -0600 |
---|---|---|
committer | T <t@tjp.lol> | 2025-08-04 15:15:18 -0600 |
commit | 56e0af3b41742876b471332aeb943a5a2ca8dfbf (patch) | |
tree | ef75f4900107ef28977823eabd11ec3014cd40ba /internal/commands | |
parent | 4c29dfee9be26996ce548e2edf0328422df598d0 (diff) |
Generate invoice PDFs
Diffstat (limited to 'internal/commands')
-rw-r--r-- | internal/commands/report.go | 207 | ||||
-rw-r--r-- | internal/commands/root.go | 1 | ||||
-rw-r--r-- | internal/commands/set.go | 333 |
3 files changed, 537 insertions, 4 deletions
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 +} + |