diff --git a/pkg/converters/gnucash/gnucash_data.go b/pkg/converters/gnucash/gnucash_data.go new file mode 100644 index 00000000..188aa2a7 --- /dev/null +++ b/pkg/converters/gnucash/gnucash_data.go @@ -0,0 +1,87 @@ +package gnucash + +import "encoding/xml" + +const gnucashCommodityCurrencySpace = "CURRENCY" +const gnucashRootAccountType = "ROOT" +const gnucashEquityAccountType = "EQUITY" +const gnucashIncomeAccountType = "INCOME" +const gnucashExpenseAccountType = "EXPENSE" + +const gnucashSlotEquityType = "equity-type" +const gnucashSlotEquityTypeOpeningBalance = "opening-balance" + +var gnucashAssetOrLiabilityAccountTypes = map[string]bool{ + "ASSET": true, + "BANK": true, + "CASH": true, + "CREDIT": true, + "LIABILITY": true, + "MUTUAL": true, + "PAYABLE": true, + "RECEIVABLE": true, + "STOCK": true, +} + +// gnucashDatabase represents the struct of gnucash database file +type gnucashDatabase struct { + XMLName xml.Name `xml:"gnc-v2"` + Counts []*gnucashCountData `xml:"count-data"` + Books []*gnucashBookData `xml:"book"` +} + +// gnucashCountData represents the struct of gnucash count data +type gnucashCountData struct { + Key string `xml:"type,attr"` + Value string `xml:",chardata"` +} + +// gnucashBookData represents the struct of gnucash book data +type gnucashBookData struct { + Id string `xml:"id"` + Counts []*gnucashCountData `xml:"count-data"` + Accounts []*gnucashAccountData `xml:"account"` + Transactions []*gnucashTransactionData `xml:"transaction"` +} + +// gnucashCommodityData represents the struct of gnucash commodity data +type gnucashCommodityData struct { + Space string `xml:"space"` + Id string `xml:"id"` +} + +// gnucashSlotData represents the struct of gnucash slot data +type gnucashSlotData struct { + Key string `xml:"key"` + Value string `xml:"value"` +} + +// gnucashAccountData represents the struct of gnucash account data +type gnucashAccountData struct { + Name string `xml:"name"` + Id string `xml:"id"` + AccountType string `xml:"type"` + Description string `xml:"description"` + ParentId string `xml:"parent"` + Commodity *gnucashCommodityData `xml:"commodity"` + Slots []*gnucashSlotData `xml:"slots>slot"` +} + +// gnucashTransactionData represents the struct of gnucash transaction data +type gnucashTransactionData struct { + Id string `xml:"id"` + Currency *gnucashCommodityData `xml:"currency"` + PostedDate string `xml:"date-posted>date"` + EnteredDate string `xml:"date-entered>date"` + Description string `xml:"description"` + Splits []*gnucashTransactionSplitData `xml:"splits>split"` +} + +// gnucashTransactionSplitData represents the struct of gnucash transaction split data +type gnucashTransactionSplitData struct { + Id string `xml:"id"` + ReconciledState string `xml:"reconciled-state"` + Value string `xml:"value"` + Quantity string `xml:"quantity"` + Account string `xml:"account"` +} diff --git a/pkg/converters/gnucash/gnucash_data_reader.go b/pkg/converters/gnucash/gnucash_data_reader.go new file mode 100644 index 00000000..a2de9bca --- /dev/null +++ b/pkg/converters/gnucash/gnucash_data_reader.go @@ -0,0 +1,55 @@ +package gnucash + +import ( + "bytes" + "compress/gzip" + "encoding/xml" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/utils" +) + +// gnucashDatabaseReader defines the structure of gnucash database reader +type gnucashDatabaseReader struct { + xmlDecoder *xml.Decoder +} + +// read returns the imported gnucash data +func (r *gnucashDatabaseReader) read(ctx core.Context) (*gnucashDatabase, error) { + database := &gnucashDatabase{} + + err := r.xmlDecoder.Decode(&database) + + if err != nil { + return nil, err + } + + return database, nil +} + +func createNewGnuCashDatabaseReader(data []byte) (*gnucashDatabaseReader, error) { + if len(data) > 2 && data[0] == 0x1F && data[1] == 0x8B { // gzip magic number + gzipReader, err := gzip.NewReader(bytes.NewReader(data)) + + if err != nil { + return nil, err + } + + xmlDecoder := xml.NewDecoder(gzipReader) + xmlDecoder.CharsetReader = utils.IdentReader + + return &gnucashDatabaseReader{ + xmlDecoder: xmlDecoder, + }, nil + } else if len(data) > 5 && data[0] == 0x3C && data[1] == 0x3F && data[2] == 0x78 && data[3] == 0x6D && data[4] == 0x6C { // \n" + + "\n" + + "\n" + + "\n" + + " Root Account\n" + + " 00000000000000000000000000000001\n" + + " ROOT\n" + + "\n" + + "\n" + + " Opening Balances\n" + + " 00000000000000000000000000000010\n" + + " EQUITY\n" + + " \n" + + " CURRENCY\n" + + " CNY\n" + + " \n" + + " \n" + + " \n" + + " equity-type\n" + + " opening-balance\n" + + " \n" + + " \n" + + "\n" + + "\n" + + " Test Category\n" + + " 00000000000000000000000000000100\n" + + " INCOME\n" + + " 00000000000000000000000000000001\n" + + "\n" + + "\n" + + " Test Category2\n" + + " 00000000000000000000000000000200\n" + + " EXPENSE\n" + + " 00000000000000000000000000000001\n" + + "\n" + + "\n" + + " Test Account\n" + + " 00000000000000000000000000001000\n" + + " BANK\n" + + " \n" + + " CURRENCY\n" + + " CNY\n" + + " \n" + + " 00000000000000000000000000000001\n" + + "\n" + + "\n" + + " Test Account2\n" + + " 00000000000000000000000000002000\n" + + " CASH\n" + + " \n" + + " CURRENCY\n" + + " CNY\n" + + " \n" + + " 00000000000000000000000000000001\n" + + "\n" + + "\n" + + " \n" + + " 2024-09-01 00:00:00 +0000\n" + + " \n" + + " \n" + + " \n" + + " 12345/100\n" + + " 00000000000000000000000000001000\n" + + " \n" + + " \n" + + " -12345/100\n" + + " 00000000000000000000000000000010\n" + + " \n" + + " \n" + + "\n" + + "\n" + + " \n" + + " 2024-09-01 01:23:45 +0000\n" + + " \n" + + " \n" + + " \n" + + " 12/100\n" + + " 00000000000000000000000000001000\n" + + " \n" + + " \n" + + " -12/100\n" + + " 00000000000000000000000000000100\n" + + " \n" + + " \n" + + "\n" + + "\n" + + " \n" + + " 2024-09-01 12:34:56 +0000\n" + + " \n" + + " \n" + + " \n" + + " 100/100\n" + + " 00000000000000000000000000000200\n" + + " \n" + + " \n" + + " -100/100\n" + + " 00000000000000000000000000001000\n" + + " \n" + + " \n" + + "\n" + + "\n" + + " \n" + + " 2024-09-01 23:59:59 +0000\n" + + " \n" + + " \n" + + " \n" + + " 5/100\n" + + " 00000000000000000000000000002000\n" + + " \n" + + " \n" + + " -5/100\n" + + " 00000000000000000000000000001000\n" + + " \n" + + " \n" + + "\n" + + "\n" + + "\n" + +const gnucashCommonValidDataCaseHeader = "\n" + + "\n" + + "\n" + + "\n" + + " Root Account\n" + + " 00000000000000000000000000000001\n" + + " ROOT\n" + + "\n" + + "\n" + + " Opening Balances\n" + + " 00000000000000000000000000000010\n" + + " EQUITY\n" + + " \n" + + " CURRENCY\n" + + " CNY\n" + + " \n" + + " \n" + + " \n" + + " equity-type\n" + + " opening-balance\n" + + " \n" + + " \n" + + "\n" + + "\n" + + " Test Account\n" + + " 00000000000000000000000000001000\n" + + " BANK\n" + + " \n" + + " CURRENCY\n" + + " CNY\n" + + " \n" + + " 00000000000000000000000000000001\n" + + "\n" + +const gnucashCommonValidDataCaseFooter = "\n" + + "\n" + +func TestGnuCashTransactionDatabaseFileParseImportedData_MinimumValidData(t *testing.T) { + converter := GnuCashTransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte(gnucashMinimumValidDataCase), 0, nil, nil, nil, nil, nil) + + assert.Nil(t, err) + checkParsedMinimumValidData(t, allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags) +} + +func TestGnuCashTransactionDatabaseFileParseImportedData_GzippedMinimumValidData(t *testing.T) { + converter := GnuCashTransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + var buffer bytes.Buffer + gzipWriter := gzip.NewWriter(&buffer) + _, err := gzipWriter.Write([]byte(gnucashMinimumValidDataCase)) + assert.Nil(t, err) + + err = gzipWriter.Close() + assert.Nil(t, err) + + gzippedData := buffer.Bytes() + allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, gzippedData, 0, nil, nil, nil, nil, nil) + + assert.Nil(t, err) + checkParsedMinimumValidData(t, allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags) +} + +func TestGnuCashTransactionDatabaseFileParseImportedData_MinimumValidDataWithReversedSplitOrder(t *testing.T) { + converter := GnuCashTransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte("\n"+ + "\n"+ + "\n"+ + "\n"+ + " Root Account\n"+ + " 00000000000000000000000000000001\n"+ + " ROOT\n"+ + "\n"+ + "\n"+ + " Opening Balances\n"+ + " 00000000000000000000000000000010\n"+ + " EQUITY\n"+ + " \n"+ + " CURRENCY\n"+ + " CNY\n"+ + " \n"+ + " \n"+ + " \n"+ + " equity-type\n"+ + " opening-balance\n"+ + " \n"+ + " \n"+ + "\n"+ + "\n"+ + " Test Category\n"+ + " 00000000000000000000000000000100\n"+ + " INCOME\n"+ + " 00000000000000000000000000000001\n"+ + "\n"+ + "\n"+ + " Test Category2\n"+ + " 00000000000000000000000000000200\n"+ + " EXPENSE\n"+ + " 00000000000000000000000000000001\n"+ + "\n"+ + "\n"+ + " Test Account\n"+ + " 00000000000000000000000000001000\n"+ + " BANK\n"+ + " \n"+ + " CURRENCY\n"+ + " CNY\n"+ + " \n"+ + " 00000000000000000000000000000001\n"+ + "\n"+ + "\n"+ + " Test Account2\n"+ + " 00000000000000000000000000002000\n"+ + " CASH\n"+ + " \n"+ + " CURRENCY\n"+ + " CNY\n"+ + " \n"+ + " 00000000000000000000000000000001\n"+ + "\n"+ + "\n"+ + " \n"+ + " 2024-09-01 00:00:00 +0000\n"+ + " \n"+ + " \n"+ + " \n"+ + " -12345/100\n"+ + " 00000000000000000000000000000010\n"+ + " \n"+ + " \n"+ + " 12345/100\n"+ + " 00000000000000000000000000001000\n"+ + " \n"+ + " \n"+ + "\n"+ + "\n"+ + " \n"+ + " 2024-09-01 01:23:45 +0000\n"+ + " \n"+ + " \n"+ + " \n"+ + " -12/100\n"+ + " 00000000000000000000000000000100\n"+ + " \n"+ + " \n"+ + " 12/100\n"+ + " 00000000000000000000000000001000\n"+ + " \n"+ + " \n"+ + "\n"+ + "\n"+ + " \n"+ + " 2024-09-01 12:34:56 +0000\n"+ + " \n"+ + " \n"+ + " \n"+ + " -100/100\n"+ + " 00000000000000000000000000001000\n"+ + " \n"+ + " \n"+ + " 100/100\n"+ + " 00000000000000000000000000000200\n"+ + " \n"+ + " \n"+ + "\n"+ + "\n"+ + " \n"+ + " 2024-09-01 23:59:59 +0000\n"+ + " \n"+ + " \n"+ + " \n"+ + " -5/100\n"+ + " 00000000000000000000000000001000\n"+ + " \n"+ + " \n"+ + " 5/100\n"+ + " 00000000000000000000000000002000\n"+ + " \n"+ + " \n"+ + "\n"+ + "\n"+ + "\n"), 0, nil, nil, nil, nil, nil) + + assert.Nil(t, err) + checkParsedMinimumValidData(t, allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags) +} + +func TestGnuCashTransactionDatabaseFileParseImportedData_ParseInvalidTime(t *testing.T) { + converter := GnuCashTransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + _, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + gnucashCommonValidDataCaseHeader+ + "\n"+ + " \n"+ + " 2024-09-01 00:00:00\n"+ + " \n"+ + " \n"+ + " \n"+ + " 12345/100\n"+ + " 00000000000000000000000000001000\n"+ + " \n"+ + " \n"+ + " -12345/100\n"+ + " 00000000000000000000000000000010\n"+ + " \n"+ + " \n"+ + "\n"+ + gnucashCommonValidDataCaseFooter), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message) + + _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + gnucashCommonValidDataCaseHeader+ + "\n"+ + " \n"+ + " 2024-09-01T00:00:00+00:00\n"+ + " \n"+ + " \n"+ + " \n"+ + " 12345/100\n"+ + " 00000000000000000000000000001000\n"+ + " \n"+ + " \n"+ + " -12345/100\n"+ + " 00000000000000000000000000000010\n"+ + " \n"+ + " \n"+ + "\n"+ + gnucashCommonValidDataCaseFooter), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message) +} + +func TestGnuCashTransactionDatabaseFileParseImportedData_ParseValidTimezone(t *testing.T) { + converter := GnuCashTransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + gnucashCommonValidDataCaseHeader+ + "\n"+ + " \n"+ + " 2024-09-01 12:34:56 -1000\n"+ + " \n"+ + " \n"+ + " \n"+ + " 12345/100\n"+ + " 00000000000000000000000000001000\n"+ + " \n"+ + " \n"+ + " -12345/100\n"+ + " 00000000000000000000000000000010\n"+ + " \n"+ + " \n"+ + "\n"+ + gnucashCommonValidDataCaseFooter), 0, nil, nil, nil, nil, nil) + assert.Nil(t, err) + assert.Equal(t, 1, len(allNewTransactions)) + assert.Equal(t, int64(1725230096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime)) + + allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + gnucashCommonValidDataCaseHeader+ + "\n"+ + " \n"+ + " 2024-09-01 12:34:56 +1245\n"+ + " \n"+ + " \n"+ + " \n"+ + " 12345/100\n"+ + " 00000000000000000000000000001000\n"+ + " \n"+ + " \n"+ + " -12345/100\n"+ + " 00000000000000000000000000000010\n"+ + " \n"+ + " \n"+ + "\n"+ + gnucashCommonValidDataCaseFooter), 0, nil, nil, nil, nil, nil) + assert.Nil(t, err) + assert.Equal(t, 1, len(allNewTransactions)) + assert.Equal(t, int64(1725148196), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime)) +} + +func TestGnuCashTransactionDatabaseFileParseImportedData_ParseValidAccountCurrency(t *testing.T) { + converter := GnuCashTransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("\n"+ + "\n"+ + "\n"+ + "\n"+ + " Root Account\n"+ + " 00000000000000000000000000000001\n"+ + " ROOT\n"+ + "\n"+ + "\n"+ + " Opening Balances\n"+ + " 00000000000000000000000000000010\n"+ + " EQUITY\n"+ + " \n"+ + " CURRENCY\n"+ + " CNY\n"+ + " \n"+ + " \n"+ + " \n"+ + " equity-type\n"+ + " opening-balance\n"+ + " \n"+ + " \n"+ + "\n"+ + "\n"+ + " Test Account\n"+ + " 00000000000000000000000000001000\n"+ + " BANK\n"+ + " \n"+ + " CURRENCY\n"+ + " USD\n"+ + " \n"+ + " 00000000000000000000000000000001\n"+ + "\n"+ + "\n"+ + " Test Account2\n"+ + " 00000000000000000000000000002000\n"+ + " CASH\n"+ + " \n"+ + " CURRENCY\n"+ + " EUR\n"+ + " \n"+ + " 00000000000000000000000000000001\n"+ + "\n"+ + "\n"+ + " \n"+ + " 2024-09-01 01:23:45 +0000\n"+ + " \n"+ + " \n"+ + " \n"+ + " 12345/100\n"+ + " 00000000000000000000000000001000\n"+ + " \n"+ + " \n"+ + " -12345/100\n"+ + " 00000000000000000000000000000010\n"+ + " \n"+ + " \n"+ + "\n"+ + "\n"+ + " \n"+ + " 2024-09-01 12:34:56 +0000\n"+ + " \n"+ + " \n"+ + " \n"+ + " 5/100\n"+ + " 00000000000000000000000000002000\n"+ + " \n"+ + " \n"+ + " -5/100\n"+ + " 00000000000000000000000000001000\n"+ + " \n"+ + " \n"+ + "\n"+ + "\n"+ + "\n"), 0, nil, nil, 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 TestGnuCashTransactionDatabaseFileParseImportedData_ParseAmount(t *testing.T) { + converter := GnuCashTransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + gnucashCommonValidDataCaseHeader+ + "\n"+ + " \n"+ + " 2024-09-01 12:34:56 +0000\n"+ + " \n"+ + " \n"+ + " \n"+ + " 12345/1\n"+ + " 00000000000000000000000000001000\n"+ + " \n"+ + " \n"+ + " -12345/1\n"+ + " 00000000000000000000000000000010\n"+ + " \n"+ + " \n"+ + "\n"+ + gnucashCommonValidDataCaseFooter), 0, nil, nil, nil, nil, nil) + + assert.Nil(t, err) + assert.Equal(t, 1, len(allNewTransactions)) + assert.Equal(t, int64(1234500), allNewTransactions[0].Amount) + + allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + gnucashCommonValidDataCaseHeader+ + "\n"+ + " \n"+ + " 2024-09-01 12:34:56 +0000\n"+ + " \n"+ + " \n"+ + " \n"+ + " 12345/1000\n"+ + " 00000000000000000000000000001000\n"+ + " \n"+ + " \n"+ + " -12345/1000\n"+ + " 00000000000000000000000000000010\n"+ + " \n"+ + " \n"+ + "\n"+ + gnucashCommonValidDataCaseFooter), 0, nil, nil, nil, nil, nil) + + assert.Nil(t, err) + assert.Equal(t, 1, len(allNewTransactions)) + assert.Equal(t, int64(1234), allNewTransactions[0].Amount) +} + +func TestGnuCashTransactionDatabaseFileParseImportedData_ParseDescription(t *testing.T) { + converter := GnuCashTransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + gnucashCommonValidDataCaseHeader+ + "\n"+ + " \n"+ + " 2024-09-01 12:34:56 +0000\n"+ + " \n"+ + " foo bar\t#test\n"+ + " \n"+ + " \n"+ + " 12345/100\n"+ + " 00000000000000000000000000001000\n"+ + " \n"+ + " \n"+ + " -12345/100\n"+ + " 00000000000000000000000000000010\n"+ + " \n"+ + " \n"+ + "\n"+ + gnucashCommonValidDataCaseFooter), 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) +} + +func TestGnuCashTransactionDatabaseFileParseImportedData_SkipZeroAmountTransaction(t *testing.T) { + converter := GnuCashTransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + _, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + gnucashCommonValidDataCaseHeader+ + "\n"+ + " \n"+ + " 2024-09-01 12:34:56 +0000\n"+ + " \n"+ + " \n"+ + " \n"+ + " 0/100\n"+ + " 00000000000000000000000000001000\n"+ + " \n"+ + " \n"+ + "\n"+ + gnucashCommonValidDataCaseFooter), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message) +} + +func TestGnuCashTransactionDatabaseFileParseImportedData_NotSupportedToParseSplitTransaction(t *testing.T) { + converter := GnuCashTransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + _, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + gnucashCommonValidDataCaseHeader+ + "\n"+ + " Test Category2\n"+ + " 00000000000000000000000000000200\n"+ + " EXPENSE\n"+ + " 00000000000000000000000000000001\n"+ + "\n"+ + "\n"+ + " Test Account2\n"+ + " 00000000000000000000000000002000\n"+ + " CASH\n"+ + " \n"+ + " CURRENCY\n"+ + " CNY\n"+ + " \n"+ + " 00000000000000000000000000000001\n"+ + "\n"+ + "\n"+ + " \n"+ + " 2024-09-01 12:34:56 +0000\n"+ + " \n"+ + " \n"+ + " \n"+ + " 100/100\n"+ + " 00000000000000000000000000000200\n"+ + " \n"+ + " \n"+ + " 200/100\n"+ + " 00000000000000000000000000002000\n"+ + " \n"+ + " \n"+ + " -300/100\n"+ + " 00000000000000000000000000001000\n"+ + " \n"+ + " \n"+ + "\n"+ + gnucashCommonValidDataCaseFooter), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrNotSupportedSplitTransactions.Message) +} + +func TestGnuCashTransactionDatabaseFileParseImportedData_MissingAccountRequiredNode(t *testing.T) { + converter := GnuCashTransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1, + DefaultCurrency: "CNY", + } + + // Missing Transaction Time Node + _, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + "\n"+ + "\n"+ + "\n"+ + "\n"+ + " Root Account\n"+ + " 00000000000000000000000000000001\n"+ + " ROOT\n"+ + "\n"+ + "\n"+ + " Opening Balances\n"+ + " 00000000000000000000000000000010\n"+ + " EQUITY\n"+ + " \n"+ + " CURRENCY\n"+ + " CNY\n"+ + " \n"+ + " \n"+ + " \n"+ + " equity-type\n"+ + " opening-balance\n"+ + " \n"+ + " \n"+ + "\n"+ + "\n"+ + " Test Account\n"+ + " 00000000000000000000000000001000\n"+ + " BANK\n"+ + " 00000000000000000000000000000001\n"+ + "\n"+ + "\n"+ + " \n"+ + " 2024-09-01 00:00:00 +0000\n"+ + " \n"+ + " \n"+ + " \n"+ + " 12345/100\n"+ + " 00000000000000000000000000001000\n"+ + " \n"+ + " \n"+ + " -12345/100\n"+ + " 00000000000000000000000000000010\n"+ + " \n"+ + " \n"+ + "\n"+ + gnucashCommonValidDataCaseFooter), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message) +} + +func TestGnuCashTransactionDatabaseFileParseImportedData_MissingTransactionRequiredNode(t *testing.T) { + converter := GnuCashTransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1, + DefaultCurrency: "CNY", + } + + // Missing Transaction Time Node + _, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + gnucashCommonValidDataCaseHeader+ + "\n"+ + " \n"+ + " \n"+ + " 12345/100\n"+ + " 00000000000000000000000000001000\n"+ + " \n"+ + " \n"+ + " -12345/100\n"+ + " 00000000000000000000000000000010\n"+ + " \n"+ + " \n"+ + "\n"+ + gnucashCommonValidDataCaseFooter), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrMissingTransactionTime.Message) + + // Missing Transaction Splits Node + _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + gnucashCommonValidDataCaseHeader+ + "\n"+ + " \n"+ + " 2024-09-01 00:00:00 +0000\n"+ + " \n"+ + "\n"+ + gnucashCommonValidDataCaseFooter), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrInvalidGnuCashFile.Message) + + // Missing Transaction Split Account Node + _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + gnucashCommonValidDataCaseHeader+ + "\n"+ + " \n"+ + " 2024-09-01 00:00:00 +0000\n"+ + " \n"+ + " \n"+ + " \n"+ + " 12345/100\n"+ + " 00000000000000000000000000001000\n"+ + " \n"+ + " \n"+ + " -12345/100\n"+ + " \n"+ + " \n"+ + "\n"+ + gnucashCommonValidDataCaseFooter), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrMissingAccountData.Message) +} + +func checkParsedMinimumValidData(t *testing.T, allNewTransactions models.ImportedTransactionSlice, allNewAccounts []*models.Account, allNewSubExpenseCategories []*models.TransactionCategory, allNewSubIncomeCategories []*models.TransactionCategory, allNewSubTransferCategories []*models.TransactionCategory, allNewTags []*models.TransactionTag) { + assert.Equal(t, 4, len(allNewTransactions)) + assert.Equal(t, 2, len(allNewAccounts)) + assert.Equal(t, 1, len(allNewSubExpenseCategories)) + assert.Equal(t, 1, len(allNewSubIncomeCategories)) + assert.Equal(t, 1, len(allNewSubTransferCategories)) + 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, "", allNewTransactions[3].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), allNewSubExpenseCategories[0].Uid) + assert.Equal(t, "Test Category2", allNewSubExpenseCategories[0].Name) + + assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid) + assert.Equal(t, "Test Category", allNewSubIncomeCategories[0].Name) + + assert.Equal(t, int64(1234567890), allNewSubTransferCategories[0].Uid) + assert.Equal(t, "", allNewSubTransferCategories[0].Name) +} diff --git a/pkg/converters/gnucash/gnucash_transaction_table.go b/pkg/converters/gnucash/gnucash_transaction_table.go new file mode 100644 index 00000000..b8aadada --- /dev/null +++ b/pkg/converters/gnucash/gnucash_transaction_table.go @@ -0,0 +1,364 @@ +package gnucash + +import ( + "strings" + + "github.com/mayswind/ezbookkeeping/pkg/converters/datatable" + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/log" + "github.com/mayswind/ezbookkeeping/pkg/models" + "github.com/mayswind/ezbookkeeping/pkg/utils" +) + +var gnucashTransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{ + datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true, + datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE: true, + datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true, + datatable.TRANSACTION_DATA_TABLE_CATEGORY: true, + datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: true, + datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: true, + datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY: true, + datatable.TRANSACTION_DATA_TABLE_AMOUNT: true, + datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: true, + datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY: true, + datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT: true, + datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: true, +} + +// gnucashTransactionDataTable defines the structure of gnucash transaction data table +type gnucashTransactionDataTable struct { + allData []*gnucashTransactionData + accountMap map[string]*gnucashAccountData +} + +// gnucashTransactionDataRow defines the structure of gnucash transaction data row +type gnucashTransactionDataRow struct { + dataTable *gnucashTransactionDataTable + data *gnucashTransactionData + finalItems map[datatable.TransactionDataTableColumn]string + isValid bool +} + +// gnucashTransactionDataRowIterator defines the structure of gnucash transaction data row iterator +type gnucashTransactionDataRowIterator struct { + dataTable *gnucashTransactionDataTable + currentIndex int +} + +// HasColumn returns whether the transaction data table has specified column +func (t *gnucashTransactionDataTable) HasColumn(column datatable.TransactionDataTableColumn) bool { + _, exists := gnucashTransactionSupportedColumns[column] + return exists +} + +// TransactionRowCount returns the total count of transaction data row +func (t *gnucashTransactionDataTable) TransactionRowCount() int { + return len(t.allData) +} + +// TransactionRowIterator returns the iterator of transaction data row +func (t *gnucashTransactionDataTable) TransactionRowIterator() datatable.TransactionDataRowIterator { + return &gnucashTransactionDataRowIterator{ + dataTable: t, + currentIndex: -1, + } +} + +// IsValid returns whether this row is valid data for importing +func (r *gnucashTransactionDataRow) IsValid() bool { + return r.isValid +} + +// GetData returns the data in the specified column type +func (r *gnucashTransactionDataRow) GetData(column datatable.TransactionDataTableColumn) string { + _, exists := gnucashTransactionSupportedColumns[column] + + if exists { + return r.finalItems[column] + } + + return "" +} + +// HasNext returns whether the iterator does not reach the end +func (t *gnucashTransactionDataRowIterator) HasNext() bool { + return t.currentIndex+1 < len(t.dataTable.allData) +} + +// Next returns the next imported data row +func (t *gnucashTransactionDataRowIterator) Next(ctx core.Context, user *models.User) (daraRow datatable.TransactionDataRow, err error) { + if t.currentIndex+1 >= len(t.dataTable.allData) { + return nil, nil + } + + t.currentIndex++ + + data := t.dataTable.allData[t.currentIndex] + rowItems, isValid, err := t.parseTransaction(ctx, user, data) + + if err != nil { + return nil, err + } + + return &gnucashTransactionDataRow{ + dataTable: t.dataTable, + data: data, + finalItems: rowItems, + isValid: isValid, + }, nil +} + +func (t *gnucashTransactionDataRowIterator) parseTransaction(ctx core.Context, user *models.User, gnucashTransaction *gnucashTransactionData) (map[datatable.TransactionDataTableColumn]string, bool, error) { + data := make(map[datatable.TransactionDataTableColumn]string, len(gnucashTransactionSupportedColumns)) + + if gnucashTransaction.PostedDate == "" { + return nil, false, errs.ErrMissingTransactionTime + } + + dateTime, err := utils.ParseFromLongDateTimeWithTimezone2(gnucashTransaction.PostedDate) + + if err != nil { + return nil, false, errs.ErrTransactionTimeInvalid + } + + data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = utils.FormatUnixTimeToLongDateTime(dateTime.Unix(), dateTime.Location()) + data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(dateTime.Location()) + + if len(gnucashTransaction.Splits) == 2 { + splitData1 := gnucashTransaction.Splits[0] + splitData2 := gnucashTransaction.Splits[1] + + account1 := t.dataTable.accountMap[splitData1.Account] + account2 := t.dataTable.accountMap[splitData2.Account] + + if account1 == nil || account2 == nil { + return nil, false, errs.ErrMissingAccountData + } + + amount1, err := t.parseAmount(splitData1.Quantity) + + if err != nil { + return nil, false, err + } + + amount2, err := t.parseAmount(splitData2.Quantity) + + if err != nil { + return nil, false, err + } + + if ((account1.AccountType == gnucashEquityAccountType || account1.AccountType == gnucashIncomeAccountType) && gnucashAssetOrLiabilityAccountTypes[account2.AccountType]) || + ((account2.AccountType == gnucashEquityAccountType || account2.AccountType == gnucashIncomeAccountType) && gnucashAssetOrLiabilityAccountTypes[account1.AccountType]) { // income + fromAccount := account1 + toAccount := account2 + toAmount := amount2 + + if (account2.AccountType == gnucashEquityAccountType || account2.AccountType == gnucashIncomeAccountType) && gnucashAssetOrLiabilityAccountTypes[account1.AccountType] { + fromAccount = account2 + toAccount = account1 + toAmount = amount1 + } + + if t.hasSpecifiedSlotKeyValue(fromAccount.Slots, gnucashSlotEquityType, gnucashSlotEquityTypeOpeningBalance) { + data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_MODIFY_BALANCE)) + } else { + data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_INCOME)) + } + + data[datatable.TRANSACTION_DATA_TABLE_CATEGORY] = t.getCategoryName(fromAccount) + data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = fromAccount.Name + data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = toAccount.Name + + if toAccount.Commodity != nil && toAccount.Commodity.Space == gnucashCommodityCurrencySpace { + data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = toAccount.Commodity.Id + } + + data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = toAmount + } else if (account1.AccountType == gnucashExpenseAccountType && gnucashAssetOrLiabilityAccountTypes[account2.AccountType]) || + (account2.AccountType == gnucashExpenseAccountType && gnucashAssetOrLiabilityAccountTypes[account1.AccountType]) { // expense + fromAccount := account1 + fromAmount := amount1 + toAccount := account2 + + if account1.AccountType == gnucashExpenseAccountType && gnucashAssetOrLiabilityAccountTypes[account2.AccountType] { + fromAccount = account2 + fromAmount = amount2 + toAccount = account1 + } + + if len(fromAmount) > 0 && fromAmount[0] == '-' { + amount, err := utils.ParseAmount(fromAmount) + + if err != nil { + return nil, false, errs.ErrAmountInvalid + } + + fromAmount = utils.FormatAmount(-amount) + } + + data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_EXPENSE)) + data[datatable.TRANSACTION_DATA_TABLE_CATEGORY] = t.getCategoryName(toAccount) + data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = toAccount.Name + data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = fromAccount.Name + + if fromAccount.Commodity != nil && fromAccount.Commodity.Space == gnucashCommodityCurrencySpace { + data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = fromAccount.Commodity.Id + } + + data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = fromAmount + } else if gnucashAssetOrLiabilityAccountTypes[account1.AccountType] && gnucashAssetOrLiabilityAccountTypes[account2.AccountType] { + var fromAccount, toAccount *gnucashAccountData + var fromAmount, toAmount string + + if len(amount1) > 0 && amount1[0] == '-' { + fromAccount = account1 + fromAmount = amount1[1:] + toAccount = account2 + toAmount = amount2 + } else if len(amount2) > 0 && amount2[0] == '-' { + fromAccount = account2 + fromAmount = amount2[1:] + toAccount = account1 + toAmount = amount1 + } else { + log.Errorf(ctx, "[gnucash_transaction_table.parseTransaction] cannot parse transfer transaction \"id:%s\", because unexcepted account amounts \"%s\" and \"%s\"", gnucashTransaction.Id, amount1, amount2) + return nil, false, errs.ErrInvalidGnuCashFile + } + + data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_TRANSFER)) + data[datatable.TRANSACTION_DATA_TABLE_CATEGORY] = "" + data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = "" + data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = fromAccount.Name + + if fromAccount.Commodity != nil && fromAccount.Commodity.Space == gnucashCommodityCurrencySpace { + data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = fromAccount.Commodity.Id + } + + data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = fromAmount + data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = toAccount.Name + + if toAccount.Commodity != nil && toAccount.Commodity.Space == gnucashCommodityCurrencySpace { + data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = toAccount.Commodity.Id + } + + data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = toAmount + } else { + log.Errorf(ctx, "[gnucash_transaction_table.parseTransaction] cannot parse transaction \"id:%s\", because unexcepted account types \"%s\" and \"%s\"", gnucashTransaction.Id, account1.AccountType, account2.AccountType) + return nil, false, errs.ErrThereAreNotSupportedTransactionType + } + } else if len(gnucashTransaction.Splits) == 1 { + splitData := gnucashTransaction.Splits[0] + account := t.dataTable.accountMap[splitData.Account] + + if account == nil { + return nil, false, errs.ErrMissingAccountData + } + + amount, err := t.parseAmount(splitData.Quantity) + + if err != nil { + return nil, false, err + } + + amountNum, err := utils.ParseAmount(amount) + + if err != nil { + return nil, false, err + } + + if amountNum == 0 { + log.Warnf(ctx, "[gnucash_transaction_table.parseTransaction] skip parsing transaction \"id:%s\" with zero amount", gnucashTransaction.Id) + return nil, false, nil + } + + log.Errorf(ctx, "[gnucash_transaction_table.parseTransaction] cannot parse transaction \"id:%s\", because split count is %d", gnucashTransaction.Id, len(gnucashTransaction.Splits)) + return nil, false, errs.ErrThereAreNotSupportedTransactionType + } else if len(gnucashTransaction.Splits) < 1 { + log.Errorf(ctx, "[gnucash_transaction_table.parseTransaction] cannot parse transaction \"id:%s\", because split count is %d", gnucashTransaction.Id, len(gnucashTransaction.Splits)) + return nil, false, errs.ErrInvalidGnuCashFile + } else { + log.Errorf(ctx, "[gnucash_transaction_table.parseTransaction] cannot parse split transaction \"id:%s\", because split count is %d", gnucashTransaction.Id, len(gnucashTransaction.Splits)) + return nil, false, errs.ErrNotSupportedSplitTransactions + } + + data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = gnucashTransaction.Description + + return data, true, nil +} + +func (t *gnucashTransactionDataRowIterator) parseAmount(quantity string) (string, error) { + items := strings.Split(quantity, "/") + + if len(items) != 2 { + return "", errs.ErrAmountInvalid + } + + value, err := utils.StringToInt64(items[0]) + + if err != nil { + return "", errs.ErrAmountInvalid + } + + if items[1] == "100" { + return utils.FormatAmount(value), nil + } + + factor, err := utils.StringToInt64(items[1]) + + if err != nil { + return "", errs.ErrAmountInvalid + } + + value = value * 100 / factor + + return utils.FormatAmount(value), nil +} + +func (t *gnucashTransactionDataRowIterator) getCategoryName(accountData *gnucashAccountData) string { + if accountData == nil || accountData.ParentId == "" { + return "" + } + + parentAccount := t.dataTable.accountMap[accountData.ParentId] + + if parentAccount == nil || parentAccount.AccountType == gnucashRootAccountType { + return "" + } + + return parentAccount.Name +} + +func (t *gnucashTransactionDataRowIterator) hasSpecifiedSlotKeyValue(slots []*gnucashSlotData, key string, value string) bool { + for i := 0; i < len(slots); i++ { + if slots[i].Key == key && slots[i].Value == value { + return true + } + } + + return false +} + +func createNewGnuCashTransactionDataTable(database *gnucashDatabase) (*gnucashTransactionDataTable, error) { + if database == nil || len(database.Books) < 1 { + return nil, errs.ErrNotFoundTransactionDataInFile + } + + allData := make([]*gnucashTransactionData, 0) + accountMap := make(map[string]*gnucashAccountData) + + for i := 0; i < len(database.Books); i++ { + book := database.Books[i] + allData = append(allData, book.Transactions...) + + for j := 0; j < len(book.Accounts); j++ { + account := book.Accounts[j] + accountMap[account.Id] = account + } + } + + return &gnucashTransactionDataTable{ + allData: allData, + accountMap: accountMap, + }, nil +} diff --git a/pkg/converters/transaction_data_converters.go b/pkg/converters/transaction_data_converters.go index db861369..2cb73b9a 100644 --- a/pkg/converters/transaction_data_converters.go +++ b/pkg/converters/transaction_data_converters.go @@ -6,6 +6,7 @@ import ( "github.com/mayswind/ezbookkeeping/pkg/converters/default" "github.com/mayswind/ezbookkeeping/pkg/converters/feidee" "github.com/mayswind/ezbookkeeping/pkg/converters/fireflyIII" + "github.com/mayswind/ezbookkeeping/pkg/converters/gnucash" "github.com/mayswind/ezbookkeeping/pkg/converters/qif" "github.com/mayswind/ezbookkeeping/pkg/converters/wechat" "github.com/mayswind/ezbookkeeping/pkg/errs" @@ -34,6 +35,8 @@ func GetTransactionDataImporter(fileType string) (base.TransactionDataImporter, return qif.QifMonthDayYearTransactionDataImporter, nil } else if fileType == "qif_dmy" { return qif.QifDayMonthYearTransactionDataImporter, nil + } else if fileType == "gnucash" { + return gnucash.GnuCashTransactionDataImporter, nil } else if fileType == "firefly_iii_csv" { return fireflyIII.FireflyIIITransactionDataCsvFileImporter, nil } else if fileType == "feidee_mymoney_csv" { diff --git a/pkg/errs/converter.go b/pkg/errs/converter.go index 4b68f246..a44cee21 100644 --- a/pkg/errs/converter.go +++ b/pkg/errs/converter.go @@ -18,4 +18,8 @@ var ( ErrFoundRecordNotHasRelatedRecord = NewNormalError(NormalSubcategoryConverter, 11, http.StatusBadRequest, "found some transactions without related records") ErrInvalidQIFFile = NewNormalError(NormalSubcategoryConverter, 12, http.StatusBadRequest, "invalid qif file") ErrMissingTransactionTime = NewNormalError(NormalSubcategoryConverter, 13, http.StatusBadRequest, "missing transaction time field") + ErrInvalidGnuCashFile = NewNormalError(NormalSubcategoryConverter, 14, http.StatusBadRequest, "invalid gnucash file") + ErrMissingAccountData = NewNormalError(NormalSubcategoryConverter, 15, http.StatusBadRequest, "missing account data") + ErrNotSupportedSplitTransactions = NewNormalError(NormalSubcategoryConverter, 16, http.StatusBadRequest, "not supported to import split transaction") + ErrThereAreNotSupportedTransactionType = NewNormalError(NormalSubcategoryConverter, 17, http.StatusBadRequest, "there are not supported transaction type") ) diff --git a/pkg/utils/datetimes.go b/pkg/utils/datetimes.go index 5a8769a6..beff3ab8 100644 --- a/pkg/utils/datetimes.go +++ b/pkg/utils/datetimes.go @@ -11,6 +11,7 @@ import ( const ( longDateTimeFormat = "2006-01-02 15:04:05" longDateTimeWithTimezoneFormat = "2006-01-02 15:04:05Z07:00" + longDateTimeWithTimezoneFormat2 = "2006-01-02 15:04:05 Z0700" longDateTimeWithoutSecondFormat = "2006-01-02 15:04" shortDateTimeFormat = "2006-1-2 15:4:5" yearMonthDateTimeFormat = "2006-01" @@ -141,6 +142,11 @@ func ParseFromLongDateTimeWithTimezone(t string) (time.Time, error) { return time.Parse(longDateTimeWithTimezoneFormat, t) } +// ParseFromLongDateTimeWithTimezone2 parses a formatted string in long date time format +func ParseFromLongDateTimeWithTimezone2(t string) (time.Time, error) { + return time.Parse(longDateTimeWithTimezoneFormat2, t) +} + // ParseFromLongDateTimeWithoutSecond parses a formatted string in long date time format (no second) func ParseFromLongDateTimeWithoutSecond(t string, utcOffset int16) (time.Time, error) { timezone := time.FixedZone("Timezone", int(utcOffset)*60) diff --git a/pkg/utils/datetimes_test.go b/pkg/utils/datetimes_test.go index d923801c..dada67ef 100644 --- a/pkg/utils/datetimes_test.go +++ b/pkg/utils/datetimes_test.go @@ -140,6 +140,15 @@ func TestParseFromLongDateTimeWithTimezone(t *testing.T) { assert.Equal(t, expectedValue, actualValue) } +func TestParseFromLongDateTimeWithTimezone2(t *testing.T) { + expectedValue := int64(1617238883) + actualTime, err := ParseFromLongDateTimeWithTimezone2("2021-04-01 06:01:23 +0500") + assert.Equal(t, nil, err) + + actualValue := actualTime.Unix() + assert.Equal(t, expectedValue, actualValue) +} + func TestParseFromLongDateTimeWithoutSecond(t *testing.T) { expectedValue := int64(1691947440) actualTime, err := ParseFromLongDateTimeWithoutSecond("2023-08-13 17:24", 0) diff --git a/src/consts/file.js b/src/consts/file.js index 4f11bb66..c374277f 100644 --- a/src/consts/file.js +++ b/src/consts/file.js @@ -38,6 +38,15 @@ const supportedImportFileTypes = [ } ] }, + { + type: 'gnucash', + name: 'GnuCash XML Database File', + extensions: '.gnucash', + document: { + supportMultiLanguages: true, + anchor: 'how-to-get-gnucash-data-export-file' + } + }, { type: 'firefly_iii_csv', name: 'Firefly III Data Export File', diff --git a/src/locales/en.json b/src/locales/en.json index 1433cb94..52fe6ff0 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1128,6 +1128,10 @@ "found some transactions without related records": "There are some transactions which don't have related records", "invalid qif file": "Invalid QIF file", "missing transaction time field": "Missing transaction time field", + "invalid gnucash file": "Invalid GnuCash file", + "missing account data": "Missing account data", + "not supported to import split transaction": "Not supported to import split transaction", + "there are not supported transaction type": "There are not supported transaction type in import file", "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", @@ -1523,6 +1527,7 @@ "Year-month-day format": "Year-month-day format", "Month-day-year format": "Month-day-year format", "Day-month-year format": "Day-month-year format", + "GnuCash XML Database File": "GnuCash XML Database File", "Firefly III Data Export File": "Firefly III 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", diff --git a/src/locales/zh_Hans.json b/src/locales/zh_Hans.json index 7e7aa305..6b08536d 100644 --- a/src/locales/zh_Hans.json +++ b/src/locales/zh_Hans.json @@ -1128,6 +1128,10 @@ "found some transactions without related records": "有一些交易没有关联记录", "invalid qif file": "无效的 QIF 文件", "missing transaction time field": "缺少交易时间字段", + "invalid gnucash file": "无效的 GnuCash 文件", + "missing account data": "缺少账户数据", + "not supported to import split transaction": "不支持导入拆分的交易", + "there are not supported transaction type": "导入文件中有不支持的交易类型", "query items cannot be blank": "请求项目不能为空", "query items too much": "请求项目过多", "query items have invalid item": "请求项目中有非法项目", @@ -1523,6 +1527,7 @@ "Year-month-day format": "年-月-日 格式", "Month-day-year format": "月-日-年 格式", "Day-month-year format": "日-月-年 格式", + "GnuCash XML Database File": "GnuCash XML 数据库文件", "Firefly III Data Export File": "Firefly III 数据导出文件", "Feidee MyMoney (App) Data Export File": "金蝶随手记 (App) 数据导出文件", "Feidee MyMoney (Web) Data Export File": "金蝶随手记 (Web版) 数据导出文件",