package reports import ( "context" "database/sql" "fmt" "path/filepath" "time" "git.tjp.lol/punchcard/internal/queries" ) type ReportParams struct { ClientName string ProjectName string DateRange DateRange OutputPath string Timezone *time.Location } type ReportResult struct { OutputPath string TotalHours float64 TotalAmount float64 TotalEntries int } func GenerateInvoice(ctx context.Context, q *queries.Queries, params ReportParams) (*ReportResult, error) { if params.ProjectName == "" { return GenerateClientInvoice(ctx, q, params) } return GenerateProjectInvoice(ctx, q, params) } func GenerateTimesheet(ctx context.Context, q *queries.Queries, params ReportParams) (*ReportResult, error) { if params.ProjectName == "" { return GenerateClientTimesheet(ctx, q, params) } return GenerateProjectTimesheet(ctx, q, params) } func GenerateUnifiedReport(ctx context.Context, q *queries.Queries, params ReportParams) (*ReportResult, error) { if params.ProjectName == "" { return GenerateClientUnifiedReport(ctx, q, params) } return GenerateProjectUnifiedReport(ctx, q, params) } func GenerateClientInvoice(ctx context.Context, q *queries.Queries, params ReportParams) (*ReportResult, error) { client, err := findClientByName(ctx, q, params.ClientName) if err != nil { return nil, fmt.Errorf("client not found: %s", params.ClientName) } contractor, err := q.GetContractor(ctx) 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(ctx, queries.GetHighestInvoiceNumberParams{ Year: int64(params.DateRange.Start.Year()), Month: int64(params.DateRange.Start.Month()), }) if err != nil { return nil, fmt.Errorf("failed to get highest invoice number: %w", err) } entries, err := q.GetInvoiceDataByClient(ctx, queries.GetInvoiceDataByClientParams{ ClientID: client.ID, StartTime: params.DateRange.Start, EndTime: params.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) } invoiceData, err := GenerateInvoiceData(entries, client.ID, client.Name, "", contractor, highestNumber+1, params.DateRange) if err != nil { return nil, fmt.Errorf("failed to generate invoice data: %w", err) } outputPath := params.OutputPath if outputPath == "" { outputPath = GenerateDefaultInvoiceFilename(invoiceData.ClientName, invoiceData.ProjectName, params.DateRange) } outputPath, err = filepath.Abs(outputPath) if err != nil { return nil, fmt.Errorf("failed to resolve output path: %w", err) } if err := GenerateInvoicePDF(invoiceData, outputPath); err != nil { return nil, fmt.Errorf("failed to generate invoice PDF: %w", err) } if err := RecordInvoice(q, int64(invoiceData.DateRange.Start.Year()), int64(invoiceData.DateRange.Start.Month()), invoiceData.InvoiceNumber, invoiceData.ClientID, int64(invoiceData.TotalAmount*100), ); err != nil { return nil, err } return &ReportResult{ OutputPath: outputPath, TotalHours: invoiceData.TotalHours, TotalAmount: invoiceData.TotalAmount, TotalEntries: len(invoiceData.LineItems), }, nil } func GenerateProjectInvoice(ctx context.Context, q *queries.Queries, params ReportParams) (*ReportResult, error) { project, err := findProjectByName(ctx, q, params.ProjectName) if err != nil { return nil, fmt.Errorf("failed to find project: %w", err) } clients, err := q.FindClient(ctx, 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] contractor, err := q.GetContractor(ctx) 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(ctx, queries.GetHighestInvoiceNumberParams{ Year: int64(params.DateRange.Start.Year()), Month: int64(params.DateRange.Start.Month()), }) if err != nil { return nil, fmt.Errorf("failed to get highest invoice number: %w", err) } entries, err := q.GetInvoiceDataByProject(ctx, queries.GetInvoiceDataByProjectParams{ ProjectID: project.ID, StartTime: params.DateRange.Start, EndTime: params.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", params.ProjectName) } invoiceData, err := GenerateInvoiceData(entries, client.ID, client.Name, params.ProjectName, contractor, highestNumber+1, params.DateRange) if err != nil { return nil, fmt.Errorf("failed to generate invoice data: %w", err) } outputPath := params.OutputPath if outputPath == "" { outputPath = GenerateDefaultInvoiceFilename(invoiceData.ClientName, invoiceData.ProjectName, params.DateRange) } outputPath, err = filepath.Abs(outputPath) if err != nil { return nil, fmt.Errorf("failed to resolve output path: %w", err) } if err := GenerateInvoicePDF(invoiceData, outputPath); err != nil { return nil, fmt.Errorf("failed to generate invoice PDF: %w", err) } if err := RecordInvoice(q, int64(invoiceData.DateRange.Start.Year()), int64(invoiceData.DateRange.Start.Month()), invoiceData.InvoiceNumber, invoiceData.ClientID, int64(invoiceData.TotalAmount*100), ); err != nil { return nil, err } return &ReportResult{ OutputPath: outputPath, TotalHours: invoiceData.TotalHours, TotalAmount: invoiceData.TotalAmount, TotalEntries: len(invoiceData.LineItems), }, nil } func GenerateClientTimesheet(ctx context.Context, q *queries.Queries, params ReportParams) (*ReportResult, error) { client, err := findClientByName(ctx, q, params.ClientName) if err != nil { return nil, fmt.Errorf("client not found: %s", params.ClientName) } contractor, err := q.GetContractor(ctx) 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) } entries, err := q.GetTimesheetDataByClient(ctx, queries.GetTimesheetDataByClientParams{ ClientID: client.ID, StartTime: params.DateRange.Start, EndTime: params.DateRange.End, }) if err != nil { return nil, fmt.Errorf("failed to get timesheet 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) } timesheetData, err := GenerateTimesheetData(entries, client.ID, client.Name, "", contractor, params.DateRange, params.Timezone) if err != nil { return nil, fmt.Errorf("failed to generate timesheet data: %w", err) } outputPath := params.OutputPath if outputPath == "" { outputPath = GenerateDefaultTimesheetFilename(timesheetData.ClientName, timesheetData.ProjectName, params.DateRange) } outputPath, err = filepath.Abs(outputPath) if err != nil { return nil, fmt.Errorf("failed to resolve output path: %w", err) } if err := GenerateTimesheetPDF(timesheetData, outputPath); err != nil { return nil, fmt.Errorf("failed to generate timesheet PDF: %w", err) } return &ReportResult{ OutputPath: outputPath, TotalHours: timesheetData.TotalHours, TotalAmount: 0, // Timesheets don't have amounts TotalEntries: len(timesheetData.Entries), }, nil } func GenerateProjectTimesheet(ctx context.Context, q *queries.Queries, params ReportParams) (*ReportResult, error) { project, err := findProjectByName(ctx, q, params.ProjectName) if err != nil { return nil, fmt.Errorf("failed to find project: %w", err) } clients, err := q.FindClient(ctx, 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] contractor, err := q.GetContractor(ctx) 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) } entries, err := q.GetTimesheetDataByProject(ctx, queries.GetTimesheetDataByProjectParams{ ProjectID: project.ID, StartTime: params.DateRange.Start, EndTime: params.DateRange.End, }) if err != nil { return nil, fmt.Errorf("failed to get timesheet data: %w", err) } if len(entries) == 0 { return nil, fmt.Errorf("no completed time entries found for project %s in the specified date range", params.ProjectName) } timesheetData, err := GenerateTimesheetData(entries, client.ID, client.Name, params.ProjectName, contractor, params.DateRange, params.Timezone) if err != nil { return nil, fmt.Errorf("failed to generate timesheet data: %w", err) } outputPath := params.OutputPath if outputPath == "" { outputPath = GenerateDefaultTimesheetFilename(timesheetData.ClientName, timesheetData.ProjectName, params.DateRange) } outputPath, err = filepath.Abs(outputPath) if err != nil { return nil, fmt.Errorf("failed to resolve output path: %w", err) } if err := GenerateTimesheetPDF(timesheetData, outputPath); err != nil { return nil, fmt.Errorf("failed to generate timesheet PDF: %w", err) } return &ReportResult{ OutputPath: outputPath, TotalHours: timesheetData.TotalHours, TotalAmount: 0, // Timesheets don't have amounts TotalEntries: len(timesheetData.Entries), }, nil } func GenerateClientUnifiedReport(ctx context.Context, q *queries.Queries, params ReportParams) (*ReportResult, error) { client, err := findClientByName(ctx, q, params.ClientName) if err != nil { return nil, fmt.Errorf("client not found: %s", params.ClientName) } contractor, err := q.GetContractor(ctx) 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(ctx, queries.GetHighestInvoiceNumberParams{ Year: int64(params.DateRange.Start.Year()), Month: int64(params.DateRange.Start.Month()), }) if err != nil { return nil, fmt.Errorf("failed to get highest invoice number: %w", err) } invoiceEntries, err := q.GetInvoiceDataByClient(ctx, queries.GetInvoiceDataByClientParams{ ClientID: client.ID, StartTime: params.DateRange.Start, EndTime: params.DateRange.End, }) if err != nil { return nil, fmt.Errorf("failed to get invoice data: %w", err) } if len(invoiceEntries) == 0 { return nil, fmt.Errorf("no completed time entries found for client %s in the specified date range", client.Name) } unifiedData, err := GenerateUnifiedReportData(invoiceEntries, client.ID, client.Name, "", contractor, highestNumber+1, params.DateRange, params.Timezone) if err != nil { return nil, fmt.Errorf("failed to generate unified report data: %w", err) } outputPath := params.OutputPath if outputPath == "" { outputPath = GenerateDefaultUnifiedFilename(unifiedData.InvoiceData.ClientName, unifiedData.InvoiceData.ProjectName, params.DateRange) } outputPath, err = filepath.Abs(outputPath) if err != nil { return nil, fmt.Errorf("failed to resolve output path: %w", err) } if err := GenerateUnifiedPDF(unifiedData, outputPath); err != nil { return nil, fmt.Errorf("failed to generate unified PDF: %w", err) } if err := RecordInvoice(q, int64(unifiedData.InvoiceData.DateRange.Start.Year()), int64(unifiedData.InvoiceData.DateRange.Start.Month()), unifiedData.InvoiceData.InvoiceNumber, unifiedData.InvoiceData.ClientID, int64(unifiedData.InvoiceData.TotalAmount*100), ); err != nil { return nil, err } return &ReportResult{ OutputPath: outputPath, TotalHours: unifiedData.InvoiceData.TotalHours, TotalAmount: unifiedData.InvoiceData.TotalAmount, TotalEntries: len(unifiedData.TimesheetData.Entries), }, nil } func GenerateProjectUnifiedReport(ctx context.Context, q *queries.Queries, params ReportParams) (*ReportResult, error) { project, err := findProjectByName(ctx, q, params.ProjectName) if err != nil { return nil, fmt.Errorf("failed to find project: %w", err) } clients, err := q.FindClient(ctx, 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] contractor, err := q.GetContractor(ctx) 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(ctx, queries.GetHighestInvoiceNumberParams{ Year: int64(params.DateRange.Start.Year()), Month: int64(params.DateRange.Start.Month()), }) if err != nil { return nil, fmt.Errorf("failed to get highest invoice number: %w", err) } invoiceEntries, err := q.GetInvoiceDataByProject(ctx, queries.GetInvoiceDataByProjectParams{ ProjectID: project.ID, StartTime: params.DateRange.Start, EndTime: params.DateRange.End, }) if err != nil { return nil, fmt.Errorf("failed to get invoice data: %w", err) } if len(invoiceEntries) == 0 { return nil, fmt.Errorf("no completed time entries found for project %s in the specified date range", params.ProjectName) } unifiedData, err := GenerateUnifiedReportData(invoiceEntries, client.ID, client.Name, params.ProjectName, contractor, highestNumber+1, params.DateRange, params.Timezone) if err != nil { return nil, fmt.Errorf("failed to generate unified report data: %w", err) } outputPath := params.OutputPath if outputPath == "" { outputPath = GenerateDefaultUnifiedFilename(unifiedData.InvoiceData.ClientName, unifiedData.InvoiceData.ProjectName, params.DateRange) } outputPath, err = filepath.Abs(outputPath) if err != nil { return nil, fmt.Errorf("failed to resolve output path: %w", err) } if err := GenerateUnifiedPDF(unifiedData, outputPath); err != nil { return nil, fmt.Errorf("failed to generate unified PDF: %w", err) } if err := RecordInvoice(q, int64(unifiedData.InvoiceData.DateRange.Start.Year()), int64(unifiedData.InvoiceData.DateRange.Start.Month()), unifiedData.InvoiceData.InvoiceNumber, unifiedData.InvoiceData.ClientID, int64(unifiedData.InvoiceData.TotalAmount*100), ); err != nil { return nil, err } return &ReportResult{ OutputPath: outputPath, TotalHours: unifiedData.InvoiceData.TotalHours, TotalAmount: unifiedData.InvoiceData.TotalAmount, TotalEntries: len(unifiedData.TimesheetData.Entries), }, nil } // Helper functions for finding clients and projects func findClientByName(ctx context.Context, q *queries.Queries, clientName string) (queries.Client, error) { clients, err := q.FindClient(ctx, queries.FindClientParams{ ID: 0, Name: clientName, }) if err != nil { return queries.Client{}, err } if len(clients) == 0 { return queries.Client{}, sql.ErrNoRows } return clients[0], nil } func findProjectByName(ctx context.Context, q *queries.Queries, projectName string) (queries.Project, error) { projects, err := q.FindProject(ctx, queries.FindProjectParams{ ID: 0, Name: projectName, }) if err != nil { return queries.Project{}, err } if len(projects) == 0 { return queries.Project{}, sql.ErrNoRows } return projects[0], nil }