diff --git a/internal/graphql/queries/transactions/create.graphql b/internal/graphql/queries/transactions/create.graphql index bdec802..767f385 100644 --- a/internal/graphql/queries/transactions/create.graphql +++ b/internal/graphql/queries/transactions/create.graphql @@ -1,11 +1,19 @@ -mutation CreateTransaction($input: CreateTransactionMutationInput!) { +mutation Common_CreateTransactionMutation($input: CreateTransactionMutationInput!) { createTransaction(input: $input) { - transaction { - id - } errors { + fieldErrors { + field + messages + __typename + } message code + __typename + } + transaction { + id + __typename } + __typename } } diff --git a/pkg/monarch/transactions.go b/pkg/monarch/transactions.go index 0a37066..71829d6 100644 --- a/pkg/monarch/transactions.go +++ b/pkg/monarch/transactions.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "math" "strings" "time" @@ -59,22 +60,38 @@ func (s *transactionService) Get(ctx context.Context, transactionID string) (*Tr return result.GetTransaction, nil } -// Create creates a new transaction +// Create creates a new transaction. +// CategoryID is required by the Monarch API — use Transactions.Categories.List() +// to find a valid ID (e.g. the "Uncategorized" category). func (s *transactionService) Create(ctx context.Context, params *CreateTransactionParams) (*Transaction, error) { + if params.CategoryID == "" { + return nil, errors.New("CategoryID is required by the Monarch API") + } + query := s.client.loadQuery("transactions/create.graphql") + // Round amount to 2 decimal places as Monarch expects + roundedAmount := math.Round(params.Amount*100) / 100 + input := map[string]interface{}{ "date": params.Date.Format("2006-01-02"), "accountId": params.AccountID, - "amount": params.Amount, - "merchant": params.Merchant, + "amount": roundedAmount, "categoryId": params.CategoryID, } + if params.Merchant != nil && params.Merchant.Name != "" { + input["merchantName"] = params.Merchant.Name + } + if params.Notes != "" { input["notes"] = params.Notes } + if params.ShouldUpdateBalance != nil { + input["shouldUpdateBalance"] = *params.ShouldUpdateBalance + } + variables := map[string]interface{}{ "input": input, } @@ -85,8 +102,12 @@ func (s *transactionService) Create(ctx context.Context, params *CreateTransacti ID string `json:"id"` } `json:"transaction"` Errors []struct { - Message string `json:"message"` - Code string `json:"code"` + Message string `json:"message"` + Code string `json:"code"` + FieldErrors []struct { + Field string `json:"field"` + Messages []string `json:"messages"` + } `json:"fieldErrors"` } `json:"errors"` } `json:"createTransaction"` } @@ -96,9 +117,21 @@ func (s *transactionService) Create(ctx context.Context, params *CreateTransacti } if len(result.CreateTransaction.Errors) > 0 { + e := result.CreateTransaction.Errors[0] + msg := e.Message + if len(e.FieldErrors) > 0 { + msg += " (" + for i, fe := range e.FieldErrors { + if i > 0 { + msg += "; " + } + msg += fe.Field + ": " + strings.Join(fe.Messages, ", ") + } + msg += ")" + } return nil, &Error{ - Code: result.CreateTransaction.Errors[0].Code, - Message: result.CreateTransaction.Errors[0].Message, + Code: e.Code, + Message: msg, } } @@ -106,32 +139,10 @@ func (s *transactionService) Create(ctx context.Context, params *CreateTransacti return nil, errors.New("no transaction returned from creation") } - // Fetch the full transaction details - details, err := s.Get(ctx, result.CreateTransaction.Transaction.ID) - if err != nil { - return nil, err - } - - // Convert details to transaction return &Transaction{ - ID: details.ID, - Date: details.Date, - Amount: details.Amount, - Pending: details.Pending, - HideFromReports: details.HideFromReports, - PlaidName: details.PlaidName, - Merchant: details.Merchant, - Notes: details.Notes, - HasSplits: details.HasSplits, - IsSplitTransaction: details.IsSplitTransaction, - IsRecurring: details.IsRecurring, - NeedsReview: details.NeedsReview, - ReviewedAt: details.ReviewedAt, - CreatedAt: details.CreatedAt, - UpdatedAt: details.UpdatedAt, - Account: details.Account, - Category: details.Category, - Tags: details.Tags, + ID: result.CreateTransaction.Transaction.ID, + Date: params.Date, + Amount: roundedAmount, }, nil } diff --git a/pkg/monarch/transactions_test.go b/pkg/monarch/transactions_test.go index dcaba1a..9a80e90 100644 --- a/pkg/monarch/transactions_test.go +++ b/pkg/monarch/transactions_test.go @@ -2,6 +2,7 @@ package monarch import ( "context" + "errors" "testing" "time" @@ -294,24 +295,6 @@ func TestTransactionService_Create(t *testing.T) { } }` - // Mock get response (for fetching full details) - getResponse := `{ - "getTransaction": { - "id": "new-txn-456", - "amount": -100.00, - "date": "2024-01-20T00:00:00Z", - "merchant": { - "name": "New Store", - "id": "new-merch" - }, - "category": { - "id": "cat-new", - "name": "New Category" - }, - "notes": "New transaction" - } - }` - mockTransport.On("Execute", mock.Anything, mock.AnythingOfType("string"), @@ -320,19 +303,12 @@ func TestTransactionService_Create(t *testing.T) { if !ok { return false } - merchant, ok := input["merchant"].(*Merchant) - return ok && merchant.Name == "New Store" + _, hasMerchant := input["merchantName"] + return hasMerchant }), mock.Anything, ).Return(createResponse, nil).Once() - mockTransport.On("Execute", - mock.Anything, - mock.AnythingOfType("string"), - mock.Anything, - mock.Anything, - ).Return(getResponse, nil).Once() - // Execute ctx := context.Background() params := &CreateTransactionParams{ @@ -350,11 +326,198 @@ func TestTransactionService_Create(t *testing.T) { require.NoError(t, err) assert.Equal(t, "new-txn-456", txn.ID) assert.Equal(t, -100.00, txn.Amount) - assert.Equal(t, "New Store", txn.Merchant.Name) mockTransport.AssertExpectations(t) } +func TestTransactionService_Create_RoundsAmount(t *testing.T) { + mockTransport := new(MockTransport) + client := &Client{ + transport: mockTransport, + queryLoader: graphql.NewQueryLoader(), + options: &ClientOptions{}, + baseURL: "https://api.test.com", + } + service := newTransactionService(client) + + mockTransport.On("Execute", + mock.Anything, + mock.AnythingOfType("string"), + mock.MatchedBy(func(v map[string]interface{}) bool { + input, ok := v["input"].(map[string]interface{}) + if !ok { + return false + } + amount, ok := input["amount"].(float64) + return ok && amount == -99.99 // should be rounded from -99.9876 + }), + mock.Anything, + ).Return(`{"createTransaction":{"transaction":{"id":"txn-1"},"errors":[]}}`, nil).Once() + + txn, err := service.Create(context.Background(), &CreateTransactionParams{ + Date: Date{Time: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}, + AccountID: "acc-1", + Amount: -99.9876, + CategoryID: "cat-1", + }) + + require.NoError(t, err) + assert.Equal(t, -99.99, txn.Amount) + mockTransport.AssertExpectations(t) +} + +func TestTransactionService_Create_ShouldUpdateBalance(t *testing.T) { + mockTransport := new(MockTransport) + client := &Client{ + transport: mockTransport, + queryLoader: graphql.NewQueryLoader(), + options: &ClientOptions{}, + baseURL: "https://api.test.com", + } + service := newTransactionService(client) + + shouldUpdate := false + mockTransport.On("Execute", + mock.Anything, + mock.AnythingOfType("string"), + mock.MatchedBy(func(v map[string]interface{}) bool { + input, ok := v["input"].(map[string]interface{}) + if !ok { + return false + } + val, exists := input["shouldUpdateBalance"] + return exists && val == false + }), + mock.Anything, + ).Return(`{"createTransaction":{"transaction":{"id":"txn-2"},"errors":[]}}`, nil).Once() + + txn, err := service.Create(context.Background(), &CreateTransactionParams{ + Date: Date{Time: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}, + AccountID: "acc-1", + Amount: 50.0, + CategoryID: "cat-1", + ShouldUpdateBalance: &shouldUpdate, + }) + + require.NoError(t, err) + assert.Equal(t, "txn-2", txn.ID) + mockTransport.AssertExpectations(t) +} + +func TestTransactionService_Create_MissingCategoryID(t *testing.T) { + mockTransport := new(MockTransport) + client := &Client{ + transport: mockTransport, + queryLoader: graphql.NewQueryLoader(), + options: &ClientOptions{}, + baseURL: "https://api.test.com", + } + service := newTransactionService(client) + + _, err := service.Create(context.Background(), &CreateTransactionParams{ + Date: Date{Time: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}, + AccountID: "acc-1", + Amount: 10.0, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "CategoryID is required") +} + +func TestTransactionService_Create_FieldErrors(t *testing.T) { + mockTransport := new(MockTransport) + client := &Client{ + transport: mockTransport, + queryLoader: graphql.NewQueryLoader(), + options: &ClientOptions{}, + baseURL: "https://api.test.com", + } + service := newTransactionService(client) + + errorResp := `{ + "createTransaction": { + "transaction": null, + "errors": [{ + "message": "Validation failed", + "code": "INVALID_INPUT", + "fieldErrors": [ + {"field": "amount", "messages": ["must be non-zero"]}, + {"field": "date", "messages": ["is required", "must be valid"]} + ] + }] + } + }` + + mockTransport.On("Execute", + mock.Anything, mock.AnythingOfType("string"), + mock.Anything, mock.Anything, + ).Return(errorResp, nil).Once() + + _, err := service.Create(context.Background(), &CreateTransactionParams{ + Date: Date{Time: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}, + AccountID: "acc-1", + Amount: 0, + CategoryID: "cat-1", + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "Validation failed") + assert.Contains(t, err.Error(), "amount: must be non-zero") + assert.Contains(t, err.Error(), "date: is required, must be valid") +} + +func TestTransactionService_Create_NoTransactionReturned(t *testing.T) { + mockTransport := new(MockTransport) + client := &Client{ + transport: mockTransport, + queryLoader: graphql.NewQueryLoader(), + options: &ClientOptions{}, + baseURL: "https://api.test.com", + } + service := newTransactionService(client) + + mockTransport.On("Execute", + mock.Anything, mock.AnythingOfType("string"), + mock.Anything, mock.Anything, + ).Return(`{"createTransaction":{"transaction":null,"errors":[]}}`, nil).Once() + + _, err := service.Create(context.Background(), &CreateTransactionParams{ + Date: Date{Time: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}, + AccountID: "acc-1", + Amount: 10.0, + CategoryID: "cat-1", + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no transaction returned") +} + +func TestTransactionService_Create_GraphQLError(t *testing.T) { + mockTransport := new(MockTransport) + client := &Client{ + transport: mockTransport, + queryLoader: graphql.NewQueryLoader(), + options: &ClientOptions{}, + baseURL: "https://api.test.com", + } + service := newTransactionService(client) + + mockTransport.On("Execute", + mock.Anything, mock.AnythingOfType("string"), + mock.Anything, mock.Anything, + ).Return("", errors.New("graphql error")).Once() + + _, err := service.Create(context.Background(), &CreateTransactionParams{ + Date: Date{Time: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}, + AccountID: "acc-1", + Amount: 10.0, + CategoryID: "cat-1", + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create transaction") +} + func TestTransactionCategoryService_List(t *testing.T) { // Setup mockTransport := new(MockTransport) diff --git a/pkg/monarch/types.go b/pkg/monarch/types.go index b959668..f6a20a1 100644 --- a/pkg/monarch/types.go +++ b/pkg/monarch/types.go @@ -376,12 +376,13 @@ type UpdateAccountParams struct { // CreateTransactionParams for creating transactions type CreateTransactionParams struct { - Date Date `json:"date"` - AccountID string `json:"accountId"` - Amount float64 `json:"amount"` - Merchant *Merchant `json:"merchant"` - CategoryID string `json:"categoryId"` - Notes string `json:"notes"` + Date Date `json:"date"` + AccountID string `json:"accountId"` + Amount float64 `json:"amount"` + Merchant *Merchant `json:"merchant"` + CategoryID string `json:"categoryId"` + Notes string `json:"notes"` + ShouldUpdateBalance *bool `json:"shouldUpdateBalance,omitempty"` } // UpdateTransactionParams for updating transactions