add budget feature

This commit is contained in:
Patrick Nagurny
2020-01-14 14:14:16 -05:00
parent ef3a825dab
commit 0a91b19b5c
11 changed files with 460 additions and 1 deletions

View File

@@ -7,6 +7,11 @@ import (
/** /**
* Changelog * Changelog
* *
* 1.4.0
* - add `GET /orgs/:orgId/budget`
* - add `POST /orgs/:orgId/budget`
* - add `DELETE /orgs/:orgId/budget`
*
* 1.3.0 * 1.3.0
* - add org.timezone * - add org.timezone
* *

149
core/api/budget.go Normal file
View File

@@ -0,0 +1,149 @@
package api
import (
"github.com/ant0ine/go-json-rest/rest"
"github.com/openaccounting/oa-server/core/model"
"github.com/openaccounting/oa-server/core/model/types"
"net/http"
)
/**
* @api {get} /orgs/:orgId/budget Get Budget
* @apiVersion 1.4.0
* @apiName GetBudget
* @apiGroup Budget
*
* @apiHeader {String} Authorization HTTP Basic Auth
* @apiHeader {String} Accept-Version ^1.4.0 semver versioning
*
* @apiSuccess {String} orgId Id of the Org.
* @apiSuccess {Date} inserted Date Transaction was created
* @apiSuccess {Object[]} items Array of Budget Items
*
* @apiSuccessExample Success-Response:
* HTTP/1.1 200 OK
* [
* {
* "orgId": "11111111111111111111111111111111",
* "inserted": "2020-01-13T20:12:29.720Z",
* "items": [
* {
* "accountId": "11111111111111111111111111111111",
* "amount": 35000,
* },
* {
* "accountId": "22222222222222222222222222222222",
* "amount": 55000
* }
* ]
* }
* ]
*
* @apiUse NotAuthorizedError
* @apiUse InternalServerError
*/
func GetBudget(w rest.ResponseWriter, r *rest.Request) {
user := r.Env["USER"].(*types.User)
orgId := r.PathParam("orgId")
budget, err := model.Instance.GetBudget(orgId, user.Id)
if err != nil {
rest.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteJson(&budget)
}
/**
* @api {post} /orgs/:orgId/budget Create a Budget
* @apiVersion 1.4.0
* @apiName PostBudget
* @apiGroup Budget
*
* @apiHeader {String} Authorization HTTP Basic Auth
* @apiHeader {String} Accept-Version ^1.4.0 semver versioning
*
* @apiParam {Object[]} items Array of Budget Items.
* @apiParam {String} items.accountId Id of Expense Account
* @apiParam {Number} items.amount Amount budgeted
*
* @apiSuccess {String} orgId Id of the Org.
* @apiSuccess {Date} inserted Date Transaction was created
* @apiSuccess {Object[]} items Array of Budget Items
*
* @apiSuccessExample Success-Response:
* HTTP/1.1 200 OK
* [
* {
* "orgId": "11111111111111111111111111111111",
* "inserted": "2020-01-13T20:12:29.720Z",
* "items": [
* {
* "accountId": "11111111111111111111111111111111",
* "amount": 35000,
* },
* {
* "accountId": "22222222222222222222222222222222",
* "amount": 55000
* }
* ]
* }
* ]
*
* @apiUse NotAuthorizedError
* @apiUse InternalServerError
*/
func PostBudget(w rest.ResponseWriter, r *rest.Request) {
user := r.Env["USER"].(*types.User)
orgId := r.PathParam("orgId")
budget := types.Budget{}
err := r.DecodeJsonPayload(&budget)
if err != nil {
rest.Error(w, err.Error(), http.StatusInternalServerError)
return
}
budget.OrgId = orgId
err = model.Instance.CreateBudget(&budget, user.Id)
if err != nil {
rest.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteJson(budget)
}
/**
* @api {delete} /orgs/:orgId/budget Delete Budget
* @apiVersion 1.4.0
* @apiName DeleteBudget
* @apiGroup Budget
*
* @apiHeader {String} Authorization HTTP Basic Auth
* @apiHeader {String} Accept-Version ^1.4.0 semver versioning
*
* @apiSuccessExample Success-Response:
* HTTP/1.1 200 OK
*
* @apiUse NotAuthorizedError
* @apiUse InternalServerError
*/
func DeleteBudget(w rest.ResponseWriter, r *rest.Request) {
user := r.Env["USER"].(*types.User)
orgId := r.PathParam("orgId")
err := model.Instance.DeleteBudget(orgId, user.Id)
if err != nil {
rest.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}

View File

@@ -46,5 +46,8 @@ func GetRouter(auth *AuthMiddleware, prefix string) (rest.App, error) {
rest.Put(prefix+"/orgs/:orgId/invites/:inviteId", auth.RequireAuth(PutInvite)), rest.Put(prefix+"/orgs/:orgId/invites/:inviteId", auth.RequireAuth(PutInvite)),
rest.Delete(prefix+"/orgs/:orgId/invites/:inviteId", auth.RequireAuth(DeleteInvite)), rest.Delete(prefix+"/orgs/:orgId/invites/:inviteId", auth.RequireAuth(DeleteInvite)),
rest.Get(prefix+"/health-check", GetSystemHealthStatus), rest.Get(prefix+"/health-check", GetSystemHealthStatus),
rest.Get(prefix+"/orgs/:orgId/budget", auth.RequireAuth(GetBudget)),
rest.Post(prefix+"/orgs/:orgId/budget", auth.RequireAuth(PostBudget)),
rest.Delete(prefix+"/orgs/:orgId/budget", auth.RequireAuth(DeleteBudget)),
) )
} }

58
core/model/budget.go Normal file
View File

@@ -0,0 +1,58 @@
package model
import (
"errors"
"github.com/openaccounting/oa-server/core/model/types"
)
type BudgetInterface interface {
GetBudget(string, string) (*types.Budget, error)
CreateBudget(*types.Budget, string) error
DeleteBudget(string, string) error
}
func (model *Model) GetBudget(orgId string, userId string) (*types.Budget, error) {
belongs, err := model.UserBelongsToOrg(userId, orgId)
if err != nil {
return nil, err
}
if belongs == false {
return nil, errors.New("User does not belong to org")
}
return model.db.GetBudget(orgId)
}
func (model *Model) CreateBudget(budget *types.Budget, userId string) error {
belongs, err := model.UserBelongsToOrg(userId, budget.OrgId)
if err != nil {
return err
}
if belongs == false {
return errors.New("User does not belong to org")
}
if budget.OrgId == "" {
return errors.New("orgId required")
}
return model.db.InsertAndReplaceBudget(budget)
}
func (model *Model) DeleteBudget(orgId string, userId string) error {
belongs, err := model.UserBelongsToOrg(userId, orgId)
if err != nil {
return err
}
if belongs == false {
return errors.New("User does not belong to org")
}
return model.db.DeleteBudget(orgId)
}

117
core/model/db/budget.go Normal file
View File

@@ -0,0 +1,117 @@
package db
import (
"errors"
"github.com/openaccounting/oa-server/core/model/types"
"github.com/openaccounting/oa-server/core/util"
"time"
)
type BudgetInterface interface {
GetBudget(string) (*types.Budget, error)
InsertAndReplaceBudget(*types.Budget) error
DeleteBudget(string) error
}
const budgetFields = "LOWER(HEX(accountId)),inserted,amount"
func (db *DB) GetBudget(orgId string) (*types.Budget, error) {
var budget types.Budget
var inserted int64
rows, err := db.Query("SELECT "+budgetFields+" FROM budgetitem WHERE orgId = UNHEX(?) ORDER BY HEX(accountId)", orgId)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]*types.BudgetItem, 0)
for rows.Next() {
i := new(types.BudgetItem)
err := rows.Scan(&i.AccountId, &inserted, &i.Amount)
if err != nil {
return nil, err
}
items = append(items, i)
}
err = rows.Err()
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, errors.New("Budget not found")
}
budget.OrgId = orgId
budget.Inserted = util.MsToTime(inserted)
budget.Items = items
return &budget, nil
}
func (db *DB) InsertAndReplaceBudget(budget *types.Budget) (err error) {
budget.Inserted = time.Now()
// Save to db
dbTx, err := db.Begin()
if err != nil {
return
}
defer func() {
if p := recover(); p != nil {
dbTx.Rollback()
panic(p) // re-throw panic after Rollback
} else if err != nil {
dbTx.Rollback()
} else {
err = dbTx.Commit()
}
}()
// delete previous budget
query1 := "DELETE FROM budgetitem WHERE orgId = UNHEX(?)"
_, err = dbTx.Exec(
query1,
budget.OrgId,
)
if err != nil {
return
}
// save items
for _, item := range budget.Items {
query := "INSERT INTO budgetitem(orgId,accountId,inserted,amount) VALUES (UNHEX(?),UNHEX(?),?,?)"
_, err = dbTx.Exec(
query,
budget.OrgId,
item.AccountId,
util.TimeToMs(budget.Inserted),
item.Amount)
if err != nil {
return
}
}
return
}
func (db *DB) DeleteBudget(orgId string) error {
query := "DELETE FROM budgetitem WHERE orgId = UNHEX(?)"
_, err := db.Exec(query, orgId)
return err
}

View File

@@ -19,6 +19,7 @@ type Datastore interface {
SessionInterface SessionInterface
ApiKeyInterface ApiKeyInterface
SystemHealthInteface SystemHealthInteface
BudgetInterface
} }
func NewDB(dataSourceName string) (*DB, error) { func NewDB(dataSourceName string) (*DB, error) {

View File

@@ -23,6 +23,7 @@ type Interface interface {
SessionInterface SessionInterface
ApiKeyInterface ApiKeyInterface
SystemHealthInteface SystemHealthInteface
BudgetInterface
} }
func NewModel(db db.Datastore, bcrypt util.Bcrypt, config types.Config) *Model { func NewModel(db db.Datastore, bcrypt util.Bcrypt, config types.Config) *Model {

View File

@@ -0,0 +1,17 @@
package types
import (
"time"
)
type Budget struct {
OrgId string `json:"orgId"`
Inserted time.Time `json:"inserted"`
Items []*BudgetItem `json:"items"`
}
type BudgetItem struct {
OrgId string `json:"-"`
AccountId string `json:"accountId"`
Amount int64 `json:"amount"`
}

View File

@@ -3,3 +3,4 @@ CREATE INDEX split_accountId_index ON split (accountId);
CREATE INDEX split_transactionId_index ON split (transactionId); CREATE INDEX split_transactionId_index ON split (transactionId);
CREATE INDEX split_date_index ON split (date); CREATE INDEX split_date_index ON split (date);
CREATE INDEX split_updated_index ON split (updated); CREATE INDEX split_updated_index ON split (updated);
CREATE INDEX budgetitem_orgId_index ON budgetitem (orgId);

105
migrations/migrate3.go Normal file
View File

@@ -0,0 +1,105 @@
package main
import (
"encoding/json"
"github.com/openaccounting/oa-server/core/model/db"
"github.com/openaccounting/oa-server/core/model/types"
"log"
"os"
)
func main() {
if len(os.Args) != 2 {
log.Fatal("Usage: migrate3.go <upgrade/downgrade>")
}
command := os.Args[1]
if command != "upgrade" && command != "downgrade" {
log.Fatal("Usage: migrate3.go <upgrade/downgrade>")
}
//filename is the path to the json config file
var config types.Config
file, err := os.Open("./config.json")
if err != nil {
log.Fatal(err)
}
decoder := json.NewDecoder(file)
err = decoder.Decode(&config)
if err != nil {
log.Fatal(err)
}
connectionString := config.User + ":" + config.Password + "@/" + config.Database
db, err := db.NewDB(connectionString)
if command == "upgrade" {
err = upgrade(db)
} else {
err = downgrade(db)
}
if err != nil {
log.Fatal(err)
}
log.Println("done")
}
func upgrade(db *db.DB) (err error) {
tx, err := db.Begin()
if err != nil {
return
}
defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p) // re-throw panic after Rollback
} else if err != nil {
tx.Rollback()
} else {
err = tx.Commit()
}
}()
query1 := "CREATE TABLE budgetitem (id INT UNSIGNED NOT NULL AUTO_INCREMENT, orgId BINARY(16) NOT NULL, accountId BINARY(16) NOT NULL, inserted BIGINT UNSIGNED NOT NULL, amount BIGINT NOT NULL, PRIMARY KEY(id)) ENGINE=InnoDB;"
if _, err = tx.Exec(query1); err != nil {
return
}
return
}
func downgrade(db *db.DB) (err error) {
tx, err := db.Begin()
if err != nil {
return
}
defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p) // re-throw panic after Rollback
} else if err != nil {
tx.Rollback()
} else {
err = tx.Commit()
}
}()
query1 := "DROP TABLE budgetitem"
if _, err = tx.Exec(query1); err != nil {
return
}
return
}

View File

@@ -29,3 +29,5 @@ CREATE TABLE session (id BINARY(16) NOT NULL, inserted BIGINT UNSIGNED NOT NULL,
CREATE TABLE apikey (id BINARY(16) NOT NULL, inserted BIGINT UNSIGNED NOT NULL, updated BIGINT UNSIGNED NOT NULL, userId BINARY(16) NOT NULL, label VARCHAR(300) NOT NULL, deleted BIGINT UNSIGNED, PRIMARY KEY(id)) ENGINE=InnoDB; CREATE TABLE apikey (id BINARY(16) NOT NULL, inserted BIGINT UNSIGNED NOT NULL, updated BIGINT UNSIGNED NOT NULL, userId BINARY(16) NOT NULL, label VARCHAR(300) NOT NULL, deleted BIGINT UNSIGNED, PRIMARY KEY(id)) ENGINE=InnoDB;
CREATE TABLE invite (id VARCHAR(32) NOT NULL, orgId BINARY(16) NOT NULL, inserted BIGINT UNSIGNED NOT NULL, updated BIGINT UNSIGNED NOT NULL, email VARCHAR(100) NOT NULL, accepted BOOLEAN NOT NULL, PRIMARY KEY(id)) ENGINE=InnoDB; CREATE TABLE invite (id VARCHAR(32) NOT NULL, orgId BINARY(16) NOT NULL, inserted BIGINT UNSIGNED NOT NULL, updated BIGINT UNSIGNED NOT NULL, email VARCHAR(100) NOT NULL, accepted BOOLEAN NOT NULL, PRIMARY KEY(id)) ENGINE=InnoDB;
CREATE TABLE budgetitem (id INT UNSIGNED NOT NULL AUTO_INCREMENT, orgId BINARY(16) NOT NULL, accountId BINARY(16) NOT NULL, inserted BIGINT UNSIGNED NOT NULL, amount BIGINT NOT NULL, PRIMARY KEY(id)) ENGINE=InnoDB;