372 lines
13 KiB
Go
372 lines
13 KiB
Go
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 {
|
|
log.Errorf(ctx, "[gnucash_transaction_table.Next] cannot parsing transaction in row#%d, because %s", t.currentIndex, err.Error())
|
|
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
|
|
}
|
|
|
|
if splitData1.Quantity == "" || splitData2.Quantity == "" {
|
|
return nil, false, errs.ErrAmountInvalid
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
if splitData.Quantity == "" {
|
|
return nil, false, errs.ErrAmountInvalid
|
|
}
|
|
|
|
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
|
|
}
|