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

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