package reports import ( "context" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "time" "git.tjp.lol/punchcard/internal/queries" "git.tjp.lol/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 func() { _ = 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) } // TimesheetJSONData represents the data structure for the JSON file that Typst will consume type TimesheetJSONData 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"` ContractorName string `json:"contractor_name"` ContractorLabel string `json:"contractor_label"` ContractorEmail string `json:"contractor_email"` Entries []TimesheetEntry `json:"entries"` TotalHours float64 `json:"total_hours"` Timezone string `json:"timezone"` } func GenerateTimesheetPDF(timesheetData *TimesheetData, 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-timesheet") if err != nil { return fmt.Errorf("failed to create temp directory: %w", err) } defer func() { _ = os.RemoveAll(tempDir) }() // Create JSON data for Typst template jsonData := TimesheetJSONData{ ClientName: timesheetData.ClientName, ProjectName: timesheetData.ProjectName, DateRangeStart: timesheetData.DateRange.Start.Format("2006-01-02"), DateRangeEnd: timesheetData.DateRange.End.Format("2006-01-02"), GeneratedDate: timesheetData.GeneratedDate.Format("2006-01-02"), ContractorName: timesheetData.ContractorName, ContractorLabel: timesheetData.ContractorLabel, ContractorEmail: timesheetData.ContractorEmail, Entries: timesheetData.Entries, TotalHours: timesheetData.TotalHours, Timezone: timesheetData.Timezone, } // 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, "timesheet.typ") if err := os.WriteFile(typstFile, []byte(templates.TimesheetTemplate), 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 GenerateDefaultTimesheetFilename(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("timesheet_%s_%s_%s.pdf", name, dateStr, timestamp) } // UnifiedJSONData represents the unified data structure containing all fields from both invoice and timesheet type UnifiedJSONData struct { // Common fields 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"` ContractorName string `json:"contractor_name"` ContractorLabel string `json:"contractor_label"` ContractorEmail string `json:"contractor_email"` // Invoice-specific fields InvoiceNumber string `json:"invoice_number"` LineItems []LineItem `json:"line_items"` TotalAmount float64 `json:"total_amount"` // Timesheet-specific fields Entries []TimesheetEntry `json:"entries"` Timezone string `json:"timezone"` // Shared field with same value TotalHours float64 `json:"total_hours"` } func GenerateUnifiedPDF(unifiedData *UnifiedReportData, 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-unified") if err != nil { return fmt.Errorf("failed to create temp directory: %w", err) } defer func() { _ = os.RemoveAll(tempDir) }() // Create unified JSON data containing all fields needed by both templates jsonData := UnifiedJSONData{ // Common fields (from invoice data, but both should be identical) ClientName: unifiedData.InvoiceData.ClientName, ProjectName: unifiedData.InvoiceData.ProjectName, DateRangeStart: unifiedData.InvoiceData.DateRange.Start.Format("2006-01-02"), DateRangeEnd: unifiedData.InvoiceData.DateRange.End.Format("2006-01-02"), GeneratedDate: unifiedData.InvoiceData.GeneratedDate.Format("2006-01-02"), ContractorName: unifiedData.InvoiceData.ContractorName, ContractorLabel: unifiedData.InvoiceData.ContractorLabel, ContractorEmail: unifiedData.InvoiceData.ContractorEmail, TotalHours: unifiedData.InvoiceData.TotalHours, // Should match timesheet total // Invoice-specific fields InvoiceNumber: fmt.Sprintf("%04d-%02d-%03d", unifiedData.InvoiceData.DateRange.Start.Year(), unifiedData.InvoiceData.DateRange.Start.Month(), unifiedData.InvoiceData.InvoiceNumber, ), LineItems: unifiedData.InvoiceData.LineItems, TotalAmount: unifiedData.InvoiceData.TotalAmount, // Timesheet-specific fields Entries: unifiedData.TimesheetData.Entries, Timezone: unifiedData.TimesheetData.Timezone, } // 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) } // Create unified template by combining invoice and timesheet templates unifiedTemplate := templates.InvoiceTemplate + "\n\n#pagebreak()\n\n" + templates.TimesheetTemplate // Write Typst template file typstFile := filepath.Join(tempDir, "unified.typ") if err := os.WriteFile(typstFile, []byte(unifiedTemplate), 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 GenerateDefaultUnifiedFilename(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("unified_%s_%s_%s.pdf", name, dateStr, timestamp) }