summaryrefslogtreecommitdiff
path: root/internal/reports/invoice.go
diff options
context:
space:
mode:
authorT <t@tjp.lol>2025-08-04 09:49:52 -0600
committerT <t@tjp.lol>2025-08-04 15:15:18 -0600
commit56e0af3b41742876b471332aeb943a5a2ca8dfbf (patch)
treeef75f4900107ef28977823eabd11ec3014cd40ba /internal/reports/invoice.go
parent4c29dfee9be26996ce548e2edf0328422df598d0 (diff)
Generate invoice PDFs
Diffstat (limited to 'internal/reports/invoice.go')
-rw-r--r--internal/reports/invoice.go239
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,
+ }
+}