/* Copyright (C) 2025 TheWanderingCrow This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package main import ( "bytes" "encoding/json" "fmt" "github.com/joho/godotenv" "io" "log" "net/http" "net/smtp" "os" "strings" "time" ) type BudgetClient struct { baseUrl string apiKey string syncId string fullUrl string encryptionKey string } type BudgetMonthsResponse struct { Data struct { Month string `json:"month"` IncomeAvailable int `json:"incomeAvailable"` LastMonthOverspent int `json:"lastMonthOverspent"` ForNextMonth int `json:"forNextMonth"` TotalBudgeted int `json:"totalBudgeted"` ToBudget int `json:"toBudget"` FromLastMonth int `json:"fromLastMonth"` TotalIncome int `json:"totalIncome"` TotalSpent int `json:"totalSpent"` TotalBalance int `json:"totalBalance"` CategoryGroups []struct { ID string `json:"id"` Name string `json:"name"` IsIncome bool `json:"is_income"` Hidden bool `json:"hidden"` Budgeted int `json:"budgeted"` Spent int `json:"spent"` Balance int `json:"balance"` Categories []struct { ID string `json:"id"` Name string `json:"name"` IsIncome bool `json:"is_income"` Hidden bool `json:"hidden"` GroupID string `json:"group_id"` Budgeted int `json:"budgeted"` Spent int `json:"spent"` Balance int `json:"balance"` Carryover bool `json:"carryover"` } `json:"categories"` } `json:"categoryGroups"` } `json:"data"` } // Create budget client, you may pass empty strings to optional parameters // Required: baseUrl, apiKey, syncId // Optional: encryptionKey func CreateBudgetClient(baseUrl string, apiKey string, syncId string, encryptionKey string) *BudgetClient { fullUrl := baseUrl + "/v1/budgets/" + syncId client := BudgetClient{baseUrl, apiKey, syncId, fullUrl, encryptionKey} return &client } // Make a call to the actualbudget API // // method is POST, GET, ect // route is the route to call with a leading / ex. /accounts/banksync // headers is a map of strings expecting "header" "value" func (b BudgetClient) callApi(method string, route string, headers map[string]string) *http.Response { var httpClient http.Client req, err := http.NewRequest(method, b.fullUrl+route, http.NoBody) if err != nil { log.Fatal(err) } req.Header.Add("x-api-key", b.apiKey) for header, value := range headers { req.Header.Add(header, value) } resp, err := httpClient.Do(req) if err != nil { log.Fatal(err) } return resp } // Triggers a bank sync, returns true if successful func (b BudgetClient) BankSync() bool { resp := b.callApi("POST", "/accounts/banksync", nil) return resp.StatusCode == http.StatusOK } func (b BudgetClient) GetBudgetMonths() *BudgetMonthsResponse { currentTime := time.Now() year, month := currentTime.Year(), int(currentTime.Month()) budgetMonth := fmt.Sprintf("%v-%v", year, month) resp := b.callApi("GET", "/months/"+budgetMonth, map[string]string{ "budget-encryption-password": b.encryptionKey, }) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { log.Fatal("GetBudgetAmounts failed with: " + string(resp.Status)) } var data bytes.Buffer _, err := io.Copy(&data, resp.Body) if err != nil { log.Println(err) } var budgetMonths BudgetMonthsResponse err = json.Unmarshal(data.Bytes(), &budgetMonths) return &budgetMonths } func main() { if os.Getenv("ENVIRONMENT") == "dev" { err := godotenv.Load() if err != nil { log.Fatal("Error loading .env file") } } var baseUrl = os.Getenv("BASE_URL") var apiKey = os.Getenv("API_KEY") var syncId = os.Getenv("SYNC_ID") var encryptionKey = os.Getenv("BUDGET_ENCRYPTION_KEY") var smtpUsername = os.Getenv("SMTP_USERNAME") var smtpPassword = os.Getenv("SMTP_PASSWORD") var smtpHost = os.Getenv("SMTP_HOST") var smtpPort = os.Getenv("SMTP_PORT") var smtpRecipients = os.Getenv("SMTP_RECIPIENTS") client := CreateBudgetClient(baseUrl, apiKey, syncId, encryptionKey) if !client.BankSync() { log.Println("Bank Sync failed, information may not be up to date") } budgetMonths := client.GetBudgetMonths() subject := "Subject: Budget Report\n" mime := "MIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\n\n" const bodyTemplate = `

Good day,

Your unallocated budget total as of %s: $%v

Category breakdown:

` var categories strings.Builder for _, v := range budgetMonths.Data.CategoryGroups { for _, v2 := range v.Categories { if !v2.IsIncome { bal := float32(v2.Balance) bal /= 100 fmt.Fprintf(&categories, "

%v: $%v

", v2.Name, bal) } } } categories.Write([]byte("

This is an automated report generated by actualbudget-report")) currentTime := time.Now() toBudget := float32(budgetMonths.Data.ToBudget) toBudget /= 100 body := fmt.Sprintf(bodyTemplate, currentTime.Format(time.RFC850), toBudget) message := []byte(subject + mime + body + categories.String()) var auth smtp.Auth auth = smtp.PlainAuth("", smtpUsername, smtpPassword, smtpHost) err := smtp.SendMail(smtpHost+":"+smtpPort, auth, smtpUsername, strings.Split(smtpRecipients, ","), []byte(message)) if err != nil { log.Fatal(err) } }