diff --git a/pkg/api/transactions.go b/pkg/api/transactions.go index 18d58899..d32e2afa 100644 --- a/pkg/api/transactions.go +++ b/pkg/api/transactions.go @@ -22,9 +22,6 @@ import ( "github.com/mayswind/ezbookkeeping/pkg/utils" ) -const maximumTagsCountOfTransaction = 10 -const maximumPicturesCountOfTransaction = 10 - // TransactionsApi represents transaction api type TransactionsApi struct { ApiUsingConfig @@ -682,7 +679,7 @@ func (a *TransactionsApi) TransactionCreateHandler(c *core.WebContext) (any, *er return nil, errs.ErrTransactionTagIdInvalid } - if len(tagIds) > maximumTagsCountOfTransaction { + if len(tagIds) > models.MaximumTagsCountOfTransaction { return nil, errs.ErrTransactionHasTooManyTags } @@ -693,7 +690,7 @@ func (a *TransactionsApi) TransactionCreateHandler(c *core.WebContext) (any, *er return nil, errs.ErrTransactionPictureIdInvalid } - if len(pictureIds) > maximumPicturesCountOfTransaction { + if len(pictureIds) > models.MaximumPicturesCountOfTransaction { return nil, errs.ErrTransactionHasTooManyPictures } @@ -812,7 +809,7 @@ func (a *TransactionsApi) TransactionModifyHandler(c *core.WebContext) (any, *er return nil, errs.ErrTransactionTagIdInvalid } - if len(tagIds) > maximumTagsCountOfTransaction { + if len(tagIds) > models.MaximumTagsCountOfTransaction { return nil, errs.ErrTransactionHasTooManyTags } @@ -823,7 +820,7 @@ func (a *TransactionsApi) TransactionModifyHandler(c *core.WebContext) (any, *er return nil, errs.ErrTransactionPictureIdInvalid } - if len(pictureIds) > maximumPicturesCountOfTransaction { + if len(pictureIds) > models.MaximumPicturesCountOfTransaction { return nil, errs.ErrTransactionHasTooManyPictures } @@ -1382,7 +1379,7 @@ func (a *TransactionsApi) TransactionImportHandler(c *core.WebContext) (any, *er return nil, errs.ErrTransactionTagIdInvalid } - if len(tagIds) > maximumTagsCountOfTransaction { + if len(tagIds) > models.MaximumTagsCountOfTransaction { return nil, errs.ErrTransactionHasTooManyTags } diff --git a/pkg/mcp/add_transaction.go b/pkg/mcp/add_transaction.go new file mode 100644 index 00000000..596613fd --- /dev/null +++ b/pkg/mcp/add_transaction.go @@ -0,0 +1,320 @@ +package mcp + +import ( + "encoding/json" + "reflect" + + "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/settings" + "github.com/mayswind/ezbookkeeping/pkg/utils" +) + +const transactionTypeIncome = "income" +const transactionTypeExpense = "expense" +const transactionTypeTransfer = "transfer" + +// MCPAddTransactionRequest represents all parameters of the add transaction request +type MCPAddTransactionRequest struct { + Type string `json:"type" jsonschema:"enum=income,enum=expense,enum=transfer" jsonschema_description:"Transaction type (income, expense, transfer)"` + Time string `json:"time" jsonschema:"format=date-time" jsonschema_description:"Transaction time in RFC 3339 format (e.g. 2023-01-01T12:00:00Z)"` + SecondaryCategoryName string `json:"category_name" jsonschema_description:"Secondary category name for the transaction"` + AccountName string `json:"account_name" jsonschema_description:"Account name for the transaction"` + Amount string `json:"amount" jsonschema_description:"Transaction amount"` + DestinationAccountName string `json:"destination_account_name,omitempty" jsonschema_description:"Destination account name for transfer transactions (optional)"` + DestinationAmount string `json:"destination_amount,omitempty" jsonschema_description:"Destination amount for transfer transactions (optional)"` + Tags []string `json:"tags,omitempty" jsonschema_description:"List of tags associated with the transaction (optional, maximum 10 tags allowed)"` + Comment string `json:"comment,omitempty" jsonschema_description:"Transaction description"` + DryRun bool `json:"dry_run,omitempty" jsonschema_description:"If true, the transaction will not be saved, only validated (optional)"` +} + +// MCPAddTransactionResponse represents the response structure for add transaction +type MCPAddTransactionResponse struct { + Success bool `json:"success" jsonschema_description:"Indicates whether the transaction was added successfully"` + DryRun bool `json:"dry_run,omitempty" jsonschema_description:"Indicates whether this is a dry run (transaction not saved actually)"` + AccountBalance string `json:"account_balance,omitempty" jsonschema_description:"Account balance (or outstanding balance for debt accounts) after the transaction"` + DestinationAccountBalance string `json:"destination_account_balance,omitempty" jsonschema_description:"Destination account balance (or outstanding balance for debt accounts) after the transaction (only for transfer transactions)"` +} + +type mcpAddTransactionToolHandler struct{} + +var MCPAddTransactionToolHandler = &mcpAddTransactionToolHandler{} + +// Name returns the name of the MCP tool +func (h *mcpAddTransactionToolHandler) Name() string { + return "add_transaction" +} + +// Description returns the description of the MCP tool +func (h *mcpAddTransactionToolHandler) Description() string { + return "Add a new transaction in ezBookkeeping." +} + +// InputType returns the input type for the MCP tool request +func (h *mcpAddTransactionToolHandler) InputType() reflect.Type { + return reflect.TypeOf(&MCPAddTransactionRequest{}) +} + +// OutputType returns the output type for the MCP tool response +func (h *mcpAddTransactionToolHandler) OutputType() reflect.Type { + return reflect.TypeOf(&MCPAddTransactionResponse{}) +} + +// Handle processes the MCP call tool request and returns the response +func (h *mcpAddTransactionToolHandler) Handle(c *core.WebContext, callToolReq *MCPCallToolRequest, currentConfig *settings.Config, services MCPAvailableServices) (any, []*MCPTextContent, *errs.Error) { + var addTransactionRequest MCPAddTransactionRequest + + if callToolReq.Arguments != nil { + if err := json.Unmarshal(callToolReq.Arguments, &addTransactionRequest); err != nil { + return nil, nil, errs.NewIncompleteOrIncorrectSubmissionError(err) + } + } else { + return nil, nil, errs.ErrIncompleteOrIncorrectSubmission + } + + if addTransactionRequest.Type == transactionTypeTransfer { + if addTransactionRequest.DestinationAccountName == "" || addTransactionRequest.DestinationAmount == "" { + return nil, nil, errs.ErrIncompleteOrIncorrectSubmission + } + } + + if len(addTransactionRequest.Tags) > models.MaximumTagsCountOfTransaction { + return nil, nil, errs.ErrTransactionHasTooManyTags + } + + uid := c.GetCurrentUid() + user, err := services.GetUserService().GetUserById(c, uid) + + if err != nil { + if !errs.IsCustomError(err) { + log.Errorf(c, "[add_transaction.Handle] failed to get user, because %s", err.Error()) + } + + return nil, nil, errs.ErrUserNotFound + } + + allAccounts, err := services.GetAccountService().GetAllAccountsByUid(c, uid) + + if err != nil { + log.Warnf(c, "[add_transaction.Handle] get account error, because %s", err.Error()) + return nil, nil, errs.Or(err, errs.ErrOperationFailed) + } + + accountsMap := services.GetAccountService().GetVisibleAccountNameMapByList(allAccounts) + sourceAccount, exists := accountsMap[addTransactionRequest.AccountName] + + if !exists { + log.Warnf(c, "[add_transaction.Handle] source account \"%s\" not found for user \"uid:%d\"", addTransactionRequest.AccountName, uid) + return nil, nil, errs.ErrSourceAccountNotFound + } + + var destinationAccount *models.Account + destinationAccountId := int64(0) + + if addTransactionRequest.Type == transactionTypeTransfer { + destinationAccount, exists = accountsMap[addTransactionRequest.DestinationAccountName] + + if !exists { + log.Warnf(c, "[add_transaction.Handle] destination account \"%s\" not found for user \"uid:%d\"", addTransactionRequest.DestinationAccountName, uid) + return nil, nil, errs.ErrDestinationAccountNotFound + } + + destinationAccountId = destinationAccount.AccountId + } + + allCategories, err := services.GetTransactionCategoryService().GetAllCategoriesByUid(c, uid, 0, -1) + + if err != nil { + log.Warnf(c, "[add_transaction.Handle] get transaction category error, because %s", err.Error()) + return nil, nil, errs.Or(err, errs.ErrOperationFailed) + } + + categoriesMap := services.GetTransactionCategoryService().GetVisibleCategoryNameMapByList(allCategories) + category, exists := categoriesMap[addTransactionRequest.SecondaryCategoryName] + + if !exists { + log.Warnf(c, "[add_transaction.Handle] secondary category \"%s\" not found for user \"uid:%d\"", addTransactionRequest.SecondaryCategoryName, uid) + return nil, nil, errs.ErrTransactionCategoryNotFound + } + + var tagIds []int64 + + if len(addTransactionRequest.Tags) > 0 { + allTags, err := services.GetTransactionTagService().GetAllTagsByUid(c, uid) + + if err != nil { + log.Warnf(c, "[add_transaction.Handle] get transaction tag ids error, because %s", err.Error()) + return nil, nil, errs.Or(err, errs.ErrOperationFailed) + } + + tagMaps := services.GetTransactionTagService().GetTagNameMapByList(allTags) + tagIds = make([]int64, 0, len(addTransactionRequest.Tags)) + + for _, tagName := range addTransactionRequest.Tags { + if tag, exists := tagMaps[tagName]; exists { + tagIds = append(tagIds, tag.TagId) + } else { + log.Warnf(c, "[add_transaction.Handle] transaction tag \"%s\" not found for user \"uid:%d\"", tagName, uid) + } + } + } + + transaction := h.createNewTransactionModel(uid, &addTransactionRequest, category.CategoryId, sourceAccount.AccountId, destinationAccountId, c.ClientIP()) + transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, transaction.TimezoneUtcOffset) + + if !transactionEditable { + return nil, nil, errs.ErrCannotCreateTransactionWithThisTransactionTime + } + + if !addTransactionRequest.DryRun { + err = services.GetTransactionService().CreateTransaction(c, transaction, tagIds, nil) + + if err != nil { + log.Errorf(c, "[add_transaction.Handle] failed to create transaction \"id:%d\" for user \"uid:%d\", because %s", transaction.TransactionId, uid, err.Error()) + return nil, nil, errs.Or(err, errs.ErrOperationFailed) + } + + log.Infof(c, "[add_transaction.Handle] user \"uid:%d\" has created a new transaction \"id:%d\" successfully", uid, transaction.TransactionId) + + accountIds := []int64{sourceAccount.AccountId} + + if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT { + accountIds = append(accountIds, destinationAccountId) + } + + newAccounts, err := services.GetAccountService().GetAccountsByAccountIds(c, uid, accountIds) + + if err != nil { + log.Warnf(c, "[add_transaction.Handle] failed to get latest accounts info after transaction created, because %s", err.Error()) + } + + structuredResponse, response, err := h.createNewMCPAddTransactionResponse(c, transaction, newAccounts, false) + + if err != nil { + return nil, nil, errs.Or(err, errs.ErrOperationFailed) + } + + return structuredResponse, response, nil + } else { + newAccounts := make(map[int64]*models.Account) + newAccounts[sourceAccount.AccountId] = sourceAccount + + if transaction.Type == models.TRANSACTION_DB_TYPE_EXPENSE || transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT { + sourceAccount.Balance -= transaction.Amount + } else if transaction.Type == models.TRANSACTION_DB_TYPE_INCOME { + sourceAccount.Balance += transaction.Amount + } + + if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT && destinationAccount != nil { + newAccounts[destinationAccount.AccountId] = destinationAccount + destinationAccount.Balance += transaction.RelatedAccountAmount + } + + structuredResponse, response, err := h.createNewMCPAddTransactionResponse(c, transaction, newAccounts, true) + + if err != nil { + return nil, nil, errs.Or(err, errs.ErrOperationFailed) + } + + return structuredResponse, response, nil + } +} + +func (h *mcpAddTransactionToolHandler) createNewTransactionModel(uid int64, addTransactionRequest *MCPAddTransactionRequest, categoryId int64, sourceAccountId int64, destinationAccountId int64, clientIp string) *models.Transaction { + var transactionDbType models.TransactionDbType + + if addTransactionRequest.Type == transactionTypeExpense { + transactionDbType = models.TRANSACTION_DB_TYPE_EXPENSE + } else if addTransactionRequest.Type == transactionTypeIncome { + transactionDbType = models.TRANSACTION_DB_TYPE_INCOME + } else if addTransactionRequest.Type == transactionTypeTransfer { + transactionDbType = models.TRANSACTION_DB_TYPE_TRANSFER_OUT + } + + transactionTime, err := utils.ParseFromLongDateTimeWithTimezoneRFC3339Format(addTransactionRequest.Time) + + if err != nil { + return nil + } + + amount, err := utils.ParseAmount(addTransactionRequest.Amount) + + if err != nil { + return nil + } + + transaction := &models.Transaction{ + Uid: uid, + Type: transactionDbType, + CategoryId: categoryId, + TransactionTime: utils.GetMinTransactionTimeFromUnixTime(transactionTime.Unix()), + TimezoneUtcOffset: utils.GetTimezoneOffsetMinutes(transactionTime.Location()), + AccountId: sourceAccountId, + Amount: amount, + HideAmount: false, + Comment: addTransactionRequest.Comment, + CreatedIp: clientIp, + } + + if addTransactionRequest.Type == transactionTypeTransfer { + transaction.RelatedAccountId = destinationAccountId + + destinationAmount, err := utils.ParseAmount(addTransactionRequest.DestinationAmount) + + if err != nil { + return nil + } + + transaction.RelatedAccountAmount = destinationAmount + } + + return transaction +} + +func (h *mcpAddTransactionToolHandler) createNewMCPAddTransactionResponse(c *core.WebContext, transaction *models.Transaction, accountsMap map[int64]*models.Account, dryRun bool) (any, []*MCPTextContent, error) { + var sourceAccountInfo *models.AccountInfoResponse + var destinationAccountInfo *models.AccountInfoResponse + + if sourceAccount, exists := accountsMap[transaction.AccountId]; exists { + sourceAccountInfo = sourceAccount.ToAccountInfoResponse() + } + + if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT { + if destinationAccount, exists := accountsMap[transaction.RelatedAccountId]; exists { + destinationAccountInfo = destinationAccount.ToAccountInfoResponse() + } + } + + response := MCPAddTransactionResponse{ + Success: true, + DryRun: dryRun, + } + + if sourceAccountInfo != nil { + if sourceAccountInfo.IsAsset { + response.AccountBalance = utils.FormatAmount(sourceAccountInfo.Balance) + } else if sourceAccountInfo.IsLiability { + response.AccountBalance = utils.FormatAmount(-sourceAccountInfo.Balance) + } + } + + if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT && destinationAccountInfo != nil { + if destinationAccountInfo.IsAsset { + response.DestinationAccountBalance = utils.FormatAmount(destinationAccountInfo.Balance) + } else if destinationAccountInfo.IsLiability { + response.DestinationAccountBalance = utils.FormatAmount(-destinationAccountInfo.Balance) + } + } + + content, err := json.Marshal(response) + + if err != nil { + return nil, nil, err + } + + return response, []*MCPTextContent{ + NewMCPTextContent(string(content)), + }, nil +} diff --git a/pkg/mcp/mcp_container.go b/pkg/mcp/mcp_container.go index 7aed16c5..11389ccc 100644 --- a/pkg/mcp/mcp_container.go +++ b/pkg/mcp/mcp_container.go @@ -69,6 +69,8 @@ func InitializeMCPHandlers(config *settings.Config) error { mcpTools: make([]*MCPTool, 0), } + registerMCPTextContentToolHandler(container, MCPAddTransactionToolHandler) + registerMCPTextContentToolHandler(container, MCPQueryTransactionsToolHandler) registerMCPTextContentToolHandler(container, MCPQueryAllAccountsToolHandler) registerMCPTextContentToolHandler(container, MCPQueryAllTransactionCategoriesToolHandler) registerMCPTextContentToolHandler(container, MCPQueryAllTransactionTagsToolHandler) diff --git a/pkg/mcp/query_transactions.go b/pkg/mcp/query_transactions.go new file mode 100644 index 00000000..7959e7f3 --- /dev/null +++ b/pkg/mcp/query_transactions.go @@ -0,0 +1,261 @@ +package mcp + +import ( + "encoding/json" + "reflect" + "strings" + "time" + + "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/settings" + "github.com/mayswind/ezbookkeeping/pkg/utils" +) + +// MCPQueryTransactionsRequest represents all parameters of the query transactions request +type MCPQueryTransactionsRequest struct { + StartTime string `json:"start_time" jsonschema:"format=date-time" jsonschema_description:"Start time for the query in RFC 3339 format (e.g. 2023-01-01T12:00:00Z)"` + EndTime string `json:"end_time" jsonschema:"format=date-time" jsonschema_description:"End time for the query in RFC 3339 format or (e.g. 2023-01-01T12:00:00Z)"` + Type string `json:"type,omitempty" jsonschema:"enum=income,enum=expense,enum=transfer" jsonschema_description:"Transaction type to filter by (income, expense, transfer) (optional)"` + SecondaryCategoryName string `json:"category_name,omitempty" jsonschema_description:"Secondary category name to filter transactions by (optional)"` + AccountName string `json:"account_name,omitempty" jsonschema_description:"Account name to filter transactions by (optional)"` + Keyword string `json:"keyword,omitempty" jsonschema_description:"Keyword to search in transaction description (optional)"` + Count int32 `json:"count,omitempty" jsonschema:"default=100" jsonschema_description:"Maximum number of results to return (default: 100)"` + Page int32 `json:"page,omitempty" jsonschema:"default=1" jsonschema_description:"Page number for pagination (default: 1)"` + ResponseFields string `json:"response_fields,omitempty" jsonschema_description:"Comma-separated list of fields to include in the response (optional, leave empty for all fields, available fields: time, currency, category_name, account_name, comment)"` +} + +// MCPQueryTransactionsResponse represents the response structure for querying transactions +type MCPQueryTransactionsResponse struct { + TotalCount int64 `json:"total_count" jsonschema_description:"Total number of transactions matching the query"` + CurrentPage int32 `json:"current_page" jsonschema_description:"Current page number of the results"` + TotalPage int32 `json:"total_page" jsonschema_description:"Total number of pages available for the query, calculated based on total_count and count"` + Transactions []*MCPTransactionInfo `json:"transactions" jsonschema_description:"List of transactions matching the query"` +} + +// MCPTransactionInfo defines the structure of transaction information +type MCPTransactionInfo struct { + Time string `json:"time,omitempty" jsonschema_description:"Time of the transaction in RFC 3339 format (e.g. 2023-01-01T12:00:00Z)"` + Type string `json:"type" jsonschema:"enum=income,enum=expense,enum=transfer" jsonschema_description:"Transaction type (income, expense, transfer)"` + Amount string `json:"amount" jsonschema_description:"Amount of the transaction in the specified currency"` + Currency string `json:"currency,omitempty" jsonschema_description:"Currency code of the transaction (e.g. USD, EUR)"` + SecondaryCategoryName string `json:"category_name,omitempty" jsonschema_description:"Secondary category name for the transaction"` + AccountName string `json:"account_name,omitempty" jsonschema_description:"Account name for the transaction"` + DestinationAmount string `json:"destination_amount,omitempty" jsonschema_description:"Destination amount for transfer transactions (optional)"` + DestinationCurrency string `json:"destination_currency,omitempty" jsonschema_description:"Currency code of the destination amount for transfer transactions (optional)"` + DestinationAccountName string `json:"destination_account_name,omitempty" jsonschema_description:"Destination account name for transfer transactions (optional)"` + Comment string `json:"comment,omitempty" jsonschema_description:"Description of the transaction"` +} + +type mcpQueryTransactionsToolHandler struct{} + +var MCPQueryTransactionsToolHandler = &mcpQueryTransactionsToolHandler{} + +// Name returns the name of the MCP tool +func (h *mcpQueryTransactionsToolHandler) Name() string { + return "query_transactions" +} + +// Description returns the description of the MCP tool +func (h *mcpQueryTransactionsToolHandler) Description() string { + return "Query transactions based on various filters." +} + +// InputType returns the input type for the MCP tool request +func (h *mcpQueryTransactionsToolHandler) InputType() reflect.Type { + return reflect.TypeOf(&MCPQueryTransactionsRequest{}) +} + +// OutputType returns the output type for the MCP tool response +func (h *mcpQueryTransactionsToolHandler) OutputType() reflect.Type { + return reflect.TypeOf(&MCPQueryTransactionsResponse{}) +} + +// Handle processes the MCP call tool request and returns the response +func (h *mcpQueryTransactionsToolHandler) Handle(c *core.WebContext, callToolReq *MCPCallToolRequest, currentConfig *settings.Config, services MCPAvailableServices) (any, []*MCPTextContent, *errs.Error) { + var queryTransactionsRequest MCPQueryTransactionsRequest + + if callToolReq.Arguments != nil { + if err := json.Unmarshal(callToolReq.Arguments, &queryTransactionsRequest); err != nil { + return nil, nil, errs.NewIncompleteOrIncorrectSubmissionError(err) + } + } else { + return nil, nil, errs.ErrIncompleteOrIncorrectSubmission + } + + uid := c.GetCurrentUid() + maxTime, err := utils.ParseFromLongDateTimeWithTimezoneRFC3339Format(queryTransactionsRequest.EndTime) + + if err != nil { + return nil, nil, errs.ErrIncompleteOrIncorrectSubmission + } + + minTime, err := utils.ParseFromLongDateTimeWithTimezoneRFC3339Format(queryTransactionsRequest.StartTime) + + if err != nil { + return nil, nil, errs.ErrIncompleteOrIncorrectSubmission + } + + maxTransactionTime := utils.GetMaxTransactionTimeFromUnixTime(maxTime.Unix()) + minTransactionTime := utils.GetMinTransactionTimeFromUnixTime(minTime.Unix()) + + if queryTransactionsRequest.Count <= 0 { + queryTransactionsRequest.Count = 100 + } + + if queryTransactionsRequest.Page <= 0 { + queryTransactionsRequest.Page = 1 + } + + transactionType := models.TransactionType(byte(0)) + + if queryTransactionsRequest.Type == transactionTypeExpense { + transactionType = models.TRANSACTION_TYPE_EXPENSE + } else if queryTransactionsRequest.Type == transactionTypeIncome { + transactionType = models.TRANSACTION_TYPE_INCOME + } else if queryTransactionsRequest.Type == transactionTypeTransfer { + transactionType = models.TRANSACTION_TYPE_TRANSFER + } + + allAccounts, err := services.GetAccountService().GetAllAccountsByUid(c, uid) + + if err != nil { + log.Warnf(c, "[add_transaction.Handle] get account error, because %s", err.Error()) + return nil, nil, errs.Or(err, errs.ErrOperationFailed) + } + + accountsMap := services.GetAccountService().GetVisibleAccountNameMapByList(allAccounts) + filterAccountIds := make([]int64, 0) + + if queryTransactionsRequest.AccountName != "" { + if account, exists := accountsMap[queryTransactionsRequest.AccountName]; exists { + filterAccountIds = append(filterAccountIds, account.AccountId) + } else { + return nil, nil, errs.ErrAccountNotFound + } + } + + allCategories, err := services.GetTransactionCategoryService().GetAllCategoriesByUid(c, uid, 0, -1) + + if err != nil { + log.Warnf(c, "[add_transaction.Handle] get transaction category error, because %s", err.Error()) + return nil, nil, errs.Or(err, errs.ErrOperationFailed) + } + + categoriesMap := services.GetTransactionCategoryService().GetVisibleCategoryNameMapByList(allCategories) + filterCategoryIds := make([]int64, 0) + + if queryTransactionsRequest.SecondaryCategoryName != "" { + if category, exists := categoriesMap[queryTransactionsRequest.SecondaryCategoryName]; exists { + filterCategoryIds = append(filterCategoryIds, category.CategoryId) + } else { + return nil, nil, errs.ErrTransactionCategoryNotFound + } + } + + totalCount, err := services.GetTransactionService().GetTransactionCount(c, uid, maxTransactionTime, minTransactionTime, transactionType, filterCategoryIds, filterAccountIds, nil, false, models.TRANSACTION_TAG_FILTER_HAS_ANY, "", queryTransactionsRequest.Keyword) + + if err != nil { + log.Errorf(c, "[transactions.TransactionListHandler] failed to get transaction count for user \"uid:%d\", because %s", uid, err.Error()) + return nil, nil, errs.Or(err, errs.ErrOperationFailed) + } + + transactions, err := services.GetTransactionService().GetTransactionsByMaxTime(c, uid, maxTransactionTime, minTransactionTime, transactionType, filterCategoryIds, filterAccountIds, nil, false, models.TRANSACTION_TAG_FILTER_HAS_ANY, "", queryTransactionsRequest.Keyword, queryTransactionsRequest.Page, queryTransactionsRequest.Count, false, true) + structuredResponse, response, err := h.createNewMCPQueryTransactionsResponse(c, &queryTransactionsRequest, transactions, totalCount, services.GetAccountService().GetAccountMapByList(allAccounts), services.GetTransactionCategoryService().GetCategoryMapByList(allCategories)) + + if err != nil { + return nil, nil, errs.Or(err, errs.ErrOperationFailed) + } + + return structuredResponse, response, nil +} + +func (h *mcpQueryTransactionsToolHandler) createNewMCPQueryTransactionsResponse(c *core.WebContext, queryTransactionsRequest *MCPQueryTransactionsRequest, transactions []*models.Transaction, totalCount int64, accountsMap map[int64]*models.Account, categoriesMap map[int64]*models.TransactionCategory) (any, []*MCPTextContent, error) { + response := MCPQueryTransactionsResponse{ + TotalCount: totalCount, + CurrentPage: queryTransactionsRequest.Page, + TotalPage: int32((totalCount + int64(queryTransactionsRequest.Count) - 1) / int64(queryTransactionsRequest.Count)), + Transactions: make([]*MCPTransactionInfo, 0, len(transactions)), + } + + filteredFields := make(map[string]bool) + + if queryTransactionsRequest.ResponseFields != "" { + for _, field := range strings.Split(queryTransactionsRequest.ResponseFields, ",") { + filteredFields[field] = true + } + } + + for i := 0; i < len(transactions); i++ { + transaction := transactions[i] + transactionInfo := MCPTransactionInfo{ + Amount: utils.FormatAmount(transaction.Amount), + } + + if transaction.Type == models.TRANSACTION_DB_TYPE_EXPENSE { + transactionInfo.Type = transactionTypeExpense + } else if transaction.Type == models.TRANSACTION_DB_TYPE_INCOME { + transactionInfo.Type = transactionTypeIncome + } else if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT { + transactionInfo.Type = transactionTypeTransfer + } + + if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT { + transactionInfo.DestinationAmount = utils.FormatAmount(transaction.RelatedAccountAmount) + } + + if _, exists := filteredFields["time"]; exists || len(filteredFields) == 0 { + transactionUnixTime := utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime) + transactionTimeZone := time.FixedZone("Transaction Timezone", int(transaction.TimezoneUtcOffset)*60) + transactionInfo.Time = utils.FormatUnixTimeToLongDateTimeWithTimezoneRFC3339Format(transactionUnixTime, transactionTimeZone) + } + + if _, exists := filteredFields["currency"]; exists || len(filteredFields) == 0 { + if account, exists := accountsMap[transaction.AccountId]; exists && account != nil { + transactionInfo.Currency = account.Currency + } + + if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT && transaction.RelatedAccountId > 0 { + if destinationAccount, exists := accountsMap[transaction.RelatedAccountId]; exists && destinationAccount != nil { + transactionInfo.DestinationCurrency = destinationAccount.Currency + } + } + } + + if _, exists := filteredFields["category_name"]; exists || len(filteredFields) == 0 { + if category, exists := categoriesMap[transaction.CategoryId]; exists && category != nil { + transactionInfo.SecondaryCategoryName = category.Name + } + } + + if _, exists := filteredFields["account_name"]; exists || len(filteredFields) == 0 { + if account, exists := accountsMap[transaction.AccountId]; exists && account != nil { + transactionInfo.AccountName = account.Name + } + + if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT && transaction.RelatedAccountId > 0 { + if destinationAccount, exists := accountsMap[transaction.RelatedAccountId]; exists && destinationAccount != nil { + transactionInfo.DestinationAccountName = destinationAccount.Name + } + } + } + + if _, exists := filteredFields["comment"]; exists || len(filteredFields) == 0 { + transactionInfo.Comment = transaction.Comment + } + + response.Transactions = append(response.Transactions, &transactionInfo) + } + + content, err := json.Marshal(response) + + if err != nil { + return nil, nil, err + } + + return response, []*MCPTextContent{ + NewMCPTextContent(string(content)), + }, nil +} diff --git a/pkg/models/transaction.go b/pkg/models/transaction.go index c04ca894..9d925db3 100644 --- a/pkg/models/transaction.go +++ b/pkg/models/transaction.go @@ -8,6 +8,9 @@ import ( "github.com/mayswind/ezbookkeeping/pkg/utils" ) +const MaximumTagsCountOfTransaction = 10 +const MaximumPicturesCountOfTransaction = 10 + // TransactionType represents transaction type type TransactionType byte diff --git a/pkg/services/transaction_categories.go b/pkg/services/transaction_categories.go index b1bc3074..9bf8f403 100644 --- a/pkg/services/transaction_categories.go +++ b/pkg/services/transaction_categories.go @@ -514,6 +514,23 @@ func (s *TransactionCategoryService) GetVisibleSubCategoryNameMapByList(categori return expenseCategoryMap, incomeCategoryMap, transferCategoryMap } +// GetVisibleCategoryNameMapByList returns visible transaction category map by a list +func (s *TransactionCategoryService) GetVisibleCategoryNameMapByList(categories []*models.TransactionCategory) map[string]*models.TransactionCategory { + categoryMap := make(map[string]*models.TransactionCategory) + + for i := 0; i < len(categories); i++ { + category := categories[i] + + if category.Hidden { + continue + } + + categoryMap[category.Name] = category + } + + return categoryMap +} + // GetCategoryNames returns a list with transaction category names from transaction category models list func (s *TransactionCategoryService) GetCategoryNames(categories []*models.TransactionCategory) []string { categoryNames := make([]string, len(categories))