Compare commits

...

4 Commits

Author SHA1 Message Date
e9069c97a8 Merge pull request 'ci: add lint, coverage check and proper deploy workflow' (#2) from dev into main
Some checks failed
Deploy / deploy (push) Failing after 1s
2026-03-26 18:34:37 +00:00
Cosmo
30a894a78f ci: add lint, coverage check and proper deploy workflow
Some checks failed
CI / lint-test (push) Failing after 2m9s
CI / lint-test (pull_request) Failing after 25s
2026-03-26 18:33:43 +00:00
Cosmo
901173a337 fix: analytics avg_daily_expense uses selected month/year instead of current
All checks were successful
CI / ci (push) Successful in 13s
2026-03-01 05:31:05 +00:00
Cosmo
9e06341564 feat: cumulative balance with carried_over in finance summary
All checks were successful
CI / ci (push) Successful in 14s
2026-03-01 05:22:56 +00:00
6 changed files with 76 additions and 29 deletions

View File

@@ -2,10 +2,12 @@ name: CI
on: on:
push: push:
branches: [dev] branches-ignore: [main]
pull_request:
branches: [main]
jobs: jobs:
ci: lint-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -17,11 +19,19 @@ jobs:
- name: Tidy - name: Tidy
run: go mod tidy run: go mod tidy
- name: Vet - name: Lint
run: go vet ./... uses: golangci/golangci-lint-action@v6
with:
version: latest
- name: Test - name: Test
run: go test ./... -v run: go test ./... -coverprofile=coverage.out -covermode=atomic
- name: Build - name: Coverage Check
run: CGO_ENABLED=0 go build -o main ./cmd/api run: |
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%')
echo "Coverage: ${COVERAGE}%"
if (( $(echo "$COVERAGE < 85" | bc -l) )); then
echo "::error::Coverage ${COVERAGE}% is below 85%"
exit 1
fi

View File

@@ -1,4 +1,4 @@
name: Deploy Production name: Deploy
on: on:
push: push:
@@ -8,11 +8,18 @@ jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - name: Deploy pulse-api
run: |
- uses: actions/setup-go@v5 cd /opt/digital-home/homelab-api
with: docker compose pull
go-version: '1.22' docker compose up -d --build
echo "Waiting for healthcheck..."
- name: Build sleep 5
run: CGO_ENABLED=0 go build -o main ./cmd/api STATUS=$(docker inspect --format='{{.State.Health.Status}}' homelab-api 2>/dev/null || echo "no-healthcheck")
echo "Container status: $STATUS"
if [ "$STATUS" = "unhealthy" ]; then
echo "::error::Container is unhealthy after deploy"
docker logs homelab-api --tail=20
exit 1
fi
echo "Deploy successful!"

View File

@@ -253,11 +253,21 @@ func (h *FinanceHandler) Analytics(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
months, _ := strconv.Atoi(r.URL.Query().Get("months")) q := r.URL.Query()
months, _ := strconv.Atoi(q.Get("months"))
if months <= 0 { if months <= 0 {
months = 6 months = 6
} }
analytics, err := h.svc.GetAnalytics(userID, months) now := time.Now()
month, _ := strconv.Atoi(q.Get("month"))
year, _ := strconv.Atoi(q.Get("year"))
if month <= 0 {
month = int(now.Month())
}
if year <= 0 {
year = now.Year()
}
analytics, err := h.svc.GetAnalytics(userID, months, month, year)
if err != nil { if err != nil {
writeError(w, "failed to get analytics", http.StatusInternalServerError) writeError(w, "failed to get analytics", http.StatusInternalServerError)
return return

View File

@@ -73,6 +73,7 @@ type UpdateFinanceTransactionRequest struct {
} }
type FinanceSummary struct { type FinanceSummary struct {
CarriedOver float64 `json:"carried_over"`
Balance float64 `json:"balance"` Balance float64 `json:"balance"`
TotalIncome float64 `json:"total_income"` TotalIncome float64 `json:"total_income"`
TotalExpense float64 `json:"total_expense"` TotalExpense float64 `json:"total_expense"`

View File

@@ -237,15 +237,34 @@ func (r *FinanceRepository) DeleteTransaction(id, userID int64) error {
func (r *FinanceRepository) GetSummary(userID int64, month, year int) (*model.FinanceSummary, error) { func (r *FinanceRepository) GetSummary(userID int64, month, year int) (*model.FinanceSummary, error) {
summary := &model.FinanceSummary{} summary := &model.FinanceSummary{}
// First day of selected month and last day of selected month
firstDay := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
lastDay := firstDay.AddDate(0, 1, -1)
// Carried over: balance of all transactions BEFORE the selected month
err := r.db.QueryRow(`SELECT COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE -amount END), 0)
FROM finance_transactions WHERE user_id=$1 AND date < $2`,
userID, firstDay).Scan(&summary.CarriedOver)
if err != nil {
return nil, err
}
// Total income & expense for the month // Total income & expense for the month
err := r.db.QueryRow(`SELECT COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE 0 END),0), err = r.db.QueryRow(`SELECT COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE 0 END),0),
COALESCE(SUM(CASE WHEN type='expense' THEN amount ELSE 0 END),0) COALESCE(SUM(CASE WHEN type='expense' THEN amount ELSE 0 END),0)
FROM finance_transactions WHERE user_id=$1 AND EXTRACT(MONTH FROM date)=$2 AND EXTRACT(YEAR FROM date)=$3`, FROM finance_transactions WHERE user_id=$1 AND EXTRACT(MONTH FROM date)=$2 AND EXTRACT(YEAR FROM date)=$3`,
userID, month, year).Scan(&summary.TotalIncome, &summary.TotalExpense) userID, month, year).Scan(&summary.TotalIncome, &summary.TotalExpense)
if err != nil { if err != nil {
return nil, err return nil, err
} }
summary.Balance = summary.TotalIncome - summary.TotalExpense
// Cumulative balance: all transactions up to end of selected month
err = r.db.QueryRow(`SELECT COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE -amount END), 0)
FROM finance_transactions WHERE user_id=$1 AND date <= $2`,
userID, lastDay).Scan(&summary.Balance)
if err != nil {
return nil, err
}
// By category // By category
rows, err := r.db.Query(`SELECT c.id, c.name, c.emoji, t.type, SUM(t.amount) as amount rows, err := r.db.Query(`SELECT c.id, c.name, c.emoji, t.type, SUM(t.amount) as amount
@@ -298,7 +317,7 @@ func (r *FinanceRepository) GetSummary(userID int64, month, year int) (*model.Fi
return summary, nil return summary, nil
} }
func (r *FinanceRepository) GetAnalytics(userID int64, months int) (*model.FinanceAnalytics, error) { func (r *FinanceRepository) GetAnalytics(userID int64, months, month, year int) (*model.FinanceAnalytics, error) {
analytics := &model.FinanceAnalytics{} analytics := &model.FinanceAnalytics{}
// Monthly trend // Monthly trend
@@ -323,27 +342,27 @@ func (r *FinanceRepository) GetAnalytics(userID int64, months int) (*model.Finan
analytics.MonthlyTrend = []model.MonthlyTrend{} analytics.MonthlyTrend = []model.MonthlyTrend{}
} }
// Avg daily expense for current month // Avg daily expense for selected month
now := time.Now()
var totalExpense float64 var totalExpense float64
var dayCount int var dayCount int
r.db.QueryRow(`SELECT COALESCE(SUM(amount),0), COUNT(DISTINCT date) r.db.QueryRow(`SELECT COALESCE(SUM(amount),0), COUNT(DISTINCT date)
FROM finance_transactions WHERE user_id=$1 AND type='expense' FROM finance_transactions WHERE user_id=$1 AND type='expense'
AND EXTRACT(MONTH FROM date)=$2 AND EXTRACT(YEAR FROM date)=$3`, AND EXTRACT(MONTH FROM date)=$2 AND EXTRACT(YEAR FROM date)=$3`,
userID, int(now.Month()), now.Year()).Scan(&totalExpense, &dayCount) userID, month, year).Scan(&totalExpense, &dayCount)
if dayCount > 0 { if dayCount > 0 {
analytics.AvgDailyExpense = totalExpense / float64(dayCount) analytics.AvgDailyExpense = totalExpense / float64(dayCount)
} }
// Comparison with previous month // Comparison with previous month
prevMonth := now.AddDate(0, -1, 0) selectedMonth := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
prevMonthTime := selectedMonth.AddDate(0, -1, 0)
var currentMonthExp, prevMonthExp float64 var currentMonthExp, prevMonthExp float64
r.db.QueryRow(`SELECT COALESCE(SUM(amount),0) FROM finance_transactions WHERE user_id=$1 AND type='expense' r.db.QueryRow(`SELECT COALESCE(SUM(amount),0) FROM finance_transactions WHERE user_id=$1 AND type='expense'
AND EXTRACT(MONTH FROM date)=$2 AND EXTRACT(YEAR FROM date)=$3`, AND EXTRACT(MONTH FROM date)=$2 AND EXTRACT(YEAR FROM date)=$3`,
userID, int(now.Month()), now.Year()).Scan(&currentMonthExp) userID, month, year).Scan(&currentMonthExp)
r.db.QueryRow(`SELECT COALESCE(SUM(amount),0) FROM finance_transactions WHERE user_id=$1 AND type='expense' r.db.QueryRow(`SELECT COALESCE(SUM(amount),0) FROM finance_transactions WHERE user_id=$1 AND type='expense'
AND EXTRACT(MONTH FROM date)=$2 AND EXTRACT(YEAR FROM date)=$3`, AND EXTRACT(MONTH FROM date)=$2 AND EXTRACT(YEAR FROM date)=$3`,
userID, int(prevMonth.Month()), prevMonth.Year()).Scan(&prevMonthExp) userID, int(prevMonthTime.Month()), prevMonthTime.Year()).Scan(&prevMonthExp)
analytics.ComparisonPrevMonth = model.Comparison{ analytics.ComparisonPrevMonth = model.Comparison{
Current: currentMonthExp, Current: currentMonthExp,

View File

@@ -162,6 +162,6 @@ func (s *FinanceService) GetSummary(userID int64, month, year int) (*model.Finan
return s.repo.GetSummary(userID, month, year) return s.repo.GetSummary(userID, month, year)
} }
func (s *FinanceService) GetAnalytics(userID int64, months int) (*model.FinanceAnalytics, error) { func (s *FinanceService) GetAnalytics(userID int64, months, month, year int) (*model.FinanceAnalytics, error) {
return s.repo.GetAnalytics(userID, months) return s.repo.GetAnalytics(userID, months, month, year)
} }