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/fireflyIII"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/gnucash"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/gnucash"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/iif"
|
"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/qif"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/wechat"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/wechat"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
@@ -38,6 +39,8 @@ func GetTransactionDataImporter(fileType string) (base.TransactionDataImporter,
|
|||||||
return qif.QifDayMonthYearTransactionDataImporter, nil
|
return qif.QifDayMonthYearTransactionDataImporter, nil
|
||||||
} else if fileType == "iif" {
|
} else if fileType == "iif" {
|
||||||
return iif.IifTransactionDataFileImporter, nil
|
return iif.IifTransactionDataFileImporter, nil
|
||||||
|
} else if fileType == "ofx" {
|
||||||
|
return ofx.OFXTransactionDataImporter, nil
|
||||||
} else if fileType == "gnucash" {
|
} else if fileType == "gnucash" {
|
||||||
return gnucash.GnuCashTransactionDataImporter, nil
|
return gnucash.GnuCashTransactionDataImporter, nil
|
||||||
} else if fileType == "firefly_iii_csv" {
|
} else if fileType == "firefly_iii_csv" {
|
||||||
|
|||||||
@@ -23,4 +23,5 @@ var (
|
|||||||
ErrNotSupportedSplitTransactions = NewNormalError(NormalSubcategoryConverter, 16, http.StatusBadRequest, "not supported to import split transaction")
|
ErrNotSupportedSplitTransactions = NewNormalError(NormalSubcategoryConverter, 16, http.StatusBadRequest, "not supported to import split transaction")
|
||||||
ErrThereAreNotSupportedTransactionType = NewNormalError(NormalSubcategoryConverter, 17, http.StatusBadRequest, "there are not supported transaction type")
|
ErrThereAreNotSupportedTransactionType = NewNormalError(NormalSubcategoryConverter, 17, http.StatusBadRequest, "there are not supported transaction type")
|
||||||
ErrInvalidIIFFile = NewNormalError(NormalSubcategoryConverter, 18, http.StatusBadRequest, "invalid iif file")
|
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)
|
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
|
// ParseFromTimezoneOffset parses a formatted string in timezone offset format
|
||||||
func ParseFromTimezoneOffset(tzOffset string) (*time.Location, error) {
|
func ParseFromTimezoneOffset(tzOffset string) (*time.Location, error) {
|
||||||
if len(tzOffset) != 6 { // +/-HH:MM
|
if len(tzOffset) != 6 { // +/-HH:MM
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseNumericYearMonth(t *testing.T) {
|
func TestParseNumericYearMonth(t *testing.T) {
|
||||||
@@ -259,6 +261,47 @@ func TestFormatTimezoneOffset(t *testing.T) {
|
|||||||
assert.Equal(t, expectedValue, actualValue)
|
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) {
|
func TestParseFromTimezoneOffset(t *testing.T) {
|
||||||
expectedValue := time.FixedZone("Timezone", 120*60)
|
expectedValue := time.FixedZone("Timezone", 120*60)
|
||||||
actualValue, err := ParseFromTimezoneOffset("+02:00")
|
actualValue, err := ParseFromTimezoneOffset("+02:00")
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ const supportedImportFileTypes = [
|
|||||||
name: 'Intuit Interchange Format (IIF) File',
|
name: 'Intuit Interchange Format (IIF) File',
|
||||||
extensions: '.iif'
|
extensions: '.iif'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'ofx',
|
||||||
|
name: 'Open Financial Exchange (OFX) File',
|
||||||
|
extensions: '.ofx'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'gnucash',
|
type: 'gnucash',
|
||||||
name: 'GnuCash XML Database File',
|
name: 'GnuCash XML Database File',
|
||||||
|
|||||||
@@ -1133,6 +1133,7 @@
|
|||||||
"not supported to import split transaction": "Not supported to import split transaction",
|
"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",
|
"there are not supported transaction type": "There are not supported transaction type in import file",
|
||||||
"invalid iif file": "Invalid IIF 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 cannot be blank": "There are no query items",
|
||||||
"query items too much": "There are too many query items",
|
"query items too much": "There are too many query items",
|
||||||
"query items have invalid item": "There is invalid item in 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",
|
"Month-day-year format": "Month-day-year format",
|
||||||
"Day-month-year format": "Day-month-year format",
|
"Day-month-year format": "Day-month-year format",
|
||||||
"Intuit Interchange Format (IIF) File": "Intuit Interchange Format (IIF) File",
|
"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",
|
"GnuCash XML Database File": "GnuCash XML Database File",
|
||||||
"Firefly III Data Export File": "Firefly III Data Export File",
|
"Firefly III Data Export File": "Firefly III Data Export File",
|
||||||
"Feidee MyMoney (App) Data Export File": "Feidee MyMoney (App) 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": "不支持导入拆分的交易",
|
"not supported to import split transaction": "不支持导入拆分的交易",
|
||||||
"there are not supported transaction type": "导入文件中有不支持的交易类型",
|
"there are not supported transaction type": "导入文件中有不支持的交易类型",
|
||||||
"invalid iif file": "无效的 IIF 文件",
|
"invalid iif file": "无效的 IIF 文件",
|
||||||
|
"invalid ofx file": "无效的 OFX 文件",
|
||||||
"query items cannot be blank": "请求项目不能为空",
|
"query items cannot be blank": "请求项目不能为空",
|
||||||
"query items too much": "请求项目过多",
|
"query items too much": "请求项目过多",
|
||||||
"query items have invalid item": "请求项目中有非法项目",
|
"query items have invalid item": "请求项目中有非法项目",
|
||||||
@@ -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) 文件",
|
"Intuit Interchange Format (IIF) File": "Intuit Interchange Format (IIF) 文件",
|
||||||
|
"Open Financial Exchange (OFX) File": "开放式金融交换 (OFX) 文件",
|
||||||
"GnuCash XML Database File": "GnuCash XML 数据库文件",
|
"GnuCash XML Database File": "GnuCash XML 数据库文件",
|
||||||
"Firefly III Data Export File": "Firefly III 数据导出文件",
|
"Firefly III Data Export File": "Firefly III 数据导出文件",
|
||||||
"Feidee MyMoney (App) Data Export File": "随手记 (App) 数据导出文件",
|
"Feidee MyMoney (App) Data Export File": "随手记 (App) 数据导出文件",
|
||||||
|
|||||||
Reference in New Issue
Block a user