diff --git a/config/config.json b/config/config.json index 8d37440..ed7fd14 100644 --- a/config/config.json +++ b/config/config.json @@ -3,5 +3,6 @@ "dataPath": "E:\\beancount", "operatingCurrency": "CNY", "startDate": "1970-01-01", - "isBak": true + "isBak": true, + "openingBalances": "Equity:OpeningBalances" } \ No newline at end of file diff --git a/script/config.go b/script/config.go index 5d4cbc9..f980a0f 100644 --- a/script/config.go +++ b/script/config.go @@ -23,15 +23,16 @@ type Config struct { OperatingCurrency string `json:"operatingCurrency"` StartDate string `json:"startDate"` IsBak bool `json:"isBak"` + OpeningBalances string `json:"openingBalances"` } type Account struct { Acc string `json:"account"` StartDate string `json:"startDate"` - Commodity string `json:"commodity,omitempty"` - PriceAmount string `json:"priceAmount,omitempty"` - PriceCommodity string `json:"priceCommodity,omitempty"` - PriceCommoditySymbol string `json:"priceCommoditySymbol,omitempty"` + Currency string `json:"currency,omitempty"` + MarketNumber string `json:"marketNumber,omitempty"` + MarketCurrency string `json:"marketCurrency,omitempty"` + MarketCurrencySymbol string `json:"marketCurrencySymbol,omitempty"` EndDate string `json:"endDate,omitempty"` Type *AccountType `json:"type,omitempty"` } @@ -77,10 +78,28 @@ func GetLedgerAccounts(ledgerId string) []Account { return ledgerAccountsMap[ledgerId] } +func GetLedgerAccount(ledgerId string, account string) Account { + accounts := ledgerAccountsMap[ledgerId] + for _, acc := range accounts { + if acc.Acc == account { + return acc + } + } + panic("Invalid account") +} + +func UpdateLedgerAccounts(ledgerId string, accounts []Account) { + ledgerAccountsMap[ledgerId] = accounts +} + func GetLedgerAccountTypes(ledgerId string) map[string]string { return ledgerAccountTypesMap[ledgerId] } +func UpdateLedgerAccountTypes(ledgerId string, accountTypesMap map[string]string) { + ledgerAccountTypesMap[ledgerId] = accountTypesMap +} + func GetAccountType(ledgerId string, acc string) AccountType { accountTypes := ledgerAccountTypesMap[ledgerId] accNodes := strings.Split(acc, ":") @@ -122,6 +141,10 @@ func LoadServerConfig() error { LogSystemError("Failed unmarshall config file (/config/config.json)") return err } + // 兼容旧版本数据,设置默认平衡账户 + if serverConfig.OpeningBalances == "" { + serverConfig.OpeningBalances = "Equity:OpeningBalances" + } LogSystemInfo("Success load config file (/config/config.json)") // load white list fileContent, err = ReadFile("./config/white_list.json") @@ -148,6 +171,15 @@ func LoadLedgerConfigMap() error { LogSystemError("Failed unmarshal config file (" + path + ")") return err } + // 兼容旧数据,初始化 平衡账户 + temp := make(map[string]Config) + for key, val := range ledgerConfigMap { + if val.OpeningBalances == "" { + val.OpeningBalances = serverConfig.OpeningBalances + } + temp[key] = val + } + ledgerConfigMap = temp LogSystemInfo("Success load ledger_config file (" + path + ")") return nil } @@ -185,7 +217,7 @@ func LoadLedgerAccountsMap() error { account := Account{Acc: key, Type: nil} // 货币单位 if len(words) >= 4 { - account.Commodity = words[3] + account.Currency = words[3] } if words[1] == "open" { account.StartDate = words[0] @@ -257,3 +289,8 @@ func GetCommoditySymbol(commodity string) string { } return "" } + +func GetAccountPrefix(account string) string { + nodes := strings.Split(account, ":") + return nodes[0] +} diff --git a/script/file.go b/script/file.go index 10127d1..2075e83 100644 --- a/script/file.go +++ b/script/file.go @@ -27,15 +27,35 @@ func ReadFile(filePath string) ([]byte, error) { } func WriteFile(filePath string, content string) error { - err := ioutil.WriteFile(filePath, []byte(content), os.ModePerm) - if err != nil { - LogSystemError("Failed to write file (" + filePath + ")") - return err + content = "\r\n" + content + file, err := os.OpenFile(filePath, os.O_CREATE, 0644) + if err == nil { + _, err = file.WriteString(content) + if err != nil { + LogSystemError("Failed to write file (" + filePath + ")") + return err + } } + defer file.Close() LogSystemInfo("Success write file (" + filePath + ")") return nil } +func AppendFileInNewLine(filePath string, content string) error { + content = "\r\n" + content + file, err := os.OpenFile(filePath, os.O_APPEND, 0644) + if err == nil { + _, err = file.WriteString(content) + if err != nil { + LogSystemError("Failed to append file (" + filePath + ")") + return err + } + } + defer file.Close() + LogSystemInfo("Success append file (" + filePath + ")") + return nil +} + func CreateFile(filePath string) error { f, err := os.Create(filePath) if nil != err { diff --git a/script/paths.go b/script/paths.go index 8bec7ee..10ed906 100644 --- a/script/paths.go +++ b/script/paths.go @@ -21,3 +21,11 @@ func GetLedgerTransactionsTemplateFilePath(dataPath string) string { func GetLedgerAccountTypeFilePath(dataPath string) string { return dataPath + "/.beancount-ns/account_type.json" } + +func GetLedgerPriceFilePath(dataPath string) string { + return dataPath + "/price/prices.bean" +} + +func GetLedgerMonthsFilePath(dataPath string) string { + return dataPath + "/month/months.bean" +} diff --git a/server.go b/server.go index a505e17..6b5d1fb 100644 --- a/server.go +++ b/server.go @@ -52,6 +52,12 @@ func RegisterRouter(router *gin.Engine) { authorized.GET("/account/valid", service.QueryValidAccount) authorized.GET("/account/all", service.QueryAllAccount) authorized.GET("/account/type", service.QueryAccountType) + authorized.POST("/account", service.AddAccount) + authorized.POST("/account/type", service.AddAccountType) + authorized.POST("/account/close", service.CloseAccount) + authorized.POST("/account/icon", service.ChangeAccountIcon) + authorized.POST("/account/balance", service.BalanceAccount) + authorized.POST("/commodity/price", service.SyncCommodityPrice) authorized.GET("/stats/months", service.MonthsList) authorized.GET("/stats/total", service.StatsTotal) authorized.GET("/stats/payee", service.StatsPayee) @@ -59,17 +65,13 @@ func RegisterRouter(router *gin.Engine) { authorized.GET("/stats/account/trend", service.StatsAccountTrend) authorized.GET("/stats/month/total", service.StatsMonthTotal) authorized.GET("/transaction", service.QueryTransactions) + authorized.POST("/transaction", service.AddTransactions) authorized.GET("/transaction/payee", service.QueryTransactionPayees) authorized.GET("/transaction/template", service.QueryTransactionTemplates) authorized.GET("/tags", service.QueryTags) authorized.GET("/file/dir", service.QueryLedgerSourceFileDir) authorized.GET("/file/content", service.QueryLedgerSourceFileContent) authorized.POST("/file", service.UpdateLedgerSourceFileContent) - - // 兼容旧版本 - authorized.GET("/entry", service.QueryTransactions) - authorized.GET("/payee", service.QueryTransactionPayees) - authorized.GET("/stats/month/incomeExpenses", service.StatsMonthTotal) } } diff --git a/service/accounts.go b/service/accounts.go index 7d8d3f6..cad649c 100644 --- a/service/accounts.go +++ b/service/accounts.go @@ -1,16 +1,25 @@ package service import ( + "encoding/json" "fmt" "github.com/beancount-gs/script" "github.com/gin-gonic/gin" "sort" "strings" + "time" ) func QueryValidAccount(c *gin.Context) { ledgerConfig := script.GetLedgerConfigFromContext(c) - OK(c, script.GetLedgerAccounts(ledgerConfig.Id)) + allAccounts := script.GetLedgerAccounts(ledgerConfig.Id) + result := make([]script.Account, 0) + for _, account := range allAccounts { + if account.EndDate == "" { + result = append(result, account) + } + } + OK(c, result) } type accountPosition struct { @@ -37,21 +46,22 @@ func QueryAllAccount(c *gin.Context) { accounts := script.GetLedgerAccounts(ledgerConfig.Id) result := make([]script.Account, 0, len(accounts)) for i := 0; i < len(accounts); i++ { + account := accounts[i] // 过滤已结束的账户 - if accounts[i].EndDate != "" { + if account.EndDate != "" { continue } - key := accounts[i].Acc + key := account.Acc typ := script.GetAccountType(ledgerConfig.Id, key) - accounts[i].Type = &typ + account.Type = &typ position := strings.Trim(accountPositionMap[key].Position, " ") if position != "" { fields := strings.Fields(position) - accounts[i].PriceAmount = fields[0] - accounts[i].PriceCommodity = fields[1] - accounts[i].PriceCommoditySymbol = script.GetCommoditySymbol(fields[1]) + account.MarketNumber = fields[0] + account.MarketCurrency = fields[1] + account.MarketCurrencySymbol = script.GetCommoditySymbol(fields[1]) } - result = append(result, accounts[i]) + result = append(result, account) } OK(c, result) } @@ -67,3 +77,165 @@ func QueryAccountType(c *gin.Context) { sort.Sort(script.AccountTypeSort(result)) OK(c, result) } + +type AddAccountForm struct { + Date string `form:"date" binding:"required"` + Account string `form:"account" binding:"required"` + // 账户计量单位可以为空 + Currency string `form:"currency"` +} + +func AddAccount(c *gin.Context) { + var accountForm AddAccountForm + if err := c.ShouldBindJSON(&accountForm); err != nil { + BadRequest(c, err.Error()) + return + } + ledgerConfig := script.GetLedgerConfigFromContext(c) + // 判断账户是否已存在 + accounts := script.GetLedgerAccounts(ledgerConfig.Id) + for _, acc := range accounts { + if acc.Acc == accountForm.Account { + DuplicateAccount(c) + return + } + } + line := fmt.Sprintf("%s open %s %s", accountForm.Date, accountForm.Account, accountForm.Currency) + if accountForm.Currency != "" && accountForm.Currency != ledgerConfig.OperatingCurrency { + line += " \"FIFO\"" + } + // 写入文件 + filePath := ledgerConfig.DataPath + "/account/" + script.GetAccountPrefix(accountForm.Account) + ".bean" + err := script.AppendFileInNewLine(filePath, line) + if err != nil { + InternalError(c, err.Error()) + return + } + // 更新缓存 + typ := script.GetAccountType(ledgerConfig.Id, accountForm.Account) + account := script.Account{Acc: accountForm.Account, StartDate: accountForm.Date, Currency: accountForm.Currency, Type: &typ} + accounts = append(accounts, account) + script.UpdateLedgerAccounts(ledgerConfig.Id, accounts) + OK(c, account) +} + +type AddAccountTypeForm struct { + Type string `form:"type" binding:"required"` + Name string `form:"name" binding:"required"` +} + +func AddAccountType(c *gin.Context) { + var addAccountTypeForm AddAccountTypeForm + if err := c.ShouldBindJSON(&addAccountTypeForm); err != nil { + BadRequest(c, err.Error()) + return + } + ledgerConfig := script.GetLedgerConfigFromContext(c) + accountTypesMap := script.GetLedgerAccountTypes(ledgerConfig.Id) + typ := addAccountTypeForm.Type + accountTypesMap[typ] = addAccountTypeForm.Name + // 更新文件 + pathFile := script.GetLedgerAccountTypeFilePath(ledgerConfig.DataPath) + bytes, err := json.Marshal(accountTypesMap) + if err != nil { + InternalError(c, err.Error()) + return + } + err = script.WriteFile(pathFile, string(bytes)) + if err != nil { + InternalError(c, err.Error()) + return + } + // 更新缓存 + script.UpdateLedgerAccountTypes(ledgerConfig.Id, accountTypesMap) + OK(c, script.AccountType{ + Key: addAccountTypeForm.Type, + Name: addAccountTypeForm.Name, + }) +} + +type CloseAccountForm struct { + Date string `form:"date" binding:"required"` + Account string `form:"account" binding:"required"` +} + +func CloseAccount(c *gin.Context) { + var accountForm CloseAccountForm + if err := c.ShouldBindJSON(&accountForm); err != nil { + BadRequest(c, err.Error()) + return + } + ledgerConfig := script.GetLedgerConfigFromContext(c) + line := fmt.Sprintf("%s close %s", accountForm.Date, accountForm.Account) + // 写入文件 + filePath := ledgerConfig.DataPath + "/account/" + script.GetAccountPrefix(accountForm.Account) + ".bean" + err := script.AppendFileInNewLine(filePath, line) + if err != nil { + InternalError(c, err.Error()) + return + } + // 更新缓存 + accounts := script.GetLedgerAccounts(ledgerConfig.Id) + for i := 0; i < len(accounts); i++ { + if accounts[i].Acc == accountForm.Account { + accounts[i].EndDate = accountForm.Date + } + } + script.UpdateLedgerAccounts(ledgerConfig.Id, accounts) + OK(c, script.Account{ + Acc: accountForm.Account, EndDate: accountForm.Date, + }) +} + +func ChangeAccountIcon(c *gin.Context) { + +} + +type BalanceAccountForm struct { + Date string `form:"date" binding:"required" json:"date"` + Account string `form:"account" binding:"required" json:"account"` + Number string `form:"number" binding:"required" json:"number"` +} + +func BalanceAccount(c *gin.Context) { + var accountForm BalanceAccountForm + if err := c.ShouldBindJSON(&accountForm); err != nil { + BadRequest(c, err.Error()) + return + } + ledgerConfig := script.GetLedgerConfigFromContext(c) + + // 获取当前账户信息 + var acc script.Account + accounts := script.GetLedgerAccounts(ledgerConfig.Id) + for _, account := range accounts { + if account.Acc == accountForm.Account { + acc = account + } + } + + today, err := time.Parse("2006-01-02", accountForm.Date) + if err != nil { + InternalError(c, err.Error()) + return + } + todayStr := today.Format("2006-01-02") + yesterdayStr := today.AddDate(0, 0, -1).Format("2006-01-02") + month := today.Format("2006-01") + line := fmt.Sprintf("\r\n%s pad %s Equity:OpeningBalances", yesterdayStr, accountForm.Account) + line += fmt.Sprintf("\r\n%s balance %s %s %s", todayStr, accountForm.Account, accountForm.Number, acc.Currency) + + filePath := fmt.Sprintf("%s/month/%s.bean", ledgerConfig.DataPath, month) + err = script.AppendFileInNewLine(filePath, line) + if err != nil { + InternalError(c, err.Error()) + return + } + result := make(map[string]string) + result["account"] = accountForm.Account + result["date"] = accountForm.Date + result["marketNumber"] = accountForm.Number + result["marketCurrency"] = ledgerConfig.OperatingCurrency + result["marketCurrencySymbol"] = script.GetCommoditySymbol(ledgerConfig.OperatingCurrency) + OK(c, result) +} diff --git a/service/commodity.go b/service/commodity.go new file mode 100644 index 0000000..ec6e1f0 --- /dev/null +++ b/service/commodity.go @@ -0,0 +1,32 @@ +package service + +import ( + "fmt" + "github.com/beancount-gs/script" + "github.com/gin-gonic/gin" +) + +type SyncCommodityPriceForm struct { + Commodity string `form:"commodity" binding:"required" json:"commodity"` + Date string `form:"date" binding:"required" json:"date"` + Price string `form:"price" binding:"required" json:"price"` +} + +func SyncCommodityPrice(c *gin.Context) { + var syncCommodityPriceForm SyncCommodityPriceForm + if err := c.ShouldBindJSON(&syncCommodityPriceForm); err != nil { + BadRequest(c, err.Error()) + return + } + + ledgerConfig := script.GetLedgerConfigFromContext(c) + filePath := script.GetLedgerPriceFilePath(ledgerConfig.DataPath) + line := fmt.Sprintf("%s price %s %s %s", syncCommodityPriceForm.Date, syncCommodityPriceForm.Commodity, syncCommodityPriceForm.Price, ledgerConfig.OperatingCurrency) + // 写入文件 + err := script.AppendFileInNewLine(filePath, line) + if err != nil { + InternalError(c, err.Error()) + return + } + OK(c, syncCommodityPriceForm) +} diff --git a/service/error.go b/service/error.go index 032ca31..087e4b3 100644 --- a/service/error.go +++ b/service/error.go @@ -21,10 +21,18 @@ func InternalError(c *gin.Context, message string) { c.JSON(http.StatusOK, gin.H{"code": 500, "message": message}) } +func TransactionNotBalance(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"code": 1001}) +} + func LedgerIsNotExist(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"code": 1006, "message": "ledger is not exist"}) + c.JSON(http.StatusOK, gin.H{"code": 1006}) } func LedgerIsNotAllowAccess(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"code": 1006, "message": "ledger is not allow access"}) + c.JSON(http.StatusOK, gin.H{"code": 1006}) +} + +func DuplicateAccount(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"code": 1007}) } diff --git a/service/transactions.go b/service/transactions.go index 38542a5..8aae920 100644 --- a/service/transactions.go +++ b/service/transactions.go @@ -2,9 +2,12 @@ package service import ( "encoding/json" + "fmt" "github.com/beancount-gs/script" "github.com/gin-gonic/gin" + "github.com/shopspring/decimal" "strings" + "time" ) type Transaction struct { @@ -47,6 +50,124 @@ func QueryTransactions(c *gin.Context) { OK(c, transactions) } +type AddTransactionForm struct { + Date string `form:"date" binding:"required"` + Payee string `form:"payee"` + Desc string `form:"desc" binding:"required"` + Tags []string `form:"tags"` + Entries []AddTransactionEntryForm `form:"entries"` +} + +type AddTransactionEntryForm struct { + Account string `form:"account" binding:"required"` + Number decimal.Decimal `form:"number"` + //Currency string `form:"currency"` + Price decimal.Decimal `form:"price"` + //PriceCurrency string `form:"priceCurrency"` +} + +func sum(entries []AddTransactionEntryForm, openingBalances string) decimal.Decimal { + sumVal := decimal.NewFromInt(0) + for _, entry := range entries { + if entry.Account == openingBalances { + return sumVal + } + if entry.Price.IntPart() == 0 { + sumVal = entry.Number.Add(sumVal) + } else { + sumVal = entry.Number.Mul(entry.Price).Add(sumVal) + } + } + return sumVal +} + +func AddTransactions(c *gin.Context) { + var addTransactionForm AddTransactionForm + if err := c.ShouldBindJSON(&addTransactionForm); err != nil { + BadRequest(c, err.Error()) + return + } + ledgerConfig := script.GetLedgerConfigFromContext(c) + // 账户是否平衡 + sumVal := sum(addTransactionForm.Entries, ledgerConfig.OpeningBalances) + val, _ := decimal.NewFromString("0.01") + if sumVal.Abs().GreaterThan(val) { + TransactionNotBalance(c) + return + } + + // 2021-09-29 * "支付宝" "黄金补仓X元" #Invest + line := fmt.Sprintf("\r\n%s * \"%s\" \"%s\"", addTransactionForm.Date, addTransactionForm.Payee, addTransactionForm.Desc) + if len(addTransactionForm.Tags) > 0 { + for _, tag := range addTransactionForm.Tags { + line += "#" + tag + " " + } + } + + var autoBalance bool + for _, entry := range addTransactionForm.Entries { + account := script.GetLedgerAccount(ledgerConfig.Id, entry.Account) + if entry.Account == ledgerConfig.OpeningBalances { + line += fmt.Sprintf("\r\n %s", entry.Account) + } else { + line += fmt.Sprintf("\r\n %s %s %s", entry.Account, entry.Number.Round(2).String(), account.Currency) + } + // 判断是否设计多币种的转换 + if account.Currency != ledgerConfig.OperatingCurrency && entry.Account != ledgerConfig.OpeningBalances { + autoBalance = true + // 根据 number 的正负来判断是买入还是卖出 + if entry.Number.GreaterThan(decimal.NewFromInt(0)) { + // {351.729 CNY, 2021-09-29} + line += fmt.Sprintf(" {%s %s, %s}", entry.Price, ledgerConfig.OperatingCurrency, addTransactionForm.Date) + } else { + // {} @ 359.019 CNY + line += fmt.Sprintf(" {} @ %s %s", entry.Price, ledgerConfig.OperatingCurrency) + } + priceLine := fmt.Sprintf("%s price %s %s %s", addTransactionForm.Date, account.Currency, entry.Price, ledgerConfig.OperatingCurrency) + err := script.AppendFileInNewLine(script.GetLedgerPriceFilePath(ledgerConfig.DataPath), priceLine) + if err != nil { + InternalError(c, err.Error()) + return + } + } + } + + // 平衡小数点误差 + if autoBalance { + line += "\r\n " + ledgerConfig.OpeningBalances + } + // 记账的日期 + month, err := time.Parse("2006-01-02", addTransactionForm.Date) + if err != nil { + InternalError(c, err.Error()) + return + } + monthStr := month.Format("2006-01") + filePath := fmt.Sprintf("%s/month/%s.bean", ledgerConfig.DataPath, monthStr) + + // 文件不存在,则创建 + if !script.FileIfExist(filePath) { + err = script.CreateFile(filePath) + if err != nil { + InternalError(c, err.Error()) + return + } + // include ./2021-11.bean + err = script.AppendFileInNewLine(script.GetLedgerMonthsFilePath(ledgerConfig.DataPath), fmt.Sprintf("include \"./%s.bean\"", monthStr)) + if err != nil { + InternalError(c, err.Error()) + return + } + } + + err = script.AppendFileInNewLine(filePath, line) + if err != nil { + InternalError(c, err.Error()) + return + } + OK(c, nil) +} + type transactionPayee struct { Value string `bql:"distinct payee" json:"value"` } @@ -70,11 +191,11 @@ func QueryTransactionPayees(c *gin.Context) { } type transactionTemplate struct { - Id string `json:"id"` - Date string `json:"date"` - TemplateName string `json:"templateName"` - Payee string `json:"payee"` - Desc string `json:"desc"` + Id string `json:"id"` + Date string `json:"date"` + TemplateName string `json:"templateName"` + Payee string `json:"payee"` + Desc string `json:"desc"` Entries []transactionTemplateEntity `json:"entries"` }