Skip to content

Commit 95ff85b

Browse files
committed
feat: add AvailableFrom for income transactions
With this field, you can set for which month an income transaction will be counted towards the available for budget sum. This also updates the YNAB 4 importer to set the AvailableFrom field to the following month for all transactions that are marked as „Income for next month“ in YNAB 4.
1 parent 00a03e2 commit 95ff85b

File tree

8 files changed

+103
-20
lines changed

8 files changed

+103
-20
lines changed

api/docs.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3257,6 +3257,11 @@ const docTemplate = `{
32573257
"multipleOf": 1e-8,
32583258
"example": 14.03
32593259
},
3260+
"availableFrom": {
3261+
"description": "The date from which on the transaction amount is available for budgeting. Only used for income transactions. Defaults to the transaction date.",
3262+
"type": "string",
3263+
"example": "2021-11-17:00:00:00Z"
3264+
},
32603265
"budgetId": {
32613266
"type": "string",
32623267
"example": "55eecbd8-7c46-4b06-ada9-f287802fb05e"
@@ -3719,6 +3724,11 @@ const docTemplate = `{
37193724
"multipleOf": 1e-8,
37203725
"example": 14.03
37213726
},
3727+
"availableFrom": {
3728+
"description": "The date from which on the transaction amount is available for budgeting. Only used for income transactions. Defaults to the transaction date.",
3729+
"type": "string",
3730+
"example": "2021-11-17:00:00:00Z"
3731+
},
37223732
"budgetId": {
37233733
"type": "string",
37243734
"example": "55eecbd8-7c46-4b06-ada9-f287802fb05e"

api/swagger.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3245,6 +3245,11 @@
32453245
"multipleOf": 1e-8,
32463246
"example": 14.03
32473247
},
3248+
"availableFrom": {
3249+
"description": "The date from which on the transaction amount is available for budgeting. Only used for income transactions. Defaults to the transaction date.",
3250+
"type": "string",
3251+
"example": "2021-11-17:00:00:00Z"
3252+
},
32483253
"budgetId": {
32493254
"type": "string",
32503255
"example": "55eecbd8-7c46-4b06-ada9-f287802fb05e"
@@ -3707,6 +3712,11 @@
37073712
"multipleOf": 1e-8,
37083713
"example": 14.03
37093714
},
3715+
"availableFrom": {
3716+
"description": "The date from which on the transaction amount is available for budgeting. Only used for income transactions. Defaults to the transaction date.",
3717+
"type": "string",
3718+
"example": "2021-11-17:00:00:00Z"
3719+
},
37103720
"budgetId": {
37113721
"type": "string",
37123722
"example": "55eecbd8-7c46-4b06-ada9-f287802fb05e"

api/swagger.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,12 @@ definitions:
403403
minimum: 1e-08
404404
multipleOf: 1e-08
405405
type: number
406+
availableFrom:
407+
description: The date from which on the transaction amount is available for
408+
budgeting. Only used for income transactions. Defaults to the transaction
409+
date.
410+
example: 2021-11-17:00:00:00Z
411+
type: string
406412
budgetId:
407413
example: 55eecbd8-7c46-4b06-ada9-f287802fb05e
408414
type: string
@@ -749,6 +755,12 @@ definitions:
749755
minimum: 1e-08
750756
multipleOf: 1e-08
751757
type: number
758+
availableFrom:
759+
description: The date from which on the transaction amount is available for
760+
budgeting. Only used for income transactions. Defaults to the transaction
761+
date.
762+
example: 2021-11-17:00:00:00Z
763+
type: string
752764
budgetId:
753765
example: 55eecbd8-7c46-4b06-ada9-f287802fb05e
754766
type: string

pkg/importer/parser/ynab4/parse.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,10 @@ func parseTransactions(resources *types.ParsedResources, transactions []Transact
304304
newTransaction.Model.TransactionCreate.Reconciled = true
305305
}
306306

307+
if transaction.CategoryID == "Category/__DeferredIncome__" {
308+
newTransaction.Model.AvailableFrom = internal_types.MonthOf(date).AddDate(0, 1)
309+
}
310+
307311
// No subtransactions, add transaction directly
308312
if len(transaction.SubTransactions) == 0 {
309313
if mapping, ok := envelopeIDNames[transaction.CategoryID]; ok {
@@ -326,6 +330,12 @@ func parseTransactions(resources *types.ParsedResources, transactions []Transact
326330
newTransaction.Category = ""
327331
}
328332

333+
if sub.CategoryID == "Category/__DeferredIncome__" {
334+
newTransaction.Model.AvailableFrom = internal_types.MonthOf(date).AddDate(0, 1)
335+
} else {
336+
newTransaction.Model.AvailableFrom = internal_types.MonthOf(date)
337+
}
338+
329339
if sub.Amount.IsPositive() {
330340
newTransaction.Model.Amount = sub.Amount
331341
} else {

pkg/models/account.go

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -71,37 +71,49 @@ func (a Account) SumReconciledTransactions(db *gorm.DB) decimal.Decimal {
7171
))
7272
}
7373

74-
// GetBalanceMonth calculates the balance at the end of a specific month.
75-
func (a Account) GetBalanceMonth(db *gorm.DB, month types.Month) (decimal.Decimal, error) {
74+
// GetBalanceMonth calculates the balance and available sums for a specific month.
75+
//
76+
// The balance Decimal is the actual account balance, factoring in all transactions before the end of the month.
77+
// The available Decimal is the sum that is available for budgeting at the end of the specified month.
78+
func (a Account) GetBalanceMonth(db *gorm.DB, month types.Month) (balance, available decimal.Decimal, err error) {
7679
var transactions []Transaction
7780

78-
err := db.
81+
err = db.
82+
Preload("DestinationAccount").
83+
Preload("SourceAccount").
7984
Where("transactions.date < date(?)", month.AddDate(0, 1)).
8085
Where(
8186
db.Where(Transaction{TransactionCreate: TransactionCreate{DestinationAccountID: a.ID}}).
8287
Or(db.Where(Transaction{TransactionCreate: TransactionCreate{SourceAccountID: a.ID}}))).
8388
Find(&transactions).
8489
Error
8590
if err != nil {
86-
return decimal.Zero, err
91+
return decimal.Zero, decimal.Zero, err
8792
}
8893

89-
sum := decimal.Zero
90-
9194
if a.InitialBalanceDate != nil && month.AddDate(0, 1).AfterTime(*a.InitialBalanceDate) {
92-
sum = a.InitialBalance
95+
balance = a.InitialBalance
96+
available = a.InitialBalance
9397
}
9498

9599
// Add incoming transactions, subtract outgoing transactions
100+
// For available, only do so if the next month is after the availableFrom date
96101
for _, t := range transactions {
97102
if t.DestinationAccountID == a.ID {
98-
sum = sum.Add(t.Amount)
103+
balance = balance.Add(t.Amount)
104+
105+
// If the transaction is an income transaction, but its AvailableFrom is after this month, skip it
106+
if !month.AddDate(0, 1).After(t.AvailableFrom) && t.SourceAccount.External && t.EnvelopeID == nil {
107+
continue
108+
}
109+
available = available.Add(t.Amount)
99110
} else {
100-
sum = sum.Sub(t.Amount)
111+
balance = balance.Sub(t.Amount)
112+
available = available.Sub(t.Amount)
101113
}
102114
}
103115

104-
return sum, nil
116+
return
105117
}
106118

107119
// getBalance returns the balance of the account calculated over all transactions.

pkg/models/account_test.go

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -95,25 +95,47 @@ func (suite *TestSuiteStandard) TestAccountCalculations() {
9595
suite.Assert().Fail("Resource could not be saved", err)
9696
}
9797

98-
a := account.WithCalculations(suite.db)
98+
futureIncomeTransaction := suite.createTestTransaction(models.TransactionCreate{
99+
BudgetID: budget.ID,
100+
SourceAccountID: externalAccount.ID,
101+
DestinationAccountID: account.ID,
102+
Amount: decimal.NewFromFloat(100),
103+
AvailableFrom: types.MonthOf(time.Now()).AddDate(0, 1),
104+
Note: "Future Income Transaction",
105+
})
106+
err = suite.db.Save(&futureIncomeTransaction).Error
107+
if err != nil {
108+
suite.Assert().Fail("Resource could not be saved", err)
109+
}
99110

100-
assert.True(suite.T(), a.Balance.Equal(incomingTransaction.Amount.Sub(outgoingTransaction.Amount).Add(a.InitialBalance)), "Balance for account is not correct. Should be: %v but is %v", incomingTransaction.Amount.Sub(outgoingTransaction.Amount), a.Balance)
111+
a := account.WithCalculations(suite.db)
101112

102-
assert.True(suite.T(), a.ReconciledBalance.Equal(incomingTransaction.Amount.Add(a.InitialBalance)), "Reconciled balance for account is not correct. Should be: %v but is %v", incomingTransaction.Amount, a.ReconciledBalance)
113+
expected := incomingTransaction.Amount.Sub(outgoingTransaction.Amount).Add(a.InitialBalance).Add(decimal.NewFromFloat(100)) // Add 100 for futureIncomeTransaction
114+
assert.True(suite.T(), a.Balance.Equal(expected), "Balance for account is not correct. Should be: %v but is %v", expected, a.Balance)
103115

104-
balanceNow, err := account.GetBalanceMonth(suite.db, types.Month(time.Now()))
116+
expected = incomingTransaction.Amount.Add(a.InitialBalance)
117+
assert.True(suite.T(), a.ReconciledBalance.Equal(expected), "Reconciled balance for account is not correct. Should be: %v but is %v", expected, a.ReconciledBalance)
105118

119+
balanceNow, availableNow, err := account.GetBalanceMonth(suite.db, types.MonthOf(time.Now()))
106120
assert.Nil(suite.T(), err)
107-
assert.True(suite.T(), balanceNow.Equal(decimal.NewFromFloat(184.72)), "Current balance for account is not correct. Should be: %v but is %v", decimal.NewFromFloat(184.72), balanceNow)
121+
122+
expected = decimal.NewFromFloat(284.72)
123+
assert.True(suite.T(), balanceNow.Equal(expected), "Current balance for account is not correct. Should be: %v but is %v", expected, balanceNow)
124+
125+
expected = decimal.NewFromFloat(184.72)
126+
assert.True(suite.T(), availableNow.Equal(expected), "Available balance for account is not correct. Should be: %v but is %v", expected, availableNow)
108127

109128
err = suite.db.Delete(&incomingTransaction).Error
110129
if err != nil {
111130
suite.Assert().Fail("Resource could not be deleted", err)
112131
}
113132

114133
a = account.WithCalculations(suite.db)
115-
assert.True(suite.T(), a.Balance.Equal(outgoingTransaction.Amount.Neg().Add(a.InitialBalance)), "Balance for account is not correct. Should be: %v but is %v", outgoingTransaction.Amount.Neg(), a.Balance)
116-
assert.True(suite.T(), a.ReconciledBalance.Equal(decimal.NewFromFloat(0).Add(a.InitialBalance)), "Reconciled balance for account is not correct. Should be: %v but is %v", decimal.NewFromFloat(0), a.ReconciledBalance)
134+
expected = outgoingTransaction.Amount.Neg().Add(a.InitialBalance).Add(decimal.NewFromFloat(100)) // Add 100 for futureIncomeTransaction
135+
assert.True(suite.T(), a.Balance.Equal(expected), "Balance for account is not correct. Should be: %v but is %v", expected, a.Balance)
136+
137+
expected = decimal.NewFromFloat(0).Add(a.InitialBalance)
138+
assert.True(suite.T(), a.ReconciledBalance.Equal(expected), "Reconciled balance for account is not correct. Should be: %v but is %v", expected, a.ReconciledBalance)
117139
}
118140

119141
func (suite *TestSuiteStandard) TestAccountTransactions() {
@@ -256,7 +278,7 @@ func (suite *TestSuiteStandard) TestAccountGetBalanceMonthDBFail() {
256278

257279
suite.CloseDB()
258280

259-
_, err := account.GetBalanceMonth(suite.db, types.NewMonth(2017, 7))
281+
_, _, err := account.GetBalanceMonth(suite.db, types.NewMonth(2017, 7))
260282
suite.Assert().NotNil(err)
261283
suite.Assert().Equal("sql: database is closed", err.Error())
262284
}

pkg/models/budget.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -313,11 +313,11 @@ func (b Budget) Month(db *gorm.DB, month types.Month, baseURL string) (Month, er
313313

314314
// Add all on-balance accounts to the available sum
315315
for _, a := range accounts {
316-
b, err := a.GetBalanceMonth(db, month)
316+
_, available, err := a.GetBalanceMonth(db, month)
317317
if err != nil {
318318
return Month{}, err
319319
}
320-
result.Available = result.Available.Add(b)
320+
result.Available = result.Available.Add(available)
321321
}
322322

323323
return result, nil

pkg/models/transaction.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package models
33
import (
44
"time"
55

6+
"github.com/envelope-zero/backend/internal/types"
67
"github.com/google/uuid"
78
"github.com/shopspring/decimal"
89
"gorm.io/gorm"
@@ -27,6 +28,7 @@ type TransactionCreate struct {
2728
DestinationAccountID uuid.UUID `json:"destinationAccountId" example:"8e16b456-a719-48ce-9fec-e115cfa7cbcc"`
2829
EnvelopeID *uuid.UUID `json:"envelopeId" example:"2649c965-7999-4873-ae16-89d5d5fa972e"`
2930
Reconciled bool `json:"reconciled" example:"true" default:"false"`
31+
AvailableFrom types.Month `json:"availableFrom" example:"2021-11-17:00:00:00Z"` // The date from which on the transaction amount is available for budgeting. Only used for income transactions. Defaults to the transaction date.
3032
}
3133

3234
// AfterFind updates the timestamps to use UTC as
@@ -52,5 +54,10 @@ func (t *Transaction) BeforeSave(tx *gorm.DB) (err error) {
5254
t.Date = t.Date.In(time.UTC)
5355
}
5456

57+
// Default the AvailableForBudget date to the transaction date
58+
if t.AvailableFrom.IsZero() {
59+
t.AvailableFrom = types.MonthOf(t.Date)
60+
}
61+
5562
return nil
5663
}

0 commit comments

Comments
 (0)