Skip to content
Merged
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
45 changes: 45 additions & 0 deletions docs/bug-fixes.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,51 @@ Each bug fix entry should include:

## Bug Fixes

### 2026-07-30: Pending purchase categorization disappeared when transactions posted

**Description:**
Amazon purchases showed the AI-selected item notes but remained in `[TEMP] Amazon`, and multi-category purchases lost their splits. SQLite showed that Monarch had accepted the original category and split mutations successfully.

**Test Case:**
```go
// internal/application/sync/reconciliation_test.go:
// TestRecordSuccessWithResult_PendingTransactionIsProvisional
// TestProcessOrder_ReconcilesRedirectedSingleCategoryFromCachedRecord
// TestProcessOrder_ReconcilesRedirectedSplitsFromCachedRecord
// TestProcessOrder_LegacySuccessStillPendingBecomesProvisional
// TestProcessOrder_FinalizesProvisionalWhenPostedStateAlreadyMatches
// TestProcessOrder_UsesUniquePostedFallbackWhenRedirectIsMissing
// TestProcessOrder_AmbiguousPostedFallbackMakesNoWrite
// TestProcessOrder_DoesNotOverwriteSameIDFinalManualCategory
// TestProcessOrder_RepairsSameIDTemporaryCategoryButNotOtherCategories
// TestProcessOrder_ReconciliationDryRunMakesNoWrites

// internal/application/sync/handlers/amazon_test.go:
// TestAmazonHandler_ProcessOrder_DoesNotConsolidatePendingMultiChargeTransactions

// internal/application/sync/handlers/walmart_test.go:
// TestWalmartHandler_ProcessOrder_MultiDelivery_DoesNotConsolidatePendingTransactions

// internal/infrastructure/storage/storage_test.go:
// TestStorage_SaveRecord_AllowsSuccessToBecomeProvisional
```

**Root Cause:**
Itemize treated pending Monarch rows as durable transaction identities. When the bank feed posted an Amazon charge, Monarch replaced its pending ID with a new posted ID. Notes sometimes migrated, but categories and splits did not. Order-level dedup then skipped the Amazon order forever because SQLite already contained `status='success'`. The update mutation audit was also misleading: that GraphQL selection does not request `pending`, so the decoded Go transaction serialized its zero value `false` even though the transaction-list snapshot from the same run recorded `pending=true`.

**Fix Applied:**
Pending single-charge mutations are now cached as `provisional`, not final successes. Later syncs first use the current transaction-list snapshot and then Monarch's `redirectPosted` lookup to resolve the stored pending ID to its posted replacement. Itemize compares the posted state with the exact category, notes, or splits saved in SQLite, reapplies only missing state without another LLM call, records the replacement ID, and promotes the order to `success`. A missing redirect falls back only to a unique posted amount/date match, with an exact cached-note match as a disambiguator; ambiguous candidates remain untouched. Final same-ID transactions with a non-temporary category are treated as possible manual edits and are not overwritten. Legacy successes that are still pending are converted to provisional records. Multi-charge Amazon and Walmart rows are never consolidated while any source transaction is pending.

**Verification:**
- The pending-to-posted regression and storage-transition tests failed before their fixes and pass afterward.
- Focused application, handler, and SQLite storage suites pass.
- `go test ./...` passes.
- `go test ./... -race` passes.
- `go build -o /tmp/itemize-reconciliation-check ./cmd/itemize/` passes.
- A live `-account wife -dry-run -order-id 112-6349422-6177822 -verbose` resolved pending transaction `250478322775591224` to posted replacement `250663215190083762`, detected that the cached `Auto Maintenance` state needed reapplication, and completed with `Processed=1 Skipped=0 Errors=0` without writing to Monarch or repeating categorization.

---

### 2026-07-25: Amazon sessions appeared to expire after every sync day

**Description:**
Expand Down
14 changes: 14 additions & 0 deletions internal/application/sync/handlers/amazon.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,20 @@ func (h *AmazonHandler) ProcessOrder(
}
}

// Never consolidate multiple pending bank-feed rows. Pending transactions
// can be replaced when they post, so deleting or merging them would create
// unstable transaction history that cannot be reconciled by a single stored
// transaction ID. Single pending charges remain safe to categorize
// provisionally and are reconciled by the orchestrator after posting.
if len(matchedTxns) > 1 && hasPendingTransaction(matchedTxns) {
result.Skipped = true
result.SkipReason = "payment pending"
h.logInfo("Waiting for all Amazon charges to post before consolidation",
"order_id", order.GetID(),
"transaction_count", len(matchedTxns))
return result, nil
}

// Step 5: Consolidate multi-transaction matches
if consolidatedTxn == nil {
if len(matchedTxns) > 1 {
Expand Down
45 changes: 45 additions & 0 deletions internal/application/sync/handlers/amazon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,51 @@ func TestAmazonHandler_ProcessOrder_MissingTransactions(t *testing.T) {
assert.Contains(t, result.SkipReason, "could not find all transactions")
}

func TestAmazonHandler_ProcessOrder_DoesNotConsolidatePendingMultiChargeTransactions(t *testing.T) {
orderDate := time.Now()
order := &mockAmazonOrder{
id: "test-pending-multi-charge",
date: orderDate,
total: 50.00,
items: []providers.OrderItem{&mockItem{name: "Item", price: 50.00}},
bankCharges: []float64{30.00, 20.00},
nonBankAmount: 0,
}
monarchTxns := []*monarch.Transaction{
{ID: "pending-1", Amount: -30.00, Date: toMonarchDate(orderDate), Pending: true},
{ID: "pending-2", Amount: -20.00, Date: toMonarchDate(orderDate), Pending: true},
}
splitter := &mockSplitter{
categoryID: "household",
notes: "Household Supplies:\n- Item $50.00",
}
monarchClient := &mockMonarch{}
handler := NewAmazonHandler(
matcher.NewMatcher(matcher.Config{AmountTolerance: 0.01, DateTolerance: 5}),
&mockConsolidator{},
splitter,
monarchClient,
nil,
)

result, err := handler.ProcessOrder(
context.Background(),
order,
monarchTxns,
make(map[string]bool),
nil,
nil,
false,
)

require.NoError(t, err)
assert.True(t, result.Skipped)
assert.Equal(t, "payment pending", result.SkipReason)
assert.Nil(t, splitter.lastOrder, "pending feed rows must not be consolidated or categorized")
assert.False(t, monarchClient.updateCalled)
assert.False(t, monarchClient.updateSplitsCalled)
}

func TestAmazonHandler_ProcessOrder_DryRun(t *testing.T) {
order := &mockAmazonOrder{
id: "test-dry-run",
Expand Down
23 changes: 23 additions & 0 deletions internal/application/sync/handlers/walmart.go
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,14 @@ func (h *WalmartHandler) processMultiDeliveryOrder(
matchedTxns = append(matchedTxns, match.Transaction)
usedTxnIDs[match.Transaction.ID] = true
}
if hasPendingTransaction(matchedTxns) {
result.Skipped = true
result.SkipReason = "payment pending"
h.logInfo("Waiting for all Walmart charges to post before consolidation",
"order_id", order.GetID(),
"transaction_count", len(matchedTxns))
return result, nil
}

h.logInfo("Matched all transactions for multi-delivery order",
"order_id", order.GetID(),
Expand Down Expand Up @@ -429,6 +437,9 @@ func (h *WalmartHandler) processMultiDeliveryAggregateFallback(
if len(matchedTxns) == 1 {
recoveryTxns := interruptedConsolidationTransactions(matchedTxns[0], partialMatches, charges, ledgerTotal)
if len(recoveryTxns) > 1 {
if hasPendingTransaction(recoveryTxns) {
return &ProcessResult{Skipped: true, SkipReason: "payment pending"}, nil
}
if h.consolidator == nil {
return nil, fmt.Errorf("interrupted consolidation found %d undeleted transactions but consolidator is not configured", len(recoveryTxns)-1)
}
Expand Down Expand Up @@ -457,6 +468,9 @@ func (h *WalmartHandler) processMultiDeliveryAggregateFallback(
if h.consolidator == nil {
return nil, fmt.Errorf("aggregate match found %d transactions but consolidator is not configured", len(matchedTxns))
}
if hasPendingTransaction(matchedTxns) {
return &ProcessResult{Skipped: true, SkipReason: "payment pending"}, nil
}

h.logInfo("Matched multi-delivery order by aggregate transaction subset",
"order_id", order.GetID(),
Expand Down Expand Up @@ -523,6 +537,15 @@ func sumCharges(charges []float64) float64 {
return math.Round(total*100) / 100
}

func hasPendingTransaction(transactions []*monarch.Transaction) bool {
for _, transaction := range transactions {
if transaction != nil && transaction.Pending {
return true
}
}
return false
}

func (h *WalmartHandler) processRefundOnlyOrder(ctx context.Context, order WalmartOrder, monarchTxns []*monarch.Transaction, usedTxnIDs map[string]bool, catCategories []categorizer.Category, monarchCategories []*monarch.TransactionCategory, refundCharges []float64, refundItems []providers.OrderItem, dryRun bool) (*ProcessResult, error) {
result := &ProcessResult{}
if len(refundItems) == 0 {
Expand Down
44 changes: 44 additions & 0 deletions internal/application/sync/handlers/walmart_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,50 @@ func TestWalmartHandler_ProcessOrder_MultiDelivery_Success(t *testing.T) {
assert.True(t, usedTxnIDs["txn-2"])
}

func TestWalmartHandler_ProcessOrder_MultiDelivery_DoesNotConsolidatePendingTransactions(t *testing.T) {
orderDate := time.Now()
order := &walmartTestOrder{
id: "ORDER-MULTI-PENDING",
date: orderDate,
total: 100.00,
subtotal: 95.00,
tax: 5.00,
items: []providers.OrderItem{&walmartTestItem{name: "Item", price: 95.00, quantity: 1}},
charges: []float64{60.00, 40.00},
isMultiDeliver: true,
}
txns := []*monarch.Transaction{
{ID: "pending-1", Amount: -60.00, Date: walmartToMonarchDate(orderDate), Pending: true},
{ID: "pending-2", Amount: -40.00, Date: walmartToMonarchDate(orderDate), Pending: true},
}
splitter := &walmartTestSplitter{categoryID: "groceries", notes: "Groceries:\n- Item $100.00"}
consolidator := &walmartTestConsolidator{
result: &ConsolidationResult{
ConsolidatedTransaction: &monarch.Transaction{ID: "consolidated", Amount: -100},
},
}
monarchClient := &walmartTestMonarch{}
handler := createTestWalmartHandler(t, splitter, consolidator, monarchClient)

result, err := handler.ProcessOrder(
context.Background(),
order,
txns,
make(map[string]bool),
nil,
nil,
false,
)

require.NoError(t, err)
assert.True(t, result.Skipped)
assert.Equal(t, "payment pending", result.SkipReason)
assert.Empty(t, consolidator.receivedTransactions)
assert.Empty(t, splitter.calls)
assert.False(t, monarchClient.updateCalled)
assert.False(t, monarchClient.updateSplitsCaled)
}

func TestWalmartHandler_ProcessOrder_MultiDelivery_FallsBackToAggregateTransaction(t *testing.T) {
splitter := &walmartTestSplitter{categoryID: "groceries", notes: "Groceries"}
monarchClient := &walmartTestMonarch{}
Expand Down
29 changes: 25 additions & 4 deletions internal/application/sync/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,31 @@ func (o *Orchestrator) processOrder(
"item_count", len(order.GetItems()),
)

// Check if already processed
if !opts.Force && o.storage != nil && o.storage.IsProcessed(order.GetID()) {
o.logger.Debug("Skipping already processed order", "order_id", order.GetID())
return false, true, nil
// A pending transaction may be replaced by a different posted transaction
// ID. Resolve and verify the stored Monarch state before applying the
// order-level deduplication guard.
if !opts.Force && o.storage != nil {
record, err := o.storage.GetRecord(order.GetID())
if err != nil {
return false, false, fmt.Errorf("load processing record: %w", err)
}
if record != nil {
handled, processed, skipped, reconcileErr := o.reconcileStoredOrder(
ctx,
order,
record,
providerTransactions,
usedTransactionIDs,
opts.DryRun,
)
if handled {
return processed, skipped, reconcileErr
}
}
if o.storage.IsProcessed(order.GetID()) {
o.logger.Debug("Skipping already processed order", "order_id", order.GetID())
return false, true, nil
}
}

// Use Amazon handler for Amazon orders (uses pro-rata allocation)
Expand Down
Loading