summaryrefslogtreecommitdiff
path: root/internal/reports
diff options
context:
space:
mode:
authorT <t@tjp.lol>2025-08-04 09:49:52 -0600
committerT <t@tjp.lol>2025-08-04 15:15:18 -0600
commit56e0af3b41742876b471332aeb943a5a2ca8dfbf (patch)
treeef75f4900107ef28977823eabd11ec3014cd40ba /internal/reports
parent4c29dfee9be26996ce548e2edf0328422df598d0 (diff)
Generate invoice PDFs
Diffstat (limited to 'internal/reports')
-rw-r--r--internal/reports/daterange.go108
-rw-r--r--internal/reports/invoice.go239
-rw-r--r--internal/reports/pdf.go131
-rw-r--r--internal/reports/pdf_test.go122
-rw-r--r--internal/reports/testdata/invoice_test_data.json27
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",
+}