From c3fa723734aa2aa47ca2e163883e72369dca21ea Mon Sep 17 00:00:00 2001 From: Martin Strobel Date: Mon, 4 Feb 2019 21:20:47 -0800 Subject: [PATCH] Automatically Determine Transaction Amount (#6) * Adding account/budget magnitude diff logic with tests. * Integrate amount finding logic into commit command --- cmd/commit.go | 192 +++++++++++++++++++++++++++++- cmd/commit_test.go | 290 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 472 insertions(+), 10 deletions(-) diff --git a/cmd/commit.go b/cmd/commit.go index 5af0bf5..3ecfeb8 100644 --- a/cmd/commit.go +++ b/cmd/commit.go @@ -187,10 +187,19 @@ var commitCmd = &cobra.Command{ func init() { rootCmd.AddCommand(commitCmd) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if defaultAmount, err := findDefaultAmount(ctx, "."); err == nil { + commitConfig.SetDefault(amountFlag, defaultAmount.String()) + } else { + commitConfig.SetDefault(amountFlag, amountDefault) + } + commitCmd.PersistentFlags().StringP(merchantFlag, merchantShorthand, merchantDefault, merchantUsage) commitCmd.PersistentFlags().StringP(commentFlag, commentShorthand, commentDefault, commentUsage) commitCmd.PersistentFlags().StringP(timeFlag, timeShorthand, timeDefault, timeUsage) - commitCmd.PersistentFlags().StringP(amountFlag, amountShorthand, amountDefault, amountUsage) + commitCmd.PersistentFlags().StringP(amountFlag, amountShorthand, commitConfig.GetString(amountFlag), amountUsage) commitCmd.PersistentFlags().BoolP(forceFlag, forceShorthand, forceDefault, forceUsage) err := commitConfig.BindPFlags(commitCmd.PersistentFlags()) @@ -269,3 +278,184 @@ func promptToContinue(ctx context.Context, message string, output io.Writer, inp return result, nil } } + +func findDefaultAmount(ctx context.Context, targetDir string) (envelopes.Balance, error) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + targetDir, err := index.RootDirectory(targetDir) + if err != nil { + return 0, err + } + + accountsDir := filepath.Join(targetDir, index.AccountsDir) + accounts, err := index.LoadAccounts(ctx, accountsDir) + if err != nil { + return 0, err + } + + budgetDir := filepath.Join(targetDir, index.BudgetDir) + budget, err := index.LoadBudget(ctx, budgetDir) + if err != nil { + return 0, err + } + + updated := envelopes.State{ + Accounts: accounts, + Budget: budget, + } + + persister := persist.FileSystem{ + Root: filepath.Join(targetDir, index.RepoName), + } + + id, err := persister.Current(ctx) + if err != nil { + return 0, err + } + + loader := persist.DefaultLoader{ + Fetcher: persister, + } + + var head envelopes.Transaction + err = loader.Load(ctx, id, &head) + if err != nil { + return 0, err + } + + return findAmount(*head.State, updated), nil + +} + +func findAmount(original, updated envelopes.State) envelopes.Balance { + if changed := findAccountAmount(original, updated); changed != 0 { + return changed + } + + return findBudgetAmount(original, updated) +} + +func findAccountAmount(original, updated envelopes.State) envelopes.Balance { + modifiedAccounts := make(envelopes.Accounts, len(original.Accounts)) + + addedAccountNames := make(map[string]struct{}, len(original.Accounts)) + for name := range updated.Accounts { + addedAccountNames[name] = struct{}{} + } + + for name, oldBalance := range original.Accounts { + if _, ok := addedAccountNames[name]; ok { + // Mark this account as not a new one. + delete(addedAccountNames, name) + } + + if newBalance, ok := updated.Accounts[name]; ok && newBalance == oldBalance { + // Nothing has changed + continue + } else if !ok { + // An account was removed + modifiedAccounts[name] = -1 * oldBalance + } else { + // An account had its balance modified + modifiedAccounts[name] = newBalance - oldBalance + } + } + + // Iterate over the accounts that weren't seen in the original, and mark them as new. + for name := range addedAccountNames { + modifiedAccounts[name] = updated.Accounts[name] + } + + // If there was a transfer between two accounts, we don't want to mark it as amount $0.00, but rather that magnitude + // of the transfer. For that reason, we'll figure out the total negative and positive change of the accounts + // involved. + // + // If it was a transfer between budgets, we'll count the total deposited into the receiving accounts. + // If it was a deposit or credit, the amount positive or negative will get reflected because the opposite will + // register as a zero. + var positiveAccountDifferences, negativeAccountDifferences envelopes.Balance + for _, bal := range modifiedAccounts { + if bal > 0 { + positiveAccountDifferences += bal + } else { + negativeAccountDifferences += bal + } + } + + if positiveAccountDifferences > 0 { + return positiveAccountDifferences + } + + if negativeAccountDifferences < 0 { + return negativeAccountDifferences + } + + return 0 +} + +func findBudgetAmount(original, updated envelopes.State) envelopes.Balance { + // Normalize the budgets into a flattened shape for easier comparison, more like Accounts + const separator = string(os.PathSeparator) + originalBudgets := make(map[string]envelopes.Balance) + updatedBudgets := make(map[string]envelopes.Balance) + + var treeFlattener func(map[string]envelopes.Balance, string, *envelopes.Budget) + treeFlattener = func(discovered map[string]envelopes.Balance, currentPath string, target *envelopes.Budget) { + discovered[currentPath] = target.Balance + + for name, subTarget := range target.Children { + treeFlattener(discovered, currentPath+separator+name, subTarget) + } + } + + treeFlattener(originalBudgets, separator, original.Budget) + treeFlattener(updatedBudgets, separator, updated.Budget) + + // Make a list of all budget names in the updated state, so that we can find the ones which were added. + addedBudgets := make(map[string]struct{}, len(updatedBudgets)) + for name := range updatedBudgets { + addedBudgets[name] = struct{}{} + } + + modifiedBudgets := make(map[string]envelopes.Balance, len(originalBudgets)) + + for name, oldBalance := range originalBudgets { + if _, ok := addedBudgets[name]; ok { + // Mark this budget as not a new one. + delete(addedBudgets, name) + } + + if newBalance, ok := updatedBudgets[name]; ok && newBalance == oldBalance { + // Nothing has changed here + continue + } else if !ok { + modifiedBudgets[name] = -1 * oldBalance + } else { + modifiedBudgets[name] = newBalance - oldBalance + } + } + + for name := range addedBudgets { + modifiedBudgets[name] = updatedBudgets[name] + } + + var positiveBudgetDifferences, negativeBudgetDifferences envelopes.Balance + for _, bal := range modifiedBudgets { + if bal > 0 { + positiveBudgetDifferences += bal + } else { + negativeBudgetDifferences += bal + } + } + + if positiveBudgetDifferences > 0 { + return positiveBudgetDifferences + } + + if negativeBudgetDifferences < 0 { + return negativeBudgetDifferences + } + + return 0 +} diff --git a/cmd/commit_test.go b/cmd/commit_test.go index c907af1..19cba78 100644 --- a/cmd/commit_test.go +++ b/cmd/commit_test.go @@ -7,6 +7,8 @@ import ( "strings" "testing" "time" + + "github.com/marstr/envelopes" ) func Test_promptToContinue(t *testing.T) { @@ -18,11 +20,20 @@ func Test_promptToContinue(t *testing.T) { t.Run("prompt", getTestPromptText(ctx)) } -func getTestAffirmativePromptReponses(ctx context.Context) func(*testing.T) { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, 2*time.Minute) +func Test_findAmount(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + t.Run("deposit", getTestDepositAmount(ctx)) + t.Run("credit", getTestCreditAmount(ctx)) + t.Run("account_transfer", getTestAccountTransferAmount(ctx)) + t.Run("budget_transfer", getTestBudgetTransferAmount(ctx)) +} + +func getTestAffirmativePromptReponses(ctx context.Context) func(*testing.T) { return func(t *testing.T) { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 2*time.Minute) defer cancel() testCases := []string{ @@ -61,10 +72,9 @@ func getTestAffirmativePromptReponses(ctx context.Context) func(*testing.T) { } func getTestNegativePromptResponses(ctx context.Context) func(t *testing.T) { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, 2*time.Minute) - return func(t *testing.T) { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 2*time.Minute) defer cancel() testCases := []string{ "n", @@ -105,10 +115,9 @@ func getTestNegativePromptResponses(ctx context.Context) func(t *testing.T) { } func getTestPromptText(ctx context.Context) func(*testing.T) { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, 2*time.Minute) - return func(t *testing.T) { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 2*time.Minute) defer cancel() testCases := []string{ @@ -137,3 +146,266 @@ func getTestPromptText(ctx context.Context) func(*testing.T) { } } } + +func getTestDepositAmount(ctx context.Context) func(*testing.T) { + return func(t *testing.T) { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + testCases := []struct { + Name string + Original envelopes.State + Updated envelopes.State + Want envelopes.Balance + }{ + { + Name: "simple_deposit", + Original: envelopes.State{ + Accounts: envelopes.Accounts{ + "checking": 10000, + }, + Budget: &envelopes.Budget{ + Children: map[string]*envelopes.Budget{ + "groceries": { + Balance: 5000, + }, + "entertainment": { + Balance: 5000, + }, + }, + }, + }, + Updated: envelopes.State{ + Accounts: envelopes.Accounts{ + "checking": 15000, + }, + Budget: &envelopes.Budget{ + Children: map[string]*envelopes.Budget{ + "groceries": { + Balance: 7500, + }, + "entertainment": { + Balance: 7500, + }, + }, + }, + }, + Want: 5000, + }, + } + + for _, tc := range testCases { + got := findAmount(tc.Original, tc.Updated) + + if got != tc.Want { + t.Logf("%s: got: %d want: %d", tc.Name, got, tc.Want) + t.Fail() + } + } + } +} + +func getTestCreditAmount(ctx context.Context) func(*testing.T) { + return func(t *testing.T) { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + testCases := []struct { + Name string + Original envelopes.State + Updated envelopes.State + Want envelopes.Balance + }{ + { + Name: "simple_credit", + Original: envelopes.State{ + Accounts: envelopes.Accounts{ + "checking": 10000, + }, + Budget: &envelopes.Budget{ + Children: map[string]*envelopes.Budget{ + "groceries": { + Balance: 5000, + }, + "entertainment": { + Balance: 5000, + }, + }, + }, + }, + Updated: envelopes.State{ + Accounts: envelopes.Accounts{ + "checking": 5000, + }, + Budget: &envelopes.Budget{ + Children: map[string]*envelopes.Budget{ + "groceries": { + Balance: 5000, + }, + "entertainment": { + Balance: 0, + }, + }, + }, + }, + Want: -5000, + }, + } + + for _, tc := range testCases { + got := findAmount(tc.Original, tc.Updated) + + if got != tc.Want { + t.Logf("%s: got: %d want: %d", tc.Name, got, tc.Want) + t.Fail() + } + } + } +} + +func getTestAccountTransferAmount(ctx context.Context) func(*testing.T) { + return func(t *testing.T) { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + testCases := []struct { + Name string + Original envelopes.State + Updated envelopes.State + Want envelopes.Balance + }{ + { + Name: "two-party", + Original: envelopes.State{ + Accounts: map[string]envelopes.Balance{ + "checking": 10000, + "savings": 0, + }, + }, + Updated: envelopes.State{ + Accounts: map[string]envelopes.Balance{ + "checking": 5000, + "savings": 5000, + }, + }, + Want: 5000, + }, + { + Name: "three-party", + Original: envelopes.State{ + Accounts: map[string]envelopes.Balance{ + "checking": 2200000, + "savings": 4000000, + }, + }, + Updated: envelopes.State{ + Accounts: map[string]envelopes.Balance{ + "checking": 500000, + "savings": 0, + "escrow": 5700000, + }, + }, + Want: 5700000, + }, + } + + for _, tc := range testCases { + got := findAmount(tc.Original, tc.Updated) + + if got != tc.Want { + t.Logf("%s: got: %d want: %d", tc.Name, got, tc.Want) + t.Fail() + } + } + } +} + +func getTestBudgetTransferAmount(ctx context.Context) func(*testing.T) { + return func(t *testing.T) { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + testCases := []struct { + Name string + Original envelopes.State + Updated envelopes.State + Want envelopes.Balance + }{ + { + Name: "two-parties", + Original: envelopes.State{ + Budget: &envelopes.Budget{ + Children: map[string]*envelopes.Budget{ + "child1": { + Balance: 4590, + }, + "child2": { + Balance: 1000, + }, + }, + }, + }, + Updated: envelopes.State{ + Budget: &envelopes.Budget{ + Children: map[string]*envelopes.Budget{ + "child1": { + Balance: 2250, + }, + "child2": { + Balance: 3340, + }, + }, + }, + }, + Want: 2340, + }, + { + Name: "three-parties", + Original: envelopes.State{ + Budget: &envelopes.Budget{ + Children: map[string]*envelopes.Budget{ + "child1": { + Balance: 20000, + }, + "child2": { + Balance: 0, + }, + "child3": { + Balance: 0, + }, + }, + }, + }, + Updated: envelopes.State{ + Budget: &envelopes.Budget{ + Children: map[string]*envelopes.Budget{ + "child1": { + Balance: 10000, + }, + "child2": { + Balance: 7500, + }, + "child3": { + Balance: 2500, + }, + }, + }, + }, + Want: 10000, + }, + } + + for _, tc := range testCases { + got := findAmount(tc.Original, tc.Updated) + + if got != tc.Want { + t.Logf("%s: got: %d want: %d", tc.Name, got, tc.Want) + t.Fail() + } + } + } +}