import transactions from ofx 2.x file

This commit is contained in:
MaysWind
2024-10-28 00:33:21 +08:00
parent 22d653cc76
commit c372272394
11 changed files with 645 additions and 0 deletions
+183
View File
@@ -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"`
}
+50
View File
@@ -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)
}
+285
View File
@@ -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" {
+1
View File
@@ -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")
) )
+23
View 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
+43
View File
@@ -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")
+5
View File
@@ -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',
+2
View 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",
+2
View 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) 数据导出文件",