add account and transaction api impl

This commit is contained in:
BaoXuebin 2021-11-28 20:19:40 +08:00
parent 27accd1fb9
commit 571c3303c8
9 changed files with 431 additions and 30 deletions

View File

@ -3,5 +3,6 @@
"dataPath": "E:\\beancount",
"operatingCurrency": "CNY",
"startDate": "1970-01-01",
"isBak": true
"isBak": true,
"openingBalances": "Equity:OpeningBalances"
}

View File

@ -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]
}

View File

@ -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 {

View File

@ -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"
}

View File

@ -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)
}
}

View File

@ -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)
}

32
service/commodity.go Normal file
View File

@ -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)
}

View File

@ -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})
}

View File

@ -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"`
}