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:
100
internal/store/balance_adjustment_store.go
Normal file
100
internal/store/balance_adjustment_store.go
Normal 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")
|
||||
128
internal/store/balance_adjustment_store_test.go
Normal file
128
internal/store/balance_adjustment_store_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wotra/wotra/internal/domain"
|
||||
"github.com/wotra/wotra/internal/store"
|
||||
)
|
||||
|
||||
func TestBalanceAdjustmentStoreCRUD(t *testing.T) {
|
||||
db, err := store.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
s := store.NewBalanceAdjustmentStore(db)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
// SumDelta on empty table.
|
||||
total, count, err := s.SumDelta(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("SumDelta empty: %v", err)
|
||||
}
|
||||
if total != 0 || count != 0 {
|
||||
t.Fatalf("empty: want (0,0), got (%d,%d)", total, count)
|
||||
}
|
||||
|
||||
// List on empty table returns nil slice, no error.
|
||||
list, err := s.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("List empty: %v", err)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Errorf("List empty: want 0, got %d", len(list))
|
||||
}
|
||||
|
||||
// Create two adjustments.
|
||||
a1 := &domain.BalanceAdjustment{
|
||||
ID: "id-1", DeltaMs: 3_600_000, Note: "carry-over",
|
||||
EffectiveAt: now - 86400000, CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
a2 := &domain.BalanceAdjustment{
|
||||
ID: "id-2", DeltaMs: -1_800_000, Note: "",
|
||||
EffectiveAt: now, CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err := s.Create(ctx, a1); err != nil {
|
||||
t.Fatalf("Create a1: %v", err)
|
||||
}
|
||||
if err := s.Create(ctx, a2); err != nil {
|
||||
t.Fatalf("Create a2: %v", err)
|
||||
}
|
||||
|
||||
// GetByID round-trip.
|
||||
got, err := s.GetByID(ctx, "id-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID: %v", err)
|
||||
}
|
||||
if got.DeltaMs != a1.DeltaMs || got.Note != a1.Note {
|
||||
t.Errorf("GetByID: want {%d,%q}, got {%d,%q}", a1.DeltaMs, a1.Note, got.DeltaMs, got.Note)
|
||||
}
|
||||
|
||||
// GetByID missing.
|
||||
_, err = s.GetByID(ctx, "no-such")
|
||||
if err != store.ErrAdjustmentNotFound {
|
||||
t.Errorf("GetByID missing: want ErrAdjustmentNotFound, got %v", err)
|
||||
}
|
||||
|
||||
// SumDelta with two rows.
|
||||
total, count, err = s.SumDelta(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("SumDelta: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("count: want 2, got %d", count)
|
||||
}
|
||||
want := int64(3_600_000 - 1_800_000)
|
||||
if total != want {
|
||||
t.Errorf("total: want %d, got %d", want, total)
|
||||
}
|
||||
|
||||
// List returns newest effective_at first (a2 has now, a1 has now-1day).
|
||||
list, err = s.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("List len: want 2, got %d", len(list))
|
||||
}
|
||||
if list[0].ID != "id-2" {
|
||||
t.Errorf("List[0]: want id-2, got %s", list[0].ID)
|
||||
}
|
||||
|
||||
// Update a1.
|
||||
a1.DeltaMs = 7_200_000
|
||||
a1.Note = "updated"
|
||||
a1.UpdatedAt = now + 1000
|
||||
if err := s.Update(ctx, a1); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
got, _ = s.GetByID(ctx, "id-1")
|
||||
if got.DeltaMs != 7_200_000 || got.Note != "updated" {
|
||||
t.Errorf("after Update: want {7200000,updated}, got {%d,%q}", got.DeltaMs, got.Note)
|
||||
}
|
||||
|
||||
// Update missing.
|
||||
missing := &domain.BalanceAdjustment{ID: "no-such", DeltaMs: 1, UpdatedAt: now}
|
||||
if err := s.Update(ctx, missing); err != store.ErrAdjustmentNotFound {
|
||||
t.Errorf("Update missing: want ErrAdjustmentNotFound, got %v", err)
|
||||
}
|
||||
|
||||
// Delete a2.
|
||||
if err := s.Delete(ctx, "id-2"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
total, count, _ = s.SumDelta(ctx)
|
||||
if count != 1 || total != 7_200_000 {
|
||||
t.Errorf("after Delete: want (7200000,1), got (%d,%d)", total, count)
|
||||
}
|
||||
|
||||
// Delete missing.
|
||||
if err := s.Delete(ctx, "no-such"); err != store.ErrAdjustmentNotFound {
|
||||
t.Errorf("Delete missing: want ErrAdjustmentNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,18 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"embed"
|
||||
)
|
||||
|
||||
//go:embed 001_initial.sql
|
||||
var schema string
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// Open opens (or creates) the SQLite database at path and runs migrations.
|
||||
func Open(path string) (*sql.DB, error) {
|
||||
@@ -31,22 +34,51 @@ func Open(path string) (*sql.DB, error) {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// migrate runs embedded SQL migrations. Simple single-file approach: we track
|
||||
// a user_version pragma and apply the schema once if version == 0.
|
||||
// migrate applies all SQL migration files in migrations/ in filename order.
|
||||
// user_version tracks the last applied migration index (1-based).
|
||||
func migrate(db *sql.DB) error {
|
||||
var version int
|
||||
if err := db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
|
||||
return err
|
||||
}
|
||||
if version >= 1 {
|
||||
return nil
|
||||
|
||||
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migrations dir: %w", err)
|
||||
}
|
||||
|
||||
// Sort by name so 001_*, 002_* apply in order.
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].Name() < entries[j].Name()
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Strip migration comments and split into individual statements
|
||||
stmts := splitStatements(schema)
|
||||
for _, stmt := range stmts {
|
||||
for i, entry := range entries {
|
||||
migrationNum := i + 1 // 1-based
|
||||
if version >= migrationNum {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := migrationsFS.ReadFile("migrations/" + entry.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
if err := applySchema(db, ctx, string(data)); err != nil {
|
||||
return fmt.Errorf("migration %d (%s): %w", migrationNum, entry.Name(), err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", migrationNum)); err != nil {
|
||||
return fmt.Errorf("set user_version = %d: %w", migrationNum, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func applySchema(db *sql.DB, ctx context.Context, sql string) error {
|
||||
for _, stmt := range splitStatements(sql) {
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
if stmt == "" {
|
||||
continue
|
||||
@@ -55,12 +87,6 @@ func migrate(db *sql.DB) error {
|
||||
return fmt.Errorf("exec %q: %w", stmt[:min(len(stmt), 60)], err)
|
||||
}
|
||||
}
|
||||
|
||||
// PRAGMA user_version cannot be set inside a regular transaction in all SQLite versions;
|
||||
// execute it as a standalone statement.
|
||||
if _, err := db.ExecContext(ctx, "PRAGMA user_version = 1"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestMigration(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
tables := []string{"entries", "closed_days", "closed_weeks", "settings_history", "sync_log"}
|
||||
tables := []string{"entries", "closed_days", "closed_weeks", "settings_history", "sync_log", "balance_adjustments"}
|
||||
for _, tbl := range tables {
|
||||
var name string
|
||||
err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", tbl).Scan(&name)
|
||||
|
||||
14
internal/store/migrations/002_balance_adjustments.sql
Normal file
14
internal/store/migrations/002_balance_adjustments.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- +migrate Up
|
||||
CREATE TABLE balance_adjustments (
|
||||
id TEXT PRIMARY KEY,
|
||||
delta_ms INTEGER NOT NULL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
effective_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_balance_adjustments_effective_at ON balance_adjustments(effective_at);
|
||||
|
||||
-- +migrate Down
|
||||
DROP INDEX IF EXISTS idx_balance_adjustments_effective_at;
|
||||
DROP TABLE IF EXISTS balance_adjustments;
|
||||
@@ -96,6 +96,21 @@ func (s *SyncStore) LogClosedWeek(ctx context.Context, w *domain.ClosedWeek) err
|
||||
return s.log(ctx, "closed_weeks", w.WeekKey, "upsert", string(payload))
|
||||
}
|
||||
|
||||
// LogBalanceAdjustment appends a balance_adjustment upsert to the sync log.
|
||||
func (s *SyncStore) LogBalanceAdjustment(ctx context.Context, a *domain.BalanceAdjustment) error {
|
||||
payload, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.log(ctx, "balance_adjustments", a.ID, "upsert", string(payload))
|
||||
}
|
||||
|
||||
// LogBalanceAdjustmentDelete appends a balance_adjustment delete to the sync log.
|
||||
func (s *SyncStore) LogBalanceAdjustmentDelete(ctx context.Context, id string) error {
|
||||
payload := fmt.Sprintf(`{"id":%q}`, id)
|
||||
return s.log(ctx, "balance_adjustments", id, "delete", payload)
|
||||
}
|
||||
|
||||
func (s *SyncStore) log(ctx context.Context, entity, entityID, op, payload string) error {
|
||||
version, err := s.nextVersion(ctx)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user