Skip to content

Commit 318bb02

Browse files
committed
feat: add commonly used envelopes for destination accounts to account object
1 parent 9303ef4 commit 318bb02

File tree

6 files changed

+256
-5
lines changed

6 files changed

+256
-5
lines changed

api/docs.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2744,6 +2744,12 @@ const docTemplate = `{
27442744
"default": false,
27452745
"example": true
27462746
},
2747+
"recentEnvelopes": {
2748+
"type": "array",
2749+
"items": {
2750+
"$ref": "#/definitions/models.Envelope"
2751+
}
2752+
},
27472753
"reconciledBalance": {
27482754
"type": "number",
27492755
"example": 2539.57
@@ -3505,6 +3511,44 @@ const docTemplate = `{
35053511
}
35063512
}
35073513
},
3514+
"models.Envelope": {
3515+
"type": "object",
3516+
"properties": {
3517+
"categoryId": {
3518+
"type": "string",
3519+
"example": "878c831f-af99-4a71-b3ca-80deb7d793c1"
3520+
},
3521+
"createdAt": {
3522+
"type": "string",
3523+
"example": "2022-04-02T19:28:44.491514Z"
3524+
},
3525+
"deletedAt": {
3526+
"type": "string",
3527+
"example": "2022-04-22T21:01:05.058161Z"
3528+
},
3529+
"hidden": {
3530+
"type": "boolean",
3531+
"default": false,
3532+
"example": true
3533+
},
3534+
"id": {
3535+
"type": "string",
3536+
"example": "65392deb-5e92-4268-b114-297faad6cdce"
3537+
},
3538+
"name": {
3539+
"type": "string",
3540+
"example": "Groceries"
3541+
},
3542+
"note": {
3543+
"type": "string",
3544+
"example": "For stuff bought at supermarkets and drugstores"
3545+
},
3546+
"updatedAt": {
3547+
"type": "string",
3548+
"example": "2022-04-17T20:14:01.048145Z"
3549+
}
3550+
}
3551+
},
35083552
"models.EnvelopeCreate": {
35093553
"type": "object",
35103554
"properties": {

api/swagger.json

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2732,6 +2732,12 @@
27322732
"default": false,
27332733
"example": true
27342734
},
2735+
"recentEnvelopes": {
2736+
"type": "array",
2737+
"items": {
2738+
"$ref": "#/definitions/models.Envelope"
2739+
}
2740+
},
27352741
"reconciledBalance": {
27362742
"type": "number",
27372743
"example": 2539.57
@@ -3493,6 +3499,44 @@
34933499
}
34943500
}
34953501
},
3502+
"models.Envelope": {
3503+
"type": "object",
3504+
"properties": {
3505+
"categoryId": {
3506+
"type": "string",
3507+
"example": "878c831f-af99-4a71-b3ca-80deb7d793c1"
3508+
},
3509+
"createdAt": {
3510+
"type": "string",
3511+
"example": "2022-04-02T19:28:44.491514Z"
3512+
},
3513+
"deletedAt": {
3514+
"type": "string",
3515+
"example": "2022-04-22T21:01:05.058161Z"
3516+
},
3517+
"hidden": {
3518+
"type": "boolean",
3519+
"default": false,
3520+
"example": true
3521+
},
3522+
"id": {
3523+
"type": "string",
3524+
"example": "65392deb-5e92-4268-b114-297faad6cdce"
3525+
},
3526+
"name": {
3527+
"type": "string",
3528+
"example": "Groceries"
3529+
},
3530+
"note": {
3531+
"type": "string",
3532+
"example": "For stuff bought at supermarkets and drugstores"
3533+
},
3534+
"updatedAt": {
3535+
"type": "string",
3536+
"example": "2022-04-17T20:14:01.048145Z"
3537+
}
3538+
}
3539+
},
34963540
"models.EnvelopeCreate": {
34973541
"type": "object",
34983542
"properties": {

api/swagger.yaml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ definitions:
4141
description: 'Always false when external: true'
4242
example: true
4343
type: boolean
44+
recentEnvelopes:
45+
items:
46+
$ref: '#/definitions/models.Envelope'
47+
type: array
4448
reconciledBalance:
4549
example: 2539.57
4650
type: number
@@ -590,6 +594,34 @@ definitions:
590594
example: 100.13
591595
type: number
592596
type: object
597+
models.Envelope:
598+
properties:
599+
categoryId:
600+
example: 878c831f-af99-4a71-b3ca-80deb7d793c1
601+
type: string
602+
createdAt:
603+
example: "2022-04-02T19:28:44.491514Z"
604+
type: string
605+
deletedAt:
606+
example: "2022-04-22T21:01:05.058161Z"
607+
type: string
608+
hidden:
609+
default: false
610+
example: true
611+
type: boolean
612+
id:
613+
example: 65392deb-5e92-4268-b114-297faad6cdce
614+
type: string
615+
name:
616+
example: Groceries
617+
type: string
618+
note:
619+
example: For stuff bought at supermarkets and drugstores
620+
type: string
621+
updatedAt:
622+
example: "2022-04-17T20:14:01.048145Z"
623+
type: string
624+
type: object
593625
models.EnvelopeCreate:
594626
properties:
595627
categoryId:

pkg/controllers/account.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ type AccountResponse struct {
2222

2323
type Account struct {
2424
models.Account
25-
Links AccountLinks `json:"links"`
25+
RecentEnvelopes []models.Envelope `json:"recentEnvelopes"`
26+
Links AccountLinks `json:"links"`
2627
}
2728

2829
type AccountLinks struct {
@@ -328,16 +329,23 @@ func (co Controller) getAccountResource(c *gin.Context, id uuid.UUID) (models.Ac
328329
}
329330

330331
func (co Controller) getAccountObject(c *gin.Context, id uuid.UUID) (Account, bool) {
331-
resource, ok := co.getAccountResource(c, id)
332+
account, ok := co.getAccountResource(c, id)
332333
if !ok {
333334
return Account{}, false
334335
}
335336

337+
recentEnvelopes, err := account.RecentEnvelopes(co.DB)
338+
if err != nil {
339+
httperrors.Handler(c, err)
340+
return Account{}, false
341+
}
342+
336343
return Account{
337-
resource.WithCalculations(co.DB),
344+
account.WithCalculations(co.DB),
345+
recentEnvelopes,
338346
AccountLinks{
339-
Self: fmt.Sprintf("%s/v1/accounts/%s", c.GetString("baseURL"), resource.ID),
340-
Transactions: fmt.Sprintf("%s/v1/transactions?account=%s", c.GetString("baseURL"), resource.ID),
347+
Self: fmt.Sprintf("%s/v1/accounts/%s", c.GetString("baseURL"), account.ID),
348+
Transactions: fmt.Sprintf("%s/v1/transactions?account=%s", c.GetString("baseURL"), account.ID),
341349
},
342350
}, true
343351
}

pkg/models/account.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,25 @@ func TransactionsSum(db *gorm.DB, incoming, outgoing Transaction) decimal.Decima
106106

107107
return incomingSum.Decimal.Sub(outgoingSum.Decimal)
108108
}
109+
110+
// RecentEnvelopes returns the most common envelopes used in the last 10
111+
// transactions where the account is the destination account.
112+
//
113+
// The list is sorted by decending frequency of the envelope being used.
114+
func (a Account) RecentEnvelopes(db *gorm.DB) (envelopes []Envelope, err error) {
115+
err = db.
116+
Table("transactions").
117+
Select("envelopes.*, count(envelopes.id) AS count").
118+
Joins("JOIN envelopes ON envelopes.id = transactions.envelope_id AND envelopes.deleted_at IS NULL").
119+
Order("count DESC, date(transactions.date) DESC").
120+
Where(&Transaction{
121+
TransactionCreate: TransactionCreate{
122+
DestinationAccountID: a.ID,
123+
},
124+
}).
125+
Limit(10).
126+
Group("envelopes.id").
127+
Find(&envelopes).Error
128+
129+
return
130+
}

pkg/models/account_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package models_test
22

33
import (
4+
"strconv"
5+
46
"github.com/envelope-zero/backend/pkg/models"
7+
"github.com/google/uuid"
58
"github.com/shopspring/decimal"
69
"github.com/stretchr/testify/assert"
710
)
@@ -138,3 +141,101 @@ func (suite *TestSuiteStandard) TestAccountOnBudget() {
138141

139142
assert.True(suite.T(), account.OnBudget, "OnBudget is set to false even though the account is internal")
140143
}
144+
145+
func (suite *TestSuiteStandard) TestAccountRecentEnvelopes() {
146+
budget := models.Budget{}
147+
err := suite.db.Save(&budget).Error
148+
if err != nil {
149+
suite.Assert().Fail("Budget could not be saved", err)
150+
}
151+
152+
account := models.Account{
153+
AccountCreate: models.AccountCreate{
154+
BudgetID: budget.ID,
155+
OnBudget: true,
156+
External: false,
157+
InitialBalance: decimal.NewFromFloat(170),
158+
},
159+
}
160+
err = suite.db.Save(&account).Error
161+
if err != nil {
162+
suite.Assert().Fail("Resource could not be saved", err)
163+
}
164+
165+
externalAccount := models.Account{
166+
AccountCreate: models.AccountCreate{
167+
BudgetID: budget.ID,
168+
External: true,
169+
},
170+
}
171+
err = suite.db.Save(&externalAccount).Error
172+
if err != nil {
173+
suite.Assert().Fail("Resource could not be saved", err)
174+
}
175+
176+
category := models.Category{
177+
CategoryCreate: models.CategoryCreate{
178+
BudgetID: budget.ID,
179+
},
180+
}
181+
err = suite.db.Save(&category).Error
182+
if err != nil {
183+
suite.Assert().Fail("Resource could not be saved", err)
184+
}
185+
186+
envelopeIDs := []*uuid.UUID{}
187+
for i := 1; i <= 3; i++ {
188+
envelope := &models.Envelope{
189+
EnvelopeCreate: models.EnvelopeCreate{
190+
CategoryID: category.ID,
191+
Name: strconv.Itoa(i),
192+
},
193+
}
194+
if err = suite.db.Save(&envelope).Error; err != nil {
195+
suite.Assert().Fail("Resource could not be saved", err)
196+
}
197+
198+
envelopeIDs = append(envelopeIDs, &envelope.ID)
199+
}
200+
201+
// Create 15 transactions:
202+
// * 2 for the first envelope
203+
// * 2 for the second envelope
204+
// * 11 for the last envelope
205+
for i := 1; i <= 15; i++ {
206+
eIndex := i
207+
if i > 5 {
208+
eIndex = 2
209+
}
210+
transaction := models.Transaction{
211+
TransactionCreate: models.TransactionCreate{
212+
BudgetID: budget.ID,
213+
EnvelopeID: envelopeIDs[eIndex%3],
214+
SourceAccountID: account.ID,
215+
DestinationAccountID: externalAccount.ID,
216+
Amount: decimal.NewFromFloat(17.45),
217+
},
218+
}
219+
err = suite.db.Save(&transaction).Error
220+
if err != nil {
221+
suite.Assert().Fail("Resource could not be saved", err)
222+
}
223+
}
224+
225+
recent, err := externalAccount.RecentEnvelopes(suite.db)
226+
if err != nil {
227+
suite.Assert().Fail("Could not compute recent envelopes", err)
228+
}
229+
230+
// The last envelope needs to be the first in the sort since it
231+
// has been the most common one in the last 10 transactions
232+
suite.Assert().Equal(*envelopeIDs[2], recent[0].ID)
233+
234+
// The second envelope is as common as the first, but its newest transaction
235+
// is newer than the first envelope's newest transaction,
236+
// so it needs to come second
237+
suite.Assert().Equal(*envelopeIDs[1], recent[1].ID)
238+
239+
// The first envelope is the last one
240+
suite.Assert().Equal(*envelopeIDs[0], recent[2].ID)
241+
}

0 commit comments

Comments
 (0)