summary history files

commit:86698917fdf9de9dcf1339cd15468dd577532ddd
date:Sun Sep 8 09:46:24 2024 +1000
parents:5d6fc7247fbf226236eb4579691c9500fbdbc4ab
more double entry support
diff --git a/Taskfile.yml b/Taskfile.yml
line changes: +8/-0
index a5e381e..716e573
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -58,6 +58,14 @@ tasks:
     cmds:
       - wails build -v 2 -debug
 
+  desktop:wails:dev:nodebug:
+    desc: "wails dev"
+    dir: desktop
+    env:
+      PENNY_DEBUG: false
+    cmds:
+      - wails dev -appargs "--debug=false"
+
   desktop:wails:dev:
     desc: "wails dev"
     dir: desktop

diff --git a/desktop/README.md b/desktop/README.md
line changes: +18/-5
index 0611f54..33a562b
--- a/desktop/README.md
+++ b/desktop/README.md
@@ -4,8 +4,7 @@ See [README.md](../README.md) in parent directory.
 
 ## TODO
 
-`BUG` or `FEAT` todo items.
-
+### `BUG` 
 * [ ] BUG: `transaction_importer_status.status_id` is set to `0` once
       successful auto transaction has completed.
 * [ ] BUG: when adding new filter within UI, "add new filter" button is not
@@ -15,11 +14,25 @@ See [README.md](../README.md) in parent directory.
       support other operating systems.
 * [ ] BUG: importing OFX transactions will fail if currency symbol within ofx
       are invalid.
+* [ ] BUG: tag amount is incorrectly calculated. Build a tag with many
+      transactions and confirm that total sum amount is correct.
+* [ ] BUG: when deleting a transaction error dialog is displayed with empty
+      message though transaction is successfully deleted. Dialog should display
+      successful message instead.
+* [ ] BUG: Imbalanced account should be immutable. Its attributes should not be
+      allowed to be change via UI.
+* [ ] BUG: Imbalanced total amount balance is incorrect. To reproduce, add
+      several transactions with no account and sum total amount balance. 
+
+### `FEAT`
+
 * [ ] FEAT: transaction attachments should have a size limit
-* [ ] FEAT: support deleting transaction attachments
+* [ ] FEAT: Add support for deleting transaction attachments
 * [ ] FEAT: account matches should not have to be duplicated when matching on
       destination account across multiple source accounts. For example,
       matching for groceries across 2 credit accounts should be a single
       account match, not 2 account matches.
-* [ ] BUG: tag amount is incorrectly calculated. Build a tag with many
-      transactions and confirm that total sum amount is correct.
+* [ ] FEAT: tags should require matching on account type. Account type should
+      default to expense. If tags do not match transaction splits on account
+      type, tagging will match all splits in the transaction which results in a
+      $0 amount.

diff --git a/desktop/backend/model/splits.go b/desktop/backend/model/splits.go
line changes: +6/-190
index 9f880a9..c88543e
--- a/desktop/backend/model/splits.go
+++ b/desktop/backend/model/splits.go
@@ -79,20 +79,17 @@ var SplitWhere = struct {
 
 // SplitRels is where relationship names are stored.
 var SplitRels = struct {
-	Account          string
-	Transaction      string
-	SplitsAttributes string
+	Account     string
+	Transaction string
 }{
-	Account:          "Account",
-	Transaction:      "Transaction",
-	SplitsAttributes: "SplitsAttributes",
+	Account:     "Account",
+	Transaction: "Transaction",
 }
 
 // splitR is where relationships are stored.
 type splitR struct {
-	Account          *Account             `boil:"Account" json:"Account" toml:"Account" yaml:"Account"`
-	Transaction      *Transaction         `boil:"Transaction" json:"Transaction" toml:"Transaction" yaml:"Transaction"`
-	SplitsAttributes SplitsAttributeSlice `boil:"SplitsAttributes" json:"SplitsAttributes" toml:"SplitsAttributes" yaml:"SplitsAttributes"`
+	Account     *Account     `boil:"Account" json:"Account" toml:"Account" yaml:"Account"`
+	Transaction *Transaction `boil:"Transaction" json:"Transaction" toml:"Transaction" yaml:"Transaction"`
 }
 
 // NewStruct creates a new relationship struct
@@ -114,13 +111,6 @@ func (r *splitR) GetTransaction() *Transaction {
 	return r.Transaction
 }
 
-func (r *splitR) GetSplitsAttributes() SplitsAttributeSlice {
-	if r == nil {
-		return nil
-	}
-	return r.SplitsAttributes
-}
-
 // splitL is where Load methods for each relationship are stored.
 type splitL struct{}
 
@@ -245,20 +235,6 @@ func (o *Split) Transaction(mods ...qm.QueryMod) transactionQuery {
 	return Transactions(queryMods...)
 }
 
-// SplitsAttributes retrieves all the splits_attribute's SplitsAttributes with an executor.
-func (o *Split) SplitsAttributes(mods ...qm.QueryMod) splitsAttributeQuery {
-	var queryMods []qm.QueryMod
-	if len(mods) != 0 {
-		queryMods = append(queryMods, mods...)
-	}
-
-	queryMods = append(queryMods,
-		qm.Where("\"splits_attributes\".\"splits_id\"=?", o.ID),
-	)
-
-	return SplitsAttributes(queryMods...)
-}
-
 // LoadAccount allows an eager lookup of values, cached into the
 // loaded structs of the objects. This is for an N-1 relationship.
 func (splitL) LoadAccount(ctx context.Context, e boil.ContextExecutor, singular bool, maybeSplit interface{}, mods queries.Applicator) error {
@@ -483,113 +459,6 @@ func (splitL) LoadTransaction(ctx context.Context, e boil.ContextExecutor, singu
 	return nil
 }
 
-// LoadSplitsAttributes allows an eager lookup of values, cached into the
-// loaded structs of the objects. This is for a 1-M or N-M relationship.
-func (splitL) LoadSplitsAttributes(ctx context.Context, e boil.ContextExecutor, singular bool, maybeSplit interface{}, mods queries.Applicator) error {
-	var slice []*Split
-	var object *Split
-
-	if singular {
-		var ok bool
-		object, ok = maybeSplit.(*Split)
-		if !ok {
-			object = new(Split)
-			ok = queries.SetFromEmbeddedStruct(&object, &maybeSplit)
-			if !ok {
-				return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", object, maybeSplit))
-			}
-		}
-	} else {
-		s, ok := maybeSplit.(*[]*Split)
-		if ok {
-			slice = *s
-		} else {
-			ok = queries.SetFromEmbeddedStruct(&slice, maybeSplit)
-			if !ok {
-				return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", slice, maybeSplit))
-			}
-		}
-	}
-
-	args := make([]interface{}, 0, 1)
-	if singular {
-		if object.R == nil {
-			object.R = &splitR{}
-		}
-		args = append(args, object.ID)
-	} else {
-	Outer:
-		for _, obj := range slice {
-			if obj.R == nil {
-				obj.R = &splitR{}
-			}
-
-			for _, a := range args {
-				if a == obj.ID {
-					continue Outer
-				}
-			}
-
-			args = append(args, obj.ID)
-		}
-	}
-
-	if len(args) == 0 {
-		return nil
-	}
-
-	query := NewQuery(
-		qm.From(`splits_attributes`),
-		qm.WhereIn(`splits_attributes.splits_id in ?`, args...),
-	)
-	if mods != nil {
-		mods.Apply(query)
-	}
-
-	results, err := query.QueryContext(ctx, e)
-	if err != nil {
-		return errors.Wrap(err, "failed to eager load splits_attributes")
-	}
-
-	var resultSlice []*SplitsAttribute
-	if err = queries.Bind(results, &resultSlice); err != nil {
-		return errors.Wrap(err, "failed to bind eager loaded slice splits_attributes")
-	}
-
-	if err = results.Close(); err != nil {
-		return errors.Wrap(err, "failed to close results in eager load on splits_attributes")
-	}
-	if err = results.Err(); err != nil {
-		return errors.Wrap(err, "error occurred during iteration of eager loaded relations for splits_attributes")
-	}
-
-	if singular {
-		object.R.SplitsAttributes = resultSlice
-		for _, foreign := range resultSlice {
-			if foreign.R == nil {
-				foreign.R = &splitsAttributeR{}
-			}
-			foreign.R.Split = object
-		}
-		return nil
-	}
-
-	for _, foreign := range resultSlice {
-		for _, local := range slice {
-			if local.ID == foreign.SplitsID {
-				local.R.SplitsAttributes = append(local.R.SplitsAttributes, foreign)
-				if foreign.R == nil {
-					foreign.R = &splitsAttributeR{}
-				}
-				foreign.R.Split = local
-				break
-			}
-		}
-	}
-
-	return nil
-}
-
 // SetAccount of the split to the related item.
 // Sets o.R.Account to related.
 // Adds o to related.R.Splits.
@@ -684,59 +553,6 @@ func (o *Split) SetTransaction(ctx context.Context, exec boil.ContextExecutor, i
 	return nil
 }
 
-// AddSplitsAttributes adds the given related objects to the existing relationships
-// of the split, optionally inserting them as new records.
-// Appends related to o.R.SplitsAttributes.
-// Sets related.R.Split appropriately.
-func (o *Split) AddSplitsAttributes(ctx context.Context, exec boil.ContextExecutor, insert bool, related ...*SplitsAttribute) error {
-	var err error
-	for _, rel := range related {
-		if insert {
-			rel.SplitsID = o.ID
-			if err = rel.Insert(ctx, exec, boil.Infer()); err != nil {
-				return errors.Wrap(err, "failed to insert into foreign table")
-			}
-		} else {
-			updateQuery := fmt.Sprintf(
-				"UPDATE \"splits_attributes\" SET %s WHERE %s",
-				strmangle.SetParamNames("\"", "\"", 0, []string{"splits_id"}),
-				strmangle.WhereClause("\"", "\"", 0, splitsAttributePrimaryKeyColumns),
-			)
-			values := []interface{}{o.ID, rel.ID}
-
-			if boil.IsDebug(ctx) {
-				writer := boil.DebugWriterFrom(ctx)
-				fmt.Fprintln(writer, updateQuery)
-				fmt.Fprintln(writer, values)
-			}
-			if _, err = exec.ExecContext(ctx, updateQuery, values...); err != nil {
-				return errors.Wrap(err, "failed to update foreign table")
-			}
-
-			rel.SplitsID = o.ID
-		}
-	}
-
-	if o.R == nil {
-		o.R = &splitR{
-			SplitsAttributes: related,
-		}
-	} else {
-		o.R.SplitsAttributes = append(o.R.SplitsAttributes, related...)
-	}
-
-	for _, rel := range related {
-		if rel.R == nil {
-			rel.R = &splitsAttributeR{
-				Split: o,
-			}
-		} else {
-			rel.R.Split = o
-		}
-	}
-	return nil
-}
-
 // Splits retrieves all the records using an executor.
 func Splits(mods ...qm.QueryMod) splitQuery {
 	mods = append(mods, qm.From("\"splits\""))

diff --git a/desktop/backend/model/splits_attributes.go b/desktop/backend/model/splits_attributes.go
line changes: +1/-182
index 01ca986..c4032c3
--- a/desktop/backend/model/splits_attributes.go
+++ b/desktop/backend/model/splits_attributes.go
@@ -72,14 +72,10 @@ var SplitsAttributeWhere = struct {
 
 // SplitsAttributeRels is where relationship names are stored.
 var SplitsAttributeRels = struct {
-	Split string
-}{
-	Split: "Split",
-}
+}{}
 
 // splitsAttributeR is where relationships are stored.
 type splitsAttributeR struct {
-	Split *Split `boil:"Split" json:"Split" toml:"Split" yaml:"Split"`
 }
 
 // NewStruct creates a new relationship struct
@@ -87,13 +83,6 @@ func (*splitsAttributeR) NewStruct() *splitsAttributeR {
 	return &splitsAttributeR{}
 }
 
-func (r *splitsAttributeR) GetSplit() *Split {
-	if r == nil {
-		return nil
-	}
-	return r.Split
-}
-
 // splitsAttributeL is where Load methods for each relationship are stored.
 type splitsAttributeL struct{}
 
@@ -196,176 +185,6 @@ func (q splitsAttributeQuery) Exists(ctx context.Context, exec boil.ContextExecu
 	return count > 0, nil
 }
 
-// Split pointed to by the foreign key.
-func (o *SplitsAttribute) Split(mods ...qm.QueryMod) splitQuery {
-	queryMods := []qm.QueryMod{
-		qm.Where("\"id\" = ?", o.SplitsID),
-	}
-
-	queryMods = append(queryMods, mods...)
-
-	return Splits(queryMods...)
-}
-
-// LoadSplit allows an eager lookup of values, cached into the
-// loaded structs of the objects. This is for an N-1 relationship.
-func (splitsAttributeL) LoadSplit(ctx context.Context, e boil.ContextExecutor, singular bool, maybeSplitsAttribute interface{}, mods queries.Applicator) error {
-	var slice []*SplitsAttribute
-	var object *SplitsAttribute
-
-	if singular {
-		var ok bool
-		object, ok = maybeSplitsAttribute.(*SplitsAttribute)
-		if !ok {
-			object = new(SplitsAttribute)
-			ok = queries.SetFromEmbeddedStruct(&object, &maybeSplitsAttribute)
-			if !ok {
-				return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", object, maybeSplitsAttribute))
-			}
-		}
-	} else {
-		s, ok := maybeSplitsAttribute.(*[]*SplitsAttribute)
-		if ok {
-			slice = *s
-		} else {
-			ok = queries.SetFromEmbeddedStruct(&slice, maybeSplitsAttribute)
-			if !ok {
-				return errors.New(fmt.Sprintf("failed to set %T from embedded struct %T", slice, maybeSplitsAttribute))
-			}
-		}
-	}
-
-	args := make([]interface{}, 0, 1)
-	if singular {
-		if object.R == nil {
-			object.R = &splitsAttributeR{}
-		}
-		args = append(args, object.SplitsID)
-
-	} else {
-	Outer:
-		for _, obj := range slice {
-			if obj.R == nil {
-				obj.R = &splitsAttributeR{}
-			}
-
-			for _, a := range args {
-				if a == obj.SplitsID {
-					continue Outer
-				}
-			}
-
-			args = append(args, obj.SplitsID)
-
-		}
-	}
-
-	if len(args) == 0 {
-		return nil
-	}
-
-	query := NewQuery(
-		qm.From(`splits`),
-		qm.WhereIn(`splits.id in ?`, args...),
-	)
-	if mods != nil {
-		mods.Apply(query)
-	}
-
-	results, err := query.QueryContext(ctx, e)
-	if err != nil {
-		return errors.Wrap(err, "failed to eager load Split")
-	}
-
-	var resultSlice []*Split
-	if err = queries.Bind(results, &resultSlice); err != nil {
-		return errors.Wrap(err, "failed to bind eager loaded slice Split")
-	}
-
-	if err = results.Close(); err != nil {
-		return errors.Wrap(err, "failed to close results of eager load for splits")
-	}
-	if err = results.Err(); err != nil {
-		return errors.Wrap(err, "error occurred during iteration of eager loaded relations for splits")
-	}
-
-	if len(resultSlice) == 0 {
-		return nil
-	}
-
-	if singular {
-		foreign := resultSlice[0]
-		object.R.Split = foreign
-		if foreign.R == nil {
-			foreign.R = &splitR{}
-		}
-		foreign.R.SplitsAttributes = append(foreign.R.SplitsAttributes, object)
-		return nil
-	}
-
-	for _, local := range slice {
-		for _, foreign := range resultSlice {
-			if local.SplitsID == foreign.ID {
-				local.R.Split = foreign
-				if foreign.R == nil {
-					foreign.R = &splitR{}
-				}
-				foreign.R.SplitsAttributes = append(foreign.R.SplitsAttributes, local)
-				break
-			}
-		}
-	}
-
-	return nil
-}
-
-// SetSplit of the splitsAttribute to the related item.
-// Sets o.R.Split to related.
-// Adds o to related.R.SplitsAttributes.
-func (o *SplitsAttribute) SetSplit(ctx context.Context, exec boil.ContextExecutor, insert bool, related *Split) error {
-	var err error
-	if insert {
-		if err = related.Insert(ctx, exec, boil.Infer()); err != nil {
-			return errors.Wrap(err, "failed to insert into foreign table")
-		}
-	}
-
-	updateQuery := fmt.Sprintf(
-		"UPDATE \"splits_attributes\" SET %s WHERE %s",
-		strmangle.SetParamNames("\"", "\"", 0, []string{"splits_id"}),
-		strmangle.WhereClause("\"", "\"", 0, splitsAttributePrimaryKeyColumns),
-	)
-	values := []interface{}{related.ID, o.ID}
-
-	if boil.IsDebug(ctx) {
-		writer := boil.DebugWriterFrom(ctx)
-		fmt.Fprintln(writer, updateQuery)
-		fmt.Fprintln(writer, values)
-	}
-	if _, err = exec.ExecContext(ctx, updateQuery, values...); err != nil {
-		return errors.Wrap(err, "failed to update local table")
-	}
-
-	o.SplitsID = related.ID
-	if o.R == nil {
-		o.R = &splitsAttributeR{
-			Split: related,
-		}
-	} else {
-		o.R.Split = related
-	}
-
-	if related.R == nil {
-		related.R = &splitR{
-			SplitsAttributes: SplitsAttributeSlice{o},
-		}
-	} else {
-		related.R.SplitsAttributes = append(related.R.SplitsAttributes, o)
-	}
-
-	return nil
-}
-
 // SplitsAttributes retrieves all the records using an executor.
 func SplitsAttributes(mods ...qm.QueryMod) splitsAttributeQuery {
 	mods = append(mods, qm.From("\"splits_attributes\""))

diff --git a/desktop/backend/services/account_match_service.go b/desktop/backend/services/account_match_service.go
line changes: +97/-55
index 9757e54..ae7d32f
--- a/desktop/backend/services/account_match_service.go
+++ b/desktop/backend/services/account_match_service.go
@@ -38,21 +38,18 @@ func (a *accountMatchService) Start(ctx context.Context, db *sql.DB, logger *log
 	a.defaultResources = defaultResources
 	a.AccountMatchRequestC = make(chan AccountMatchRequest)
 
-	go func() { a.matcher(a.ctx, a.db, a.logger) }()
+	go func() { a.matcher(a.ctx, a.db, a.AccountMatchRequestC, a.logger) }()
 }
 
-func (a *accountMatchService) matcher(ctx context.Context, db *sql.DB, logger *logwrap.LogWrap) {
+func (a *accountMatchService) matcher(ctx context.Context, db *sql.DB, c chan AccountMatchRequest, logger *logwrap.LogWrap) {
 	var q []qm.QueryMod
 
-	for range a.AccountMatchRequestC {
+	for range c {
 		logger.Info(fmt.Sprintf("received new account match request"))
 
-		accountMatches, err := model.AccountMatches().All(ctx, db)
-		if err != nil {
-			logger.Error("Failed to find Account Matches")
-		}
-
 		q = []qm.QueryMod{
+			qm.InnerJoin("splits on splits.transactions_id = transactions.id"),
+			qm.InnerJoin("account on splits.account_id = account.id"),
 			qm.Load("Splits"),
 			qm.Load("Splits.Account"),
 		}
@@ -62,22 +59,27 @@ func (a *accountMatchService) matcher(ctx context.Context, db *sql.DB, logger *l
 			continue
 		}
 
+		q = []qm.QueryMod{
+			qm.InnerJoin("account_match_regex on account_match_regex.account_match_id = account_match.id"),
+			qm.Load("AccountMatchRegexes"),
+		}
+		accountMatches, err := model.AccountMatches(q...).All(ctx, db)
+		if err != nil {
+			logger.Error("Failed to find any Account Matches")
+			continue
+		}
+
 		for _, accountMatch := range accountMatches {
-			attr, err := model.AccountMatchAttributes(qm.Where("account_match_id=? AND name=?", accountMatch.ID, "deleted")).One(ctx, db)
-			switch {
-			case dberror.IsNoRowsFound(err):
-			case err != nil:
+			attrDeleted, err := model.AccountMatchAttributes(qm.Where("account_match_id=? AND name=? AND value=?", accountMatch.ID, "deleted", "true")).Exists(ctx, db)
+			if err != nil {
 				logger.Error(err.Error())
 				continue
-			default:
-				if attr.Value == "true" {
-					continue
-				}
+			}
+			if attrDeleted {
+				continue
 			}
 
-			accountMatchRegexes, err := model.AccountMatchRegexes(qm.Where("account_match_id=?", accountMatch.ID)).All(ctx, db)
-			if err != nil {
-				logger.Error(err.Error())
+			if accountMatch.R == nil || len(accountMatch.R.AccountMatchRegexes) == 0 {
 				continue
 			}
 
@@ -91,44 +93,50 @@ func (a *accountMatchService) matcher(ctx context.Context, db *sql.DB, logger *l
 					continue
 				}
 
+				if transaction.R == nil || len(transaction.R.Splits) == 0 {
+					continue
+				}
+
+				match, err := a.transactionMemoMatchesAccountMatchRegex(transaction.Memo, accountMatch.R.AccountMatchRegexes)
+				if err != nil {
+					logger.Error(err.Error())
+					continue
+				}
+				if !match {
+					continue
+				}
+
+				// Check if one of the splits is assigned to the source account
+				// of the account match. If it is, an account match should be
+				// performed on all splits for this transaction.
+				transactionMatchesAccountMatchSourceAccount := false
 				for _, split := range transaction.R.Splits {
 					if accountMatch.SourceAccountID == split.R.Account.ID {
-						exists, err := model.Splits(qm.Where("transactions_id=? AND id!=?", split.TransactionsID, split.ID)).Exists(ctx, db)
-						if err != nil {
-							logger.Error(err.Error())
-							continue
-						}
-						if exists {
-							continue
-						}
-
-						for _, regex := range accountMatchRegexes {
-							r, err := regexp.Compile(fmt.Sprintf("(?i)%s", regex.Regex))
-							if err != nil {
-								logger.Error(fmt.Sprintf("regexp compile %d: %s", regex.ID, err.Error()))
-								continue
-							}
-
-							result := r.FindStringSubmatch(transaction.Memo)
-
-							valueNum := split.ValueNum
-							if split.ValueNum < 0 {
-								valueNum = split.ValueNum * -1
-							}
-
-							if len(result) > 0 {
-								s := model.Split{
-									TransactionsID: transaction.ID,
-									AccountID:      accountMatch.DestinationAccountID,
-									ValueNum:       valueNum,
-									ValueDenom:     split.ValueDenom,
-								}
-								if err := s.Insert(ctx, db, boil.Infer()); err != nil {
-									logger.Error(err.Error())
-									continue
-								}
-							}
-						}
+						transactionMatchesAccountMatchSourceAccount = true
+						break
+					}
+				}
+				if !transactionMatchesAccountMatchSourceAccount {
+					continue
+				}
+
+				for _, split := range transaction.R.Splits {
+					// If split matches source account id, skip this split.
+					// Account match will run on the remaining splits.
+					if accountMatch.SourceAccountID == split.R.Account.ID {
+						continue
+					}
+
+					// If split matches destination account id, skip this
+					// split. The split has already been assigned to the
+					// account.
+					if accountMatch.DestinationAccountID == split.R.Account.ID {
+						continue
+					}
+
+					if _, err := a.updateSplitIfImbalanced(ctx, db, split, accountMatch.DestinationAccountID); err != nil {
+						logger.Error(fmt.Sprintf("failed to update split: %d", split.ID))
+						continue
 					}
 				}
 			}
@@ -414,3 +422,37 @@ func (a *accountMatchService) UndeleteAccountMatch(id int64) types.AccountMatchR
 	resp.Msg = "Account Match undeleted"
 	return resp
 }
+
+// updateSplitIfImbalanced accepts a pointer transaction and accountID. It
+// finds any splits which have an account "Imbalanced" and updates that split
+// account id with the accountID. If split is updated, true is returned else
+// false.
+func (a *accountMatchService) updateSplitIfImbalanced(ctx context.Context, db *sql.DB, split *model.Split, accountID int64) (bool, error) {
+	account, err := model.Accounts(qm.Where("id=?", split.AccountID)).One(ctx, db)
+	if err != nil {
+		return false, err
+	}
+	if account.Name == "Imbalanced" {
+		split.AccountID = accountID
+		if _, err := split.Update(ctx, db, boil.Infer()); err != nil {
+			return false, err
+		}
+		return true, nil
+	}
+	return false, nil
+}
+
+func (a *accountMatchService) transactionMemoMatchesAccountMatchRegex(memo string, accountMatches model.AccountMatchRegexSlice) (bool, error) {
+	for _, regex := range accountMatches {
+		r, err := regexp.Compile(fmt.Sprintf("(?i)%s", regex.Regex))
+		if err != nil {
+			return false, err
+		}
+
+		result := r.FindStringSubmatch(memo)
+		if len(result) != 0 {
+			return true, nil
+		}
+	}
+	return false, nil
+}

diff --git a/desktop/backend/services/account_service.go b/desktop/backend/services/account_service.go
line changes: +23/-0
index e699599..9416d4f
--- a/desktop/backend/services/account_service.go
+++ b/desktop/backend/services/account_service.go
@@ -354,6 +354,29 @@ func (a *accountService) getAccount(id int64) (*model.Account, error) {
 	return model.Accounts(q...).One(a.ctx, a.db)
 }
 
+func (a *accountService) GetAccountByName(name string) types.AccountResponse {
+	var resp types.AccountResponse
+	q := []qm.QueryMod{
+		qm.Where("account.name=?", name),
+		qm.InnerJoin("account_type on account_type.id = account.account_type_id"),
+		qm.Load("AccountType"),
+	}
+	account, err := model.Accounts(q...).One(a.ctx, a.db)
+	if err != nil {
+		resp.Msg = err.Error()
+		a.logger.Error(resp.Msg)
+		return resp
+	}
+	resp.Data, err = types.NewAccount(a.ctx, a.db, account)
+	if err != nil {
+		resp.Msg = err.Error()
+		a.logger.Error(resp.Msg)
+		return resp
+	}
+	resp.Success = true
+	return resp
+}
+
 func (a *accountService) GetAccount(id int64) types.AccountResponse {
 	var err error
 

diff --git a/desktop/backend/services/db_service.go b/desktop/backend/services/db_service.go
line changes: +35/-17
index 02b07c7..149a119
--- a/desktop/backend/services/db_service.go
+++ b/desktop/backend/services/db_service.go
@@ -224,26 +224,44 @@ func (d *dbService) initAccount(ctx context.Context) error {
 	if err != nil {
 		return err
 	}
-	_, err = model.Accounts(qm.Where("name=? AND parent_id=0 AND entity_id=?", "Account", defaultEntity.ID)).One(ctx, d.DB)
+
+	grandParentAccountType, err := d.getGrantParentAccountType()
 	if err != nil {
-		switch {
-		case dberror.IsNoRowsFound(err):
-			grandParentAccountType, err := d.getGrantParentAccountType()
-			if err != nil {
-				return err
-			}
+		return err
+	}
 
-			grandParentAccount := &model.Account{
-				Name:          "Account",
-				EntityID:      defaultEntity.ID,
-				AccountTypeID: grandParentAccountType.ID,
-				ParentID:      int64(0),
-			}
-			if err := grandParentAccount.Insert(ctx, d.DB, boil.Infer()); err != nil {
-				return err
+	grandParentAccount, err := model.Accounts(qm.Where("name=? AND parent_id=0 AND entity_id=? AND account_type_id=?", "Account", defaultEntity.ID, grandParentAccountType.ID)).One(ctx, d.DB)
+	switch {
+	case dberror.IsNoRowsFound(err):
+		grandParentAccount = &model.Account{
+			Name:          "Account",
+			EntityID:      defaultEntity.ID,
+			AccountTypeID: grandParentAccountType.ID,
+			ParentID:      int64(0),
+		}
+		if err := grandParentAccount.Insert(ctx, d.DB, boil.Infer()); err != nil {
+			return err
+		}
+	case err != nil:
+		return fmt.Errorf("failed to insert grand parent account: %w", err)
+	}
+
+	assetAccountType, err := model.AccountTypes(qm.Where("name=?", "Asset")).One(ctx, d.DB)
+	if err != nil {
+		return err
+	}
+
+	accounts := []model.Account{
+		{Name: "Imbalanced", ParentID: grandParentAccount.ID, EntityID: defaultEntity.ID, AccountTypeID: assetAccountType.ID},
+	}
+	for _, account := range accounts {
+		if err := account.Insert(ctx, d.DB, boil.Infer()); err != nil {
+			switch {
+			case dberror.IsUniqueConstraint(err):
+				continue
+			default:
+				return fmt.Errorf("failed to insert account: %w", err)
 			}
-		default:
-			return fmt.Errorf("failed to insert grand parent account: %w", err)
 		}
 	}
 

diff --git a/desktop/backend/services/transaction_importer.go b/desktop/backend/services/transaction_importer.go
line changes: +40/-6
index 03a53f8..b2826ed
--- a/desktop/backend/services/transaction_importer.go
+++ b/desktop/backend/services/transaction_importer.go
@@ -354,6 +354,11 @@ func (t *transactionImporterService) ofxRespBank(msg ofxgo.Message) error {
 			}
 		}
 
+		imbalancedAccount, err := model.Accounts(qm.Where("name=?", "Imbalanced")).One(t.ctx, t.db)
+		if err != nil {
+			return err
+		}
+
 		splits := []model.Split{
 			{
 				TransactionsID: transaction.ID,
@@ -361,6 +366,12 @@ func (t *transactionImporterService) ofxRespBank(msg ofxgo.Message) error {
 				ValueNum:       i.TrnAmt.Num().Int64(),
 				ValueDenom:     i.TrnAmt.Denom().Int64(),
 			},
+			{
+				TransactionsID: transaction.ID,
+				AccountID:      imbalancedAccount.ID,
+				ValueNum:       -i.TrnAmt.Num().Int64(),
+				ValueDenom:     -i.TrnAmt.Denom().Int64(),
+			},
 		}
 		for _, ii := range splits {
 			if err := ii.Insert(t.ctx, t.db, boil.Infer()); err != nil {
@@ -387,6 +398,17 @@ func (t *transactionImporterService) ofxRespCreditCard(msg ofxgo.Message) error 
 		return err
 	}
 
+	tx, err := t.db.BeginTx(t.ctx, nil)
+	if err != nil {
+		return err
+	}
+	defer tx.Rollback()
+
+	imbalancedAccount, err := model.Accounts(qm.Where("name=?", "Imbalanced")).One(t.ctx, tx)
+	if err != nil {
+		return err
+	}
+
 	for _, i := range stmt.BankTranList.Transactions {
 		transaction := model.Transaction{
 			EntityID:   t.defaultResources.EntityID(),
@@ -397,7 +419,7 @@ func (t *transactionImporterService) ofxRespCreditCard(msg ofxgo.Message) error 
 
 		v1Hash := hashers.NewTransactionHashV1()
 		transactionHash := v1Hash.Hash(transaction)
-		hashType, err := model.TransactionsHashTypes(qm.Where("name=?", v1Hash.String())).One(t.ctx, t.db)
+		hashType, err := model.TransactionsHashTypes(qm.Where("name=?", v1Hash.String())).One(t.ctx, tx)
 		if err != nil {
 			return err
 		}
@@ -406,7 +428,7 @@ func (t *transactionImporterService) ofxRespCreditCard(msg ofxgo.Message) error 
 			qm.Where("hash=?", fmt.Sprintf("%x", transactionHash.Sum(nil))),
 			qm.Where("transactions_hash_type_id=?", hashType.ID),
 		}
-		exists, err := model.TransactionsHashes(q...).Exists(t.ctx, t.db)
+		exists, err := model.TransactionsHashes(q...).Exists(t.ctx, tx)
 		if err != nil {
 			return err
 		}
@@ -414,7 +436,7 @@ func (t *transactionImporterService) ofxRespCreditCard(msg ofxgo.Message) error 
 			continue
 		}
 
-		if err := transaction.Insert(t.ctx, t.db, boil.Infer()); err != nil {
+		if err := transaction.Insert(t.ctx, tx, boil.Infer()); err != nil {
 			return err
 		}
 
@@ -423,7 +445,7 @@ func (t *transactionImporterService) ofxRespCreditCard(msg ofxgo.Message) error 
 			TransactionsHashTypeID: hashType.ID,
 			Hash:                   fmt.Sprintf("%x", transactionHash.Sum(nil)),
 		}
-		if err := th.Insert(t.ctx, t.db, boil.Infer()); err != nil {
+		if err := th.Insert(t.ctx, tx, boil.Infer()); err != nil {
 			return err
 		}
 
@@ -449,7 +471,7 @@ func (t *transactionImporterService) ofxRespCreditCard(msg ofxgo.Message) error 
 		}
 
 		for _, ii := range transactionAttributes {
-			if err := ii.Insert(t.ctx, t.db, boil.Infer()); err != nil {
+			if err := ii.Upsert(t.ctx, tx, true, []string{"transactions_id", "name"}, boil.Whitelist("name"), boil.Infer()); err != nil {
 				return err
 			}
 		}
@@ -461,13 +483,25 @@ func (t *transactionImporterService) ofxRespCreditCard(msg ofxgo.Message) error 
 				ValueNum:       i.TrnAmt.Num().Int64(),
 				ValueDenom:     i.TrnAmt.Denom().Int64(),
 			},
+			{
+				TransactionsID: transaction.ID,
+				AccountID:      imbalancedAccount.ID,
+				ValueNum:       -i.TrnAmt.Num().Int64(),
+				ValueDenom:     i.TrnAmt.Denom().Int64(),
+			},
 		}
 		for _, ii := range splits {
-			if err := ii.Insert(t.ctx, t.db, boil.Infer()); err != nil {
+			if err := ii.Insert(t.ctx, tx, boil.Infer()); err != nil {
 				return err
 			}
 		}
+
+	}
+
+	if err := tx.Commit(); err != nil {
+		return err
 	}
+
 	return nil
 }
 

diff --git a/desktop/backend/services/transaction_service.go b/desktop/backend/services/transaction_service.go
line changes: +62/-0
index 0c37898..0372d0b
--- a/desktop/backend/services/transaction_service.go
+++ b/desktop/backend/services/transaction_service.go
@@ -6,6 +6,7 @@ import (
 	"database/sql"
 	"encoding/base64"
 	"fmt"
+	"math/big"
 	"pennyapp/backend/logwrap"
 	"pennyapp/backend/model"
 	"pennyapp/backend/types"
@@ -13,6 +14,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/shopspring/decimal"
 	"github.com/volatiletech/sqlboiler/v4/boil"
 	"github.com/volatiletech/sqlboiler/v4/queries/qm"
 )
@@ -126,6 +128,15 @@ func (t *transactionService) GetTransactionsTag(id int64) types.TransactionsResp
 	return resp
 }
 
+func (t *transactionService) transactionExistsInSlice(transaction *model.Transaction, transactions []types.Transaction) bool {
+	for _, i := range transactions {
+		if i.ID == transaction.ID {
+			return true
+		}
+	}
+	return false
+}
+
 func (t *transactionService) GetTransactionsAccount(id int64) types.TransactionsResponse {
 	var resp types.TransactionsResponse
 	var q []qm.QueryMod
@@ -134,6 +145,7 @@ func (t *transactionService) GetTransactionsAccount(id int64) types.Transactions
 		qm.Where("account.id=?", id),
 		qm.InnerJoin("splits on splits.transactions_id = transactions.id"),
 		qm.InnerJoin("account on account.id = splits.account_id"),
+		qm.OrderBy("transactions.date DESC"),
 		qm.Load("Splits"),
 		qm.Load("Splits.Account"),
 	}
@@ -146,6 +158,11 @@ func (t *transactionService) GetTransactionsAccount(id int64) types.Transactions
 
 	resp.Data = []types.Transaction{}
 	for _, i := range transactions {
+
+		if exists := t.transactionExistsInSlice(i, resp.Data); exists {
+			continue
+		}
+
 		transaction, err := types.NewTransaction(t.ctx, t.db, i)
 		if err != nil {
 			resp.Msg = err.Error()
@@ -164,6 +181,51 @@ func (t *transactionService) GetTransactionsAccount(id int64) types.Transactions
 	return resp
 }
 
+func (t *transactionService) GetTotalBalance() types.TransactionsNetAssetsResponse {
+	var resp types.TransactionsNetAssetsResponse
+	var err error
+
+	transactions, err := model.Transactions().All(t.ctx, t.db)
+	if err != nil {
+		resp.Msg = err.Error()
+		t.logger.Error(resp.Msg)
+		return resp
+	}
+
+	imbalancedAccount, err := model.Accounts(qm.Where("name=?", "Imbalanced")).One(t.ctx, t.db)
+	if err != nil {
+		resp.Msg = err.Error()
+		t.logger.Error(resp.Msg)
+		return resp
+	}
+
+	amount := float64(0)
+	for _, i := range transactions {
+		transaction, err := types.NewTransaction(t.ctx, t.db, i)
+		if err != nil {
+			resp.Msg = err.Error()
+			t.logger.Error(resp.Msg)
+			return resp
+		}
+		if transaction.Deleted == true {
+			continue
+		}
+
+		for _, split := range transaction.Splits {
+			if split.Account.ID != imbalancedAccount.ID {
+				continue
+			}
+			r := big.NewRat(split.ValueNum, split.ValueDenom)
+			f, _ := r.Float64()
+			amount = amount + f
+			t.logger.Debug(fmt.Sprintf("%#+v, %#+v", split, amount))
+		}
+	}
+	resp.Data = decimal.NewFromFloat(amount).StringFixed(2)
+	resp.Success = true
+	return resp
+}
+
 func (t *transactionService) GetTransactions() types.TransactionsResponse {
 	var resp types.TransactionsResponse
 	var err error

diff --git a/desktop/backend/types/account.go b/desktop/backend/types/account.go
line changes: +21/-0
index e4a5791..396cd1c
--- a/desktop/backend/types/account.go
+++ b/desktop/backend/types/account.go
@@ -6,6 +6,7 @@ import (
 	"math/big"
 	"pennyapp/backend/internal/dberror"
 	"pennyapp/backend/model"
+	"strings"
 
 	"github.com/shopspring/decimal"
 	"github.com/volatiletech/sqlboiler/v4/queries/qm"
@@ -80,12 +81,32 @@ func (a *Account) setAmount(ctx context.Context, db *sql.DB, account *model.Acco
 		return err
 	}
 
+	accountType, err := model.AccountTypes(qm.Where("id=?", account.AccountTypeID)).One(ctx, db)
+	if err != nil {
+		return err
+	}
+
 	amount := float64(0)
 	for _, split := range splits {
+		transactionDeleted, err := isTransactionDeleted(ctx, db, split.TransactionsID)
+		if err != nil {
+			return err
+		}
+		if transactionDeleted {
+			continue
+		}
+
 		r := big.NewRat(split.ValueNum, split.ValueDenom)
 		f, _ := r.Float64()
+
+		switch strings.ToLower(accountType.Name) {
+		case "liability", "income":
+			f = -f
+		}
+
 		amount = amount + f
 	}
+
 	a.Amount = decimal.NewFromFloat(amount).StringFixed(2)
 	return nil
 }

diff --git a/desktop/backend/types/tag.go b/desktop/backend/types/tag.go
line changes: +36/-4
index 33df858..e9f0b89
--- a/desktop/backend/types/tag.go
+++ b/desktop/backend/types/tag.go
@@ -6,6 +6,7 @@ import (
 	"math/big"
 	"pennyapp/backend/internal/dberror"
 	"pennyapp/backend/model"
+	"strings"
 
 	"github.com/shopspring/decimal"
 	"github.com/volatiletech/sqlboiler/v4/queries/qm"
@@ -114,7 +115,6 @@ func (t *Tag) setAmount(ctx context.Context, db *sql.DB, tag *model.Tag) error {
 		qm.Load("Transaction"),
 		qm.Load("Transaction.Splits"),
 		qm.Load("Transaction.Splits.Account"),
-		qm.Load("Transaction.Splits.Account.AccountType"),
 	}
 	tagTransactions, err := model.TagTransactions(q...).All(ctx, db)
 	if err != nil {
@@ -127,10 +127,42 @@ func (t *Tag) setAmount(ctx context.Context, db *sql.DB, tag *model.Tag) error {
 			continue
 		}
 		transaction := tagTransaction.R.Transaction
+
+		transactionDeleted, err := isTransactionDeleted(ctx, db, transaction.ID)
+		if err != nil {
+			return err
+		}
+		if transactionDeleted {
+			continue
+		}
+
 		for _, split := range transaction.R.Splits {
-			r := big.NewRat(split.ValueNum, split.ValueDenom)
-			f, _ := r.Float64()
-			amount = amount + f
+			q = []qm.QueryMod{
+				qm.Where("account.id=?", split.AccountID),
+				qm.InnerJoin("account_type on account_type.id = account.account_type_id"),
+				qm.Load("AccountType"),
+			}
+			account, err := model.Accounts(q...).One(ctx, db)
+			if err != nil {
+				return err
+			}
+
+			accountType, err := account.AccountType().One(ctx, db)
+			if err != nil {
+				return err
+			}
+
+			// Only calculate amount for splits which have an account type of
+			// expense. There is a FEAT that improves Tag support by tagging
+			// transactions by account type which should remove this hack.
+			switch strings.ToLower(accountType.Name) {
+			case "expense":
+				r := big.NewRat(split.ValueNum, split.ValueDenom)
+				f, _ := r.Float64()
+				amount = amount + f
+			default:
+			}
+
 		}
 	}
 	t.Amount = decimal.NewFromFloat(amount).StringFixed(2)

diff --git a/desktop/backend/types/transaction.go b/desktop/backend/types/transaction.go
line changes: +16/-0
index 351729b..0cc8cbf
--- a/desktop/backend/types/transaction.go
+++ b/desktop/backend/types/transaction.go
@@ -28,6 +28,22 @@ type TransactionsResponse struct {
 	Data    []Transaction `json:"data"`
 }
 
+// TransactionsTotalBalance is the total balance amount for all non-deleted
+// transactions.
+type TransactionsTotalBalance struct {
+	Success bool   `json:"success"`
+	Msg     string `json:"msg"`
+	Data    string `json:"data"`
+}
+
+// TransactionsNetAssetsResponse is the total net assets amount for all
+// non-deleted transactions.
+type TransactionsNetAssetsResponse struct {
+	Success bool   `json:"success"`
+	Msg     string `json:"msg"`
+	Data    string `json:"data"`
+}
+
 // NewTransactionResponse returns a default TransactionResponse. All attributes
 // of TransactionResponse must be set or else the struct wont be returned
 // correctly to the frontend.

diff --git a/desktop/backend/types/types.go b/desktop/backend/types/types.go
line changes: +26/-0
index 593d7f3..1331ecd
--- a/desktop/backend/types/types.go
+++ b/desktop/backend/types/types.go
@@ -1,5 +1,14 @@
 package types
 
+import (
+	"context"
+	"database/sql"
+	"pennyapp/backend/internal/dberror"
+	"pennyapp/backend/model"
+
+	"github.com/volatiletech/sqlboiler/v4/queries/qm"
+)
+
 type JSResp struct {
 	Success bool   `json:"success"`
 	Msg     string `json:"msg"`
@@ -10,3 +19,20 @@ type Currency struct {
 	ID   int64  `json:"id"`
 	Name string `json:"name"`
 }
+
+// isTransactionDeleted accepts a transaction id and returns true if it is marked
+// as deleted.
+func isTransactionDeleted(ctx context.Context, db *sql.DB, transactionsID int64) (bool, error) {
+	var resp bool
+	transactionAttribute, err := model.TransactionsAttributes(qm.Where("transactions_id=? AND name=?", transactionsID, "deleted")).One(ctx, db)
+	switch {
+	case dberror.IsNoRowsFound(err):
+	case err != nil:
+		return resp, err
+	default:
+		if transactionAttribute.Value == "true" {
+			resp = true
+		}
+	}
+	return resp, nil
+}

diff --git a/desktop/db/migrations/000010_doubleentry.down.sql b/desktop/db/migrations/000010_doubleentry.down.sql
line changes: +0/-0
index 0000000..e69de29
--- /dev/null
+++ b/desktop/db/migrations/000010_doubleentry.down.sql

diff --git a/desktop/db/migrations/000010_doubleentry.up.sql b/desktop/db/migrations/000010_doubleentry.up.sql
line changes: +20/-0
index 0000000..a07cd41
--- /dev/null
+++ b/desktop/db/migrations/000010_doubleentry.up.sql
@@ -0,0 +1,20 @@
+PRAGMA foreign_keys=off;
+
+ALTER TABLE splits RENAME TO splits_1;
+
+CREATE TABLE IF NOT EXISTS splits
+(
+    id INTEGER NOT NULL PRIMARY KEY,
+    transactions_id INTEGER NOT NULL,
+    account_id INTEGER NOT NULL,
+    value_num BIGINT NOT NULL,
+    value_denom BIGINT NOT NULL,
+    FOREIGN KEY(transactions_id) REFERENCES transactions(id),
+    FOREIGN KEY(account_id) REFERENCES account(id)
+);
+
+INSERT INTO splits SELECT * FROM splits_1;
+
+DROP TABLE splits_1;
+
+PRAGMA foreign_keys=on;

diff --git a/desktop/db/migrations/bindata.go b/desktop/db/migrations/bindata.go
line changes: +50/-4
index a516d84..df95fe2
--- a/desktop/db/migrations/bindata.go
+++ b/desktop/db/migrations/bindata.go
@@ -18,6 +18,8 @@
 // 000008_comments.up.sql
 // 000009_attachments.down.sql
 // 000009_attachments.up.sql
+// 000010_doubleentry.down.sql
+// 000010_doubleentry.up.sql
 // migrations.go
 // migrations_test.go
 package migrations
@@ -380,7 +382,7 @@ func _000008_commentsDownSql() (*asset, error) {
 		return nil, err
 	}
 
-	info := bindataFileInfo{name: "000008_comments.down.sql", size: 67, mode: os.FileMode(420), modTime: time.Unix(1723236041, 0)}
+	info := bindataFileInfo{name: "000008_comments.down.sql", size: 67, mode: os.FileMode(420), modTime: time.Unix(1723934530, 0)}
 	a := &asset{bytes: bytes, info: info}
 	return a, nil
 }
@@ -400,7 +402,7 @@ func _000008_commentsUpSql() (*asset, error) {
 		return nil, err
 	}
 
-	info := bindataFileInfo{name: "000008_comments.up.sql", size: 398, mode: os.FileMode(420), modTime: time.Unix(1723236041, 0)}
+	info := bindataFileInfo{name: "000008_comments.up.sql", size: 398, mode: os.FileMode(420), modTime: time.Unix(1723934530, 0)}
 	a := &asset{bytes: bytes, info: info}
 	return a, nil
 }
@@ -420,7 +422,7 @@ func _000009_attachmentsDownSql() (*asset, error) {
 		return nil, err
 	}
 
-	info := bindataFileInfo{name: "000009_attachments.down.sql", size: 79, mode: os.FileMode(420), modTime: time.Unix(1723324013, 0)}
+	info := bindataFileInfo{name: "000009_attachments.down.sql", size: 79, mode: os.FileMode(420), modTime: time.Unix(1723934551, 0)}
 	a := &asset{bytes: bytes, info: info}
 	return a, nil
 }
@@ -440,7 +442,47 @@ func _000009_attachmentsUpSql() (*asset, error) {
 		return nil, err
 	}
 
-	info := bindataFileInfo{name: "000009_attachments.up.sql", size: 540, mode: os.FileMode(420), modTime: time.Unix(1723374891, 0)}
+	info := bindataFileInfo{name: "000009_attachments.up.sql", size: 540, mode: os.FileMode(420), modTime: time.Unix(1723934551, 0)}
+	a := &asset{bytes: bytes, info: info}
+	return a, nil
+}
+
+var __000010_doubleentryDownSql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x01\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00")
+
+func _000010_doubleentryDownSqlBytes() ([]byte, error) {
+	return bindataRead(
+		__000010_doubleentryDownSql,
+		"000010_doubleentry.down.sql",
+	)
+}
+
+func _000010_doubleentryDownSql() (*asset, error) {
+	bytes, err := _000010_doubleentryDownSqlBytes()
+	if err != nil {
+		return nil, err
+	}
+
+	info := bindataFileInfo{name: "000010_doubleentry.down.sql", size: 0, mode: os.FileMode(420), modTime: time.Unix(1724476098, 0)}
+	a := &asset{bytes: bytes, info: info}
+	return a, nil
+}
+
+var __000010_doubleentryUpSql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x74\x91\xcd\x4e\x84\x40\x10\x84\xef\xfd\x14\x7d\x04\xe3\xc5\x33\xf1\x30\x8b\x0d\x99\x08\x03\x69\xda\xc4\x3d\x11\xc2\x8f\x21\xae\x83\xd9\x01\x13\xdf\xde\x80\x18\xc5\x65\xaf\x35\x5f\x25\xdf\x74\xe5\xac\xe2\x54\x61\x37\x9c\xdb\xfe\xc5\x96\xaf\xed\xa7\xbb\x1f\xba\x2e\x00\x50\x89\x10\xa3\xa8\x43\x42\xe8\xde\x4f\xfd\xe8\x90\xc9\xa8\x94\x50\xb2\x35\x28\xef\x02\x80\x90\x49\x09\xad\xa0\x8e\xd0\x64\x82\xf4\xac\x0b\x29\x56\x0a\x3c\x40\x44\xec\x1b\xd4\x46\x28\x26\x5e\x10\xf3\x94\x24\x98\xb3\x4e\x15\x1f\xf1\x91\x8e\xb7\x0b\x34\x9e\x2b\xeb\xaa\x7a\xec\x07\xeb\xca\x9d\xc6\x37\x55\xd5\xf5\x30\xd9\xf1\x3a\xf0\x51\x9d\xa6\xb6\xb4\xd3\x1b\x1e\x74\xac\x8d\xec\x3e\x37\xad\x1d\xae\x00\x51\xc6\xa4\x63\x33\x7b\x79\xff\x94\x7c\x64\x8a\x88\xc9\x84\x54\x6c\x74\xbd\xbe\xf1\x2f\xdb\xbf\xaa\x9b\xe2\x1a\xcf\x1d\xf0\x03\x00\x6d\x0a\x62\x99\x7f\xf3\x73\x5b\x2c\x28\xa1\x50\xf0\x06\x23\xce\xd2\xbf\x07\x7f\xe0\x2c\xdf\xec\xb2\xa4\xbb\x43\xda\x00\xbe\x02\x00\x00\xff\xff\xe8\x95\x40\x5c\xe1\x01\x00\x00")
+
+func _000010_doubleentryUpSqlBytes() ([]byte, error) {
+	return bindataRead(
+		__000010_doubleentryUpSql,
+		"000010_doubleentry.up.sql",
+	)
+}
+
+func _000010_doubleentryUpSql() (*asset, error) {
+	bytes, err := _000010_doubleentryUpSqlBytes()
+	if err != nil {
+		return nil, err
+	}
+
+	info := bindataFileInfo{name: "000010_doubleentry.up.sql", size: 481, mode: os.FileMode(420), modTime: time.Unix(1724476935, 0)}
 	a := &asset{bytes: bytes, info: info}
 	return a, nil
 }
@@ -555,6 +597,8 @@ var _bindata = map[string]func() (*asset, error){
 	"000008_comments.up.sql": _000008_commentsUpSql,
 	"000009_attachments.down.sql": _000009_attachmentsDownSql,
 	"000009_attachments.up.sql": _000009_attachmentsUpSql,
+	"000010_doubleentry.down.sql": _000010_doubleentryDownSql,
+	"000010_doubleentry.up.sql": _000010_doubleentryUpSql,
 	"migrations.go": migrationsGo,
 	"migrations_test.go": migrations_testGo,
 }
@@ -617,6 +661,8 @@ var _bintree = &bintree{nil, map[string]*bintree{
 	"000008_comments.up.sql": &bintree{_000008_commentsUpSql, map[string]*bintree{}},
 	"000009_attachments.down.sql": &bintree{_000009_attachmentsDownSql, map[string]*bintree{}},
 	"000009_attachments.up.sql": &bintree{_000009_attachmentsUpSql, map[string]*bintree{}},
+	"000010_doubleentry.down.sql": &bintree{_000010_doubleentryDownSql, map[string]*bintree{}},
+	"000010_doubleentry.up.sql": &bintree{_000010_doubleentryUpSql, map[string]*bintree{}},
 	"migrations.go": &bintree{migrationsGo, map[string]*bintree{}},
 	"migrations_test.go": &bintree{migrations_testGo, map[string]*bintree{}},
 }}

diff --git a/desktop/frontend/src/views/CreateTransaction.vue b/desktop/frontend/src/views/CreateTransaction.vue
line changes: +2/-3
index 814e040..e6471b3
--- a/desktop/frontend/src/views/CreateTransaction.vue
+++ b/desktop/frontend/src/views/CreateTransaction.vue
@@ -165,14 +165,13 @@ export default {
                                 </div>
 
                                 <!-- <div v-for="(split, index) in splits" :key="index" v-show="splits.length >= 2"> -->
-                                <div class="row gx-3 mb-3" v-show="splits[0].amount != '0.00'">
+                                <div class="row gx-3 mb-3" v-show="splits[0].amount != '0.00' && splits[0].account.id != null">
                                   <!-- Form Group (increase account)-->
                                   <div class="col-md-6">
                                       <label class="small mb-1" for="inputFirstName">Account</label>
                                       <div class="mb-3">
                                           <select class="form-select" aria-label="Type" v-model="splits[1].account.id">
                                               <option selected="" disabled="">Select an account:</option>
-                                              <!-- <option v-for="account in accounts.filter(account => account.account_type.name === 'Current Liability')" :value="account.id">{{ account.name }}</option> -->
                                               <option v-for="account in accounts" :value="account.id">{{ account.name }}</option>
                                           </select>
                                       </div>
@@ -188,7 +187,7 @@ export default {
                                 <!-- </div> -->
 
                                 <div class="row gx-3 jjmb-3">
-                                  <div class="col-md-6" v-show="splits[1].amount != '0.00' && splits[0].amount != '0.00'">
+                                  <div class="col-md-6" v-show="splits[1].amount != '0.00' && splits[0].amount != '0.00' && splits[0].account.id != null && splits[1].account.id != null && (splits[0].amount + splits[1].amount == 0)">
                                     <button class="btn btn-primary" type="submit">Create</button>
                                   </div>
                                 </div>

diff --git a/desktop/frontend/src/views/GetTransaction.vue b/desktop/frontend/src/views/GetTransaction.vue
line changes: +1/-0
index 208cc30..ba8e136
--- a/desktop/frontend/src/views/GetTransaction.vue
+++ b/desktop/frontend/src/views/GetTransaction.vue
@@ -157,6 +157,7 @@ export default {
             }
 
             const { success, msg, data } = await UpdateSplit(split.id, split.account_id, Number(split.amount))
+            console.log("DEBUG1: " + JSON.stringify(split))
             if (!success) {
               $message.error(msg)
               return

diff --git a/desktop/frontend/src/views/GetTransactions.vue b/desktop/frontend/src/views/GetTransactions.vue
line changes: +27/-1
index 947cd73..fd5559a
--- a/desktop/frontend/src/views/GetTransactions.vue
+++ b/desktop/frontend/src/views/GetTransactions.vue
@@ -1,6 +1,7 @@
 <script>
 import { ref } from 'vue';
-import { GetTransactions, GetTransactionsAccount, GetTransactionsTag } from 'wailsjs/go/services/transactionService.js'
+import { GetTransactions, GetTransactionsAccount, GetTransactionsTag, GetTotalBalance } from 'wailsjs/go/services/transactionService.js'
+import { GetAccountByName } from 'wailsjs/go/services/accountService.js'
 import { useRouter } from 'vue-router';
 
 export default {
@@ -20,6 +21,8 @@ export default {
       searchValue: ref(),
       searchFields: ['memo', 'amount'],
       transactions: [],
+      totalBalance: ref(),
+      imbalancedAccountID: ref(),
       headers: [
         {
           text: 'Date',
@@ -46,12 +49,29 @@ export default {
       () => this.$route.params,
       () => {
         this.getTransactions()
+        this.getTotalBalance()
+        this.getImbalancedAccountID()
       },
       { immediate: true },
     )
   },
 
   methods: {
+    async getImbalancedAccountID() {
+      const getAccountByNameResp = await GetAccountByName("Imbalanced")
+      if (!getAccountByNameResp.success) {
+        return
+      }
+      this.imbalancedAccountID = getAccountByNameResp.data.id
+    },
+    async getTotalBalance() {
+      const getTotalBalanceResp = await GetTotalBalance()
+      if (!getTotalBalanceResp.success) {
+        $message.error(getTotalBalanceResp.msg)
+        return
+      }
+      this.totalBalance = getTotalBalanceResp.data
+    },
     async getTransactions() {
       let transactions = [];
 
@@ -154,11 +174,17 @@ export default {
                               </EasyDataTable>
                             </div>
                         </div>
+
+                      <div class="col-md-6">
+                          <label class="small mb-1">Total Balance:</label>
+                          <label class="small mb-1"><router-link :to="{ name: 'get-account', params: { id: this.imbalancedAccountID }}">{{ totalBalance }}</router-link></label>
+                      </div>
                     </div>
 
                 </div>
                 <!-- /.container-fluid -->
 
+
             </div>
             <!-- End of Main Content -->
 

diff --git a/desktop/frontend/wailsjs/go/models.ts b/desktop/frontend/wailsjs/go/models.ts
line changes: +16/-0
index 4ecafbb..cfb78ce
--- a/desktop/frontend/wailsjs/go/models.ts
+++ b/desktop/frontend/wailsjs/go/models.ts
@@ -757,6 +757,22 @@ export namespace types {
 		    return a;
 		}
 	}
+	export class TransactionsNetAssetsResponse {
+	    success: boolean;
+	    msg: string;
+	    data: string;
+	
+	    static createFrom(source: any = {}) {
+	        return new TransactionsNetAssetsResponse(source);
+	    }
+	
+	    constructor(source: any = {}) {
+	        if ('string' === typeof source) source = JSON.parse(source);
+	        this.success = source["success"];
+	        this.msg = source["msg"];
+	        this.data = source["data"];
+	    }
+	}
 	export class TransactionsResponse {
 	    success: boolean;
 	    msg: string;

diff --git a/desktop/frontend/wailsjs/go/services/accountService.d.ts b/desktop/frontend/wailsjs/go/services/accountService.d.ts
line changes: +2/-0
index 0209e18..8e62627
--- a/desktop/frontend/wailsjs/go/services/accountService.d.ts
+++ b/desktop/frontend/wailsjs/go/services/accountService.d.ts
@@ -12,6 +12,8 @@ export function DeleteAccount(arg1:number):Promise<types.AccountResponse>;
 
 export function GetAccount(arg1:number):Promise<types.AccountResponse>;
 
+export function GetAccountByName(arg1:string):Promise<types.AccountResponse>;
+
 export function GetAccounts():Promise<types.JSResp>;
 
 export function GetChildAccountTypes():Promise<types.JSResp>;

diff --git a/desktop/frontend/wailsjs/go/services/accountService.js b/desktop/frontend/wailsjs/go/services/accountService.js
line changes: +4/-0
index 4f63265..722facc
--- a/desktop/frontend/wailsjs/go/services/accountService.js
+++ b/desktop/frontend/wailsjs/go/services/accountService.js
@@ -14,6 +14,10 @@ export function GetAccount(arg1) {
   return window['go']['services']['accountService']['GetAccount'](arg1);
 }
 
+export function GetAccountByName(arg1) {
+  return window['go']['services']['accountService']['GetAccountByName'](arg1);
+}
+
 export function GetAccounts() {
   return window['go']['services']['accountService']['GetAccounts']();
 }

diff --git a/desktop/frontend/wailsjs/go/services/transactionService.d.ts b/desktop/frontend/wailsjs/go/services/transactionService.d.ts
line changes: +2/-0
index 395325d..fbbb591
--- a/desktop/frontend/wailsjs/go/services/transactionService.d.ts
+++ b/desktop/frontend/wailsjs/go/services/transactionService.d.ts
@@ -12,6 +12,8 @@ export function CreateTransaction(arg1:string,arg2:string):Promise<types.Transac
 
 export function DeleteTransaction(arg1:number):Promise<types.TransactionResponse>;
 
+export function GetTotalBalance():Promise<types.TransactionsNetAssetsResponse>;
+
 export function GetTransaction(arg1:number):Promise<types.TransactionResponse>;
 
 export function GetTransactions():Promise<types.TransactionsResponse>;

diff --git a/desktop/frontend/wailsjs/go/services/transactionService.js b/desktop/frontend/wailsjs/go/services/transactionService.js
line changes: +4/-0
index ef0e262..9d8dcbc
--- a/desktop/frontend/wailsjs/go/services/transactionService.js
+++ b/desktop/frontend/wailsjs/go/services/transactionService.js
@@ -14,6 +14,10 @@ export function DeleteTransaction(arg1) {
   return window['go']['services']['transactionService']['DeleteTransaction'](arg1);
 }
 
+export function GetTotalBalance() {
+  return window['go']['services']['transactionService']['GetTotalBalance']();
+}
+
 export function GetTransaction(arg1) {
   return window['go']['services']['transactionService']['GetTransaction'](arg1);
 }

diff --git a/desktop/sqlboiler.toml b/desktop/sqlboiler.toml
line changes: +1/-1
index 2969de5..881f3bf
--- a/desktop/sqlboiler.toml
+++ b/desktop/sqlboiler.toml
@@ -5,4 +5,4 @@ no-tests = true
 
 [sqlite3]
 dbname = "./.pennyapp.db"
-blacklist = ["schema_migrations"]
+blacklist = ["schema_migrations", "splits_1"]