Files
ezbookkeeping/pkg/converters/beancount/beancount_transaction_data_table.go
T
2025-06-20 00:55:59 +08:00

249 lines
11 KiB
Go

package beancount
import (
"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 beancountTransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: 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,
}
var BEANCOUNT_TRANSACTION_TAG_SEPARATOR = "#"
// beancountTransactionDataTable defines the structure of Beancount transaction data table
type beancountTransactionDataTable struct {
allData []*beancountTransactionEntry
accountMap map[string]*beancountAccount
}
// beancountTransactionDataRow defines the structure of Beancount transaction data row
type beancountTransactionDataRow struct {
dataTable *beancountTransactionDataTable
data *beancountTransactionEntry
finalItems map[datatable.TransactionDataTableColumn]string
}
// beancountTransactionDataRowIterator defines the structure of Beancount transaction data row iterator
type beancountTransactionDataRowIterator struct {
dataTable *beancountTransactionDataTable
currentIndex int
}
// HasColumn returns whether the transaction data table has specified column
func (t *beancountTransactionDataTable) HasColumn(column datatable.TransactionDataTableColumn) bool {
_, exists := beancountTransactionSupportedColumns[column]
return exists
}
// TransactionRowCount returns the total count of transaction data row
func (t *beancountTransactionDataTable) TransactionRowCount() int {
return len(t.allData)
}
// TransactionRowIterator returns the iterator of transaction data row
func (t *beancountTransactionDataTable) TransactionRowIterator() datatable.TransactionDataRowIterator {
return &beancountTransactionDataRowIterator{
dataTable: t,
currentIndex: -1,
}
}
// IsValid returns whether this row is valid data for importing
func (r *beancountTransactionDataRow) IsValid() bool {
return true
}
// GetData returns the data in the specified column type
func (r *beancountTransactionDataRow) GetData(column datatable.TransactionDataTableColumn) string {
_, exists := beancountTransactionSupportedColumns[column]
if exists {
return r.finalItems[column]
}
return ""
}
// HasNext returns whether the iterator does not reach the end
func (t *beancountTransactionDataRowIterator) HasNext() bool {
return t.currentIndex+1 < len(t.dataTable.allData)
}
// Next returns the next transaction data row
func (t *beancountTransactionDataRowIterator) 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 &beancountTransactionDataRow{
dataTable: t.dataTable,
data: data,
finalItems: rowItems,
}, nil
}
func (t *beancountTransactionDataRowIterator) parseTransaction(ctx core.Context, user *models.User, beancountEntry *beancountTransactionEntry) (map[datatable.TransactionDataTableColumn]string, error) {
data := make(map[datatable.TransactionDataTableColumn]string, len(beancountTransactionSupportedColumns))
if beancountEntry.Date == "" {
return nil, errs.ErrMissingTransactionTime
}
// Beancount supports the international ISO 8601 standard format for dates, with dashes or the same ordering with slashes
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = strings.ReplaceAll(beancountEntry.Date, "/", "-") + " 00:00:00"
if len(beancountEntry.Postings) == 2 {
splitData1 := beancountEntry.Postings[0]
splitData2 := beancountEntry.Postings[1]
account1 := t.dataTable.accountMap[splitData1.Account]
account2 := t.dataTable.accountMap[splitData2.Account]
if account1 == nil || account2 == nil {
return nil, errs.ErrMissingAccountData
}
amount1, err := utils.ParseAmount(splitData1.Amount)
if err != nil {
log.Errorf(ctx, "[beancount_transaction_data_table.parseTransaction] cannot parse amount \"%s\", because %s", splitData1.Amount, err.Error())
return nil, errs.ErrAmountInvalid
}
amount2, err := utils.ParseAmount(splitData2.Amount)
if err != nil {
log.Errorf(ctx, "[beancount_transaction_data_table.parseTransaction] cannot parse amount \"%s\", because %s", splitData2.Amount, err.Error())
return nil, errs.ErrAmountInvalid
}
if ((account1.AccountType == beancountEquityAccountType || account1.AccountType == beancountIncomeAccountType) && (account2.AccountType == beancountAssetsAccountType || account2.AccountType == beancountLiabilitiesAccountType)) ||
((account2.AccountType == beancountEquityAccountType || account2.AccountType == beancountIncomeAccountType) && (account1.AccountType == beancountAssetsAccountType || account1.AccountType == beancountLiabilitiesAccountType)) { // income
fromAccount := account1
toAccount := account2
toCurrency := splitData2.Commodity
toAmount := amount2
if (account2.AccountType == beancountEquityAccountType || account2.AccountType == beancountIncomeAccountType) && (account1.AccountType == beancountAssetsAccountType || account1.AccountType == beancountLiabilitiesAccountType) {
fromAccount = account2
toAccount = account1
toCurrency = splitData1.Commodity
toAmount = amount1
}
if fromAccount.isOpeningBalanceEquityAccount() {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_MODIFY_BALANCE))
} else {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_INCOME))
}
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = fromAccount.Name
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = toAccount.Name
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = toCurrency
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(toAmount)
} else if account1.AccountType == beancountExpensesAccountType && (account2.AccountType == beancountAssetsAccountType || account2.AccountType == beancountLiabilitiesAccountType) ||
(account2.AccountType == beancountExpensesAccountType && (account1.AccountType == beancountAssetsAccountType || account1.AccountType == beancountLiabilitiesAccountType)) { // expense
fromAccount := account1
fromCurrency := splitData1.Commodity
fromAmount := amount1
toAccount := account2
if account1.AccountType == beancountExpensesAccountType && (account2.AccountType == beancountAssetsAccountType || account2.AccountType == beancountLiabilitiesAccountType) {
fromAccount = account2
fromCurrency = splitData2.Commodity
fromAmount = amount2
toAccount = account1
}
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_EXPENSE))
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = toAccount.Name
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = fromAccount.Name
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = fromCurrency
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-fromAmount)
} else if (account1.AccountType == beancountAssetsAccountType || account1.AccountType == beancountLiabilitiesAccountType) &&
(account2.AccountType == beancountAssetsAccountType || account2.AccountType == beancountLiabilitiesAccountType) {
var fromAccount, toAccount *beancountAccount
var fromAmount, toAmount int64
var fromCurrency, toCurrency string
if amount1 < 0 {
fromAccount = account1
fromCurrency = splitData1.Commodity
fromAmount = -amount1
toAccount = account2
toCurrency = splitData2.Commodity
toAmount = amount2
} else if amount2 < 0 {
fromAccount = account2
fromCurrency = splitData2.Commodity
fromAmount = -amount2
toAccount = account1
toCurrency = splitData1.Commodity
toAmount = amount1
} else {
log.Errorf(ctx, "[beancount_transaction_data_table.parseTransaction] cannot parse transfer transaction, because unexcepted account amounts \"%d\" and \"%d\"", amount1, amount2)
return nil, errs.ErrInvalidBeancountFile
}
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_TRANSFER))
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = ""
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = fromAccount.Name
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = fromCurrency
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(fromAmount)
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = toAccount.Name
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = toCurrency
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(toAmount)
} else {
log.Errorf(ctx, "[beancount_transaction_data_table.parseTransaction] cannot parse transaction, because unexcepted account types \"%d\" and \"%d\"", account1.AccountType, account2.AccountType)
return nil, errs.ErrThereAreNotSupportedTransactionType
}
} else if len(beancountEntry.Postings) <= 1 {
log.Errorf(ctx, "[beancount_transaction_data_table.parseTransaction] cannot parse transaction, because postings count is %d", len(beancountEntry.Postings))
return nil, errs.ErrInvalidBeancountFile
} else {
log.Errorf(ctx, "[beancount_transaction_data_table.parseTransaction] cannot parse split transaction, because postings count is %d", len(beancountEntry.Postings))
return nil, errs.ErrNotSupportedSplitTransactions
}
data[datatable.TRANSACTION_DATA_TABLE_TAGS] = strings.Join(beancountEntry.Tags, BEANCOUNT_TRANSACTION_TAG_SEPARATOR)
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = beancountEntry.Narration
return data, nil
}
func createNewBeancountTransactionDataTable(beancountData *beancountData) (*beancountTransactionDataTable, error) {
if beancountData == nil {
return nil, errs.ErrNotFoundTransactionDataInFile
}
return &beancountTransactionDataTable{
allData: beancountData.Transactions,
accountMap: beancountData.Accounts,
}, nil
}