From d0cf1594eadd5c91633a91ebae624826759c398a Mon Sep 17 00:00:00 2001 From: LIU HANCHENG Date: Wed, 17 Apr 2024 18:26:07 +0800 Subject: [PATCH] feat: support jd provider (#127) --- README.md | 59 +++++++ example/jd/config.yaml | 27 +++ example/jd/example-jd-output.beancount | 167 ++++++++++++++++++ example/jd/example-jd-output.ledger | 165 ++++++++++++++++++ example/jd/example-jd-records.csv | 37 ++++ pkg/analyser/interface.go | 4 + pkg/analyser/jd/jd.go | 132 +++++++++++++++ pkg/config/config.go | 2 + pkg/consts/consts.go | 2 + pkg/provider/alipay/convert.go | 3 +- pkg/provider/interface.go | 4 + pkg/provider/jd/config.go | 25 +++ pkg/provider/jd/jd.go | 224 +++++++++++++++++++++++++ test/jd-test-beancout.sh | 30 ++++ test/jd-test-ledger.sh | 32 ++++ 15 files changed, 912 insertions(+), 1 deletion(-) create mode 100644 example/jd/config.yaml create mode 100644 example/jd/example-jd-output.beancount create mode 100644 example/jd/example-jd-output.ledger create mode 100644 example/jd/example-jd-records.csv create mode 100644 pkg/analyser/jd/jd.go create mode 100644 pkg/provider/jd/config.go create mode 100644 pkg/provider/jd/jd.go create mode 100644 test/jd-test-beancout.sh create mode 100644 test/jd-test-ledger.sh diff --git a/README.md b/README.md index e9a46fb..f23ee31 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,17 @@ double-entry-generator translate \ + Beancount 转换的结果示例: [example-bmo-out.beancount](./example/bmo/debit/example-bmo-output.beancount) + Ledger 转换的结果示例: [example-bmo-out.ledger](./example/bmo/debit/example-bmo-output.ledger) +### 京东 + +1. 打开京东手机 APP +2. 前往我的 -> 我的钱包 -> 账单 +3. 点击右上角 Icon(三条横杠) +4. 选择“账单导出(仅限个人对账)” + +#### 格式示例 + +[example-jd-records.csv](./example/jd/example-jd-records.csv) + ## 配置 ### 支付宝 @@ -855,6 +866,54 @@ BMO账单中的记账金额中存在收入/支出之分,通过这个机制就 | 收入 | targetAccount | defaultCashAccount | | 支出 | defaultCashAccount | targetAccount | +### 京东 + +```yaml +defaultMinusAccount: Assets:FIXME +defaultPlusAccount: Expenses:FIXME +defaultCurrency: CNY +title: 测试 +jd: + rules: + - method: 京东白条 + methodAccount: Liabilities:Baitiao + - method: 小金库零用钱 + methodAccount: Assets:EPay:JD + - item: 椰子 + targetAccount: Expenses:Food + - item: 京东小金库-转入 + peer: 京东金融 + targetAccount: Assets:EPay:JD + - category: 美妆个护 + targetAccount: Expenses:MakeUp + - item: "食品酒饮" + targetAccount: Assets:Food + - peer: 亲密卡 + targetAccount: Expenses:Prpaid + - item: 白条,还款 + targetAccount: Liabilities:Baitiao + - item: 京东小金库收益 + fullMatch: true + targetAccount: Income:PnL:JD + methodAccount: Assets:EPay:JD +``` + +京东账单的格式总体上和[支付宝](#支付宝-3)类似。 + +京东账单在交易类别为`不计收支`时,账户的处理分为两种情况: + +1. 一般情况:`收/付款方式`(即`method`匹配的字段) 一般为支出账户, `交易分类`(即 `category` 匹配的字段)一般为收入账户。例如银行卡资金转入京东小金库时,`收/付款方式` 为银行卡,`交易分类` 为小金库; 白条还款时,`收/付款方式` 为银行卡或小金库零用钱,`交易分类` 为白条。 + +2. 特殊情况:`交易说明`(即`item`匹配的字段)的前缀为`冻结-`或`解冻-`时为`不计收支`的特殊情况。`冻结-`情形下, `收/付款方式`为支出账户; `解冻-`情形下 `收/付款方式`为收入账户但是金额为 0。目前所有和`冻结` , `解冻` 相关的交易会被忽略。 + +`targetAccount` 与 `methodAccount` 的增减账户关系如下表: + +| 收/支 | minusAccount | plusAccount | +| -------- | ------------- | ------------- | +| 收入 | targetAccount | methodAccount | +| 支出 | methodAccount | targetAccount | +| 不计收支 | methodAccount | targetAccount | + ## Special Thanks - [dilfish/atb](https://github.com/dilfish/atb) convert alipay bill to beancount version diff --git a/example/jd/config.yaml b/example/jd/config.yaml new file mode 100644 index 0000000..8588007 --- /dev/null +++ b/example/jd/config.yaml @@ -0,0 +1,27 @@ +defaultMinusAccount: Assets:FIXME +defaultPlusAccount: Expenses:FIXME +defaultCurrency: CNY +title: 测试 +jd: + rules: + - method: 京东白条 + methodAccount: Liabilities:Baitiao + - method: 小金库零用钱 + methodAccount: Assets:EPay:JD + - item: 椰子 + targetAccount: Expenses:Food + - item: 京东小金库-转入 + peer: 京东金融 + targetAccount: Assets:EPay:JD + - category: 美妆个护 + targetAccount: Expenses:MakeUp + - item: "食品酒饮" + targetAccount: Assets:Food + - peer: 亲密卡 + targetAccount: Expenses:Prpaid + - item: 白条,还款 + targetAccount: Liabilities:Baitiao + - item: 京东小金库收益 + fullMatch: true + targetAccount: Income:PnL:JD + methodAccount: Assets:EPay:JD diff --git a/example/jd/example-jd-output.beancount b/example/jd/example-jd-output.beancount new file mode 100644 index 0000000..4dc9034 --- /dev/null +++ b/example/jd/example-jd-output.beancount @@ -0,0 +1,167 @@ +option "title" "测试" +option "operating_currency" "CNY" + +1970-01-01 open Assets:EPay:JD +1970-01-01 open Assets:FIXME +1970-01-01 open Assets:Food +1970-01-01 open Expenses:FIXME +1970-01-01 open Expenses:Food +1970-01-01 open Expenses:MakeUp +1970-01-01 open Expenses:Prpaid +1970-01-01 open Income:PnL:JD +1970-01-01 open Liabilities:Baitiao + +2021-11-17 * "京东金融" "京东小金库-转入" + category: "小金库" + merchantId: "100000000000000000000027" + method: "银行卡" + orderId: "2000000000000079" + payTime: "2021-11-17 23:06:50 +0800 CST" + source: "京东金融" + status: "交易成功" + type: "不计收支" + Assets:EPay:JD 2990.13 CNY + Assets:FIXME -2990.13 CNY + +2021-12-11 * "亲密卡" "亲密卡" + category: "线下消费" + merchantId: "160000000000000000000006" + method: "京东白条" + payTime: "2021-12-11 23:51:52 +0800 CST" + source: "亲密卡" + status: "交易成功" + type: "支出" + Expenses:Prpaid 238.18 CNY + Liabilities:Baitiao -238.18 CNY + +2021-12-12 * "亲密卡" "亲密卡" + category: "线下消费" + merchantId: "100000000000000000000045" + method: "京东白条" + payTime: "2021-12-12 19:06:00 +0800 CST" + source: "亲密卡" + status: "交易成功" + type: "支出" + Expenses:Prpaid 53.00 CNY + Liabilities:Baitiao -53.00 CNY + +2021-12-15 * "京东金融" "白条主动还款" + category: "白条" + merchantId: "107500000000000000000058" + method: "小金库零用钱" + orderId: "2000000000000000000" + payTime: "2021-12-15 12:09:23 +0800 CST" + source: "京东金融" + status: "交易成功" + type: "不计收支" + Liabilities:Baitiao 1125.43 CNY + Assets:EPay:JD -1125.43 CNY + +2021-12-15 * "京东平台商户" "退款-freeplus洁面乳150ml" + category: "网购" + merchantId: "2000000000000000954" + method: "京东白条" + orderId: "88438843884" + payTime: "2021-12-15 18:35:00 +0800 CST" + source: "京东平台商户" + status: "交易成功" + type: "收入" + Liabilities:Baitiao 112.98 CNY + Assets:FIXME -112.98 CNY + +2021-12-15 * "京东平台商户" "退款-freeplus洁面乳150ml" + category: "网购" + merchantId: "2000000000000000000" + method: "京东白条" + orderId: "88438843884" + payTime: "2021-12-15 19:29:00 +0800 CST" + source: "京东平台商户" + status: "交易成功" + type: "收入" + Liabilities:Baitiao 1.00 CNY + Assets:FIXME -1.00 CNY + +2021-12-15 * "Food" "Food" + category: "食品酒饮" + merchantId: "110000000000000000000000" + method: "京东白条" + orderId: "200000000014" + payTime: "2021-12-15 19:35:14 +0800 CST" + source: "Food" + status: "交易成功" + type: "支出" + Expenses:FIXME 79.88 CNY + Liabilities:Baitiao -79.88 CNY + +2021-12-16 * "京东平台商户" "退款-freeplus洁面乳150ml" + category: "网购" + merchantId: "1000000000000000000" + method: "京东白条" + orderId: "88438843884" + payTime: "2021-12-16 08:22:00 +0800 CST" + source: "京东平台商户" + status: "交易成功" + type: "收入" + Liabilities:Baitiao 1.00 CNY + Assets:FIXME -1.00 CNY + +2021-12-18 * "京东平台商户" "freeplus洁面乳150ml" + category: "美妆个护" + merchantId: "100000000000000000000002" + method: "京东白条" + orderId: "88438843884" + payTime: "2021-12-18 19:45:25 +0800 CST" + source: "京东平台商户" + status: "交易成功" + type: "支出" + Expenses:MakeUp 223.99 CNY + Liabilities:Baitiao -223.99 CNY + +2021-12-23 * "Members" "Members Mark 泰国进口 椰子水 1L*6支 椰子汁饮料" + category: "食品酒饮" + merchantId: "110000000000000000000044" + method: "京东白条" + orderId: "234906303557" + payTime: "2021-12-23 12:04:37 +0800 CST" + source: "Members" + status: "交易成功" + type: "支出" + Expenses:Food 91.42 CNY + Liabilities:Baitiao -91.42 CNY + +2021-12-28 * "京东小金库" "京东小金库收益" + category: "小金库" + merchantId: "29999999999999999992" + method: "小金库零用钱" + orderId: "20000000000000000002" + payTime: "2021-12-28 12:14:38 +0800 CST" + source: "京东小金库" + status: "交易成功" + type: "收入" + Assets:EPay:JD 0.01 CNY + Income:PnL:JD -0.01 CNY + +2021-12-29 * "京东小金库" "京东小金库收益" + category: "小金库" + merchantId: "20000000000000000002" + method: "小金库零用钱" + orderId: "20000000000000000022" + payTime: "2021-12-29 06:37:20 +0800 CST" + source: "京东小金库" + status: "交易成功" + type: "收入" + Assets:EPay:JD 0.01 CNY + Income:PnL:JD -0.01 CNY + +2023-12-08 * "京东云余额充值预付款网银账户" "PLUS京典年卡(先享后付)" + category: "其他" + merchantId: "700000000000000" + method: "京东白条" + orderId: "700000000000008" + payTime: "2023-12-08 00:14:40 +0800 CST" + source: "京东云余额充值预付款网银账户" + status: "交易成功" + type: "支出" + Expenses:FIXME 99.00 CNY + Liabilities:Baitiao -99.00 CNY + diff --git a/example/jd/example-jd-output.ledger b/example/jd/example-jd-output.ledger new file mode 100644 index 0000000..8fd6e76 --- /dev/null +++ b/example/jd/example-jd-output.ledger @@ -0,0 +1,165 @@ +1970/01/01 * Open Balance + Assets:EPay:JD 0 CNY + Assets:FIXME 0 CNY + Assets:Food 0 CNY + Expenses:FIXME 0 CNY + Expenses:Food 0 CNY + Expenses:MakeUp 0 CNY + Expenses:Prpaid 0 CNY + Income:PnL:JD 0 CNY + Liabilities:Baitiao 0 CNY + Equity:Opening Balances +2021/11/17 * 京东金融 - 京东小金库-转入 + ; category: "小金库" + ; merchantId: "100000000000000000000027" + ; method: "银行卡" + ; orderId: "2000000000000079" + ; payTime: "2021-11-17 23:06:50 +0800 CST" + ; source: "京东金融" + ; status: "交易成功" + ; type: "不计收支" + Assets:EPay:JD 2990.13 CNY + Assets:FIXME - 2990.13 CNY + +2021/12/11 * 亲密卡 - 亲密卡 + ; category: "线下消费" + ; merchantId: "160000000000000000000006" + ; method: "京东白条" + ; payTime: "2021-12-11 23:51:52 +0800 CST" + ; source: "亲密卡" + ; status: "交易成功" + ; type: "支出" + Expenses:Prpaid 238.18 CNY + Liabilities:Baitiao - 238.18 CNY + +2021/12/12 * 亲密卡 - 亲密卡 + ; category: "线下消费" + ; merchantId: "100000000000000000000045" + ; method: "京东白条" + ; payTime: "2021-12-12 19:06:00 +0800 CST" + ; source: "亲密卡" + ; status: "交易成功" + ; type: "支出" + Expenses:Prpaid 53.00 CNY + Liabilities:Baitiao - 53.00 CNY + +2021/12/15 * 京东金融 - 白条主动还款 + ; category: "白条" + ; merchantId: "107500000000000000000058" + ; method: "小金库零用钱" + ; orderId: "2000000000000000000" + ; payTime: "2021-12-15 12:09:23 +0800 CST" + ; source: "京东金融" + ; status: "交易成功" + ; type: "不计收支" + Liabilities:Baitiao 1125.43 CNY + Assets:EPay:JD - 1125.43 CNY + +2021/12/15 * 京东平台商户 - 退款-freeplus洁面乳150ml + ; category: "网购" + ; merchantId: "2000000000000000954" + ; method: "京东白条" + ; orderId: "88438843884" + ; payTime: "2021-12-15 18:35:00 +0800 CST" + ; source: "京东平台商户" + ; status: "交易成功" + ; type: "收入" + Liabilities:Baitiao 112.98 CNY + Assets:FIXME - 112.98 CNY + +2021/12/15 * 京东平台商户 - 退款-freeplus洁面乳150ml + ; category: "网购" + ; merchantId: "2000000000000000000" + ; method: "京东白条" + ; orderId: "88438843884" + ; payTime: "2021-12-15 19:29:00 +0800 CST" + ; source: "京东平台商户" + ; status: "交易成功" + ; type: "收入" + Liabilities:Baitiao 1.00 CNY + Assets:FIXME - 1.00 CNY + +2021/12/15 * Food - Food + ; category: "食品酒饮" + ; merchantId: "110000000000000000000000" + ; method: "京东白条" + ; orderId: "200000000014" + ; payTime: "2021-12-15 19:35:14 +0800 CST" + ; source: "Food" + ; status: "交易成功" + ; type: "支出" + Expenses:FIXME 79.88 CNY + Liabilities:Baitiao - 79.88 CNY + +2021/12/16 * 京东平台商户 - 退款-freeplus洁面乳150ml + ; category: "网购" + ; merchantId: "1000000000000000000" + ; method: "京东白条" + ; orderId: "88438843884" + ; payTime: "2021-12-16 08:22:00 +0800 CST" + ; source: "京东平台商户" + ; status: "交易成功" + ; type: "收入" + Liabilities:Baitiao 1.00 CNY + Assets:FIXME - 1.00 CNY + +2021/12/18 * 京东平台商户 - freeplus洁面乳150ml + ; category: "美妆个护" + ; merchantId: "100000000000000000000002" + ; method: "京东白条" + ; orderId: "88438843884" + ; payTime: "2021-12-18 19:45:25 +0800 CST" + ; source: "京东平台商户" + ; status: "交易成功" + ; type: "支出" + Expenses:MakeUp 223.99 CNY + Liabilities:Baitiao - 223.99 CNY + +2021/12/23 * Members - Members Mark 泰国进口 椰子水 1L*6支 椰子汁饮料 + ; category: "食品酒饮" + ; merchantId: "110000000000000000000044" + ; method: "京东白条" + ; orderId: "234906303557" + ; payTime: "2021-12-23 12:04:37 +0800 CST" + ; source: "Members" + ; status: "交易成功" + ; type: "支出" + Expenses:Food 91.42 CNY + Liabilities:Baitiao - 91.42 CNY + +2021/12/28 * 京东小金库 - 京东小金库收益 + ; category: "小金库" + ; merchantId: "29999999999999999992" + ; method: "小金库零用钱" + ; orderId: "20000000000000000002" + ; payTime: "2021-12-28 12:14:38 +0800 CST" + ; source: "京东小金库" + ; status: "交易成功" + ; type: "收入" + Assets:EPay:JD 0.01 CNY + Income:PnL:JD - 0.01 CNY + +2021/12/29 * 京东小金库 - 京东小金库收益 + ; category: "小金库" + ; merchantId: "20000000000000000002" + ; method: "小金库零用钱" + ; orderId: "20000000000000000022" + ; payTime: "2021-12-29 06:37:20 +0800 CST" + ; source: "京东小金库" + ; status: "交易成功" + ; type: "收入" + Assets:EPay:JD 0.01 CNY + Income:PnL:JD - 0.01 CNY + +2023/12/08 * 京东云余额充值预付款网银账户 - PLUS京典年卡(先享后付) + ; category: "其他" + ; merchantId: "700000000000000" + ; method: "京东白条" + ; orderId: "700000000000008" + ; payTime: "2023-12-08 00:14:40 +0800 CST" + ; source: "京东云余额充值预付款网银账户" + ; status: "交易成功" + ; type: "支出" + Expenses:FIXME 99.00 CNY + Liabilities:Baitiao - 99.00 CNY + diff --git a/example/jd/example-jd-records.csv b/example/jd/example-jd-records.csv new file mode 100644 index 0000000..80dd315 --- /dev/null +++ b/example/jd/example-jd-records.csv @@ -0,0 +1,37 @@ +导出信息: +京东账号名:jd_whatthefuck +起始时间:2021-01-01 终止时间:2021-12-30 +导出交易类型:全部 +导出时间:2023-03-03 23:33:33 +共:212笔记录 +收入:31笔,15595.04元 +支出:154笔,60735.99元 +不计收支:27笔,69300.12元 + +特别提示: +1.本明细与实际交易结果不符时,以实际交易情况为准; +2.京东快捷支付等非余额支付方式可能既产生京东交易也同步产生银行交易,因此请勿使用本回单进行重复记账; +3.明细如经任何涂改、编造,均立即失去效力; +4.部分账单如:个人资金互转等不计入为收入或者支出,记为不计收支类; +5.因统计逻辑不同,明细金额直接累加后,可能会和下方统计金额不一致,请以实际交易金额为准; +6.禁止将本回单用于非法用途; +7.本明细仅展示当前账单中的交易,不包括已删除的记录; +8.本明细仅供个人对账使用。 + +电子回单客户 +交易时间,交易分类,商户名称,交易说明,收/支,金额,收/付款方式,交易状态,交易订单号,商家订单号,备注 +2023-12-08 00:15:08 ,其他,京东云余额充值预付款网银账户,解冻-PLUS京典年卡(先享后付),不计收支,0.00,京东白条,交易成功,700000000000000_1 ,700000000000000_1 , +2023-12-08 00:14:40 ,其他,京东云余额充值预付款网银账户,PLUS京典年卡(先享后付),支出,99.00,京东白条,交易成功,700000000000008 ,700000000000000 , +2022-12-04 23:29:31 ,其他,null,冻结-PLUS京典年卡(先享后付),不计收支,99.00,京东白条,交易成功,700000000000007 ,700000000000007 , +2021-12-29 06:37:20 ,小金库,京东小金库,京东小金库收益,收入,0.01,小金库零用钱,交易成功,20000000000000000022 ,20000000000000000002 , +2021-12-28 12:14:38 ,小金库,京东小金库,京东小金库收益,收入,0.01,小金库零用钱,交易成功,20000000000000000002 ,29999999999999999992 , +2021-12-23 12:04:37 ,食品酒饮,京东平台商户,Members Mark 泰国进口 椰子水 1L*6支 椰子汁饮料,支出,91.42,京东白条,交易成功,234906303557 ,110000000000000000000044 , +2021-12-18 19:45:25 ,美妆个护,京东平台商户,freeplus洁面乳150ml,支出,223.99,京东白条,交易成功,88438843884 ,100000000000000000000002 , +2021-12-16 08:22:00 ,网购,京东平台商户,退款-freeplus洁面乳150ml,收入,1.00,京东白条,交易成功,88438843884 ,1000000000000000000 , +2021-12-12 19:06:00 ,线下消费,京东平台商户,亲密卡,支出,53.00,京东白条,交易成功, ,100000000000000000000045 , +2021-12-15 12:09:23 ,白条,京东金融,白条主动还款,不计收支,1125.43,小金库零用钱,交易成功,2000000000000000000 ,107500000000000000000058 , +2021-12-11 23:51:52 ,线下消费,京东平台商户,亲密卡,支出,238.18,京东白条,交易成功, ,160000000000000000000006 , +2021-12-15 19:35:14 ,食品酒饮,京东平台商户,Food,支出,79.88,京东白条,交易成功,200000000014 ,110000000000000000000000 , +2021-12-15 19:29:00 ,网购,京东平台商户,退款-freeplus洁面乳150ml,收入,1.00,京东白条,交易成功,88438843884 ,2000000000000000000 , +2021-12-15 18:35:00 ,网购,京东平台商户,退款-freeplus洁面乳150ml,收入,112.98,京东白条,交易成功,88438843884 ,2000000000000000954 , +2021-11-17 23:06:50 ,小金库,京东金融,京东小金库-转入,不计收支,2990.13,银行卡,交易成功,2000000000000079 ,100000000000000000000027 , \ No newline at end of file diff --git a/pkg/analyser/interface.go b/pkg/analyser/interface.go index 8ea90ba..189b252 100644 --- a/pkg/analyser/interface.go +++ b/pkg/analyser/interface.go @@ -5,6 +5,7 @@ import ( "github.com/deb-sig/double-entry-generator/pkg/analyser/bmo" "github.com/deb-sig/double-entry-generator/pkg/analyser/icbc" + "github.com/deb-sig/double-entry-generator/pkg/analyser/jd" "github.com/deb-sig/double-entry-generator/pkg/analyser/td" "github.com/deb-sig/double-entry-generator/pkg/analyser/alipay" @@ -39,6 +40,9 @@ func New(providerName string) (Interface, error) { return td.Td{}, nil case consts.ProviderBmo: return bmo.Bmo{}, nil + case consts.ProviderJD: + return jd.JD{}, nil + default: return nil, fmt.Errorf("Fail to create the analyser for the given name %s", providerName) } diff --git a/pkg/analyser/jd/jd.go b/pkg/analyser/jd/jd.go new file mode 100644 index 0000000..b9f893f --- /dev/null +++ b/pkg/analyser/jd/jd.go @@ -0,0 +1,132 @@ +package jd + +import ( + "log" + "strings" + + "github.com/deb-sig/double-entry-generator/pkg/config" + "github.com/deb-sig/double-entry-generator/pkg/ir" + "github.com/deb-sig/double-entry-generator/pkg/util" +) + +type JD struct { +} + +// GetAllCandidateAccounts returns all accounts defined in config. +func (a JD) GetAllCandidateAccounts(cfg *config.Config) map[string]bool { + // uniqMap will be used to create the concepts. + uniqMap := make(map[string]bool) + + if cfg.JD == nil || len(cfg.JD.Rules) == 0 { + return uniqMap + } + + for _, r := range cfg.JD.Rules { + if r.MethodAccount != nil { + uniqMap[*r.MethodAccount] = true + } + if r.TargetAccount != nil { + uniqMap[*r.TargetAccount] = true + } + if r.PnlAccount != nil { + uniqMap[*r.PnlAccount] = true + } + } + uniqMap[cfg.DefaultPlusAccount] = true + uniqMap[cfg.DefaultMinusAccount] = true + return uniqMap +} + +// GetAccounts returns minus and plus account. +func (a JD) GetAccountsAndTags(o *ir.Order, cfg *config.Config, target, provider string) (bool, string, string, map[ir.Account]string, []string) { + ignore := false + + if cfg.JD == nil || len(cfg.JD.Rules) == 0 { + return ignore, cfg.DefaultMinusAccount, cfg.DefaultPlusAccount, nil, nil + } + resMinus := cfg.DefaultMinusAccount + resPlus := cfg.DefaultPlusAccount + var extraAccounts map[ir.Account]string + var tags = make([]string, 0) + + var err error + for _, r := range cfg.JD.Rules { + match := true + // get separator + sep := "," + if r.Separator != nil { + sep = *r.Separator + } + + matchFunc := util.SplitFindContains + if r.FullMatch { + matchFunc = util.SplitFindEquals + } + + if r.Peer != nil { + match = matchFunc(*r.Peer, o.Peer, sep, match) + } + if r.Type != nil { + match = matchFunc(*r.Type, o.TypeOriginal, sep, match) + } + if r.Item != nil { + match = matchFunc(*r.Item, o.Item, sep, match) + } + if r.Method != nil { + match = matchFunc(*r.Method, o.Method, sep, match) + } + if r.Category != nil { + match = matchFunc(*r.Category, o.Category, sep, match) + } + if r.Time != nil { + match, err = util.SplitFindTimeInterval(*r.Time, o.PayTime, match) + if err != nil { + log.Fatalf(err.Error()) + } + } + if r.TimestampRange != nil { + match, err = util.SplitFindTimeStampInterval(*r.TimestampRange, o.PayTime, match) + if err != nil { + log.Fatalf(err.Error()) + } + } + + if match { + if r.Ignore { + ignore = true + break + } + + if r.TargetAccount != nil { + if o.Type == ir.TypeRecv { + resMinus = *r.TargetAccount + } else { + resPlus = *r.TargetAccount + } + } + if r.MethodAccount != nil { + if o.Type == ir.TypeRecv { + resPlus = *r.MethodAccount + } else { + resMinus = *r.MethodAccount + } + } + if r.PnlAccount != nil { + extraAccounts = map[ir.Account]string{ + ir.PnlAccount: *r.PnlAccount, + } + } + + if r.Tags != nil { + tags = strings.Split(*r.Tags, sep) + } + + } + } + + if o.TypeOriginal == "不计收支" && (strings.HasPrefix(o.Item, "冻结-") || + strings.HasPrefix(o.Item, "解冻-")) { + ignore = true + } + return ignore, resMinus, resPlus, extraAccounts, tags +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 91f5f45..2b30a39 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -6,6 +6,7 @@ import ( "github.com/deb-sig/double-entry-generator/pkg/provider/htsec" "github.com/deb-sig/double-entry-generator/pkg/provider/huobi" "github.com/deb-sig/double-entry-generator/pkg/provider/icbc" + "github.com/deb-sig/double-entry-generator/pkg/provider/jd" "github.com/deb-sig/double-entry-generator/pkg/provider/td" "github.com/deb-sig/double-entry-generator/pkg/provider/wechat" ) @@ -27,4 +28,5 @@ type Config struct { Icbc *icbc.Config `yaml:"icbc,omitempty"` Td *td.Config `yaml:"td,omitempty"` Bmo *bmo.Config `yaml:"bmo,omitempty"` + JD *jd.Config `yaml:"jd,omitempty"` } diff --git a/pkg/consts/consts.go b/pkg/consts/consts.go index cf65756..94ea566 100644 --- a/pkg/consts/consts.go +++ b/pkg/consts/consts.go @@ -36,4 +36,6 @@ const ( ProviderTd = "td" //ProviderBmo is the name for BMO provider. ProviderBmo = "bmo" + //ProviderJD is the name for JD provider + ProviderJD = "jd" ) diff --git a/pkg/provider/alipay/convert.go b/pkg/provider/alipay/convert.go index 0e46167..0b04f35 100644 --- a/pkg/provider/alipay/convert.go +++ b/pkg/provider/alipay/convert.go @@ -41,7 +41,8 @@ func convertType(t Type) ir.Type { } // getMetadata get the metadata (e.g. status, method, category and so on.) -// from order. +// +// from order. func getMetadata(o Order) map[string]string { // FIXME(TripleZ): hard-coded, bad pattern source := "支付宝" diff --git a/pkg/provider/interface.go b/pkg/provider/interface.go index b85254a..702806c 100644 --- a/pkg/provider/interface.go +++ b/pkg/provider/interface.go @@ -21,6 +21,7 @@ import ( "github.com/deb-sig/double-entry-generator/pkg/provider/bmo" "github.com/deb-sig/double-entry-generator/pkg/provider/icbc" + "github.com/deb-sig/double-entry-generator/pkg/provider/jd" "github.com/deb-sig/double-entry-generator/pkg/provider/td" "github.com/deb-sig/double-entry-generator/pkg/consts" @@ -53,6 +54,9 @@ func New(name string) (Interface, error) { return td.New(), nil case consts.ProviderBmo: return bmo.New(), nil + case consts.ProviderJD: + + return jd.New(), nil default: return nil, fmt.Errorf("Fail to create the provider for the given name %s", name) } diff --git a/pkg/provider/jd/config.go b/pkg/provider/jd/config.go new file mode 100644 index 0000000..d265bef --- /dev/null +++ b/pkg/provider/jd/config.go @@ -0,0 +1,25 @@ +package jd + +// Config is the configuration for JD. +type Config struct { + Rules []Rule `mapstructure:"rules,omitempty"` +} + +// Rule is the type for match rules. +type Rule struct { + Peer *string `mapstructure:"peer,omitempty"` + Item *string `mapstructure:"item,omitempty"` + Category *string `mapstructure:"category,omitempty"` + Type *string `mapstructure:"type,omitempty"` + Method *string `mapstructure:"method,omitempty"` + OrderStatus *string `mapstructure:"orderStatus,omitempty"` + Separator *string `mapstructure:"sep,omitempty"` // default: , + Time *string `mapstructure:"time,omitempty"` + TimestampRange *string `mapstructure:"timestamp_range,omitempty"` + MethodAccount *string `mapstructure:"methodAccount,omitempty"` + TargetAccount *string `mapstructure:"targetAccount,omitempty"` + PnlAccount *string `mapstructure:"pnlAccount,omitempty"` + FullMatch bool `mapstructure:"fullMatch,omitempty"` + Tags *string `mapstructure:"tags,omitempty"` + Ignore bool `mapstructure:"ignore,omitempty"` // default: false +} diff --git a/pkg/provider/jd/jd.go b/pkg/provider/jd/jd.go new file mode 100644 index 0000000..e525d7c --- /dev/null +++ b/pkg/provider/jd/jd.go @@ -0,0 +1,224 @@ +/* +Copyright © 2024 CNLHC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package jd + +import ( + "encoding/csv" + "fmt" + "io" + "log" + "strconv" + "strings" + "time" + + "github.com/deb-sig/double-entry-generator/pkg/io/reader" + "github.com/deb-sig/double-entry-generator/pkg/ir" +) + +const ( + TypeSend Type = "支出" + TypeRecv Type = "收入" + TypeOthers Type = "不计收支" + TypeUnknown Type = "未知" +) + +var ( + timeFormat = "2006-01-02 15:04:05 -0700 CST" +) + +type ( + JD struct { + LineNum int `json:"line_num,omitempty"` + Orders []Order `json:"orders,omitempty"` + + // TitleParsed is a workaround to ignore the title row. + TitleParsed bool `json:"title_parsed,omitempty"` + } + Type string + + Order struct { + PayTime time.Time `json:"payTime,omitempty"` // 交易时间 + Category string `json:"category,omitempty"` // 交易分类 + Peer string `json:"peer,omitempty"` // 商户名称 + PeerType string `json:"peerType,omitempty"` + ItemName string `json:"itemName,omitempty"` // 交易说明 + Type Type `json:"type,omitempty"` // 收/支 + Money int64 `json:"money,omitempty"` // 金额 + Method string `json:"method,omitempty"` // 收/付款方式 + Status string `json:"status,omitempty"` // 交易状态 + DealNo string `json:"dealNo,omitempty"` // 交易订单号 + MerchantId string `json:"merchantId,omitempty"` // 商家订单号 + Notes string `json:"notes,omitempty"` // 交易备注 + + // below is filled at runtime + TargetAccount string `json:"targetAccount,omitempty"` + MethodAccount string `json:"methodAccount,omitempty"` + } +) + +func New() *JD { + return &JD{} + +} + +func (c *JD) Translate(fn string) (*ir.IR, error) { + + log.SetPrefix("[Provider-JD] ") + r, err := reader.GetReader(fn) + if err != nil { + return nil, fmt.Errorf("can not get bill reader. %w", err) + } + csvReader := csv.NewReader(r) + csvReader.LazyQuotes = true + csvReader.FieldsPerRecord = -1 + res := ir.New() + + for { + row, err := csvReader.Read() + if err == io.EOF { + break + } else if err != nil { + return nil, fmt.Errorf("io error: %w", err) + } + c.LineNum++ + if c.LineNum < 21 { + // skip header + continue + } + err = c.translateLine(row) + if err != nil { + return nil, fmt.Errorf("failed to translate bill: line %d: %w ", c.LineNum, err) + } + } + for _, o := range c.Orders { + res.Orders = append(res.Orders, c.convertToIR(o)) + } + return res, nil + +} + +func (c *JD) translateLine(row []string) error { + var ( + bill Order + err error + ) + + if len(row) < 11 { + return fmt.Errorf("row length is less than expected(11)") + } + for idx, a := range row { + a = strings.Trim(a, " ") + a = strings.Trim(a, "\t") + row[idx] = a + } + + bill.PayTime, err = time.Parse(timeFormat, row[0]+" +0800 CST") + if err != nil { + return err + } + bill.Category = row[1] + bill.PeerType = row[2] + bill.ItemName = row[3] + + bill.Type = c.translateType(row[4]) + bill.Money, err = c.translateValue(row[5]) + if err != nil { + return err + } + bill.Method = row[6] + bill.Status = row[7] + bill.DealNo = row[8] + bill.MerchantId = row[9] + bill.Notes = row[10] + + if bill.PeerType == "京东平台商户" { + realPeer := strings.Split(bill.ItemName, " ") + if !strings.HasPrefix(bill.ItemName, "退款") && + len(realPeer) > 0 && + len(realPeer[0]) < 15 { + bill.Peer = realPeer[0] + } else { + bill.Peer = bill.PeerType + } + } else { + bill.Peer = bill.PeerType + } + + c.Orders = append(c.Orders, bill) + + return nil +} + +func (c *JD) translateType(s string) Type { + switch Type(s) { + case TypeRecv: + return TypeRecv + case TypeSend: + return TypeSend + case TypeOthers: + return TypeOthers + default: + return TypeUnknown + } +} + +func (c *JD) convertToIRType(s Type) ir.Type { + switch s { + case TypeRecv: + return ir.TypeRecv + case TypeSend: + return ir.TypeSend + default: + return ir.TypeUnknown + } +} + +func (c *JD) translateValue(s string) (int64, error) { + s = strings.ReplaceAll(s, ".", "") + return strconv.ParseInt(s, 10, 64) +} + +func (c *JD) convertToIR(s Order) ir.Order { + return ir.Order{ + OrderType: ir.OrderTypeNormal, + Peer: s.Peer, + Item: s.ItemName, + Category: s.Category, + MerchantOrderID: &s.MerchantId, + OrderID: &s.DealNo, + Money: float64(s.Money) / 100.0, + Note: s.Notes, + PayTime: s.PayTime, + Type: c.convertToIRType(s.Type), + Method: s.Method, + Metadata: c.getMetadata(s), + TypeOriginal: string(s.Type), + } + +} + +func (*JD) getMetadata(s Order) map[string]string { + return map[string]string{ + "source": s.Peer, + "category": s.Category, + "payTime": s.PayTime.Format(timeFormat), + "orderId": s.DealNo, + "merchantId": s.MerchantId, + "type": string(s.Type), + "method": s.Method, + "status": s.Status, + } +} diff --git a/test/jd-test-beancout.sh b/test/jd-test-beancout.sh new file mode 100644 index 0000000..ad86937 --- /dev/null +++ b/test/jd-test-beancout.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# +# E2E test for jd provider. + +# set -x # debug +set -eo errexit + +TEST_DIR=`dirname "$(realpath $0)"` +ROOT_DIR="$TEST_DIR/.." + +make -f "$ROOT_DIR/Makefile" build +mkdir -p "$ROOT_DIR/test/output" + +# generate jd bills output in beancount format +"$ROOT_DIR/bin/double-entry-generator" translate \ + --provider jd \ + --config "$ROOT_DIR/example/jd/config.yaml" \ + --output "$ROOT_DIR/test/output/test-jd-output.beancount" \ + "$ROOT_DIR/example/jd/example-jd-records.csv" + +diff -u --color \ + "$ROOT_DIR/example/jd/example-jd-output.beancount" \ + "$ROOT_DIR/test/output/test-jd-output.beancount" + +if [ $? -ne 0 ]; then + echo "[FAIL] jd provider output is different from expected output." + exit 1 +fi + +echo "[PASS] All jd provider tests!" diff --git a/test/jd-test-ledger.sh b/test/jd-test-ledger.sh new file mode 100644 index 0000000..a9091e6 --- /dev/null +++ b/test/jd-test-ledger.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# +# E2E test for jd provider. + +# set -x # debug +set -eo errexit + +TEST_DIR=$(dirname "$(realpath $0)") +ROOT_DIR="$TEST_DIR/.." + +make -f "$ROOT_DIR/Makefile" build +mkdir -p "$ROOT_DIR/test/output" +OUTPUT="$ROOT_DIR/test/output/test-jd-output.ledger" + +# generate jd bills output in ledger format +"$ROOT_DIR/bin/double-entry-generator" translate \ + --provider jd \ + --target ledger \ + --config "$ROOT_DIR/example/jd/config.yaml" \ + --output "$OUTPUT" \ + "$ROOT_DIR/example/jd/example-jd-records.csv" + +diff -u --color \ + "$ROOT_DIR/example/jd/example-jd-output.ledger" \ + "$OUTPUT" + +if [ $? -ne 0 ]; then + echo "[FAIL] jd provider output is different from expected output." + exit 1 +fi + +echo "[PASS] All jd provider for ledger target tests!"