actualbudget-report/main.go
TheWanderingCrow 612a82390f chore: add nixosModule
docs: add LICENSE
2025-11-22 15:28:11 -05:00

190 lines
5.5 KiB
Go

/*
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 <https://www.gnu.org/licenses/>.
*/
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
}
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"`
}
func CreateBudgetClient(baseUrl string, apiKey string, syncId string) *BudgetClient {
fullUrl := baseUrl + "/v1/budgets/" + syncId
client := BudgetClient{baseUrl, apiKey, syncId, fullUrl}
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, nil)
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 smtpUsername = os.Getenv("SMTP_USERNAME")
var smtpPassword = os.Getenv("SMTP_PASSWORD")
var smtpHost = os.Getenv("SMTP_HOST")
var smtpRecipients = os.Getenv("SMTP_RECIPIENTS")
client := CreateBudgetClient(baseUrl, apiKey, syncId)
//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 = `
<p>Good day,</p>
<p>Your unallocated budget total as of %s: <b>$%v</b></p>
<p>Category breakdown:</p>
`
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, "<p>%v: <b>$%v</b></p>", v2.Name, bal)
}
}
}
categories.Write([]byte("<p>This is an automated report generated by <a href=\"https://git.wanderingcrow.net/TheWanderingCrow/actualbudget-report\">actualbudget-report</a>"))
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
if os.Getenv("ENVIRONMENT") == "dev" {
auth = nil
} else {
auth = smtp.PlainAuth("", smtpUsername, smtpPassword, smtpHost)
}
err := smtp.SendMail(smtpHost, auth, smtpUsername, []string{smtpRecipients}, []byte(message))
if err != nil {
log.Fatal(err)
}
}