Complete syntax
This commit is contained in:
parent
f68ce60bfc
commit
6bb7af6c43
|
@ -20,4 +20,4 @@
|
|||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
.idea
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
# beanbot
|
||||
|
||||
Bean bot to assist beancount
|
||||
Bean bot to assist beancount
|
||||
|
|
|
@ -0,0 +1,143 @@
|
|||
package syntax
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrMissingPayee = errors.New("missing payee")
|
||||
var ErrUnknownAccount = errors.New("unknown account")
|
||||
|
||||
type Config struct {
|
||||
Currencies []string
|
||||
Accounts map[string]string
|
||||
DefaultCurrency string
|
||||
}
|
||||
|
||||
func (c *Config) hasCurrency(cur string) bool {
|
||||
for _, v := range c.Currencies {
|
||||
if v == cur {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Config) account(abbr string) (string, bool) {
|
||||
if res, ok := c.Accounts[abbr]; ok {
|
||||
return res, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
type tokenReader struct {
|
||||
tokens []string
|
||||
cur int
|
||||
}
|
||||
|
||||
func (r *tokenReader) HasNext() bool {
|
||||
return r.cur < len(r.tokens)
|
||||
}
|
||||
|
||||
func (r *tokenReader) Peek() string {
|
||||
return r.tokens[r.cur]
|
||||
}
|
||||
|
||||
func (r *tokenReader) Next() string {
|
||||
res := r.tokens[r.cur]
|
||||
r.cur++
|
||||
return res
|
||||
}
|
||||
|
||||
func Parse(tokens []string, cfg *Config) (*Transaction, error) {
|
||||
r := &tokenReader{tokens: tokens}
|
||||
txn := &Transaction{}
|
||||
|
||||
// optional transaction date
|
||||
if r.HasNext() {
|
||||
t, err := time.Parse("2006-01-02", r.Peek())
|
||||
if err == nil {
|
||||
txn.Date = t.Format("2006-01-02")
|
||||
r.Next()
|
||||
} else {
|
||||
// default date: current date
|
||||
txn.Date = time.Now().Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
|
||||
// optional txn mark
|
||||
if r.HasNext() {
|
||||
if r.Peek() == "*" || r.Peek() == "!" {
|
||||
txn.Flag = r.Next()
|
||||
} else {
|
||||
// default flag is * (complete txn)
|
||||
txn.Flag = "*"
|
||||
}
|
||||
}
|
||||
|
||||
// mandatory payee
|
||||
if !r.HasNext() {
|
||||
return nil, ErrMissingPayee
|
||||
}
|
||||
txn.Payee = r.Next()
|
||||
|
||||
p, err := readPosting(r, cfg)
|
||||
if err != nil {
|
||||
// account does not exist, token is optional narration
|
||||
txn.Narration = r.Next()
|
||||
} else {
|
||||
txn.Postings = append(txn.Postings, p)
|
||||
}
|
||||
|
||||
// remaining postings
|
||||
for r.HasNext() {
|
||||
p, err := readPosting(r, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
txn.Postings = append(txn.Postings, p)
|
||||
}
|
||||
|
||||
return txn, txn.Validate()
|
||||
}
|
||||
|
||||
func readPosting(r *tokenReader, cfg *Config) (*Posting, error) {
|
||||
p := &Posting{}
|
||||
|
||||
// mandatory account
|
||||
if !r.HasNext() {
|
||||
return nil, ErrUnknownAccount
|
||||
}
|
||||
account, ok := cfg.account(r.Peek())
|
||||
if !ok {
|
||||
return nil, ErrUnknownAccount
|
||||
}
|
||||
p.Account = account
|
||||
r.Next()
|
||||
|
||||
// optional flag
|
||||
if r.HasNext() && (r.Peek() == "!" || r.Peek() == "*") {
|
||||
p.Flag = r.Next()
|
||||
} else {
|
||||
p.Flag = "*"
|
||||
}
|
||||
|
||||
// optional amount
|
||||
if r.HasNext() {
|
||||
amount, err := strconv.ParseFloat(r.Peek(), 64)
|
||||
if err == nil {
|
||||
p.Amount = amount
|
||||
r.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// optional currency
|
||||
if r.HasNext() && cfg.hasCurrency(r.Peek()) {
|
||||
p.Currency = r.Next()
|
||||
} else {
|
||||
p.Currency = cfg.DefaultCurrency
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
package syntax
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var Cfg = &Config{
|
||||
Currencies: []string{"CNY", "USD"},
|
||||
Accounts: map[string]string{
|
||||
"zfb": "Assets::Digital::Alipay",
|
||||
"wx": "Assets::Digital::Wechat",
|
||||
"dt": "Expenses::Travel::Train",
|
||||
"lunch": "Expenses::Food::Lunch",
|
||||
},
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
for _, str := range []string{
|
||||
"地铁 dt 3 zfb -1 wx",
|
||||
"午饭 lunch 11.20 wx",
|
||||
"2023-01-01 转账 zfb 100 wx",
|
||||
} {
|
||||
t.Run(str, func(t *testing.T) {
|
||||
txn, err := Parse(strings.Split(str, " "), Cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
fmt.Print(txn.ToBeanLanguageSyntax())
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
package syntax
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
//go:embed transaction.tmpl
|
||||
var txnTmpl string
|
||||
|
||||
var parsedTmpl *template.Template
|
||||
|
||||
func init() {
|
||||
parsedTmpl = template.Must(template.New("bean-language-txn").Parse(txnTmpl))
|
||||
}
|
||||
|
||||
type Posting struct {
|
||||
Account string
|
||||
Amount float64
|
||||
Currency string
|
||||
Flag string
|
||||
}
|
||||
|
||||
type Transaction struct {
|
||||
Date string
|
||||
Payee string
|
||||
Narration string
|
||||
Flag string
|
||||
Postings []*Posting
|
||||
}
|
||||
|
||||
func (txn *Transaction) Validate() error {
|
||||
// currency: amount
|
||||
sum := map[string]float64{}
|
||||
zeros := map[string]int{}
|
||||
zerop := map[string]*Posting{}
|
||||
|
||||
for _, p := range txn.Postings {
|
||||
sum[p.Currency] += p.Amount
|
||||
if p.Amount == 0 {
|
||||
zeros[p.Currency]++
|
||||
zerop[p.Currency] = p
|
||||
}
|
||||
}
|
||||
|
||||
for cur, s := range sum {
|
||||
if zeros[cur] > 1 {
|
||||
return errors.New("multiple zero amount postings")
|
||||
}
|
||||
if zeros[cur] == 1 {
|
||||
zerop[cur].Amount = -s
|
||||
}
|
||||
if zeros[cur] == 0 && s != 0 {
|
||||
return errors.New("sum of amount is non-zero")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (txn *Transaction) ToBeanLanguageSyntax() string {
|
||||
buf := &bytes.Buffer{}
|
||||
err := parsedTmpl.Execute(buf, txn)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
{{.Date}} {{.Flag}} "{{.Payee}}"{{if ne .Narration ""}} "{{.Narration}}"{{end}}
|
||||
{{range .Postings}} {{if ne .Flag "*" }}{{.Flag}} {{end}}{{.Account}} {{printf "%.2f" .Amount}} {{.Currency}}
|
||||
{{end}}
|
Loading…
Reference in New Issue