├── constants ├── version.go └── name.go ├── go.mod ├── .gitignore ├── types ├── config.go └── transaction.go ├── transform ├── account.go └── common.go ├── collect ├── collect.go ├── cmbchina │ └── cmbchina.go ├── wechat │ └── wechat.go └── alipay │ └── alipay.go ├── bean └── bean.go ├── docs └── README-zh-CN.md ├── Makefile ├── README.md ├── cmd └── beancollect │ └── main.go └── go.sum /constants/version.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | // Version is the version for beancollect 4 | const Version = "0.1.0" 5 | -------------------------------------------------------------------------------- /constants/name.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | // Constants for beancollect 4 | const ( 5 | Name = "beancollect" 6 | Usage = "beancollect helps your collect beans so that you can count them." 7 | ) 8 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Xuanwo/beancollect 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/PuerkitoBio/goquery v1.5.1 7 | github.com/imdario/mergo v0.3.7 8 | github.com/sirupsen/logrus v1.4.2 9 | github.com/urfave/cli v1.20.0 10 | golang.org/x/text v0.3.2 11 | gopkg.in/yaml.v2 v2.2.2 12 | ) 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Go template 3 | # Binaries for programs and plugins 4 | *.exe 5 | *.exe~ 6 | *.dll 7 | *.so 8 | *.dylib 9 | 10 | # Test binary, build with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | 16 | bin/ -------------------------------------------------------------------------------- /types/config.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // Config is the config for beancollect transform. 4 | type Config struct { 5 | Account map[string]string `yaml:"account"` 6 | Rules []Rule `yaml:"rules"` 7 | } 8 | 9 | // Rule is the config for roles. 10 | type Rule struct { 11 | Type string 12 | Condition map[string]string 13 | Value string 14 | } 15 | -------------------------------------------------------------------------------- /transform/account.go: -------------------------------------------------------------------------------- 1 | package transform 2 | 3 | import ( 4 | "github.com/Xuanwo/beancollect/types" 5 | ) 6 | 7 | // AddAccounts will do transform for add_accounts type. 8 | type AddAccounts struct { 9 | Condition map[string]string 10 | Value string 11 | } 12 | 13 | // Transform implements Transformer interface. 14 | func (a *AddAccounts) Transform(t *types.Transactions) { 15 | for k, v := range *t { 16 | if !v.IsMatch(a.Condition) { 17 | continue 18 | } 19 | (*t)[k].Accounts = append((*t)[k].Accounts, a.Value) 20 | } 21 | return 22 | } 23 | -------------------------------------------------------------------------------- /transform/common.go: -------------------------------------------------------------------------------- 1 | package transform 2 | 3 | import ( 4 | "github.com/Xuanwo/beancollect/types" 5 | ) 6 | 7 | // Available transform type. 8 | const ( 9 | TypeAddAccounts = "add_accounts" 10 | ) 11 | 12 | // Transformer is the interface to do transform. 13 | type Transformer interface { 14 | Transform(t *types.Transactions) 15 | } 16 | 17 | // Execute will execute transformers from rule. 18 | func Execute(rule types.Rule, t *types.Transactions) { 19 | switch rule.Type { 20 | case TypeAddAccounts: 21 | tr := &AddAccounts{ 22 | Condition: rule.Condition, 23 | Value: rule.Value, 24 | } 25 | tr.Transform(t) 26 | default: 27 | return 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /collect/collect.go: -------------------------------------------------------------------------------- 1 | package collect 2 | 3 | import ( 4 | "io" 5 | "log" 6 | 7 | "github.com/Xuanwo/beancollect/collect/alipay" 8 | "github.com/Xuanwo/beancollect/collect/cmbchina" 9 | "github.com/Xuanwo/beancollect/collect/wechat" 10 | "github.com/Xuanwo/beancollect/types" 11 | ) 12 | 13 | // Collector is the interface to parse transactions from io.Reader. 14 | type Collector interface { 15 | Parse(c *types.Config, r io.Reader) (types.Transactions, error) 16 | } 17 | 18 | // NewCollector will create a new collector. 19 | func NewCollector(t string) Collector { 20 | switch t { 21 | case wechat.Type: 22 | return wechat.NewWeChat() 23 | case alipay.Type: 24 | return alipay.NewAliPay() 25 | case cmbchina.Type: 26 | return cmbchina.NewCMBChina() 27 | default: 28 | log.Fatalf("not supported type %s", t) 29 | } 30 | 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /bean/bean.go: -------------------------------------------------------------------------------- 1 | package bean 2 | 3 | import ( 4 | "github.com/Xuanwo/beancollect/transform" 5 | "github.com/Xuanwo/beancollect/types" 6 | "github.com/sirupsen/logrus" 7 | "os" 8 | "sort" 9 | "text/template" 10 | ) 11 | 12 | const content = `{{ .Time.Format "2006-01-02" }} {{ .Flag }} "{{ .Payee }}" "{{ .Narration }}" 13 | {{ index .Accounts 0 }} {{ .Amount }} {{ .Currency }} 14 | {{ if gt (len .Accounts) 1 }} {{ index .Accounts 1 }} 15 | {{ end }} 16 | ` 17 | 18 | var tmpl = template.Must(template.New("bean").Parse(content)) 19 | 20 | // Generate will generate the transactions into bean. 21 | func Generate(c *types.Config, t *types.Transactions) { 22 | sort.Sort(t) 23 | 24 | for _, v := range c.Rules { 25 | transform.Execute(v, t) 26 | } 27 | 28 | for _, v := range *t { 29 | err := tmpl.Execute(os.Stdout, v) 30 | if err != nil { 31 | logrus.Fatalf("Template execute failed for %v", err) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /types/transaction.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "time" 4 | 5 | // Transaction means a real transaction in beancount. 6 | type Transaction struct { 7 | Time time.Time 8 | Flag string 9 | Narration string 10 | Payee string 11 | Tags []string 12 | Accounts []string 13 | Amount float64 14 | Currency string 15 | 16 | // Metadata map[string]string 17 | } 18 | 19 | // IsMatch will check whether this transaction match condition. 20 | // TODO: support regex 21 | func (t Transaction) IsMatch(cond map[string]string) bool { 22 | // Currently, we only support payee. 23 | if cond["payee"] == t.Payee { 24 | return true 25 | } 26 | return false 27 | } 28 | 29 | // Transactions is the array for transactions 30 | type Transactions []Transaction 31 | 32 | // Len implement Sorter.Len 33 | func (t Transactions) Len() int { 34 | return len(t) 35 | } 36 | 37 | // Less implement Sorter.Less 38 | func (t Transactions) Less(i, j int) bool { 39 | return t[i].Time.Before(t[j].Time) 40 | } 41 | 42 | // Swap implement Sorter.Swap 43 | func (t Transactions) Swap(i, j int) { 44 | t[i], t[j] = t[j], t[i] 45 | } 46 | -------------------------------------------------------------------------------- /docs/README-zh-CN.md: -------------------------------------------------------------------------------- 1 | # beancollect 2 | 3 | beancollect 帮助你收集豆子 (bean) 以便于清点他们。 4 | 5 | ## 使用方法 6 | 7 | ```bash 8 | beancollect -s wechat collect/wechat.csv 9 | ``` 10 | 11 | 将会输出: 12 | 13 | ``` 14 | 2019-06-20 ! "北京麦当劳食品有限公司" "北京麦当劳食品有限公司" 15 | Assets:Deposit:WeChat -20.5 CNY 16 | Expenses:Intake:FastFood 17 | 2019-06-23 ! "街电科技" "街电充电宝" 18 | Assets:Deposit:WeChat -4 CNY 19 | ``` 20 | 21 | ## 设置 22 | 23 | 推荐的 beancount 项目结构: 24 | 25 | ``` 26 | ├── account 27 | │   ├── assets.bean 28 | │   ├── equity.bean 29 | │   ├── expenses.bean 30 | │   ├── incomes.bean 31 | │   └── liabilities.bean 32 | ├── collect 33 | │   ├── global.yaml 34 | │   └── wechat.yaml 35 | ├── main.bean 36 | └── transactions 37 | └── 2019 38 | ├── 03.bean 39 | ├── 04.bean 40 | ├── 05.bean 41 | ├── 06.bean 42 | └── 07.bean 43 | ``` 44 | 45 | beancollect 需要的所有文件都在 `collect` 目录下。 46 | 47 | ## 配置 48 | 49 | beancollect 支持账户映射和规则执行。 50 | 51 | beancollect 会有一个全局的配置文件 `global.yaml`,然后每种格式都会一个独立的配置文件 `schema.yaml`,`global.yaml` 中的条目将会被 `schema.yaml` 覆盖。 52 | 53 | 比如: 54 | 55 | ```yaml 56 | account: 57 | "招商银行(XXXX)": "Liabilities:Credit:CMB" 58 | "招商银行": "Assets:Deposit:CMB:CardXXXX" 59 | "零钱通": "Assets:Deposit:WeChat" 60 | "零钱": "Assets:Deposit:WeChat" 61 | 62 | rules: 63 | - type: add_accounts 64 | condition: 65 | payee: "猫眼/格瓦拉生活" 66 | value: "Expenses:Recreation:Movie" 67 | - type: add_accounts 68 | condition: 69 | payee: "北京麦当劳食品有限公司" 70 | value: "Expenses:Intake:FastFood" 71 | - type: add_accounts 72 | condition: 73 | payee: "滴滴出行" 74 | value: "Expenses:Transport:Taxi" 75 | - type: add_accounts 76 | condition: 77 | payee: "摩拜单车" 78 | value: "Expenses:Transport:Bicycle" 79 | ``` 80 | 81 | ### Schema 82 | 83 | - wechat 84 | 85 | ### Account 86 | 87 | 这个配置会用来做账户的映射。 88 | 89 | ### 规则 90 | 91 | - add_accounts: 如果条件满足,就在事务中增加一个账户 92 | 93 | ## 账单 94 | 95 | ### 微信 96 | 97 | `我` -> `支付` -> `钱包` -> `账单` -> `...` in right up corner -> `导出账单` 98 | 99 | 账单将会发送到你指定的邮箱。 100 | 101 | ### 支付宝 102 | 103 | 访问 `https://www.alipay.com/` 下载帐单 104 | 105 | ### 招商银行信用卡 106 | 107 | 启用电子帐单,并将邮件中的 HTML 下载下来 -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | 3 | .PHONY: all check format vet lint build install uninstall release clean test 4 | 5 | VERSION=$(shell cat ./constants/version.go | grep "Version\ =" | sed -e s/^.*\ //g | sed -e s/\"//g) 6 | 7 | help: 8 | @echo "Please use \`make \` where is one of" 9 | @echo " check to format, vet and lint " 10 | @echo " build to create bin directory and build beancollect" 11 | @echo " install to install beancollect to /usr/local/bin/beancollect" 12 | @echo " uninstall to uninstall beancollect" 13 | @echo " release to release beancollect" 14 | @echo " clean to clean build and test files" 15 | @echo " test to run test" 16 | 17 | check: format vet lint 18 | 19 | format: 20 | @echo "go fmt" 21 | go fmt ./... 22 | @echo "ok" 23 | 24 | vet: 25 | @echo "go vet" 26 | @go vet -all ./... 27 | @echo "ok" 28 | 29 | lint: 30 | @echo "golint" 31 | golint ./... 32 | @echo "ok" 33 | 34 | build: check 35 | @echo "build beancollect" 36 | @mkdir -p ./bin 37 | @go build -tags netgo -o ./bin/beancollect ./cmd/beancollect 38 | @echo "ok" 39 | 40 | install: build 41 | @echo "install beancollect to GOPATH" 42 | @cp ./bin/beancollect ${GOPATH}/bin/beancollect 43 | @echo "ok" 44 | 45 | release: 46 | @echo "release beancollect" 47 | @rm ./release/* 48 | @mkdir -p ./release 49 | 50 | @echo "build for linux" 51 | @GOOS=linux GOARCH=amd64 go build -o ./bin/linux/beancollect_v${VERSION}_linux_amd64 . 52 | @tar -C ./bin/linux/ -czf ./release/beancollect_v${VERSION}_linux_amd64.tar.gz beancollect_v${VERSION}_linux_amd64 53 | 54 | @echo "build for macOS" 55 | @GOOS=darwin GOARCH=amd64 go build -o ./bin/macos/beancollect_v${VERSION}_macos_amd64 . 56 | @tar -C ./bin/macos/ -czf ./release/beancollect_v${VERSION}_macos_amd64.tar.gz beancollect_v${VERSION}_macos_amd64 57 | 58 | @echo "build for windows" 59 | @GOOS=windows GOARCH=amd64 go build -o ./bin/windows/beancollect_v${VERSION}_windows_amd64.exe . 60 | @tar -C ./bin/windows/ -czf ./release/beancollect_v${VERSION}_windows_amd64.tar.gz beancollect_v${VERSION}_windows_amd64.exe 61 | 62 | @echo "ok" 63 | 64 | clean: 65 | @rm -rf ./bin 66 | @rm -rf ./release 67 | @rm -rf ./coverage 68 | 69 | test: 70 | @echo "run test" 71 | @go test -v ./... 72 | @echo "ok" 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # beancollect 2 | 3 | beancollect helps your collect beans so that you can count them. 4 | 5 | [中文文档](docs/README-zh-CN.md) 6 | 7 | ## Usage 8 | 9 | ```bash 10 | beancollect -s wechat collect/wechat.csv 11 | ``` 12 | 13 | Output will be like: 14 | 15 | ``` 16 | 2019-06-20 ! "北京麦当劳食品有限公司" "北京麦当劳食品有限公司" 17 | Assets:Deposit:WeChat -20.5 CNY 18 | Expenses:Intake:FastFood 19 | 2019-06-23 ! "街电科技" "街电充电宝" 20 | Assets:Deposit:WeChat -4 CNY 21 | ``` 22 | 23 | ## Setup 24 | 25 | Recommended beancount directory structure: 26 | 27 | ``` 28 | ├── account 29 | │   ├── assets.bean 30 | │   ├── equity.bean 31 | │   ├── expenses.bean 32 | │   ├── incomes.bean 33 | │   └── liabilities.bean 34 | ├── collect 35 | │   ├── global.yaml 36 | │   └── wechat.yaml 37 | ├── main.bean 38 | └── transactions 39 | └── 2019 40 | ├── 03.bean 41 | ├── 04.bean 42 | ├── 05.bean 43 | ├── 06.bean 44 | └── 07.bean 45 | ``` 46 | 47 | All file that beancollect located in `collect` directory. 48 | 49 | ## Config 50 | 51 | beancollect supports account mapping and rules execution on beancount transactions. 52 | 53 | beancollect will one `global.yaml` and a `schema.yaml` for every schema, and `global.yaml` will be override by `schema.yaml`. 54 | 55 | For example: 56 | 57 | ```yaml 58 | account: 59 | "招商银行(XXXX)": "Liabilities:Credit:CMB" 60 | "招商银行": "Assets:Deposit:CMB:CardXXXX" 61 | "零钱通": "Assets:Deposit:WeChat" 62 | "零钱": "Assets:Deposit:WeChat" 63 | 64 | rules: 65 | - type: add_accounts 66 | condition: 67 | payee: "猫眼/格瓦拉生活" 68 | value: "Expenses:Recreation:Movie" 69 | - type: add_accounts 70 | condition: 71 | payee: "北京麦当劳食品有限公司" 72 | value: "Expenses:Intake:FastFood" 73 | - type: add_accounts 74 | condition: 75 | payee: "滴滴出行" 76 | value: "Expenses:Transport:Taxi" 77 | - type: add_accounts 78 | condition: 79 | payee: "摩拜单车" 80 | value: "Expenses:Transport:Bicycle" 81 | ``` 82 | 83 | ### Schema 84 | 85 | - wechat 86 | 87 | ### Account 88 | 89 | Account will convert account in beancount. 90 | 91 | ### Rules 92 | 93 | - add_accounts: If condition is matched, we will add an account into transaction. 94 | 95 | ## Billings 96 | 97 | ### WeChat 98 | 99 | On the phone: 100 | 101 | `Me` -> `WeChat Pay` -> `Wallet` -> `Transactions` -> `...` in right up corner -> `导出账单` 102 | 103 | The billing will be sent to your email. 104 | 105 | ### Alipay 106 | 107 | Visit website: `https://www.alipay.com/` to download billings 108 | 109 | ### CMBChina 110 | 111 | Enable email billings 112 | -------------------------------------------------------------------------------- /cmd/beancollect/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | 7 | "github.com/imdario/mergo" 8 | log "github.com/sirupsen/logrus" 9 | "github.com/urfave/cli" 10 | "gopkg.in/yaml.v2" 11 | 12 | "github.com/Xuanwo/beancollect/bean" 13 | "github.com/Xuanwo/beancollect/collect" 14 | "github.com/Xuanwo/beancollect/constants" 15 | "github.com/Xuanwo/beancollect/types" 16 | ) 17 | 18 | func run(c *cli.Context) (err error) { 19 | log.SetLevel(log.DebugLevel) 20 | 21 | if c.NArg() == 0 { 22 | println("Please specify the file to collect") 23 | os.Exit(1) 24 | } 25 | 26 | fi, err := ioutil.ReadDir("collect") 27 | if err != nil { 28 | println("Please collect from your bean main directory") 29 | os.Exit(1) 30 | } 31 | 32 | schema := c.String("schema") 33 | if schema == "" { 34 | println("Please specify the schema to collect") 35 | os.Exit(1) 36 | } 37 | 38 | globalConfig, schemaConfig := &types.Config{}, &types.Config{} 39 | 40 | for _, v := range fi { 41 | if v.Name() == "global.yaml" { 42 | cfgContent, err := ioutil.ReadFile("collect/global.yaml") 43 | if err != nil { 44 | log.Errorf("Open config failed for %v", err) 45 | return err 46 | } 47 | err = yaml.Unmarshal(cfgContent, globalConfig) 48 | if err != nil { 49 | log.Errorf("Load config failed for %v", err) 50 | return err 51 | } 52 | continue 53 | } 54 | if v.Name() == schema+".yaml" { 55 | cfgContent, err := ioutil.ReadFile("collect/" + schema + ".yaml") 56 | if err != nil { 57 | log.Errorf("Open config failed for %v", err) 58 | return err 59 | } 60 | err = yaml.Unmarshal(cfgContent, schemaConfig) 61 | if err != nil { 62 | log.Errorf("Load config failed for %v", err) 63 | return err 64 | } 65 | continue 66 | } 67 | continue 68 | } 69 | 70 | if err := mergo.Merge(schemaConfig, globalConfig, mergo.WithOverride); err != nil { 71 | log.Errorf("Config merge failed for %v", err) 72 | return err 73 | } 74 | 75 | var t types.Transactions 76 | 77 | f, err := os.Open(c.Args().Get(0)) 78 | if err != nil { 79 | log.Errorf("Open file failed for %v", err) 80 | return err 81 | } 82 | defer f.Close() 83 | 84 | t, err = collect.NewCollector(schema).Parse(schemaConfig, f) 85 | if err != nil { 86 | log.Errorf("Parse failed for %v", err) 87 | return err 88 | } 89 | 90 | bean.Generate(schemaConfig, &t) 91 | return nil 92 | } 93 | 94 | func main() { 95 | app := cli.NewApp() 96 | app.Name = constants.Name 97 | app.Usage = constants.Usage 98 | app.Version = constants.Version 99 | app.Action = run 100 | 101 | app.Flags = []cli.Flag{ 102 | cli.StringFlag{ 103 | Name: "schema, s", 104 | Usage: "schema for the collect", 105 | }, 106 | } 107 | 108 | err := app.Run(os.Args) 109 | if err != nil { 110 | log.Fatal(err) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/PuerkitoBio/goquery v1.5.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE= 2 | github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= 3 | github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo= 4 | github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/imdario/mergo v0.3.7 h1:Y+UAYTZ7gDEuOfhxKWy+dvb5dRQ6rJjFSdX2HZY1/gI= 8 | github.com/imdario/mergo v0.3.7/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= 9 | github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= 10 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 11 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 12 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 13 | github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= 14 | github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= 15 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 16 | github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 17 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 18 | github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw= 19 | github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= 20 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 21 | golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 22 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2 h1:CCH4IOTTfewWjGOlSp+zGcjutRKlBEZQ6wTn8ozI/nI= 23 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 24 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 25 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= 26 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 27 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 28 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 29 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 30 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 31 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 32 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 33 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 34 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 35 | -------------------------------------------------------------------------------- /collect/cmbchina/cmbchina.go: -------------------------------------------------------------------------------- 1 | package cmbchina 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "io/ioutil" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/PuerkitoBio/goquery" 12 | "github.com/Xuanwo/beancollect/types" 13 | log "github.com/sirupsen/logrus" 14 | ) 15 | 16 | const Type = "cmbchina" 17 | 18 | type CMBChina struct { 19 | } 20 | 21 | type record struct { 22 | TransDate string // 交易日 23 | PostDate string // 记账日 24 | Description string // 交易摘要 25 | RMBAmount float64 // 人民币金额 26 | CardNumber string // 卡号后四位 27 | Area string // 交易地点 28 | OriginalTransAmount float64 // 交易地金额 29 | } 30 | 31 | func NewCMBChina() *CMBChina { 32 | return &CMBChina{} 33 | } 34 | 35 | func (cmb *CMBChina) Parse(c *types.Config, r io.Reader) (t types.Transactions, err error) { 36 | t = make(types.Transactions, 0) 37 | 38 | rb, err := ioutil.ReadAll(r) 39 | if err != nil { 40 | log.Errorf("ioutil read failed for %s", err) 41 | return 42 | } 43 | 44 | doc, err := goquery.NewDocumentFromReader(bytes.NewReader(rb)) 45 | if err != nil { 46 | log.Errorf("parse html failed for %s", err) 47 | return 48 | } 49 | 50 | var rs []*record 51 | var tr *record 52 | doc.Find("span[id$=\"fixBand15\"]").Each(func(i int, s *goquery.Selection) { 53 | s.Find("td").Each(func(i int, s *goquery.Selection) { 54 | if _, ok := s.Attr("valign"); !ok { 55 | return 56 | } 57 | 58 | value := s.Text() 59 | switch (i - 2) % 7 { 60 | case 0: 61 | tr = &record{} 62 | rs = append(rs, tr) 63 | 64 | tr.TransDate = value 65 | case 1: 66 | tr.PostDate = value 67 | case 2: 68 | tr.Description = value 69 | case 3: 70 | value = strings.TrimPrefix(value, "¥") 71 | value = strings.TrimSpace(value) 72 | // CMBChina will insert "," in amount 73 | value = strings.ReplaceAll(value, ",", "") 74 | // CMBChina will have "\u00a0" between "-" and amount 75 | value = strings.ReplaceAll(value, "\u00a0", "") 76 | amount, err := strconv.ParseFloat(value, 64) 77 | if err != nil { 78 | log.Errorf("parse rmb amount failed: %s", err) 79 | return 80 | } 81 | tr.RMBAmount = amount 82 | case 4: 83 | tr.CardNumber = value 84 | case 5: 85 | tr.Area = value 86 | case 6: 87 | // CMBChina will insert "," in amount 88 | value = strings.ReplaceAll(value, ",", "") 89 | amount, err := strconv.ParseFloat(value, 64) 90 | if err != nil { 91 | log.Errorf("parse original trans amount failed: %s", err) 92 | return 93 | } 94 | tr.OriginalTransAmount = amount 95 | } 96 | }) 97 | }) 98 | 99 | for _, v := range rs { 100 | t = append(t, formatTransaction(v, c)) 101 | } 102 | return 103 | } 104 | 105 | func formatTransaction(r *record, c *types.Config) types.Transaction { 106 | t := types.Transaction{} 107 | 108 | var err error 109 | if len(r.PostDate) != 0 { 110 | t.Time, err = time.Parse("0102", r.PostDate) 111 | if err != nil { 112 | log.Errorf("parse time failed for %s", err) 113 | } 114 | } 115 | t.Flag = "!" 116 | t.Accounts = append(t.Accounts, c.Account[r.CardNumber]) 117 | t.Payee = r.Description 118 | t.Amount = r.RMBAmount 119 | t.Currency = "CNY" 120 | 121 | return t 122 | } 123 | -------------------------------------------------------------------------------- /collect/wechat/wechat.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "bytes" 5 | "encoding/csv" 6 | "io" 7 | "io/ioutil" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | log "github.com/sirupsen/logrus" 13 | 14 | "github.com/Xuanwo/beancollect/types" 15 | ) 16 | 17 | // Type is the type of wechat. 18 | const Type = "wechat" 19 | 20 | // Available status for wechat. 21 | const ( 22 | StatusPaymentSuccess = "支付成功" 23 | StatusWithdrawSuccess = "提现已到账" 24 | StatusDepositSuccess = "已存入零钱" 25 | StatusRefundSuccess = "已全额退款" 26 | ) 27 | 28 | var comments = []byte(",,,,,,,,") 29 | 30 | // WeChat is the struct for wechat type. 31 | type WeChat struct { 32 | } 33 | 34 | // NewWeChat will create a new wechat. 35 | func NewWeChat() *WeChat { 36 | return &WeChat{} 37 | } 38 | 39 | type record struct { 40 | Time time.Time // 交易时间 41 | Type string // 交易类型 42 | Payee string // 交易对方 43 | Commodity string // 商品 44 | Flow string // 收/支 45 | Amount float64 // 金额(元) 46 | Payment string // 支付方式 47 | Status string // 当前状态 48 | ID string // 交易单号 49 | PayeeID string // 商户单号 50 | Comment string // 备注 51 | } 52 | 53 | // Parse implement Collector.Parse. 54 | func (w *WeChat) Parse(c *types.Config, r io.Reader) (t types.Transactions, err error) { 55 | t = make(types.Transactions, 0) 56 | 57 | b, err := ioutil.ReadAll(r) 58 | if err != nil { 59 | log.Errorf("ioutil read failed for %s", err) 60 | return 61 | } 62 | 63 | idx := bytes.LastIndex(b, comments) 64 | if idx != -1 { 65 | b = b[idx+len(comments):] 66 | } 67 | 68 | cr := csv.NewReader(bytes.NewReader(b)) 69 | records, err := cr.ReadAll() 70 | if err != nil { 71 | log.Errorf("csv read failed for %s", err) 72 | return 73 | } 74 | for _, s := range records[1:] { 75 | r := &record{} 76 | r.Time, err = time.Parse("2006-01-02 15:04:05", s[0]) 77 | if err != nil { 78 | log.Errorf("time <%s> parse failed for [%v]", s[0], err) 79 | return 80 | } 81 | r.Type = strings.TrimSpace(s[1]) 82 | r.Payee = strings.TrimSpace(s[2]) 83 | r.Commodity = strings.TrimSpace(s[3]) 84 | r.Flow = strings.TrimSpace(s[4]) 85 | // WeChat will use "¥298.00" for amount, we should trim it. 86 | r.Amount, err = strconv.ParseFloat(s[5][2:], 64) 87 | if err != nil { 88 | log.Errorf("amount <%s> parse failed for [%v]", s[5], err) 89 | return 90 | } 91 | r.Payment = strings.TrimSpace(s[6]) 92 | r.Status = strings.TrimSpace(s[7]) 93 | r.ID = strings.TrimSpace(s[8]) 94 | r.PayeeID = strings.TrimSpace(s[9]) 95 | r.Comment = strings.TrimSpace(s[10]) 96 | 97 | // Ignore all refund payment. 98 | if r.Status == StatusRefundSuccess { 99 | continue 100 | } 101 | 102 | t = append(t, formatTransaction(r, c)) 103 | } 104 | 105 | return t, nil 106 | } 107 | 108 | // formatTransaction will format record into transaction. 109 | func formatTransaction(r *record, c *types.Config) types.Transaction { 110 | t := types.Transaction{} 111 | 112 | t.Time = r.Time 113 | t.Flag = "!" 114 | // WeChat may have " around narration, let's trim them. 115 | t.Narration = strings.Trim(r.Commodity, "\"") 116 | if t.Narration == "/" { 117 | t.Narration = strings.Trim(r.Type, "\"") 118 | } 119 | t.Payee = r.Payee 120 | 121 | if _, ok := c.Account[r.Payment]; !ok { 122 | log.Infof("payment %s doesn't have related account", r.Payment) 123 | } 124 | t.Accounts = append(t.Accounts, c.Account[r.Payment]) 125 | t.Amount = r.Amount 126 | if r.Flow == "支出" { 127 | t.Amount = -r.Amount 128 | } 129 | t.Currency = "CNY" 130 | 131 | if r.Status == StatusWithdrawSuccess { 132 | t.Accounts = append(t.Accounts, c.Account["零钱"]) 133 | } 134 | 135 | return t 136 | } 137 | -------------------------------------------------------------------------------- /collect/alipay/alipay.go: -------------------------------------------------------------------------------- 1 | package alipay 2 | 3 | import ( 4 | "bytes" 5 | "encoding/csv" 6 | "io" 7 | "io/ioutil" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | log "github.com/sirupsen/logrus" 13 | "golang.org/x/text/encoding/simplifiedchinese" 14 | 15 | "github.com/Xuanwo/beancollect/types" 16 | ) 17 | 18 | // Type is the type of wechat. 19 | const Type = "alipay" 20 | 21 | var ( 22 | startComment = []byte("---------------------------------交易记录明细列表------------------------------------") 23 | endComment = []byte("------------------------------------------------------------------------------------") 24 | ) 25 | 26 | // AliPay is the struct for alipay type. 27 | type AliPay struct { 28 | } 29 | 30 | type record struct { 31 | ID string // 交易号 32 | PayeeID string // 商家订单号 33 | CreatedAt time.Time // 交易创建时间 34 | PayedAt time.Time // 付款时间 35 | UpdatedAt time.Time // 最近修改时间 36 | Source string // 交易来源地 37 | Type string // 类型 38 | Payee string // 交易对方 39 | Commodity string // 商品名称 40 | Amount float64 // 金额(元) 41 | Flow string // 收/支 42 | Status string // 交易状态 43 | Fees float64 // 服务费(元) 44 | Refund float64 // 成功退款(元) 45 | Comment string // 备注 46 | CurrencyStatus string // 资金状态 47 | } 48 | 49 | // NewAliPay will create a new alipay. 50 | func NewAliPay() *AliPay { 51 | return &AliPay{} 52 | } 53 | 54 | // Parse implement Collector.Parse. 55 | func (ali *AliPay) Parse(c *types.Config, r io.Reader) (t types.Transactions, err error) { 56 | t = make(types.Transactions, 0) 57 | 58 | rb, err := ioutil.ReadAll(r) 59 | if err != nil { 60 | log.Errorf("ioutil read failed for %s", err) 61 | return 62 | } 63 | 64 | b := make([]byte, 2*len(rb)) 65 | 66 | _, _, err = simplifiedchinese.GB18030.NewDecoder().Transform(b, rb, true) 67 | if err != nil { 68 | log.Errorf("GB18030 read failed for %s", err) 69 | return 70 | } 71 | 72 | startIdx := bytes.Index(b, startComment) 73 | if startIdx != -1 { 74 | b = b[startIdx+len(startComment):] 75 | } 76 | endIdx := bytes.Index(b, endComment) 77 | if endIdx != -1 { 78 | b = b[:endIdx] 79 | } 80 | cr := csv.NewReader(bytes.NewReader(b)) 81 | records, err := cr.ReadAll() 82 | if err != nil { 83 | log.Errorf("csv read failed for %s", err) 84 | return 85 | } 86 | 87 | for _, s := range records[2:] { 88 | r := &record{} 89 | 90 | idx := 0 91 | 92 | s[idx] = strings.TrimSpace(s[idx]) 93 | r.ID = s[idx] 94 | idx++ 95 | 96 | s[idx] = strings.TrimSpace(s[idx]) 97 | r.PayeeID = s[idx] 98 | idx++ 99 | 100 | s[idx] = strings.TrimSpace(s[idx]) 101 | if s[idx] != "" { 102 | r.CreatedAt, err = time.Parse("2006-01-02 15:04:05", s[idx]) 103 | if err != nil { 104 | log.Errorf("time <%s> parse failed for [%v]", s[idx], err) 105 | return 106 | } 107 | } 108 | idx++ 109 | 110 | s[idx] = strings.TrimSpace(s[idx]) 111 | if s[idx] != "" { 112 | r.PayedAt, err = time.Parse("2006-01-02 15:04:05", s[idx]) 113 | if err != nil { 114 | log.Errorf("time <%s> parse failed for [%v]", s[idx], err) 115 | return 116 | } 117 | } 118 | idx++ 119 | 120 | s[idx] = strings.TrimSpace(s[idx]) 121 | if s[idx] != "" { 122 | r.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", s[idx]) 123 | if err != nil { 124 | log.Errorf("time <%s> parse failed for [%v]", s[idx], err) 125 | return 126 | } 127 | } 128 | idx++ 129 | 130 | s[idx] = strings.TrimSpace(s[idx]) 131 | r.Source = s[idx] 132 | idx++ 133 | 134 | s[idx] = strings.TrimSpace(s[idx]) 135 | r.Type = s[idx] 136 | idx++ 137 | 138 | s[idx] = strings.TrimSpace(s[idx]) 139 | r.Payee = s[idx] 140 | idx++ 141 | 142 | s[idx] = strings.TrimSpace(s[idx]) 143 | r.Commodity = s[idx] 144 | idx++ 145 | 146 | s[idx] = strings.TrimSpace(s[idx]) 147 | r.Amount, err = strconv.ParseFloat(s[idx], 64) 148 | if err != nil { 149 | log.Errorf("amount <%s> parse failed for [%v]", s[idx], err) 150 | return 151 | } 152 | idx++ 153 | 154 | s[idx] = strings.TrimSpace(s[idx]) 155 | r.Flow = s[idx] 156 | idx++ 157 | 158 | s[idx] = strings.TrimSpace(s[idx]) 159 | r.Status = s[idx] 160 | idx++ 161 | 162 | s[idx] = strings.TrimSpace(s[idx]) 163 | r.Fees, err = strconv.ParseFloat(s[idx], 64) 164 | if err != nil { 165 | log.Errorf("fees <%s> parse failed for [%v]", s[idx], err) 166 | return 167 | } 168 | idx++ 169 | 170 | s[idx] = strings.TrimSpace(s[idx]) 171 | r.Refund, err = strconv.ParseFloat(s[idx], 64) 172 | if err != nil { 173 | log.Errorf("refund <%s> parse failed for [%v]", s[idx], err) 174 | return 175 | } 176 | idx++ 177 | 178 | s[idx] = strings.TrimSpace(s[idx]) 179 | r.Comment = s[idx] 180 | idx++ 181 | 182 | s[idx] = strings.TrimSpace(s[idx]) 183 | r.CurrencyStatus = s[idx] 184 | idx++ 185 | 186 | t = append(t, formatTransaction(r, c)) 187 | } 188 | 189 | return t, nil 190 | } 191 | 192 | func formatTransaction(r *record, c *types.Config) types.Transaction { 193 | t := types.Transaction{} 194 | 195 | t.Time = r.PayedAt 196 | t.Flag = "!" 197 | t.Narration = r.Commodity 198 | t.Accounts = make([]string, 1) 199 | t.Payee = r.Payee 200 | t.Amount = r.Amount 201 | if r.Flow == "支出" { 202 | t.Amount = -r.Amount 203 | } 204 | t.Currency = "CNY" 205 | 206 | return t 207 | } 208 | --------------------------------------------------------------------------------