diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index ad15205..fdee8a1 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -61,8 +61,9 @@ func TestCLIResourceWorkflow(t *testing.T) { "--ended-at", "2026-07-30T12:00:00Z", "--note", "second pass") runCLI(t, stor, "payments", "create", "--project", "1", "--amount-minor", "5000", "--currency", "usd", - "--paid-at", "2026-07-30", "--paid-for", "2026-07-01") - runCLI(t, stor, "payments", "update", "1", "--note", "deposit") + "--paid-at", "2026-07-30", "--paid-for", "2026-06-01") + runCLI(t, stor, "payments", "update", "1", "--note", "deposit", + "--paid-for", "discarded-value") var rate rateOutput decodeCLI(t, stor, &rate, "--json", "rates", "get", "1") @@ -103,7 +104,8 @@ func TestCLIResourceWorkflow(t *testing.T) { var payment paymentOutput decodeCLI(t, stor, &payment, "--json", "payments", "get", "1") - if payment.ProjectName != "Acme Corp" || payment.Note != "deposit" { + if payment.ProjectName != "Acme Corp" || payment.Note != "deposit" || + payment.PaidAt != "2026-07-30" { t.Fatalf("unexpected payment: %#v", payment) } @@ -177,21 +179,22 @@ func TestCLIDashboardCustomPeriod(t *testing.T) { "--amount-minor", "10000", "--currency", "USD") runCLI(t, stor, "projects", "create", "--name", "Acme", "--rate", "1") runCLI(t, stor, "tasks", "create", "--name", "Build", "--project", "1", - "--started-at", "2026-08-02T23:00:00Z", - "--ended-at", "2026-08-03T01:00:00Z") + "--started-at", "2026-08-02 23:00", + "--ended-at", "2026-08-03 01:00") runCLI(t, stor, "payments", "create", "--project", "1", - "--amount-minor", "5000", "--currency", "USD", - "--paid-at", "2026-08-09", "--paid-for", "2026-08-03") + "--amount-minor", "20000", "--currency", "USD", + "--paid-at", "2026-08-09") - output := runCLIAt(t, stor, time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC), + output := runCLIAt(t, stor, time.Date(2026, 8, 9, 12, 0, 0, 0, time.Local), "--json", "dashboard", "--from", "2026-08-03", "--to", "2026-08-03") var got dashboardOutput if err := json.Unmarshal([]byte(output), &got); err != nil { t.Fatal(err) } if got.Period != "custom" || got.TrackedSeconds != 3600 || - got.EarnedMinor["USD"] != 10_000 || got.PaidMinor["USD"] != 5_000 || - len(got.Projects) != 1 { + got.EarnedMinor["USD"] != 10_000 || got.PaidMinor["USD"] != 0 || + got.BalanceMinor["USD"] != 0 || len(got.Projects) != 1 || + got.Projects[0].BalanceMinor["USD"] != 0 { t.Fatalf("dashboard = %#v", got) } } diff --git a/internal/cli/dashboard.go b/internal/cli/dashboard.go index 44eb22c..a8a72cd 100644 --- a/internal/cli/dashboard.go +++ b/internal/cli/dashboard.go @@ -15,6 +15,7 @@ type dashboardOutput struct { EarnedMinor map[string]int64 `json:"earned_minor"` PaidMinor map[string]int64 `json:"paid_minor"` NetMinor map[string]int64 `json:"net_minor"` + BalanceMinor map[string]int64 `json:"balance_minor"` Projects []dashboardProjectOutput `json:"projects"` } @@ -25,6 +26,7 @@ type dashboardProjectOutput struct { EarnedMinor map[string]int64 `json:"earned_minor"` PaidMinor map[string]int64 `json:"paid_minor"` NetMinor map[string]int64 `json:"net_minor"` + BalanceMinor map[string]int64 `json:"balance_minor"` Tasks []dashboardTaskOutput `json:"tasks"` } @@ -126,15 +128,19 @@ func (r runner) runDashboard(args []string) error { } rows := make([]string, len(output.Projects)+1) for i, project := range output.Projects { - rows[i] = fmt.Sprintf("%s\t%s\t%s\t%s\t%s", + rows[i] = fmt.Sprintf("%s\t%s\t%s\t%s\t%s\t%s", project.ProjectName, formatTracked(project.TrackedSeconds), formatMinorMap(project.EarnedMinor), formatMinorMap(project.PaidMinor), - formatMinorMap(project.NetMinor)) + formatMinorMap(project.NetMinor), formatMinorMap(project.BalanceMinor)) } - rows[len(rows)-1] = fmt.Sprintf("TOTAL\t%s\t%s\t%s\t%s", + rows[len(rows)-1] = fmt.Sprintf("TOTAL\t%s\t%s\t%s\t%s\t%s", formatTracked(output.TrackedSeconds), formatMinorMap(output.EarnedMinor), - formatMinorMap(output.PaidMinor), formatMinorMap(output.NetMinor)) - return r.table("PROJECT\tTRACKED\tEARNED_MINOR\tPAID_MINOR\tNET_MINOR", rows) + formatMinorMap(output.PaidMinor), formatMinorMap(output.NetMinor), + formatMinorMap(output.BalanceMinor)) + return r.table( + "PROJECT\tTRACKED\tEARNED_MINOR\tPAID_MINOR\tNET_MINOR\tBALANCE_MINOR", + rows, + ) } func filterDashboardEntries(entries []storage.Entry, projectID int) []storage.Entry { @@ -164,7 +170,7 @@ func makeDashboardOutput( output := dashboardOutput{ Period: string(period.Kind), TrackedSeconds: int64(summary.Tracked / time.Second), EarnedMinor: summary.EarnedMinor, PaidMinor: summary.PaidMinor, - NetMinor: summary.NetMinor, + NetMinor: summary.NetMinor, BalanceMinor: summary.BalanceMinor, Projects: make([]dashboardProjectOutput, 0, len(summary.Projects)), } if period.Kind == "" { @@ -181,8 +187,8 @@ func makeDashboardOutput( ProjectID: project.ProjectID, ProjectName: project.ProjectName, TrackedSeconds: int64(project.Tracked / time.Second), EarnedMinor: project.EarnedMinor, PaidMinor: project.PaidMinor, - NetMinor: project.NetMinor, - Tasks: make([]dashboardTaskOutput, 0, len(project.Tasks)), + NetMinor: project.NetMinor, BalanceMinor: project.BalanceMinor, + Tasks: make([]dashboardTaskOutput, 0, len(project.Tasks)), } for _, task := range project.Tasks { item.Tasks = append(item.Tasks, dashboardTaskOutput{ diff --git a/internal/cli/output.go b/internal/cli/output.go index 0d2891b..c2274a0 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -54,7 +54,6 @@ type paymentOutput struct { AmountMinor int `json:"amount_minor"` Currency string `json:"currency"` PaidAt string `json:"paid_at"` - PaidForDate string `json:"paid_for_date"` Note string `json:"note"` } @@ -171,9 +170,7 @@ func paymentOutputs( ID: payment.ID, ProjectID: payment.ProjectID, ProjectName: projectNames[payment.ProjectID], AmountMinor: payment.AmountMinor, Currency: payment.Currency, - PaidAt: payment.PaidAt.Format("2006-01-02"), - PaidForDate: payment.PaidForDate.Format("2006-01-02"), - Note: payment.Note, + PaidAt: payment.PaidAt.Format("2006-01-02"), Note: payment.Note, } } return result diff --git a/internal/cli/payments.go b/internal/cli/payments.go index 210480c..1133527 100644 --- a/internal/cli/payments.go +++ b/internal/cli/payments.go @@ -55,12 +55,12 @@ func (r runner) listPayments(args []string) error { } rows := make([]string, len(output)) for i, item := range output { - rows[i] = fmt.Sprintf("%d\t%d\t%s\t%d\t%s\t%s\t%s\t%s", item.ID, + rows[i] = fmt.Sprintf("%d\t%d\t%s\t%d\t%s\t%s\t%s", item.ID, item.ProjectID, item.ProjectName, item.AmountMinor, item.Currency, - item.PaidAt, item.PaidForDate, item.Note) + item.PaidAt, item.Note) } return r.table( - "ID\tPROJECT_ID\tPROJECT\tAMOUNT_MINOR\tCURRENCY\tPAID_AT\tPAID_FOR\tNOTE", + "ID\tPROJECT_ID\tPROJECT\tAMOUNT_MINOR\tCURRENCY\tPAID_AT\tNOTE", rows, ) } @@ -86,10 +86,10 @@ func (r runner) getPayment(args []string) error { return r.writeJSON(output) } return r.table( - "ID\tPROJECT_ID\tPROJECT\tAMOUNT_MINOR\tCURRENCY\tPAID_AT\tPAID_FOR\tNOTE", - []string{fmt.Sprintf("%d\t%d\t%s\t%d\t%s\t%s\t%s\t%s", output.ID, + "ID\tPROJECT_ID\tPROJECT\tAMOUNT_MINOR\tCURRENCY\tPAID_AT\tNOTE", + []string{fmt.Sprintf("%d\t%d\t%s\t%d\t%s\t%s\t%s", output.ID, output.ProjectID, output.ProjectName, output.AmountMinor, - output.Currency, output.PaidAt, output.PaidForDate, output.Note)}, + output.Currency, output.PaidAt, output.Note)}, ) } @@ -100,7 +100,7 @@ func (r runner) createPayment(args []string) error { amount := flags.Int("amount-minor", 0, "amount in minor units") currency := flags.String("currency", "", "three-letter currency code") paidAt := flags.String("paid-at", today, "payment date") - paidFor := flags.String("paid-for", today, "date the payment covers") + flags.String("paid-for", "", "legacy payment date (ignored)") note := flags.String("note", "", "payment note") if err := flags.Parse(args); err != nil { return err @@ -115,13 +115,9 @@ func (r runner) createPayment(args []string) error { if err != nil { return err } - paidForValue, err := parseDate(*paidFor) - if err != nil { - return err - } id, err := r.stor.CreatePaymentID(r.ctx, storage.Payment{ ProjectID: *projectID, AmountMinor: *amount, Currency: *currency, - PaidAt: paidAtValue, PaidForDate: paidForValue, Note: *note, + PaidAt: paidAtValue, Note: *note, }) if err != nil { return err @@ -146,8 +142,7 @@ func (r runner) updatePayment(args []string) error { amount := flags.Int("amount-minor", payment.AmountMinor, "amount in minor units") currency := flags.String("currency", payment.Currency, "three-letter currency code") paidAt := flags.String("paid-at", payment.PaidAt.Format(dateLayout), "payment date") - paidFor := flags.String("paid-for", payment.PaidForDate.Format(dateLayout), - "date the payment covers") + flags.String("paid-for", "", "legacy payment date (ignored)") note := flags.String("note", payment.Note, "payment note") if err := flags.Parse(args[1:]); err != nil { return err @@ -162,15 +157,10 @@ func (r runner) updatePayment(args []string) error { if err != nil { return err } - paidForValue, err := parseDate(*paidFor) - if err != nil { - return err - } payment.ProjectID = *projectID payment.AmountMinor = *amount payment.Currency = *currency payment.PaidAt = paidAtValue - payment.PaidForDate = paidForValue payment.Note = *note if err := r.stor.UpdatePayment(r.ctx, payment); err != nil { return err diff --git a/internal/cli/projects.go b/internal/cli/projects.go index 05d72ce..1389cfd 100644 --- a/internal/cli/projects.go +++ b/internal/cli/projects.go @@ -46,7 +46,9 @@ func (r runner) loadProjects() ([]storage.ProjectSummary, error) { if err != nil { return nil, err } - return storage.SummarizeProjects(projects, rates, entries, payments), nil + return storage.SummarizeProjects( + projects, rates, entries, payments, r.now(), + ), nil } func (r runner) listProjects(args []string) error { diff --git a/internal/storage/constraints.go b/internal/storage/constraints.go index a2b307b..409e475 100644 --- a/internal/storage/constraints.go +++ b/internal/storage/constraints.go @@ -51,9 +51,6 @@ func validatePayment(payment Payment) error { if payment.PaidAt.IsZero() { return fmt.Errorf("paid-at date is required") } - if payment.PaidForDate.IsZero() { - return fmt.Errorf("paid-for date is required") - } return nil } diff --git a/internal/storage/dashboard.go b/internal/storage/dashboard.go index b0c8123..870a31b 100644 --- a/internal/storage/dashboard.go +++ b/internal/storage/dashboard.go @@ -6,21 +6,23 @@ import ( ) type DashboardSummary struct { - Tracked time.Duration - EarnedMinor map[string]int64 - PaidMinor map[string]int64 - NetMinor map[string]int64 - Projects []DashboardProjectSummary + Tracked time.Duration + EarnedMinor map[string]int64 + PaidMinor map[string]int64 + NetMinor map[string]int64 + BalanceMinor map[string]int64 + Projects []DashboardProjectSummary } type DashboardProjectSummary struct { - ProjectID *int - ProjectName string - Tracked time.Duration - EarnedMinor map[string]int64 - PaidMinor map[string]int64 - NetMinor map[string]int64 - Tasks []DashboardTaskSummary + ProjectID *int + ProjectName string + Tracked time.Duration + EarnedMinor map[string]int64 + PaidMinor map[string]int64 + NetMinor map[string]int64 + BalanceMinor map[string]int64 + Tasks []DashboardTaskSummary } type DashboardTaskSummary struct { @@ -49,8 +51,10 @@ type dashboardProjectTotals struct { tasks map[int]*dashboardTaskTotals } -// SummarizeDashboard reports activity in the half-open interval [start, end). -// A zero start means there is no lower bound. Active entries stop at now. +// SummarizeDashboard reports work and receipts in the half-open interval +// [start, end). BalanceMinor is the current project ledger balance through now, +// independent of the selected interval. A zero start means there is no lower +// bound. Active entries stop at now. func SummarizeDashboard( projects []Project, tasks []Task, @@ -71,6 +75,18 @@ func SummarizeDashboard( } ratesByID := RatesByID(rates) totals := make(map[int]*dashboardProjectTotals) + periodLocation := now.Location() + if !start.IsZero() { + periodLocation = start.Location() + } else if !end.IsZero() { + periodLocation = end.Location() + } + balances := make(map[int]map[string]int64, len(projects)) + for _, project := range SummarizeProjects( + projects, rates, entries, payments, now, + ) { + balances[project.ID] = project.BalanceMinor + } projectTotals := func(projectID int) *dashboardProjectTotals { if item, ok := totals[projectID]; ok { @@ -137,10 +153,14 @@ func SummarizeDashboard( } for _, payment := range payments { - if !start.IsZero() && payment.PaidForDate.Before(start) { + paidAt := paymentDateInLocation(payment.PaidAt, periodLocation) + if !now.IsZero() && paidAt.After(now) { + continue + } + if !start.IsZero() && paidAt.Before(start) { continue } - if !end.IsZero() && !payment.PaidForDate.Before(end) { + if !end.IsZero() && !paidAt.Before(end) { continue } projectTotals(payment.ProjectID).paid[payment.Currency] += @@ -148,16 +168,18 @@ func SummarizeDashboard( } result := DashboardSummary{ - EarnedMinor: make(map[string]int64), - PaidMinor: make(map[string]int64), - NetMinor: make(map[string]int64), + EarnedMinor: make(map[string]int64), + PaidMinor: make(map[string]int64), + NetMinor: make(map[string]int64), + BalanceMinor: make(map[string]int64), } for projectID, totals := range totals { project := DashboardProjectSummary{ - ProjectName: totals.name, - Tracked: totals.tracked, - EarnedMinor: minorSecondsToAmounts(totals.minorSeconds), - PaidMinor: cloneAmounts(totals.paid), + ProjectName: totals.name, + Tracked: totals.tracked, + EarnedMinor: minorSecondsToAmounts(totals.minorSeconds), + PaidMinor: cloneAmounts(totals.paid), + BalanceMinor: cloneAmounts(balances[projectID]), } if projectID != 0 { id := projectID @@ -165,6 +187,11 @@ func SummarizeDashboard( } project.NetMinor = subtractAmounts(project.EarnedMinor, project.PaidMinor) + taskMinorSeconds := make(map[int]map[string]int64, len(totals.tasks)) + for taskID, taskTotals := range totals.tasks { + taskMinorSeconds[taskID] = taskTotals.minorSeconds + } + taskAmounts := groupedMinorSecondsToAmounts(taskMinorSeconds) for taskID, totals := range totals.tasks { taskName := taskNames[taskID] if taskID == 0 { @@ -174,7 +201,7 @@ func SummarizeDashboard( } task := DashboardTaskSummary{ TaskName: taskName, Tracked: totals.tracked, - EarnedMinor: minorSecondsToAmounts(totals.minorSeconds), + EarnedMinor: taskAmounts[taskID], } if taskID != 0 { id := taskID @@ -195,6 +222,9 @@ func SummarizeDashboard( result.Projects = append(result.Projects, project) } result.NetMinor = subtractAmounts(result.EarnedMinor, result.PaidMinor) + for _, balance := range balances { + addAmounts(result.BalanceMinor, balance) + } sort.Slice(result.Projects, func(i, j int) bool { if result.Projects[i].Tracked != result.Projects[j].Tracked { return result.Projects[i].Tracked > result.Projects[j].Tracked diff --git a/internal/storage/dashboard_test.go b/internal/storage/dashboard_test.go index 62370b5..6b6d775 100644 --- a/internal/storage/dashboard_test.go +++ b/internal/storage/dashboard_test.go @@ -38,8 +38,8 @@ func TestSummarizeDashboardClipsEntriesAndGroupsProjects(t *testing.T) { }, } payments := []storage.Payment{ - {ProjectID: 1, AmountMinor: 5_000, Currency: "USD", PaidForDate: start}, - {ProjectID: 2, AmountMinor: 9_000, Currency: "USD", PaidForDate: end}, + {ProjectID: 1, AmountMinor: 5_000, Currency: "USD", PaidAt: start}, + {ProjectID: 2, AmountMinor: 9_000, Currency: "USD", PaidAt: end}, } got := storage.SummarizeDashboard( @@ -91,6 +91,54 @@ func TestSummarizeDashboardKeepsCurrenciesSeparate(t *testing.T) { } } +func TestSummarizeDashboardUsesStableProjectRounding(t *testing.T) { + start := time.Date(2026, 8, 9, 0, 0, 0, 0, time.UTC) + end := start.Add(time.Hour) + project1, project2, rateID := 1, 2, 1 + got := storage.SummarizeDashboard( + []storage.Project{{ID: project1}, {ID: project2}}, + nil, + []storage.Rate{{ID: rateID, AmountMinor: 1, Currency: "USD"}}, + []storage.Entry{ + {ProjectID: &project1, RateID: &rateID, StartedAt: start, + EndedAt: timePointer(start.Add(30 * time.Minute))}, + {ProjectID: &project2, RateID: &rateID, StartedAt: start, + EndedAt: timePointer(start.Add(30 * time.Minute))}, + }, + nil, start, end, end, + ) + if got.EarnedMinor["USD"] != 0 { + t.Fatalf("earned = %v, want project-rounded total", got.EarnedMinor) + } + var projectTotal int64 + for _, project := range got.Projects { + projectTotal += project.EarnedMinor["USD"] + } + if projectTotal != got.EarnedMinor["USD"] { + t.Fatalf("project total = %d, dashboard total = %d", + projectTotal, got.EarnedMinor["USD"]) + } +} + +func TestSummarizeDashboardTreatsPaymentsAsCivilDates(t *testing.T) { + location := time.FixedZone("UTC-5", -5*60*60) + start := time.Date(2026, 9, 12, 0, 0, 0, 0, location) + got := storage.SummarizeDashboard( + []storage.Project{{ID: 1, RateID: 1}}, nil, + []storage.Rate{{ID: 1, Currency: "USD"}}, nil, + []storage.Payment{{ + ProjectID: 1, AmountMinor: 5_000, Currency: "USD", + PaidAt: time.Date(2026, 9, 12, 0, 0, 0, 0, time.UTC), + }}, + start, start.AddDate(0, 0, 1), start.Add(12*time.Hour), + ) + if got.PaidMinor["USD"] != 5_000 || got.BalanceMinor["USD"] != -5_000 { + t.Fatalf("payment date shifted across zones: %#v", got) + } +} + +func timePointer(value time.Time) *time.Time { return &value } + func TestSummarizeDashboardTimelineSplitsEntriesAcrossHours(t *testing.T) { start := time.Date(2026, 8, 9, 0, 0, 0, 0, time.UTC) period, err := storage.CurrentPeriod(storage.Day, start.Add(12*time.Hour)) diff --git a/internal/storage/development_seed.go b/internal/storage/development_seed.go index 0839a50..009d5d2 100644 --- a/internal/storage/development_seed.go +++ b/internal/storage/development_seed.go @@ -123,9 +123,9 @@ func (s *Storage) SeedDevelopment( } payments := []Payment{ - {ID: 1, ProjectID: 1, AmountMinor: 45_000, Currency: "USD", PaidAt: day, PaidForDate: week, Note: "Weekly invoice"}, - {ID: 2, ProjectID: 2, AmountMinor: 72_000, Currency: "USD", PaidAt: day.AddDate(0, 0, -2), PaidForDate: month, Note: "Retainer installment"}, - {ID: 3, ProjectID: 3, AmountMinor: 33_000, Currency: "EUR", PaidAt: month.AddDate(0, 0, -1), PaidForDate: month.AddDate(0, -1, 1), Note: "Brand discovery"}, + {ID: 1, ProjectID: 1, AmountMinor: 45_000, Currency: "USD", PaidAt: day, Note: "Weekly invoice"}, + {ID: 2, ProjectID: 2, AmountMinor: 72_000, Currency: "USD", PaidAt: day.AddDate(0, 0, -2), Note: "Retainer installment"}, + {ID: 3, ProjectID: 3, AmountMinor: 33_000, Currency: "EUR", PaidAt: month.AddDate(0, 0, -1), Note: "Brand discovery"}, } for _, payment := range payments { if _, err := tx.ExecContext(ctx, ` @@ -134,7 +134,7 @@ func (s *Storage) SeedDevelopment( PAID_AT, PAID_FOR_DATE, NOTES ) VALUES ($1, $2, $3, $4, $5, $6, $7) `, payment.ID, payment.ProjectID, payment.AmountMinor, payment.Currency, - payment.PaidAt.Unix(), payment.PaidForDate.Unix(), payment.Note); err != nil { + payment.PaidAt.Unix(), payment.PaidAt.Unix(), payment.Note); err != nil { return fmt.Errorf("seed development data: insert payment: %w", err) } } diff --git a/internal/storage/payments.go b/internal/storage/payments.go index 47b9fe7..16ed739 100644 --- a/internal/storage/payments.go +++ b/internal/storage/payments.go @@ -14,7 +14,6 @@ type Payment struct { AmountMinor int Currency string PaidAt time.Time - PaidForDate time.Time Note string } @@ -24,7 +23,6 @@ type paymentRow struct { AmountMinor int `db:"amount_minor"` Currency string `db:"currency"` PaidAt int64 `db:"paid_at"` - PaidForDate int64 `db:"paid_for_date"` Note string `db:"note"` } @@ -35,7 +33,6 @@ const selectPayments = ` AMOUNT_MINOR AS amount_minor, CURRENCY AS currency, PAID_AT AS paid_at, - PAID_FOR_DATE AS paid_for_date, NOTES AS note FROM PAYMENT ` @@ -72,6 +69,10 @@ func (s *Storage) CreatePaymentID(ctx context.Context, payment Payment) (int, er if err != nil { return 0, fmt.Errorf("create payment: %w", err) } + if err := s.validatePaymentCurrency(ctx, payment.ProjectID, currency); err != nil { + return 0, fmt.Errorf("create payment: %w", err) + } + paidAt := canonicalPaymentDate(payment.PaidAt) const query = ` INSERT INTO PAYMENT ( @@ -91,8 +92,8 @@ func (s *Storage) CreatePaymentID(ctx context.Context, payment Payment) (int, er payment.ProjectID, payment.AmountMinor, currency, - payment.PaidAt.Unix(), - payment.PaidForDate.Unix(), + paidAt.Unix(), + paidAt.Unix(), strings.TrimSpace(payment.Note), ) if err != nil { @@ -113,6 +114,16 @@ func (s *Storage) UpdatePayment(ctx context.Context, payment Payment) error { if err != nil { return fmt.Errorf("update payment: %w", err) } + existing, err := s.GetPayment(ctx, payment.ID) + if err != nil { + return fmt.Errorf("update payment: %w", err) + } + if existing.ProjectID != payment.ProjectID || existing.Currency != currency { + if err := s.validatePaymentCurrency(ctx, payment.ProjectID, currency); err != nil { + return fmt.Errorf("update payment: %w", err) + } + } + paidAt := canonicalPaymentDate(payment.PaidAt) const query = ` UPDATE PAYMENT @@ -132,8 +143,8 @@ func (s *Storage) UpdatePayment(ctx context.Context, payment Payment) error { payment.ProjectID, payment.AmountMinor, currency, - payment.PaidAt.Unix(), - payment.PaidForDate.Unix(), + paidAt.Unix(), + paidAt.Unix(), strings.TrimSpace(payment.Note), payment.ID, ) @@ -179,8 +190,60 @@ func paymentFromRow(row paymentRow) Payment { ProjectID: row.ProjectID, AmountMinor: row.AmountMinor, Currency: row.Currency, - PaidAt: time.Unix(row.PaidAt, 0), - PaidForDate: time.Unix(row.PaidForDate, 0), + PaidAt: time.Unix(row.PaidAt, 0).UTC(), Note: row.Note, } } + +func canonicalPaymentDate(value time.Time) time.Time { + return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, time.UTC) +} + +func paymentDateInLocation(value time.Time, location *time.Location) time.Time { + return time.Date( + value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, location, + ) +} + +func (s *Storage) validatePaymentCurrency( + ctx context.Context, + projectID int, + currency string, +) error { + var projectExists bool + if err := s.db.GetContext(ctx, &projectExists, ` + SELECT EXISTS (SELECT 1 FROM PROJECT WHERE ID = $1) + `, projectID); err != nil { + return fmt.Errorf("check payment project: %w", err) + } + if !projectExists { + return fmt.Errorf("project %d not found", projectID) + } + + var allowed bool + if err := s.db.GetContext(ctx, &allowed, ` + SELECT EXISTS ( + SELECT 1 + FROM RATE + WHERE CURRENCY = $2 + AND ( + ID = (SELECT RATE_ID FROM PROJECT WHERE ID = $1) + OR ID IN ( + SELECT RATE_ID + FROM ENTRY + WHERE PROJECT_ID = $1 AND RATE_ID IS NOT NULL + ) + ) + ) + `, projectID, currency); err != nil { + return fmt.Errorf("check payment currency: %w", err) + } + if !allowed { + return fmt.Errorf( + "currency %s is not associated with project %d", + currency, + projectID, + ) + } + return nil +} diff --git a/internal/storage/payments_test.go b/internal/storage/payments_test.go index eaf3135..16f1430 100644 --- a/internal/storage/payments_test.go +++ b/internal/storage/payments_test.go @@ -9,15 +9,14 @@ import ( ) func TestCreatePayment(t *testing.T) { - t.Run("persists receipt and accounting dates", func(t *testing.T) { + t.Run("persists receipt date and keeps legacy date compatible", func(t *testing.T) { stor := fixtureStorage(t) project := fixtureProject(t, stor) paidAt := time.Unix(1_706_745_600, 0) - paidFor := time.Unix(1_704_067_200, 0) if err := stor.CreatePayment(t.Context(), storage.Payment{ ProjectID: project.ID, AmountMinor: 150_000, Currency: "USD", - PaidAt: paidAt, PaidForDate: paidFor, Note: "January", + PaidAt: paidAt, Note: "January", }); err != nil { t.Fatal(err) } @@ -25,8 +24,18 @@ func TestCreatePayment(t *testing.T) { if err != nil { t.Fatal(err) } - if !payment.PaidAt.Equal(paidAt) || !payment.PaidForDate.Equal(paidFor) { - t.Fatalf("unexpected payment dates: %#v", payment) + expectedDate := canonicalTestDate(paidAt) + if !payment.PaidAt.Equal(expectedDate) { + t.Fatalf("unexpected payment date: %#v", payment) + } + var legacyDate int64 + if err := stor.QueryRow( + `SELECT PAID_FOR_DATE FROM PAYMENT WHERE ID = 1`, + ).Scan(&legacyDate); err != nil { + t.Fatal(err) + } + if legacyDate != expectedDate.Unix() { + t.Fatalf("legacy date = %d, want %d", legacyDate, expectedDate.Unix()) } }) @@ -37,15 +46,14 @@ func TestCreatePayment(t *testing.T) { tests := []storage.Payment{ { ProjectID: project.ID, AmountMinor: -1, Currency: "USD", - PaidAt: date, PaidForDate: date, + PaidAt: date, }, { ProjectID: project.ID, AmountMinor: 1, Currency: "US1", - PaidAt: date, PaidForDate: date, + PaidAt: date, }, { ProjectID: project.ID, AmountMinor: 1, Currency: "USD", - PaidForDate: date, }, } for _, payment := range tests { @@ -54,6 +62,47 @@ func TestCreatePayment(t *testing.T) { } } }) + + t.Run("rejects a currency unrelated to the project", func(t *testing.T) { + stor := fixtureStorage(t) + project := fixtureProject(t, stor) + err := stor.CreatePayment(t.Context(), storage.Payment{ + ProjectID: project.ID, AmountMinor: 1, Currency: "EUR", + PaidAt: time.Unix(1_706_745_600, 0), + }) + if err == nil || !strings.Contains(err.Error(), "not associated") { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("allows a currency captured by historical work", func(t *testing.T) { + stor := fixtureStorage(t) + project := fixtureProject(t, stor) + if err := stor.CreateRate(t.Context(), storage.Rate{ + Name: "old euro", AmountMinor: 10_000, Currency: "EUR", + }); err != nil { + t.Fatal(err) + } + rates, err := stor.GetRates(t.Context()) + if err != nil { + t.Fatal(err) + } + rateID := rates[len(rates)-1].ID + projectID := project.ID + endedAt := time.Unix(1_706_745_600, 0) + if err := stor.CreateEntry(t.Context(), storage.Entry{ + ProjectID: &projectID, RateID: &rateID, + StartedAt: endedAt.Add(-time.Hour), EndedAt: &endedAt, + }); err != nil { + t.Fatal(err) + } + if err := stor.CreatePayment(t.Context(), storage.Payment{ + ProjectID: project.ID, AmountMinor: 1, Currency: "EUR", + PaidAt: endedAt, + }); err != nil { + t.Fatal(err) + } + }) } func TestGetPayments(t *testing.T) { @@ -80,20 +129,19 @@ func TestGetPayment(t *testing.T) { } func TestUpdatePayment(t *testing.T) { - t.Run("updates paid-for date", func(t *testing.T) { + t.Run("updates receipt date", func(t *testing.T) { stor := fixtureStorage(t) project := fixtureProject(t, stor) paidAt := time.Unix(1_706_745_600, 0) - paidFor := time.Unix(1_704_067_200, 0) payment := storage.Payment{ ProjectID: project.ID, AmountMinor: 150_000, Currency: "USD", - PaidAt: paidAt, PaidForDate: paidFor, + PaidAt: paidAt, } if err := stor.CreatePayment(t.Context(), payment); err != nil { t.Fatal(err) } payment.ID = 1 - payment.PaidForDate = paidFor.AddDate(0, 1, 0) + payment.PaidAt = paidAt.AddDate(0, 1, 0) if err := stor.UpdatePayment(t.Context(), payment); err != nil { t.Fatal(err) } @@ -101,12 +149,57 @@ func TestUpdatePayment(t *testing.T) { if err != nil { t.Fatal(err) } - if !got.PaidForDate.Equal(payment.PaidForDate) { - t.Fatalf("got paid-for date %v, want %v", got.PaidForDate, payment.PaidForDate) + expectedDate := canonicalTestDate(payment.PaidAt) + if !got.PaidAt.Equal(expectedDate) { + t.Fatalf("got paid-at date %v, want %v", got.PaidAt, payment.PaidAt) + } + var legacyDate int64 + if err := stor.QueryRow( + `SELECT PAID_FOR_DATE FROM PAYMENT WHERE ID = 1`, + ).Scan(&legacyDate); err != nil { + t.Fatal(err) + } + if legacyDate != expectedDate.Unix() { + t.Fatalf("legacy date = %d, want %d", + legacyDate, expectedDate.Unix()) + } + }) + + t.Run("allows edits when a historical currency is unchanged", func(t *testing.T) { + stor := fixtureStorage(t) + project := fixtureProject(t, stor) + payment := storage.Payment{ + ProjectID: project.ID, AmountMinor: 5_000, Currency: "USD", + PaidAt: time.Date(2026, 7, 20, 0, 0, 0, 0, time.Local), + } + if err := stor.CreatePayment(t.Context(), payment); err != nil { + t.Fatal(err) + } + if err := stor.CreateRate(t.Context(), storage.Rate{ + Name: "euro", AmountMinor: 10_000, Currency: "EUR", + }); err != nil { + t.Fatal(err) + } + rates, err := stor.GetRates(t.Context()) + if err != nil { + t.Fatal(err) + } + project.RateID = rates[len(rates)-1].ID + if err := stor.UpdateProject(t.Context(), project); err != nil { + t.Fatal(err) + } + payment.ID = 1 + payment.Note = "updated" + if err := stor.UpdatePayment(t.Context(), payment); err != nil { + t.Fatal(err) } }) } +func canonicalTestDate(value time.Time) time.Time { + return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, time.UTC) +} + func TestDeletePayment(t *testing.T) { t.Run("missing payment", func(t *testing.T) { stor := fixtureStorage(t) diff --git a/internal/storage/schema.go b/internal/storage/schema.go index c0cdcfd..05273f0 100644 --- a/internal/storage/schema.go +++ b/internal/storage/schema.go @@ -51,7 +51,18 @@ ALTER TABLE PAYMENT ADD COLUMN PAID_FOR_DATE INTEGER NOT NULL DEFAULT 0 ` +const normalizePaymentDates = ` +UPDATE PAYMENT +SET PAID_AT = CAST(strftime( + '%s', datetime(PAID_AT, 'unixepoch', 'localtime', 'start of day') +) AS INTEGER); + +UPDATE PAYMENT +SET PAID_FOR_DATE = PAID_AT +` + var migrations = []string{ initial, addPaidForDate, + normalizePaymentDates, } diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index ee21137..77b6626 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -1,13 +1,117 @@ package storage_test import ( + "database/sql" "os" "path/filepath" "testing" + "time" "chankat/internal/storage" ) +func TestMigrationBackfillsLegacyPaymentDate(t *testing.T) { + path := filepath.Join(t.TempDir(), "legacy.sqlite") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + legacyPaidAt := time.Date(2024, 2, 1, 0, 0, 0, 0, time.Local).Unix() + paidAt := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).Unix() + if _, err := db.Exec(` + CREATE TABLE PAYMENT ( + ID INTEGER PRIMARY KEY, + PROJECT_ID INTEGER NOT NULL, + AMOUNT_MINOR INTEGER NOT NULL, + CURRENCY TEXT NOT NULL, + PAID_AT INTEGER NOT NULL, + NOTES TEXT NOT NULL DEFAULT '' + ); + INSERT INTO PAYMENT ( + ID, PROJECT_ID, AMOUNT_MINOR, CURRENCY, PAID_AT + ) VALUES (1, 1, 5000, 'USD', ?); + PRAGMA user_version = 1; + `, legacyPaidAt); err != nil { + db.Close() + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + + t.Setenv("CHANKAT_DATA_PATH", path) + stor, err := storage.Open() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { stor.Close() }) + if err := stor.Migrate(); err != nil { + t.Fatal(err) + } + var got int64 + if err := stor.QueryRow( + `SELECT PAID_FOR_DATE FROM PAYMENT WHERE ID = 1`, + ).Scan(&got); err != nil { + t.Fatal(err) + } + if got != paidAt { + t.Fatalf("paid-for compatibility date = %d, want %d", got, paidAt) + } +} + +func TestMigrationReplacesExistingPaymentAccountingDate(t *testing.T) { + path := filepath.Join(t.TempDir(), "version-two.sqlite") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + const ( + paidFor = int64(1_704_067_200) + ) + legacyPaidAt := time.Date(2024, 2, 1, 0, 0, 0, 0, time.Local).Unix() + paidAt := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).Unix() + if _, err := db.Exec(` + CREATE TABLE PAYMENT ( + ID INTEGER PRIMARY KEY, + PROJECT_ID INTEGER NOT NULL, + AMOUNT_MINOR INTEGER NOT NULL, + CURRENCY TEXT NOT NULL, + PAID_AT INTEGER NOT NULL, + PAID_FOR_DATE INTEGER NOT NULL DEFAULT 0, + NOTES TEXT NOT NULL DEFAULT '' + ); + INSERT INTO PAYMENT ( + ID, PROJECT_ID, AMOUNT_MINOR, CURRENCY, PAID_AT, PAID_FOR_DATE + ) VALUES (1, 1, 5000, 'USD', ?, ?); + PRAGMA user_version = 2; + `, legacyPaidAt, paidFor); err != nil { + db.Close() + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + + t.Setenv("CHANKAT_DATA_PATH", path) + stor, err := storage.Open() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { stor.Close() }) + if err := stor.Migrate(); err != nil { + t.Fatal(err) + } + var got int64 + if err := stor.QueryRow( + `SELECT PAID_FOR_DATE FROM PAYMENT WHERE ID = 1`, + ).Scan(&got); err != nil { + t.Fatal(err) + } + if got != paidAt { + t.Fatalf("migrated compatibility date = %d, want %d", got, paidAt) + } +} + func TestOpenAndMigrate(t *testing.T) { t.Setenv("CHANKAT_DATA_PATH", "") t.Setenv("XDG_DATA_HOME", t.TempDir()) diff --git a/internal/storage/summaries.go b/internal/storage/summaries.go index c1bd5fa..1d1fcfe 100644 --- a/internal/storage/summaries.go +++ b/internal/storage/summaries.go @@ -44,6 +44,7 @@ func SummarizeProjects( rates []Rate, entries []Entry, payments []Payment, + now time.Time, ) []ProjectSummary { ratesByID := RatesByID(rates) balances := make(map[int]map[string]int64, len(projects)) @@ -57,7 +58,7 @@ func SummarizeProjects( minorSeconds[project.ID] = make(map[string]int64) } for _, entry := range entries { - if entry.ProjectID == nil || entry.RateID == nil || entry.EndedAt == nil { + if entry.ProjectID == nil || entry.RateID == nil { continue } rate, rateOK := ratesByID[*entry.RateID] @@ -65,7 +66,14 @@ func SummarizeProjects( if !rateOK || !projectOK { continue } - elapsed := nonNegativeDuration(entry.StartedAt, *entry.EndedAt) + endedAt := now + if entry.EndedAt != nil && (endedAt.IsZero() || entry.EndedAt.Before(endedAt)) { + endedAt = *entry.EndedAt + } + if endedAt.IsZero() { + continue + } + elapsed := nonNegativeDuration(entry.StartedAt, endedAt) tracked[*entry.ProjectID] += elapsed minorSeconds[*entry.ProjectID][rate.Currency] += int64(rate.AmountMinor) * int64(elapsed/time.Second) @@ -76,6 +84,13 @@ func SummarizeProjects( } } for _, payment := range payments { + paidAt := payment.PaidAt + if !now.IsZero() { + paidAt = paymentDateInLocation(payment.PaidAt, now.Location()) + } + if !now.IsZero() && paidAt.After(now) { + continue + } if balances[payment.ProjectID] != nil { balances[payment.ProjectID][payment.Currency] -= int64(payment.AmountMinor) } @@ -190,6 +205,50 @@ func SortedCurrencies(amounts map[string]int64) []string { return result } +// groupedMinorSecondsToAmounts distributes a group's fractional remainders so +// its children add up to the amount calculated at the parent boundary. +func groupedMinorSecondsToAmounts( + values map[int]map[string]int64, +) map[int]map[string]int64 { + result := make(map[int]map[string]int64, len(values)) + currencies := make(map[string]bool) + for id, amounts := range values { + result[id] = make(map[string]int64) + for currency := range amounts { + currencies[currency] = true + } + } + type remainder struct { + id int + value int64 + } + for currency := range currencies { + var total, roundedGroups int64 + remainders := make([]remainder, 0, len(values)) + for id, amounts := range values { + value, present := amounts[currency] + rounded := value / 3600 + if present { + result[id][currency] = rounded + } + total += value + roundedGroups += rounded + remainders = append(remainders, remainder{id: id, value: value % 3600}) + } + sort.Slice(remainders, func(i, j int) bool { + if remainders[i].value != remainders[j].value { + return remainders[i].value > remainders[j].value + } + return remainders[i].id < remainders[j].id + }) + extra := total/3600 - roundedGroups + for i := int64(0); i < extra; i++ { + result[remainders[i].id][currency]++ + } + } + return result +} + func nonNegativeDuration(startedAt, endedAt time.Time) time.Duration { elapsed := endedAt.Sub(startedAt) if elapsed < 0 { diff --git a/internal/storage/summaries_test.go b/internal/storage/summaries_test.go index b5e622d..ef8a8a9 100644 --- a/internal/storage/summaries_test.go +++ b/internal/storage/summaries_test.go @@ -39,10 +39,10 @@ func TestSummaries(t *testing.T) { } projects := storage.SummarizeProjects( - []storage.Project{project}, []storage.Rate{rate}, entries, payments, + []storage.Project{project}, []storage.Rate{rate}, entries, payments, now, ) - if projects[0].Tracked != 90*time.Minute || - projects[0].BalanceMinor["USD"] != 10_000 { + if projects[0].Tracked != 2*time.Hour || + projects[0].BalanceMinor["USD"] != 15_000 { t.Fatalf("got project summary %#v", projects[0]) } @@ -60,3 +60,26 @@ func TestSummaries(t *testing.T) { t.Fatalf("got task summary %#v", tasks[0]) } } + +func TestProjectBalanceCarriesPrepaymentForward(t *testing.T) { + projectID, rateID := 1, 1 + startedAt := time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC) + endedAt := startedAt.Add(time.Hour) + paidAt := endedAt.AddDate(0, 0, 30) + summaries := storage.SummarizeProjects( + []storage.Project{{ID: projectID, RateID: rateID}}, + []storage.Rate{{ID: rateID, AmountMinor: 10_000, Currency: "USD"}}, + []storage.Entry{{ + ProjectID: &projectID, RateID: &rateID, + StartedAt: startedAt, EndedAt: &endedAt, + }}, + []storage.Payment{{ + ProjectID: projectID, AmountMinor: 15_000, + Currency: "USD", PaidAt: paidAt, + }}, + paidAt, + ) + if got := summaries[0].BalanceMinor["USD"]; got != -5_000 { + t.Fatalf("balance = %d, want 5000 credit", got) + } +} diff --git a/internal/storage/tasks.go b/internal/storage/tasks.go index 4820dcf..894bf4a 100644 --- a/internal/storage/tasks.go +++ b/internal/storage/tasks.go @@ -317,7 +317,19 @@ func (s *Storage) UpdateTask(ctx context.Context, task Task) error { } func (s *Storage) DeleteTask(ctx context.Context, id int) error { - result, err := s.db.ExecContext(ctx, `DELETE FROM TASK WHERE ID = $1`, id) + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("begin delete task: %w", err) + } + defer tx.Rollback() + if _, err := tx.ExecContext( + ctx, + `UPDATE ENTRY SET TASK_ID = NULL WHERE TASK_ID = $1`, + id, + ); err != nil { + return fmt.Errorf("detach task entries: %w", err) + } + result, err := tx.ExecContext(ctx, `DELETE FROM TASK WHERE ID = $1`, id) if err != nil { return fmt.Errorf("delete task: %w", err) } @@ -328,6 +340,9 @@ func (s *Storage) DeleteTask(ctx context.Context, id int) error { if deleted == 0 { return fmt.Errorf("task %d not found", id) } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit delete task: %w", err) + } return nil } diff --git a/internal/storage/tasks_test.go b/internal/storage/tasks_test.go index 28184c9..f6b8a09 100644 --- a/internal/storage/tasks_test.go +++ b/internal/storage/tasks_test.go @@ -114,6 +114,62 @@ func TestStartTask(t *testing.T) { } } +func TestTaskEntriesCaptureProjectRateChanges(t *testing.T) { + stor := fixtureStorage(t) + ctx := t.Context() + project := fixtureProject(t, stor) + if err := stor.CreateTask(ctx, storage.Task{ + Name: "tracked task", ProjectID: project.ID, + }); err != nil { + t.Fatal(err) + } + startedAt := time.Unix(1_700_000_000, 0) + if err := stor.StartTask(ctx, 1, startedAt); err != nil { + t.Fatal(err) + } + if err := stor.PauseTask(ctx, 1, startedAt.Add(time.Hour)); err != nil { + t.Fatal(err) + } + + if err := stor.CreateRate(ctx, storage.Rate{ + Name: "increased", AmountMinor: 10_000, Currency: "USD", + }); err != nil { + t.Fatal(err) + } + rates, err := stor.GetRates(ctx) + if err != nil { + t.Fatal(err) + } + newRateID := rates[len(rates)-1].ID + project.RateID = newRateID + if err := stor.UpdateProject(ctx, project); err != nil { + t.Fatal(err) + } + if err := stor.StartTask(ctx, 1, startedAt.Add(2*time.Hour)); err != nil { + t.Fatal(err) + } + if err := stor.PauseTask(ctx, 1, startedAt.Add(3*time.Hour)); err != nil { + t.Fatal(err) + } + + entries, err := stor.GetEntries(ctx) + if err != nil { + t.Fatal(err) + } + if len(entries) != 2 || entries[0].RateID == nil || + *entries[0].RateID != rates[0].ID || entries[1].RateID == nil || + *entries[1].RateID != newRateID { + t.Fatalf("entries did not preserve rate history: %#v", entries) + } + summaries := storage.SummarizeProjects( + []storage.Project{project}, rates, entries, nil, + startedAt.Add(3*time.Hour), + ) + if got := summaries[0].BalanceMinor["USD"]; got != 17_500 { + t.Fatalf("historical-rate earnings = %d, want 17500", got) + } +} + func TestPauseTask(t *testing.T) { stor := fixtureStorage(t) ctx := t.Context() @@ -249,6 +305,35 @@ func TestGetTask(t *testing.T) { } }) + t.Run("preserves time entries", func(t *testing.T) { + stor := fixtureStorage(t) + project := fixtureProject(t, stor) + if err := stor.CreateTask(t.Context(), storage.Task{ + Name: "task", ProjectID: project.ID, + }); err != nil { + t.Fatal(err) + } + startedAt := time.Unix(1_700_000_000, 0) + endedAt := startedAt.Add(time.Hour) + if err := stor.CreateEntryForTask( + t.Context(), 1, startedAt, &endedAt, "work", + ); err != nil { + t.Fatal(err) + } + if err := stor.DeleteTask(t.Context(), 1); err != nil { + t.Fatal(err) + } + entries, err := stor.GetEntries(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].TaskID != nil || + entries[0].ProjectID == nil || *entries[0].ProjectID != project.ID || + entries[0].RateID == nil || *entries[0].RateID != project.RateID { + t.Fatalf("task deletion lost accounting history: %#v", entries) + } + }) + t.Run("missing task", func(t *testing.T) { stor := fixtureStorage(t) _, err := stor.GetTask(t.Context(), 999) diff --git a/internal/tui/components/formatting.go b/internal/tui/components/formatting.go index 08d2e31..afba24f 100644 --- a/internal/tui/components/formatting.go +++ b/internal/tui/components/formatting.go @@ -60,6 +60,13 @@ func FormatMoney(amountMinor int64, currency string) string { return amount + " " + code } +func FormatBalance(amountMinor int64, currency string) string { + if amountMinor < 0 { + return FormatMoney(-amountMinor, currency) + " credit" + } + return FormatMoney(amountMinor, currency) + " outstanding" +} + func groupMoneyDigits(amount string) string { offset := 0 if strings.HasPrefix(amount, "-") { diff --git a/internal/tui/components/formatting_test.go b/internal/tui/components/formatting_test.go index 04d9bf6..dfc4a32 100644 --- a/internal/tui/components/formatting_test.go +++ b/internal/tui/components/formatting_test.go @@ -50,6 +50,15 @@ func TestFormatMoney(t *testing.T) { } +func TestFormatBalance(t *testing.T) { + if got := FormatBalance(5_000, "USD"); got != "$50.00 outstanding" { + t.Fatalf("positive balance = %q", got) + } + if got := FormatBalance(-5_000, "USD"); got != "$50.00 credit" { + t.Fatalf("negative balance = %q", got) + } +} + func TestFormatDate(t *testing.T) { value := time.Date(2026, 7, 20, 12, 30, 0, 0, time.UTC) if got := FormatDate(value); got != "2026-07-20" { diff --git a/internal/tui/screens/payments.go b/internal/tui/screens/payments.go index f2a8245..fecbb49 100644 --- a/internal/tui/screens/payments.go +++ b/internal/tui/screens/payments.go @@ -3,7 +3,6 @@ package screens import ( "context" "errors" - "fmt" "strconv" "strings" "time" @@ -32,11 +31,7 @@ func (p paymentItem) Title() string { } func (p paymentItem) Description() string { - description := fmt.Sprintf( - "paid %s · for %s", - components.FormatDate(p.payment.PaidAt), - components.FormatDate(p.payment.PaidForDate), - ) + description := "paid " + components.FormatDate(p.payment.PaidAt) if p.payment.Note != "" { description += " · " + p.payment.Note } @@ -113,13 +108,11 @@ func paymentForm( values := storage.Payment{ProjectID: projects[0].ID} amountMinor := "" paidAt := today - paidForDate := today action := "new" if payment != nil { values = *payment amountMinor = strconv.Itoa(payment.AmountMinor) paidAt = components.FormatDate(payment.PaidAt) - paidForDate = components.FormatDate(payment.PaidForDate) action = "edit" } @@ -146,10 +139,6 @@ func paymentForm( Title("Paid at (YYYY-MM-DD)"). Value(&paidAt). Validate(components.Date), - huh.NewInput(). - Title("Paid for (YYYY-MM-DD)"). - Value(&paidForDate). - Validate(components.Date), huh.NewInput(). Title("Note"). Value(&values.Note), @@ -171,11 +160,6 @@ func paymentForm( return err } values.PaidAt = parsedPaidAt - parsedPaidForDate, err := components.ParseDate(paidForDate) - if err != nil { - return err - } - values.PaidForDate = parsedPaidForDate if payment == nil { return stor.CreatePayment(ctx, values) } diff --git a/internal/tui/screens/payments_test.go b/internal/tui/screens/payments_test.go index 7f9b3e3..068bcab 100644 --- a/internal/tui/screens/payments_test.go +++ b/internal/tui/screens/payments_test.go @@ -16,7 +16,6 @@ func TestPaymentItem(t *testing.T) { AmountMinor: 150_050, Currency: "USD", PaidAt: time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC), - PaidForDate: time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC), Note: "June", } item := paymentItems([]storage.Payment{payment}, []storage.Project{project})[0] @@ -24,7 +23,7 @@ func TestPaymentItem(t *testing.T) { if got := item.Title(); got != "$1,500.50 · Client" { t.Fatalf("got title %q", got) } - for _, value := range []string{"2026-07-20", "2026-06-30", "June"} { + for _, value := range []string{"2026-07-20", "June"} { if !strings.Contains(item.Description(), value) { t.Fatalf("description %q does not contain %q", item.Description(), value) } diff --git a/internal/tui/screens/projects.go b/internal/tui/screens/projects.go index d333b03..e3894b1 100644 --- a/internal/tui/screens/projects.go +++ b/internal/tui/screens/projects.go @@ -38,11 +38,11 @@ func (p projectItem) Description() string { sort.Strings(currencies) balances := make([]string, len(currencies)) for i, currency := range currencies { - balances[i] = components.FormatMoney(p.balance[currency], currency) + balances[i] = components.FormatBalance(p.balance[currency], currency) } parts := make([]string, 0, 4) if len(balances) > 0 { - parts = append(parts, strings.Join(balances, ", ")+" outstanding") + parts = append(parts, strings.Join(balances, ", ")) } parts = append( parts, @@ -66,7 +66,9 @@ func projectItems( entries []storage.Entry, payments []storage.Payment, ) []projectItem { - summaries := storage.SummarizeProjects(projects, rates, entries, payments) + summaries := storage.SummarizeProjects( + projects, rates, entries, payments, time.Now(), + ) items := make([]projectItem, len(summaries)) for i, summary := range summaries { items[i] = projectItem{ diff --git a/internal/tui/screens/stats.go b/internal/tui/screens/stats.go index 47dbd48..18fad71 100644 --- a/internal/tui/screens/stats.go +++ b/internal/tui/screens/stats.go @@ -66,6 +66,9 @@ func (i statsProjectItem) Description() string { if amounts := formatStatsAmounts(i.project.PaidMinor); amounts != "" { description += " · " + amounts + " paid" } + if amounts := formatStatsBalances(i.project.BalanceMinor); amounts != "" { + description += " · current " + amounts + } return description } func (i statsProjectItem) FilterValue() string { return i.project.ProjectName } @@ -217,11 +220,13 @@ func (m Stats) headerView() string { b.WriteString(" tracked") for _, currency := range dashboardCurrencies( m.summary.EarnedMinor, m.summary.PaidMinor, m.summary.NetMinor, + m.summary.BalanceMinor, ) { - fmt.Fprintf(&b, "\n%s earned · %s paid · %s net", + fmt.Fprintf(&b, "\n%s earned · %s paid · %s net · current %s", components.FormatMoney(m.summary.EarnedMinor[currency], currency), components.FormatMoney(m.summary.PaidMinor[currency], currency), - components.FormatMoney(m.summary.NetMinor[currency], currency)) + components.FormatMoney(m.summary.NetMinor[currency], currency), + components.FormatBalance(m.summary.BalanceMinor[currency], currency)) } if chart := m.timelineChartView(lipgloss.Height(b.String())); chart != "" { b.WriteString("\n\nTracked over time by project (hours)\n" + chart) @@ -562,3 +567,12 @@ func formatStatsAmounts(amounts map[string]int64) string { } return strings.Join(parts, ", ") } + +func formatStatsBalances(amounts map[string]int64) string { + currencies := storage.SortedCurrencies(amounts) + parts := make([]string, len(currencies)) + for i, currency := range currencies { + parts[i] = components.FormatBalance(amounts[currency], currency) + } + return strings.Join(parts, ", ") +}