Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail

exec scripts/pre-commit.sh
65 changes: 60 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Variables
BINARY_NAME=itemize
GO_FILES=$(shell find . -name '*.go' -type f -not -path "./vendor/*")
GO_FILES=$(shell git ls-files '*.go')
STAGED_GO_FILES=$(shell git diff --cached --name-only --diff-filter=ACMR -- '*.go')
COVERAGE_FILE=coverage.out
COVERAGE_HTML=coverage.html

Expand All @@ -19,7 +20,7 @@ GREEN=\033[0;32m
YELLOW=\033[1;33m
NC=\033[0m # No Color

.PHONY: all build clean test coverage fmt vet deps help install-tools pre-commit release version run-costco
.PHONY: all build clean test coverage fmt check-fmt check-fmt-staged lint vet deps help install-tools install-hooks pre-commit pre-commit-local release version run-costco

## help: Display this help message
help:
Expand Down Expand Up @@ -74,6 +75,45 @@ fmt:
fi
@echo "$(GREEN)Formatting complete!$(NC)"

## check-fmt: Verify Go files are gofmt-formatted
check-fmt:
@echo "$(GREEN)Checking Go formatting...$(NC)"
@unformatted="$$(gofmt -l $(GO_FILES))"; \
if [ -n "$$unformatted" ]; then \
echo "$(RED)Go files need formatting:$(NC)"; \
echo "$$unformatted"; \
echo "Run: make fmt"; \
exit 1; \
fi
@echo "$(GREEN)Formatting check passed!$(NC)"

## check-fmt-staged: Verify staged Go files are gofmt-formatted
check-fmt-staged:
@echo "$(GREEN)Checking staged Go formatting...$(NC)"
@if [ -n "$(STAGED_GO_FILES)" ]; then \
unformatted="$$(gofmt -l $(STAGED_GO_FILES))"; \
if [ -n "$$unformatted" ]; then \
echo "$(RED)Staged Go files need formatting:$(NC)"; \
echo "$$unformatted"; \
echo "Run: gofmt -w $$unformatted"; \
exit 1; \
fi; \
else \
echo "$(YELLOW)No staged Go files to check.$(NC)"; \
fi
@echo "$(GREEN)Staged formatting check passed!$(NC)"

## lint: Run golangci-lint using the repo config
lint:
@echo "$(GREEN)Running golangci-lint...$(NC)"
@if ! command -v golangci-lint >/dev/null 2>&1; then \
echo "$(RED)golangci-lint is not installed.$(NC)"; \
echo "Install v2.4.0+ or run: brew install golangci-lint"; \
exit 1; \
fi
golangci-lint run --timeout=5m
@echo "$(GREEN)Lint complete!$(NC)"

## vet: Run go vet
vet:
@echo "$(GREEN)Running go vet...$(NC)"
Expand All @@ -88,8 +128,14 @@ deps:
$(GOMOD) tidy
@echo "$(GREEN)Dependencies updated!$(NC)"

## pre-commit: Run pre-commit checks (fmt, vet, test)
pre-commit: fmt vet test
## pre-commit-local: Run local pre-commit checks without modifying files
pre-commit-local: check-fmt-staged lint vet
@echo "$(GREEN)Running tests...$(NC)"
$(GOTEST) ./...
@echo "$(GREEN)Pre-commit checks passed!$(NC)"

## pre-commit: Run full pre-commit checks (format, lint, vet, race tests)
pre-commit: fmt lint vet test
@echo "$(GREEN)Pre-commit checks passed!$(NC)"

## release: Build release binaries for multiple platforms
Expand All @@ -116,11 +162,20 @@ run-costco:
@echo "$(GREEN)Running Costco sync (dry-run)...$(NC)"
$(GOCMD) run ./cmd/itemize costco -dry-run -verbose

## install-tools: Install development tools (goimports)
## install-hooks: Install versioned git hooks for this checkout
install-hooks:
@echo "$(GREEN)Installing git hooks...$(NC)"
@git config core.hooksPath .githooks
@chmod +x .githooks/pre-commit scripts/pre-commit.sh
@echo "$(GREEN)Git hooks installed. Use ITEMIZE_SKIP_PRECOMMIT=1 to bypass once.$(NC)"

## install-tools: Install development tools (goimports, golangci-lint)
install-tools:
@echo "$(GREEN)Installing development tools...$(NC)"
@echo "Installing goimports..."
@go install golang.org/x/tools/cmd/goimports@latest
@echo "Installing golangci-lint..."
@go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.4.0
@echo "$(GREEN)Tools installation complete!$(NC)"

# Default target
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ cd itemize
go build -o itemize ./cmd/itemize/
```

#### Contributor checks

Install local tools and hooks before committing:

```bash
make install-tools
make install-hooks
```

The pre-commit hook runs formatting checks, `golangci-lint`, `go vet`, and `go test ./...`.
To bypass once in an emergency, run `ITEMIZE_SKIP_PRECOMMIT=1 git commit ...`.

### Configure

Set environment variables:
Expand Down
8 changes: 8 additions & 0 deletions docs/bug-fixes.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ Added append-only `processing_attempts`, `order_transactions`, match diagnostics
- `go test ./internal/infrastructure/storage ./internal/application/sync/handlers ./internal/application/sync -run 'TestStorage_SaveRecord_DoesNotDowngradeSuccessWithFailure|TestStorage_SaveRecord_AppendsAttemptHistory|TestStorage_OrderTransactions_MultipleRowsPerOrder|TestSimpleHandler_ProcessOrder_ReconcilesAlreadySplitTransactions|TestMonarchAdapter_LogAPICallIncludesOrderAndTransaction' -count=1` passes.
- `go test ./...` passes.

**Follow-up Audit Hardening:**
Added provider fetch snapshots and pre-call Monarch mutation intent logs so future investigations can distinguish "we intended to write this" from "the write completed" and can inspect the provider/Monarch fetch payloads that led to a decision.

**Follow-up Verification:**
- `TestStorage_ProviderFetchLog_RoundTrip` verifies provider fetch logs are durable.
- `TestOrchestrator_fetchOrders_LogsProviderFetch` verifies provider order fetches are logged by the orchestrator.
- `TestMonarchAdapter_LogAPICallRecordsIntentAndCompletion` verifies mutation intent and completion are recorded separately.

---

### 2026-07-02: Dependabot PR tests failed because Codecov upload required a token
Expand Down
127 changes: 124 additions & 3 deletions internal/application/sync/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ package sync

import (
"context"
"encoding/json"
"fmt"
"strings"
"time"

"github.com/eshaffer321/monarch-go/v2/pkg/monarch"
"github.com/eshaffer321/itemize/internal/adapters/providers"
"github.com/eshaffer321/itemize/internal/domain/categorizer"
"github.com/eshaffer321/itemize/internal/infrastructure/storage"
"github.com/eshaffer321/monarch-go/v2/pkg/monarch"
)

// Data fetching functions for the sync orchestrator.
Expand All @@ -24,12 +26,24 @@ func (o *Orchestrator) fetchOrders(ctx context.Context, opts Options) ([]provide
"end_date", endDate.Format("2006-01-02"),
)

orders, err := o.provider.FetchOrders(ctx, providers.FetchOptions{
fetchOpts := providers.FetchOptions{
StartDate: startDate,
EndDate: endDate,
MaxOrders: opts.MaxOrders,
IncludeDetails: true,
})
}
started := time.Now()
orders, err := o.provider.FetchOrders(ctx, fetchOpts)
var response any
if o.storage != nil {
response = summarizeOrdersForFetchLog(orders)
}
o.logProviderFetch("orders", map[string]any{
"start_date": startDate.Format("2006-01-02"),
"end_date": endDate.Format("2006-01-02"),
"max_orders": opts.MaxOrders,
"include_details": true,
}, response, err, time.Since(started), len(orders), 0)
if err != nil {
return nil, fmt.Errorf("failed to fetch orders: %w", err)
}
Expand All @@ -46,11 +60,18 @@ func (o *Orchestrator) fetchMonarchTransactions(ctx context.Context, opts Option
endDate := time.Now()
startDate := endDate.AddDate(0, 0, -opts.LookbackDays)

request := map[string]any{
"start_date": startDate.AddDate(0, 0, -7).Format("2006-01-02"),
"end_date": endDate.Format("2006-01-02"),
"limit": 500,
}
started := time.Now()
txList, err := o.clients.Monarch.Transactions.Query().
Between(startDate.AddDate(0, 0, -7), endDate). // Add buffer for date matching
Limit(500).
Execute(ctx)
if err != nil {
o.logProviderFetch("monarch_transactions", request, nil, err, time.Since(started), 0, 0)
return nil, fmt.Errorf("failed to fetch transactions: %w", err)
}

Expand All @@ -71,6 +92,11 @@ func (o *Orchestrator) fetchMonarchTransactions(ctx context.Context, opts Option
"total", len(txList.Transactions),
"provider_transactions", len(providerTransactions),
)
o.logProviderFetch("monarch_transactions", request, map[string]any{
"total_count": len(txList.Transactions),
"provider_count": len(providerTransactions),
"provider_transactions": summarizeTransactionsForFetchLog(providerTransactions),
}, nil, time.Since(started), 0, len(providerTransactions))

return providerTransactions, nil
}
Expand All @@ -79,7 +105,11 @@ func (o *Orchestrator) fetchMonarchTransactions(ctx context.Context, opts Option
func (o *Orchestrator) fetchCategories(ctx context.Context) ([]categorizer.Category, []*monarch.TransactionCategory, error) {
o.logger.Debug("Loading Monarch categories")

started := time.Now()
categories, err := o.clients.Monarch.Transactions.Categories().List(ctx)
o.logProviderFetch("monarch_categories", map[string]any{}, map[string]any{
"category_count": len(categories),
}, err, time.Since(started), 0, 0)
if err != nil {
return nil, nil, fmt.Errorf("failed to load categories: %w", err)
}
Expand All @@ -97,3 +127,94 @@ func (o *Orchestrator) fetchCategories(ctx context.Context) ([]categorizer.Categ

return catCategories, categories, nil
}

type orderFetchSummary struct {
ID string `json:"id"`
Date string `json:"date"`
Total float64 `json:"total"`
Subtotal float64 `json:"subtotal"`
Tax float64 `json:"tax"`
Tip float64 `json:"tip"`
FeeTotal float64 `json:"fee_total"`
ItemCount int `json:"item_count"`
RawOrderJSON string `json:"raw_order_json,omitempty"`
}

type transactionFetchSummary struct {
ID string `json:"id"`
Date string `json:"date"`
Amount float64 `json:"amount"`
MerchantName string `json:"merchant_name,omitempty"`
PlaidName string `json:"plaid_name,omitempty"`
Pending bool `json:"pending"`
HasSplits bool `json:"has_splits"`
IsSplitTransaction bool `json:"is_split_transaction"`
}

func summarizeOrdersForFetchLog(orders []providers.Order) []orderFetchSummary {
summaries := make([]orderFetchSummary, 0, len(orders))
for _, order := range orders {
summary := orderFetchSummary{
ID: order.GetID(),
Date: order.GetDate().Format("2006-01-02"),
Total: order.GetTotal(),
Subtotal: order.GetSubtotal(),
Tax: order.GetTax(),
Tip: order.GetTip(),
FeeTotal: order.GetFees(),
ItemCount: len(order.GetItems()),
}
if raw := order.GetRawData(); raw != nil {
if data, err := json.Marshal(raw); err == nil {
summary.RawOrderJSON = string(data)
}
}
summaries = append(summaries, summary)
}
return summaries
}

func summarizeTransactionsForFetchLog(txns []*monarch.Transaction) []transactionFetchSummary {
summaries := make([]transactionFetchSummary, 0, len(txns))
for _, tx := range txns {
summary := transactionFetchSummary{
ID: tx.ID,
Date: tx.Date.Format("2006-01-02"),
Amount: tx.Amount,
PlaidName: tx.PlaidName,
Pending: tx.Pending,
HasSplits: tx.HasSplits,
IsSplitTransaction: tx.IsSplitTransaction,
}
if tx.Merchant != nil {
summary.MerchantName = tx.Merchant.Name
}
summaries = append(summaries, summary)
}
return summaries
}

func (o *Orchestrator) logProviderFetch(fetchType string, request, response any, fetchErr error, duration time.Duration, orderCount, transactionCount int) {
if o.storage == nil {
return
}
requestJSON, _ := json.Marshal(request)
responseJSON, _ := json.Marshal(response)
errText := ""
if fetchErr != nil {
errText = fetchErr.Error()
}
if err := o.storage.LogProviderFetch(&storage.ProviderFetchLog{
RunID: o.runID,
Provider: o.provider.DisplayName(),
FetchType: fetchType,
RequestJSON: string(requestJSON),
ResponseJSON: string(responseJSON),
Error: errText,
DurationMs: duration.Milliseconds(),
OrderCount: orderCount,
TransactionCount: transactionCount,
}); err != nil {
o.logger.Warn("Failed to log provider fetch", "fetch_type", fetchType, "error", err)
}
}
Loading