diff options
Diffstat (limited to 'internal/reports/invoice.go')
-rw-r--r-- | internal/reports/invoice.go | 239 |
1 files changed, 239 insertions, 0 deletions
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, + } +} |