Skip to content

Commit

Permalink
feat: implement transaction "direction" filter (#988)
Browse files Browse the repository at this point in the history
  • Loading branch information
morremeyer authored Mar 9, 2024
1 parent 241494e commit e26fbba
Show file tree
Hide file tree
Showing 8 changed files with 133 additions and 49 deletions.
11 changes: 11 additions & 0 deletions api/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions api/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions api/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions pkg/controllers/v4/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
)
3 changes: 2 additions & 1 deletion pkg/controllers/v4/month.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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),
})
Expand Down
68 changes: 51 additions & 17 deletions pkg/controllers/v4/transaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
26 changes: 15 additions & 11 deletions pkg/controllers/v4/transaction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})

Expand Down Expand Up @@ -199,7 +199,7 @@ func (suite *TestSuiteStandard) TestTransactionsGetFilter() {
EnvelopeID: e2ID,
SourceAccountID: a2.Data.ID,
DestinationAccountID: a1.Data.ID,
ReconciledSource: true,
ReconciledSource: false,
ReconciledDestination: true,
})

Expand All @@ -211,7 +211,7 @@ func (suite *TestSuiteStandard) TestTransactionsGetFilter() {
SourceAccountID: a3.Data.ID,
DestinationAccountID: a2.Data.ID,
ReconciledSource: false,
ReconciledDestination: true,
ReconciledDestination: false,
})

tests := []struct {
Expand All @@ -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&note=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},
Expand All @@ -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&note=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},
Expand Down Expand Up @@ -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 {
Expand Down
42 changes: 26 additions & 16 deletions pkg/controllers/v4/transaction_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down

0 comments on commit e26fbba

Please sign in to comment.