From e26fbba1ff30ff0fb50246d500cd57914255f682 Mon Sep 17 00:00:00 2001 From: morre Date: Sat, 9 Mar 2024 09:52:06 +0100 Subject: [PATCH] feat: implement transaction "direction" filter (#988) --- api/docs.go | 11 ++++ api/swagger.json | 11 ++++ api/swagger.yaml | 8 +++ pkg/controllers/v4/errors.go | 13 +++-- pkg/controllers/v4/month.go | 3 +- pkg/controllers/v4/transaction.go | 68 ++++++++++++++++++------- pkg/controllers/v4/transaction_test.go | 26 ++++++---- pkg/controllers/v4/transaction_types.go | 42 +++++++++------ 8 files changed, 133 insertions(+), 49 deletions(-) diff --git a/api/docs.go b/api/docs.go index 73fc3147..15d7458e 100644 --- a/api/docs.go +++ b/api/docs.go @@ -2732,6 +2732,17 @@ const docTemplate = `{ "name": "destination", "in": "query" }, + { + "enum": [ + "INCOMING", + "OUTGOING", + "TRANSFER" + ], + "type": "string", + "description": "Filter by direction of transaction", + "name": "direction", + "in": "query" + }, { "type": "string", "description": "Filter by envelope ID", diff --git a/api/swagger.json b/api/swagger.json index 77a256cc..59430e73 100644 --- a/api/swagger.json +++ b/api/swagger.json @@ -2721,6 +2721,17 @@ "name": "destination", "in": "query" }, + { + "enum": [ + "INCOMING", + "OUTGOING", + "TRANSFER" + ], + "type": "string", + "description": "Filter by direction of transaction", + "name": "direction", + "in": "query" + }, { "type": "string", "description": "Filter by envelope ID", diff --git a/api/swagger.yaml b/api/swagger.yaml index 364440d0..d487d624 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -3219,6 +3219,14 @@ paths: in: query name: destination type: string + - description: Filter by direction of transaction + enum: + - INCOMING + - OUTGOING + - TRANSFER + in: query + name: direction + type: string - description: Filter by envelope ID in: query name: envelope diff --git a/pkg/controllers/v4/errors.go b/pkg/controllers/v4/errors.go index 09c6de5e..7fb4472b 100644 --- a/pkg/controllers/v4/errors.go +++ b/pkg/controllers/v4/errors.go @@ -24,14 +24,14 @@ func status(err error) int { return http.StatusBadRequest } -// Cleanup errors var ( - errCleanupConfirmation = errors.New("the confirmation for the cleanup API call was incorrect") + errAccountIDParameter = errors.New("the accountId parameter must be set") + errMonthNotSetInQuery = errors.New("the month query parameter must be set") ) +// Cleanup errors var ( - errAccountIDParameter = errors.New("the accountId parameter must be set") - errMonthNotSetInQuery = errors.New("the month query parameter must be set") + errCleanupConfirmation = errors.New("the confirmation for the cleanup API call was incorrect") ) // Import errors @@ -41,3 +41,8 @@ var ( errBudgetNameInUse = errors.New("this budget name is already in use. Imports from YNAB 4 create a new budget, therefore the name needs to be unique") errBudgetNameNotSet = errors.New("the budgetName parameter must be set") ) + +// Transaction errors +var ( + errTransactionDirectionInvalid = errors.New("the specified transaction direction is invalid") +) diff --git a/pkg/controllers/v4/month.go b/pkg/controllers/v4/month.go index 7809931b..4f8ab7ad 100644 --- a/pkg/controllers/v4/month.go +++ b/pkg/controllers/v4/month.go @@ -11,6 +11,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/shopspring/decimal" + "golang.org/x/exp/slices" "gorm.io/gorm" ) @@ -305,7 +306,7 @@ func SetAllocations(c *gin.Context) { return } - if data.Mode != AllocateLastMonthBudget && data.Mode != AllocateLastMonthSpend { + if !slices.Contains([]AllocationMode{AllocateLastMonthBudget, AllocateLastMonthSpend}, data.Mode) { c.JSON(http.StatusBadRequest, httpError{ Error: fmt.Sprintf("The mode must be %s or %s", AllocateLastMonthBudget, AllocateLastMonthSpend), }) diff --git a/pkg/controllers/v4/transaction.go b/pkg/controllers/v4/transaction.go index 39947801..831ea4ac 100644 --- a/pkg/controllers/v4/transaction.go +++ b/pkg/controllers/v4/transaction.go @@ -112,22 +112,23 @@ func GetTransaction(c *gin.Context) { // @Failure 400 {object} TransactionListResponse // @Failure 500 {object} TransactionListResponse // @Router /v4/transactions [get] -// @Param date query string false "Date of the transaction. Ignores exact time, matches on the day of the RFC3339 timestamp provided." -// @Param fromDate query string false "Transactions at and after this date. Ignores exact time, matches on the day of the RFC3339 timestamp provided." -// @Param untilDate query string false "Transactions before and at this date. Ignores exact time, matches on the day of the RFC3339 timestamp provided." -// @Param amount query string false "Filter by amount" -// @Param amountLessOrEqual query string false "Amount less than or equal to this" -// @Param amountMoreOrEqual query string false "Amount more than or equal to this" -// @Param note query string false "Filter by note" -// @Param budget query string false "Filter by budget ID" -// @Param account query string false "Filter by ID of associated account, regardeless of source or destination" -// @Param source query string false "Filter by source account ID" -// @Param destination query string false "Filter by destination account ID" -// @Param envelope query string false "Filter by envelope ID" -// @Param reconciledSource query bool false "Reconcilication state in source account" -// @Param reconciledDestination query bool false "Reconcilication state in destination account" -// @Param offset query uint false "The offset of the first Transaction returned. Defaults to 0." -// @Param limit query int false "Maximum number of Transactions to return. Defaults to 50." +// @Param date query string false "Date of the transaction. Ignores exact time, matches on the day of the RFC3339 timestamp provided." +// @Param fromDate query string false "Transactions at and after this date. Ignores exact time, matches on the day of the RFC3339 timestamp provided." +// @Param untilDate query string false "Transactions before and at this date. Ignores exact time, matches on the day of the RFC3339 timestamp provided." +// @Param amount query string false "Filter by amount" +// @Param amountLessOrEqual query string false "Amount less than or equal to this" +// @Param amountMoreOrEqual query string false "Amount more than or equal to this" +// @Param note query string false "Filter by note" +// @Param budget query string false "Filter by budget ID" +// @Param account query string false "Filter by ID of associated account, regardeless of source or destination" +// @Param source query string false "Filter by source account ID" +// @Param destination query string false "Filter by destination account ID" +// @Param direction query TransactionDirection false "Filter by direction of transaction" +// @Param envelope query string false "Filter by envelope ID" +// @Param reconciledSource query bool false "Reconcilication state in source account" +// @Param reconciledDestination query bool false "Reconcilication state in destination account" +// @Param offset query uint false "The offset of the first Transaction returned. Defaults to 0." +// @Param limit query int false "Maximum number of Transactions to return. Defaults to 50." func GetTransactions(c *gin.Context) { var filter TransactionQueryFilter if err := c.Bind(&filter); err != nil { @@ -181,7 +182,7 @@ func GetTransactions(c *gin.Context) { // We join on the source account ID since all resources need to belong to the // same budget anyways q = q. - Joins("JOIN accounts on accounts.id = transactions.source_account_id "). + Joins("JOIN accounts on accounts.id = transactions.source_account_id"). Joins("JOIN budgets on budgets.id = accounts.budget_id"). Where("budgets.id = ?", budgetID) } @@ -203,6 +204,39 @@ func GetTransactions(c *gin.Context) { })) } + if filter.Direction != "" { + if !slices.Contains([]TransactionDirection{DirectionIncoming, DirectionOutgoing, DirectionTransfer}, filter.Direction) { + s := errTransactionDirectionInvalid.Error() + c.JSON(http.StatusBadRequest, TransactionListResponse{ + Error: &s, + }) + } + + if filter.Direction == DirectionTransfer { + // Transfers are internal account to internal account + q = q. + Joins("JOIN accounts AS accounts_source on accounts_source.id = transactions.source_account_id"). + Joins("JOIN accounts AS accounts_destination on accounts_destination.id = transactions.destination_account_id"). + Where("accounts_source.external = false AND accounts_destination.external = false") + } + + if filter.Direction == DirectionIncoming { + // Incoming is off-budget (external accounts are enforced to be off-budget) to on-budget accounts + q = q. + Joins("JOIN accounts AS accounts_source on accounts_source.id = transactions.source_account_id"). + Joins("JOIN accounts AS accounts_destination on accounts_destination.id = transactions.destination_account_id"). + Where("accounts_source.on_budget = false AND accounts_destination.on_budget = true") + } + + if filter.Direction == DirectionOutgoing { + // Outgoing is on-budget to off-budget accounts (external accounts are enforced to be off-budget) + q = q. + Joins("JOIN accounts AS accounts_source on accounts_source.id = transactions.source_account_id"). + Joins("JOIN accounts AS accounts_destination on accounts_destination.id = transactions.destination_account_id"). + Where("accounts_source.on_budget = true AND accounts_destination.on_budget = false") + } + } + if !filter.AmountLessOrEqual.IsZero() { q = q.Where("transactions.amount <= ?", filter.AmountLessOrEqual) } diff --git a/pkg/controllers/v4/transaction_test.go b/pkg/controllers/v4/transaction_test.go index 17630613..09685cc6 100644 --- a/pkg/controllers/v4/transaction_test.go +++ b/pkg/controllers/v4/transaction_test.go @@ -169,9 +169,9 @@ func (suite *TestSuiteStandard) TestTransactionsGet() { func (suite *TestSuiteStandard) TestTransactionsGetFilter() { b := createTestBudget(suite.T(), v4.BudgetEditable{}) - a1 := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: b.Data.ID, Name: "TestTransactionsGetFilter 1"}) - a2 := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: b.Data.ID, Name: "TestTransactionsGetFilter 2"}) - a3 := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: b.Data.ID, Name: "TestTransactionsGetFilter 3"}) + a1 := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: b.Data.ID, Name: "TestTransactionsGetFilter 1", OnBudget: true}) + a2 := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: b.Data.ID, Name: "TestTransactionsGetFilter 2", External: true}) + a3 := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: b.Data.ID, Name: "TestTransactionsGetFilter 3", OnBudget: true}) c := createTestCategory(suite.T(), v4.CategoryEditable{BudgetID: b.Data.ID}) @@ -199,7 +199,7 @@ func (suite *TestSuiteStandard) TestTransactionsGetFilter() { EnvelopeID: e2ID, SourceAccountID: a2.Data.ID, DestinationAccountID: a1.Data.ID, - ReconciledSource: true, + ReconciledSource: false, ReconciledDestination: true, }) @@ -211,7 +211,7 @@ func (suite *TestSuiteStandard) TestTransactionsGetFilter() { SourceAccountID: a3.Data.ID, DestinationAccountID: a2.Data.ID, ReconciledSource: false, - ReconciledDestination: true, + ReconciledDestination: false, }) tests := []struct { @@ -236,6 +236,9 @@ func (suite *TestSuiteStandard) TestTransactionsGetFilter() { {"Budget Match", fmt.Sprintf("budget=%s", b.Data.ID), 3}, {"Budget and Note", fmt.Sprintf("budget=%s¬e=Not", b.Data.ID), 1}, {"Destination Account", fmt.Sprintf("destination=%s", a2.Data.ID), 2}, + {"Direction=TRANSFER and Budget ID", fmt.Sprintf("budget=%s&direction=TRANSFER", b.Data.ID), 0}, + {"Direction=INCOMING", "direction=INCOMING", 1}, + {"Direction=OUTGOING", "direction=OUTGOING", 2}, {"Envelope 2", fmt.Sprintf("envelope=%s", e2.Data.ID), 1}, {"Exact Amount", fmt.Sprintf("amount=%s", decimal.NewFromFloat(2.718).String()), 2}, {"Exact Time", fmt.Sprintf("date=%s", time.Date(2021, 2, 6, 5, 1, 0, 585, time.UTC).Format(time.RFC3339Nano)), 1}, @@ -252,15 +255,15 @@ func (suite *TestSuiteStandard) TestTransactionsGetFilter() { {"No note", "note=", 1}, {"Non-existing Account", "account=534a3562-c5e8-46d1-a2e2-e96c00e7efec", 0}, {"Non-existing Source Account", "source=3340a084-acf8-4cb4-8f86-9e7f88a86190", 0}, - {"Not reconciled in destination account", "reconciledDestination=false", 1}, - {"Not reconciled in source account", "reconciledSource=false", 1}, + {"Not reconciled in destination account", "reconciledDestination=false", 2}, + {"Not reconciled in source account", "reconciledSource=false", 2}, {"Note", "note=Not important", 1}, {"Offset and Fuzzy Note", "offset=2¬e=important", 0}, {"Offset higher than number", "offset=5", 0}, {"Offset positive", "offset=2", 1}, {"Offset zero", "offset=0", 3}, - {"Reconciled in destination account", "reconciledDestination=true", 2}, - {"Reconciled in source account", "reconciledSource=true", 2}, + {"Reconciled in destination account", "reconciledDestination=true", 1}, + {"Reconciled in source account", "reconciledSource=true", 1}, {"Regression - For 'account', query needs to be ORed between the accounts and ANDed with all other conditions", fmt.Sprintf("note=&account=%s", a2.Data.ID), 1}, {"Regression #749", fmt.Sprintf("untilDate=%s", time.Date(2021, 2, 6, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 3}, {"Same date", fmt.Sprintf("date=%s", time.Date(2021, 2, 6, 7, 0, 0, 700, time.UTC).Format(time.RFC3339Nano)), 1}, @@ -289,8 +292,9 @@ func (suite *TestSuiteStandard) TestTransactionsGetInvalidQuery() { "amount=Seventeen Cents", "reconciledSource=I don't think so", "account=ItIsAHippo!", - "offset=-1", // offset is a uint - "limit=name", // limit is an int + "offset=-1", // offset is a uint + "limit=name", // limit is an int + "direction=reverse", // direction needs to be a TransactionDirection } for _, tt := range tests { diff --git a/pkg/controllers/v4/transaction_types.go b/pkg/controllers/v4/transaction_types.go index b9cf71a1..1e7351df 100644 --- a/pkg/controllers/v4/transaction_types.go +++ b/pkg/controllers/v4/transaction_types.go @@ -110,23 +110,33 @@ type TransactionResponse struct { Data *Transaction `json:"data"` // The Transaction data, if creation was successful } +// swagger:enum TransactionDirection +type TransactionDirection string + +const ( + DirectionIncoming TransactionDirection = "INCOMING" + DirectionOutgoing TransactionDirection = "OUTGOING" + DirectionTransfer TransactionDirection = "TRANSFER" +) + type TransactionQueryFilter struct { - Date time.Time `form:"date" filterField:"false"` // Exact date. Time is ignored. - FromDate time.Time `form:"fromDate" filterField:"false"` // From this date. Time is ignored. - UntilDate time.Time `form:"untilDate" filterField:"false"` // Until this date. Time is ignored. - Amount decimal.Decimal `form:"amount"` // Exact amount - AmountLessOrEqual decimal.Decimal `form:"amountLessOrEqual" filterField:"false"` // Amount less than or equal to this - AmountMoreOrEqual decimal.Decimal `form:"amountMoreOrEqual" filterField:"false"` // Amount more than or equal to this - Note string `form:"note" filterField:"false"` // Note contains this string - BudgetID string `form:"budget" filterField:"false"` // ID of the budget - SourceAccountID string `form:"source"` // ID of the source account - DestinationAccountID string `form:"destination"` // ID of the destination account - EnvelopeID string `form:"envelope"` // ID of the envelope - ReconciledSource bool `form:"reconciledSource"` // Is the transaction reconciled in the source account? - ReconciledDestination bool `form:"reconciledDestination"` // Is the transaction reconciled in the destination account? - AccountID string `form:"account" filterField:"false"` // ID of either source or destination account - Offset uint `form:"offset" filterField:"false"` // The offset of the first Transaction returned. Defaults to 0. - Limit int `form:"limit" filterField:"false"` // Maximum number of transactions to return. Defaults to 50. + Date time.Time `form:"date" filterField:"false"` // Exact date. Time is ignored. + FromDate time.Time `form:"fromDate" filterField:"false"` // From this date. Time is ignored. + UntilDate time.Time `form:"untilDate" filterField:"false"` // Until this date. Time is ignored. + Amount decimal.Decimal `form:"amount"` // Exact amount + AmountLessOrEqual decimal.Decimal `form:"amountLessOrEqual" filterField:"false"` // Amount less than or equal to this + AmountMoreOrEqual decimal.Decimal `form:"amountMoreOrEqual" filterField:"false"` // Amount more than or equal to this + Note string `form:"note" filterField:"false"` // Note contains this string + BudgetID string `form:"budget" filterField:"false"` // ID of the budget + SourceAccountID string `form:"source"` // ID of the source account + DestinationAccountID string `form:"destination"` // ID of the destination account + Direction TransactionDirection `form:"direction" filterField:"false"` // Direction of the transaction + EnvelopeID string `form:"envelope"` // ID of the envelope + ReconciledSource bool `form:"reconciledSource"` // Is the transaction reconciled in the source account? + ReconciledDestination bool `form:"reconciledDestination"` // Is the transaction reconciled in the destination account? + AccountID string `form:"account" filterField:"false"` // ID of either source or destination account + Offset uint `form:"offset" filterField:"false"` // The offset of the first Transaction returned. Defaults to 0. + Limit int `form:"limit" filterField:"false"` // Maximum number of transactions to return. Defaults to 50. } func (f TransactionQueryFilter) model() (models.Transaction, error) {