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版) 数据导出文件",