summary history files

commit:e9162a8925b212b34c42de39b393e3a5383155c8
date:Sun Aug 18 10:08:46 2024 +1000
parents:53ee99c306073d0749f4e266e6a2e05ac5e50b7c
initial support for account and tag transaction views
diff --git a/desktop/README.md b/desktop/README.md
line changes: +17/-15
index e99d058..0611f54
--- a/desktop/README.md
+++ b/desktop/README.md
@@ -6,18 +6,20 @@ See [README.md](../README.md) in parent directory.
 
 `BUG` or `FEAT` todo items.
 
-* [ ] - 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
-        disabed after filter is available.
-* [ ] - BUG: opening attachment only works with operating systems that have
-        xdg-open installed in /usr/bin/open. This needs to be extended to
-        support other operating systems.
-* [ ] - BUG: importing OFX transactions will fail if currency symbol within ofx
-        are invalid.
-* [ ] - FEAT: transaction attachments should have a size limit
-* [ ] - FEAT: support 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: `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
+      disabed after filter is available.
+* [ ] BUG: opening attachment only works with operating systems that have
+      xdg-open installed in /usr/bin/open. This needs to be extended to
+      support other operating systems.
+* [ ] BUG: importing OFX transactions will fail if currency symbol within ofx
+      are invalid.
+* [ ] FEAT: transaction attachments should have a size limit
+* [ ] FEAT: support 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.

diff --git a/desktop/backend/services/attachment_service.go b/desktop/backend/services/attachment_service.go
line changes: +16/-4
index 0ce6b07..3bce3fd
--- a/desktop/backend/services/attachment_service.go
+++ b/desktop/backend/services/attachment_service.go
@@ -9,6 +9,7 @@ import (
 	"os/exec"
 	"path/filepath"
 	"pennyapp/backend/config"
+	"pennyapp/backend/internal/dberror"
 	"pennyapp/backend/logwrap"
 	"pennyapp/backend/model"
 	"pennyapp/backend/types"
@@ -76,21 +77,32 @@ func (a *attachmentService) CreateAttachment(transactionID int64, name, data str
 		return resp
 	}
 
-	decoded, err := base64.StdEncoding.DecodeString(sArray[1])
+	attachmentBlob := sArray[1]
+
+	decoded, err := base64.StdEncoding.DecodeString(attachmentBlob)
 	if err != nil {
 		resp.Msg = fmt.Sprintf("Failed to decode: %s", err.Error())
 		a.logger.Error(resp.Msg)
 		return resp
 	}
 
-	attachment := model.Attachment{
+	attachment := &model.Attachment{
 		Name:      name,
 		SZ:        int64(len(decoded)),
-		Data:      []byte(sArray[1]),
+		Data:      []byte(attachmentBlob),
 		CreatedAt: time.Now().UTC().Unix(),
 	}
 
-	if err = attachment.Insert(a.ctx, tx, boil.Infer()); err != nil {
+	err = attachment.Insert(a.ctx, tx, boil.Infer())
+	switch {
+	case dberror.IsUniqueConstraint(err):
+		attachment, err = model.Attachments(qm.Where("name=? AND data=?", name, []byte(attachmentBlob))).One(a.ctx, tx)
+		if err != nil {
+			resp.Msg = err.Error()
+			a.logger.Error(resp.Msg)
+			return resp
+		}
+	case err != nil:
 		resp.Msg = err.Error()
 		a.logger.Error(resp.Msg)
 		return resp

diff --git a/desktop/backend/services/transaction_service.go b/desktop/backend/services/transaction_service.go
line changes: +78/-28
index d772753..0c37898
--- a/desktop/backend/services/transaction_service.go
+++ b/desktop/backend/services/transaction_service.go
@@ -72,35 +72,101 @@ func (t *transactionService) GetTransaction(id int64) types.TransactionResponse 
 		return resp
 	}
 
-	data, err := types.NewTransaction(t.logger, transaction)
+	resp.Data, err = types.NewTransaction(t.ctx, t.db, transaction)
 	if err != nil {
 		resp.Msg = err.Error()
 		t.logger.Error(resp.Msg)
 		return resp
 	}
 
-	if err := data.SetAttributes(t.ctx, t.db); err != nil {
-		resp.Msg = fmt.Sprintf("Failed to set attributes for transaction %d: %s", transaction.ID, err.Error())
+	resp.Success = true
+
+	return resp
+
+}
+
+func (t *transactionService) GetTransactionsTag(id int64) types.TransactionsResponse {
+	var resp types.TransactionsResponse
+	var q []qm.QueryMod
+
+	q = []qm.QueryMod{
+		qm.Where("tag.id=?", id),
+		qm.InnerJoin("tag on tag.id = tag_transactions.tag_id"),
+		qm.InnerJoin("tag_transactions on tag_transactions.transactions_id = transactions.id"),
+		qm.InnerJoin("splits on splits.transactions_id = transactions.id"),
+		qm.InnerJoin("account on account.id = splits.account_id"),
+		qm.Load("Splits"),
+		qm.Load("Splits.Account"),
+		qm.Load("TagTransactions"),
+	}
+	transactions, err := model.Transactions(q...).All(t.ctx, t.db)
+	if err != nil {
+		resp.Msg = err.Error()
 		t.logger.Error(resp.Msg)
 		return resp
 	}
 
-	if err := data.SetSplits(t.ctx, t.db); err != nil {
+	resp.Data = []types.Transaction{}
+	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
+		}
+
+		resp.Data = append(resp.Data, transaction)
+	}
+
+	resp.Success = true
+	return resp
+}
+
+func (t *transactionService) GetTransactionsAccount(id int64) types.TransactionsResponse {
+	var resp types.TransactionsResponse
+	var q []qm.QueryMod
+
+	q = []qm.QueryMod{
+		qm.Where("account.id=?", id),
+		qm.InnerJoin("splits on splits.transactions_id = transactions.id"),
+		qm.InnerJoin("account on account.id = splits.account_id"),
+		qm.Load("Splits"),
+		qm.Load("Splits.Account"),
+	}
+	transactions, err := model.Transactions(q...).All(t.ctx, t.db)
+	if err != nil {
 		resp.Msg = err.Error()
 		t.logger.Error(resp.Msg)
 		return resp
 	}
 
-	resp.Success = true
-	resp.Data = data
+	resp.Data = []types.Transaction{}
+	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
+		}
 
-	return resp
+		if transaction.Deleted == true {
+			continue
+		}
+
+		resp.Data = append(resp.Data, transaction)
+	}
 
+	resp.Success = true
+	return resp
 }
 
-func (t *transactionService) GetTransactions() types.JSResp {
+func (t *transactionService) GetTransactions() types.TransactionsResponse {
+	var resp types.TransactionsResponse
 	var err error
-	var resp types.JSResp
 	var q []qm.QueryMod
 
 	q = []qm.QueryMod{
@@ -114,40 +180,24 @@ func (t *transactionService) GetTransactions() types.JSResp {
 		return resp
 	}
 
-	data := []types.Transaction{}
+	resp.Data = []types.Transaction{}
 
 	for _, i := range transactions {
-		transaction, err := types.NewTransaction(t.logger, i)
+		transaction, err := types.NewTransaction(t.ctx, t.db, i)
 		if err != nil {
 			resp.Msg = err.Error()
 			t.logger.Error(resp.Msg)
 			return resp
 		}
 
-		if err := transaction.SetSplits(t.ctx, t.db); err != nil {
-			resp.Msg = fmt.Sprintf("Failed to get splits for transaction %d: %s", i.ID, err.Error())
-			t.logger.Error(resp.Msg)
-			return resp
-		}
-
-		if err := transaction.SetAttributes(t.ctx, t.db); err != nil {
-			resp.Msg = fmt.Sprintf("Failed to set attributes for transaction %d: %s", i.ID, err.Error())
-			t.logger.Error(resp.Msg)
-			return resp
-		}
-
 		if transaction.Deleted == true {
 			continue
 		}
 
-		transaction.SetAmount()
-		transaction.SetDisplay()
-
-		data = append(data, transaction)
+		resp.Data = append(resp.Data, transaction)
 	}
 
 	resp.Success = true
-	resp.Data = data
 
 	return resp
 }

diff --git a/desktop/backend/types/transaction.go b/desktop/backend/types/transaction.go
line changes: +35/-35
index 61b5336..351729b
--- a/desktop/backend/types/transaction.go
+++ b/desktop/backend/types/transaction.go
@@ -6,7 +6,6 @@ import (
 	"fmt"
 	"math/big"
 	"pennyapp/backend/internal/dberror"
-	"pennyapp/backend/logwrap"
 	"pennyapp/backend/model"
 	"time"
 
@@ -23,6 +22,12 @@ type TransactionResponse struct {
 	Data    Transaction `json:"data"`
 }
 
+type TransactionsResponse struct {
+	Success bool          `json:"success"`
+	Msg     string        `json:"msg"`
+	Data    []Transaction `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.
@@ -36,19 +41,14 @@ func NewTransactionResponse() TransactionResponse {
 }
 
 type Transaction struct {
-	ID       int64  `json:"id"`
-	Memo     string `json:"memo"`
-	Date     string `json:"date"`
+	ID       int64              `json:"id"`
+	Memo     string             `json:"memo"`
+	Date     string             `json:"date"`
+	Splits   []Split            `json:"splits"`
+	Amount   string             `json:"amount"`
+	Deleted  bool               `json:"deleted"`
+	Display  TransactionDisplay `json:"display"`
 	Currency `json:"currency"`
-	Splits   []Split `json:"splits"`
-	Amount   string  `json:"amount"`
-	Deleted  bool    `json:"deleted"`
-
-	modelTransaction *model.Transaction
-	logger           *logwrap.LogWrap
-
-	// Display attributes are potentially shown in UI and not used in any other logic.
-	Display TransactionDisplay `json:"display"`
 }
 
 type TransactionDisplay struct {
@@ -60,12 +60,12 @@ type TransactionDisplayAccount struct {
 	ID   int64  `json:"id"`
 }
 
-func NewTransaction(logger *logwrap.LogWrap, t *model.Transaction) (Transaction, error) {
+func NewTransaction(ctx context.Context, db *sql.DB, t *model.Transaction) (Transaction, error) {
+	var err error
+
 	transaction := Transaction{
-		ID:               t.ID,
-		Memo:             t.Memo,
-		modelTransaction: t,
-		logger:           logger,
+		ID:   t.ID,
+		Memo: t.Memo,
 	}
 
 	date, err := time.Parse(DefaultDBTimeLayout, t.Date)
@@ -74,13 +74,23 @@ func NewTransaction(logger *logwrap.LogWrap, t *model.Transaction) (Transaction,
 	}
 	transaction.Date = date.Format("2006-01-02")
 
+	if err = transaction.setSplits(ctx, db, t); err != nil {
+		return transaction, err
+	}
+
+	if err = transaction.setDeleted(ctx, db, t); err != nil {
+		return transaction, err
+	}
+
+	transaction.setAmount()
+	transaction.setDisplay()
+
 	return transaction, nil
 }
 
-func (t *Transaction) SetSplits(ctx context.Context, db *sql.DB) error {
-	splits, err := t.modelTransaction.Splits(qm.Select("splits.id, splits.transactions_id, splits.account_id, splits.value_num, splits.value_denom")).All(ctx, db)
+func (t *Transaction) setSplits(ctx context.Context, db *sql.DB, transaction *model.Transaction) error {
+	splits, err := transaction.Splits(qm.Select("splits.id, splits.transactions_id, splits.account_id, splits.value_num, splits.value_denom")).All(ctx, db)
 	if err != nil {
-		t.logger.Error(err.Error())
 		return err
 	}
 
@@ -89,13 +99,11 @@ func (t *Transaction) SetSplits(ctx context.Context, db *sql.DB) error {
 	for _, ii := range splits {
 		account, err := model.FindAccount(ctx, db, ii.AccountID)
 		if err != nil {
-			t.logger.Error(err.Error())
 			return err
 		}
 
 		accountType, err := model.FindAccountType(ctx, db, account.AccountTypeID)
 		if err != nil {
-			t.logger.Error(err.Error())
 			return err
 		}
 
@@ -103,7 +111,6 @@ func (t *Transaction) SetSplits(ctx context.Context, db *sql.DB) error {
 		if accountType.ParentID != 0 {
 			pat, err := model.FindAccountType(ctx, db, accountType.ParentID)
 			if err != nil {
-				t.logger.Error(err.Error())
 				return err
 			}
 			parentAccountType = &ParentAccountType{
@@ -136,7 +143,7 @@ func (t *Transaction) SetSplits(ctx context.Context, db *sql.DB) error {
 	return nil
 }
 
-func (t *Transaction) SetDisplay() {
+func (t *Transaction) setDisplay() {
 	display := TransactionDisplay{
 		Account: TransactionDisplayAccount{},
 	}
@@ -155,7 +162,7 @@ func (t *Transaction) SetDisplay() {
 	return
 }
 
-func (t *Transaction) SetAmount() {
+func (t *Transaction) setAmount() {
 	if len(t.Splits) < 1 {
 		t.Amount = "0"
 		return
@@ -171,15 +178,8 @@ func (t *Transaction) SetAmount() {
 	}
 }
 
-func (t *Transaction) SetAttributes(ctx context.Context, db *sql.DB) error {
-	if err := t.setDeleted(ctx, db); err != nil {
-		return err
-	}
-	return nil
-}
-
-func (t *Transaction) setDeleted(ctx context.Context, db *sql.DB) error {
-	transactionAttribute, err := model.TransactionsAttributes(qm.Where("transactions_id=? AND name=?", t.modelTransaction.ID, "deleted")).One(ctx, db)
+func (t *Transaction) setDeleted(ctx context.Context, db *sql.DB, transaction *model.Transaction) error {
+	transactionAttribute, err := model.TransactionsAttributes(qm.Where("transactions_id=? AND name=?", transaction.ID, "deleted")).One(ctx, db)
 	switch {
 	case dberror.IsNoRowsFound(err):
 	case err != nil:

diff --git a/desktop/frontend/src/router/index.ts b/desktop/frontend/src/router/index.ts
line changes: +12/-0
index e1582c9..b7e7cde
--- a/desktop/frontend/src/router/index.ts
+++ b/desktop/frontend/src/router/index.ts
@@ -36,6 +36,18 @@ const router = createRouter({
         component: () => import('../views/GetTransactions.vue')
     },
     {
+        path: '/get-transactions/account/:accountid',
+        name: 'get-transactions-account',
+        component: () => import('../views/GetTransactions.vue'),
+        props: true
+    },
+    {
+        path: '/get-transactions/tag/:tagid',
+        name: 'get-transactions-tag',
+        component: () => import('../views/GetTransactions.vue'),
+        props: true
+    },
+    {
         path: '/get-transaction/:id',
         name: 'get-transaction',
         component: () => import('../views/GetTransaction.vue'),

diff --git a/desktop/frontend/src/views/GetAccount.vue b/desktop/frontend/src/views/GetAccount.vue
line changes: +3/-1
index d8bf6a4..fe8815c
--- a/desktop/frontend/src/views/GetAccount.vue
+++ b/desktop/frontend/src/views/GetAccount.vue
@@ -145,7 +145,9 @@ export default {
                                     <label class="small mb-1">
                                       Amount
                                     </label>
-                                    <input class="form-control" readonly type="text" v-model="account.amount">
+                                    <div>
+                                      <router-link :to="{ name: 'get-transactions-account', params: { accountid: account.id }}">{{ account.amount }}</router-link>
+                                    </div>
                                 </div>
                                 <div class="mb-1">
                                     <label class="small mb-1">

diff --git a/desktop/frontend/src/views/GetTag.vue b/desktop/frontend/src/views/GetTag.vue
line changes: +3/-1
index 6bfa739..e0c5151
--- a/desktop/frontend/src/views/GetTag.vue
+++ b/desktop/frontend/src/views/GetTag.vue
@@ -168,7 +168,9 @@ export default {
                                     <label class="small mb-1">
                                       Amount
                                     </label>
-                                    <input class="form-control" readonly type="text" v-model="tag.amount">
+                                    <div>
+                                      <router-link :to="{ name: 'get-transactions-tag', params: { tagid: tag.id }}">{{ tag.amount }}</router-link>
+                                    </div>
                                 </div>
                                 <div v-for="(regex, idx) in tag.regexes" class="row gx-3 mb-3">
                                   <div class="col-md-6">

diff --git a/desktop/frontend/src/views/GetTransactions.vue b/desktop/frontend/src/views/GetTransactions.vue
line changes: +39/-5
index 4a8ee5c..947cd73
--- a/desktop/frontend/src/views/GetTransactions.vue
+++ b/desktop/frontend/src/views/GetTransactions.vue
@@ -1,9 +1,13 @@
 <script>
 import { ref } from 'vue';
-import { GetTransactions } from 'wailsjs/go/services/transactionService.js'
+import { GetTransactions, GetTransactionsAccount, GetTransactionsTag } from 'wailsjs/go/services/transactionService.js'
 import { useRouter } from 'vue-router';
 
 export default {
+  props: [
+    'accountid',
+    'tagid',
+  ],
   setup() {
     const router = useRouter();
     return {
@@ -50,11 +54,41 @@ export default {
   methods: {
     async getTransactions() {
       let transactions = [];
-      const { success, msg, data } = await GetTransactions()
-      if (!success) {
-          $message.error(msg)
-          return
+
+			let success = false
+			let msg = ""
+			let data = {}
+
+      if (typeof this.accountid !== 'undefined') {
+        const getTransactionsAccountResp = await GetTransactionsAccount(Number(this.accountid))
+				success = getTransactionsAccountResp.success
+				msg = getTransactionsAccountResp.msg
+				data = getTransactionsAccountResp.data
+        if (!success) {
+            $message.error(msg)
+            return
+        }
+      } else if (typeof this.tagid !== 'undefined') {
+        const getTransactionsTagResp = await GetTransactionsTag(Number(this.tagid))
+				success = getTransactionsTagResp.success
+				msg = getTransactionsTagResp.msg
+				data = getTransactionsTagResp.data
+        if (!success) {
+            $message.error(msg)
+            return
+        }
+
+			} else {
+				const getTransactionsResp = await GetTransactions()
+				success = getTransactionsResp.success
+				msg = getTransactionsResp.msg
+				data = getTransactionsResp.data
+				if (!success) {
+						$message.error(msg)
+						return
+				}
       }
+
       for (const transaction of data) {
         let t = {
           id: transaction.id,

diff --git a/desktop/frontend/wailsjs/go/models.ts b/desktop/frontend/wailsjs/go/models.ts
line changes: +38/-4
index 4f129fe..4ecafbb
--- a/desktop/frontend/wailsjs/go/models.ts
+++ b/desktop/frontend/wailsjs/go/models.ts
@@ -679,12 +679,12 @@ export namespace types {
 	    id: number;
 	    memo: string;
 	    date: string;
-	    id: number;
-	    name: string;
 	    splits: Split[];
 	    amount: string;
 	    deleted: boolean;
 	    display: TransactionDisplay;
+	    id: number;
+	    name: string;
 	
 	    static createFrom(source: any = {}) {
 	        return new Transaction(source);
@@ -695,12 +695,12 @@ export namespace types {
 	        this.id = source["id"];
 	        this.memo = source["memo"];
 	        this.date = source["date"];
-	        this.id = source["id"];
-	        this.name = source["name"];
 	        this.splits = this.convertValues(source["splits"], Split);
 	        this.amount = source["amount"];
 	        this.deleted = source["deleted"];
 	        this.display = this.convertValues(source["display"], TransactionDisplay);
+	        this.id = source["id"];
+	        this.name = source["name"];
 	    }
 	
 		convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -757,6 +757,40 @@ export namespace types {
 		    return a;
 		}
 	}
+	export class TransactionsResponse {
+	    success: boolean;
+	    msg: string;
+	    data: Transaction[];
+	
+	    static createFrom(source: any = {}) {
+	        return new TransactionsResponse(source);
+	    }
+	
+	    constructor(source: any = {}) {
+	        if ('string' === typeof source) source = JSON.parse(source);
+	        this.success = source["success"];
+	        this.msg = source["msg"];
+	        this.data = this.convertValues(source["data"], Transaction);
+	    }
+	
+		convertValues(a: any, classs: any, asMap: boolean = false): any {
+		    if (!a) {
+		        return a;
+		    }
+		    if (a.slice) {
+		        return (a as any[]).map(elem => this.convertValues(elem, classs));
+		    } else if ("object" === typeof a) {
+		        if (asMap) {
+		            for (const key of Object.keys(a)) {
+		                a[key] = new classs(a[key]);
+		            }
+		            return a;
+		        }
+		        return new classs(a);
+		    }
+		    return a;
+		}
+	}
 
 }
 

diff --git a/desktop/frontend/wailsjs/go/services/transactionService.d.ts b/desktop/frontend/wailsjs/go/services/transactionService.d.ts
line changes: +5/-1
index a9d0414..395325d
--- a/desktop/frontend/wailsjs/go/services/transactionService.d.ts
+++ b/desktop/frontend/wailsjs/go/services/transactionService.d.ts
@@ -14,7 +14,11 @@ export function DeleteTransaction(arg1:number):Promise<types.TransactionResponse
 
 export function GetTransaction(arg1:number):Promise<types.TransactionResponse>;
 
-export function GetTransactions():Promise<types.JSResp>;
+export function GetTransactions():Promise<types.TransactionsResponse>;
+
+export function GetTransactionsAccount(arg1:number):Promise<types.TransactionsResponse>;
+
+export function GetTransactionsTag(arg1:number):Promise<types.TransactionsResponse>;
 
 export function ImportTransactions(arg1:string):Promise<types.JSResp>;
 

diff --git a/desktop/frontend/wailsjs/go/services/transactionService.js b/desktop/frontend/wailsjs/go/services/transactionService.js
line changes: +8/-0
index 54d72d4..ef0e262
--- a/desktop/frontend/wailsjs/go/services/transactionService.js
+++ b/desktop/frontend/wailsjs/go/services/transactionService.js
@@ -22,6 +22,14 @@ export function GetTransactions() {
   return window['go']['services']['transactionService']['GetTransactions']();
 }
 
+export function GetTransactionsAccount(arg1) {
+  return window['go']['services']['transactionService']['GetTransactionsAccount'](arg1);
+}
+
+export function GetTransactionsTag(arg1) {
+  return window['go']['services']['transactionService']['GetTransactionsTag'](arg1);
+}
+
 export function ImportTransactions(arg1) {
   return window['go']['services']['transactionService']['ImportTransactions'](arg1);
 }