-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathmain.go
332 lines (270 loc) · 10.1 KB
/
main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
package main
import (
"context"
"errors"
"fmt"
"os"
"github.com/jrh3k5/autonabber/args"
"github.com/jrh3k5/autonabber/client/ynab"
"github.com/jrh3k5/autonabber/client/ynab/http"
"github.com/jrh3k5/autonabber/client/ynab/model"
"github.com/jrh3k5/autonabber/delta"
"github.com/jrh3k5/autonabber/format"
"github.com/jrh3k5/autonabber/input"
"github.com/jrh3k5/oauth-cli/pkg/auth"
"github.com/manifoldco/promptui"
"go.uber.org/zap"
)
func main() {
ctx := context.Background()
_l, err := zap.NewDevelopment()
if err != nil {
fmt.Printf("unable to instantiate logger: %v\n", err)
os.Exit(1)
}
logger := _l.Sugar()
appArgs, err := args.GetArgs()
if err != nil {
logger.Fatalf("Unable to parse application arguments: %v", err)
}
oauthToken, err := auth.DefaultGetOAuthToken(ctx,
"https://app.ynab.com/oauth/authorize",
"https://api.ynab.com/oauth/token",
auth.WithLogger(logger.Infof),
auth.WithOAuthServerPort(appArgs.OAuthServerPort))
if err != nil {
logger.Fatalf("Unable to retrieve OAuth token: %v", err)
}
var client ynab.Client
client, err = http.NewClient(oauthToken.AccessToken)
if err != nil {
logger.Fatalf("Unable to instantiate YNAB client: %v", err)
}
if appArgs.DryRun {
logger.Info("Dry run mode is enabled; no changes will be written to YNAB")
client = ynab.NewReadOnlyClient(logger, client)
}
budget, err := getBudget(client)
if err != nil {
logger.Fatalf("Unable to successfully choose a budget: %v", err)
}
budgetCategoryGroups, err := client.GetCategories(budget)
if err != nil {
logger.Fatalf("Unable to retrieve budget categories: %w", err)
}
if appArgs.PrintBudget {
logger.Infof("Printing budget as requested:")
model.PrintBudgetCategoryGroups(budgetCategoryGroups, appArgs.PrintHiddenCategories)
}
budgetChange, err := getBudgetChanges(appArgs.ConfigFilePath)
if err != nil {
logger.Fatalf("Failed to select budget change: %w", err)
}
ignoreMismatches, err := warnApplicationMismatches(budgetCategoryGroups, budgetChange.CategoryGroups)
if err != nil {
logger.Fatalf("Failed to check for and confirm acceptance of mismatches between input file and budget: %w", err)
}
if !ignoreMismatches {
fmt.Println("Application has been cancelled")
os.Exit(0)
}
deltas, err := delta.NewDeltas(client, budget, budgetCategoryGroups, budgetChange)
if err != nil {
logger.Fatalf("Failed to generate delta: %w", err)
}
appConfirmed, err := confirmApplication(deltas)
if err != nil {
logger.Fatalf("Failed to get confirmation to apply changes: %w", err)
}
if appConfirmed {
assignableDollars, assignableCents, err := client.GetReadyToAssign(budget)
if err != nil {
logger.Fatalf("Failed to get assignable dollars and cents: %w", err)
}
doAssignment, err := checkAssignability(assignableDollars, assignableCents, budgetCategoryGroups, deltas)
if err != nil {
logger.Fatalf("Failed to check availability of assignable funds: %w", err)
}
if !doAssignment {
fmt.Println("Application has been cancelled")
} else {
var nonZeroChanges []*delta.BudgetCategoryDelta
for _, delta := range deltas {
for _, change := range delta.CategoryDeltas {
if change.HasChanges() {
nonZeroChanges = append(nonZeroChanges, change)
}
}
}
for changeIndex, change := range nonZeroChanges {
logger.Infof("Applying change %d of %d", changeIndex+1, len(nonZeroChanges))
if err := client.SetBudget(budget, change.BudgetCategory, change.FinalBudgetDollars, change.FinalBudgetCents); err != nil {
formattedFinal := format.FormatUSD(change.FinalDollars, change.FinalCents)
logger.Fatalf("Failed to set budget category '%s' under budget '%s' to %s: %w", change.BudgetCategory.Name, budget.Name, formattedFinal, err)
}
}
deltaDollars, deltaCents := delta.SumChanges(nonZeroChanges)
formattedDelta := format.FormatUSD(deltaDollars, deltaCents)
fmt.Printf("Added %s across %d categories\n", formattedDelta, len(nonZeroChanges))
}
} else {
fmt.Println("Application has been cancelled")
}
os.Exit(0)
}
// checkAssignability checks to see if the total of the changes to be applied exceeds the amount available for assignment
// It returns true if either the user has chosen to continue or there is enough to be assigned; false if not and the application should be canceled
func checkAssignability(assignableDollars int64, assignableCents int16, groups []*model.BudgetCategoryGroup, deltaGroups []*delta.BudgetCategoryDeltaGroup) (bool, error) {
var deltas []*delta.BudgetCategoryDelta
for _, group := range deltaGroups {
for _, category := range group.CategoryDeltas {
deltas = append(deltas, category)
}
}
changeDollars, changeCents := delta.SumChanges(deltas)
assignableTotal := assignableDollars*100 + int64(assignableCents)
changeTotal := changeDollars*100 + int64(changeCents)
if changeTotal > assignableTotal {
formattedChange := format.FormatUSD(changeDollars, changeCents)
formattedAssignable := format.FormatUSD(assignableDollars, assignableCents)
confirmPrompt := &promptui.Prompt{
Label: fmt.Sprintf("Your total to be assigned (%s) is greater than your amount ready for assignment (%s). Do you wish to continue the application? (yes/no)", formattedChange, formattedAssignable),
Validate: validateYesNo,
}
promptResult, err := confirmPrompt.Run()
if err != nil {
return false, fmt.Errorf("failed to prompt for confirmation of application: %w", err)
}
return promptResult == "yes", nil
}
return true, nil
}
func validateYesNo(input string) error {
if input != "yes" && input != "no" {
return fmt.Errorf("invalid selection: %s", input)
}
return nil
}
func confirmApplication(deltas []*delta.BudgetCategoryDeltaGroup) (bool, error) {
delta.PrintDeltas(deltas)
confirmPrompt := promptui.Prompt{
Label: "Do you wish to apply these changes? (yes/no)",
Validate: validateYesNo,
}
result, err := confirmPrompt.Run()
if err != nil {
return false, fmt.Errorf("failed to confirm desire to apply deltas: %w", err)
}
return result == "yes", nil
}
func getBudget(client ynab.Client) (*model.Budget, error) {
budgets, err := client.GetBudgets()
if err != nil {
return nil, fmt.Errorf("failed to get budgets: %w", err)
}
if len(budgets) == 0 {
return nil, errors.New("no budgets found; please create a budget before using this tool")
}
if len(budgets) == 1 {
return budgets[0], nil
}
budgetPromptTemplate := &promptui.SelectTemplates{
Label: "{{ . }}?",
Active: "🔨 {{ .Name | cyan }}",
Inactive: " {{ .Name }}",
Selected: "✔ {{ .Name }}",
}
prompt := promptui.Select{
Label: "Select a budget",
Templates: budgetPromptTemplate,
Items: budgets,
}
chosenBudget, _, err := prompt.Run()
if err != nil {
return nil, fmt.Errorf("failed in prompt for budget selection: %w", err)
}
return budgets[chosenBudget], nil
}
func getBudgetChanges(filePath string) (*input.BudgetChange, error) {
budgetChanges, err := input.ParseInputFile(filePath)
if err != nil {
return nil, fmt.Errorf("unable to parse input file '%s': %w", filePath, err)
}
if len(budgetChanges.Changes) == 0 {
return nil, errors.New("at least one change set must be supplied")
}
if len(budgetChanges.Changes) == 1 {
return budgetChanges.Changes[0], nil
}
changePromptTemplate := &promptui.SelectTemplates{
Label: "{{ . }}",
Active: "🔨 {{ .Name | cyan }}",
Inactive: " {{ .Name }}",
Selected: "✔ {{ .Name }}",
}
prompt := promptui.Select{
Label: "Select a budget change",
Templates: changePromptTemplate,
Items: budgetChanges.Changes,
}
chosenBudgetChange, _, err := prompt.Run()
if err != nil {
return nil, fmt.Errorf("failed in prompt for budget change selection: %w", err)
}
return budgetChanges.Changes[chosenBudgetChange], nil
}
func warnApplicationMismatches(budgetCategoryGroups []*model.BudgetCategoryGroup, inputGroups []*input.BudgetCategoryGroup) (bool, error) {
var missingGroups []string
missingCategoriesByGroupName := make(map[string][]string)
for _, inputCategory := range inputGroups {
var matchingBudgetGroup *model.BudgetCategoryGroup
for _, budgetGroup := range budgetCategoryGroups {
if budgetGroup.Name == inputCategory.Name {
matchingBudgetGroup = budgetGroup
break
}
}
if matchingBudgetGroup == nil {
missingGroups = append(missingGroups, inputCategory.Name)
continue
}
budgetCategoriesByName := make(map[string]*model.BudgetCategory)
for _, budgetCategory := range matchingBudgetGroup.Categories {
budgetCategoriesByName[budgetCategory.Name] = budgetCategory
}
for _, inputCategory := range inputCategory.Changes {
if _, categoryExists := budgetCategoriesByName[inputCategory.Name]; !categoryExists {
var missingCategories []string
if existingCategories, ok := missingCategoriesByGroupName[matchingBudgetGroup.Name]; ok {
missingCategories = existingCategories
}
missingCategories = append(missingCategories, inputCategory.Name)
missingCategoriesByGroupName[matchingBudgetGroup.Name] = missingCategories
}
}
}
// if there are no mistmatches, we can continue on
if len(missingGroups) == 0 && len(missingCategoriesByGroupName) == 0 {
return true, nil
}
fmt.Printf("WARNING: %d categories were in the given file that do not exist and/or %d categories specified in the input file do not exist in the budget:\n", len(missingGroups), len(missingCategoriesByGroupName))
for _, missingGroup := range missingGroups {
fmt.Printf(" Missing category group: %s\n", missingGroup)
}
for categoryGroupName, categories := range missingCategoriesByGroupName {
fmt.Printf(" Category group with missing category/categories: %s\n", categoryGroupName)
for _, category := range categories {
fmt.Printf(" Missing category: %s\n", category)
}
}
fmt.Println("None of the changes specified in the above category groups and categories will be applied to the budget.")
continuePrompt := promptui.Prompt{
Label: "Do you wish to continue? (yes/no)",
Validate: validateYesNo,
}
promptResult, err := continuePrompt.Run()
if err != nil {
return false, fmt.Errorf("failed to prompt user to confirm continuing with missing category groups and/or categories: %w", err)
}
return promptResult == "yes", nil
}