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