Add balance adjustments (M8)
- New balance_adjustments table with CRUD store, sync logging, and service methods
- SQL migrations restructured: embed fs.FS from internal/store/migrations/, apply in order via user_version
- WeekService.Balance combines closed-weeks delta + adjustments delta; BalanceSummary breakdown
- Four REST routes: GET/POST /api/balance/adjustments, PUT/DELETE /api/balance/adjustments/{id}
- Dexie schema v2 + sync apply cases for balance_adjustments
- API client: BalanceAdjustment type, balance namespace (list/create/update/delete)
- utils: composeDeltaMs / decomposeDeltaMs helpers + 8 new Vitest tests (19 total, all passing)
- History page: balance card breakdown line + full adjustments section with inline add/edit/delete
This commit is contained in:
@@ -11,13 +11,21 @@ import (
|
||||
"github.com/wotra/wotra/internal/store"
|
||||
)
|
||||
|
||||
// Sentinel errors for balance adjustments.
|
||||
var (
|
||||
ErrZeroAdjustment = errors.New("delta_ms must be non-zero")
|
||||
ErrAdjustmentNotFound = errors.New("balance adjustment not found")
|
||||
)
|
||||
|
||||
// WeekService handles closing weeks and computing overtime/undertime.
|
||||
type WeekService struct {
|
||||
closedDays *store.ClosedDayStore
|
||||
closedWeeks *store.ClosedWeekStore
|
||||
entries *store.EntryStore
|
||||
settings *store.SettingsStore
|
||||
db interface {
|
||||
closedDays *store.ClosedDayStore
|
||||
closedWeeks *store.ClosedWeekStore
|
||||
adjustments *store.BalanceAdjustmentStore
|
||||
syncStore *store.SyncStore
|
||||
entries *store.EntryStore
|
||||
settings *store.SettingsStore
|
||||
db interface {
|
||||
QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row
|
||||
}
|
||||
rawDB *sql.DB
|
||||
@@ -29,12 +37,16 @@ func NewWeekService(
|
||||
closedWeeks *store.ClosedWeekStore,
|
||||
entries *store.EntryStore,
|
||||
settings *store.SettingsStore,
|
||||
adjustments *store.BalanceAdjustmentStore,
|
||||
syncStore *store.SyncStore,
|
||||
rawDB *sql.DB,
|
||||
tz *time.Location,
|
||||
) *WeekService {
|
||||
return &WeekService{
|
||||
closedDays: closedDays,
|
||||
closedWeeks: closedWeeks,
|
||||
adjustments: adjustments,
|
||||
syncStore: syncStore,
|
||||
entries: entries,
|
||||
settings: settings,
|
||||
rawDB: rawDB,
|
||||
@@ -44,17 +56,78 @@ func NewWeekService(
|
||||
|
||||
// BalanceResult holds the overall overtime/undertime balance across all closed weeks.
|
||||
type BalanceResult struct {
|
||||
TotalDeltaMs int64 `json:"total_delta_ms"`
|
||||
ClosedWeekCount int `json:"closed_week_count"`
|
||||
TotalDeltaMs int64 `json:"total_delta_ms"`
|
||||
WeeksDeltaMs int64 `json:"weeks_delta_ms"`
|
||||
AdjustmentsDeltaMs int64 `json:"adjustments_delta_ms"`
|
||||
ClosedWeekCount int `json:"closed_week_count"`
|
||||
AdjustmentCount int `json:"adjustment_count"`
|
||||
}
|
||||
|
||||
// Balance returns the sum of delta_ms across all closed weeks.
|
||||
// Balance returns the combined overtime balance: closed weeks + manual adjustments.
|
||||
func (s *WeekService) Balance(ctx context.Context) (BalanceResult, error) {
|
||||
total, count, err := s.closedWeeks.SumDelta(ctx)
|
||||
weeksTotal, weekCount, err := s.closedWeeks.SumDelta(ctx)
|
||||
if err != nil {
|
||||
return BalanceResult{}, err
|
||||
}
|
||||
return BalanceResult{TotalDeltaMs: total, ClosedWeekCount: count}, nil
|
||||
adjTotal, adjCount, err := s.adjustments.SumDelta(ctx)
|
||||
if err != nil {
|
||||
return BalanceResult{}, err
|
||||
}
|
||||
return BalanceResult{
|
||||
TotalDeltaMs: weeksTotal + adjTotal,
|
||||
WeeksDeltaMs: weeksTotal,
|
||||
AdjustmentsDeltaMs: adjTotal,
|
||||
ClosedWeekCount: weekCount,
|
||||
AdjustmentCount: adjCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListAdjustments returns all balance adjustments ordered by effective_at DESC.
|
||||
func (s *WeekService) ListAdjustments(ctx context.Context) ([]*domain.BalanceAdjustment, error) {
|
||||
return s.adjustments.List(ctx)
|
||||
}
|
||||
|
||||
// CreateAdjustment creates and sync-logs a new balance adjustment.
|
||||
func (s *WeekService) CreateAdjustment(ctx context.Context, a *domain.BalanceAdjustment) (*domain.BalanceAdjustment, error) {
|
||||
if a.DeltaMs == 0 {
|
||||
return nil, ErrZeroAdjustment
|
||||
}
|
||||
if err := s.adjustments.Create(ctx, a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = s.syncStore.LogBalanceAdjustment(ctx, a) // best-effort
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// UpdateAdjustment updates and sync-logs an existing balance adjustment.
|
||||
func (s *WeekService) UpdateAdjustment(ctx context.Context, a *domain.BalanceAdjustment) (*domain.BalanceAdjustment, error) {
|
||||
if a.DeltaMs == 0 {
|
||||
return nil, ErrZeroAdjustment
|
||||
}
|
||||
if err := s.adjustments.Update(ctx, a); err != nil {
|
||||
if errors.Is(err, store.ErrAdjustmentNotFound) {
|
||||
return nil, ErrAdjustmentNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
got, err := s.adjustments.GetByID(ctx, a.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = s.syncStore.LogBalanceAdjustment(ctx, got) // best-effort
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// DeleteAdjustment deletes and sync-logs a balance adjustment.
|
||||
func (s *WeekService) DeleteAdjustment(ctx context.Context, id string) error {
|
||||
if err := s.adjustments.Delete(ctx, id); err != nil {
|
||||
if errors.Is(err, store.ErrAdjustmentNotFound) {
|
||||
return ErrAdjustmentNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
_ = s.syncStore.LogBalanceAdjustmentDelete(ctx, id) // best-effort
|
||||
return nil
|
||||
}
|
||||
|
||||
// WeekDayKeysExported is exported for testing.
|
||||
|
||||
@@ -22,12 +22,14 @@ func newFullServices(t *testing.T) (*service.EntryService, *service.DayService,
|
||||
entryStore := store.NewEntryStore(db)
|
||||
closedDayStore := store.NewClosedDayStore(db)
|
||||
closedWeekStore := store.NewClosedWeekStore(db)
|
||||
adjustmentStore := store.NewBalanceAdjustmentStore(db)
|
||||
syncStore := store.NewSyncStore(db)
|
||||
settingsStore := store.NewSettingsStore(db)
|
||||
tz, _ := time.LoadLocation("UTC")
|
||||
|
||||
entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz)
|
||||
daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, tz)
|
||||
weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, entryStore, settingsStore, db, tz)
|
||||
weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, entryStore, settingsStore, adjustmentStore, syncStore, db, tz)
|
||||
settingsSvc := service.NewSettingsService(settingsStore)
|
||||
return entrySvc, daySvc, weekSvc, settingsSvc
|
||||
}
|
||||
@@ -246,13 +248,13 @@ func TestWeekServiceBalance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, daySvc, weekSvc, _ := newFullServices(t)
|
||||
|
||||
// Empty — no closed weeks yet.
|
||||
// Empty — no closed weeks, no adjustments.
|
||||
bal, err := weekSvc.Balance(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Balance (empty): %v", err)
|
||||
}
|
||||
if bal.TotalDeltaMs != 0 || bal.ClosedWeekCount != 0 {
|
||||
t.Errorf("empty: want {0,0}, got {%d,%d}", bal.TotalDeltaMs, bal.ClosedWeekCount)
|
||||
if bal.TotalDeltaMs != 0 || bal.ClosedWeekCount != 0 || bal.AdjustmentCount != 0 {
|
||||
t.Errorf("empty: want all zeros, got %+v", bal)
|
||||
}
|
||||
|
||||
// Close two weeks as holiday (worked = expected → delta = 0 each).
|
||||
@@ -279,10 +281,73 @@ func TestWeekServiceBalance(t *testing.T) {
|
||||
t.Fatalf("Balance (populated): %v", err)
|
||||
}
|
||||
if bal.ClosedWeekCount != 2 {
|
||||
t.Errorf("count: want 2, got %d", bal.ClosedWeekCount)
|
||||
t.Errorf("closed_week_count: want 2, got %d", bal.ClosedWeekCount)
|
||||
}
|
||||
// Both weeks: worked == expected (holiday marks = full hours), delta = 0.
|
||||
if bal.TotalDeltaMs != 0 {
|
||||
t.Errorf("total_delta_ms: want 0, got %d", bal.TotalDeltaMs)
|
||||
if bal.TotalDeltaMs != 0 || bal.WeeksDeltaMs != 0 {
|
||||
t.Errorf("weeks-only total: want 0, got %+v", bal)
|
||||
}
|
||||
|
||||
// Add a +2h adjustment.
|
||||
now := time.Now().UnixMilli()
|
||||
adj, err := weekSvc.CreateAdjustment(ctx, &domain.BalanceAdjustment{
|
||||
ID: "adj-1", DeltaMs: 7_200_000, Note: "carry-over",
|
||||
EffectiveAt: now, CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAdjustment: %v", err)
|
||||
}
|
||||
if adj.ID != "adj-1" {
|
||||
t.Errorf("CreateAdjustment ID: want adj-1, got %s", adj.ID)
|
||||
}
|
||||
|
||||
bal, err = weekSvc.Balance(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Balance with adjustment: %v", err)
|
||||
}
|
||||
if bal.TotalDeltaMs != 7_200_000 {
|
||||
t.Errorf("total with adj: want 7200000, got %d", bal.TotalDeltaMs)
|
||||
}
|
||||
if bal.AdjustmentsDeltaMs != 7_200_000 {
|
||||
t.Errorf("adjustments_delta_ms: want 7200000, got %d", bal.AdjustmentsDeltaMs)
|
||||
}
|
||||
if bal.AdjustmentCount != 1 {
|
||||
t.Errorf("adjustment_count: want 1, got %d", bal.AdjustmentCount)
|
||||
}
|
||||
|
||||
// Zero delta rejected.
|
||||
_, err = weekSvc.CreateAdjustment(ctx, &domain.BalanceAdjustment{
|
||||
ID: "adj-zero", DeltaMs: 0, EffectiveAt: now, CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != service.ErrZeroAdjustment {
|
||||
t.Errorf("zero delta: want ErrZeroAdjustment, got %v", err)
|
||||
}
|
||||
|
||||
// Update adjustment.
|
||||
adj.DeltaMs = 3_600_000
|
||||
adj.UpdatedAt = now + 1
|
||||
updated, err := weekSvc.UpdateAdjustment(ctx, adj)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateAdjustment: %v", err)
|
||||
}
|
||||
if updated.DeltaMs != 3_600_000 {
|
||||
t.Errorf("updated delta_ms: want 3600000, got %d", updated.DeltaMs)
|
||||
}
|
||||
bal, _ = weekSvc.Balance(ctx)
|
||||
if bal.TotalDeltaMs != 3_600_000 {
|
||||
t.Errorf("balance after update: want 3600000, got %d", bal.TotalDeltaMs)
|
||||
}
|
||||
|
||||
// Delete adjustment.
|
||||
if err := weekSvc.DeleteAdjustment(ctx, "adj-1"); err != nil {
|
||||
t.Fatalf("DeleteAdjustment: %v", err)
|
||||
}
|
||||
bal, _ = weekSvc.Balance(ctx)
|
||||
if bal.TotalDeltaMs != 0 || bal.AdjustmentCount != 0 {
|
||||
t.Errorf("balance after delete: want 0/0, got %d/%d", bal.TotalDeltaMs, bal.AdjustmentCount)
|
||||
}
|
||||
|
||||
// Delete missing.
|
||||
if err := weekSvc.DeleteAdjustment(ctx, "no-such"); err != service.ErrAdjustmentNotFound {
|
||||
t.Errorf("delete missing: want ErrAdjustmentNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user