346 lines
13 KiB
Go
346 lines
13 KiB
Go
package ofx
|
|
|
|
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"
|
|
)
|
|
|
|
var ofxTransactionSupportedColumns = 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_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,
|
|
}
|
|
|
|
// ofxTransactionData defines the structure of open financial exchange (ofx) transaction data
|
|
type ofxTransactionData struct {
|
|
ofxBaseStatementTransaction
|
|
DefaultCurrency string
|
|
FromAccountId string
|
|
FromCreditAccount bool
|
|
ToAccountId string
|
|
}
|
|
|
|
// ofxTransactionDataTable defines the structure of open financial exchange (ofx) transaction data table
|
|
type ofxTransactionDataTable struct {
|
|
allData []*ofxTransactionData
|
|
}
|
|
|
|
// ofxTransactionDataRow defines the structure of open financial exchange (ofx) transaction data row
|
|
type ofxTransactionDataRow struct {
|
|
dataTable *ofxTransactionDataTable
|
|
data *ofxTransactionData
|
|
finalItems map[datatable.TransactionDataTableColumn]string
|
|
}
|
|
|
|
// ofxTransactionDataRowIterator defines the structure of open financial exchange (ofx) transaction data row iterator
|
|
type ofxTransactionDataRowIterator struct {
|
|
dataTable *ofxTransactionDataTable
|
|
currentIndex int
|
|
}
|
|
|
|
// HasColumn returns whether the transaction data table has specified column
|
|
func (t *ofxTransactionDataTable) HasColumn(column datatable.TransactionDataTableColumn) bool {
|
|
_, exists := ofxTransactionSupportedColumns[column]
|
|
return exists
|
|
}
|
|
|
|
// TransactionRowCount returns the total count of transaction data row
|
|
func (t *ofxTransactionDataTable) TransactionRowCount() int {
|
|
return len(t.allData)
|
|
}
|
|
|
|
// TransactionRowIterator returns the iterator of transaction data row
|
|
func (t *ofxTransactionDataTable) TransactionRowIterator() datatable.TransactionDataRowIterator {
|
|
return &ofxTransactionDataRowIterator{
|
|
dataTable: t,
|
|
currentIndex: -1,
|
|
}
|
|
}
|
|
|
|
// IsValid returns whether this row is valid data for importing
|
|
func (r *ofxTransactionDataRow) IsValid() bool {
|
|
return true
|
|
}
|
|
|
|
// GetData returns the data in the specified column type
|
|
func (r *ofxTransactionDataRow) GetData(column datatable.TransactionDataTableColumn) string {
|
|
_, exists := ofxTransactionSupportedColumns[column]
|
|
|
|
if exists {
|
|
return r.finalItems[column]
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// HasNext returns whether the iterator does not reach the end
|
|
func (t *ofxTransactionDataRowIterator) HasNext() bool {
|
|
return t.currentIndex+1 < len(t.dataTable.allData)
|
|
}
|
|
|
|
// Next returns the next imported data row
|
|
func (t *ofxTransactionDataRowIterator) 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, err := t.parseTransaction(ctx, user, data)
|
|
|
|
if err != nil {
|
|
log.Errorf(ctx, "[ofx_transaction_table.Next] cannot parsing transaction in row#%d, because %s", t.currentIndex, err.Error())
|
|
return nil, err
|
|
}
|
|
|
|
return &ofxTransactionDataRow{
|
|
dataTable: t.dataTable,
|
|
data: data,
|
|
finalItems: rowItems,
|
|
}, nil
|
|
}
|
|
|
|
func (t *ofxTransactionDataRowIterator) parseTransaction(ctx core.Context, user *models.User, ofxTransaction *ofxTransactionData) (map[datatable.TransactionDataTableColumn]string, error) {
|
|
data := make(map[datatable.TransactionDataTableColumn]string, len(ofxTransactionSupportedColumns))
|
|
|
|
if ofxTransaction.PostedDate == "" {
|
|
return nil, errs.ErrMissingTransactionTime
|
|
}
|
|
|
|
datetime, timezone, err := t.parseTransactionTimeAndTimeZone(ctx, ofxTransaction.PostedDate)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = datetime
|
|
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = timezone
|
|
|
|
if ofxTransaction.TransactionType == "" {
|
|
return nil, errs.ErrTransactionTypeInvalid
|
|
}
|
|
|
|
if ofxTransaction.FromAccountId == "" {
|
|
return nil, errs.ErrMissingAccountData
|
|
}
|
|
|
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = ofxTransaction.FromAccountId
|
|
|
|
if ofxTransaction.Currency != "" {
|
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = ofxTransaction.Currency
|
|
} else {
|
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = ofxTransaction.DefaultCurrency
|
|
}
|
|
|
|
if ofxTransaction.Amount == "" {
|
|
return nil, errs.ErrAmountInvalid
|
|
}
|
|
|
|
amount, err := utils.ParseAmount(utils.TrimTrailingZerosInDecimal(strings.ReplaceAll(ofxTransaction.Amount, ",", "."))) // ofx supports decimal point or comma to indicate the start of the fractional amount
|
|
|
|
if err != nil {
|
|
log.Errorf(ctx, "[ofx_transaction_table.parseTransaction] cannot parsing transaction amount \"%s\", because %s", ofxTransaction.Amount, err.Error())
|
|
return nil, errs.ErrAmountInvalid
|
|
}
|
|
|
|
if transactionType, exists := ofxTransactionTypeMapping[ofxTransaction.TransactionType]; exists { // known transaction type
|
|
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(transactionType))
|
|
|
|
if data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == ofxTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] { // income
|
|
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
|
|
} else if data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == ofxTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] { // expense
|
|
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
|
|
} else { // transfer
|
|
if amount >= 0 { // transfer in
|
|
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
|
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME]
|
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY]
|
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = data[datatable.TRANSACTION_DATA_TABLE_AMOUNT]
|
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = ""
|
|
} else { // transfer out
|
|
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
|
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ofxTransaction.ToAccountId
|
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY]
|
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = data[datatable.TRANSACTION_DATA_TABLE_AMOUNT]
|
|
}
|
|
}
|
|
} else { // transaction type depends on signage of amount
|
|
if amount >= 0 { // income
|
|
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = ofxTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
|
|
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
|
|
} else { // expense
|
|
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = ofxTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
|
|
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
|
|
}
|
|
}
|
|
|
|
if data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] != ofxTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER] {
|
|
if ofxTransaction.FromCreditAccount || ofxTransaction.TransactionType == ofxGenericCreditTransaction {
|
|
if amount >= 0 { // payment
|
|
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = ofxTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER]
|
|
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
|
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME]
|
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY]
|
|
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = data[datatable.TRANSACTION_DATA_TABLE_AMOUNT]
|
|
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = ""
|
|
} else { // purchase
|
|
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = ofxTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
|
|
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
|
|
}
|
|
}
|
|
}
|
|
|
|
if ofxTransaction.Memo != "" {
|
|
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = ofxTransaction.Memo
|
|
} else if ofxTransaction.Name != "" {
|
|
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = ofxTransaction.Name
|
|
} else if ofxTransaction.Payee != nil {
|
|
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = ofxTransaction.Payee.Name
|
|
} else {
|
|
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = ""
|
|
}
|
|
|
|
return data, nil
|
|
}
|
|
|
|
func (t *ofxTransactionDataRowIterator) parseTransactionTimeAndTimeZone(ctx core.Context, datetime string) (string, string, error) {
|
|
if len(datetime) < 8 {
|
|
return "", "", errs.ErrTransactionTimeInvalid
|
|
}
|
|
|
|
var err error
|
|
var year, month, day string
|
|
hour := "00"
|
|
minute := "00"
|
|
second := "00"
|
|
tzOffset := ofxDefaultTimezoneOffset
|
|
|
|
if len(datetime) >= 8 { // YYYYMMDD
|
|
if !utils.IsStringOnlyContainsDigits(datetime[0:8]) {
|
|
log.Errorf(ctx, "[ofx_transaction_table.parseTransactionTimeAndTimeZone] cannot parse time \"%s\", because contains non-digit character", datetime)
|
|
return "", "", errs.ErrTransactionTimeInvalid
|
|
}
|
|
|
|
year = datetime[0:4]
|
|
month = datetime[4:6]
|
|
day = datetime[6:8]
|
|
}
|
|
|
|
if len(datetime) >= 14 { // YYYYMMDDHHMMSS
|
|
if !utils.IsStringOnlyContainsDigits(datetime[8:14]) {
|
|
log.Errorf(ctx, "[ofx_transaction_table.parseTransactionTimeAndTimeZone] cannot parse time \"%s\", because contains non-digit character", datetime)
|
|
return "", "", errs.ErrTransactionTimeInvalid
|
|
}
|
|
|
|
hour = datetime[8:10]
|
|
minute = datetime[10:12]
|
|
second = datetime[12:14]
|
|
}
|
|
|
|
squareBracketStartIndex := strings.Index(datetime, "[")
|
|
|
|
if squareBracketStartIndex > 0 { // YYYYMMDDHHMMSS.XXX [gmt offset[:tz name]]
|
|
timezoneInfo := datetime[squareBracketStartIndex+1 : len(datetime)-1]
|
|
timezoneItems := strings.Split(timezoneInfo, ":")
|
|
tzOffset, err = utils.FormatTimezoneOffsetFromHoursOffset(timezoneItems[0])
|
|
|
|
if err != nil {
|
|
log.Errorf(ctx, "[ofx_transaction_table.parseTransactionTimeAndTimeZone] cannot parse timezone offset \"%s\", because %s", timezoneInfo, err.Error())
|
|
return "", "", errs.ErrTransactionTimeZoneInvalid
|
|
}
|
|
}
|
|
|
|
return fmt.Sprintf("%s-%s-%s %s:%s:%s", year, month, day, hour, minute, second), tzOffset, nil
|
|
}
|
|
|
|
func createNewOFXTransactionDataTable(file *ofxFile) (*ofxTransactionDataTable, error) {
|
|
if file == nil {
|
|
return nil, errs.ErrNotFoundTransactionDataInFile
|
|
}
|
|
|
|
allData := make([]*ofxTransactionData, 0)
|
|
|
|
if file.BankMessageResponseV1 != nil &&
|
|
file.BankMessageResponseV1.StatementTransactionResponse != nil &&
|
|
file.BankMessageResponseV1.StatementTransactionResponse.StatementResponse != nil &&
|
|
file.BankMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList != nil {
|
|
statement := file.BankMessageResponseV1.StatementTransactionResponse.StatementResponse
|
|
bankTransactions := statement.TransactionList.StatementTransactions
|
|
fromAccountId := ""
|
|
fromCreditAccount := false
|
|
|
|
if statement.AccountFrom != nil {
|
|
fromAccountId = statement.AccountFrom.AccountId
|
|
|
|
if statement.AccountFrom.AccountType == ofxLineOfCreditAccount {
|
|
fromCreditAccount = true
|
|
}
|
|
}
|
|
|
|
for i := 0; i < len(bankTransactions); i++ {
|
|
toAccountId := ""
|
|
|
|
if bankTransactions[i].AccountTo != nil {
|
|
toAccountId = bankTransactions[i].AccountTo.AccountId
|
|
}
|
|
|
|
allData = append(allData, &ofxTransactionData{
|
|
ofxBaseStatementTransaction: bankTransactions[i].ofxBaseStatementTransaction,
|
|
DefaultCurrency: statement.DefaultCurrency,
|
|
FromAccountId: fromAccountId,
|
|
FromCreditAccount: fromCreditAccount,
|
|
ToAccountId: toAccountId,
|
|
})
|
|
}
|
|
}
|
|
|
|
if file.CreditCardMessageResponseV1 != nil &&
|
|
file.CreditCardMessageResponseV1.StatementTransactionResponse != nil &&
|
|
file.CreditCardMessageResponseV1.StatementTransactionResponse.StatementResponse != nil &&
|
|
file.CreditCardMessageResponseV1.StatementTransactionResponse.StatementResponse.TransactionList != nil {
|
|
statement := file.CreditCardMessageResponseV1.StatementTransactionResponse.StatementResponse
|
|
bankTransactions := statement.TransactionList.StatementTransactions
|
|
fromAccountId := ""
|
|
|
|
if statement.AccountFrom != nil {
|
|
fromAccountId = statement.AccountFrom.AccountId
|
|
}
|
|
|
|
for i := 0; i < len(bankTransactions); i++ {
|
|
toAccountId := ""
|
|
|
|
if bankTransactions[i].AccountTo != nil {
|
|
toAccountId = bankTransactions[i].AccountTo.AccountId
|
|
}
|
|
|
|
allData = append(allData, &ofxTransactionData{
|
|
ofxBaseStatementTransaction: bankTransactions[i].ofxBaseStatementTransaction,
|
|
DefaultCurrency: statement.DefaultCurrency,
|
|
FromAccountId: fromAccountId,
|
|
FromCreditAccount: true,
|
|
ToAccountId: toAccountId,
|
|
})
|
|
}
|
|
}
|
|
|
|
return &ofxTransactionDataTable{
|
|
allData: allData,
|
|
}, nil
|
|
}
|