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:
2026-04-30 21:50:57 +02:00
parent 8ca838fa6e
commit 3214f48a6f
19 changed files with 1014 additions and 86 deletions

View File

@@ -0,0 +1,100 @@
package store
import (
"context"
"database/sql"
"errors"
"github.com/wotra/wotra/internal/domain"
)
// BalanceAdjustmentStore handles persistence for balance_adjustments.
type BalanceAdjustmentStore struct {
db *sql.DB
}
func NewBalanceAdjustmentStore(db *sql.DB) *BalanceAdjustmentStore {
return &BalanceAdjustmentStore{db: db}
}
func (s *BalanceAdjustmentStore) Create(ctx context.Context, a *domain.BalanceAdjustment) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO balance_adjustments (id, delta_ms, note, effective_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
a.ID, a.DeltaMs, a.Note, a.EffectiveAt, a.CreatedAt, a.UpdatedAt,
)
return err
}
func (s *BalanceAdjustmentStore) Update(ctx context.Context, a *domain.BalanceAdjustment) error {
res, err := s.db.ExecContext(ctx,
`UPDATE balance_adjustments SET delta_ms=?, note=?, effective_at=?, updated_at=? WHERE id=?`,
a.DeltaMs, a.Note, a.EffectiveAt, a.UpdatedAt, a.ID,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrAdjustmentNotFound
}
return nil
}
func (s *BalanceAdjustmentStore) Delete(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM balance_adjustments WHERE id=?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrAdjustmentNotFound
}
return nil
}
func (s *BalanceAdjustmentStore) GetByID(ctx context.Context, id string) (*domain.BalanceAdjustment, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, delta_ms, note, effective_at, created_at, updated_at
FROM balance_adjustments WHERE id=?`, id)
var a domain.BalanceAdjustment
err := row.Scan(&a.ID, &a.DeltaMs, &a.Note, &a.EffectiveAt, &a.CreatedAt, &a.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAdjustmentNotFound
}
if err != nil {
return nil, err
}
return &a, nil
}
// List returns all adjustments ordered by effective_at DESC.
func (s *BalanceAdjustmentStore) List(ctx context.Context) ([]*domain.BalanceAdjustment, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, delta_ms, note, effective_at, created_at, updated_at
FROM balance_adjustments ORDER BY effective_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var result []*domain.BalanceAdjustment
for rows.Next() {
var a domain.BalanceAdjustment
if err := rows.Scan(&a.ID, &a.DeltaMs, &a.Note, &a.EffectiveAt, &a.CreatedAt, &a.UpdatedAt); err != nil {
return nil, err
}
result = append(result, &a)
}
return result, rows.Err()
}
// SumDelta returns the sum of delta_ms and row count across all adjustments.
func (s *BalanceAdjustmentStore) SumDelta(ctx context.Context) (totalDeltaMs int64, count int, err error) {
err = s.db.QueryRowContext(ctx,
`SELECT COALESCE(SUM(delta_ms), 0), COUNT(*) FROM balance_adjustments`,
).Scan(&totalDeltaMs, &count)
return
}
// ErrAdjustmentNotFound is returned when no balance_adjustment row matches the given ID.
var ErrAdjustmentNotFound = errors.New("balance adjustment not found")