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

@@ -109,6 +109,18 @@ func (s *Settings) IsWorkday(wd int) bool {
return s.WorkdaysMask&bit != 0
}
// BalanceAdjustment is a manual corrective entry that adjusts the overall
// overtime balance without touching week math. DeltaMs is signed: positive
// credits time, negative debits time.
type BalanceAdjustment struct {
ID string `json:"id"` // UUIDv7, client-generated
DeltaMs int64 `json:"delta_ms"` // signed, non-zero
Note string `json:"note"` // optional free-text reason
EffectiveAt int64 `json:"effective_at"` // unix ms, backdatable
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
func popcount(n int) int {
count := 0
for n != 0 {

View File

@@ -1,14 +1,18 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/wotra/wotra/internal/domain"
"github.com/wotra/wotra/internal/service"
)
// WeekHandler serves /api/weeks routes.
// WeekHandler serves /api/weeks and /api/balance/adjustments routes.
type WeekHandler struct {
svc *service.WeekService
}
@@ -22,6 +26,11 @@ func (h *WeekHandler) Routes(r chi.Router) {
r.Get("/weeks/balance", h.Balance)
r.Post("/weeks/{week_key}/close", h.Close)
r.Delete("/weeks/{week_key}/close", h.Reopen)
r.Get("/balance/adjustments", h.ListAdjustments)
r.Post("/balance/adjustments", h.CreateAdjustment)
r.Put("/balance/adjustments/{id}", h.UpdateAdjustment)
r.Delete("/balance/adjustments/{id}", h.DeleteAdjustment)
}
// Balance GET /api/weeks/balance
@@ -90,3 +99,107 @@ func (h *WeekHandler) Reopen(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(http.StatusNoContent)
}
// ── Balance adjustments ───────────────────────────────────────────────────────
// ListAdjustments GET /api/balance/adjustments
func (h *WeekHandler) ListAdjustments(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.ListAdjustments(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if list == nil {
writeJSON(w, http.StatusOK, []*domain.BalanceAdjustment{})
return
}
writeJSON(w, http.StatusOK, list)
}
type adjustmentRequest struct {
DeltaMs int64 `json:"delta_ms"`
Note string `json:"note"`
EffectiveAt *int64 `json:"effective_at"` // optional; defaults to now
}
// CreateAdjustment POST /api/balance/adjustments
func (h *WeekHandler) CreateAdjustment(w http.ResponseWriter, r *http.Request) {
var req adjustmentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
now := time.Now().UnixMilli()
effectiveAt := now
if req.EffectiveAt != nil {
effectiveAt = *req.EffectiveAt
}
a := &domain.BalanceAdjustment{
ID: uuid.New().String(),
DeltaMs: req.DeltaMs,
Note: req.Note,
EffectiveAt: effectiveAt,
CreatedAt: now,
UpdatedAt: now,
}
created, err := h.svc.CreateAdjustment(r.Context(), a)
if err != nil {
if errors.Is(err, service.ErrZeroAdjustment) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, created)
}
// UpdateAdjustment PUT /api/balance/adjustments/{id}
func (h *WeekHandler) UpdateAdjustment(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req adjustmentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
now := time.Now().UnixMilli()
effectiveAt := now
if req.EffectiveAt != nil {
effectiveAt = *req.EffectiveAt
}
a := &domain.BalanceAdjustment{
ID: id,
DeltaMs: req.DeltaMs,
Note: req.Note,
EffectiveAt: effectiveAt,
UpdatedAt: now,
}
updated, err := h.svc.UpdateAdjustment(r.Context(), a)
if err != nil {
switch {
case errors.Is(err, service.ErrZeroAdjustment):
writeError(w, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrAdjustmentNotFound):
writeError(w, http.StatusNotFound, err.Error())
default:
writeError(w, http.StatusInternalServerError, err.Error())
}
return
}
writeJSON(w, http.StatusOK, updated)
}
// DeleteAdjustment DELETE /api/balance/adjustments/{id}
func (h *WeekHandler) DeleteAdjustment(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := h.svc.DeleteAdjustment(r.Context(), id); err != nil {
switch {
case errors.Is(err, service.ErrAdjustmentNotFound):
writeError(w, http.StatusNotFound, err.Error())
default:
writeError(w, http.StatusInternalServerError, err.Error())
}
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -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.

View File

@@ -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)
}
}

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")

View 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)
}
}

View File

@@ -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
}

View File

@@ -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)

View 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;

View File

@@ -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 {