summaryrefslogtreecommitdiff
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
parent4c29dfee9be26996ce548e2edf0328422df598d0 (diff)
Generate invoice PDFs
-rw-r--r--go.mod7
-rw-r--r--go.sum26
-rw-r--r--internal/commands/report.go207
-rw-r--r--internal/commands/root.go1
-rw-r--r--internal/commands/set.go333
-rw-r--r--internal/database/queries.sql101
-rw-r--r--internal/database/schema.sql20
-rw-r--r--internal/queries/models.go18
-rw-r--r--internal/queries/queries.sql.go365
-rw-r--r--internal/reports/daterange.go108
-rw-r--r--internal/reports/invoice.go239
-rw-r--r--internal/reports/pdf.go131
-rw-r--r--internal/reports/pdf_test.go122
-rw-r--r--internal/reports/testdata/invoice_test_data.json27
-rw-r--r--templates/embeds.go7
-rw-r--r--templates/invoice.typ148
16 files changed, 1854 insertions, 6 deletions
diff --git a/go.mod b/go.mod
index 4c39953..056f5cc 100644
--- a/go.mod
+++ b/go.mod
@@ -3,18 +3,21 @@ module punchcard
go 1.24.4
require (
+ github.com/spf13/cobra v1.9.1
+ modernc.org/sqlite v1.38.2
+)
+
+require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
- github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.34.0 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
- modernc.org/sqlite v1.38.2 // indirect
)
diff --git a/go.sum b/go.sum
index e2cdc92..6c60822 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,8 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -18,16 +20,40 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
+golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
+golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
+golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
+golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
+golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
+modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
+modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
+modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
+modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
+modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
+modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/internal/commands/report.go b/internal/commands/report.go
index eaa1b49..25e0483 100644
--- a/internal/commands/report.go
+++ b/internal/commands/report.go
@@ -1,7 +1,15 @@
package commands
import (
+ "context"
+ "database/sql"
"fmt"
+ "path/filepath"
+
+ punchctx "punchcard/internal/context"
+ "punchcard/internal/database"
+ "punchcard/internal/queries"
+ "punchcard/internal/reports"
"github.com/spf13/cobra"
)
@@ -20,15 +28,206 @@ func NewReportCmd() *cobra.Command {
}
func NewReportInvoiceCmd() *cobra.Command {
- return &cobra.Command{
+ cmd := &cobra.Command{
Use: "invoice",
Short: "Generate a PDF invoice",
- Long: "Generate a PDF invoice from tracked time.",
+ Long: `Generate a PDF invoice from tracked time. Either --client or --project must be specified.
+
+Examples:
+ # Generate invoice for last month (default)
+ punch report invoice -c "Acme Corp"
+
+ # Generate invoice for last week
+ punch report invoice -c "Acme Corp" -d "last week"
+
+ # Generate invoice for custom date range
+ punch report invoice -c "Acme Corp" -d "2025-06-01 to 2025-06-30"
+
+ # Generate invoice for specific project
+ punch report invoice -p "Website Redesign" -d "2025-01-01 to 2025-01-31"`,
RunE: func(cmd *cobra.Command, args []string) error {
- fmt.Println("Invoice generation (placeholder)")
- return nil
+ return runInvoiceCommand(cmd, args)
},
}
+
+ cmd.Flags().StringP("client", "c", "", "Generate invoice for specific client")
+ cmd.Flags().StringP("project", "p", "", "Generate invoice for specific project")
+ cmd.Flags().StringP("dates", "d", "last month", "Date range ('last week', 'last month', or 'YYYY-MM-DD to YYYY-MM-DD')")
+ cmd.Flags().StringP("output", "o", "", "Output file path (default: auto-generated filename)")
+
+ return cmd
+}
+
+func runInvoiceCommand(cmd *cobra.Command, args []string) error {
+ // Get flag values
+ clientName, _ := cmd.Flags().GetString("client")
+ projectName, _ := cmd.Flags().GetString("project")
+ dateStr, _ := cmd.Flags().GetString("dates")
+ outputPath, _ := cmd.Flags().GetString("output")
+
+ // Validate flags
+ if clientName == "" && projectName == "" {
+ return fmt.Errorf("either --client or --project must be specified")
+ }
+ if clientName != "" && projectName != "" {
+ return fmt.Errorf("--client and --project are mutually exclusive")
+ }
+
+ // Parse date range
+ dateRange, err := reports.ParseDateRange(dateStr)
+ if err != nil {
+ return fmt.Errorf("invalid date range: %w", err)
+ }
+
+ // Get database connection
+ q := punchctx.GetDB(cmd.Context())
+ if q == nil {
+ var err error
+ q, err = database.GetDB()
+ if err != nil {
+ return fmt.Errorf("failed to connect to database: %w", err)
+ }
+ }
+
+ // Generate invoice based on client or project
+ var invoiceData *reports.InvoiceData
+ if clientName != "" {
+ invoiceData, err = generateClientInvoice(q, clientName, dateRange)
+ } else {
+ invoiceData, err = generateProjectInvoice(q, projectName, dateRange)
+ }
+ if err != nil {
+ return err
+ }
+
+ // Generate output filename if not specified
+ if outputPath == "" {
+ outputPath = reports.GenerateDefaultInvoiceFilename(invoiceData.ClientName, invoiceData.ProjectName, dateRange)
+ }
+
+ // Convert to absolute path
+ outputPath, err = filepath.Abs(outputPath)
+ if err != nil {
+ return fmt.Errorf("failed to resolve output path: %w", err)
+ }
+
+ // Generate PDF
+ err = reports.GenerateInvoicePDF(invoiceData, outputPath)
+ if err != nil {
+ return fmt.Errorf("failed to generate invoice PDF: %w", err)
+ }
+
+ if _, err := q.CreateInvoice(cmd.Context(), queries.CreateInvoiceParams{
+ Year: int64(invoiceData.DateRange.Start.Year()),
+ Month: int64(invoiceData.DateRange.Start.Month()),
+ Number: invoiceData.InvoiceNumber,
+ ClientID: invoiceData.ClientID,
+ TotalAmount: int64(invoiceData.TotalAmount * 100),
+ }); err != nil {
+ return fmt.Errorf("failed to record invoice in database: %w", err)
+ }
+
+ fmt.Printf("Invoice generated successfully: %s\n", outputPath)
+ fmt.Printf("Total hours: %.2f\n", invoiceData.TotalHours)
+ fmt.Printf("Total amount: $%.2f\n", invoiceData.TotalAmount)
+
+ return nil
+}
+
+func generateClientInvoice(q *queries.Queries, clientName string, dateRange reports.DateRange) (*reports.InvoiceData, error) {
+ // Find client
+ client, err := findClient(context.Background(), q, clientName)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("client not found: %s", clientName)
+ }
+ return nil, fmt.Errorf("failed to find client: %w", err)
+ }
+
+ // Get contractor data
+ contractor, err := q.GetContractor(context.Background())
+ 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(context.Background(), queries.GetHighestInvoiceNumberParams{
+ Year: int64(dateRange.Start.Year()),
+ Month: int64(dateRange.Start.Month()),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to get highest invoice number: %w", err)
+ }
+
+ // Get invoice data
+ entries, err := q.GetInvoiceDataByClient(context.Background(), queries.GetInvoiceDataByClientParams{
+ ClientID: client.ID,
+ StartTime: dateRange.Start,
+ EndTime: 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)
+ }
+
+ return reports.GenerateInvoiceData(entries, client.ID, client.Name, "", contractor, highestNumber+1, dateRange)
+}
+
+func generateProjectInvoice(q *queries.Queries, projectName string, dateRange reports.DateRange) (*reports.InvoiceData, error) {
+ // We need to find the project, but we need the client info too
+ // Let's first find all projects with this name
+ project, err := findProject(context.Background(), q, projectName)
+ if err != nil {
+ return nil, fmt.Errorf("failed to find project: %w", err)
+ }
+
+ // Get client info
+ clients, err := q.FindClient(context.Background(), 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]
+
+ // Get contractor data
+ contractor, err := q.GetContractor(context.Background())
+ 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(context.Background(), queries.GetHighestInvoiceNumberParams{
+ Year: int64(dateRange.Start.Year()),
+ Month: int64(dateRange.Start.Month()),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to get highest invoice number: %w", err)
+ }
+
+ // Get invoice data
+ entries, err := q.GetInvoiceDataByProject(context.Background(), queries.GetInvoiceDataByProjectParams{
+ ProjectID: project.ID,
+ StartTime: dateRange.Start,
+ EndTime: 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", projectName)
+ }
+
+ return reports.GenerateInvoiceData(entries, client.ID, client.Name, projectName, contractor, highestNumber+1, dateRange)
}
func NewReportTimesheetCmd() *cobra.Command {
diff --git a/internal/commands/root.go b/internal/commands/root.go
index 6c400ee..04f1203 100644
--- a/internal/commands/root.go
+++ b/internal/commands/root.go
@@ -24,6 +24,7 @@ func NewRootCmd() *cobra.Command {
cmd.AddCommand(NewStatusCmd())
cmd.AddCommand(NewImportCmd())
cmd.AddCommand(NewReportCmd())
+ cmd.AddCommand(NewSetCmd())
return cmd
}
diff --git a/internal/commands/set.go b/internal/commands/set.go
new file mode 100644
index 0000000..32f3b96
--- /dev/null
+++ b/internal/commands/set.go
@@ -0,0 +1,333 @@
+package commands
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "strconv"
+ "strings"
+
+ punchctx "punchcard/internal/context"
+ "punchcard/internal/database"
+ "punchcard/internal/queries"
+
+ "github.com/spf13/cobra"
+)
+
+func NewSetCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "set [key=value ...]",
+ Short: "Set configuration values for clients, projects, or contractor info",
+ Long: `Set configuration values using key=value pairs.
+
+Examples:
+ # Set contractor information (no flags)
+ punch set name="John Doe" label="Software Engineer" email="john@example.com"
+
+ # Set client information
+ punch set -c "Acme Corp" name="Acme Corporation" email="billing@acme.com" hourly-rate=150.00
+
+ # Set project information
+ punch set -p "Website Redesign" name="Website Redesign v2" hourly-rate=180.00
+
+Valid keys:
+ - With no flags (contractor): name, label, email
+ - With -c/--client: name, email, hourly-rate (in dollars)
+ - With -p/--project: name, hourly-rate (in dollars)`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return runSetCommand(cmd, args)
+ },
+ }
+
+ cmd.Flags().StringP("client", "c", "", "Set values for specified client")
+ cmd.Flags().StringP("project", "p", "", "Set values for specified project")
+ cmd.MarkFlagsMutuallyExclusive("client", "project")
+
+ return cmd
+}
+
+func runSetCommand(cmd *cobra.Command, args []string) error {
+ // Get flag values
+ clientName, _ := cmd.Flags().GetString("client")
+ projectName, _ := cmd.Flags().GetString("project")
+
+ // Parse key=value pairs
+ updates, err := parseKeyValuePairs(args)
+ if err != nil {
+ return err
+ }
+
+ if len(updates) == 0 {
+ return fmt.Errorf("no key=value pairs provided")
+ }
+
+ // Get database connection
+ q := punchctx.GetDB(cmd.Context())
+ if q == nil {
+ var err error
+ q, err = database.GetDB()
+ if err != nil {
+ return fmt.Errorf("failed to connect to database: %w", err)
+ }
+ }
+
+ // Route to appropriate handler
+ if clientName != "" {
+ return setClientValues(q, clientName, updates)
+ } else if projectName != "" {
+ return setProjectValues(q, projectName, updates)
+ } else {
+ return setContractorValues(q, updates)
+ }
+}
+
+func parseKeyValuePairs(args []string) (map[string]string, error) {
+ updates := make(map[string]string)
+
+ for _, arg := range args {
+ parts := strings.SplitN(arg, "=", 2)
+ if len(parts) != 2 {
+ return nil, fmt.Errorf("invalid key=value pair: %s", arg)
+ }
+
+ key := strings.TrimSpace(parts[0])
+ value := strings.TrimSpace(parts[1])
+
+ if key == "" {
+ return nil, fmt.Errorf("empty key in pair: %s", arg)
+ }
+
+ updates[key] = value
+ }
+
+ return updates, nil
+}
+
+func setClientValues(q *queries.Queries, clientName string, updates map[string]string) error {
+ // Validate keys
+ validKeys := map[string]bool{"name": true, "email": true, "hourly-rate": true}
+ for key := range updates {
+ if !validKeys[key] {
+ return fmt.Errorf("invalid key '%s' for client. Valid keys: name, email, hourly-rate", key)
+ }
+ }
+
+ // Find the client
+ client, err := findClient(context.Background(), q, clientName)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return fmt.Errorf("client not found: %s", clientName)
+ }
+ return fmt.Errorf("failed to find client: %w", err)
+ }
+
+ // Prepare update values (start with current values)
+ newName := client.Name
+ newEmail := client.Email
+ newBillableRate := client.BillableRate
+
+ // Apply updates
+ for key, value := range updates {
+ switch key {
+ case "name":
+ newName = value
+ case "email":
+ if value == "" {
+ newEmail = sql.NullString{Valid: false}
+ } else {
+ newEmail = sql.NullString{String: value, Valid: true}
+ }
+ case "hourly-rate":
+ if value == "" {
+ newBillableRate = sql.NullInt64{Valid: false}
+ } else {
+ rateFloat, err := strconv.ParseFloat(value, 64)
+ if err != nil {
+ return fmt.Errorf("invalid hourly-rate value '%s': must be a number (in dollars)", value)
+ }
+ if rateFloat < 0 {
+ return fmt.Errorf("hourly-rate must be non-negative")
+ }
+ // Convert dollars to cents
+ rateCents := int64(rateFloat * 100)
+ newBillableRate = sql.NullInt64{Int64: rateCents, Valid: true}
+ }
+ }
+ }
+
+ // Update the client
+ updated, err := q.UpdateClient(context.Background(), queries.UpdateClientParams{
+ ID: client.ID,
+ Name: newName,
+ Email: newEmail,
+ BillableRate: newBillableRate,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to update client: %w", err)
+ }
+
+ fmt.Printf("Updated client '%s':\n", clientName)
+ fmt.Printf(" name: %s\n", updated.Name)
+ if updated.Email.Valid {
+ fmt.Printf(" email: %s\n", updated.Email.String)
+ } else {
+ fmt.Printf(" email: (not set)\n")
+ }
+ if updated.BillableRate.Valid {
+ fmt.Printf(" billable_rate: %d cents ($%.2f/hour)\n", updated.BillableRate.Int64, float64(updated.BillableRate.Int64)/100.0)
+ } else {
+ fmt.Printf(" billable_rate: (not set)\n")
+ }
+
+ return nil
+}
+
+func setProjectValues(q *queries.Queries, projectName string, updates map[string]string) error {
+ // Validate keys
+ validKeys := map[string]bool{"name": true, "hourly-rate": true}
+ for key := range updates {
+ if !validKeys[key] {
+ return fmt.Errorf("invalid key '%s' for project. Valid keys: name, hourly-rate", key)
+ }
+ }
+
+ // Find the project
+ project, err := findProject(context.Background(), q, projectName)
+ if err != nil {
+ return fmt.Errorf("failed to find project: %w", err)
+ }
+
+ // Prepare update values (start with current values)
+ newName := project.Name
+ newBillableRate := project.BillableRate
+
+ // Apply updates
+ for key, value := range updates {
+ switch key {
+ case "name":
+ newName = value
+ case "hourly-rate":
+ if value == "" {
+ newBillableRate = sql.NullInt64{Valid: false}
+ } else {
+ rateFloat, err := strconv.ParseFloat(value, 64)
+ if err != nil {
+ return fmt.Errorf("invalid hourly-rate value '%s': must be a number (in dollars)", value)
+ }
+ if rateFloat < 0 {
+ return fmt.Errorf("hourly-rate must be non-negative")
+ }
+ // Convert dollars to cents
+ rateCents := int64(rateFloat * 100)
+ newBillableRate = sql.NullInt64{Int64: rateCents, Valid: true}
+ }
+ }
+ }
+
+ // Update the project
+ updated, err := q.UpdateProject(context.Background(), queries.UpdateProjectParams{
+ ID: project.ID,
+ Name: newName,
+ BillableRate: newBillableRate,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to update project: %w", err)
+ }
+
+ fmt.Printf("Updated project '%s':\n", projectName)
+ fmt.Printf(" name: %s\n", updated.Name)
+ if updated.BillableRate.Valid {
+ fmt.Printf(" billable_rate: %d cents ($%.2f/hour)\n", updated.BillableRate.Int64, float64(updated.BillableRate.Int64)/100.0)
+ } else {
+ fmt.Printf(" billable_rate: (not set)\n")
+ }
+
+ return nil
+}
+
+func setContractorValues(q *queries.Queries, updates map[string]string) error {
+ // Validate keys
+ validKeys := map[string]bool{"name": true, "label": true, "email": true}
+ for key := range updates {
+ if !validKeys[key] {
+ return fmt.Errorf("invalid key '%s' for contractor. Valid keys: name, label, email", key)
+ }
+ }
+
+ // Try to get existing contractor
+ contractor, err := q.GetContractor(context.Background())
+
+ var newName, newLabel, newEmail string
+
+ if err == sql.ErrNoRows {
+ // No contractor exists, we'll create one
+ // Set default values
+ newName = ""
+ newLabel = ""
+ newEmail = ""
+ } else if err != nil {
+ return fmt.Errorf("failed to get contractor information: %w", err)
+ } else {
+ // Contractor exists, start with current values
+ newName = contractor.Name
+ newLabel = contractor.Label
+ newEmail = contractor.Email
+ }
+
+ // Apply updates
+ for key, value := range updates {
+ switch key {
+ case "name":
+ newName = value
+ case "label":
+ newLabel = value
+ case "email":
+ newEmail = value
+ }
+ }
+
+ // Validate required fields
+ if newName == "" {
+ return fmt.Errorf("contractor name cannot be empty")
+ }
+ if newLabel == "" {
+ return fmt.Errorf("contractor label cannot be empty")
+ }
+ if newEmail == "" {
+ return fmt.Errorf("contractor email cannot be empty")
+ }
+
+ // Create or update contractor
+ if err == sql.ErrNoRows {
+ // Create new contractor
+ created, err := q.CreateContractor(context.Background(), queries.CreateContractorParams{
+ Name: newName,
+ Label: newLabel,
+ Email: newEmail,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to create contractor: %w", err)
+ }
+ fmt.Printf("Created contractor:\n")
+ fmt.Printf(" name: %s\n", created.Name)
+ fmt.Printf(" label: %s\n", created.Label)
+ fmt.Printf(" email: %s\n", created.Email)
+ } else {
+ // Update existing contractor
+ updated, err := q.UpdateContractor(context.Background(), queries.UpdateContractorParams{
+ Name: newName,
+ Label: newLabel,
+ Email: newEmail,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to update contractor: %w", err)
+ }
+ fmt.Printf("Updated contractor:\n")
+ fmt.Printf(" name: %s\n", updated.Name)
+ fmt.Printf(" label: %s\n", updated.Label)
+ fmt.Printf(" email: %s\n", updated.Email)
+ }
+
+ return nil
+}
+
diff --git a/internal/database/queries.sql b/internal/database/queries.sql
index 073574a..32114a6 100644
--- a/internal/database/queries.sql
+++ b/internal/database/queries.sql
@@ -130,3 +130,104 @@ values (
)
)
returning *;
+
+-- name: GetInvoiceDataByClient :many
+select
+ te.id as time_entry_id,
+ te.start_time,
+ te.end_time,
+ te.description,
+ te.billable_rate as entry_billable_rate,
+ c.id as client_id,
+ c.name as client_name,
+ c.billable_rate as client_billable_rate,
+ p.id as project_id,
+ p.name as project_name,
+ p.billable_rate as project_billable_rate,
+ cast(round((julianday(te.end_time) - julianday(te.start_time)) * 24 * 60 * 60) as integer) as duration_seconds,
+ case
+ when te.billable_rate is not null then 'entry'
+ when p.billable_rate is not null then 'project'
+ else 'client'
+ end as rate_source
+from time_entry te
+join client c on te.client_id = c.id
+left join project p on te.project_id = p.id
+where c.id = @client_id
+ and te.start_time >= @start_time
+ and te.start_time <= @end_time
+ and te.end_time is not null
+order by te.start_time;
+
+-- name: GetInvoiceDataByProject :many
+select
+ te.id as time_entry_id,
+ te.start_time,
+ te.end_time,
+ te.description,
+ te.billable_rate as entry_billable_rate,
+ c.id as client_id,
+ c.name as client_name,
+ c.billable_rate as client_billable_rate,
+ p.id as project_id,
+ p.name as project_name,
+ p.billable_rate as project_billable_rate,
+ cast(
+ case
+ when te.end_time is null then
+ (julianday('now', 'utc') - julianday(te.start_time)) * 24 * 60 * 60
+ else
+ (julianday(te.end_time) - julianday(te.start_time)) * 24 * 60 * 60
+ end as integer
+ ) as duration_seconds,
+ case
+ when te.billable_rate is not null then 'entry'
+ when p.billable_rate is not null then 'project'
+ else 'client'
+ end as rate_source
+from time_entry te
+join client c on te.client_id = c.id
+join project p on te.project_id = p.id
+where p.id = @project_id
+ and te.start_time >= @start_time
+ and te.start_time <= @end_time
+ and te.end_time is not null
+order by te.start_time;
+
+-- name: GetContractor :one
+select * from contractor
+order by id
+limit 1;
+
+-- name: CreateContractor :one
+insert into contractor (name, label, email)
+values (@name, @label, @email)
+returning *;
+
+-- name: UpdateContractor :one
+update contractor
+set name = @name, label = @label, email = @email
+where id = (select id from contractor order by id limit 1)
+returning *;
+
+-- name: UpdateClient :one
+update client
+set name = @name, email = @email, billable_rate = @billable_rate
+where id = @id
+returning *;
+
+-- name: UpdateProject :one
+update project
+set name = @name, billable_rate = @billable_rate
+where id = @id
+returning *;
+
+-- name: GetHighestInvoiceNumber :one
+select cast(coalesce(max(number), 0) as integer) as max_number
+from invoice
+where year = @year and month = @month;
+
+-- name: CreateInvoice :one
+insert into invoice (year, month, number, client_id, total_amount)
+values (@year, @month, @number, @client_id, @total_amount)
+returning *;
diff --git a/internal/database/schema.sql b/internal/database/schema.sql
index a4483e1..84f4f02 100644
--- a/internal/database/schema.sql
+++ b/internal/database/schema.sql
@@ -15,6 +15,26 @@ create table if not exists project (
foreign key (client_id) references client(id)
);
+create table if not exists contractor (
+ id integer primary key autoincrement,
+ name text not null,
+ label text not null,
+ email text not null,
+ created_at datetime default current_timestamp
+);
+
+create table if not exists invoice (
+ id integer primary key autoincrement,
+ year integer not null,
+ month integer not null,
+ number integer not null,
+ client_id integer not null,
+ total_amount integer not null,
+ created_at datetime default current_timestamp,
+ unique(year, month, number),
+ foreign key (client_id) references client(id)
+);
+
create table if not exists time_entry (
id integer primary key autoincrement,
start_time datetime not null,
diff --git a/internal/queries/models.go b/internal/queries/models.go
index 1257397..b42de02 100644
--- a/internal/queries/models.go
+++ b/internal/queries/models.go
@@ -17,6 +17,24 @@ type Client struct {
CreatedAt sql.NullTime
}
+type Contractor struct {
+ ID int64
+ Name string
+ Label string
+ Email string
+ CreatedAt sql.NullTime
+}
+
+type Invoice struct {
+ ID int64
+ Year int64
+ Month int64
+ Number int64
+ ClientID int64
+ TotalAmount int64
+ CreatedAt sql.NullTime
+}
+
type Project struct {
ID int64
Name string
diff --git a/internal/queries/queries.sql.go b/internal/queries/queries.sql.go
index 1bd8ec1..e70de22 100644
--- a/internal/queries/queries.sql.go
+++ b/internal/queries/queries.sql.go
@@ -8,6 +8,7 @@ package queries
import (
"context"
"database/sql"
+ "time"
)
const createClient = `-- name: CreateClient :one
@@ -35,6 +36,66 @@ func (q *Queries) CreateClient(ctx context.Context, arg CreateClientParams) (Cli
return i, err
}
+const createContractor = `-- name: CreateContractor :one
+insert into contractor (name, label, email)
+values (?1, ?2, ?3)
+returning id, name, label, email, created_at
+`
+
+type CreateContractorParams struct {
+ Name string
+ Label string
+ Email string
+}
+
+func (q *Queries) CreateContractor(ctx context.Context, arg CreateContractorParams) (Contractor, error) {
+ row := q.db.QueryRowContext(ctx, createContractor, arg.Name, arg.Label, arg.Email)
+ var i Contractor
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Label,
+ &i.Email,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const createInvoice = `-- name: CreateInvoice :one
+insert into invoice (year, month, number, client_id, total_amount)
+values (?1, ?2, ?3, ?4, ?5)
+returning id, year, month, number, client_id, total_amount, created_at
+`
+
+type CreateInvoiceParams struct {
+ Year int64
+ Month int64
+ Number int64
+ ClientID int64
+ TotalAmount int64
+}
+
+func (q *Queries) CreateInvoice(ctx context.Context, arg CreateInvoiceParams) (Invoice, error) {
+ row := q.db.QueryRowContext(ctx, createInvoice,
+ arg.Year,
+ arg.Month,
+ arg.Number,
+ arg.ClientID,
+ arg.TotalAmount,
+ )
+ var i Invoice
+ err := row.Scan(
+ &i.ID,
+ &i.Year,
+ &i.Month,
+ &i.Number,
+ &i.ClientID,
+ &i.TotalAmount,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
const createProject = `-- name: CreateProject :one
insert into project (name, client_id, billable_rate)
values (?1, ?2, ?3)
@@ -270,6 +331,226 @@ func (q *Queries) GetClientByName(ctx context.Context, name string) (Client, err
return i, err
}
+const getContractor = `-- name: GetContractor :one
+select id, name, label, email, created_at from contractor
+order by id
+limit 1
+`
+
+func (q *Queries) GetContractor(ctx context.Context) (Contractor, error) {
+ row := q.db.QueryRowContext(ctx, getContractor)
+ var i Contractor
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Label,
+ &i.Email,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const getHighestInvoiceNumber = `-- name: GetHighestInvoiceNumber :one
+select cast(coalesce(max(number), 0) as integer) as max_number
+from invoice
+where year = ?1 and month = ?2
+`
+
+type GetHighestInvoiceNumberParams struct {
+ Year int64
+ Month int64
+}
+
+func (q *Queries) GetHighestInvoiceNumber(ctx context.Context, arg GetHighestInvoiceNumberParams) (int64, error) {
+ row := q.db.QueryRowContext(ctx, getHighestInvoiceNumber, arg.Year, arg.Month)
+ var max_number int64
+ err := row.Scan(&max_number)
+ return max_number, err
+}
+
+const getInvoiceDataByClient = `-- name: GetInvoiceDataByClient :many
+select
+ te.id as time_entry_id,
+ te.start_time,
+ te.end_time,
+ te.description,
+ te.billable_rate as entry_billable_rate,
+ c.id as client_id,
+ c.name as client_name,
+ c.billable_rate as client_billable_rate,
+ p.id as project_id,
+ p.name as project_name,
+ p.billable_rate as project_billable_rate,
+ cast(round((julianday(te.end_time) - julianday(te.start_time)) * 24 * 60 * 60) as integer) as duration_seconds,
+ case
+ when te.billable_rate is not null then 'entry'
+ when p.billable_rate is not null then 'project'
+ else 'client'
+ end as rate_source
+from time_entry te
+join client c on te.client_id = c.id
+left join project p on te.project_id = p.id
+where c.id = ?1
+ and te.start_time >= ?2
+ and te.start_time <= ?3
+ and te.end_time is not null
+order by te.start_time
+`
+
+type GetInvoiceDataByClientParams struct {
+ ClientID int64
+ StartTime time.Time
+ EndTime time.Time
+}
+
+type GetInvoiceDataByClientRow 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 (q *Queries) GetInvoiceDataByClient(ctx context.Context, arg GetInvoiceDataByClientParams) ([]GetInvoiceDataByClientRow, error) {
+ rows, err := q.db.QueryContext(ctx, getInvoiceDataByClient, arg.ClientID, arg.StartTime, arg.EndTime)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetInvoiceDataByClientRow
+ for rows.Next() {
+ var i GetInvoiceDataByClientRow
+ if err := rows.Scan(
+ &i.TimeEntryID,
+ &i.StartTime,
+ &i.EndTime,
+ &i.Description,
+ &i.EntryBillableRate,
+ &i.ClientID,
+ &i.ClientName,
+ &i.ClientBillableRate,
+ &i.ProjectID,
+ &i.ProjectName,
+ &i.ProjectBillableRate,
+ &i.DurationSeconds,
+ &i.RateSource,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getInvoiceDataByProject = `-- name: GetInvoiceDataByProject :many
+select
+ te.id as time_entry_id,
+ te.start_time,
+ te.end_time,
+ te.description,
+ te.billable_rate as entry_billable_rate,
+ c.id as client_id,
+ c.name as client_name,
+ c.billable_rate as client_billable_rate,
+ p.id as project_id,
+ p.name as project_name,
+ p.billable_rate as project_billable_rate,
+ cast(
+ case
+ when te.end_time is null then
+ (julianday('now', 'utc') - julianday(te.start_time)) * 24 * 60 * 60
+ else
+ (julianday(te.end_time) - julianday(te.start_time)) * 24 * 60 * 60
+ end as integer
+ ) as duration_seconds,
+ case
+ when te.billable_rate is not null then 'entry'
+ when p.billable_rate is not null then 'project'
+ else 'client'
+ end as rate_source
+from time_entry te
+join client c on te.client_id = c.id
+join project p on te.project_id = p.id
+where p.id = ?1
+ and te.start_time >= ?2
+ and te.start_time <= ?3
+ and te.end_time is not null
+order by te.start_time
+`
+
+type GetInvoiceDataByProjectParams struct {
+ ProjectID int64
+ StartTime time.Time
+ EndTime time.Time
+}
+
+type GetInvoiceDataByProjectRow struct {
+ TimeEntryID int64
+ StartTime time.Time
+ EndTime sql.NullTime
+ Description sql.NullString
+ EntryBillableRate sql.NullInt64
+ ClientID int64
+ ClientName string
+ ClientBillableRate sql.NullInt64
+ ProjectID int64
+ ProjectName string
+ ProjectBillableRate sql.NullInt64
+ DurationSeconds int64
+ RateSource string
+}
+
+func (q *Queries) GetInvoiceDataByProject(ctx context.Context, arg GetInvoiceDataByProjectParams) ([]GetInvoiceDataByProjectRow, error) {
+ rows, err := q.db.QueryContext(ctx, getInvoiceDataByProject, arg.ProjectID, arg.StartTime, arg.EndTime)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetInvoiceDataByProjectRow
+ for rows.Next() {
+ var i GetInvoiceDataByProjectRow
+ if err := rows.Scan(
+ &i.TimeEntryID,
+ &i.StartTime,
+ &i.EndTime,
+ &i.Description,
+ &i.EntryBillableRate,
+ &i.ClientID,
+ &i.ClientName,
+ &i.ClientBillableRate,
+ &i.ProjectID,
+ &i.ProjectName,
+ &i.ProjectBillableRate,
+ &i.DurationSeconds,
+ &i.RateSource,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const getMonthSummaryByProject = `-- name: GetMonthSummaryByProject :many
select
p.id as project_id,
@@ -539,3 +820,87 @@ func (q *Queries) StopTimeEntry(ctx context.Context) (TimeEntry, error) {
)
return i, err
}
+
+const updateClient = `-- name: UpdateClient :one
+update client
+set name = ?1, email = ?2, billable_rate = ?3
+where id = ?4
+returning id, name, email, billable_rate, created_at
+`
+
+type UpdateClientParams struct {
+ Name string
+ Email sql.NullString
+ BillableRate sql.NullInt64
+ ID int64
+}
+
+func (q *Queries) UpdateClient(ctx context.Context, arg UpdateClientParams) (Client, error) {
+ row := q.db.QueryRowContext(ctx, updateClient,
+ arg.Name,
+ arg.Email,
+ arg.BillableRate,
+ arg.ID,
+ )
+ var i Client
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Email,
+ &i.BillableRate,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const updateContractor = `-- name: UpdateContractor :one
+update contractor
+set name = ?1, label = ?2, email = ?3
+where id = (select id from contractor order by id limit 1)
+returning id, name, label, email, created_at
+`
+
+type UpdateContractorParams struct {
+ Name string
+ Label string
+ Email string
+}
+
+func (q *Queries) UpdateContractor(ctx context.Context, arg UpdateContractorParams) (Contractor, error) {
+ row := q.db.QueryRowContext(ctx, updateContractor, arg.Name, arg.Label, arg.Email)
+ var i Contractor
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Label,
+ &i.Email,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const updateProject = `-- name: UpdateProject :one
+update project
+set name = ?1, billable_rate = ?2
+where id = ?3
+returning id, name, client_id, billable_rate, created_at
+`
+
+type UpdateProjectParams struct {
+ Name string
+ BillableRate sql.NullInt64
+ ID int64
+}
+
+func (q *Queries) UpdateProject(ctx context.Context, arg UpdateProjectParams) (Project, error) {
+ row := q.db.QueryRowContext(ctx, updateProject, arg.Name, arg.BillableRate, arg.ID)
+ var i Project
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.ClientID,
+ &i.BillableRate,
+ &i.CreatedAt,
+ )
+ return i, err
+}
diff --git a/internal/reports/daterange.go b/internal/reports/daterange.go
new file mode 100644
index 0000000..3478615
--- /dev/null
+++ b/internal/reports/daterange.go
@@ -0,0 +1,108 @@
+package reports
+
+import (
+ "fmt"
+ "strings"
+ "time"
+)
+
+type DateRange struct {
+ Start time.Time
+ End time.Time
+}
+
+func ParseDateRange(dateStr string) (DateRange, error) {
+ dateStr = strings.TrimSpace(dateStr)
+ now := time.Now().UTC()
+
+ // Check for predefined ranges (case-insensitive)
+ lowerDateStr := strings.ToLower(dateStr)
+ switch lowerDateStr {
+ case "last week":
+ return getLastWeek(now), nil
+ case "last month":
+ return getLastMonth(now), nil
+ }
+
+ // Check for custom date range format: "YYYY-MM-DD to YYYY-MM-DD"
+ if strings.Contains(dateStr, " to ") {
+ return parseCustomDateRange(dateStr)
+ }
+
+ return DateRange{}, fmt.Errorf("unsupported date range: %s (supported: 'last week', 'last month', or 'YYYY-MM-DD to YYYY-MM-DD')", dateStr)
+}
+
+func parseCustomDateRange(dateStr string) (DateRange, error) {
+ parts := strings.Split(dateStr, " to ")
+ if len(parts) != 2 {
+ return DateRange{}, fmt.Errorf("invalid date range format: expected 'YYYY-MM-DD to YYYY-MM-DD'")
+ }
+
+ startStr := strings.TrimSpace(parts[0])
+ endStr := strings.TrimSpace(parts[1])
+
+ // Parse start date
+ startDate, err := time.Parse("2006-01-02", startStr)
+ if err != nil {
+ return DateRange{}, fmt.Errorf("invalid start date '%s': expected YYYY-MM-DD format", startStr)
+ }
+
+ // Parse end date
+ endDate, err := time.Parse("2006-01-02", endStr)
+ if err != nil {
+ return DateRange{}, fmt.Errorf("invalid end date '%s': expected YYYY-MM-DD format", endStr)
+ }
+
+ // Validate that start date is before or equal to end date
+ if startDate.After(endDate) {
+ return DateRange{}, fmt.Errorf("start date '%s' must be before or equal to end date '%s'", startStr, endStr)
+ }
+
+ // Convert to UTC and set times appropriately
+ // Start date: beginning of day (00:00:00)
+ startUTC := time.Date(startDate.Year(), startDate.Month(), startDate.Day(), 0, 0, 0, 0, time.UTC)
+
+ // End date: end of day (23:59:59.999999999)
+ endUTC := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 23, 59, 59, 999999999, time.UTC)
+
+ return DateRange{
+ Start: startUTC,
+ End: endUTC,
+ }, nil
+}
+
+func getLastWeek(now time.Time) DateRange {
+ // Find the start of current week (Monday)
+ weekday := int(now.Weekday())
+ if weekday == 0 { // Sunday
+ weekday = 7
+ }
+
+ // Start of current week
+ currentWeekStart := now.AddDate(0, 0, -(weekday-1)).Truncate(24 * time.Hour)
+
+ // Last week is the week before current week
+ lastWeekStart := currentWeekStart.AddDate(0, 0, -7)
+ lastWeekEnd := currentWeekStart.Add(-time.Nanosecond)
+
+ return DateRange{
+ Start: lastWeekStart,
+ End: lastWeekEnd,
+ }
+}
+
+func getLastMonth(now time.Time) DateRange {
+ // Start of current month
+ currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
+
+ // Last month start
+ lastMonthStart := currentMonthStart.AddDate(0, -1, 0)
+
+ // Last month end (last nanosecond of last month)
+ lastMonthEnd := currentMonthStart.Add(-time.Nanosecond)
+
+ return DateRange{
+ Start: lastMonthStart,
+ End: lastMonthEnd,
+ }
+} \ No newline at end of file
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,
+ }
+}
diff --git a/internal/reports/pdf.go b/internal/reports/pdf.go
new file mode 100644
index 0000000..96630cf
--- /dev/null
+++ b/internal/reports/pdf.go
@@ -0,0 +1,131 @@
+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)
+}
diff --git a/internal/reports/pdf_test.go b/internal/reports/pdf_test.go
new file mode 100644
index 0000000..e1c4020
--- /dev/null
+++ b/internal/reports/pdf_test.go
@@ -0,0 +1,122 @@
+package reports
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "punchcard/templates"
+ "testing"
+ "time"
+)
+
+// Helper function for tests
+func mustParseDate(dateStr string) time.Time {
+ t, err := time.Parse("2006-01-02", dateStr)
+ if err != nil {
+ panic(err)
+ }
+ return t
+}
+
+func TestTypstTemplateCompilation(t *testing.T) {
+ // Check if Typst is installed
+ if err := checkTypstInstalled(); err != nil {
+ t.Skip("Typst is not installed, skipping template compilation test")
+ }
+
+ // Create temporary directory
+ tempDir, err := os.MkdirTemp("", "punchcard-test")
+ if err != nil {
+ t.Fatalf("Failed to create temp directory: %v", err)
+ }
+ defer os.RemoveAll(tempDir)
+
+ // Copy test data to temp directory
+ testDataPath := filepath.Join("testdata", "invoice_test_data.json")
+ testData, err := os.ReadFile(testDataPath)
+ if err != nil {
+ t.Fatalf("Failed to read test data: %v", err)
+ }
+
+ dataFile := filepath.Join(tempDir, "data.json")
+ if err := os.WriteFile(dataFile, testData, 0644); err != nil {
+ t.Fatalf("Failed to write test data file: %v", err)
+ }
+
+ // Write Typst template to temp directory
+ typstFile := filepath.Join(tempDir, "invoice.typ")
+ if err := os.WriteFile(typstFile, []byte(templates.InvoiceTemplate), 0644); err != nil {
+ t.Fatalf("Failed to write Typst template: %v", err)
+ }
+
+ // Compile with Typst
+ outputPDF := filepath.Join(tempDir, "test-invoice.pdf")
+ cmd := exec.Command("typst", "compile", typstFile, outputPDF)
+ cmd.Dir = tempDir
+
+ if output, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("Typst compilation failed: %v\nOutput: %s", err, string(output))
+ }
+
+ // Verify PDF was created
+ if _, err := os.Stat(outputPDF); os.IsNotExist(err) {
+ t.Fatalf("PDF file was not created")
+ }
+
+ t.Logf("Successfully compiled Typst template to PDF")
+}
+
+func TestGenerateInvoicePDF(t *testing.T) {
+ // Check if Typst is installed
+ if err := checkTypstInstalled(); err != nil {
+ t.Skip("Typst is not installed, skipping PDF generation test")
+ }
+
+ // Create test invoice data
+ invoiceData := &InvoiceData{
+ ClientName: "Test Client Co.",
+ ProjectName: "Test Project",
+ DateRange: DateRange{
+ Start: mustParseDate("2025-07-01"),
+ End: mustParseDate("2025-07-31"),
+ },
+ LineItems: []LineItem{
+ {
+ Description: "Software development",
+ Hours: 8.5,
+ Rate: 150.0,
+ Amount: 1275.0,
+ },
+ {
+ Description: "Code review",
+ Hours: 1.5,
+ Rate: 150.0,
+ Amount: 225.0,
+ },
+ },
+ TotalHours: 10.0,
+ TotalAmount: 1500.0,
+ GeneratedDate: mustParseDate("2025-08-04"),
+ }
+
+ // Create temporary output file
+ tempDir, err := os.MkdirTemp("", "punchcard-pdf-test")
+ if err != nil {
+ t.Fatalf("Failed to create temp directory: %v", err)
+ }
+ defer os.RemoveAll(tempDir)
+
+ outputPath := filepath.Join(tempDir, "test-invoice.pdf")
+
+ // Generate PDF
+ if err := GenerateInvoicePDF(invoiceData, outputPath); err != nil {
+ t.Fatalf("Failed to generate invoice PDF: %v", err)
+ }
+
+ // Verify PDF was created
+ if _, err := os.Stat(outputPath); os.IsNotExist(err) {
+ t.Fatalf("PDF file was not created at %s", outputPath)
+ }
+
+ t.Logf("Successfully generated invoice PDF at %s", outputPath)
+}
diff --git a/internal/reports/testdata/invoice_test_data.json b/internal/reports/testdata/invoice_test_data.json
new file mode 100644
index 0000000..19ae7cb
--- /dev/null
+++ b/internal/reports/testdata/invoice_test_data.json
@@ -0,0 +1,27 @@
+{
+ "client_name": "Test Client",
+ "project_name": "Test Project",
+ "date_range_start": "2025-07-01",
+ "date_range_end": "2025-07-31",
+ "generated_date": "2025-08-04",
+ "invoice_number": "2025-07-001",
+ "line_items": [
+ {
+ "description": "Development work",
+ "hours": 8.5,
+ "rate": 150.0,
+ "amount": 1275.0
+ },
+ {
+ "description": "Code review and testing",
+ "hours": 2.25,
+ "rate": 150.0,
+ "amount": 337.5
+ }
+ ],
+ "total_hours": 10.75,
+ "total_amount": 1612.5,
+ "consultant_name": "Travis Parker",
+ "consultant_label": "Software Development",
+ "consultant_email": "travis.parker@gmail.com",
+}
diff --git a/templates/embeds.go b/templates/embeds.go
new file mode 100644
index 0000000..394a136
--- /dev/null
+++ b/templates/embeds.go
@@ -0,0 +1,7 @@
+package templates
+
+import _ "embed"
+
+// InvoiceTemplate contains the Typst invoice template
+//go:embed invoice.typ
+var InvoiceTemplate string \ No newline at end of file
diff --git a/templates/invoice.typ b/templates/invoice.typ
new file mode 100644
index 0000000..7dacc59
--- /dev/null
+++ b/templates/invoice.typ
@@ -0,0 +1,148 @@
+#set page(margin: (top: 0.75in, bottom: 1in, left: 1in, right: 1in))
+#set text(font: ("EB Garamond", "Georgia"), size: 10pt)
+#set par(leading: 0.65em)
+
+// Load invoice data from JSON file
+#let data = json("data.json")
+
+// Helper function to format hours as HH:MM
+#let format-hours(hours) = {
+ let total-minutes = calc.round(hours * 60)
+ let h = calc.floor(total-minutes / 60)
+ let m = calc.rem(total-minutes, 60)
+ str(h) + ":" + if m < 10 { "0" + str(m) } else { str(m) }
+}
+
+// Helper function to format currency with thousands separator and cents
+#let format-currency(amount) = {
+ let dollars = calc.floor(amount)
+ let cents = calc.round((amount - dollars) * 100)
+
+ // Convert to string and add thousands separators
+ let dollar-str = str(dollars)
+ let len = dollar-str.len()
+
+ let formatted = ""
+ for i in range(len) {
+ let digit = dollar-str.at(i)
+ formatted += digit
+ let remaining = len - i - 1
+ if remaining > 0 and calc.rem(remaining, 3) == 0 {
+ formatted += ","
+ }
+ }
+
+ "$" + formatted + "." + if cents < 10 { "0" + str(cents) } else { str(cents) }
+}
+
+// Professional header with company info
+#let professional-header() = {
+ // Company header
+ align(left)[
+ #text(size: 9pt, fill: gray)[
+ #text(weight: "bold")[#data.contractor_name] • #data.contractor_label • #data.contractor_email
+ ]
+ ]
+
+ v(3em)
+
+ // Invoice title and number
+ grid(
+ columns: (1fr, auto),
+ align(left)[
+ #text(size: 28pt, weight: "bold")[Invoice]
+ ],
+ align(right)[
+ #text(size: 11pt)[
+ #text(weight: "bold")[Invoice \##data.invoice_number] \
+ #data.generated_date
+ ]
+ ]
+ )
+
+ v(2.5em)
+}
+
+#let client-info-section() = {
+ grid(
+ columns: (1fr, 1fr),
+ gutter: 3em,
+ // Bill To section
+ [
+ #text(size: 9pt, fill: gray)[BILL TO]
+ #v(0.5em)
+ #text(size: 12pt, weight: "bold")[#data.client_name]
+ #if data.project_name != "" [
+ #v(0.3em)
+ #text(size: 10pt)[Project: #data.project_name]
+ ]
+ ],
+ // Invoice details
+ align(right)[
+ #text(size: 9pt, fill: gray)[INVOICE PERIOD]
+ #v(0.5em)
+ #text(size: 10pt)[#data.date_range_start to #data.date_range_end]
+ ]
+ )
+
+ v(2.5em)
+}
+
+#let professional-table() = {
+ table(
+ columns: (1fr, auto, auto, auto),
+ stroke: (x, y) => if y == 0 or y == 1 { (bottom: 0.8pt + black) } else { none },
+ inset: (x: 8pt, y: 12pt),
+ align: (left, center, center, right),
+ column-gutter: 12pt,
+
+ // Header
+ table.cell(fill: rgb("#f8f9fa"))[#text(weight: "bold", size: 9pt)[DESCRIPTION]],
+ table.cell(fill: rgb("#f8f9fa"))[#text(weight: "bold", size: 9pt)[HOURS]],
+ table.cell(fill: rgb("#f8f9fa"))[#text(weight: "bold", size: 9pt)[RATE]],
+ table.cell(fill: rgb("#f8f9fa"))[#text(weight: "bold", size: 9pt)[AMOUNT]],
+
+ // Line items
+ ..data.line_items.map(item => (
+ text(size: 10pt)[#item.description],
+ text(size: 10pt)[#format-hours(item.hours)],
+ text(size: 10pt)[#format-currency(item.rate)],
+ text(size: 10pt, weight: "medium")[#format-currency(item.amount)]
+ )).flatten()
+ )
+}
+
+#let invoice-summary() = {
+ v(1.5em)
+
+ // Subtotal and total section
+ align(right,
+ table(
+ columns: (auto, auto),
+ stroke: none,
+ inset: (x: 12pt, y: 6pt),
+ align: (right, right),
+
+ [#text(size: 10pt)[Total Hours:]], [#text(size: 10pt)[#format-hours(data.total_hours)]],
+ table.hline(stroke: 0.5pt),
+ [#text(size: 12pt, weight: "bold")[Total Amount:]], [#text(size: 12pt, weight: "bold")[#format-currency(data.total_amount)]]
+ )
+ )
+}
+
+#let payment-terms() = {
+ v(3em)
+
+ [
+ #text(size: 9pt, fill: gray)[
+ *Payment Terms:* Net 30 days. Please remit payment within 30 days of invoice date.
+ ]
+ ]
+}
+
+// Main invoice layout
+#professional-header()
+#client-info-section()
+#professional-table()
+#invoice-summary()
+//#payment-terms()