support importing transaction data from alipay app
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
package alipay
|
||||
|
||||
// alipayAppTransactionDataCsvImporter defines the structure of alipay app csv importer for transaction data
|
||||
type alipayAppTransactionDataCsvImporter struct {
|
||||
alipayTransactionDataCsvImporter
|
||||
}
|
||||
|
||||
// Initialize a alipay app transaction data csv file importer singleton instance
|
||||
var (
|
||||
AlipayAppTransactionDataCsvImporter = &alipayAppTransactionDataCsvImporter{
|
||||
alipayTransactionDataCsvImporter{
|
||||
fileHeaderLine: "------------------------------------------------------------------------------------",
|
||||
dataHeaderStartContent: "支付宝(中国)网络技术有限公司 电子客户回单",
|
||||
timeColumnName: "交易时间",
|
||||
categoryColumnName: "交易分类",
|
||||
targetNameColumnName: "交易对方",
|
||||
productNameColumnName: "商品说明",
|
||||
amountColumnName: "金额",
|
||||
typeColumnName: "收/支",
|
||||
relatedAccountColumnName: "收/付款方式",
|
||||
statusColumnName: "交易状态",
|
||||
descriptionColumnName: "备注",
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,373 @@
|
||||
package alipay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"golang.org/x/text/transform"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/locales"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
const alipayTransactionDataStatusSuccessName = "交易成功"
|
||||
const alipayTransactionDataStatusPaymentSuccessName = "支付成功"
|
||||
const alipayTransactionDataStatusRepaymentSuccessName = "还款成功"
|
||||
const alipayTransactionDataStatusClosedName = "交易关闭"
|
||||
const alipayTransactionDataStatusRefundSuccessName = "退款成功"
|
||||
const alipayTransactionDataStatusTaxRefundSuccessName = "退税成功"
|
||||
|
||||
const alipayTransactionDataProductNameRechargePrefix = "充值-"
|
||||
const alipayTransactionDataProductNameCashWithdrawalPrefix = "提现-"
|
||||
const alipayTransactionDataProductNameTransferInText = "转入"
|
||||
const alipayTransactionDataProductNameTransferOutText = "转出"
|
||||
const alipayTransactionDataProductNameRepaymentText = "还款"
|
||||
|
||||
var alipayTransactionTypeNameMapping = map[models.TransactionType]string{
|
||||
models.TRANSACTION_TYPE_INCOME: "收入",
|
||||
models.TRANSACTION_TYPE_EXPENSE: "支出",
|
||||
models.TRANSACTION_TYPE_TRANSFER: "不计收支",
|
||||
}
|
||||
|
||||
// alipayTransactionDataCsvImporter defines the structure of alipay csv importer for transaction data
|
||||
type alipayTransactionDataCsvImporter struct {
|
||||
fileHeaderLine string
|
||||
dataHeaderStartContent string
|
||||
dataBottomEndLineRune rune
|
||||
timeColumnName string
|
||||
categoryColumnName string
|
||||
targetNameColumnName string
|
||||
productNameColumnName string
|
||||
amountColumnName string
|
||||
typeColumnName string
|
||||
relatedAccountColumnName string
|
||||
statusColumnName string
|
||||
descriptionColumnName string
|
||||
}
|
||||
|
||||
// ParseImportedData returns the imported data by parsing the alipay transaction csv data
|
||||
func (c *alipayTransactionDataCsvImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]*models.TransactionCategory, incomeCategoryMap map[string]*models.TransactionCategory, transferCategoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
|
||||
enc := simplifiedchinese.GB18030
|
||||
reader := transform.NewReader(bytes.NewReader(data), enc.NewDecoder())
|
||||
allLines, err := c.parseAllLinesFromCsvData(ctx, reader)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
if len(allLines) <= 1 {
|
||||
log.Errorf(ctx, "[alipayTransactionDataCsvImporter.ParseImportedData] cannot parse import data for user \"uid:%d\", because data table row count is less 1", user.Uid)
|
||||
return nil, nil, nil, nil, nil, nil, errs.ErrNotFoundTransactionDataInFile
|
||||
}
|
||||
|
||||
headerLineItems := allLines[0]
|
||||
headerItemMap := make(map[string]int)
|
||||
|
||||
for i := 0; i < len(headerLineItems); i++ {
|
||||
headerItemMap[headerLineItems[i]] = i
|
||||
}
|
||||
|
||||
timeColumnIdx, timeColumnExists := headerItemMap[c.timeColumnName]
|
||||
categoryColumnIdx, categoryColumnExists := headerItemMap[c.categoryColumnName]
|
||||
targetNameColumnIdx, targetNameColumnExists := headerItemMap[c.targetNameColumnName]
|
||||
productNameColumnIdx, productNameColumnExists := headerItemMap[c.productNameColumnName]
|
||||
amountColumnIdx, amountColumnExists := headerItemMap[c.amountColumnName]
|
||||
typeColumnIdx, typeColumnExists := headerItemMap[c.typeColumnName]
|
||||
relatedAccountColumnIdx, relatedAccountColumnExists := headerItemMap[c.relatedAccountColumnName]
|
||||
statusColumnIdx, statusColumnExists := headerItemMap[c.statusColumnName]
|
||||
descriptionColumnIdx, descriptionColumnExists := headerItemMap[c.descriptionColumnName]
|
||||
|
||||
if !timeColumnExists || !amountColumnExists || !typeColumnExists || !statusColumnExists {
|
||||
log.Errorf(ctx, "[alipayTransactionDataCsvImporter.ParseImportedData] cannot parse import data for user \"uid:%d\", because missing essential columns in header row", user.Uid)
|
||||
return nil, nil, nil, nil, nil, nil, errs.ErrMissingRequiredFieldInHeaderRow
|
||||
}
|
||||
|
||||
newColumns := make([]datatable.DataTableColumn, 0, 7)
|
||||
newColumns = append(newColumns, datatable.DATA_TABLE_TRANSACTION_TYPE)
|
||||
newColumns = append(newColumns, datatable.DATA_TABLE_TRANSACTION_TIME)
|
||||
newColumns = append(newColumns, datatable.DATA_TABLE_SUB_CATEGORY)
|
||||
newColumns = append(newColumns, datatable.DATA_TABLE_ACCOUNT_NAME)
|
||||
newColumns = append(newColumns, datatable.DATA_TABLE_AMOUNT)
|
||||
newColumns = append(newColumns, datatable.DATA_TABLE_RELATED_ACCOUNT_NAME)
|
||||
newColumns = append(newColumns, datatable.DATA_TABLE_DESCRIPTION)
|
||||
|
||||
dataTable := datatable.CreateNewWritableDataTable(newColumns)
|
||||
|
||||
for i := 1; i < len(allLines); i++ {
|
||||
items := allLines[i]
|
||||
|
||||
if len(items) < len(headerLineItems) {
|
||||
log.Errorf(ctx, "[alipayTransactionDataCsvImporter.ParseImportedData] cannot parse row \"index:%d\" for user \"uid:%d\", because may missing some columns (column count %d in data row is less than header column count %d)", i, user.Uid, len(items), len(headerLineItems))
|
||||
return nil, nil, nil, nil, nil, nil, errs.ErrFewerFieldsInDataRowThanInHeaderRow
|
||||
}
|
||||
|
||||
if items[typeColumnIdx] != alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] &&
|
||||
items[typeColumnIdx] != alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] &&
|
||||
items[typeColumnIdx] != alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER] {
|
||||
log.Warnf(ctx, "[alipayTransactionDataCsvImporter.ParseImportedData] skip parsing transaction in row \"index:%d\" for user \"uid:%d\", because type is \"%s\"", i, user.Uid, items[typeColumnIdx])
|
||||
continue
|
||||
}
|
||||
|
||||
if items[statusColumnIdx] != alipayTransactionDataStatusSuccessName &&
|
||||
items[statusColumnIdx] != alipayTransactionDataStatusPaymentSuccessName &&
|
||||
items[statusColumnIdx] != alipayTransactionDataStatusRepaymentSuccessName &&
|
||||
items[statusColumnIdx] != alipayTransactionDataStatusClosedName &&
|
||||
items[statusColumnIdx] != alipayTransactionDataStatusRefundSuccessName &&
|
||||
items[statusColumnIdx] != alipayTransactionDataStatusTaxRefundSuccessName {
|
||||
log.Warnf(ctx, "[alipayTransactionDataCsvImporter.ParseImportedData] skip parsing transaction in row \"index:%d\" for user \"uid:%d\", because status is \"%s\"", i, user.Uid, items[statusColumnIdx])
|
||||
continue
|
||||
}
|
||||
|
||||
data, errMsg := c.parseTransactionData(ctx,
|
||||
user,
|
||||
items,
|
||||
timeColumnIdx,
|
||||
timeColumnExists,
|
||||
categoryColumnIdx,
|
||||
categoryColumnExists,
|
||||
targetNameColumnIdx,
|
||||
targetNameColumnExists,
|
||||
productNameColumnIdx,
|
||||
productNameColumnExists,
|
||||
amountColumnIdx,
|
||||
amountColumnExists,
|
||||
typeColumnIdx,
|
||||
typeColumnExists,
|
||||
relatedAccountColumnIdx,
|
||||
relatedAccountColumnExists,
|
||||
statusColumnIdx,
|
||||
statusColumnExists,
|
||||
descriptionColumnIdx,
|
||||
descriptionColumnExists,
|
||||
)
|
||||
|
||||
if data == nil {
|
||||
log.Warnf(ctx, "[alipayTransactionDataCsvImporter.ParseImportedData] skip parsing transaction in row \"index:%d\" for user \"uid:%d\", because %s", i, user.Uid, errMsg)
|
||||
continue
|
||||
}
|
||||
|
||||
dataTable.Add(data)
|
||||
}
|
||||
|
||||
dataTableImporter := datatable.CreateNewSimpleImporterFromWritableDataTable(
|
||||
dataTable,
|
||||
alipayTransactionTypeNameMapping,
|
||||
)
|
||||
|
||||
return dataTableImporter.ParseImportedData(ctx, user, dataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||
}
|
||||
|
||||
func (c *alipayTransactionDataCsvImporter) parseAllLinesFromCsvData(ctx core.Context, reader io.Reader) ([][]string, error) {
|
||||
csvReader := csv.NewReader(reader)
|
||||
csvReader.FieldsPerRecord = -1
|
||||
|
||||
allLines := make([][]string, 0)
|
||||
hasFileHeader := false
|
||||
foundContentBeforeDataHeaderLine := false
|
||||
|
||||
for {
|
||||
items, err := csvReader.Read()
|
||||
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[alipayTransactionDataCsvImporter.parseAllLinesFromCsvData] cannot parse alipay csv data, because %s", err.Error())
|
||||
return nil, errs.ErrInvalidCSVFile
|
||||
}
|
||||
|
||||
if !hasFileHeader {
|
||||
if len(items) <= 0 {
|
||||
continue
|
||||
} else if strings.Index(items[0], c.fileHeaderLine) == 0 {
|
||||
hasFileHeader = true
|
||||
continue
|
||||
} else {
|
||||
log.Warnf(ctx, "[alipayTransactionDataCsvImporter.parseAllLinesFromCsvData] read unexpected line before read file header, line content is %s", strings.Join(items, ","))
|
||||
}
|
||||
}
|
||||
|
||||
if !foundContentBeforeDataHeaderLine {
|
||||
if len(items) <= 0 {
|
||||
continue
|
||||
} else if strings.Index(items[0], c.dataHeaderStartContent) >= 0 {
|
||||
foundContentBeforeDataHeaderLine = true
|
||||
continue
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if foundContentBeforeDataHeaderLine {
|
||||
if len(items) <= 0 {
|
||||
continue
|
||||
} else if len(items) == 1 && c.dataBottomEndLineRune > 0 && utils.ContainsOnlyOneRune(items[0], c.dataBottomEndLineRune) {
|
||||
break
|
||||
}
|
||||
|
||||
for i := 0; i < len(items); i++ {
|
||||
items[i] = strings.Trim(items[i], " ")
|
||||
}
|
||||
|
||||
allLines = append(allLines, items)
|
||||
}
|
||||
}
|
||||
|
||||
if !hasFileHeader || !foundContentBeforeDataHeaderLine {
|
||||
return nil, errs.ErrInvalidFileHeader
|
||||
}
|
||||
|
||||
return allLines, nil
|
||||
}
|
||||
|
||||
func (c *alipayTransactionDataCsvImporter) parseTransactionData(
|
||||
ctx core.Context,
|
||||
user *models.User,
|
||||
items []string,
|
||||
timeColumnIdx int,
|
||||
timeColumnExists bool,
|
||||
categoryColumnIdx int,
|
||||
categoryColumnExists bool,
|
||||
targetNameColumnIdx int,
|
||||
targetNameColumnExists bool,
|
||||
productNameColumnIdx int,
|
||||
productNameColumnExists bool,
|
||||
amountColumnIdx int,
|
||||
amountColumnExists bool,
|
||||
typeColumnIdx int,
|
||||
typeColumnExists bool,
|
||||
relatedAccountColumnIdx int,
|
||||
relatedAccountColumnExists bool,
|
||||
statusColumnIdx int,
|
||||
statusColumnExists bool,
|
||||
descriptionColumnIdx int,
|
||||
descriptionColumnExists bool,
|
||||
) (map[datatable.DataTableColumn]string, string) {
|
||||
data := make(map[datatable.DataTableColumn]string, 11)
|
||||
|
||||
if timeColumnExists && timeColumnIdx < len(items) {
|
||||
data[datatable.DATA_TABLE_TRANSACTION_TIME] = items[timeColumnIdx]
|
||||
}
|
||||
|
||||
if categoryColumnExists && categoryColumnIdx < len(items) {
|
||||
data[datatable.DATA_TABLE_SUB_CATEGORY] = items[categoryColumnIdx]
|
||||
} else {
|
||||
data[datatable.DATA_TABLE_SUB_CATEGORY] = ""
|
||||
}
|
||||
|
||||
if amountColumnExists && amountColumnIdx < len(items) {
|
||||
data[datatable.DATA_TABLE_AMOUNT] = items[amountColumnIdx]
|
||||
}
|
||||
|
||||
if descriptionColumnExists && descriptionColumnIdx < len(items) && items[descriptionColumnIdx] != "" {
|
||||
data[datatable.DATA_TABLE_DESCRIPTION] = items[descriptionColumnIdx]
|
||||
} else if productNameColumnExists && productNameColumnIdx < len(items) && items[productNameColumnIdx] != "" {
|
||||
data[datatable.DATA_TABLE_DESCRIPTION] = items[productNameColumnIdx]
|
||||
} else {
|
||||
data[datatable.DATA_TABLE_DESCRIPTION] = ""
|
||||
}
|
||||
|
||||
relatedAccountName := ""
|
||||
|
||||
if relatedAccountColumnExists && relatedAccountColumnIdx < len(items) {
|
||||
relatedAccountName = items[relatedAccountColumnIdx]
|
||||
}
|
||||
|
||||
statusName := ""
|
||||
|
||||
if statusColumnExists && statusColumnIdx < len(items) {
|
||||
statusName = items[statusColumnIdx]
|
||||
}
|
||||
|
||||
locale := user.Language
|
||||
|
||||
if locale == "" {
|
||||
locale = ctx.GetClientLocale()
|
||||
}
|
||||
|
||||
localeTextItems := locales.GetLocaleTextItems(locale)
|
||||
|
||||
if typeColumnExists && typeColumnIdx < len(items) {
|
||||
data[datatable.DATA_TABLE_TRANSACTION_TYPE] = items[typeColumnIdx]
|
||||
|
||||
if items[typeColumnIdx] == alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] {
|
||||
if statusName == alipayTransactionDataStatusClosedName {
|
||||
return nil, fmt.Sprintf("income transaction is closed")
|
||||
}
|
||||
|
||||
if statusName == alipayTransactionDataStatusSuccessName {
|
||||
data[datatable.DATA_TABLE_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay
|
||||
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
|
||||
} else {
|
||||
data[datatable.DATA_TABLE_ACCOUNT_NAME] = ""
|
||||
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
|
||||
}
|
||||
} else if items[typeColumnIdx] == alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER] {
|
||||
if statusName == alipayTransactionDataStatusClosedName {
|
||||
return nil, fmt.Sprintf("non-income/expense transaction is closed")
|
||||
}
|
||||
|
||||
targetName := ""
|
||||
productName := ""
|
||||
|
||||
if targetNameColumnExists && targetNameColumnIdx < len(items) {
|
||||
targetName = items[targetNameColumnIdx]
|
||||
}
|
||||
|
||||
if productNameColumnExists && productNameColumnIdx < len(items) {
|
||||
productName = items[productNameColumnIdx]
|
||||
}
|
||||
|
||||
if statusName == alipayTransactionDataStatusRefundSuccessName {
|
||||
data[datatable.DATA_TABLE_TRANSACTION_TYPE] = alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
|
||||
data[datatable.DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
||||
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
|
||||
} else {
|
||||
if strings.Index(productName, alipayTransactionDataProductNameRechargePrefix) == 0 { // transfer to alipay wallet
|
||||
data[datatable.DATA_TABLE_ACCOUNT_NAME] = ""
|
||||
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay
|
||||
} else if strings.Index(productName, alipayTransactionDataProductNameCashWithdrawalPrefix) == 0 { // transfer from alipay wallet
|
||||
data[datatable.DATA_TABLE_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay
|
||||
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
||||
} else if strings.Index(productName, alipayTransactionDataProductNameTransferInText) >= 0 { // transfer in
|
||||
data[datatable.DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
||||
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
||||
} else if strings.Index(productName, alipayTransactionDataProductNameTransferOutText) >= 0 { // transfer out
|
||||
data[datatable.DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
||||
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
||||
} else if strings.Index(productName, alipayTransactionDataProductNameRepaymentText) >= 0 { // repayment
|
||||
data[datatable.DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
||||
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
||||
} else {
|
||||
return nil, fmt.Sprintf("product name (\"%s\") is unknown", productName)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
data[datatable.DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
||||
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
|
||||
}
|
||||
}
|
||||
|
||||
if data[datatable.DATA_TABLE_TRANSACTION_TYPE] == alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] && statusName != "" {
|
||||
if statusName == alipayTransactionDataStatusRefundSuccessName || statusName == alipayTransactionDataStatusTaxRefundSuccessName {
|
||||
amount, err := utils.ParseAmount(data[datatable.DATA_TABLE_AMOUNT])
|
||||
|
||||
if err == nil {
|
||||
data[datatable.DATA_TABLE_TRANSACTION_TYPE] = alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
|
||||
data[datatable.DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data, ""
|
||||
}
|
||||
@@ -1,349 +1,26 @@
|
||||
package alipay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"golang.org/x/text/transform"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/locales"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
const alipayWebTransactionDataCsvFileHeader = "支付宝交易记录明细查询"
|
||||
const alipayWebTransactionDataCsvDataHeaderLineStartContent = "交易记录明细列表"
|
||||
const alipayWebTransactionDataCsvDataDataLineEndLineRune = '-'
|
||||
|
||||
const alipayWebTransactionDataStatusSuccessName = "交易成功"
|
||||
const alipayWebTransactionDataStatusPaymentSuccessName = "支付成功"
|
||||
const alipayWebTransactionDataStatusRepaymentSuccessName = "还款成功"
|
||||
const alipayWebTransactionDataStatusClosedName = "交易关闭"
|
||||
const alipayWebTransactionDataStatusRefundSuccessName = "退款成功"
|
||||
const alipayWebTransactionDataStatusTaxRefundSuccessName = "退税成功"
|
||||
|
||||
const alipayWebTransactionDataProductNameRechargePrefix = "充值-"
|
||||
const alipayWebTransactionDataProductNameCashWithdrawalPrefix = "提现-"
|
||||
const alipayWebTransactionDataProductNameTransferInText = "转入"
|
||||
const alipayWebTransactionDataProductNameTransferOutText = "转出"
|
||||
const alipayWebTransactionDataProductNameRepaymentText = "还款"
|
||||
|
||||
var alipayTransactionTypeNameMapping = map[models.TransactionType]string{
|
||||
models.TRANSACTION_TYPE_INCOME: "收入",
|
||||
models.TRANSACTION_TYPE_EXPENSE: "支出",
|
||||
models.TRANSACTION_TYPE_TRANSFER: "不计收支",
|
||||
}
|
||||
|
||||
// alipayWebTransactionDataCsvImporter defines the structure of alipay (web) csv importer for transaction data
|
||||
type alipayWebTransactionDataCsvImporter struct{}
|
||||
type alipayWebTransactionDataCsvImporter struct {
|
||||
alipayTransactionDataCsvImporter
|
||||
}
|
||||
|
||||
// Initialize a alipay (web) transaction data csv file importer singleton instance
|
||||
var (
|
||||
AlipayWebTransactionDataCsvImporter = &alipayWebTransactionDataCsvImporter{}
|
||||
AlipayWebTransactionDataCsvImporter = &alipayWebTransactionDataCsvImporter{
|
||||
alipayTransactionDataCsvImporter{
|
||||
fileHeaderLine: "支付宝交易记录明细查询",
|
||||
dataHeaderStartContent: "交易记录明细列表",
|
||||
dataBottomEndLineRune: '-',
|
||||
timeColumnName: "交易创建时间",
|
||||
categoryColumnName: "",
|
||||
targetNameColumnName: "交易对方",
|
||||
productNameColumnName: "商品名称",
|
||||
amountColumnName: "金额(元)",
|
||||
typeColumnName: "收/支",
|
||||
relatedAccountColumnName: "",
|
||||
statusColumnName: "交易状态",
|
||||
descriptionColumnName: "备注",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// ParseImportedData returns the imported data by parsing the alipay (web) transaction csv data
|
||||
func (c *alipayWebTransactionDataCsvImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]*models.TransactionCategory, incomeCategoryMap map[string]*models.TransactionCategory, transferCategoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
|
||||
enc := simplifiedchinese.GB18030
|
||||
reader := transform.NewReader(bytes.NewReader(data), enc.NewDecoder())
|
||||
allLines, err := c.parseAllLinesFromCsvData(ctx, reader)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
if len(allLines) <= 1 {
|
||||
log.Errorf(ctx, "[alipayWebTransactionDataCsvImporter.ParseImportedData] cannot parse import data for user \"uid:%d\", because data table row count is less 1", user.Uid)
|
||||
return nil, nil, nil, nil, nil, nil, errs.ErrNotFoundTransactionDataInFile
|
||||
}
|
||||
|
||||
headerLineItems := allLines[0]
|
||||
headerItemMap := make(map[string]int)
|
||||
|
||||
for i := 0; i < len(headerLineItems); i++ {
|
||||
headerItemMap[headerLineItems[i]] = i
|
||||
}
|
||||
|
||||
timeColumnIdx, timeColumnExists := headerItemMap["交易创建时间"]
|
||||
targetNameColumnIdx, targetNameColumnExists := headerItemMap["交易对方"]
|
||||
productNameColumnIdx, productNameColumnExists := headerItemMap["商品名称"]
|
||||
amountColumnIdx, amountColumnExists := headerItemMap["金额(元)"]
|
||||
typeColumnIdx, typeColumnExists := headerItemMap["收/支"]
|
||||
statusColumnIdx, statusColumnExists := headerItemMap["交易状态"]
|
||||
descriptionColumnIdx, descriptionColumnExists := headerItemMap["备注"]
|
||||
|
||||
if !timeColumnExists || !amountColumnExists || !typeColumnExists || !statusColumnExists {
|
||||
log.Errorf(ctx, "[alipayWebTransactionDataCsvImporter.ParseImportedData] cannot parse import data for user \"uid:%d\", because missing essential columns in header row", user.Uid)
|
||||
return nil, nil, nil, nil, nil, nil, errs.ErrMissingRequiredFieldInHeaderRow
|
||||
}
|
||||
|
||||
newColumns := make([]datatable.DataTableColumn, 0, 7)
|
||||
newColumns = append(newColumns, datatable.DATA_TABLE_TRANSACTION_TYPE)
|
||||
newColumns = append(newColumns, datatable.DATA_TABLE_TRANSACTION_TIME)
|
||||
newColumns = append(newColumns, datatable.DATA_TABLE_SUB_CATEGORY)
|
||||
newColumns = append(newColumns, datatable.DATA_TABLE_ACCOUNT_NAME)
|
||||
newColumns = append(newColumns, datatable.DATA_TABLE_AMOUNT)
|
||||
newColumns = append(newColumns, datatable.DATA_TABLE_RELATED_ACCOUNT_NAME)
|
||||
newColumns = append(newColumns, datatable.DATA_TABLE_DESCRIPTION)
|
||||
|
||||
dataTable := datatable.CreateNewWritableDataTable(newColumns)
|
||||
|
||||
for i := 1; i < len(allLines); i++ {
|
||||
items := allLines[i]
|
||||
|
||||
if len(items) < len(headerLineItems) {
|
||||
log.Errorf(ctx, "[alipayWebTransactionDataCsvImporter.ParseImportedData] cannot parse row \"index:%d\" for user \"uid:%d\", because may missing some columns (column count %d in data row is less than header column count %d)", i, user.Uid, len(items), len(headerLineItems))
|
||||
return nil, nil, nil, nil, nil, nil, errs.ErrFewerFieldsInDataRowThanInHeaderRow
|
||||
}
|
||||
|
||||
if items[typeColumnIdx] != alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] &&
|
||||
items[typeColumnIdx] != alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] &&
|
||||
items[typeColumnIdx] != alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER] {
|
||||
log.Warnf(ctx, "[alipayWebTransactionDataCsvImporter.ParseImportedData] skip parsing transaction in row \"index:%d\" for user \"uid:%d\", because type is \"%s\"", i, user.Uid, items[typeColumnIdx])
|
||||
continue
|
||||
}
|
||||
|
||||
if items[statusColumnIdx] != alipayWebTransactionDataStatusSuccessName &&
|
||||
items[statusColumnIdx] != alipayWebTransactionDataStatusPaymentSuccessName &&
|
||||
items[statusColumnIdx] != alipayWebTransactionDataStatusRepaymentSuccessName &&
|
||||
items[statusColumnIdx] != alipayWebTransactionDataStatusClosedName &&
|
||||
items[statusColumnIdx] != alipayWebTransactionDataStatusRefundSuccessName &&
|
||||
items[statusColumnIdx] != alipayWebTransactionDataStatusTaxRefundSuccessName {
|
||||
log.Warnf(ctx, "[alipayWebTransactionDataCsvImporter.ParseImportedData] skip parsing transaction in row \"index:%d\" for user \"uid:%d\", because status is \"%s\"", i, user.Uid, items[statusColumnIdx])
|
||||
continue
|
||||
}
|
||||
|
||||
data, errMsg := c.parseTransactionData(ctx,
|
||||
user,
|
||||
items,
|
||||
timeColumnIdx,
|
||||
timeColumnExists,
|
||||
targetNameColumnIdx,
|
||||
targetNameColumnExists,
|
||||
productNameColumnIdx,
|
||||
productNameColumnExists,
|
||||
amountColumnIdx,
|
||||
amountColumnExists,
|
||||
typeColumnIdx,
|
||||
typeColumnExists,
|
||||
statusColumnIdx,
|
||||
statusColumnExists,
|
||||
descriptionColumnIdx,
|
||||
descriptionColumnExists,
|
||||
)
|
||||
|
||||
if data == nil {
|
||||
log.Warnf(ctx, "[alipayWebTransactionDataCsvImporter.ParseImportedData] skip parsing transaction in row \"index:%d\" for user \"uid:%d\", because %s", i, user.Uid, errMsg)
|
||||
continue
|
||||
}
|
||||
|
||||
dataTable.Add(data)
|
||||
}
|
||||
|
||||
dataTableImporter := datatable.CreateNewSimpleImporterFromWritableDataTable(
|
||||
dataTable,
|
||||
alipayTransactionTypeNameMapping,
|
||||
)
|
||||
|
||||
return dataTableImporter.ParseImportedData(ctx, user, dataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||
}
|
||||
|
||||
func (c *alipayWebTransactionDataCsvImporter) parseAllLinesFromCsvData(ctx core.Context, reader io.Reader) ([][]string, error) {
|
||||
csvReader := csv.NewReader(reader)
|
||||
csvReader.FieldsPerRecord = -1
|
||||
|
||||
allLines := make([][]string, 0)
|
||||
hasFileHeader := false
|
||||
foundContentBeforeDataHeaderLine := false
|
||||
|
||||
for {
|
||||
items, err := csvReader.Read()
|
||||
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[alipayWebTransactionDataCsvImporter.parseAllLinesFromCsvData] cannot parse alipay csv data, because %s", err.Error())
|
||||
return nil, errs.ErrInvalidCSVFile
|
||||
}
|
||||
|
||||
if !hasFileHeader {
|
||||
if len(items) <= 0 {
|
||||
continue
|
||||
} else if strings.Index(items[0], alipayWebTransactionDataCsvFileHeader) == 0 {
|
||||
hasFileHeader = true
|
||||
continue
|
||||
} else {
|
||||
log.Warnf(ctx, "[alipayWebTransactionDataCsvImporter.parseAllLinesFromCsvData] read unexpected line before read file header, line content is %s", strings.Join(items, ","))
|
||||
}
|
||||
}
|
||||
|
||||
if !foundContentBeforeDataHeaderLine {
|
||||
if len(items) <= 0 {
|
||||
continue
|
||||
} else if strings.Index(items[0], alipayWebTransactionDataCsvDataHeaderLineStartContent) >= 0 {
|
||||
foundContentBeforeDataHeaderLine = true
|
||||
continue
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if foundContentBeforeDataHeaderLine {
|
||||
if len(items) <= 0 {
|
||||
continue
|
||||
} else if len(items) == 1 && utils.ContainsOnlyOneRune(items[0], alipayWebTransactionDataCsvDataDataLineEndLineRune) {
|
||||
break
|
||||
}
|
||||
|
||||
for i := 0; i < len(items); i++ {
|
||||
items[i] = strings.Trim(items[i], " ")
|
||||
}
|
||||
|
||||
allLines = append(allLines, items)
|
||||
}
|
||||
}
|
||||
|
||||
if !hasFileHeader || !foundContentBeforeDataHeaderLine {
|
||||
return nil, errs.ErrInvalidFileHeader
|
||||
}
|
||||
|
||||
return allLines, nil
|
||||
}
|
||||
|
||||
func (c *alipayWebTransactionDataCsvImporter) parseTransactionData(
|
||||
ctx core.Context,
|
||||
user *models.User,
|
||||
items []string,
|
||||
timeColumnIdx int,
|
||||
timeColumnExists bool,
|
||||
targetNameColumnIdx int,
|
||||
targetNameColumnExists bool,
|
||||
productNameColumnIdx int,
|
||||
productNameColumnExists bool,
|
||||
amountColumnIdx int,
|
||||
amountColumnExists bool,
|
||||
typeColumnIdx int,
|
||||
typeColumnExists bool,
|
||||
statusColumnIdx int,
|
||||
statusColumnExists bool,
|
||||
descriptionColumnIdx int,
|
||||
descriptionColumnExists bool,
|
||||
) (map[datatable.DataTableColumn]string, string) {
|
||||
data := make(map[datatable.DataTableColumn]string, 11)
|
||||
|
||||
if timeColumnExists && timeColumnIdx < len(items) {
|
||||
data[datatable.DATA_TABLE_TRANSACTION_TIME] = items[timeColumnIdx]
|
||||
}
|
||||
|
||||
if amountColumnExists && amountColumnIdx < len(items) {
|
||||
data[datatable.DATA_TABLE_AMOUNT] = items[amountColumnIdx]
|
||||
}
|
||||
|
||||
data[datatable.DATA_TABLE_SUB_CATEGORY] = ""
|
||||
|
||||
if descriptionColumnExists && descriptionColumnIdx < len(items) && items[descriptionColumnIdx] != "" {
|
||||
data[datatable.DATA_TABLE_DESCRIPTION] = items[descriptionColumnIdx]
|
||||
} else if productNameColumnExists && productNameColumnIdx < len(items) && items[productNameColumnIdx] != "" {
|
||||
data[datatable.DATA_TABLE_DESCRIPTION] = items[productNameColumnIdx]
|
||||
} else {
|
||||
data[datatable.DATA_TABLE_DESCRIPTION] = ""
|
||||
}
|
||||
|
||||
statusName := ""
|
||||
|
||||
if statusColumnExists && statusColumnIdx < len(items) {
|
||||
statusName = items[statusColumnIdx]
|
||||
}
|
||||
|
||||
locale := user.Language
|
||||
|
||||
if locale == "" {
|
||||
locale = ctx.GetClientLocale()
|
||||
}
|
||||
|
||||
localeTextItems := locales.GetLocaleTextItems(locale)
|
||||
|
||||
if typeColumnExists && typeColumnIdx < len(items) {
|
||||
data[datatable.DATA_TABLE_TRANSACTION_TYPE] = items[typeColumnIdx]
|
||||
|
||||
if items[typeColumnIdx] == alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] {
|
||||
if statusName == alipayWebTransactionDataStatusClosedName {
|
||||
return nil, fmt.Sprintf("income transaction is closed")
|
||||
}
|
||||
|
||||
if statusName == alipayWebTransactionDataStatusSuccessName {
|
||||
data[datatable.DATA_TABLE_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay
|
||||
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
|
||||
} else {
|
||||
data[datatable.DATA_TABLE_ACCOUNT_NAME] = ""
|
||||
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
|
||||
}
|
||||
} else if items[typeColumnIdx] == alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER] {
|
||||
if statusName == alipayWebTransactionDataStatusClosedName {
|
||||
return nil, fmt.Sprintf("non-income/expense transaction is closed")
|
||||
}
|
||||
|
||||
targetName := ""
|
||||
productName := ""
|
||||
|
||||
if targetNameColumnExists && targetNameColumnIdx < len(items) {
|
||||
targetName = items[targetNameColumnIdx]
|
||||
}
|
||||
|
||||
if productNameColumnExists && productNameColumnIdx < len(items) {
|
||||
productName = items[productNameColumnIdx]
|
||||
}
|
||||
|
||||
if statusName == alipayWebTransactionDataStatusRefundSuccessName {
|
||||
data[datatable.DATA_TABLE_TRANSACTION_TYPE] = alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
|
||||
data[datatable.DATA_TABLE_ACCOUNT_NAME] = ""
|
||||
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
|
||||
} else {
|
||||
if strings.Index(productName, alipayWebTransactionDataProductNameRechargePrefix) == 0 { // transfer to alipay wallet
|
||||
data[datatable.DATA_TABLE_ACCOUNT_NAME] = ""
|
||||
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay
|
||||
} else if strings.Index(productName, alipayWebTransactionDataProductNameCashWithdrawalPrefix) == 0 { // transfer from alipay wallet
|
||||
data[datatable.DATA_TABLE_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay
|
||||
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
||||
} else if strings.Index(productName, alipayWebTransactionDataProductNameTransferInText) >= 0 { // transfer in
|
||||
data[datatable.DATA_TABLE_ACCOUNT_NAME] = ""
|
||||
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
||||
} else if strings.Index(productName, alipayWebTransactionDataProductNameTransferOutText) >= 0 { // transfer out
|
||||
data[datatable.DATA_TABLE_ACCOUNT_NAME] = ""
|
||||
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
||||
} else if strings.Index(productName, alipayWebTransactionDataProductNameRepaymentText) >= 0 { // repayment
|
||||
data[datatable.DATA_TABLE_ACCOUNT_NAME] = ""
|
||||
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
||||
} else {
|
||||
return nil, fmt.Sprintf("product name (\"%s\") is unknown", productName)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
data[datatable.DATA_TABLE_ACCOUNT_NAME] = ""
|
||||
data[datatable.DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
|
||||
}
|
||||
}
|
||||
|
||||
if data[datatable.DATA_TABLE_TRANSACTION_TYPE] == alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] && statusName != "" {
|
||||
if statusName == alipayWebTransactionDataStatusRefundSuccessName || statusName == alipayWebTransactionDataStatusTaxRefundSuccessName {
|
||||
amount, err := utils.ParseAmount(data[datatable.DATA_TABLE_AMOUNT])
|
||||
|
||||
if err == nil {
|
||||
data[datatable.DATA_TABLE_TRANSACTION_TYPE] = alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
|
||||
data[datatable.DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data, ""
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ func GetTransactionDataImporter(fileType string) (base.TransactionDataImporter,
|
||||
return feidee.FeideeMymoneyTransactionDataCsvImporter, nil
|
||||
} else if fileType == "feidee_mymoney_xls" {
|
||||
return feidee.FeideeMymoneyTransactionDataXlsImporter, nil
|
||||
} else if fileType == "alipay_app_csv" {
|
||||
return alipay.AlipayAppTransactionDataCsvImporter, nil
|
||||
} else if fileType == "alipay_web_csv" {
|
||||
return alipay.AlipayWebTransactionDataCsvImporter, nil
|
||||
} else {
|
||||
|
||||
@@ -37,6 +37,15 @@ const supportedImportFileTypes = [
|
||||
anchor: '如何获取金蝶随手记web版数据导出文件'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'alipay_app_csv',
|
||||
name: 'Alipay (App) Data Export File',
|
||||
extensions: '.csv',
|
||||
document: {
|
||||
supportMultiLanguages: 'zh-Hans',
|
||||
anchor: '如何获取支付宝app数据导出文件'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'alipay_web_csv',
|
||||
name: 'Alipay (Web) Data Export File',
|
||||
|
||||
+2
-1
@@ -1514,8 +1514,9 @@
|
||||
"How to export this file?": "How to export this file?",
|
||||
"ezbookkeeping Data Export File (CSV)": "ezbookkeeping Data Export File (CSV)",
|
||||
"ezbookkeeping Data Export File (TSV)": "ezbookkeeping Data Export File (TSV)",
|
||||
"Feidee MyMoney (Web) Data Export File": "Feidee MyMoney (Web) Data Export File",
|
||||
"Feidee MyMoney (App) Data Export File": "Feidee MyMoney (App) Data Export File",
|
||||
"Feidee MyMoney (Web) Data Export File": "Feidee MyMoney (Web) Data Export File",
|
||||
"Alipay (App) Data Export File": "Alipay (App) Data Export File",
|
||||
"Alipay (Web) Data Export File": "Alipay (Web) Data Export File",
|
||||
"Data File": "Data File",
|
||||
"No data to import": "No data to import",
|
||||
|
||||
@@ -1514,8 +1514,9 @@
|
||||
"How to export this file?": "如何导出该文件?",
|
||||
"ezbookkeeping Data Export File (CSV)": "ezbookkeeping 数据导出文件 (CSV)",
|
||||
"ezbookkeeping Data Export File (TSV)": "ezbookkeeping 数据导出文件 (TSV)",
|
||||
"Feidee MyMoney (Web) Data Export File": "金蝶随手记 (Web版) 数据导出文件",
|
||||
"Feidee MyMoney (App) Data Export File": "金蝶随手记 (App) 数据导出文件",
|
||||
"Feidee MyMoney (Web) Data Export File": "金蝶随手记 (Web版) 数据导出文件",
|
||||
"Alipay (App) Data Export File": "支付宝 (App) 数据导出文件",
|
||||
"Alipay (Web) Data Export File": "支付宝 (网页版) 数据导出文件",
|
||||
"Data File": "数据文件",
|
||||
"No data to import": "没有可以导入的数据",
|
||||
|
||||
Reference in New Issue
Block a user