From 44fb6c43b8fc9c968d2a525c86d7456f2749522d Mon Sep 17 00:00:00 2001 From: Erick Shaffer Date: Mon, 10 Aug 2026 20:20:13 -0600 Subject: [PATCH] fix: reconcile pending Monarch transactions --- docs/bug-fixes.md | 45 ++ internal/application/sync/handlers/amazon.go | 14 + .../application/sync/handlers/amazon_test.go | 45 ++ internal/application/sync/handlers/walmart.go | 23 + .../application/sync/handlers/walmart_test.go | 44 ++ internal/application/sync/orchestrator.go | 29 +- internal/application/sync/reconciliation.go | 339 ++++++++++ .../application/sync/reconciliation_test.go | 586 ++++++++++++++++++ internal/application/sync/recording.go | 6 + internal/application/sync/types.go | 46 +- internal/infrastructure/storage/mock.go | 6 +- internal/infrastructure/storage/sqlite.go | 2 +- .../infrastructure/storage/storage_test.go | 34 + 13 files changed, 1199 insertions(+), 20 deletions(-) create mode 100644 internal/application/sync/reconciliation.go create mode 100644 internal/application/sync/reconciliation_test.go diff --git a/docs/bug-fixes.md b/docs/bug-fixes.md index 52db539..98b9050 100644 --- a/docs/bug-fixes.md +++ b/docs/bug-fixes.md @@ -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:** diff --git a/internal/application/sync/handlers/amazon.go b/internal/application/sync/handlers/amazon.go index 35dbaaa..0a71508 100644 --- a/internal/application/sync/handlers/amazon.go +++ b/internal/application/sync/handlers/amazon.go @@ -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 { diff --git a/internal/application/sync/handlers/amazon_test.go b/internal/application/sync/handlers/amazon_test.go index b250973..ba9c3d4 100644 --- a/internal/application/sync/handlers/amazon_test.go +++ b/internal/application/sync/handlers/amazon_test.go @@ -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", diff --git a/internal/application/sync/handlers/walmart.go b/internal/application/sync/handlers/walmart.go index fc8fb3d..c17de35 100644 --- a/internal/application/sync/handlers/walmart.go +++ b/internal/application/sync/handlers/walmart.go @@ -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(), @@ -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) } @@ -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(), @@ -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 { diff --git a/internal/application/sync/handlers/walmart_test.go b/internal/application/sync/handlers/walmart_test.go index 9d2b33f..5f57eeb 100644 --- a/internal/application/sync/handlers/walmart_test.go +++ b/internal/application/sync/handlers/walmart_test.go @@ -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{} diff --git a/internal/application/sync/orchestrator.go b/internal/application/sync/orchestrator.go index 284a1d4..ca7b50b 100644 --- a/internal/application/sync/orchestrator.go +++ b/internal/application/sync/orchestrator.go @@ -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) diff --git a/internal/application/sync/reconciliation.go b/internal/application/sync/reconciliation.go new file mode 100644 index 0000000..d1ea3aa --- /dev/null +++ b/internal/application/sync/reconciliation.go @@ -0,0 +1,339 @@ +package sync + +import ( + "context" + "errors" + "fmt" + "math" + "strings" + "time" + + "github.com/eshaffer321/itemize/internal/adapters/providers" + "github.com/eshaffer321/itemize/internal/infrastructure/storage" + "github.com/eshaffer321/monarch-go/v2/pkg/monarch" +) + +const provisionalStatus = "provisional" + +// transactionReconciliationClient is the subset of Monarch operations needed +// to follow a pending transaction to its posted replacement and restore the +// exact mutation payload cached in SQLite. +type transactionReconciliationClient interface { + GetTransaction(ctx context.Context, id string) (*monarch.TransactionDetails, error) + GetSplits(ctx context.Context, id string) ([]*monarch.TransactionSplit, error) + UpdateTransaction(ctx context.Context, id string, params *monarch.UpdateTransactionParams) error + UpdateSplits(ctx context.Context, id string, splits []*monarch.TransactionSplit) error +} + +// reconcileStoredOrder handles an order whose desired Monarch state is already +// cached locally. It returns handled=true whenever normal categorization should +// not run, which prevents repeated LLM calls for provisional orders. +func (o *Orchestrator) reconcileStoredOrder( + ctx context.Context, + order providers.Order, + record *storage.ProcessingRecord, + providerTransactions []*monarch.Transaction, + usedTransactionIDs map[string]bool, + dryRun bool, +) (handled, processed, skipped bool, err error) { + if record == nil || + record.DryRun || + (record.Status != "success" && record.Status != provisionalStatus) { + return false, false, false, nil + } + if o.reconciliationClient == nil || record.TransactionID == "" { + return true, false, true, nil + } + + details := transactionDetailsByID(providerTransactions, record.TransactionID) + var getErr error + if details == nil { + // The list snapshot no longer contains the stored ID. Monarch's Get + // endpoint follows pending rows to their posted replacements. + details, getErr = o.reconciliationClient.GetTransaction(ctx, record.TransactionID) + } + if getErr != nil && !errors.Is(getErr, monarch.ErrNotFound) { + // A read failure must not fall through to normal processing, which would + // repeat the LLM call and risk a second mutation. Keep the cached state + // unchanged and retry reconciliation on the next sync. + o.logger.Warn("Could not resolve cached Monarch transaction; will retry", + "order_id", order.GetID(), + "transaction_id", record.TransactionID, + "error", getErr) + return true, false, true, nil + } + + if getErr != nil || details == nil || details.Transaction == nil { + replacement, ambiguous := findPostedReplacement(record, providerTransactions) + if ambiguous { + o.logger.Warn("Pending transaction replacement is ambiguous; leaving cached categorization provisional", + "order_id", order.GetID(), + "transaction_id", record.TransactionID, + "amount", record.TransactionAmount) + return true, false, true, nil + } + if replacement == nil { + o.logger.Debug("Pending transaction has no posted replacement yet", + "order_id", order.GetID(), + "transaction_id", record.TransactionID) + return true, false, true, nil + } + details = &monarch.TransactionDetails{Transaction: replacement} + } + + transaction := details.Transaction + if !matchesRecordedAmount(record, transaction) { + return true, false, false, fmt.Errorf( + "resolved Monarch transaction amount %.2f does not match cached amount %.2f for order %s", + transaction.Amount, + record.TransactionAmount, + order.GetID(), + ) + } + usedTransactionIDs[transaction.ID] = true + + if transaction.Pending { + // This also repairs legacy records that were incorrectly marked as a + // final success while their matched Monarch row was still pending. + if record.Status == "success" && !dryRun { + provisional := *record + provisional.RunID = o.runID + provisional.Status = provisionalStatus + provisional.ProcessedAt = time.Now() + if saveErr := o.storage.SaveRecord(&provisional); saveErr != nil { + return true, false, false, fmt.Errorf("save provisional record: %w", saveErr) + } + } + o.logger.Debug("Categorized transaction is still pending", + "order_id", order.GetID(), + "transaction_id", transaction.ID) + return true, false, true, nil + } + + redirected := transaction.ID != record.TransactionID + needsRepair := record.Status == provisionalStatus || + redirected || + sameIDSafeRepairNeeded(record, transaction) + if !needsRepair { + // A final same-ID transaction with a non-temporary category may have + // been edited by the user. Leave it alone. + return true, false, true, nil + } + + stateMatches, compareErr := o.cachedStateMatches(ctx, record, transaction) + if compareErr != nil { + return true, false, false, compareErr + } + + if !stateMatches && !dryRun { + if applyErr := o.applyCachedState(ctx, record, transaction); applyErr != nil { + return true, false, false, applyErr + } + } + + if !dryRun { + finalized := *record + finalized.RunID = o.runID + finalized.TransactionID = transaction.ID + finalized.TransactionAmount = transaction.Amount + finalized.Status = "success" + finalized.ErrorMessage = "" + finalized.ProcessedAt = time.Now() + if saveErr := o.storage.SaveRecord(&finalized); saveErr != nil { + return true, false, false, fmt.Errorf("save reconciled record: %w", saveErr) + } + if saveErr := o.storage.SaveOrderTransaction(&storage.OrderTransaction{ + RunID: o.runID, + OrderID: order.GetID(), + TransactionID: transaction.ID, + Role: "posted_replacement", + Amount: transaction.Amount, + CategoryID: finalized.CategoryID, + CategoryName: finalized.CategoryName, + Notes: finalized.MonarchNotes, + }); saveErr != nil { + o.logger.Warn("Failed to save posted replacement transaction", + "order_id", order.GetID(), + "transaction_id", transaction.ID, + "error", saveErr) + } + } + + o.logger.Info("Reconciled categorized order to posted Monarch transaction", + "order_id", order.GetID(), + "previous_transaction_id", record.TransactionID, + "transaction_id", transaction.ID, + "redirected", redirected, + "state_reapplied", !stateMatches) + return true, true, false, nil +} + +func (o *Orchestrator) cachedStateMatches( + ctx context.Context, + record *storage.ProcessingRecord, + transaction *monarch.Transaction, +) (bool, error) { + if len(record.Splits) == 0 { + if record.CategoryID == "" { + return true, nil + } + if transaction.Category == nil || transaction.Category.ID != record.CategoryID { + return false, nil + } + return record.MonarchNotes == "" || transaction.Notes == record.MonarchNotes, nil + } + + current, err := o.reconciliationClient.GetSplits(ctx, transaction.ID) + if err != nil { + return false, fmt.Errorf("get splits for posted transaction %s: %w", transaction.ID, err) + } + return cachedSplitsMatch(record.Splits, current), nil +} + +func (o *Orchestrator) applyCachedState( + ctx context.Context, + record *storage.ProcessingRecord, + transaction *monarch.Transaction, +) error { + if len(record.Splits) > 0 { + splits := make([]*monarch.TransactionSplit, len(record.Splits)) + total := 0.0 + for i, cached := range record.Splits { + total += cached.Amount + splits[i] = &monarch.TransactionSplit{ + Amount: cached.Amount, + CategoryID: cached.CategoryID, + Notes: cached.Notes, + } + } + if math.Abs(total-transaction.Amount) > 0.011 { + return fmt.Errorf( + "cached splits total %.2f does not match posted transaction %.2f", + total, + transaction.Amount, + ) + } + if err := o.reconciliationClient.UpdateSplits(ctx, transaction.ID, splits); err != nil { + return fmt.Errorf("restore cached splits on transaction %s: %w", transaction.ID, err) + } + return nil + } + + if record.CategoryID == "" { + return nil + } + categoryID := record.CategoryID + notes := record.MonarchNotes + reviewed := false + params := &monarch.UpdateTransactionParams{ + CategoryID: &categoryID, + Notes: ¬es, + NeedsReview: &reviewed, + } + if err := o.reconciliationClient.UpdateTransaction(ctx, transaction.ID, params); err != nil { + return fmt.Errorf("restore cached category on transaction %s: %w", transaction.ID, err) + } + return nil +} + +func findPostedReplacement( + record *storage.ProcessingRecord, + transactions []*monarch.Transaction, +) (*monarch.Transaction, bool) { + var candidates []*monarch.Transaction + for _, transaction := range transactions { + if transaction == nil || transaction.Pending || transaction.IsSplitTransaction { + continue + } + if !matchesRecordedAmount(record, transaction) { + continue + } + if !record.OrderDate.IsZero() && !transaction.Date.IsZero() { + days := math.Abs(transaction.Date.Sub(record.OrderDate).Hours() / 24) + if days > 5 { + continue + } + } + candidates = append(candidates, transaction) + } + + if len(candidates) == 1 { + return candidates[0], false + } + if record.MonarchNotes != "" { + var noteMatch *monarch.Transaction + for _, candidate := range candidates { + if strings.TrimSpace(candidate.Notes) != strings.TrimSpace(record.MonarchNotes) { + continue + } + if noteMatch != nil { + return nil, true + } + noteMatch = candidate + } + if noteMatch != nil { + return noteMatch, false + } + } + return nil, len(candidates) > 1 +} + +func transactionDetailsByID( + transactions []*monarch.Transaction, + transactionID string, +) *monarch.TransactionDetails { + for _, transaction := range transactions { + if transaction != nil && transaction.ID == transactionID { + return &monarch.TransactionDetails{Transaction: transaction} + } + } + return nil +} + +func matchesRecordedAmount(record *storage.ProcessingRecord, transaction *monarch.Transaction) bool { + if transaction == nil { + return false + } + return math.Abs(math.Abs(record.TransactionAmount)-math.Abs(transaction.Amount)) <= 0.011 +} + +func sameIDSafeRepairNeeded(record *storage.ProcessingRecord, transaction *monarch.Transaction) bool { + if len(record.Splits) > 0 { + return false + } + if record.CategoryID == "" { + return false + } + return transaction.Category == nil || + strings.Contains(strings.ToLower(transaction.Category.Name), "temp") +} + +func cachedSplitsMatch(cached []storage.SplitDetail, current []*monarch.TransactionSplit) bool { + if len(cached) != len(current) { + return false + } + matched := make([]bool, len(current)) + for _, expected := range cached { + found := false + for i, actual := range current { + if matched[i] || actual == nil { + continue + } + categoryID := actual.CategoryID + if categoryID == "" && actual.Category != nil { + categoryID = actual.Category.ID + } + if categoryID == expected.CategoryID && + math.Abs(actual.Amount-expected.Amount) <= 0.011 && + actual.Notes == expected.Notes { + matched[i] = true + found = true + break + } + } + if !found { + return false + } + } + return true +} diff --git a/internal/application/sync/reconciliation_test.go b/internal/application/sync/reconciliation_test.go new file mode 100644 index 0000000..2c42af2 --- /dev/null +++ b/internal/application/sync/reconciliation_test.go @@ -0,0 +1,586 @@ +package sync + +import ( + "context" + "io" + "log/slog" + "testing" + "time" + + "github.com/eshaffer321/itemize/internal/application/sync/handlers" + "github.com/eshaffer321/itemize/internal/infrastructure/storage" + "github.com/eshaffer321/monarch-go/v2/pkg/monarch" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type reconciliationTestClient struct { + detailsByID map[string]*monarch.TransactionDetails + splitsByID map[string][]*monarch.TransactionSplit + getErr error + + updatedTransactionID string + updatedParams *monarch.UpdateTransactionParams + updatedSplitsID string + updatedSplits []*monarch.TransactionSplit +} + +func (c *reconciliationTestClient) GetTransaction(_ context.Context, id string) (*monarch.TransactionDetails, error) { + if c.getErr != nil { + return nil, c.getErr + } + return c.detailsByID[id], nil +} + +func (c *reconciliationTestClient) GetSplits(_ context.Context, id string) ([]*monarch.TransactionSplit, error) { + return c.splitsByID[id], nil +} + +func (c *reconciliationTestClient) UpdateTransaction(_ context.Context, id string, params *monarch.UpdateTransactionParams) error { + c.updatedTransactionID = id + c.updatedParams = params + return nil +} + +func (c *reconciliationTestClient) UpdateSplits(_ context.Context, id string, splits []*monarch.TransactionSplit) error { + c.updatedSplitsID = id + c.updatedSplits = splits + return nil +} + +func reconciliationTestLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func reconciliationTestOrder(id string, date time.Time, total float64) *mockSimpleOrder { + return &mockSimpleOrder{ + id: id, + date: date, + total: total, + subtotal: total, + providerName: "Amazon", + } +} + +func TestRecordSuccessWithResult_PendingTransactionIsProvisional(t *testing.T) { + store := storage.NewMockRepository() + orch := &Orchestrator{storage: store, logger: reconciliationTestLogger()} + order := reconciliationTestOrder("ORDER-PENDING", time.Now(), 25.75) + transaction := &monarch.Transaction{ + ID: "pending-txn", + Amount: -25.75, + Pending: true, + } + result := &handlers.ProcessResult{ + Processed: true, + Transaction: transaction, + CategoryID: "auto", + CategoryName: "Auto Maintenance", + MonarchNotes: "Auto Maintenance:\n- Air filter $25.75", + } + + orch.recordSuccessWithResult(order, transaction, nil, 1, false, result, nil) + + record, err := store.GetRecord(order.GetID()) + require.NoError(t, err) + require.NotNil(t, record) + assert.Equal(t, "provisional", record.Status) + assert.False(t, store.IsProcessed(order.GetID())) +} + +func TestProcessOrder_ReconcilesRedirectedSingleCategoryFromCachedRecord(t *testing.T) { + orderDate := time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC) + order := reconciliationTestOrder("ORDER-AIR-FILTER", orderDate, 25.75) + store := storage.NewMockRepository() + store.AddRecord(&storage.ProcessingRecord{ + OrderID: order.GetID(), + Provider: "Amazon", + TransactionID: "pending-txn", + OrderDate: orderDate, + ProcessedAt: orderDate, + TransactionAmount: -25.75, + Status: "provisional", + CategoryID: "auto", + CategoryName: "Auto Maintenance", + MonarchNotes: "Auto Maintenance:\n- Air filter $25.75", + }) + client := &reconciliationTestClient{ + detailsByID: map[string]*monarch.TransactionDetails{ + "pending-txn": { + Transaction: &monarch.Transaction{ + ID: "posted-txn", + Amount: -25.75, + Pending: false, + Category: &monarch.TransactionCategory{ + ID: "amazon-temp", + Name: "[TEMP] Amazon", + }, + }, + }, + }, + } + orch := &Orchestrator{ + storage: store, + reconciliationClient: client, + logger: reconciliationTestLogger(), + } + used := make(map[string]bool) + + processed, skipped, err := orch.processOrder( + context.Background(), + order, + []*monarch.Transaction{client.detailsByID["pending-txn"].Transaction}, + used, + nil, + nil, + Options{}, + ) + + require.NoError(t, err) + assert.True(t, processed) + assert.False(t, skipped) + assert.Equal(t, "posted-txn", client.updatedTransactionID) + require.NotNil(t, client.updatedParams) + require.NotNil(t, client.updatedParams.CategoryID) + assert.Equal(t, "auto", *client.updatedParams.CategoryID) + require.NotNil(t, client.updatedParams.Notes) + assert.Contains(t, *client.updatedParams.Notes, "Air filter") + assert.True(t, used["posted-txn"]) + + record, getErr := store.GetRecord(order.GetID()) + require.NoError(t, getErr) + assert.Equal(t, "success", record.Status) + assert.Equal(t, "posted-txn", record.TransactionID) +} + +func TestProcessOrder_ReconcilesRedirectedSplitsFromCachedRecord(t *testing.T) { + orderDate := time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC) + order := reconciliationTestOrder("ORDER-SPLIT", orderDate, 58.28) + store := storage.NewMockRepository() + store.AddRecord(&storage.ProcessingRecord{ + OrderID: order.GetID(), + Provider: "Amazon", + TransactionID: "pending-split", + OrderDate: orderDate, + ProcessedAt: orderDate, + TransactionAmount: -58.28, + SplitCount: 2, + Status: "success", + Splits: []storage.SplitDetail{ + {CategoryID: "home", Amount: -33.57, Notes: "Home Spending:\n- Vase $33.57"}, + {CategoryID: "household", Amount: -24.71, Notes: "Household Supplies:\n- Dispenser $24.71"}, + }, + }) + client := &reconciliationTestClient{ + detailsByID: map[string]*monarch.TransactionDetails{ + "pending-split": { + Transaction: &monarch.Transaction{ + ID: "posted-split", + Amount: -58.28, + Pending: false, + }, + }, + }, + } + orch := &Orchestrator{ + storage: store, + reconciliationClient: client, + logger: reconciliationTestLogger(), + } + + processed, skipped, err := orch.processOrder( + context.Background(), + order, + []*monarch.Transaction{client.detailsByID["pending-split"].Transaction}, + make(map[string]bool), + nil, + nil, + Options{}, + ) + + require.NoError(t, err) + assert.True(t, processed) + assert.False(t, skipped) + assert.Equal(t, "posted-split", client.updatedSplitsID) + require.Len(t, client.updatedSplits, 2) + assert.Equal(t, "home", client.updatedSplits[0].CategoryID) + assert.Equal(t, -33.57, client.updatedSplits[0].Amount) + assert.Empty(t, client.updatedTransactionID) +} + +func TestProcessOrder_DoesNotOverwriteSameIDFinalManualCategory(t *testing.T) { + orderDate := time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC) + order := reconciliationTestOrder("ORDER-MANUAL", orderDate, 25.75) + store := storage.NewMockRepository() + store.AddRecord(&storage.ProcessingRecord{ + OrderID: order.GetID(), + Provider: "Amazon", + TransactionID: "posted-txn", + OrderDate: orderDate, + ProcessedAt: orderDate, + TransactionAmount: -25.75, + Status: "success", + CategoryID: "auto", + CategoryName: "Auto Maintenance", + }) + client := &reconciliationTestClient{ + detailsByID: map[string]*monarch.TransactionDetails{ + "posted-txn": { + Transaction: &monarch.Transaction{ + ID: "posted-txn", + Amount: -25.75, + Pending: false, + Category: &monarch.TransactionCategory{ + ID: "manual", + Name: "His Fund", + }, + }, + }, + }, + } + orch := &Orchestrator{ + storage: store, + reconciliationClient: client, + logger: reconciliationTestLogger(), + } + + processed, skipped, err := orch.processOrder( + context.Background(), + order, + []*monarch.Transaction{client.detailsByID["posted-txn"].Transaction}, + make(map[string]bool), + nil, + nil, + Options{}, + ) + + require.NoError(t, err) + assert.False(t, processed) + assert.True(t, skipped) + assert.Empty(t, client.updatedTransactionID) + assert.Empty(t, client.updatedSplitsID) +} + +func TestProcessOrder_LegacySuccessStillPendingBecomesProvisional(t *testing.T) { + orderDate := time.Date(2026, 7, 28, 0, 0, 0, 0, time.UTC) + order := reconciliationTestOrder("ORDER-LEGACY-PENDING", orderDate, 37.58) + store := storage.NewMockRepository() + store.AddRecord(&storage.ProcessingRecord{ + OrderID: order.GetID(), + Provider: "Amazon", + TransactionID: "pending-txn", + OrderDate: orderDate, + ProcessedAt: orderDate, + TransactionAmount: -37.58, + Status: "success", + CategoryID: "kid-needs", + CategoryName: "Kid Needs", + MonarchNotes: "Kid Needs:\n- Pajamas $37.58", + }) + client := &reconciliationTestClient{ + detailsByID: map[string]*monarch.TransactionDetails{ + "pending-txn": { + Transaction: &monarch.Transaction{ + ID: "pending-txn", + Amount: -37.58, + Pending: true, + }, + }, + }, + } + orch := &Orchestrator{ + storage: store, + reconciliationClient: client, + logger: reconciliationTestLogger(), + } + + processed, skipped, err := orch.processOrder( + context.Background(), + order, + nil, + make(map[string]bool), + nil, + nil, + Options{}, + ) + + require.NoError(t, err) + assert.False(t, processed) + assert.True(t, skipped) + record, getErr := store.GetRecord(order.GetID()) + require.NoError(t, getErr) + assert.Equal(t, "provisional", record.Status) + assert.False(t, store.IsProcessed(order.GetID())) + assert.Empty(t, client.updatedTransactionID) +} + +func TestProcessOrder_FinalizesProvisionalWhenPostedStateAlreadyMatches(t *testing.T) { + orderDate := time.Date(2026, 7, 28, 0, 0, 0, 0, time.UTC) + order := reconciliationTestOrder("ORDER-ALREADY-CORRECT", orderDate, 37.58) + notes := "Kid Needs:\n- Pajamas $37.58" + store := storage.NewMockRepository() + store.AddRecord(&storage.ProcessingRecord{ + OrderID: order.GetID(), + Provider: "Amazon", + TransactionID: "same-txn", + OrderDate: orderDate, + ProcessedAt: orderDate, + TransactionAmount: -37.58, + Status: "provisional", + CategoryID: "kid-needs", + CategoryName: "Kid Needs", + MonarchNotes: notes, + }) + client := &reconciliationTestClient{ + detailsByID: map[string]*monarch.TransactionDetails{ + "same-txn": { + Transaction: &monarch.Transaction{ + ID: "same-txn", + Amount: -37.58, + Pending: false, + Notes: notes, + Category: &monarch.TransactionCategory{ + ID: "kid-needs", + Name: "Kid Needs", + }, + }, + }, + }, + } + orch := &Orchestrator{ + storage: store, + reconciliationClient: client, + logger: reconciliationTestLogger(), + } + + processed, skipped, err := orch.processOrder( + context.Background(), + order, + nil, + make(map[string]bool), + nil, + nil, + Options{}, + ) + + require.NoError(t, err) + assert.True(t, processed) + assert.False(t, skipped) + assert.Empty(t, client.updatedTransactionID) + record, getErr := store.GetRecord(order.GetID()) + require.NoError(t, getErr) + assert.Equal(t, "success", record.Status) +} + +func TestProcessOrder_UsesUniquePostedFallbackWhenRedirectIsMissing(t *testing.T) { + orderDate := time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC) + order := reconciliationTestOrder("ORDER-FALLBACK", orderDate, 25.75) + store := storage.NewMockRepository() + store.AddRecord(&storage.ProcessingRecord{ + OrderID: order.GetID(), + Provider: "Amazon", + TransactionID: "missing-pending", + OrderDate: orderDate, + ProcessedAt: orderDate, + TransactionAmount: -25.75, + Status: "provisional", + CategoryID: "auto", + CategoryName: "Auto Maintenance", + MonarchNotes: "Auto Maintenance:\n- Air filter $25.75", + }) + client := &reconciliationTestClient{getErr: monarch.ErrNotFound} + posted := &monarch.Transaction{ + ID: "unique-posted", + Amount: -25.75, + Date: monarch.Date{Time: orderDate.AddDate(0, 0, 1)}, + Pending: false, + Category: &monarch.TransactionCategory{ + ID: "amazon-temp", + Name: "[TEMP] Amazon", + }, + } + orch := &Orchestrator{ + storage: store, + reconciliationClient: client, + logger: reconciliationTestLogger(), + } + + processed, skipped, err := orch.processOrder( + context.Background(), + order, + []*monarch.Transaction{posted}, + make(map[string]bool), + nil, + nil, + Options{}, + ) + + require.NoError(t, err) + assert.True(t, processed) + assert.False(t, skipped) + assert.Equal(t, "unique-posted", client.updatedTransactionID) + record, getErr := store.GetRecord(order.GetID()) + require.NoError(t, getErr) + assert.Equal(t, "unique-posted", record.TransactionID) + assert.Equal(t, "success", record.Status) +} + +func TestProcessOrder_AmbiguousPostedFallbackMakesNoWrite(t *testing.T) { + orderDate := time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC) + order := reconciliationTestOrder("ORDER-AMBIGUOUS", orderDate, 11.01) + store := storage.NewMockRepository() + store.AddRecord(&storage.ProcessingRecord{ + OrderID: order.GetID(), + Provider: "Amazon", + TransactionID: "missing-pending", + OrderDate: orderDate, + ProcessedAt: orderDate, + TransactionAmount: -11.01, + Status: "provisional", + CategoryID: "kid-needs", + CategoryName: "Kid Needs", + }) + client := &reconciliationTestClient{getErr: monarch.ErrNotFound} + transactions := []*monarch.Transaction{ + {ID: "posted-one", Amount: -11.01, Date: monarch.Date{Time: orderDate}, Pending: false}, + {ID: "posted-two", Amount: -11.01, Date: monarch.Date{Time: orderDate}, Pending: false}, + } + orch := &Orchestrator{ + storage: store, + reconciliationClient: client, + logger: reconciliationTestLogger(), + } + + processed, skipped, err := orch.processOrder( + context.Background(), + order, + transactions, + make(map[string]bool), + nil, + nil, + Options{}, + ) + + require.NoError(t, err) + assert.False(t, processed) + assert.True(t, skipped) + assert.Empty(t, client.updatedTransactionID) + assert.Empty(t, client.updatedSplitsID) + record, getErr := store.GetRecord(order.GetID()) + require.NoError(t, getErr) + assert.Equal(t, "provisional", record.Status) + assert.Equal(t, "missing-pending", record.TransactionID) +} + +func TestProcessOrder_RepairsSameIDTemporaryCategoryButNotOtherCategories(t *testing.T) { + orderDate := time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC) + order := reconciliationTestOrder("ORDER-SAME-ID-TEMP", orderDate, 25.75) + store := storage.NewMockRepository() + store.AddRecord(&storage.ProcessingRecord{ + OrderID: order.GetID(), + Provider: "Amazon", + TransactionID: "posted-txn", + OrderDate: orderDate, + ProcessedAt: orderDate, + TransactionAmount: -25.75, + Status: "success", + CategoryID: "auto", + CategoryName: "Auto Maintenance", + MonarchNotes: "Auto Maintenance:\n- Air filter $25.75", + }) + client := &reconciliationTestClient{ + detailsByID: map[string]*monarch.TransactionDetails{ + "posted-txn": { + Transaction: &monarch.Transaction{ + ID: "posted-txn", + Amount: -25.75, + Pending: false, + Category: &monarch.TransactionCategory{ + ID: "amazon-temp", + Name: "[TEMP] Amazon", + }, + }, + }, + }, + } + orch := &Orchestrator{ + storage: store, + reconciliationClient: client, + logger: reconciliationTestLogger(), + } + + processed, skipped, err := orch.processOrder( + context.Background(), + order, + nil, + make(map[string]bool), + nil, + nil, + Options{}, + ) + + require.NoError(t, err) + assert.True(t, processed) + assert.False(t, skipped) + assert.Equal(t, "posted-txn", client.updatedTransactionID) + require.NotNil(t, client.updatedParams) + require.NotNil(t, client.updatedParams.CategoryID) + assert.Equal(t, "auto", *client.updatedParams.CategoryID) +} + +func TestProcessOrder_ReconciliationDryRunMakesNoWrites(t *testing.T) { + orderDate := time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC) + order := reconciliationTestOrder("ORDER-DRY-RUN", orderDate, 25.75) + store := storage.NewMockRepository() + store.AddRecord(&storage.ProcessingRecord{ + OrderID: order.GetID(), + Provider: "Amazon", + TransactionID: "pending-txn", + OrderDate: orderDate, + ProcessedAt: orderDate, + TransactionAmount: -25.75, + Status: "provisional", + CategoryID: "auto", + CategoryName: "Auto Maintenance", + }) + client := &reconciliationTestClient{ + detailsByID: map[string]*monarch.TransactionDetails{ + "pending-txn": { + Transaction: &monarch.Transaction{ + ID: "posted-txn", + Amount: -25.75, + Pending: false, + Category: &monarch.TransactionCategory{ + ID: "amazon-temp", + Name: "[TEMP] Amazon", + }, + }, + }, + }, + } + orch := &Orchestrator{ + storage: store, + reconciliationClient: client, + logger: reconciliationTestLogger(), + } + + processed, skipped, err := orch.processOrder( + context.Background(), + order, + nil, + make(map[string]bool), + nil, + nil, + Options{DryRun: true}, + ) + + require.NoError(t, err) + assert.True(t, processed) + assert.False(t, skipped) + assert.Empty(t, client.updatedTransactionID) + assert.Empty(t, client.updatedSplitsID) + record, getErr := store.GetRecord(order.GetID()) + require.NoError(t, getErr) + assert.Equal(t, "provisional", record.Status) + assert.Equal(t, "pending-txn", record.TransactionID) +} diff --git a/internal/application/sync/recording.go b/internal/application/sync/recording.go index 97cbc90..7268e75 100644 --- a/internal/application/sync/recording.go +++ b/internal/application/sync/recording.go @@ -140,6 +140,12 @@ func (o *Orchestrator) recordSuccessWithResult( if transaction != nil { record.TransactionID = transaction.ID record.TransactionAmount = transaction.Amount + if transaction.Pending && !dryRun { + // Pending feed rows can be replaced with a new transaction ID + // when they post. Keep the cached categorization provisional so + // a later sync resolves and verifies the posted transaction. + record.Status = "provisional" + } } if dryRun { record.Status = "dry-run" diff --git a/internal/application/sync/types.go b/internal/application/sync/types.go index 1e41cb9..ca0e8bb 100644 --- a/internal/application/sync/types.go +++ b/internal/application/sync/types.go @@ -60,9 +60,12 @@ type Orchestrator struct { walmartHandler *handlers.WalmartHandler simpleHandler *handlers.SimpleHandler monarchAdapter *monarchAdapter - storage storage.Repository // Interface instead of concrete type - logger *slog.Logger - runID int64 // Current sync run ID for API logging + // reconciliationClient resolves pending transaction IDs to their posted + // replacements and reapplies cached categorization without another LLM call. + reconciliationClient transactionReconciliationClient + storage storage.Repository // Interface instead of concrete type + logger *slog.Logger + runID int64 // Current sync run ID for API logging } // NewOrchestrator creates a new sync orchestrator @@ -136,17 +139,18 @@ func NewOrchestrator( } return &Orchestrator{ - provider: provider, - clients: clients, - splitter: spl, - matcher: transactionMatcher, - consolidator: consolidator, - amazonHandler: amazonHandler, - walmartHandler: walmartHandler, - simpleHandler: simpleHandler, - monarchAdapter: mAdapter, - storage: store, - logger: logger, + provider: provider, + clients: clients, + splitter: spl, + matcher: transactionMatcher, + consolidator: consolidator, + amazonHandler: amazonHandler, + walmartHandler: walmartHandler, + simpleHandler: simpleHandler, + monarchAdapter: mAdapter, + reconciliationClient: mAdapter, + storage: store, + logger: logger, } } @@ -195,6 +199,20 @@ func (a *monarchAdapter) UpdateTransaction(ctx context.Context, id string, param return err } +func (a *monarchAdapter) GetTransaction(ctx context.Context, id string) (*monarch.TransactionDetails, error) { + start := time.Now() + transaction, err := a.client.Transactions.Get(ctx, id) + a.logAPICallCompletion(ctx, id, "Transactions.Get", transaction, err, time.Since(start)) + return transaction, err +} + +func (a *monarchAdapter) GetSplits(ctx context.Context, id string) ([]*monarch.TransactionSplit, error) { + start := time.Now() + splits, err := a.client.Transactions.GetSplits(ctx, id) + a.logAPICallCompletion(ctx, id, "Transactions.GetSplits", splits, err, time.Since(start)) + return splits, err +} + func (a *monarchAdapter) UpdateSplits(ctx context.Context, id string, splits []*monarch.TransactionSplit) error { a.logAPICallIntent(ctx, id, "Transactions.UpdateSplits", splits) start := time.Now() diff --git a/internal/infrastructure/storage/mock.go b/internal/infrastructure/storage/mock.go index e176bbe..568b205 100644 --- a/internal/infrastructure/storage/mock.go +++ b/internal/infrastructure/storage/mock.go @@ -81,7 +81,11 @@ func (m *MockRepository) SaveRecord(record *ProcessingRecord) error { // Deep copy to avoid test mutations copied := *record m.attempts[record.OrderID] = append(m.attempts[record.OrderID], ProcessingAttempt{ProcessingRecord: copied}) - if existing, ok := m.records[record.OrderID]; ok && existing.Status == "success" && !existing.DryRun && record.Status != "success" { + if existing, ok := m.records[record.OrderID]; ok && + existing.Status == "success" && + !existing.DryRun && + record.Status != "success" && + record.Status != "provisional" { return nil } m.records[record.OrderID] = &copied diff --git a/internal/infrastructure/storage/sqlite.go b/internal/infrastructure/storage/sqlite.go index 8e0b808..2a5872a 100644 --- a/internal/infrastructure/storage/sqlite.go +++ b/internal/infrastructure/storage/sqlite.go @@ -281,7 +281,7 @@ func (s *Storage) SaveRecord(record *ProcessingRecord) error { WHERE NOT ( processing_records.status = 'success' AND processing_records.dry_run = 0 - AND excluded.status != 'success' + AND excluded.status NOT IN ('success', 'provisional') ) ` diff --git a/internal/infrastructure/storage/storage_test.go b/internal/infrastructure/storage/storage_test.go index ff71077..f70db10 100644 --- a/internal/infrastructure/storage/storage_test.go +++ b/internal/infrastructure/storage/storage_test.go @@ -453,6 +453,40 @@ func TestStorage_IsProcessed(t *testing.T) { assert.True(t, store.IsProcessed("ORDER-SUCCESS"), "Success should count as processed") } +func TestStorage_SaveRecord_AllowsSuccessToBecomeProvisional(t *testing.T) { + tmpDB := createTempDB(t) + defer os.Remove(tmpDB) + + store, err := NewStorage(tmpDB) + require.NoError(t, err) + defer store.Close() + + require.NoError(t, store.SaveRecord(&ProcessingRecord{ + OrderID: "ORDER-LEGACY-PENDING", + Provider: "amazon", + TransactionID: "pending-txn", + OrderDate: time.Now(), + ProcessedAt: time.Now(), + Status: "success", + })) + require.True(t, store.IsProcessed("ORDER-LEGACY-PENDING")) + + require.NoError(t, store.SaveRecord(&ProcessingRecord{ + OrderID: "ORDER-LEGACY-PENDING", + Provider: "amazon", + TransactionID: "pending-txn", + OrderDate: time.Now(), + ProcessedAt: time.Now(), + Status: "provisional", + })) + + record, err := store.GetRecord("ORDER-LEGACY-PENDING") + require.NoError(t, err) + require.NotNil(t, record) + assert.Equal(t, "provisional", record.Status) + assert.False(t, store.IsProcessed("ORDER-LEGACY-PENDING")) +} + // ============================================================================= // API Query Method Tests // =============================================================================