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) }