import transactions from ofx 2.x file
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
@@ -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 <?xml
|
||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(data))
|
||||
xmlDecoder.CharsetReader = utils.IdentReader
|
||||
|
||||
return &ofxFileReader{
|
||||
xmlDecoder: xmlDecoder,
|
||||
}, nil
|
||||
} else if len(data) > 13 && string(data[0:13]) == "OFXHEADER:100" { // ofx 1.x starts with OFXHEADER:100
|
||||
|
||||
} else if len(data) > 5 && string(data[0:5]) == "<OFX>" { // no ofx header
|
||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(data))
|
||||
xmlDecoder.CharsetReader = utils.IdentReader
|
||||
|
||||
return &ofxFileReader{
|
||||
xmlDecoder: xmlDecoder,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, errs.ErrInvalidOFXFile
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" {
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) 数据导出文件",
|
||||
|
||||
Reference in New Issue
Block a user