Skip to content

Commit c105241

Browse files
authored
feat: add query filters for transactions (#282)
1 parent 1528040 commit c105241

File tree

6 files changed

+277
-5
lines changed

6 files changed

+277
-5
lines changed

api/docs.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1576,6 +1576,44 @@ const docTemplate = `{
15761576
"Transactions"
15771577
],
15781578
"summary": "Get transactions",
1579+
"parameters": [
1580+
{
1581+
"type": "string",
1582+
"description": "Filter by note",
1583+
"name": "note",
1584+
"in": "query"
1585+
},
1586+
{
1587+
"type": "string",
1588+
"description": "Filter by budget ID",
1589+
"name": "budget",
1590+
"in": "query"
1591+
},
1592+
{
1593+
"type": "string",
1594+
"description": "Filter by source account ID",
1595+
"name": "source",
1596+
"in": "query"
1597+
},
1598+
{
1599+
"type": "string",
1600+
"description": "Filter by destination account ID",
1601+
"name": "destination",
1602+
"in": "query"
1603+
},
1604+
{
1605+
"type": "string",
1606+
"description": "Filter by envelope ID",
1607+
"name": "envelope",
1608+
"in": "query"
1609+
},
1610+
{
1611+
"type": "boolean",
1612+
"description": "Filter by reconcilication state",
1613+
"name": "reconciled",
1614+
"in": "query"
1615+
}
1616+
],
15791617
"responses": {
15801618
"200": {
15811619
"description": "OK",

api/swagger.json

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1564,6 +1564,44 @@
15641564
"Transactions"
15651565
],
15661566
"summary": "Get transactions",
1567+
"parameters": [
1568+
{
1569+
"type": "string",
1570+
"description": "Filter by note",
1571+
"name": "note",
1572+
"in": "query"
1573+
},
1574+
{
1575+
"type": "string",
1576+
"description": "Filter by budget ID",
1577+
"name": "budget",
1578+
"in": "query"
1579+
},
1580+
{
1581+
"type": "string",
1582+
"description": "Filter by source account ID",
1583+
"name": "source",
1584+
"in": "query"
1585+
},
1586+
{
1587+
"type": "string",
1588+
"description": "Filter by destination account ID",
1589+
"name": "destination",
1590+
"in": "query"
1591+
},
1592+
{
1593+
"type": "string",
1594+
"description": "Filter by envelope ID",
1595+
"name": "envelope",
1596+
"in": "query"
1597+
},
1598+
{
1599+
"type": "boolean",
1600+
"description": "Filter by reconcilication state",
1601+
"name": "reconciled",
1602+
"in": "query"
1603+
}
1604+
],
15671605
"responses": {
15681606
"200": {
15691607
"description": "OK",

api/swagger.yaml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1619,6 +1619,31 @@ paths:
16191619
/v1/transactions:
16201620
get:
16211621
description: Returns a list of transactions
1622+
parameters:
1623+
- description: Filter by note
1624+
in: query
1625+
name: note
1626+
type: string
1627+
- description: Filter by budget ID
1628+
in: query
1629+
name: budget
1630+
type: string
1631+
- description: Filter by source account ID
1632+
in: query
1633+
name: source
1634+
type: string
1635+
- description: Filter by destination account ID
1636+
in: query
1637+
name: destination
1638+
type: string
1639+
- description: Filter by envelope ID
1640+
in: query
1641+
name: envelope
1642+
type: string
1643+
- description: Filter by reconcilication state
1644+
in: query
1645+
name: reconciled
1646+
type: boolean
16221647
produces:
16231648
- application/json
16241649
responses:

pkg/controllers/transaction.go

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"errors"
55
"fmt"
66
"net/http"
7+
"time"
78

89
"github.com/envelope-zero/backend/internal/database"
910
"github.com/envelope-zero/backend/pkg/httputil"
@@ -30,6 +31,56 @@ type TransactionLinks struct {
3031
Self string `json:"self" example:"https://example.com/api/v1/transactions/d430d7c3-d14c-4712-9336-ee56965a6673"`
3132
}
3233

34+
type TransactionQueryFilter struct {
35+
Date time.Time `form:"date"`
36+
Amount decimal.Decimal `form:"amount"`
37+
Note string `form:"note"`
38+
BudgetID string `form:"budget"`
39+
SourceAccountID string `form:"source"`
40+
DestinationAccountID string `form:"destination"`
41+
EnvelopeID string `form:"envelope"`
42+
Reconciled bool `form:"reconciled"`
43+
}
44+
45+
func (f TransactionQueryFilter) ToCreate(c *gin.Context) (models.TransactionCreate, error) {
46+
budgetID, err := httputil.UUIDFromString(c, f.BudgetID)
47+
if err != nil {
48+
return models.TransactionCreate{}, err
49+
}
50+
51+
sourceAccountID, err := httputil.UUIDFromString(c, f.SourceAccountID)
52+
if err != nil {
53+
return models.TransactionCreate{}, err
54+
}
55+
56+
destinationAccountID, err := httputil.UUIDFromString(c, f.DestinationAccountID)
57+
if err != nil {
58+
return models.TransactionCreate{}, err
59+
}
60+
61+
envelopeID, err := httputil.UUIDFromString(c, f.EnvelopeID)
62+
if err != nil {
63+
return models.TransactionCreate{}, err
64+
}
65+
66+
// If the envelopeID is nil, use an actual nil, not uuid.Nil
67+
var eID *uuid.UUID = nil
68+
if envelopeID != uuid.Nil {
69+
eID = &envelopeID
70+
}
71+
72+
return models.TransactionCreate{
73+
Date: f.Date,
74+
Amount: f.Amount,
75+
Note: f.Note,
76+
BudgetID: budgetID,
77+
SourceAccountID: sourceAccountID,
78+
DestinationAccountID: destinationAccountID,
79+
EnvelopeID: eID,
80+
Reconciled: f.Reconciled,
81+
}, nil
82+
}
83+
3384
// RegisterTransactionRoutes registers the routes for transactions with
3485
// the RouterGroup that is passed.
3586
func RegisterTransactionRoutes(r *gin.RouterGroup) {
@@ -142,10 +193,34 @@ func CreateTransaction(c *gin.Context) {
142193
// @Failure 404
143194
// @Failure 500 {object} httputil.HTTPError
144195
// @Router /v1/transactions [get]
196+
// @Param date query time.Time false "Filter by date"
197+
// @Param amount query decimal.Decimal false "Filter by amount"
198+
// @Param note query string false "Filter by note"
199+
// @Param budget query string false "Filter by budget ID"
200+
// @Param source query string false "Filter by source account ID"
201+
// @Param destination query string false "Filter by destination account ID"
202+
// @Param envelope query string false "Filter by envelope ID"
203+
// @Param reconciled query bool false "Filter by reconcilication state"
145204
func GetTransactions(c *gin.Context) {
146-
var transactions []models.Transaction
205+
var filter TransactionQueryFilter
206+
if err := c.Bind(&filter); err != nil {
207+
httputil.ErrorInvalidQueryString(c)
208+
return
209+
}
210+
211+
// Get the fields set in the filter
212+
queryFields := httputil.GetURLFields(c.Request.URL, filter)
147213

148-
database.DB.Order("date(date) DESC").Find(&transactions)
214+
// Convert the QueryFilter to a Create struct
215+
create, err := filter.ToCreate(c)
216+
if err != nil {
217+
return
218+
}
219+
220+
var transactions []models.Transaction
221+
database.DB.Order("date(date) DESC").Where(&models.Transaction{
222+
TransactionCreate: create,
223+
}, queryFields...).Find(&transactions)
149224

150225
// When there are no resources, we want an empty list, not null
151226
// Therefore, we use make to create a slice with zero elements

pkg/controllers/transaction_test.go

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,100 @@ func (suite *TestSuiteEnv) TestGetTransactions() {
6666
assert.Len(suite.T(), response.Data, 2)
6767
}
6868

69+
func (suite *TestSuiteEnv) TestGetTransactionsInvalidQuery() {
70+
tests := []string{
71+
"budget=DefinitelyACat",
72+
"source=MaybeADog",
73+
"destination=OrARat?",
74+
"envelope=NopeDefinitelyAMole",
75+
"date=A long time ago",
76+
"amount=Seventeen Cents",
77+
"reconciled=I don't think so",
78+
}
79+
80+
for _, tt := range tests {
81+
suite.T().Run(tt, func(t *testing.T) {
82+
recorder := test.Request(suite.T(), http.MethodGet, fmt.Sprintf("http://example.com/v1/transactions?%s", tt), "")
83+
test.AssertHTTPStatus(suite.T(), http.StatusBadRequest, &recorder)
84+
})
85+
}
86+
}
87+
88+
func (suite *TestSuiteEnv) TestGetTransactionsFilter() {
89+
b := createTestBudget(suite.T(), models.BudgetCreate{})
90+
91+
a1 := createTestAccount(suite.T(), models.AccountCreate{BudgetID: b.Data.ID})
92+
a2 := createTestAccount(suite.T(), models.AccountCreate{BudgetID: b.Data.ID})
93+
94+
c := createTestCategory(suite.T(), models.CategoryCreate{BudgetID: b.Data.ID})
95+
96+
e1 := createTestEnvelope(suite.T(), models.EnvelopeCreate{CategoryID: c.Data.ID})
97+
e2 := createTestEnvelope(suite.T(), models.EnvelopeCreate{CategoryID: c.Data.ID})
98+
99+
e1ID := &e1.Data.ID
100+
e2ID := &e2.Data.ID
101+
102+
_ = createTestTransaction(suite.T(), models.TransactionCreate{
103+
Date: time.Date(2018, 9, 5, 17, 13, 29, 45256, time.UTC),
104+
Amount: decimal.NewFromFloat(2.718),
105+
Note: "This was an important expense",
106+
BudgetID: b.Data.ID,
107+
EnvelopeID: e1ID,
108+
SourceAccountID: a1.Data.ID,
109+
DestinationAccountID: a2.Data.ID,
110+
Reconciled: false,
111+
})
112+
113+
_ = createTestTransaction(suite.T(), models.TransactionCreate{
114+
Date: time.Date(2016, 5, 1, 14, 13, 25, 584575, time.UTC),
115+
Amount: decimal.NewFromFloat(11235.813),
116+
Note: "Not important",
117+
BudgetID: b.Data.ID,
118+
EnvelopeID: e2ID,
119+
SourceAccountID: a2.Data.ID,
120+
DestinationAccountID: a1.Data.ID,
121+
Reconciled: false,
122+
})
123+
124+
_ = createTestTransaction(suite.T(), models.TransactionCreate{
125+
Date: time.Date(2021, 2, 6, 5, 1, 0, 585, time.UTC),
126+
Amount: decimal.NewFromFloat(2.718),
127+
Note: "",
128+
BudgetID: b.Data.ID,
129+
EnvelopeID: e1ID,
130+
SourceAccountID: a1.Data.ID,
131+
DestinationAccountID: a2.Data.ID,
132+
Reconciled: true,
133+
})
134+
135+
tests := []struct {
136+
name string
137+
query string
138+
len int
139+
}{
140+
{"Exact Date", fmt.Sprintf("date=%s", time.Date(2021, 2, 6, 5, 1, 0, 585, time.UTC).Format(time.RFC3339Nano)), 1},
141+
{"Exact Amount", fmt.Sprintf("amount=%s", decimal.NewFromFloat(2.718).String()), 2},
142+
{"Note", "note=Not important", 1},
143+
{"No note", "note=", 1},
144+
{"Budget Match", fmt.Sprintf("budget=%s", b.Data.ID), 3},
145+
{"Envelope 2", fmt.Sprintf("envelope=%s", e2.Data.ID), 1},
146+
{"Non-existing Source Account", "source=3340a084-acf8-4cb4-8f86-9e7f88a86190", 0},
147+
{"Destination Account", fmt.Sprintf("destination=%s", a2.Data.ID), 2},
148+
{"Reconciled", "reconciled=false", 2},
149+
}
150+
151+
for _, tt := range tests {
152+
suite.T().Run(tt.name, func(t *testing.T) {
153+
var re controllers.TransactionListResponse
154+
r := test.Request(t, http.MethodGet, fmt.Sprintf("/v1/transactions?%s", tt.query), "")
155+
test.AssertHTTPStatus(t, http.StatusOK, &r)
156+
test.DecodeResponse(t, &r, &re)
157+
158+
assert.Equal(t, tt.len, len(re.Data), "Request ID: %s", r.Result().Header.Get("x-request-id"))
159+
})
160+
}
161+
}
162+
69163
func (suite *TestSuiteEnv) TestNoTransactionNotFound() {
70164
recorder := test.Request(suite.T(), http.MethodGet, "http://example.com/v1/transactions/048b061f-3b6b-45ab-b0e9-0f38d2fff0c8", "")
71165

@@ -121,7 +215,9 @@ func (suite *TestSuiteEnv) TestTransactionSorting() {
121215
var transactions controllers.TransactionListResponse
122216
test.DecodeResponse(suite.T(), &r, &transactions)
123217

124-
assert.Len(suite.T(), transactions.Data, 3, "There are not exactly three transactions")
218+
if !assert.Len(suite.T(), transactions.Data, 3, "There are not exactly three transactions") {
219+
assert.FailNow(suite.T(), "Number of transactions is wrong, aborting")
220+
}
125221
assert.Equal(suite.T(), tMarch.Data.Date, transactions.Data[0].Date, "The first transaction is not the March transaction")
126222
assert.Equal(suite.T(), tFebrurary.Data.Date, transactions.Data[1].Date, "The second transaction is not the February transaction")
127223
assert.Equal(suite.T(), tJanuary.Data.Date, transactions.Data[2].Date, "The third transaction is not the January transaction")

pkg/httputil/request.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,13 @@ func BindData(c *gin.Context, data interface{}) error {
3333
// Follow https://github.com/gin-gonic/gin/pull/3045 to see when this gets resolved.
3434
func UUIDFromString(c *gin.Context, s string) (uuid.UUID, error) {
3535
if s == "" {
36-
return uuid.UUID{}, nil
36+
return uuid.Nil, nil
3737
}
3838

3939
u, err := uuid.Parse(s)
4040
if err != nil {
4141
ErrorInvalidUUID(c)
42-
return uuid.UUID{}, err
42+
return uuid.Nil, err
4343
}
4444

4545
return u, nil

0 commit comments

Comments
 (0)