diff --git a/pkg/converters/ofx/ofx_transaction_data_file_importer_test.go b/pkg/converters/ofx/ofx_transaction_data_file_importer_test.go index 24fcc3af..e8b47df1 100644 --- a/pkg/converters/ofx/ofx_transaction_data_file_importer_test.go +++ b/pkg/converters/ofx/ofx_transaction_data_file_importer_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/errs" "github.com/mayswind/ezbookkeeping/pkg/models" "github.com/mayswind/ezbookkeeping/pkg/utils" ) @@ -22,38 +23,60 @@ func TestOFXTransactionDataFileParseImportedData_MinimumValidData(t *testing.T) allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte( ""+ " "+ - " "+ - " "+ - " CNY"+ - " "+ - " 123"+ - " "+ - " "+ - " "+ - " DEP"+ - " 20240901012345.000[+8:CST]"+ - " 123.45"+ - " "+ - " "+ - " CHECK"+ - " 20240901123456.000[+8:CST]"+ - " -0.12"+ - " "+ - " "+ - " XFER"+ - " 20240901235959.000[+8:CST]"+ - " -1.00"+ - " "+ - " "+ - " "+ - " "+ + " "+ + " "+ + " CNY"+ + " "+ + " 123"+ + " "+ + " "+ + " "+ + " DEP"+ + " 20240901012345.000[+8:CST]"+ + " 123.45"+ + " "+ + " "+ + " CHECK"+ + " 20240901123456.000[+8:CST]"+ + " -0.12"+ + " "+ + " "+ + " XFER"+ + " 20240901235959.000[+8:CST]"+ + " -1.00"+ + " "+ + " "+ + " "+ + " "+ " "+ + " "+ + " "+ + " "+ + " USD"+ + " "+ + " 456"+ + " "+ + " "+ + " "+ + " ATM"+ + " 20240902012345.000[+8:CST]"+ + " 1.23"+ + " "+ + " "+ + " POS"+ + " 20240902123456.000[+8:CST]"+ + " -0.01"+ + " "+ + " "+ + " "+ + " "+ + " "+ ""), 0, nil, nil, nil, nil, nil) assert.Nil(t, err) - assert.Equal(t, 3, len(allNewTransactions)) - assert.Equal(t, 2, len(allNewAccounts)) + assert.Equal(t, 5, len(allNewTransactions)) + assert.Equal(t, 3, len(allNewAccounts)) assert.Equal(t, 1, len(allNewSubExpenseCategories)) assert.Equal(t, 1, len(allNewSubIncomeCategories)) assert.Equal(t, 1, len(allNewSubTransferCategories)) @@ -83,6 +106,22 @@ func TestOFXTransactionDataFileParseImportedData_MinimumValidData(t *testing.T) assert.Equal(t, "", allNewTransactions[2].OriginalDestinationAccountName) assert.Equal(t, "", allNewTransactions[2].OriginalCategoryName) + assert.Equal(t, int64(1234567890), allNewTransactions[3].Uid) + assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[3].Type) + assert.Equal(t, int64(1725211425), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime)) + assert.Equal(t, int64(123), allNewTransactions[3].Amount) + assert.Equal(t, "456", allNewTransactions[3].OriginalSourceAccountName) + assert.Equal(t, "USD", allNewTransactions[3].OriginalSourceAccountCurrency) + assert.Equal(t, "", allNewTransactions[3].OriginalCategoryName) + + assert.Equal(t, int64(1234567890), allNewTransactions[4].Uid) + assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[4].Type) + assert.Equal(t, int64(1725251696), utils.GetUnixTimeFromTransactionTime(allNewTransactions[4].TransactionTime)) + assert.Equal(t, int64(1), allNewTransactions[4].Amount) + assert.Equal(t, "456", allNewTransactions[4].OriginalSourceAccountName) + assert.Equal(t, "USD", allNewTransactions[4].OriginalSourceAccountCurrency) + assert.Equal(t, "", allNewTransactions[4].OriginalCategoryName) + assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid) assert.Equal(t, "123", allNewAccounts[0].Name) assert.Equal(t, "CNY", allNewAccounts[0].Currency) @@ -91,6 +130,10 @@ func TestOFXTransactionDataFileParseImportedData_MinimumValidData(t *testing.T) assert.Equal(t, "", allNewAccounts[1].Name) assert.Equal(t, "CNY", allNewAccounts[1].Currency) + assert.Equal(t, int64(1234567890), allNewAccounts[2].Uid) + assert.Equal(t, "456", allNewAccounts[2].Name) + assert.Equal(t, "USD", allNewAccounts[2].Currency) + assert.Equal(t, int64(1234567890), allNewSubExpenseCategories[0].Uid) assert.Equal(t, "", allNewSubExpenseCategories[0].Name) @@ -100,3 +143,501 @@ func TestOFXTransactionDataFileParseImportedData_MinimumValidData(t *testing.T) assert.Equal(t, int64(1234567890), allNewSubTransferCategories[0].Uid) assert.Equal(t, "", allNewSubTransferCategories[0].Name) } + +func TestOFXTransactionDataFileParseImportedData_ParseValidTransactionTime(t *testing.T) { + converter := OFXTransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + ""+ + " "+ + " "+ + " "+ + " CNY"+ + " "+ + " 123"+ + " "+ + " "+ + " "+ + " DEP"+ + " 20240901"+ + " 123.45"+ + " "+ + " "+ + " DEP"+ + " 20240901123456"+ + " 123.45"+ + " "+ + " "+ + " DEP"+ + " 20240901123456.789"+ + " 123.45"+ + " "+ + " "+ + " DEP"+ + " 20240901125959.000[-3]"+ + " 123.45"+ + " "+ + " "+ + " DEP"+ + " 20240901122959.000[-3.5]"+ + " 123.45"+ + " "+ + " "+ + " DEP"+ + " 20240902030405.000[0]"+ + " 123.45"+ + " "+ + " "+ + " "+ + " "+ + " "+ + ""), 0, nil, nil, nil, nil, nil) + + assert.Nil(t, err) + + assert.Equal(t, 6, len(allNewTransactions)) + + assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime)) + assert.Equal(t, int64(1725194096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime)) + assert.Equal(t, int64(1725194096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime)) + assert.Equal(t, int64(1725206399), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime)) + assert.Equal(t, int64(1725206399), utils.GetUnixTimeFromTransactionTime(allNewTransactions[4].TransactionTime)) + assert.Equal(t, int64(1725246245), utils.GetUnixTimeFromTransactionTime(allNewTransactions[5].TransactionTime)) +} + +func TestOFXTransactionDataFileParseImportedData_ParseInvalidTransactionTime(t *testing.T) { + converter := OFXTransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + _, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + ""+ + " "+ + " "+ + " "+ + " CNY"+ + " "+ + " 123"+ + " "+ + " "+ + " "+ + " DEP"+ + " 2024"+ + " 123.45"+ + " "+ + " "+ + " "+ + " "+ + " "+ + ""), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message) + + _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + ""+ + " "+ + " "+ + " "+ + " CNY"+ + " "+ + " 123"+ + " "+ + " "+ + " "+ + " DEP"+ + " 2024-09-01"+ + " 123.45"+ + " "+ + " "+ + " "+ + " "+ + " "+ + ""), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message) + + _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + ""+ + " "+ + " "+ + " "+ + " CNY"+ + " "+ + " 123"+ + " "+ + " "+ + " "+ + " DEP"+ + " 202491"+ + " 123.45"+ + " "+ + " "+ + " "+ + " "+ + " "+ + ""), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message) + + _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + ""+ + " "+ + " "+ + " "+ + " CNY"+ + " "+ + " 123"+ + " "+ + " "+ + " "+ + " DEP"+ + " 20240901 12:34:56"+ + " 123.45"+ + " "+ + " "+ + " "+ + " "+ + " "+ + ""), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message) +} + +func TestOFXTransactionDataFileParseImportedData_ParseAmount_CommaAsDecimalPoint(t *testing.T) { + converter := OFXTransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + ""+ + " "+ + " "+ + " "+ + " CNY"+ + " "+ + " 123"+ + " "+ + " "+ + " "+ + " DEP"+ + " 20240901012345.000[+8:CST]"+ + " 123,45"+ + " "+ + " "+ + " "+ + " "+ + " "+ + ""), 0, nil, nil, nil, nil, nil) + + assert.Nil(t, err) + + assert.Equal(t, 1, len(allNewTransactions)) + assert.Equal(t, int64(12345), allNewTransactions[0].Amount) +} + +func TestOFXTransactionDataFileParseImportedData_ParseInvalidAmount(t *testing.T) { + converter := OFXTransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + _, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + ""+ + " "+ + " "+ + " "+ + " CNY"+ + " "+ + " 123"+ + " "+ + " "+ + " "+ + " DEP"+ + " 20240901012345.000[+8:CST]"+ + " 123 45"+ + " "+ + " "+ + " "+ + " "+ + " "+ + ""), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrAmountInvalid.Message) +} + +func TestOFXTransactionDataFileParseImportedData_ParseTransactionCurrency(t *testing.T) { + converter := OFXTransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + ""+ + " "+ + " "+ + " "+ + " CNY"+ + " "+ + " 123"+ + " "+ + " "+ + " "+ + " DEP"+ + " 20240901012345.000[+8:CST]"+ + " 123.45"+ + " USD"+ + " "+ + " "+ + " "+ + " "+ + " "+ + ""), 0, nil, nil, nil, nil, nil) + + assert.Nil(t, err) + assert.Equal(t, 1, len(allNewTransactions)) + assert.Equal(t, "USD", allNewTransactions[0].OriginalSourceAccountCurrency) +} + +func TestOFXTransactionDataFileParseImportedData_ParseDescription(t *testing.T) { + converter := OFXTransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + ""+ + " "+ + " "+ + " "+ + " CNY"+ + " "+ + " 123"+ + " "+ + " "+ + " "+ + " DEP"+ + " 20240901012345.000[+8:CST]"+ + " 123.45"+ + " Test"+ + " foo bar\t#test"+ + " "+ + " "+ + " "+ + " "+ + " "+ + ""), 0, nil, nil, nil, nil, nil) + + assert.Nil(t, err) + assert.Equal(t, 1, len(allNewTransactions)) + assert.Equal(t, "foo bar\t#test", allNewTransactions[0].Comment) + + allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + ""+ + " "+ + " "+ + " "+ + " CNY"+ + " "+ + " 123"+ + " "+ + " "+ + " "+ + " DEP"+ + " 20240901012345.000[+8:CST]"+ + " 123.45"+ + " Test"+ + " "+ + " "+ + " "+ + " "+ + " "+ + ""), 0, nil, nil, nil, nil, nil) + + assert.Nil(t, err) + assert.Equal(t, 1, len(allNewTransactions)) + assert.Equal(t, "Test", allNewTransactions[0].Comment) + + allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + ""+ + " "+ + " "+ + " "+ + " CNY"+ + " "+ + " 123"+ + " "+ + " "+ + " "+ + " DEP"+ + " 20240901012345.000[+8:CST]"+ + " 123.45"+ + " "+ + " Test"+ + " "+ + " "+ + " "+ + " "+ + " "+ + " "+ + ""), 0, nil, nil, nil, nil, nil) + + assert.Nil(t, err) + assert.Equal(t, 1, len(allNewTransactions)) + assert.Equal(t, "Test", allNewTransactions[0].Comment) +} + +func TestOFXTransactionDataFileParseImportedData_MissingAccountFromNode(t *testing.T) { + converter := OFXTransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1, + DefaultCurrency: "CNY", + } + + // Missing Posted Date Node + _, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + ""+ + " "+ + " "+ + " "+ + " CNY"+ + " "+ + " "+ + " DEP"+ + " 20240901012345.000[+8:CST]"+ + " 123.45"+ + " "+ + " "+ + " "+ + " "+ + " "+ + ""), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrMissingAccountData.Message) +} + +func TestOFXTransactionDataFileParseImportedData_MissingCurrencyNode(t *testing.T) { + converter := OFXTransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1, + DefaultCurrency: "CNY", + } + + // Missing Default Currency Node + _, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + ""+ + " "+ + " "+ + " "+ + " "+ + " 123"+ + " "+ + " "+ + " "+ + " DEP"+ + " 20240901012345.000[+8:CST]"+ + " 123.45"+ + " "+ + " "+ + " "+ + " "+ + " "+ + ""), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message) +} + +func TestOFXTransactionDataFileParseImportedData_MissingTransactionRequiredNode(t *testing.T) { + converter := OFXTransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1, + DefaultCurrency: "CNY", + } + + // Missing Posted Date Node + _, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + ""+ + " "+ + " "+ + " "+ + " CNY"+ + " "+ + " 123"+ + " "+ + " "+ + " "+ + " DEP"+ + " 123.45"+ + " "+ + " "+ + " "+ + " "+ + " "+ + ""), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrMissingTransactionTime.Message) + + // Missing Transaction Type Node + _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + ""+ + " "+ + " "+ + " "+ + " CNY"+ + " "+ + " 123"+ + " "+ + " "+ + " "+ + " 20240901012345.000[+8:CST]"+ + " 123.45"+ + " "+ + " "+ + " "+ + " "+ + " "+ + ""), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrTransactionTypeInvalid.Message) + + // Missing Amount Node + _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + ""+ + " "+ + " "+ + " "+ + " CNY"+ + " "+ + " 123"+ + " "+ + " "+ + " "+ + " DEP"+ + " 20240901012345.000[+8:CST]"+ + " "+ + " "+ + " "+ + " "+ + " "+ + ""), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrAmountInvalid.Message) +} diff --git a/pkg/converters/ofx/ofx_transaction_table.go b/pkg/converters/ofx/ofx_transaction_table.go index 9d16a79d..6fea776c 100644 --- a/pkg/converters/ofx/ofx_transaction_table.go +++ b/pkg/converters/ofx/ofx_transaction_table.go @@ -117,6 +117,10 @@ func (t *ofxTransactionDataRowIterator) Next(ctx core.Context, user *models.User func (t *ofxTransactionDataRowIterator) parseTransaction(ctx core.Context, user *models.User, ofxTransaction *ofxTransactionData) (map[datatable.TransactionDataTableColumn]string, error) { data := make(map[datatable.TransactionDataTableColumn]string, len(ofxTransactionSupportedColumns)) + if ofxTransaction.PostedDate == "" { + return nil, errs.ErrMissingTransactionTime + } + datetime, timezone, err := t.parseTransactionTimeAndTimeZone(ctx, ofxTransaction.PostedDate) if err != nil { @@ -126,12 +130,20 @@ func (t *ofxTransactionDataRowIterator) parseTransaction(ctx core.Context, user data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = datetime data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = timezone + if ofxTransaction.Amount == "" { + return nil, errs.ErrAmountInvalid + } + amount, err := utils.ParseAmount(strings.ReplaceAll(ofxTransaction.Amount, ",", ".")) // ofx supports decimal point or comma to indicate the start of the fractional amount if err != nil { return nil, errs.ErrAmountInvalid } + if ofxTransaction.TransactionType == "" { + return nil, errs.ErrTransactionTypeInvalid + } + if transactionType, exists := ofxTransactionTypeMapping[ofxTransaction.TransactionType]; exists { data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(transactionType)) @@ -150,6 +162,10 @@ func (t *ofxTransactionDataRowIterator) parseTransaction(ctx core.Context, user } } + if ofxTransaction.FromAccountId == "" { + return nil, errs.ErrMissingAccountData + } + data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = ofxTransaction.FromAccountId if ofxTransaction.Currency != "" { @@ -189,12 +205,22 @@ func (t *ofxTransactionDataRowIterator) parseTransactionTimeAndTimeZone(ctx core tzOffset := ofxDefaultTimezoneOffset if len(datetime) >= 8 { // YYYYMMDD + if !utils.IsStringOnlyContainsDigits(datetime[0:8]) { + log.Errorf(ctx, "[ofx_transaction_table.parseTransactionTimeAndTimeZone] cannot parse time \"%s\", because contains non-digit character", datetime) + return "", "", errs.ErrTransactionTimeInvalid + } + year = datetime[0:4] month = datetime[4:6] day = datetime[6:8] } if len(datetime) >= 14 { // YYYYMMDDHHMMSS + if !utils.IsStringOnlyContainsDigits(datetime[8:14]) { + log.Errorf(ctx, "[ofx_transaction_table.parseTransactionTimeAndTimeZone] cannot parse time \"%s\", because contains non-digit character", datetime) + return "", "", errs.ErrTransactionTimeInvalid + } + hour = datetime[8:10] minute = datetime[10:12] second = datetime[12:14] diff --git a/pkg/utils/numbers.go b/pkg/utils/numbers.go index 42fabab6..8995d307 100644 --- a/pkg/utils/numbers.go +++ b/pkg/utils/numbers.go @@ -11,6 +11,17 @@ var ( numberPattern = regexp.MustCompile("(-?\\d+)(\\.\\d+)?") ) +// IsStringOnlyContainsDigits returns whether the specified string only contains digit characters +func IsStringOnlyContainsDigits(str string) bool { + for i := 0; i < len(str); i++ { + if str[i] < '0' || str[i] > '9' { + return false + } + } + + return true +} + // GetRandomInteger returns a random number, the max parameter represents upper limit func GetRandomInteger(max int) (int, error) { result, err := rand.Int(rand.Reader, big.NewInt(int64(max))) diff --git a/pkg/utils/numbers_test.go b/pkg/utils/numbers_test.go index 5746b7c1..221310aa 100644 --- a/pkg/utils/numbers_test.go +++ b/pkg/utils/numbers_test.go @@ -6,6 +6,17 @@ import ( "github.com/stretchr/testify/assert" ) +func TestIsStringOnlyContainsDigits(t *testing.T) { + actualValue := IsStringOnlyContainsDigits("0123456789") + assert.True(t, actualValue) + + actualValue = IsStringOnlyContainsDigits("12345a") + assert.False(t, actualValue) + + actualValue = IsStringOnlyContainsDigits("12345 ") + assert.False(t, actualValue) +} + func TestParseFirstConsecutiveNumber(t *testing.T) { expectedValue := "¥123.45" actualValue, success := ParseFirstConsecutiveNumber(expectedValue)