Skip to content

Commit 3e3191a

Browse files
authored
fix: transaction filtering by direction and type (#1101)
Transaction filtering was incomplete and wrong so far, which is fixed with this change. There are now two different filters, direction and type. direction filters by the source/destination accounts being internal or external. It has the following values: IN: Transaction from an external to an internal account OUT: Transaction from an internal account to an external account INTERNAL: Transaction from an internal to an internal account (same as the old direction: TRANSFER) type filters by the effect a transaction has on the budget. It has the following values INCOME: From an off-budget to an on-budget account (same as the old direction: INCOMING) SPEND: From an on-budget to an off-budget account (same as the old direction: OUTGOING) TRANSFER: From an on-budget to an on-budget account
1 parent 51ba679 commit 3e3191a

File tree

7 files changed

+113
-52
lines changed

7 files changed

+113
-52
lines changed

api/docs.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2871,9 +2871,9 @@ const docTemplate = `{
28712871
},
28722872
{
28732873
"enum": [
2874-
"INCOMING",
2875-
"OUTGOING",
2876-
"TRANSFER"
2874+
"IN",
2875+
"OUT",
2876+
"INTERNAL"
28772877
],
28782878
"type": "string",
28792879
"description": "Filter by direction of transaction",

api/swagger.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2860,9 +2860,9 @@
28602860
},
28612861
{
28622862
"enum": [
2863-
"INCOMING",
2864-
"OUTGOING",
2865-
"TRANSFER"
2863+
"IN",
2864+
"OUT",
2865+
"INTERNAL"
28662866
],
28672867
"type": "string",
28682868
"description": "Filter by direction of transaction",

api/swagger.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3350,9 +3350,9 @@ paths:
33503350
type: string
33513351
- description: Filter by direction of transaction
33523352
enum:
3353-
- INCOMING
3354-
- OUTGOING
3355-
- TRANSFER
3353+
- IN
3354+
- OUT
3355+
- INTERNAL
33563356
in: query
33573357
name: direction
33583358
type: string

pkg/controllers/v4/errors.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,5 @@ var (
4545
// Transaction errors
4646
var (
4747
errTransactionDirectionInvalid = errors.New("the specified transaction direction is invalid")
48+
errTransactionTypeInvalid = errors.New("the specified transaction type is invalid")
4849
)

pkg/controllers/v4/transaction.go

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -207,36 +207,70 @@ func GetTransactions(c *gin.Context) {
207207
}
208208

209209
if filter.Direction != "" {
210-
if !slices.Contains([]TransactionDirection{DirectionIncoming, DirectionOutgoing, DirectionTransfer}, filter.Direction) {
210+
if !slices.Contains([]TransactionDirection{DirectionIn, DirectionOut, DirectionInternal}, filter.Direction) {
211211
s := errTransactionDirectionInvalid.Error()
212212
c.JSON(http.StatusBadRequest, TransactionListResponse{
213213
Error: &s,
214214
})
215215
return
216216
}
217217

218-
if filter.Direction == DirectionTransfer {
219-
// Transfers are internal account to internal account
218+
// Internal transactions are internal account to internal account
219+
if filter.Direction == DirectionInternal {
220220
q = q.
221-
Joins("JOIN accounts AS accounts_source on accounts_source.id = transactions.source_account_id").
222-
Joins("JOIN accounts AS accounts_destination on accounts_destination.id = transactions.destination_account_id").
223-
Where("accounts_source.external = false AND accounts_destination.external = false")
221+
Joins("JOIN accounts AS direction_accounts_source on direction_accounts_source.id = transactions.source_account_id").
222+
Joins("JOIN accounts AS direction_accounts_destination on direction_accounts_destination.id = transactions.destination_account_id").
223+
Where("direction_accounts_source.external = false AND direction_accounts_destination.external = false")
224224
}
225225

226-
if filter.Direction == DirectionIncoming {
227-
// Incoming is off-budget (external accounts are enforced to be off-budget) to on-budget accounts
226+
// Transactions going in are external account to internal account
227+
if filter.Direction == DirectionIn {
228228
q = q.
229-
Joins("JOIN accounts AS accounts_source on accounts_source.id = transactions.source_account_id").
230-
Joins("JOIN accounts AS accounts_destination on accounts_destination.id = transactions.destination_account_id").
231-
Where("accounts_source.on_budget = false AND accounts_destination.on_budget = true")
229+
Joins("JOIN accounts AS direction_accounts_source on direction_accounts_source.id = transactions.source_account_id").
230+
Joins("JOIN accounts AS direction_accounts_destination on direction_accounts_destination.id = transactions.destination_account_id").
231+
Where("direction_accounts_source.external = true AND direction_accounts_destination.external = false")
232232
}
233233

234-
if filter.Direction == DirectionOutgoing {
235-
// Outgoing is on-budget to off-budget accounts (external accounts are enforced to be off-budget)
234+
// Transactions going out are internal account to external account
235+
if filter.Direction == DirectionOut {
236236
q = q.
237-
Joins("JOIN accounts AS accounts_source on accounts_source.id = transactions.source_account_id").
238-
Joins("JOIN accounts AS accounts_destination on accounts_destination.id = transactions.destination_account_id").
239-
Where("accounts_source.on_budget = true AND accounts_destination.on_budget = false")
237+
Joins("JOIN accounts AS direction_accounts_source on direction_accounts_source.id = transactions.source_account_id").
238+
Joins("JOIN accounts AS direction_accounts_destination on direction_accounts_destination.id = transactions.destination_account_id").
239+
Where("direction_accounts_source.external = false AND direction_accounts_destination.external = true")
240+
}
241+
}
242+
243+
if filter.Type != "" {
244+
if !slices.Contains([]TransactionType{TypeIncome, TypeSpend, TypeTransfer}, filter.Type) {
245+
s := errTransactionTypeInvalid.Error()
246+
c.JSON(http.StatusBadRequest, TransactionListResponse{
247+
Error: &s,
248+
})
249+
return
250+
}
251+
252+
// Income is coming from an off-budget to an on-budget account
253+
if filter.Type == TypeIncome {
254+
q = q.
255+
Joins("JOIN accounts AS type_accounts_source on type_accounts_source.id = transactions.source_account_id").
256+
Joins("JOIN accounts AS type_accounts_destination on type_accounts_destination.id = transactions.destination_account_id").
257+
Where("type_accounts_source.on_budget = false AND type_accounts_destination.on_budget = true")
258+
}
259+
260+
// Spend is going from an on-budget to an off-budget account
261+
if filter.Type == TypeSpend {
262+
q = q.
263+
Joins("JOIN accounts AS type_accounts_source on type_accounts_source.id = transactions.source_account_id").
264+
Joins("JOIN accounts AS type_accounts_destination on type_accounts_destination.id = transactions.destination_account_id").
265+
Where("type_accounts_source.on_budget = true AND type_accounts_destination.on_budget = false")
266+
}
267+
268+
// Transfers are going from an on-budget to an on-budget account
269+
if filter.Type == TypeTransfer {
270+
q = q.
271+
Joins("JOIN accounts AS type_accounts_source on type_accounts_source.id = transactions.source_account_id").
272+
Joins("JOIN accounts AS type_accounts_destination on type_accounts_destination.id = transactions.destination_account_id").
273+
Where("type_accounts_source.on_budget = true AND type_accounts_destination.on_budget = true")
240274
}
241275
}
242276

pkg/controllers/v4/transaction_test.go

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -216,60 +216,75 @@ func (suite *TestSuiteStandard) TestTransactionsGetFilter() {
216216
ReconciledDestination: false,
217217
})
218218

219+
_ = createTestTransaction(suite.T(), v4.TransactionEditable{
220+
Date: time.Date(2024, 11, 24, 0, 0, 0, 0, time.UTC),
221+
Amount: decimal.NewFromFloat(1),
222+
Note: "This is a transfer",
223+
EnvelopeID: nil,
224+
SourceAccountID: a1.Data.ID,
225+
DestinationAccountID: a3.Data.ID,
226+
ReconciledSource: false,
227+
ReconciledDestination: false,
228+
})
229+
219230
tests := []struct {
220231
name string
221232
query string
222233
len int
223234
}{
224-
{"After all dates", fmt.Sprintf("fromDate=%s", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 0},
225-
{"After date", fmt.Sprintf("fromDate=%s", time.Date(2017, 1, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 2},
226-
{"Amount less or equal to 2.71", "amountLessOrEqual=2.71", 0},
227-
{"Amount less or equal to 2.718", "amountLessOrEqual=2.718", 2},
228-
{"Amount less or equal to 1000", "amountLessOrEqual=1000", 2},
229-
{"Amount more or equal to 1 and less than 3", "amountMoreOrEqual=1&amountLessOrEqual=3", 2},
235+
{"After all dates", fmt.Sprintf("fromDate=%s", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 1},
236+
{"After date", fmt.Sprintf("fromDate=%s", time.Date(2017, 1, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 3},
237+
{"Amount less or equal to 2.71", "amountLessOrEqual=2.71", 1},
238+
{"Amount less or equal to 2.718", "amountLessOrEqual=2.718", 3},
239+
{"Amount less or equal to 1000", "amountLessOrEqual=1000", 3},
240+
{"Amount more or equal to 1 and less than 3", "amountMoreOrEqual=1&amountLessOrEqual=3", 3},
230241
{"Amount more or equal to 2.718", "amountMoreOrEqual=2.718", 3},
231242
{"Amount more or equal to 100 and less than 10", "amountMoreOrEqual=100&amountLessOrEqual=10", 0},
232243
{"Amount more or equal to 100", "amountMoreOrEqual=100", 1},
233244
{"Amount more or equal to 11235.813", "amountMoreOrEqual=11235.813", 1},
234245
{"Amount more or equal to 99999", "amountMoreOrEqual=99999", 0},
235-
{"Available after - no transactions", fmt.Sprintf("availableFromFromDate=%s", time.Date(2020, 7, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 1}, // Available from is only relevant for income, but set for all transactions
236-
{"Available after - returns transactions", fmt.Sprintf("availableFromFromDate=%s", time.Date(2000, 12, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 3}, // Available from is only relevant for income, but set for all transactions
246+
{"Available after - no transactions", fmt.Sprintf("availableFromFromDate=%s", time.Date(2020, 7, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 2}, // Available from is only relevant for income, but set for all transactions
247+
{"Available after - returns transactions", fmt.Sprintf("availableFromFromDate=%s", time.Date(2000, 12, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 4}, // Available from is only relevant for income, but set for all transactions
237248
{"Available at date - no transactions", fmt.Sprintf("availableFromDate=%s", time.Date(2016, 5, 2, 11, 17, 0, 0, time.UTC).Format(time.RFC3339Nano)), 0},
238249
{"Available at month - with transaction", fmt.Sprintf("availableFromDate=%s", time.Date(2016, 5, 1, 12, 53, 15, 148041, time.UTC).Format(time.RFC3339Nano)), 1},
239250
{"Available before - no transactions", fmt.Sprintf("availableFromUntilDate=%s", time.Date(2016, 4, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 0}, // Needs to be before 2016-05-01T00:00:00Z since that's what the transaction defaults to when created
240-
{"Available before - returns transactions", fmt.Sprintf("availableFromUntilDate=%s", time.Date(2024, 12, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 3}, // Available from is only relevant for income, but set for all transactions
251+
{"Available before - returns transactions", fmt.Sprintf("availableFromUntilDate=%s", time.Date(2024, 12, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 4}, // Available from is only relevant for income, but set for all transactions
241252
{"Before all dates", fmt.Sprintf("untilDate=%s", time.Date(2010, 8, 17, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 0},
242253
{"Before date", fmt.Sprintf("untilDate=%s", time.Date(2017, 1, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 1},
243254
{"Between two dates", fmt.Sprintf("untilDate=%s&fromDate=%s", time.Date(2019, 8, 17, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano), time.Date(2015, 12, 24, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 2},
244255
{"Budget and Note", fmt.Sprintf("budget=%s&note=Not", b.Data.ID), 1},
245-
{"Budget Match", fmt.Sprintf("budget=%s", b.Data.ID), 3},
256+
{"Budget Match", fmt.Sprintf("budget=%s", b.Data.ID), 4},
246257
{"Destination Account", fmt.Sprintf("destination=%s", a2.Data.ID), 2},
247-
{"Direction=INCOMING", "direction=INCOMING", 1},
248-
{"Direction=OUTGOING", "direction=OUTGOING", 2},
249-
{"Direction=TRANSFER and Budget ID", fmt.Sprintf("budget=%s&direction=TRANSFER", b.Data.ID), 0},
258+
{"Type=INCOME", "type=INCOME", 1},
259+
{"Type=SPEND", "type=SPEND", 2},
260+
{"Type=TRANSFER", "type=TRANSFER", 1},
261+
{"Direction=IN", "direction=IN", 1},
262+
{"Direction=OUT", "direction=OUT", 2},
263+
{"Direction=INTERNAL and Budget ID", fmt.Sprintf("budget=%s&direction=INTERNAL", b.Data.ID), 1},
264+
{"Direction=INTERNAL and TYPE=TRANSFER", "direction=INTERNAL&type=TRANSFER", 1}, // Testing both at the same time to ensure functioning SQL
250265
{"Envelope 2", fmt.Sprintf("envelope=%s", e2.Data.ID), 1},
251266
{"Exact Amount", fmt.Sprintf("amount=%s", decimal.NewFromFloat(2.718).String()), 2},
252267
{"Exact Date", fmt.Sprintf("date=%s", time.Date(2021, 2, 6, 5, 1, 0, 585, time.UTC).Format(time.RFC3339Nano)), 1},
253-
{"Existing Account 1", fmt.Sprintf("account=%s", a1.Data.ID), 2},
268+
{"Existing Account 1", fmt.Sprintf("account=%s", a1.Data.ID), 3},
254269
{"Existing Account 2", fmt.Sprintf("account=%s", a2.Data.ID), 3},
255270
{"Fuzzy note", "note=important", 2},
256271
{"Impossible between two dates", fmt.Sprintf("fromDate=%s&untilDate=%s", time.Date(2019, 8, 17, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano), time.Date(2015, 12, 24, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)), 0},
257272
{"Limit and Fuzzy Note", "limit=1&note=important", 1},
258273
{"Limit and Offset", "limit=1&offset=1", 1},
259-
{"Limit negative", "limit=-123", 3},
274+
{"Limit negative", "limit=-123", 4},
260275
{"Limit positive", "limit=2", 2},
261-
{"Limit unset", "limit=-1", 3},
276+
{"Limit unset", "limit=-1", 4},
262277
{"Limit zero", "limit=0", 0},
263278
{"No note", "note=", 1},
264279
{"Non-existing Account", "account=534a3562-c5e8-46d1-a2e2-e96c00e7efec", 0},
265280
{"Non-existing Source Account", "source=3340a084-acf8-4cb4-8f86-9e7f88a86190", 0},
266-
{"Not reconciled in destination account", "reconciledDestination=false", 2},
267-
{"Not reconciled in source account", "reconciledSource=false", 2},
281+
{"Not reconciled in destination account", "reconciledDestination=false", 3},
282+
{"Not reconciled in source account", "reconciledSource=false", 3},
268283
{"Note", "note=Not important", 1},
269284
{"Offset and Fuzzy Note", "offset=2&note=important", 0},
270285
{"Offset higher than number", "offset=5", 0},
271-
{"Offset positive", "offset=2", 1},
272-
{"Offset zero", "offset=0", 3},
286+
{"Offset positive", "offset=2", 2},
287+
{"Offset zero", "offset=0", 4},
273288
{"Reconciled in destination account", "reconciledDestination=true", 1},
274289
{"Reconciled in source account", "reconciledSource=true", 1},
275290
{"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},
@@ -300,9 +315,10 @@ func (suite *TestSuiteStandard) TestTransactionsGetInvalidQuery() {
300315
"amount=Seventeen Cents",
301316
"reconciledSource=I don't think so",
302317
"account=ItIsAHippo!",
303-
"offset=-1", // offset is a uint
304-
"limit=name", // limit is an int
305-
"direction=reverse", // direction needs to be a TransactionDirection
318+
"offset=-1", // offset is a uint
319+
"limit=name", // limit is an int
320+
"direction=external", // direction needs to be a TransactionDirection, external does not exist
321+
"type=winnings", // type needs to be a TransactionType, winnings don't exist (would be nice though, right?)
306322
}
307323

308324
for _, tt := range tests {

pkg/controllers/v4/transaction_types.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,18 @@ type TransactionResponse struct {
114114
type TransactionDirection string
115115

116116
const (
117-
DirectionIncoming TransactionDirection = "INCOMING"
118-
DirectionOutgoing TransactionDirection = "OUTGOING"
119-
DirectionTransfer TransactionDirection = "TRANSFER"
117+
DirectionIn TransactionDirection = "IN"
118+
DirectionOut TransactionDirection = "OUT"
119+
DirectionInternal TransactionDirection = "INTERNAL"
120+
)
121+
122+
// swagger:enum TransactionType
123+
type TransactionType string
124+
125+
const (
126+
TypeIncome TransactionType = "INCOME"
127+
TypeSpend TransactionType = "SPEND"
128+
TypeTransfer TransactionType = "TRANSFER"
120129
)
121130

122131
type TransactionQueryFilter struct {
@@ -133,7 +142,8 @@ type TransactionQueryFilter struct {
133142
BudgetID ez_uuid.UUID `form:"budget" filterField:"false"` // ID of the budget
134143
SourceAccountID ez_uuid.UUID `form:"source"` // ID of the source account
135144
DestinationAccountID ez_uuid.UUID `form:"destination"` // ID of the destination account
136-
Direction TransactionDirection `form:"direction" filterField:"false"` // Direction of the transaction
145+
Direction TransactionDirection `form:"direction" filterField:"false"` // Direction of the transaction - are involved accounts internal or external?
146+
Type TransactionType `form:"type" filterField:"false"` // Type of the transaction - the effect the transaction has on the budget
137147
EnvelopeID ez_uuid.UUID `form:"envelope"` // ID of the envelope
138148
ReconciledSource bool `form:"reconciledSource"` // Is the transaction reconciled in the source account?
139149
ReconciledDestination bool `form:"reconciledDestination"` // Is the transaction reconciled in the destination account?

0 commit comments

Comments
 (0)