diff options
Diffstat (limited to 'internal/reports')
-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 |
5 files changed, 627 insertions, 0 deletions
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", +} |