package iif import ( "fmt" "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" ) const iifAccountNameColumnName = "NAME" const iifAccountTypeColumnName = "ACCNTTYPE" const iifAccountTypeIncome = "INC" const iifAccountTypeExpense = "EXP" const iifTransactionTypeColumnName = "TRNSTYPE" const iifTransactionDateColumnName = "DATE" const iifTransactionAccountNameColumnName = "ACCNT" const iifTransactionNameColumnName = "NAME" const iifTransactionAmountColumnName = "AMOUNT" const iifTransactionMemoColumnName = "MEMO" const iifTransactionTypeBeginningBalance = "BEGINBALCHECK" const iifTransactionCategorySeparator = ":" var iifTransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{ datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: 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_AMOUNT: true, datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: true, datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT: true, datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: true, } // iifTransactionDataTable defines the structure of intuit interchange format (iif) transaction data table type iifTransactionDataTable struct { incomeAccountNames map[string]bool expenseAccountNames map[string]bool transactionDatasets []*iifTransactionDataset } // iifTransactionDataRow defines the structure of intuit interchange format (iif) transaction data row type iifTransactionDataRow struct { dataTable *iifTransactionDataTable finalItems map[datatable.TransactionDataTableColumn]string } // iifTransactionDataRowIterator defines the structure of intuit interchange format (iif) transaction data row iterator type iifTransactionDataRowIterator struct { dataTable *iifTransactionDataTable currentDatasetIndex int currentIndexInDataset int } // HasColumn returns whether the transaction data table has specified column func (t *iifTransactionDataTable) HasColumn(column datatable.TransactionDataTableColumn) bool { _, exists := iifTransactionSupportedColumns[column] return exists } // TransactionRowCount returns the total count of transaction data row func (t *iifTransactionDataTable) TransactionRowCount() int { totalDataRowCount := 0 for i := 0; i < len(t.transactionDatasets); i++ { transactions := t.transactionDatasets[i] totalDataRowCount += len(transactions.transactions) } return totalDataRowCount } // TransactionRowIterator returns the iterator of transaction data row func (t *iifTransactionDataTable) TransactionRowIterator() datatable.TransactionDataRowIterator { return &iifTransactionDataRowIterator{ dataTable: t, currentDatasetIndex: 0, currentIndexInDataset: -1, } } // IsValid returns whether this row is valid data for importing func (r *iifTransactionDataRow) IsValid() bool { return true } // GetData returns the data in the specified column type func (r *iifTransactionDataRow) GetData(column datatable.TransactionDataTableColumn) string { _, exists := iifTransactionSupportedColumns[column] if exists { return r.finalItems[column] } return "" } // HasNext returns whether the iterator does not reach the end func (t *iifTransactionDataRowIterator) HasNext() bool { allDatasets := t.dataTable.transactionDatasets if t.currentDatasetIndex >= len(allDatasets) { return false } currentDataset := allDatasets[t.currentDatasetIndex] if t.currentIndexInDataset+1 < len(currentDataset.transactions) { return true } for i := t.currentDatasetIndex + 1; i < len(allDatasets); i++ { dataset := allDatasets[i] if len(dataset.transactions) < 1 { continue } return true } return false } // Next returns the next imported data row func (t *iifTransactionDataRowIterator) Next(ctx core.Context, user *models.User) (daraRow datatable.TransactionDataRow, err error) { allDatasets := t.dataTable.transactionDatasets currentIndexInDataset := t.currentIndexInDataset for i := t.currentDatasetIndex; i < len(allDatasets); i++ { dataset := allDatasets[i] if currentIndexInDataset+1 < len(dataset.transactions) { t.currentIndexInDataset++ currentIndexInDataset = t.currentIndexInDataset break } t.currentDatasetIndex++ t.currentIndexInDataset = -1 currentIndexInDataset = -1 } if t.currentDatasetIndex >= len(allDatasets) { return nil, nil } currentDataset := allDatasets[t.currentDatasetIndex] if t.currentIndexInDataset >= len(currentDataset.transactions) { return nil, nil } data := currentDataset.transactions[t.currentIndexInDataset] rowItems, err := t.parseTransaction(ctx, user, currentDataset, data) if err != nil { return nil, err } return &iifTransactionDataRow{ dataTable: t.dataTable, finalItems: rowItems, }, nil } func (t *iifTransactionDataRowIterator) parseTransaction(ctx core.Context, user *models.User, dataset *iifTransactionDataset, transactionData *iifTransactionData) (map[datatable.TransactionDataTableColumn]string, error) { if len(transactionData.splitData) < 1 { return nil, errs.ErrInvalidIIFFile } else if len(transactionData.splitData) > 1 { return nil, errs.ErrNotSupportedSplitTransactions } var err error data := make(map[datatable.TransactionDataTableColumn]string, len(iifTransactionSupportedColumns)) data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME], err = t.parseTransactionTime(dataset, transactionData) if err != nil { return nil, err } transactionType, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionTypeColumnName) accountName1, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionAccountNameColumnName) accountName2, _ := dataset.getSplitDataItemValue(transactionData.splitData[0], iifTransactionAccountNameColumnName) amount1, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionAmountColumnName) amount2, _ := dataset.getSplitDataItemValue(transactionData.splitData[0], iifTransactionAmountColumnName) amountNum1, err := utils.ParseAmount(strings.ReplaceAll(amount1, ",", "")) if err != nil { return nil, errs.ErrAmountInvalid } amountNum2, err := utils.ParseAmount(strings.ReplaceAll(amount2, ",", "")) if err != nil { return nil, errs.ErrAmountInvalid } if transactionType == iifTransactionTypeBeginningBalance { // balance modification data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = iifTransactionTypeNameMapping[models.TRANSACTION_TYPE_MODIFY_BALANCE] data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = accountName1 data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amountNum1) } else if t.dataTable.incomeAccountNames[accountName1] || t.dataTable.incomeAccountNames[accountName2] { // income data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = iifTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] categoryName := "" accountName := "" amountNum := int64(0) if t.dataTable.incomeAccountNames[accountName1] && !t.dataTable.incomeAccountNames[accountName2] { categoryName = accountName1 accountName = accountName2 amountNum = amountNum2 } else if t.dataTable.incomeAccountNames[accountName2] && !t.dataTable.incomeAccountNames[accountName1] { categoryName = accountName2 accountName = accountName1 amountNum = amountNum1 } else { log.Errorf(ctx, "[iif_transaction_data_table.parseTransaction] cannot parse transaction, because two accounts \"%s\" and \"%s\" are all income account", accountName1, accountName2) return nil, errs.ErrInvalidIIFFile } categoryNames := strings.Split(categoryName, iifTransactionCategorySeparator) if len(categoryNames) > 1 { data[datatable.TRANSACTION_DATA_TABLE_CATEGORY] = categoryNames[0] data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = categoryNames[len(categoryNames)-1] } else { data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = categoryName } data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = accountName data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amountNum) } else if t.dataTable.expenseAccountNames[accountName1] || t.dataTable.expenseAccountNames[accountName2] { // expense data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = iifTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] categoryName := "" accountName := "" amountNum := int64(0) if t.dataTable.expenseAccountNames[accountName1] && !t.dataTable.expenseAccountNames[accountName2] { categoryName = accountName1 accountName = accountName2 amountNum = amountNum2 } else if t.dataTable.expenseAccountNames[accountName2] && !t.dataTable.expenseAccountNames[accountName1] { categoryName = accountName2 accountName = accountName1 amountNum = amountNum1 } else { log.Errorf(ctx, "[iif_transaction_data_table.parseTransaction] cannot parse transaction, because two accounts \"%s\" and \"%s\" are all expense account", accountName1, accountName2) return nil, errs.ErrInvalidIIFFile } categoryNames := strings.Split(categoryName, iifTransactionCategorySeparator) if len(categoryNames) > 1 { data[datatable.TRANSACTION_DATA_TABLE_CATEGORY] = categoryNames[0] data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = categoryNames[len(categoryNames)-1] } else { data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = categoryName } data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = accountName data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amountNum) } else { data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = iifTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER] data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = "" if amountNum1 >= 0 { data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = accountName2 data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amountNum2) data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = accountName1 data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(amountNum1) } else if amountNum2 >= 0 { data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = accountName1 data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amountNum1) data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = accountName2 data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(amountNum2) } } if memo, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionMemoColumnName); memo != "" { data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = memo } else if name, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionNameColumnName); name != "" { data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = name } else { data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = "" } return data, nil } func (t *iifTransactionDataRowIterator) parseTransactionTime(dataset *iifTransactionDataset, transactionData *iifTransactionData) (string, error) { date, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionDateColumnName) dateParts := strings.Split(date, "/") if len(dateParts) != 3 { return "", errs.ErrTransactionTimeInvalid } month := dateParts[0] day := dateParts[1] year := dateParts[2] if utils.IsValidYearMonthDayLongOrShortDateFormat(date) { year = dateParts[0] month = dateParts[1] day = dateParts[2] } if len(month) < 2 { month = "0" + month } if len(day) < 2 { day = "0" + day } return fmt.Sprintf("%s-%s-%s 00:00:00", year, month, day), nil } func createNewIIfTransactionDataTable(ctx core.Context, accountDatasets []*iifAccountDataset, transactionDatasets []*iifTransactionDataset) (*iifTransactionDataTable, error) { if len(transactionDatasets) < 1 { return nil, errs.ErrNotFoundTransactionDataInFile } incomeAccountNames, expenseAccountNames := getIncomeAndExpenseAccountNameMap(accountDatasets) for i := 0; i < len(transactionDatasets); i++ { transactionDataset := transactionDatasets[i] for _, requiredColumnName := range []string{ iifTransactionDateColumnName, iifTransactionAccountNameColumnName, iifTransactionAmountColumnName, } { if _, exists := transactionDataset.transactionDataColumnIndexes[requiredColumnName]; !exists { return nil, errs.ErrMissingRequiredFieldInHeaderRow } } } return &iifTransactionDataTable{ incomeAccountNames: incomeAccountNames, expenseAccountNames: expenseAccountNames, transactionDatasets: transactionDatasets, }, nil } func getIncomeAndExpenseAccountNameMap(accountDatasets []*iifAccountDataset) (incomeAccountNames map[string]bool, expenseAccountNames map[string]bool) { incomeAccountNames = make(map[string]bool) expenseAccountNames = make(map[string]bool) for i := 0; i < len(accountDatasets); i++ { accountDataset := accountDatasets[i] accountNameColumnIndex, accountNameColumnExists := accountDataset.accountDataColumnIndexes[iifAccountNameColumnName] accountTypeColumnIndex, accountTypeColumnExists := accountDataset.accountDataColumnIndexes[iifAccountTypeColumnName] if !accountNameColumnExists || accountNameColumnIndex < 0 || !accountTypeColumnExists || accountTypeColumnIndex < 0 { continue } for j := 0; j < len(accountDataset.accounts); j++ { items := accountDataset.accounts[j].dataItems if accountNameColumnIndex >= len(items) || accountTypeColumnIndex >= len(items) { continue } accountName := items[accountNameColumnIndex] accountType := items[accountTypeColumnIndex] if accountType == iifAccountTypeIncome { incomeAccountNames[accountName] = true } else if accountType == iifAccountTypeExpense { expenseAccountNames[accountName] = true } } } return incomeAccountNames, expenseAccountNames }