Skip to content

Commit e26fbba

Browse files
authored
feat: implement transaction "direction" filter (#988)
1 parent 241494e commit e26fbba

File tree

8 files changed

+133
-49
lines changed

8 files changed

+133
-49
lines changed

api/docs.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2732,6 +2732,17 @@ const docTemplate = `{
27322732
"name": "destination",
27332733
"in": "query"
27342734
},
2735+
{
2736+
"enum": [
2737+
"INCOMING",
2738+
"OUTGOING",
2739+
"TRANSFER"
2740+
],
2741+
"type": "string",
2742+
"description": "Filter by direction of transaction",
2743+
"name": "direction",
2744+
"in": "query"
2745+
},
27352746
{
27362747
"type": "string",
27372748
"description": "Filter by envelope ID",

api/swagger.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2721,6 +2721,17 @@
27212721
"name": "destination",
27222722
"in": "query"
27232723
},
2724+
{
2725+
"enum": [
2726+
"INCOMING",
2727+
"OUTGOING",
2728+
"TRANSFER"
2729+
],
2730+
"type": "string",
2731+
"description": "Filter by direction of transaction",
2732+
"name": "direction",
2733+
"in": "query"
2734+
},
27242735
{
27252736
"type": "string",
27262737
"description": "Filter by envelope ID",

api/swagger.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3219,6 +3219,14 @@ paths:
32193219
in: query
32203220
name: destination
32213221
type: string
3222+
- description: Filter by direction of transaction
3223+
enum:
3224+
- INCOMING
3225+
- OUTGOING
3226+
- TRANSFER
3227+
in: query
3228+
name: direction
3229+
type: string
32223230
- description: Filter by envelope ID
32233231
in: query
32243232
name: envelope

pkg/controllers/v4/errors.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,14 @@ func status(err error) int {
2424
return http.StatusBadRequest
2525
}
2626

27-
// Cleanup errors
2827
var (
29-
errCleanupConfirmation = errors.New("the confirmation for the cleanup API call was incorrect")
28+
errAccountIDParameter = errors.New("the accountId parameter must be set")
29+
errMonthNotSetInQuery = errors.New("the month query parameter must be set")
3030
)
3131

32+
// Cleanup errors
3233
var (
33-
errAccountIDParameter = errors.New("the accountId parameter must be set")
34-
errMonthNotSetInQuery = errors.New("the month query parameter must be set")
34+
errCleanupConfirmation = errors.New("the confirmation for the cleanup API call was incorrect")
3535
)
3636

3737
// Import errors
@@ -41,3 +41,8 @@ var (
4141
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")
4242
errBudgetNameNotSet = errors.New("the budgetName parameter must be set")
4343
)
44+
45+
// Transaction errors
46+
var (
47+
errTransactionDirectionInvalid = errors.New("the specified transaction direction is invalid")
48+
)

pkg/controllers/v4/month.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/gin-gonic/gin"
1212
"github.com/google/uuid"
1313
"github.com/shopspring/decimal"
14+
"golang.org/x/exp/slices"
1415
"gorm.io/gorm"
1516
)
1617

@@ -305,7 +306,7 @@ func SetAllocations(c *gin.Context) {
305306
return
306307
}
307308

308-
if data.Mode != AllocateLastMonthBudget && data.Mode != AllocateLastMonthSpend {
309+
if !slices.Contains([]AllocationMode{AllocateLastMonthBudget, AllocateLastMonthSpend}, data.Mode) {
309310
c.JSON(http.StatusBadRequest, httpError{
310311
Error: fmt.Sprintf("The mode must be %s or %s", AllocateLastMonthBudget, AllocateLastMonthSpend),
311312
})

pkg/controllers/v4/transaction.go

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -112,22 +112,23 @@ func GetTransaction(c *gin.Context) {
112112
// @Failure 400 {object} TransactionListResponse
113113
// @Failure 500 {object} TransactionListResponse
114114
// @Router /v4/transactions [get]
115-
// @Param date query string false "Date of the transaction. Ignores exact time, matches on the day of the RFC3339 timestamp provided."
116-
// @Param fromDate query string false "Transactions at and after this date. Ignores exact time, matches on the day of the RFC3339 timestamp provided."
117-
// @Param untilDate query string false "Transactions before and at this date. Ignores exact time, matches on the day of the RFC3339 timestamp provided."
118-
// @Param amount query string false "Filter by amount"
119-
// @Param amountLessOrEqual query string false "Amount less than or equal to this"
120-
// @Param amountMoreOrEqual query string false "Amount more than or equal to this"
121-
// @Param note query string false "Filter by note"
122-
// @Param budget query string false "Filter by budget ID"
123-
// @Param account query string false "Filter by ID of associated account, regardeless of source or destination"
124-
// @Param source query string false "Filter by source account ID"
125-
// @Param destination query string false "Filter by destination account ID"
126-
// @Param envelope query string false "Filter by envelope ID"
127-
// @Param reconciledSource query bool false "Reconcilication state in source account"
128-
// @Param reconciledDestination query bool false "Reconcilication state in destination account"
129-
// @Param offset query uint false "The offset of the first Transaction returned. Defaults to 0."
130-
// @Param limit query int false "Maximum number of Transactions to return. Defaults to 50."
115+
// @Param date query string false "Date of the transaction. Ignores exact time, matches on the day of the RFC3339 timestamp provided."
116+
// @Param fromDate query string false "Transactions at and after this date. Ignores exact time, matches on the day of the RFC3339 timestamp provided."
117+
// @Param untilDate query string false "Transactions before and at this date. Ignores exact time, matches on the day of the RFC3339 timestamp provided."
118+
// @Param amount query string false "Filter by amount"
119+
// @Param amountLessOrEqual query string false "Amount less than or equal to this"
120+
// @Param amountMoreOrEqual query string false "Amount more than or equal to this"
121+
// @Param note query string false "Filter by note"
122+
// @Param budget query string false "Filter by budget ID"
123+
// @Param account query string false "Filter by ID of associated account, regardeless of source or destination"
124+
// @Param source query string false "Filter by source account ID"
125+
// @Param destination query string false "Filter by destination account ID"
126+
// @Param direction query TransactionDirection false "Filter by direction of transaction"
127+
// @Param envelope query string false "Filter by envelope ID"
128+
// @Param reconciledSource query bool false "Reconcilication state in source account"
129+
// @Param reconciledDestination query bool false "Reconcilication state in destination account"
130+
// @Param offset query uint false "The offset of the first Transaction returned. Defaults to 0."
131+
// @Param limit query int false "Maximum number of Transactions to return. Defaults to 50."
131132
func GetTransactions(c *gin.Context) {
132133
var filter TransactionQueryFilter
133134
if err := c.Bind(&filter); err != nil {
@@ -181,7 +182,7 @@ func GetTransactions(c *gin.Context) {
181182
// We join on the source account ID since all resources need to belong to the
182183
// same budget anyways
183184
q = q.
184-
Joins("JOIN accounts on accounts.id = transactions.source_account_id ").
185+
Joins("JOIN accounts on accounts.id = transactions.source_account_id").
185186
Joins("JOIN budgets on budgets.id = accounts.budget_id").
186187
Where("budgets.id = ?", budgetID)
187188
}
@@ -203,6 +204,39 @@ func GetTransactions(c *gin.Context) {
203204
}))
204205
}
205206

207+
if filter.Direction != "" {
208+
if !slices.Contains([]TransactionDirection{DirectionIncoming, DirectionOutgoing, DirectionTransfer}, filter.Direction) {
209+
s := errTransactionDirectionInvalid.Error()
210+
c.JSON(http.StatusBadRequest, TransactionListResponse{
211+
Error: &s,
212+
})
213+
}
214+
215+
if filter.Direction == DirectionTransfer {
216+
// Transfers are internal account to internal account
217+
q = q.
218+
Joins("JOIN accounts AS accounts_source on accounts_source.id = transactions.source_account_id").
219+
Joins("JOIN accounts AS accounts_destination on accounts_destination.id = transactions.destination_account_id").
220+
Where("accounts_source.external = false AND accounts_destination.external = false")
221+
}
222+
223+
if filter.Direction == DirectionIncoming {
224+
// Incoming is off-budget (external accounts are enforced to be off-budget) to on-budget accounts
225+
q = q.
226+
Joins("JOIN accounts AS accounts_source on accounts_source.id = transactions.source_account_id").
227+
Joins("JOIN accounts AS accounts_destination on accounts_destination.id = transactions.destination_account_id").
228+
Where("accounts_source.on_budget = false AND accounts_destination.on_budget = true")
229+
}
230+
231+
if filter.Direction == DirectionOutgoing {
232+
// Outgoing is on-budget to off-budget accounts (external accounts are enforced to be off-budget)
233+
q = q.
234+
Joins("JOIN accounts AS accounts_source on accounts_source.id = transactions.source_account_id").
235+
Joins("JOIN accounts AS accounts_destination on accounts_destination.id = transactions.destination_account_id").
236+
Where("accounts_source.on_budget = true AND accounts_destination.on_budget = false")
237+
}
238+
}
239+
206240
if !filter.AmountLessOrEqual.IsZero() {
207241
q = q.Where("transactions.amount <= ?", filter.AmountLessOrEqual)
208242
}

pkg/controllers/v4/transaction_test.go

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -169,9 +169,9 @@ func (suite *TestSuiteStandard) TestTransactionsGet() {
169169
func (suite *TestSuiteStandard) TestTransactionsGetFilter() {
170170
b := createTestBudget(suite.T(), v4.BudgetEditable{})
171171

172-
a1 := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: b.Data.ID, Name: "TestTransactionsGetFilter 1"})
173-
a2 := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: b.Data.ID, Name: "TestTransactionsGetFilter 2"})
174-
a3 := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: b.Data.ID, Name: "TestTransactionsGetFilter 3"})
172+
a1 := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: b.Data.ID, Name: "TestTransactionsGetFilter 1", OnBudget: true})
173+
a2 := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: b.Data.ID, Name: "TestTransactionsGetFilter 2", External: true})
174+
a3 := createTestAccount(suite.T(), v4.AccountEditable{BudgetID: b.Data.ID, Name: "TestTransactionsGetFilter 3", OnBudget: true})
175175

176176
c := createTestCategory(suite.T(), v4.CategoryEditable{BudgetID: b.Data.ID})
177177

@@ -199,7 +199,7 @@ func (suite *TestSuiteStandard) TestTransactionsGetFilter() {
199199
EnvelopeID: e2ID,
200200
SourceAccountID: a2.Data.ID,
201201
DestinationAccountID: a1.Data.ID,
202-
ReconciledSource: true,
202+
ReconciledSource: false,
203203
ReconciledDestination: true,
204204
})
205205

@@ -211,7 +211,7 @@ func (suite *TestSuiteStandard) TestTransactionsGetFilter() {
211211
SourceAccountID: a3.Data.ID,
212212
DestinationAccountID: a2.Data.ID,
213213
ReconciledSource: false,
214-
ReconciledDestination: true,
214+
ReconciledDestination: false,
215215
})
216216

217217
tests := []struct {
@@ -236,6 +236,9 @@ func (suite *TestSuiteStandard) TestTransactionsGetFilter() {
236236
{"Budget Match", fmt.Sprintf("budget=%s", b.Data.ID), 3},
237237
{"Budget and Note", fmt.Sprintf("budget=%s&note=Not", b.Data.ID), 1},
238238
{"Destination Account", fmt.Sprintf("destination=%s", a2.Data.ID), 2},
239+
{"Direction=TRANSFER and Budget ID", fmt.Sprintf("budget=%s&direction=TRANSFER", b.Data.ID), 0},
240+
{"Direction=INCOMING", "direction=INCOMING", 1},
241+
{"Direction=OUTGOING", "direction=OUTGOING", 2},
239242
{"Envelope 2", fmt.Sprintf("envelope=%s", e2.Data.ID), 1},
240243
{"Exact Amount", fmt.Sprintf("amount=%s", decimal.NewFromFloat(2.718).String()), 2},
241244
{"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() {
252255
{"No note", "note=", 1},
253256
{"Non-existing Account", "account=534a3562-c5e8-46d1-a2e2-e96c00e7efec", 0},
254257
{"Non-existing Source Account", "source=3340a084-acf8-4cb4-8f86-9e7f88a86190", 0},
255-
{"Not reconciled in destination account", "reconciledDestination=false", 1},
256-
{"Not reconciled in source account", "reconciledSource=false", 1},
258+
{"Not reconciled in destination account", "reconciledDestination=false", 2},
259+
{"Not reconciled in source account", "reconciledSource=false", 2},
257260
{"Note", "note=Not important", 1},
258261
{"Offset and Fuzzy Note", "offset=2&note=important", 0},
259262
{"Offset higher than number", "offset=5", 0},
260263
{"Offset positive", "offset=2", 1},
261264
{"Offset zero", "offset=0", 3},
262-
{"Reconciled in destination account", "reconciledDestination=true", 2},
263-
{"Reconciled in source account", "reconciledSource=true", 2},
265+
{"Reconciled in destination account", "reconciledDestination=true", 1},
266+
{"Reconciled in source account", "reconciledSource=true", 1},
264267
{"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},
265268
{"Regression #749", fmt.Sprintf("untilDate=%s", time.Date(2021, 2, 6, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 3},
266269
{"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() {
289292
"amount=Seventeen Cents",
290293
"reconciledSource=I don't think so",
291294
"account=ItIsAHippo!",
292-
"offset=-1", // offset is a uint
293-
"limit=name", // limit is an int
295+
"offset=-1", // offset is a uint
296+
"limit=name", // limit is an int
297+
"direction=reverse", // direction needs to be a TransactionDirection
294298
}
295299

296300
for _, tt := range tests {

pkg/controllers/v4/transaction_types.go

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -110,23 +110,33 @@ type TransactionResponse struct {
110110
Data *Transaction `json:"data"` // The Transaction data, if creation was successful
111111
}
112112

113+
// swagger:enum TransactionDirection
114+
type TransactionDirection string
115+
116+
const (
117+
DirectionIncoming TransactionDirection = "INCOMING"
118+
DirectionOutgoing TransactionDirection = "OUTGOING"
119+
DirectionTransfer TransactionDirection = "TRANSFER"
120+
)
121+
113122
type TransactionQueryFilter struct {
114-
Date time.Time `form:"date" filterField:"false"` // Exact date. Time is ignored.
115-
FromDate time.Time `form:"fromDate" filterField:"false"` // From this date. Time is ignored.
116-
UntilDate time.Time `form:"untilDate" filterField:"false"` // Until this date. Time is ignored.
117-
Amount decimal.Decimal `form:"amount"` // Exact amount
118-
AmountLessOrEqual decimal.Decimal `form:"amountLessOrEqual" filterField:"false"` // Amount less than or equal to this
119-
AmountMoreOrEqual decimal.Decimal `form:"amountMoreOrEqual" filterField:"false"` // Amount more than or equal to this
120-
Note string `form:"note" filterField:"false"` // Note contains this string
121-
BudgetID string `form:"budget" filterField:"false"` // ID of the budget
122-
SourceAccountID string `form:"source"` // ID of the source account
123-
DestinationAccountID string `form:"destination"` // ID of the destination account
124-
EnvelopeID string `form:"envelope"` // ID of the envelope
125-
ReconciledSource bool `form:"reconciledSource"` // Is the transaction reconciled in the source account?
126-
ReconciledDestination bool `form:"reconciledDestination"` // Is the transaction reconciled in the destination account?
127-
AccountID string `form:"account" filterField:"false"` // ID of either source or destination account
128-
Offset uint `form:"offset" filterField:"false"` // The offset of the first Transaction returned. Defaults to 0.
129-
Limit int `form:"limit" filterField:"false"` // Maximum number of transactions to return. Defaults to 50.
123+
Date time.Time `form:"date" filterField:"false"` // Exact date. Time is ignored.
124+
FromDate time.Time `form:"fromDate" filterField:"false"` // From this date. Time is ignored.
125+
UntilDate time.Time `form:"untilDate" filterField:"false"` // Until this date. Time is ignored.
126+
Amount decimal.Decimal `form:"amount"` // Exact amount
127+
AmountLessOrEqual decimal.Decimal `form:"amountLessOrEqual" filterField:"false"` // Amount less than or equal to this
128+
AmountMoreOrEqual decimal.Decimal `form:"amountMoreOrEqual" filterField:"false"` // Amount more than or equal to this
129+
Note string `form:"note" filterField:"false"` // Note contains this string
130+
BudgetID string `form:"budget" filterField:"false"` // ID of the budget
131+
SourceAccountID string `form:"source"` // ID of the source account
132+
DestinationAccountID string `form:"destination"` // ID of the destination account
133+
Direction TransactionDirection `form:"direction" filterField:"false"` // Direction of the transaction
134+
EnvelopeID string `form:"envelope"` // ID of the envelope
135+
ReconciledSource bool `form:"reconciledSource"` // Is the transaction reconciled in the source account?
136+
ReconciledDestination bool `form:"reconciledDestination"` // Is the transaction reconciled in the destination account?
137+
AccountID string `form:"account" filterField:"false"` // ID of either source or destination account
138+
Offset uint `form:"offset" filterField:"false"` // The offset of the first Transaction returned. Defaults to 0.
139+
Limit int `form:"limit" filterField:"false"` // Maximum number of transactions to return. Defaults to 50.
130140
}
131141

132142
func (f TransactionQueryFilter) model() (models.Transaction, error) {

0 commit comments

Comments
 (0)