diff --git a/pkg/converters/ofx/ofx_data.go b/pkg/converters/ofx/ofx_data.go new file mode 100644 index 00000000..cf384298 --- /dev/null +++ b/pkg/converters/ofx/ofx_data.go @@ -0,0 +1,183 @@ +package ofx + +import ( + "encoding/xml" + + "github.com/mayswind/ezbookkeeping/pkg/models" +) + +const ofxVersion1 = "100" +const ofxVersion2 = "200" + +const ofxDefaultTimezoneOffset = "+00:00" + +// ofxAccountType represents account type in open financial exchange (ofx) file +type ofxAccountType string + +// OFX account types +const ( + ofxCheckingAccount ofxAccountType = "CHECKING" + ofxSavingsAccount ofxAccountType = "SAVINGS" + ofxMoneyMarketAccount ofxAccountType = "MONEYMRKT" + ofxLineOfCreditAccount ofxAccountType = "CREDITLINE" + ofxCertificateOfDepositAccount ofxAccountType = "CD" +) + +// ofxTransactionType represents transaction type in open financial exchange (ofx) file +type ofxTransactionType string + +// OFX transaction types +const ( + ofxGenericCreditTransaction ofxTransactionType = "CREDIT" + ofxGenericDebitTransaction ofxTransactionType = "DEBIT" + ofxInterestTransaction ofxTransactionType = "INT" + ofxDividendTransaction ofxTransactionType = "DIV" + ofxFIFeeTransaction ofxTransactionType = "FEE" + ofxServiceChargeTransaction ofxTransactionType = "SRVCHG" + ofxDepositTransaction ofxTransactionType = "DEP" + ofxATMTransaction ofxTransactionType = "ATM" + ofxPOSTransaction ofxTransactionType = "POS" + ofxTransferTransaction ofxTransactionType = "XFER" + ofxCheckTransaction ofxTransactionType = "CHECK" + ofxElectronicPaymentTransaction ofxTransactionType = "PAYMENT" + ofxCashWithdrawalTransaction ofxTransactionType = "CASH" + ofxDirectDepositTransaction ofxTransactionType = "DIRECTDEP" + ofxMerchantInitiatedDebitTransaction ofxTransactionType = "DIRECTDEBIT" + ofxRepeatingPaymentTransaction ofxTransactionType = "REPEATPMT" + ofxHoldTransaction ofxTransactionType = "HOLD" + ofxOtherTransaction ofxTransactionType = "OTHER" +) + +var ofxTransactionTypeMapping = map[ofxTransactionType]models.TransactionType{ + ofxGenericCreditTransaction: models.TRANSACTION_TYPE_EXPENSE, + ofxGenericDebitTransaction: models.TRANSACTION_TYPE_EXPENSE, + ofxDividendTransaction: models.TRANSACTION_TYPE_INCOME, + ofxFIFeeTransaction: models.TRANSACTION_TYPE_EXPENSE, + ofxServiceChargeTransaction: models.TRANSACTION_TYPE_EXPENSE, + ofxDepositTransaction: models.TRANSACTION_TYPE_INCOME, + ofxTransferTransaction: models.TRANSACTION_TYPE_TRANSFER, + ofxCheckTransaction: models.TRANSACTION_TYPE_EXPENSE, + ofxElectronicPaymentTransaction: models.TRANSACTION_TYPE_EXPENSE, + ofxCashWithdrawalTransaction: models.TRANSACTION_TYPE_EXPENSE, + ofxDirectDepositTransaction: models.TRANSACTION_TYPE_INCOME, + ofxMerchantInitiatedDebitTransaction: models.TRANSACTION_TYPE_EXPENSE, + ofxRepeatingPaymentTransaction: models.TRANSACTION_TYPE_EXPENSE, +} + +// ofxFile represents the struct of open financial exchange (ofx) file +type ofxFile struct { + XMLName xml.Name `xml:"OFX"` + FileHeader *ofxFileHeader + BankMessageResponseV1 *ofxBankMessageResponseV1 `xml:"BANKMSGSRSV1"` + CreditCardMessageResponseV1 *ofxCreditCardMessageResponseV1 `xml:"CREDITCARDMSGSRSV1"` +} + +// ofxFileHeader represents the struct of open financial exchange (ofx) file header +type ofxFileHeader struct { + OFXVersion string + OFXDataVersion string + Security string + OldFileUid string + NewFileUid string +} + +// ofxBankMessageResponseV1 represents the struct of open financial exchange (ofx) bank message response v1 +type ofxBankMessageResponseV1 struct { + StatementTransactionResponse *ofxBankStatementTransactionResponse `xml:"STMTTRNRS"` +} + +// ofxCreditCardMessageResponseV1 represents the struct of open financial exchange (ofx) credit card message response v1 +type ofxCreditCardMessageResponseV1 struct { + StatementTransactionResponse *ofxCreditCardStatementTransactionResponse `xml:"CCSTMTTRNRS"` +} + +// ofxBankStatementTransactionResponse represents the struct of open financial exchange (ofx) bank statement transaction response +type ofxBankStatementTransactionResponse struct { + StatementResponse *ofxBankStatementResponse `xml:"STMTRS"` +} + +// ofxCreditCardStatementTransactionResponse represents the struct of open financial exchange (ofx) credit card statement transaction response +type ofxCreditCardStatementTransactionResponse struct { + StatementResponse *ofxCreditCardStatementResponse `xml:"CCSTMTRS"` +} + +// ofxBankStatementResponse represents the struct of open financial exchange (ofx) bank statement response +type ofxBankStatementResponse struct { + DefaultCurrency string `xml:"CURDEF"` + AccountFrom *ofxBankAccount `xml:"BANKACCTFROM"` + TransactionList *ofxBankTransactionList `xml:"BANKTRANLIST"` +} + +// ofxCreditCardStatementResponse represents the struct of open financial exchange (ofx) credit card statement response +type ofxCreditCardStatementResponse struct { + DefaultCurrency string `xml:"CURDEF"` + AccountFrom *ofxCreditCardAccount `xml:"CCACCTFROM"` + TransactionList *ofxCreditCardTransactionList `xml:"BANKTRANLIST"` +} + +// ofxBankAccount represents the struct of open financial exchange (ofx) bank account +type ofxBankAccount struct { + BankId string `xml:"BANKID"` + BranchId string `xml:"BRANCHID"` + AccountId string `xml:"ACCTID"` + AccountType ofxAccountType `xml:"ACCTTYPE"` + AccountKey string `xml:"ACCTKEY"` +} + +// ofxCreditCardAccount represents the struct of open financial exchange (ofx) credit card account +type ofxCreditCardAccount struct { + AccountId string `xml:"ACCTID"` + AccountKey string `xml:"ACCTKEY"` +} + +// ofxBankTransactionList represents the struct of open financial exchange (ofx) bank transaction list +type ofxBankTransactionList struct { + StartDate string `xml:"DTSTART"` + EndDate string `xml:"DTEND"` + StatementTransactions []*ofxBankStatementTransaction `xml:"STMTTRN"` +} + +// ofxCreditCardTransactionList represents the struct of open financial exchange (ofx) credit card transaction list +type ofxCreditCardTransactionList struct { + StartDate string `xml:"DTSTART"` + EndDate string `xml:"DTEND"` + StatementTransactions []*ofxCreditCardStatementTransaction `xml:"STMTTRN"` +} + +// ofxBasicStatementTransaction represents the struct of open financial exchange (ofx) basic statement transaction +type ofxBasicStatementTransaction struct { + TransactionId string `xml:"FITID"` + TransactionType ofxTransactionType `xml:"TRNTYPE"` + PostedDate string `xml:"DTPOSTED"` + Amount string `xml:"TRNAMT"` + Name string `xml:"NAME"` + Payee *ofxPayee `xml:"PAYEE"` + Memo string `xml:"MEMO"` + Currency string `xml:"CURRENCY"` + OriginalCurrency string `xml:"ORIGCURRENCY"` +} + +// ofxBankStatementTransaction represents the struct of open financial exchange (ofx) bank statement transaction +type ofxBankStatementTransaction struct { + ofxBasicStatementTransaction + AccountTo *ofxCreditCardAccount `xml:"BANKACCTTO"` +} + +// ofxCreditCardStatementTransaction represents the struct of open financial exchange (ofx) credit card statement transaction +type ofxCreditCardStatementTransaction struct { + ofxBasicStatementTransaction + AccountTo *ofxCreditCardAccount `xml:"CCACCTTO"` +} + +// ofxPayee represents the struct of open financial exchange (ofx) payee info +type ofxPayee struct { + Name string `xml:"NAME"` + Address1 string `xml:"ADDR1"` + Address2 string `xml:"ADDR2"` + Address3 string `xml:"ADDR3"` + City string `xml:"CITY"` + State string `xml:"STATE"` + PostalCode string `xml:"POSTALCODE"` + Country string `xml:"COUNTRY"` + Phone string `xml:"PHONE"` +} diff --git a/pkg/converters/ofx/ofx_data_reader.go b/pkg/converters/ofx/ofx_data_reader.go new file mode 100644 index 00000000..35489c92 --- /dev/null +++ b/pkg/converters/ofx/ofx_data_reader.go @@ -0,0 +1,50 @@ +package ofx + +import ( + "bytes" + "encoding/xml" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/utils" +) + +// ofxFileReader defines the structure of open financial exchange (ofx) file reader +type ofxFileReader struct { + xmlDecoder *xml.Decoder +} + +// read returns the imported open financial exchange (ofx) file +func (r *ofxFileReader) read(ctx core.Context) (*ofxFile, error) { + file := &ofxFile{} + + err := r.xmlDecoder.Decode(&file) + + if err != nil { + return nil, err + } + + return file, nil +} + +func createNewOFXFileReader(data []byte) (*ofxFileReader, error) { + if len(data) > 5 && data[0] == 0x3C && data[1] == 0x3F && data[2] == 0x78 && data[3] == 0x6D && data[4] == 0x6C { // ofx 2.x starts with 13 && string(data[0:13]) == "OFXHEADER:100" { // ofx 1.x starts with OFXHEADER:100 + + } else if len(data) > 5 && string(data[0:5]) == "" { // no ofx header + xmlDecoder := xml.NewDecoder(bytes.NewReader(data)) + xmlDecoder.CharsetReader = utils.IdentReader + + return &ofxFileReader{ + xmlDecoder: xmlDecoder, + }, nil + } + + return nil, errs.ErrInvalidOFXFile +} diff --git a/pkg/converters/ofx/ofx_transaction_data_file_importer.go b/pkg/converters/ofx/ofx_transaction_data_file_importer.go new file mode 100644 index 00000000..b56c37fc --- /dev/null +++ b/pkg/converters/ofx/ofx_transaction_data_file_importer.go @@ -0,0 +1,48 @@ +package ofx + +import ( + "github.com/mayswind/ezbookkeeping/pkg/converters/datatable" + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/models" + "github.com/mayswind/ezbookkeeping/pkg/utils" +) + +var ofxTransactionTypeNameMapping = map[models.TransactionType]string{ + models.TRANSACTION_TYPE_INCOME: utils.IntToString(int(models.TRANSACTION_TYPE_INCOME)), + models.TRANSACTION_TYPE_EXPENSE: utils.IntToString(int(models.TRANSACTION_TYPE_EXPENSE)), + models.TRANSACTION_TYPE_TRANSFER: utils.IntToString(int(models.TRANSACTION_TYPE_TRANSFER)), +} + +// ofxTransactionDataImporter defines the structure of open financial exchange (ofx) file importer for transaction data +type ofxTransactionDataImporter struct { +} + +// Initialize a open financial exchange (ofx) transaction data importer singleton instance +var ( + OFXTransactionDataImporter = &ofxTransactionDataImporter{} +) + +// ParseImportedData returns the imported data by parsing the open financial exchange (ofx) file transaction data +func (c *ofxTransactionDataImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]*models.TransactionCategory, incomeCategoryMap map[string]*models.TransactionCategory, transferCategoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) { + ofxDataReader, err := createNewOFXFileReader(data) + + if err != nil { + return nil, nil, nil, nil, nil, nil, err + } + + ofxFile, err := ofxDataReader.read(ctx) + + if err != nil { + return nil, nil, nil, nil, nil, nil, err + } + + transactionDataTable, err := createNewOFXTransactionDataTable(ofxFile) + + if err != nil { + return nil, nil, nil, nil, nil, nil, err + } + + dataTableImporter := datatable.CreateNewSimpleImporter(ofxTransactionTypeNameMapping) + + return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap) +} diff --git a/pkg/converters/ofx/ofx_transaction_table.go b/pkg/converters/ofx/ofx_transaction_table.go new file mode 100644 index 00000000..68d4362f --- /dev/null +++ b/pkg/converters/ofx/ofx_transaction_table.go @@ -0,0 +1,285 @@ +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 { + ofxBasicStatementTransaction + DefaultCurrency string + FromAccountId string + 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 { + 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)) + + 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 + + amount, err := utils.ParseAmount(ofxTransaction.Amount) + + if err != nil { + return nil, errs.ErrAmountInvalid + } + + if transactionType, exists := ofxTransactionTypeMapping[ofxTransaction.TransactionType]; exists { + data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(transactionType)) + + if data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == ofxTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] { + data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount) + } else { + data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount) + } + } else { + if amount >= 0 { + data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = ofxTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] + data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount) + } else { + data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = ofxTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] + data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount) + } + } + + 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 data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == ofxTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER] { + 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] + } + + 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 + year = datetime[0:4] + month = datetime[4:6] + day = datetime[6:8] + } + + if len(datetime) >= 14 { // YYYYMMDDHHMMSS + 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 := "" + + 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{ + ofxBasicStatementTransaction: bankTransactions[i].ofxBasicStatementTransaction, + DefaultCurrency: statement.DefaultCurrency, + FromAccountId: fromAccountId, + 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{ + ofxBasicStatementTransaction: bankTransactions[i].ofxBasicStatementTransaction, + DefaultCurrency: statement.DefaultCurrency, + FromAccountId: fromAccountId, + ToAccountId: toAccountId, + }) + } + } + + return &ofxTransactionDataTable{ + allData: allData, + }, nil +} diff --git a/pkg/converters/transaction_data_converters.go b/pkg/converters/transaction_data_converters.go index aa0de7c0..f4d53a57 100644 --- a/pkg/converters/transaction_data_converters.go +++ b/pkg/converters/transaction_data_converters.go @@ -8,6 +8,7 @@ import ( "github.com/mayswind/ezbookkeeping/pkg/converters/fireflyIII" "github.com/mayswind/ezbookkeeping/pkg/converters/gnucash" "github.com/mayswind/ezbookkeeping/pkg/converters/iif" + "github.com/mayswind/ezbookkeeping/pkg/converters/ofx" "github.com/mayswind/ezbookkeeping/pkg/converters/qif" "github.com/mayswind/ezbookkeeping/pkg/converters/wechat" "github.com/mayswind/ezbookkeeping/pkg/errs" @@ -38,6 +39,8 @@ func GetTransactionDataImporter(fileType string) (base.TransactionDataImporter, return qif.QifDayMonthYearTransactionDataImporter, nil } else if fileType == "iif" { return iif.IifTransactionDataFileImporter, nil + } else if fileType == "ofx" { + return ofx.OFXTransactionDataImporter, nil } else if fileType == "gnucash" { return gnucash.GnuCashTransactionDataImporter, nil } else if fileType == "firefly_iii_csv" { diff --git a/pkg/errs/converter.go b/pkg/errs/converter.go index e1fccfb5..7c26e2fc 100644 --- a/pkg/errs/converter.go +++ b/pkg/errs/converter.go @@ -23,4 +23,5 @@ var ( ErrNotSupportedSplitTransactions = NewNormalError(NormalSubcategoryConverter, 16, http.StatusBadRequest, "not supported to import split transaction") ErrThereAreNotSupportedTransactionType = NewNormalError(NormalSubcategoryConverter, 17, http.StatusBadRequest, "there are not supported transaction type") ErrInvalidIIFFile = NewNormalError(NormalSubcategoryConverter, 18, http.StatusBadRequest, "invalid iif file") + ErrInvalidOFXFile = NewNormalError(NormalSubcategoryConverter, 19, http.StatusBadRequest, "invalid ofx file") ) diff --git a/pkg/utils/datetimes.go b/pkg/utils/datetimes.go index beff3ab8..85c9dba8 100644 --- a/pkg/utils/datetimes.go +++ b/pkg/utils/datetimes.go @@ -216,6 +216,29 @@ func FormatTimezoneOffset(timezone *time.Location) string { return fmt.Sprintf("%s%02d:%02d", sign, hourAbsOffset, minuteAbsOffset) } +// FormatTimezoneOffsetFromHoursOffset returns "+/-HH:MM" format of timezone from hours offset +func FormatTimezoneOffsetFromHoursOffset(hoursOffset string) (string, error) { + hoursOffsetValue, err := StringToFloat64(hoursOffset) + + if err != nil { + return "", errs.ErrFormatInvalid + } + + tzMinutesOffset := int16(hoursOffsetValue * 60) + + sign := "+" + hourAbsOffset := tzMinutesOffset / 60 + minuteAbsOffset := tzMinutesOffset % 60 + + if hourAbsOffset < 0 { + sign = "-" + hourAbsOffset = -hourAbsOffset + minuteAbsOffset = -minuteAbsOffset + } + + return fmt.Sprintf("%s%02d:%02d", sign, hourAbsOffset, minuteAbsOffset), nil +} + // ParseFromTimezoneOffset parses a formatted string in timezone offset format func ParseFromTimezoneOffset(tzOffset string) (*time.Location, error) { if len(tzOffset) != 6 { // +/-HH:MM diff --git a/pkg/utils/datetimes_test.go b/pkg/utils/datetimes_test.go index dada67ef..a4c0e0ca 100644 --- a/pkg/utils/datetimes_test.go +++ b/pkg/utils/datetimes_test.go @@ -5,6 +5,8 @@ import ( "time" "github.com/stretchr/testify/assert" + + "github.com/mayswind/ezbookkeeping/pkg/errs" ) func TestParseNumericYearMonth(t *testing.T) { @@ -259,6 +261,47 @@ func TestFormatTimezoneOffset(t *testing.T) { assert.Equal(t, expectedValue, actualValue) } +func TestFormatTimezoneOffsetFromHoursOffset(t *testing.T) { + expectedValue := "+02:00" + actualValue, err := FormatTimezoneOffsetFromHoursOffset("2") + assert.Nil(t, err) + assert.Equal(t, expectedValue, actualValue) + + expectedValue = "+05:45" + actualValue, err = FormatTimezoneOffsetFromHoursOffset("+5.75") + assert.Nil(t, err) + assert.Equal(t, expectedValue, actualValue) + + expectedValue = "-12:00" + actualValue, err = FormatTimezoneOffsetFromHoursOffset("-12.00") + assert.Nil(t, err) + assert.Equal(t, expectedValue, actualValue) + + expectedValue = "-02:30" + actualValue, err = FormatTimezoneOffsetFromHoursOffset("-2.5") + assert.Nil(t, err) + assert.Equal(t, expectedValue, actualValue) + + expectedValue = "+00:00" + actualValue, err = FormatTimezoneOffsetFromHoursOffset("0") + assert.Nil(t, err) + assert.Equal(t, expectedValue, actualValue) +} + +func TestFormatTimezoneOffsetFromHoursOffset_InvalidHoursOffset(t *testing.T) { + _, err := FormatTimezoneOffsetFromHoursOffset("") + assert.EqualError(t, err, errs.ErrFormatInvalid.Message) + + _, err = FormatTimezoneOffsetFromHoursOffset("+") + assert.EqualError(t, err, errs.ErrFormatInvalid.Message) + + _, err = FormatTimezoneOffsetFromHoursOffset("-") + assert.EqualError(t, err, errs.ErrFormatInvalid.Message) + + _, err = FormatTimezoneOffsetFromHoursOffset("a") + assert.EqualError(t, err, errs.ErrFormatInvalid.Message) +} + func TestParseFromTimezoneOffset(t *testing.T) { expectedValue := time.FixedZone("Timezone", 120*60) actualValue, err := ParseFromTimezoneOffset("+02:00") diff --git a/src/consts/file.js b/src/consts/file.js index 9a78b596..3984ef7d 100644 --- a/src/consts/file.js +++ b/src/consts/file.js @@ -46,6 +46,11 @@ const supportedImportFileTypes = [ name: 'Intuit Interchange Format (IIF) File', extensions: '.iif' }, + { + type: 'ofx', + name: 'Open Financial Exchange (OFX) File', + extensions: '.ofx' + }, { type: 'gnucash', name: 'GnuCash XML Database File', diff --git a/src/locales/en.json b/src/locales/en.json index b98960b3..0939a6d6 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1133,6 +1133,7 @@ "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", "invalid iif file": "Invalid IIF file", + "invalid ofx file": "Invalid OFX 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", @@ -1530,6 +1531,7 @@ "Month-day-year format": "Month-day-year format", "Day-month-year format": "Day-month-year format", "Intuit Interchange Format (IIF) File": "Intuit Interchange Format (IIF) File", + "Open Financial Exchange (OFX) File": "Open Financial Exchange (OFX) File", "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", diff --git a/src/locales/zh_Hans.json b/src/locales/zh_Hans.json index b48f7134..12d9e390 100644 --- a/src/locales/zh_Hans.json +++ b/src/locales/zh_Hans.json @@ -1133,6 +1133,7 @@ "not supported to import split transaction": "不支持导入拆分的交易", "there are not supported transaction type": "导入文件中有不支持的交易类型", "invalid iif file": "无效的 IIF 文件", + "invalid ofx file": "无效的 OFX 文件", "query items cannot be blank": "请求项目不能为空", "query items too much": "请求项目过多", "query items have invalid item": "请求项目中有非法项目", @@ -1530,6 +1531,7 @@ "Month-day-year format": "月-日-年 格式", "Day-month-year format": "日-月-年 格式", "Intuit Interchange Format (IIF) File": "Intuit Interchange Format (IIF) 文件", + "Open Financial Exchange (OFX) File": "开放式金融交换 (OFX) 文件", "GnuCash XML Database File": "GnuCash XML 数据库文件", "Firefly III Data Export File": "Firefly III 数据导出文件", "Feidee MyMoney (App) Data Export File": "随手记 (App) 数据导出文件",