diff --git a/core/api/api.go b/core/api/api.go index 2b4ef44..6df338e 100644 --- a/core/api/api.go +++ b/core/api/api.go @@ -7,6 +7,11 @@ import ( /** * Changelog * + * 1.4.0 + * - add `GET /orgs/:orgId/budget` + * - add `POST /orgs/:orgId/budget` + * - add `DELETE /orgs/:orgId/budget` + * * 1.3.0 * - add org.timezone * diff --git a/core/api/budget.go b/core/api/budget.go new file mode 100644 index 0000000..d763952 --- /dev/null +++ b/core/api/budget.go @@ -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) +} diff --git a/core/api/routes.go b/core/api/routes.go index c22ad60..648b69b 100644 --- a/core/api/routes.go +++ b/core/api/routes.go @@ -46,5 +46,8 @@ func GetRouter(auth *AuthMiddleware, prefix string) (rest.App, error) { rest.Put(prefix+"/orgs/:orgId/invites/:inviteId", auth.RequireAuth(PutInvite)), rest.Delete(prefix+"/orgs/:orgId/invites/:inviteId", auth.RequireAuth(DeleteInvite)), 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)), ) } diff --git a/core/model/budget.go b/core/model/budget.go new file mode 100644 index 0000000..e1798fa --- /dev/null +++ b/core/model/budget.go @@ -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) +} diff --git a/core/model/db/budget.go b/core/model/db/budget.go new file mode 100644 index 0000000..af85f6c --- /dev/null +++ b/core/model/db/budget.go @@ -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 +} \ No newline at end of file diff --git a/core/model/db/db.go b/core/model/db/db.go index 168a413..5bf638a 100644 --- a/core/model/db/db.go +++ b/core/model/db/db.go @@ -19,6 +19,7 @@ type Datastore interface { SessionInterface ApiKeyInterface SystemHealthInteface + BudgetInterface } func NewDB(dataSourceName string) (*DB, error) { diff --git a/core/model/model.go b/core/model/model.go index b84ddbf..a053910 100644 --- a/core/model/model.go +++ b/core/model/model.go @@ -23,6 +23,7 @@ type Interface interface { SessionInterface ApiKeyInterface SystemHealthInteface + BudgetInterface } func NewModel(db db.Datastore, bcrypt util.Bcrypt, config types.Config) *Model { diff --git a/core/model/types/budget.go b/core/model/types/budget.go new file mode 100644 index 0000000..d8ac0b7 --- /dev/null +++ b/core/model/types/budget.go @@ -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"` +} diff --git a/indexes.sql b/indexes.sql index 7d93181..3efdcea 100644 --- a/indexes.sql +++ b/indexes.sql @@ -2,4 +2,5 @@ CREATE INDEX account_orgId_index ON account (orgId); CREATE INDEX split_accountId_index ON split (accountId); CREATE INDEX split_transactionId_index ON split (transactionId); CREATE INDEX split_date_index ON split (date); -CREATE INDEX split_updated_index ON split (updated); \ No newline at end of file +CREATE INDEX split_updated_index ON split (updated); +CREATE INDEX budgetitem_orgId_index ON budgetitem (orgId); \ No newline at end of file diff --git a/migrations/migrate3.go b/migrations/migrate3.go new file mode 100644 index 0000000..c7cf43a --- /dev/null +++ b/migrations/migrate3.go @@ -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 ") + } + + command := os.Args[1] + + if command != "upgrade" && command != "downgrade" { + log.Fatal("Usage: migrate3.go ") + } + + //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 +} diff --git a/schema.sql b/schema.sql index 36aa3a4..6db50e0 100644 --- a/schema.sql +++ b/schema.sql @@ -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 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; \ No newline at end of file