diff --git a/pkg/converters/feidee_mymoney_transaction_data_csv_file_importer.go b/pkg/converters/feidee_mymoney_transaction_data_csv_file_importer.go new file mode 100644 index 00000000..0ddda326 --- /dev/null +++ b/pkg/converters/feidee_mymoney_transaction_data_csv_file_importer.go @@ -0,0 +1,303 @@ +package converters + +import ( + "encoding/csv" + "io" + "strings" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/log" + "github.com/mayswind/ezbookkeeping/pkg/models" +) + +const feideeMymoneyTransactionDataCsvFileHeader = "随手记导出文件(headers:v5;" +const feideeMymoneyTransactionDataCsvFileHeaderWithUtf8Bom = "\xEF\xBB\xBF" + feideeMymoneyTransactionDataCsvFileHeader + +// feideeMymoneyTransactionDataCsvImporter defines the structure of feidee mymoney csv importer for transaction data +type feideeMymoneyTransactionDataCsvImporter struct { + transactionTypeMapping map[models.TransactionType]string +} + +// Initialize a feidee mymoney transaction data csv file importer singleton instance +var ( + FeideeMymoneyTransactionDataCsvImporter = &feideeMymoneyTransactionDataCsvImporter{ + transactionTypeMapping: feideeMymoneyTransactionTypeNameMapping, + } +) + +// ParseImportedData returns the imported data by parsing the feidee mymoney transaction csv data +func (c *feideeMymoneyTransactionDataCsvImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, categoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionTag, error) { + content := string(data) + + if strings.Index(content, feideeMymoneyTransactionDataCsvFileHeader) != 0 && strings.Index(content, feideeMymoneyTransactionDataCsvFileHeaderWithUtf8Bom) != 0 { + return nil, nil, nil, nil, errs.ErrInvalidFileHeader + } + + allLines, err := c.parseAllLinesFromCsvData(ctx, content) + + if err != nil { + return nil, nil, nil, nil, err + } + + if len(allLines) <= 1 { + log.Errorf(ctx, "[feidee_mymoney_transaction_data_csv_file_importer.ParseImportedData] cannot parse import data for user \"uid:%d\", because data table row count is less 1", user.Uid) + return 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["日期"] + typeColumnIdx, typeColumnExists := headerItemMap["交易类型"] + categoryColumnIdx, categoryColumnExists := headerItemMap["类别"] + subCategoryColumnIdx, subCategoryColumnExists := headerItemMap["子类别"] + accountColumnIdx, accountColumnExists := headerItemMap["账户"] + accountCurrencyColumnIdx, accountCurrencyColumnExists := headerItemMap["账户币种"] + amountColumnIdx, amountColumnExists := headerItemMap["金额"] + descriptionColumnIdx, descriptionColumnExists := headerItemMap["备注"] + relatedIdColumnIdx, relatedIdColumnExists := headerItemMap["关联Id"] + + if !timeColumnExists || !typeColumnExists || !subCategoryColumnExists || + !accountColumnExists || !amountColumnExists || !relatedIdColumnExists { + log.Errorf(ctx, "[feidee_mymoney_transaction_data_csv_file_importer.ParseImportedData] cannot parse import data for user \"uid:%d\", because missing essential columns in header row", user.Uid) + return nil, nil, nil, nil, errs.ErrMissingRequiredFieldInHeaderRow + } + + newColumns := make([]DataTableColumn, 0, 11) + newColumns = append(newColumns, DATA_TABLE_TRANSACTION_TYPE) + newColumns = append(newColumns, DATA_TABLE_TRANSACTION_TIME) + + if categoryColumnExists { + newColumns = append(newColumns, DATA_TABLE_CATEGORY) + } + + newColumns = append(newColumns, DATA_TABLE_SUB_CATEGORY) + newColumns = append(newColumns, DATA_TABLE_ACCOUNT_NAME) + + if accountCurrencyColumnExists { + newColumns = append(newColumns, DATA_TABLE_ACCOUNT_CURRENCY) + } + + newColumns = append(newColumns, DATA_TABLE_AMOUNT) + newColumns = append(newColumns, DATA_TABLE_RELATED_ACCOUNT_NAME) + + if accountCurrencyColumnExists { + newColumns = append(newColumns, DATA_TABLE_RELATED_ACCOUNT_CURRENCY) + } + + newColumns = append(newColumns, DATA_TABLE_RELATED_AMOUNT) + + if descriptionColumnExists { + newColumns = append(newColumns, DATA_TABLE_DESCRIPTION) + } + + dataTable, err := createNewWritableDataTable(newColumns) + + if err != nil { + return nil, nil, nil, nil, err + } + + transferTransactionsMap := make(map[string]map[DataTableColumn]string, 0) + + for i := 1; i < len(allLines); i++ { + items := allLines[i] + data, relatedId := c.parseTransactionData(items, + timeColumnIdx, + timeColumnExists, + typeColumnIdx, + typeColumnExists, + categoryColumnIdx, + categoryColumnExists, + subCategoryColumnIdx, + subCategoryColumnExists, + accountColumnIdx, + accountColumnExists, + accountCurrencyColumnIdx, + accountCurrencyColumnExists, + amountColumnIdx, + amountColumnExists, + descriptionColumnIdx, + descriptionColumnExists, + relatedIdColumnIdx, + relatedIdColumnExists, + ) + + if len(items) < len(headerLineItems) { + log.Errorf(ctx, "[feidee_mymoney_transaction_data_csv_file_importer.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, errs.ErrFewerFieldsInDataRowThanInHeaderRow + } + + transactionType := data[DATA_TABLE_TRANSACTION_TYPE] + + if transactionType == "余额变更" || transactionType == "收入" || transactionType == "支出" { + if transactionType == "余额变更" { + data[DATA_TABLE_TRANSACTION_TYPE] = c.transactionTypeMapping[models.TRANSACTION_TYPE_MODIFY_BALANCE] + } else if transactionType == "收入" { + data[DATA_TABLE_TRANSACTION_TYPE] = c.transactionTypeMapping[models.TRANSACTION_TYPE_INCOME] + } else if transactionType == "支出" { + data[DATA_TABLE_TRANSACTION_TYPE] = c.transactionTypeMapping[models.TRANSACTION_TYPE_EXPENSE] + } + + data[DATA_TABLE_RELATED_ACCOUNT_NAME] = "" + data[DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = "" + data[DATA_TABLE_RELATED_AMOUNT] = "" + dataTable.Add(data) + } else if transactionType == "转入" || transactionType == "转出" { + if relatedId == "" { + log.Errorf(ctx, "[feidee_mymoney_transaction_data_csv_file_importer.ParseImportedData] transfer transaction has blank related id in row \"index:%d\" for user \"uid:%d\"", i, user.Uid) + return nil, nil, nil, nil, errs.ErrRelatedIdCannotBeBlank + } + + relatedData, exists := transferTransactionsMap[relatedId] + + if !exists { + transferTransactionsMap[relatedId] = data + continue + } + + if transactionType == "转入" && relatedData[DATA_TABLE_TRANSACTION_TYPE] == "转出" { + relatedData[DATA_TABLE_TRANSACTION_TYPE] = c.transactionTypeMapping[models.TRANSACTION_TYPE_TRANSFER] + relatedData[DATA_TABLE_RELATED_ACCOUNT_NAME] = data[DATA_TABLE_ACCOUNT_NAME] + relatedData[DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = data[DATA_TABLE_ACCOUNT_CURRENCY] + relatedData[DATA_TABLE_RELATED_AMOUNT] = data[DATA_TABLE_AMOUNT] + dataTable.Add(relatedData) + delete(transferTransactionsMap, relatedId) + } else if transactionType == "转出" && relatedData[DATA_TABLE_TRANSACTION_TYPE] == "转入" { + data[DATA_TABLE_TRANSACTION_TYPE] = c.transactionTypeMapping[models.TRANSACTION_TYPE_TRANSFER] + data[DATA_TABLE_RELATED_ACCOUNT_NAME] = relatedData[DATA_TABLE_ACCOUNT_NAME] + data[DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = relatedData[DATA_TABLE_ACCOUNT_CURRENCY] + data[DATA_TABLE_RELATED_AMOUNT] = relatedData[DATA_TABLE_AMOUNT] + dataTable.Add(data) + delete(transferTransactionsMap, relatedId) + } else { + log.Errorf(ctx, "[feidee_mymoney_transaction_data_csv_file_importer.ParseImportedData] transfer transaction type \"%s\" is not expected in row \"index:%d\" for user \"uid:%d\"", transactionType, i, user.Uid) + return nil, nil, nil, nil, errs.ErrTransactionTypeInvalid + } + } else { + log.Errorf(ctx, "[feidee_mymoney_transaction_data_csv_file_importer.ParseImportedData] cannot parse transaction type \"%s\" in row \"index:%d\" for user \"uid:%d\"", transactionType, i, user.Uid) + return nil, nil, nil, nil, errs.ErrTransactionTypeInvalid + } + } + + if len(transferTransactionsMap) > 0 { + log.Errorf(ctx, "[feidee_mymoney_transaction_data_csv_file_importer.ParseImportedData] there are %d transactions (related id is %s) which don't have related records", len(transferTransactionsMap), c.getRelatedIds(transferTransactionsMap)) + return nil, nil, nil, nil, errs.ErrFoundRecordNotHasRelatedRecord + } + + dataTableImporter := DataTableTransactionDataImporter{ + dataColumnMapping: dataTable.GetDataColumnMapping(), + transactionTypeMapping: c.transactionTypeMapping, + } + + return dataTableImporter.parseImportedData(ctx, user, dataTable, defaultTimezoneOffset, accountMap, categoryMap, tagMap) +} + +func (c *feideeMymoneyTransactionDataCsvImporter) parseAllLinesFromCsvData(ctx core.Context, content string) ([][]string, error) { + csvReader := csv.NewReader(strings.NewReader(content)) + csvReader.FieldsPerRecord = -1 + + allLines := make([][]string, 0) + + for { + items, err := csvReader.Read() + + if err == io.EOF { + break + } + + if err != nil { + log.Errorf(ctx, "[feidee_mymoney_transaction_data_csv_file_importer.parseCsvData] cannot parse feidee mymoney csv data, because %s", err.Error()) + return nil, errs.ErrInvalidCSVFile + } + + if len(items) <= 0 || strings.Index(items[0], feideeMymoneyTransactionDataCsvFileHeader) == 0 || strings.Index(items[0], feideeMymoneyTransactionDataCsvFileHeaderWithUtf8Bom) == 0 { + continue + } + + allLines = append(allLines, items) + } + + return allLines, nil +} + +func (c *feideeMymoneyTransactionDataCsvImporter) parseTransactionData( + items []string, + timeColumnIdx int, + timeColumnExists bool, + typeColumnIdx int, + typeColumnExists bool, + categoryColumnIdx int, + categoryColumnExists bool, + subCategoryColumnIdx int, + subCategoryColumnExists bool, + accountColumnIdx int, + accountColumnExists bool, + accountCurrencyColumnIdx int, + accountCurrencyColumnExists bool, + amountColumnIdx int, + amountColumnExists bool, + descriptionColumnIdx int, + descriptionColumnExists bool, + relatedIdColumnIdx int, + relatedIdColumnExists bool, +) (map[DataTableColumn]string, string) { + data := make(map[DataTableColumn]string, 11) + relatedId := "" + + if timeColumnExists && timeColumnIdx < len(items) { + data[DATA_TABLE_TRANSACTION_TIME] = items[timeColumnIdx] + } + + if typeColumnExists && typeColumnIdx < len(items) { + data[DATA_TABLE_TRANSACTION_TYPE] = items[typeColumnIdx] + } + + if categoryColumnExists && categoryColumnIdx < len(items) { + data[DATA_TABLE_CATEGORY] = items[categoryColumnIdx] + } + + if subCategoryColumnExists && subCategoryColumnIdx < len(items) { + data[DATA_TABLE_SUB_CATEGORY] = items[subCategoryColumnIdx] + } + + if accountColumnExists && accountColumnIdx < len(items) { + data[DATA_TABLE_ACCOUNT_NAME] = items[accountColumnIdx] + } + + if accountCurrencyColumnExists && accountCurrencyColumnIdx < len(items) { + data[DATA_TABLE_ACCOUNT_CURRENCY] = items[accountCurrencyColumnIdx] + } + + if amountColumnExists && amountColumnIdx < len(items) { + data[DATA_TABLE_AMOUNT] = items[amountColumnIdx] + } + + if descriptionColumnExists && descriptionColumnIdx < len(items) { + data[DATA_TABLE_DESCRIPTION] = items[descriptionColumnIdx] + } + + if relatedIdColumnExists && relatedIdColumnIdx < len(items) { + relatedId = items[relatedIdColumnIdx] + } + + return data, relatedId +} + +func (c *feideeMymoneyTransactionDataCsvImporter) getRelatedIds(transferTransactionsMap map[string]map[DataTableColumn]string) string { + builder := strings.Builder{} + + for relatedId := range transferTransactionsMap { + if builder.Len() > 0 { + builder.WriteRune(',') + } + + builder.WriteString(relatedId) + } + + return builder.String() +} diff --git a/pkg/converters/feidee_mymoney_transaction_data_csv_file_importer_test.go b/pkg/converters/feidee_mymoney_transaction_data_csv_file_importer_test.go new file mode 100644 index 00000000..e1edd6cc --- /dev/null +++ b/pkg/converters/feidee_mymoney_transaction_data_csv_file_importer_test.go @@ -0,0 +1,368 @@ +package converters + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/models" + "github.com/mayswind/ezbookkeeping/pkg/utils" +) + +func TestFeideeMymoneyCsvFileImporterParseImportedData_MinimumValidData(t *testing.T) { + converter := FeideeMymoneyTransactionDataCsvImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + allNewTransactions, allNewAccounts, allNewSubCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"余额变更\",\"2024-09-01 00:00:00\",\"\",\"Test Account\",\"123.45\",\"\",\"\"\n"+ + "\"收入\",\"2024-09-01 01:23:45\",\"Test Category\",\"Test Account\",\"0.12\",\"\",\"\"\n"+ + "\"支出\",\"2024-09-01 12:34:56\",\"Test Category2\",\"Test Account\",\"1.00\",\"\",\"\"\n"+ + "\"转出\",\"2024-09-01 23:59:59\",\"Test Category3\",\"Test Account\",\"0.05\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+ + "\"转入\",\"2024-09-01 23:59:59\",\"Test Category3\",\"Test Account2\",\"0.05\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+ + "\"转入\",\"2024-09-02 23:59:59\",\"Test Category3\",\"Test Account\",\"0.5\",\"\",\"00000000-0000-0000-0000-000000000002\"\n"+ + "\"转出\",\"2024-09-02 23:59:59\",\"Test Category3\",\"Test Account2\",\"0.5\",\"\",\"00000000-0000-0000-0000-000000000002\""), 0, nil, nil, nil) + + assert.Nil(t, err) + + assert.Equal(t, 5, len(allNewTransactions)) + assert.Equal(t, 2, len(allNewAccounts)) + assert.Equal(t, 3, len(allNewSubCategories)) + assert.Equal(t, 0, len(allNewTags)) + + assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid) + assert.Equal(t, models.TRANSACTION_DB_TYPE_MODIFY_BALANCE, allNewTransactions[0].Type) + assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime)) + assert.Equal(t, int64(12345), allNewTransactions[0].Amount) + assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName) + assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName) + + assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid) + assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[1].Type) + assert.Equal(t, int64(1725153825), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime)) + assert.Equal(t, int64(12), allNewTransactions[1].Amount) + assert.Equal(t, "Test Account", allNewTransactions[1].OriginalSourceAccountName) + assert.Equal(t, "Test Category", allNewTransactions[1].OriginalCategoryName) + + assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid) + assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[2].Type) + assert.Equal(t, int64(1725194096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime)) + assert.Equal(t, int64(100), allNewTransactions[2].Amount) + assert.Equal(t, "Test Account", allNewTransactions[2].OriginalSourceAccountName) + assert.Equal(t, "Test Category2", allNewTransactions[2].OriginalCategoryName) + + assert.Equal(t, int64(1234567890), allNewTransactions[3].Uid) + assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[3].Type) + assert.Equal(t, int64(1725235199), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime)) + assert.Equal(t, int64(5), allNewTransactions[3].Amount) + assert.Equal(t, "Test Account", allNewTransactions[3].OriginalSourceAccountName) + assert.Equal(t, "Test Account2", allNewTransactions[3].OriginalDestinationAccountName) + assert.Equal(t, "Test Category3", allNewTransactions[3].OriginalCategoryName) + + assert.Equal(t, int64(1234567890), allNewTransactions[4].Uid) + assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[4].Type) + assert.Equal(t, int64(1725321599), utils.GetUnixTimeFromTransactionTime(allNewTransactions[4].TransactionTime)) + assert.Equal(t, int64(50), allNewTransactions[4].Amount) + assert.Equal(t, "Test Account2", allNewTransactions[4].OriginalSourceAccountName) + assert.Equal(t, "Test Account", allNewTransactions[4].OriginalDestinationAccountName) + assert.Equal(t, "Test Category3", allNewTransactions[4].OriginalCategoryName) + + assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid) + assert.Equal(t, "Test Account", allNewAccounts[0].Name) + assert.Equal(t, "CNY", allNewAccounts[0].Currency) + + assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid) + assert.Equal(t, "Test Account2", allNewAccounts[1].Name) + assert.Equal(t, "CNY", allNewAccounts[1].Currency) + + assert.Equal(t, int64(1234567890), allNewSubCategories[0].Uid) + assert.Equal(t, "Test Category", allNewSubCategories[0].Name) + + assert.Equal(t, int64(1234567890), allNewSubCategories[1].Uid) + assert.Equal(t, "Test Category2", allNewSubCategories[1].Name) + + assert.Equal(t, int64(1234567890), allNewSubCategories[2].Uid) + assert.Equal(t, "Test Category3", allNewSubCategories[2].Name) +} + +func TestFeideeMymoneyCsvFileImporterParseImportedData_ParseInvalidTime(t *testing.T) { + converter := FeideeMymoneyTransactionDataCsvImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"收入\",\"2024-09-01T12:34:56\",\"Test Category\",\"Test Account\",\"0.12\",\"\",\"\""), 0, nil, nil, nil) + assert.NotNil(t, err) + + _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"收入\",\"09/01/2024 12:34:56\",\"Test Category\",\"Test Account\",\"0.12\",\"\",\"\""), 0, nil, nil, nil) + assert.NotNil(t, err) +} + +func TestFeideeMymoneyCsvFileImporterParseImportedData_ParseInvalidType(t *testing.T) { + converter := FeideeMymoneyTransactionDataCsvImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"Type\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account\",\"0.12\",\"\",\"\""), 0, nil, nil, nil) + assert.NotNil(t, err) +} + +func TestFeideeMymoneyCsvFileImporterParseImportedData_ParseInvalidAccountName(t *testing.T) { + converter := FeideeMymoneyTransactionDataCsvImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"支出\",\"2024-09-01 12:34:56\",\"Test Category\",\"\",\"123.45\",\"\",\"\""), 0, nil, nil, nil) + assert.NotNil(t, err) + + _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"转出\",\"2024-09-01 12:34:56\",\"Test Category\",\"\",\"123.45\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+ + "\"转入\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account2\",\"123.45\",\"\",\"00000000-0000-0000-0000-000000000001\""), 0, nil, nil, nil) + assert.NotNil(t, err) + + _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"转出\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account\",\"123.45\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+ + "\"转入\",\"2024-09-01 12:34:56\",\"Test Category\",\"\",\"123.45\",\"\",\"00000000-0000-0000-0000-000000000001\""), 0, nil, nil, nil) + assert.NotNil(t, err) +} + +func TestFeideeMymoneyCsvFileImporterParseImportedData_ParseValidAccountCurrency(t *testing.T) { + converter := FeideeMymoneyTransactionDataCsvImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + allNewTransactions, allNewAccounts, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"日期\",\"子类别\",\"账户\",\"账户币种\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"余额变更\",\"2024-09-01 01:23:45\",\"\",\"Test Account\",\"USD\",\"123.45\",\"\",\"\"\n"+ + "\"转出\",\"2024-09-01 12:34:56\",\"Test Category3\",\"Test Account\",\"USD\",\"1.23\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+ + "\"转入\",\"2024-09-01 12:34:56\",\"Test Category3\",\"Test Account2\",\"EUR\",\"1.10\",\"\",\"00000000-0000-0000-0000-000000000001\""), 0, nil, nil, nil) + + assert.Nil(t, err) + + assert.Equal(t, 2, len(allNewTransactions)) + assert.Equal(t, 2, len(allNewAccounts)) + + assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid) + assert.Equal(t, "Test Account", allNewAccounts[0].Name) + assert.Equal(t, "USD", allNewAccounts[0].Currency) + + assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid) + assert.Equal(t, "Test Account2", allNewAccounts[1].Name) + assert.Equal(t, "EUR", allNewAccounts[1].Currency) +} + +func TestFeideeMymoneyCsvFileImporterParseImportedData_ParseInvalidAccountCurrency(t *testing.T) { + converter := FeideeMymoneyTransactionDataCsvImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"日期\",\"子类别\",\"账户\",\"账户币种\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"余额变更\",\"2024-09-01 01:23:45\",\"\",\"Test Account\",\"USD\",\"123.45\",\"\",\"\"\n"+ + "\"转出\",\"2024-09-01 12:34:56\",\"Test Category3\",\"Test Account\",\"CNY\",\"1.23\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+ + "\"转入\",\"2024-09-01 12:34:56\",\"Test Category3\",\"Test Account2\",\"EUR\",\"1.10\",\"\",\"00000000-0000-0000-0000-000000000001\""), 0, nil, nil, nil) + assert.NotNil(t, err) + + _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"日期\",\"子类别\",\"账户\",\"账户币种\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"余额变更\",\"2024-09-01 01:23:45\",\"\",\"Test Account\",\"USD\",\"123.45\",\"\",\"\"\n"+ + "\"转出\",\"2024-09-01 12:34:56\",\"Test Category3\",\"Test Account2\",\"CNY\",\"1.23\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+ + "\"转入\",\"2024-09-01 12:34:56\",\"Test Category3\",\"Test Account\",\"EUR\",\"1.10\",\"\",\"00000000-0000-0000-0000-000000000001\""), 0, nil, nil, nil) + assert.NotNil(t, err) +} + +func TestFeideeMymoneyCsvFileImporterParseImportedData_ParseNotSupportedCurrency(t *testing.T) { + converter := FeideeMymoneyTransactionDataCsvImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"日期\",\"子类别\",\"账户\",\"账户币种\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"余额变更\",\"2024-09-01 01:23:45\",\"\",\"Test Account\",\"XXX\",\"123.45\",\"\",\"\""), 0, nil, nil, nil) + assert.NotNil(t, err) + + _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"日期\",\"子类别\",\"账户\",\"账户币种\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"转出\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account\",\"USD\",\"123.45\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+ + "\"转入\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account2\",\"XXX\",\"123.45\",\"\",\"00000000-0000-0000-0000-000000000001\""), 0, nil, nil, nil) + assert.NotNil(t, err) + + _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"日期\",\"子类别\",\"账户\",\"账户币种\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"转出\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account\",\"XXX\",\"123.45\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+ + "\"转入\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account2\",\"USD\",\"123.45\",\"\",\"00000000-0000-0000-0000-000000000001\""), 0, nil, nil, nil) + assert.NotNil(t, err) +} + +func TestFeideeMymoneyCsvFileImporterParseImportedData_ParseInvalidAmount(t *testing.T) { + converter := FeideeMymoneyTransactionDataCsvImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"余额变更\",\"2024-09-01 01:23:45\",\"\",\"Test Account\",\"123 45\",\"\",\"\""), 0, nil, nil, nil) + assert.NotNil(t, err) + + _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"转出\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account\",\"123 45\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+ + "\"转入\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account2\",\"123.45\",\"\",\"00000000-0000-0000-0000-000000000001\""), 0, nil, nil, nil) + assert.NotNil(t, err) + + _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"转出\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account\",\"123.45\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+ + "\"转入\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account2\",\"123 45\",\"\",\"00000000-0000-0000-0000-000000000001\""), 0, nil, nil, nil) + assert.NotNil(t, err) +} + +func TestFeideeMymoneyCsvFileImporterParseImportedData_ParseDescription(t *testing.T) { + converter := FeideeMymoneyTransactionDataCsvImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + allNewTransactions, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"支出\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account\",\"123.45\",\"Test\n"+ + "A new line break\",\"\""), 0, nil, nil, nil) + + assert.Nil(t, err) + assert.Equal(t, 1, len(allNewTransactions)) + assert.Equal(t, "Test\nA new line break", allNewTransactions[0].Comment) +} + +func TestFeideeMymoneyCsvFileImporterParseImportedData_InvalidRelatedId(t *testing.T) { + converter := FeideeMymoneyTransactionDataCsvImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"转出\",\"2024-09-01 23:59:59\",\"Test Category3\",\"Test Account\",\"0.05\",\"\",\"\""), 0, nil, nil, nil) + assert.NotNil(t, err) + + _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"转入\",\"2024-09-01 23:59:59\",\"Test Category3\",\"Test Account\",\"0.05\",\"\",\"\""), 0, nil, nil, nil) + assert.NotNil(t, err) + + _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"转出\",\"2024-09-01 23:59:59\",\"Test Category3\",\"Test Account\",\"0.05\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+ + "\"转入\",\"2024-09-02 23:59:59\",\"Test Category3\",\"Test Account\",\"0.5\",\"\",\"00000000-0000-0000-0000-000000000002\""), 0, nil, nil, nil) + assert.NotNil(t, err) +} + +func TestFeideeMymoneyCsvFileImporterParseImportedData_MissingFileHeader(t *testing.T) { + converter := FeideeMymoneyTransactionDataCsvImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1, + DefaultCurrency: "CNY", + } + _, _, _, _, err := converter.ParseImportedData(context, user, []byte("\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"余额变更\",\"2024-09-01 00:00:00\",\"\",\"Test Account\",\"123.45\",\"\",\"\"\n"), 0, nil, nil, nil) + assert.NotNil(t, err) +} + +func TestFeideeMymoneyCsvFileImporterParseImportedData_MissingRequiredColumn(t *testing.T) { + converter := FeideeMymoneyTransactionDataCsvImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1, + DefaultCurrency: "CNY", + } + + _, _, _, _, err := converter.ParseImportedData(context, user, []byte(""), 0, nil, nil, nil) + assert.NotNil(t, err) + + // Missing Time Column + _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"余额变更\",\"\",\"Test Account\",\"123.45\",\"\",\"\"\n"), 0, nil, nil, nil) + assert.NotNil(t, err) + + // Missing Type Column + _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"2024-09-01 00:00:00\",\"Test Category\",\"Test Account\",\"123.45\",\"\",\"\"\n"), 0, nil, nil, nil) + assert.NotNil(t, err) + + // Missing Sub Category Column + _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"日期\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"余额变更\",\"2024-09-01 00:00:00\",\"Test Account\",\"123.45\",\"\",\"\"\n"), 0, nil, nil, nil) + assert.NotNil(t, err) + + // Missing Account Name Column + _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"日期\",\"子类别\",\"金额\",\"备注\",\"关联Id\"\n"+ + "\"余额变更\",\"2024-09-01 00:00:00\",\"\",\"123.45\",\"\",\"\"\n"), 0, nil, nil, nil) + assert.NotNil(t, err) + + // Missing Amount Column + _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"日期\",\"子类别\",\"账户\",\"备注\",\"关联Id\"\n"+ + "\"余额变更\",\"2024-09-01 00:00:00\",\"\",\"Test Account\",\"\",\"\"\n"), 0, nil, nil, nil) + assert.NotNil(t, err) + + // Missing Related ID Column + _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+ + "\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\"\n"+ + "\"余额变更\",\"2024-09-01 00:00:00\",\"\",\"Test Account\",\"123.45\",\"\"\n"), 0, nil, nil, nil) + assert.NotNil(t, err) +} diff --git a/pkg/converters/transaction_data_converters.go b/pkg/converters/transaction_data_converters.go index 6d2bd962..3e90bd15 100644 --- a/pkg/converters/transaction_data_converters.go +++ b/pkg/converters/transaction_data_converters.go @@ -19,6 +19,8 @@ func GetTransactionDataImporter(fileType string) (TransactionDataImporter, error return EzBookKeepingTransactionDataCSVFileConverter, nil } else if fileType == "ezbookkeeping_tsv" { return EzBookKeepingTransactionDataTSVFileConverter, nil + } else if fileType == "feidee_mymoney_csv" { + return FeideeMymoneyTransactionDataCsvImporter, nil } else if fileType == "feidee_mymoney_xls" { return FeideeMymoneyTransactionDataXlsImporter, nil } else { diff --git a/pkg/converters/writable_data_table.go b/pkg/converters/writable_data_table.go new file mode 100644 index 00000000..a5563682 --- /dev/null +++ b/pkg/converters/writable_data_table.go @@ -0,0 +1,144 @@ +package converters + +import ( + "time" + + "github.com/mayswind/ezbookkeeping/pkg/utils" +) + +// WritableDataTable defines the structure of writable data table +type WritableDataTable struct { + allData []map[DataTableColumn]string + columns []DataTableColumn +} + +// WritableDataRow defines the structure of data row of writable data table +type WritableDataRow struct { + dataTable *WritableDataTable + rowData map[DataTableColumn]string +} + +// WritableDataRowIterator defines the structure of data row iterator of writable data table +type WritableDataRowIterator struct { + dataTable *WritableDataTable + nextIndex int +} + +// Add appends a new record to data table +func (t *WritableDataTable) Add(data map[DataTableColumn]string) { + finalData := make(map[DataTableColumn]string, len(data)) + + for i := 0; i < len(t.columns); i++ { + column := t.columns[i] + + if value, exists := data[column]; exists { + finalData[column] = value + } + } + + t.allData = append(t.allData, finalData) +} + +// Get returns the record in the specified index +func (t *WritableDataTable) Get(index int) ImportedDataRow { + if index >= len(t.allData) { + return nil + } + + rowData := t.allData[index] + + return &WritableDataRow{ + dataTable: t, + rowData: rowData, + } +} + +// DataRowCount returns the total count of data row +func (t *WritableDataTable) DataRowCount() int { + return len(t.allData) +} + +// GetDataColumnMapping returns data column map for data importer +func (t *WritableDataTable) GetDataColumnMapping() map[DataTableColumn]string { + dataColumnMapping := make(map[DataTableColumn]string, len(t.columns)) + + for i := 0; i < len(t.columns); i++ { + column := t.columns[i] + dataColumnMapping[column] = utils.IntToString(int(column)) + } + + return dataColumnMapping +} + +// HeaderLineColumnNames returns the header column name list +func (t *WritableDataTable) HeaderLineColumnNames() []string { + columnIndexes := make([]string, len(t.columns)) + + for i := 0; i < len(t.columns); i++ { + columnIndexes[i] = utils.IntToString(int(t.columns[i])) + } + + return columnIndexes +} + +// DataRowIterator returns the iterator of data row +func (t *WritableDataTable) DataRowIterator() ImportedDataRowIterator { + return &WritableDataRowIterator{ + dataTable: t, + nextIndex: 0, + } +} + +// ColumnCount returns the total count of column in this data row +func (r *WritableDataRow) ColumnCount() int { + return len(r.rowData) +} + +// GetData returns the data in the specified column index +func (r *WritableDataRow) GetData(columnIndex int) string { + if columnIndex >= len(r.dataTable.columns) { + return "" + } + + dataColumn := r.dataTable.columns[columnIndex] + + return r.rowData[dataColumn] +} + +// GetTime returns the time in the specified column index +func (r *WritableDataRow) GetTime(columnIndex int, timezoneOffset int16) (time.Time, error) { + return utils.ParseFromLongDateTime(r.GetData(columnIndex), timezoneOffset) +} + +// GetTimezoneOffset returns the time zone offset in the specified column index +func (r *WritableDataRow) GetTimezoneOffset(columnIndex int) (*time.Location, error) { + return utils.ParseFromTimezoneOffset(r.GetData(columnIndex)) +} + +// HasNext returns whether the iterator does not reach the end +func (t *WritableDataRowIterator) HasNext() bool { + return t.nextIndex < len(t.dataTable.allData) +} + +// Next returns the next imported data row +func (t *WritableDataRowIterator) Next() ImportedDataRow { + if t.nextIndex >= len(t.dataTable.allData) { + return nil + } + + rowData := t.dataTable.allData[t.nextIndex] + + t.nextIndex++ + + return &WritableDataRow{ + dataTable: t.dataTable, + rowData: rowData, + } +} + +func createNewWritableDataTable(columns []DataTableColumn) (*WritableDataTable, error) { + return &WritableDataTable{ + allData: make([]map[DataTableColumn]string, 0), + columns: columns, + }, nil +} diff --git a/pkg/converters/writable_data_table_test.go b/pkg/converters/writable_data_table_test.go new file mode 100644 index 00000000..a396d61a --- /dev/null +++ b/pkg/converters/writable_data_table_test.go @@ -0,0 +1,213 @@ +package converters + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/mayswind/ezbookkeeping/pkg/utils" +) + +func TestWritableDataTableAdd(t *testing.T) { + columns := make([]DataTableColumn, 5) + columns[0] = DATA_TABLE_TRANSACTION_TIME + columns[1] = DATA_TABLE_TRANSACTION_TYPE + columns[2] = DATA_TABLE_SUB_CATEGORY + columns[3] = DATA_TABLE_ACCOUNT_NAME + columns[4] = DATA_TABLE_AMOUNT + + writableDataTable, err := createNewWritableDataTable(columns) + assert.Nil(t, err) + + assert.Equal(t, 0, writableDataTable.DataRowCount()) + + expectedTransactionUnixTime := time.Now().Unix() + expectedTextualTransactionTime := utils.FormatUnixTimeToLongDateTime(expectedTransactionUnixTime, time.Local) + expectedTransactionType := "Expense" + expectedSubCategory := "Test Category" + expectedAccountName := "Test Account" + expectedAmount := "123.45" + + writableDataTable.Add(map[DataTableColumn]string{ + DATA_TABLE_TRANSACTION_TIME: expectedTextualTransactionTime, + DATA_TABLE_TRANSACTION_TYPE: expectedTransactionType, + DATA_TABLE_SUB_CATEGORY: expectedSubCategory, + DATA_TABLE_ACCOUNT_NAME: expectedAccountName, + DATA_TABLE_AMOUNT: expectedAmount, + }) + assert.Equal(t, 1, writableDataTable.DataRowCount()) + + dataRow := writableDataTable.Get(0) + + actualTransactionTime, err := dataRow.GetTime(0, utils.GetTimezoneOffsetMinutes(time.Local)) + assert.Nil(t, err) + + actualTransactionUnixTime := actualTransactionTime.Unix() + assert.Equal(t, expectedTransactionUnixTime, actualTransactionUnixTime) + + actualTextualTransactionTime := dataRow.GetData(0) + assert.Equal(t, expectedTextualTransactionTime, actualTextualTransactionTime) + + actualTransactionType := dataRow.GetData(1) + assert.Equal(t, expectedTransactionType, actualTransactionType) + + actualSubCategory := dataRow.GetData(2) + assert.Equal(t, expectedSubCategory, actualSubCategory) + + actualAccountName := dataRow.GetData(3) + assert.Equal(t, expectedAccountName, actualAccountName) + + actualAmount := dataRow.GetData(4) + assert.Equal(t, expectedAmount, actualAmount) +} + +func TestWritableDataTableAdd_NotExistsColumn(t *testing.T) { + columns := make([]DataTableColumn, 1) + columns[0] = DATA_TABLE_TRANSACTION_TIME + + writableDataTable, err := createNewWritableDataTable(columns) + assert.Nil(t, err) + + expectedTransactionUnixTime := time.Now().Unix() + expectedTextualTransactionTime := utils.FormatUnixTimeToLongDateTime(expectedTransactionUnixTime, time.Local) + expectedTransactionType := "Expense" + + writableDataTable.Add(map[DataTableColumn]string{ + DATA_TABLE_TRANSACTION_TIME: expectedTextualTransactionTime, + DATA_TABLE_TRANSACTION_TYPE: expectedTransactionType, + }) + assert.Equal(t, 1, writableDataTable.DataRowCount()) + + dataRow := writableDataTable.Get(0) + assert.Equal(t, 1, dataRow.ColumnCount()) +} + +func TestWritableDataTableGet_NotExistsRow(t *testing.T) { + columns := make([]DataTableColumn, 1) + columns[0] = DATA_TABLE_TRANSACTION_TIME + + writableDataTable, err := createNewWritableDataTable(columns) + assert.Nil(t, err) + + assert.Equal(t, 0, writableDataTable.DataRowCount()) + + dataRow := writableDataTable.Get(0) + assert.Nil(t, dataRow) +} + +func TestWritableDataRowGetData_NotExistsColumn(t *testing.T) { + columns := make([]DataTableColumn, 1) + columns[0] = DATA_TABLE_TRANSACTION_TIME + + writableDataTable, err := createNewWritableDataTable(columns) + assert.Nil(t, err) + + expectedTransactionUnixTime := time.Now().Unix() + expectedTextualTransactionTime := utils.FormatUnixTimeToLongDateTime(expectedTransactionUnixTime, time.Local) + + writableDataTable.Add(map[DataTableColumn]string{ + DATA_TABLE_TRANSACTION_TIME: expectedTextualTransactionTime, + }) + assert.Equal(t, 1, writableDataTable.DataRowCount()) + + dataRow := writableDataTable.Get(0) + assert.Equal(t, 1, dataRow.ColumnCount()) + assert.Equal(t, "", dataRow.GetData(1)) +} + +func TestWritableDataTableDataRowIterator(t *testing.T) { + columns := make([]DataTableColumn, 5) + columns[0] = DATA_TABLE_TRANSACTION_TIME + columns[1] = DATA_TABLE_TRANSACTION_TYPE + columns[2] = DATA_TABLE_SUB_CATEGORY + columns[3] = DATA_TABLE_ACCOUNT_NAME + columns[4] = DATA_TABLE_AMOUNT + + writableDataTable, err := createNewWritableDataTable(columns) + assert.Nil(t, err) + + assert.Equal(t, 0, writableDataTable.DataRowCount()) + + expectedTransactionUnixTimes := make([]int64, 3) + expectedTextualTransactionTimes := make([]string, 3) + expectedTransactionTypes := make([]string, 3) + expectedSubCategories := make([]string, 3) + expectedAccountNames := make([]string, 3) + expectedAmounts := make([]string, 3) + + expectedTransactionUnixTimes[0] = time.Now().Add(-5 * time.Hour).Unix() + expectedTextualTransactionTimes[0] = utils.FormatUnixTimeToLongDateTime(expectedTransactionUnixTimes[0], time.Local) + expectedTransactionTypes[0] = "Balance Modification" + expectedSubCategories[0] = "" + expectedAccountNames[0] = "Test Account" + expectedAmounts[0] = "123.45" + writableDataTable.Add(map[DataTableColumn]string{ + DATA_TABLE_TRANSACTION_TIME: expectedTextualTransactionTimes[0], + DATA_TABLE_TRANSACTION_TYPE: expectedTransactionTypes[0], + DATA_TABLE_SUB_CATEGORY: expectedSubCategories[0], + DATA_TABLE_ACCOUNT_NAME: expectedAccountNames[0], + DATA_TABLE_AMOUNT: expectedAmounts[0], + }) + + expectedTransactionUnixTimes[1] = time.Now().Add(-45 * time.Minute).Unix() + expectedTextualTransactionTimes[1] = utils.FormatUnixTimeToLongDateTime(expectedTransactionUnixTimes[1], time.Local) + expectedTransactionTypes[1] = "Expense" + expectedSubCategories[1] = "Test Category2" + expectedAccountNames[1] = "Test Account" + expectedAmounts[1] = "-23.4" + writableDataTable.Add(map[DataTableColumn]string{ + DATA_TABLE_TRANSACTION_TIME: expectedTextualTransactionTimes[1], + DATA_TABLE_TRANSACTION_TYPE: expectedTransactionTypes[1], + DATA_TABLE_SUB_CATEGORY: expectedSubCategories[1], + DATA_TABLE_ACCOUNT_NAME: expectedAccountNames[1], + DATA_TABLE_AMOUNT: expectedAmounts[1], + }) + + expectedTransactionUnixTimes[2] = time.Now().Unix() + expectedTextualTransactionTimes[2] = utils.FormatUnixTimeToLongDateTime(expectedTransactionUnixTimes[2], time.Local) + expectedTransactionTypes[2] = "Income" + expectedSubCategories[2] = "Test Category3" + expectedAccountNames[2] = "Test Account2" + expectedAmounts[2] = "123" + writableDataTable.Add(map[DataTableColumn]string{ + DATA_TABLE_TRANSACTION_TIME: expectedTextualTransactionTimes[2], + DATA_TABLE_TRANSACTION_TYPE: expectedTransactionTypes[2], + DATA_TABLE_SUB_CATEGORY: expectedSubCategories[2], + DATA_TABLE_ACCOUNT_NAME: expectedAccountNames[2], + DATA_TABLE_AMOUNT: expectedAmounts[2], + }) + assert.Equal(t, 3, writableDataTable.DataRowCount()) + + index := 0 + iterator := writableDataTable.DataRowIterator() + + for iterator.HasNext() { + dataRow := iterator.Next() + + actualTransactionTime, err := dataRow.GetTime(0, utils.GetTimezoneOffsetMinutes(time.Local)) + assert.Nil(t, err) + + actualTransactionUnixTime := actualTransactionTime.Unix() + assert.Equal(t, expectedTransactionUnixTimes[index], actualTransactionUnixTime) + + actualTextualTransactionTime := dataRow.GetData(0) + assert.Equal(t, expectedTextualTransactionTimes[index], actualTextualTransactionTime) + + actualTransactionType := dataRow.GetData(1) + assert.Equal(t, expectedTransactionTypes[index], actualTransactionType) + + actualSubCategory := dataRow.GetData(2) + assert.Equal(t, expectedSubCategories[index], actualSubCategory) + + actualAccountName := dataRow.GetData(3) + assert.Equal(t, expectedAccountNames[index], actualAccountName) + + actualAmount := dataRow.GetData(4) + assert.Equal(t, expectedAmounts[index], actualAmount) + + index++ + } + + assert.Equal(t, 3, index) +} diff --git a/pkg/errs/converter.go b/pkg/errs/converter.go index 72518301..63f12617 100644 --- a/pkg/errs/converter.go +++ b/pkg/errs/converter.go @@ -16,4 +16,8 @@ var ( ErrAmountInvalid = NewNormalError(NormalSubcategoryConverter, 9, http.StatusBadRequest, "transaction amount is invalid") ErrGeographicLocationInvalid = NewNormalError(NormalSubcategoryConverter, 10, http.StatusBadRequest, "geographic location is invalid") ErrFieldsInMultiTableAreDifferent = NewNormalError(NormalSubcategoryConverter, 11, http.StatusBadRequest, "fields in multiple table headers are different") + ErrInvalidFileHeader = NewNormalError(NormalSubcategoryConverter, 12, http.StatusBadRequest, "invalid file header") + ErrInvalidCSVFile = NewNormalError(NormalSubcategoryConverter, 13, http.StatusBadRequest, "invalid csv file") + ErrRelatedIdCannotBeBlank = NewNormalError(NormalSubcategoryConverter, 14, http.StatusBadRequest, "related id cannot be blank") + ErrFoundRecordNotHasRelatedRecord = NewNormalError(NormalSubcategoryConverter, 15, http.StatusBadRequest, "found some transactions without related records") ) diff --git a/src/consts/file.js b/src/consts/file.js index ef1511d3..45214e92 100644 --- a/src/consts/file.js +++ b/src/consts/file.js @@ -11,6 +11,11 @@ const supportedImportFileTypes = [ name: 'ezbookkeeping Data Export File (TSV)', extensions: '.tsv' }, + { + type: 'feidee_mymoney_csv', + name: 'Feidee MyMoney (App) Data Export File', + extensions: '.csv' + }, { type: 'feidee_mymoney_xls', name: 'Feidee MyMoney (Web) Data Export File', diff --git a/src/locales/en.json b/src/locales/en.json index 65fce611..5e275a96 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1118,6 +1118,10 @@ "transaction amount is invalid": "Transaction amount is invalid", "geographic location is invalid": "Geographic location is invalid", "fields in multiple table headers are different": "Fields in multiple table headers are different", + "invalid file header": "Invalid file header", + "invalid csv file": "Invalid CSV file", + "related id cannot be blank": "Related ID cannot be blank", + "found some transactions without related records": "There are some transactions which don't have related records", "query items cannot be blank": "There are no query items", "query items too much": "There are too many query items", "query items have invalid item": "There is invalid item in query items", @@ -1497,6 +1501,7 @@ "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", "Data File": "Data File", "Click to select import file": "Click to select import file", "No data to import": "No data to import", diff --git a/src/locales/zh_Hans.json b/src/locales/zh_Hans.json index 983adb66..b618aa84 100644 --- a/src/locales/zh_Hans.json +++ b/src/locales/zh_Hans.json @@ -1118,6 +1118,10 @@ "transaction amount is invalid": "交易金额无效", "geographic location is invalid": "地理位置无效", "fields in multiple table headers are different": "多个表头中的字段不同", + "invalid file header": "无效的文件头", + "invalid csv file": "无效的 CSV 文件", + "related id cannot be blank": "关联Id不能为空", + "found some transactions without related records": "有一些交易没有关联记录", "query items cannot be blank": "请求项目不能为空", "query items too much": "请求项目过多", "query items have invalid item": "请求项目中有非法项目", @@ -1497,6 +1501,7 @@ "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) 数据导出文件", "Data File": "数据文件", "Click to select import file": "点击选择导入文件", "No data to import": "没有可以导入的数据",