diff --git a/docs/bug-fixes.md b/docs/bug-fixes.md index 796c4a2..a02993c 100644 --- a/docs/bug-fixes.md +++ b/docs/bug-fixes.md @@ -12,6 +12,20 @@ Each bug fix entry should include: ## Bug Fixes +### 2026-07-10: Walmart refunds lacked item-level categorization + +**Description:** +Walmart ledger credits were ignored, leaving the matching Monarch refund uncategorized. The typed Walmart client also discarded the `returnId` metadata that identifies the refunded item in the order response. + +**Fix Applied:** +Match a supported single refund to its positive Monarch credit, use `walmart-client-go/v2 v2.1.0` to extract its typed `returnId` item from the existing order response, and categorize/note only that item. Ambiguous or multi-refund orders remain untouched. + +**Verification:** +- Regression tests cover `returnId` extraction and item-only categorization. +- `./itemize walmart -dry-run -force -days 14 -order-id 200014872726122 -verbose` identified the Chobani Coffee Creamer and dry-ran the `$5.58` credit update. + +--- + ### 2026-07-11: Walmart ledger charge lines did not always match Monarch posting groups **Description:** diff --git a/go.mod b/go.mod index eacfc03..a30c1ae 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/eshaffer321/amazon-go v0.3.0 github.com/eshaffer321/costco-go v0.3.11 github.com/eshaffer321/monarch-go/v2 v2.0.0 - github.com/eshaffer321/walmart-client-go/v2 v2.0.2 + github.com/eshaffer321/walmart-client-go/v2 v2.1.0 github.com/getsentry/sentry-go v0.36.0 github.com/go-chi/chi/v5 v5.3.0 github.com/pressly/goose/v3 v3.27.2 diff --git a/go.sum b/go.sum index f6012e8..6837fa7 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,8 @@ github.com/eshaffer321/costco-go v0.3.11 h1:vOEXcSj/vGlwE/4AbXK3uUdXG7XYg1qfUPVi github.com/eshaffer321/costco-go v0.3.11/go.mod h1:ANJTHfRSPyot9cWKNlfs5qDKRKkA4tYORrorvWx/vbM= github.com/eshaffer321/monarch-go/v2 v2.0.0 h1:3reQ15D0K/Btc3mu+sfWdKJnU0hmNb0/ZwsbVUjN4l8= github.com/eshaffer321/monarch-go/v2 v2.0.0/go.mod h1:0rdAjSetvYQ8wGkUdg4BsqN8jTMhJ4TbHAr6qhAzSk4= -github.com/eshaffer321/walmart-client-go/v2 v2.0.2 h1:0NJLInbsgUKs24PzxumQMMFyEwVA5zegD2t38gURSq0= -github.com/eshaffer321/walmart-client-go/v2 v2.0.2/go.mod h1:4PVK9TsqFscTZypC67dgCt/vnPxXdtaheJVM7HOnod0= +github.com/eshaffer321/walmart-client-go/v2 v2.1.0 h1:FbOL/CuTXaTVDLgcBtE5C0SvjkwO6d/cIqNAVAF1OvU= +github.com/eshaffer321/walmart-client-go/v2 v2.1.0/go.mod h1:4PVK9TsqFscTZypC67dgCt/vnPxXdtaheJVM7HOnod0= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/getsentry/sentry-go v0.36.0 h1:UkCk0zV28PiGf+2YIONSSYiYhxwlERE5Li3JPpZqEns= diff --git a/internal/adapters/providers/walmart/order.go b/internal/adapters/providers/walmart/order.go index 8f10075..b91aae2 100644 --- a/internal/adapters/providers/walmart/order.go +++ b/internal/adapters/providers/walmart/order.go @@ -195,30 +195,9 @@ func (i *OrderItem) GetCategory() string { // Returns multiple charges for multi-delivery orders // Uses cached ledger result to avoid duplicate API calls func (o *Order) GetFinalCharges() ([]float64, error) { - var ledger *walmartclient.OrderLedger - - // Check cache first - if o.ledgerCache != nil { - ledger = o.ledgerCache - } else { - // Client is required for ledger API - if o.client == nil { - return nil, fmt.Errorf("client not available") - } - - // Fetch ledger from API - var err error - ctx := o.ctx - if ctx == nil { - ctx = context.Background() - } - ledger, err = o.client.GetOrderLedger(ctx, o.GetID()) - if err != nil { - return nil, fmt.Errorf("failed to get order ledger: %w", err) - } - - // Cache for future calls - o.ledgerCache = ledger + ledger, err := o.getLedger() + if err != nil { + return nil, err } // Extract and validate charges @@ -226,13 +205,6 @@ func (o *Order) GetFinalCharges() ([]float64, error) { return nil, fmt.Errorf("order not yet charged (payment pending)") } - // TODO: Handle refund transactions (negative charges) - // Currently we skip negative charges in the ledger, which means refund - // transactions in Monarch won't be categorized. Future enhancement: - // 1. Match the refund transaction (positive amount in Monarch) - // 2. Determine which item(s) were refunded from the ledger - // 3. Categorize the refund with the same category as the refunded item - // Collect positive charges from credit card payment methods only // Gift cards don't appear in bank transactions, so skip them var positiveCharges []float64 @@ -249,17 +221,11 @@ func (o *Order) GetFinalCharges() ([]float64, error) { continue } - // Filter to positive charges only (actual bank charges) - // Skip negative charges (refunds/credits) and zero charges + // Filter to positive charges only (actual bank charges). + // Refunds are exposed separately via GetRefundCharges. for _, charge := range pm.FinalCharges { if charge > 0 { positiveCharges = append(positiveCharges, charge) - } else if charge < 0 { - if o.logger != nil { - o.logger.Warn("Skipping refund in ledger (not yet supported)", - "order_id", o.GetID(), - "refund_amount", charge) - } } } } @@ -271,6 +237,68 @@ func (o *Order) GetFinalCharges() ([]float64, error) { return positiveCharges, nil } +// GetRefundCharges returns credit-card refunds for this order as positive amounts. +// Walmart records refunds as negative ledger entries, while Monarch records the +// corresponding bank credits as positive transactions. +func (o *Order) GetRefundCharges() ([]float64, error) { + ledger, err := o.getLedger() + if err != nil { + return nil, err + } + + if len(ledger.PaymentMethods) == 0 { + return nil, fmt.Errorf("order not yet charged (payment pending)") + } + + var refunds []float64 + for _, pm := range ledger.PaymentMethods { + if pm.PaymentType != "CREDITCARD" { + continue + } + + for _, charge := range pm.FinalCharges { + if charge < 0 { + refunds = append(refunds, -charge) + } + } + } + + return refunds, nil +} + +// GetRefundItems returns the item(s) that Walmart explicitly marked as refunded. +func (o *Order) GetRefundItems() ([]providers.OrderItem, error) { + refunded := o.walmartOrder.GetRefundedItems() + items := make([]providers.OrderItem, 0, len(refunded)) + for _, item := range refunded { + items = append(items, &OrderItem{item: item}) + } + return items, nil +} + +func (o *Order) getLedger() (*walmartclient.OrderLedger, error) { + if o.ledgerCache != nil { + return o.ledgerCache, nil + } + + if o.client == nil { + return nil, fmt.Errorf("client not available") + } + + ctx := o.ctx + if ctx == nil { + ctx = context.Background() + } + + ledger, err := o.client.GetOrderLedger(ctx, o.GetID()) + if err != nil { + return nil, fmt.Errorf("failed to get order ledger: %w", err) + } + + o.ledgerCache = ledger + return ledger, nil +} + // IsMultiDelivery checks if order was split into multiple deliveries // Returns true if there are multiple final charges func (o *Order) IsMultiDelivery() (bool, error) { diff --git a/internal/adapters/providers/walmart/order_multi_delivery_test.go b/internal/adapters/providers/walmart/order_multi_delivery_test.go index 7216645..8cf2e46 100644 --- a/internal/adapters/providers/walmart/order_multi_delivery_test.go +++ b/internal/adapters/providers/walmart/order_multi_delivery_test.go @@ -8,6 +8,29 @@ import ( "github.com/stretchr/testify/require" ) +func TestOrder_GetRefundItems_UsesClientReturnIDMetadata(t *testing.T) { + order := &Order{walmartOrder: &walmartclient.Order{ + ID: "REFUND-ITEMS", + Groups: []walmartclient.OrderGroup{{ + Categories: []walmartclient.OrderCategory{{ + Items: []walmartclient.OrderItem{{ + ID: "item-1", + ReturnID: "return-1", + Quantity: 1, + ProductInfo: &walmartclient.ProductInfo{Name: "Refunded item", USItemID: "sku-1"}, + PriceInfo: &walmartclient.ItemPrice{LinePrice: &walmartclient.Price{Value: 4.5}}, + }}, + }}, + }}, + }} + + items, err := order.GetRefundItems() + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "Refunded item", items[0].GetName()) + assert.Equal(t, "sku-1", items[0].GetSKU()) +} + // TestOrder_GetFinalCharges tests retrieving final charges from order ledger func TestOrder_GetFinalCharges(t *testing.T) { t.Run("single delivery order", func(t *testing.T) { @@ -150,6 +173,31 @@ func TestOrder_GetFinalCharges(t *testing.T) { assert.Equal(t, 100.00, charges[0], "should return only positive charge") }) + t.Run("returns refund charges separately", func(t *testing.T) { + order := &Order{ + walmartOrder: &walmartclient.Order{ID: "TEST003-REFUND"}, + ledgerCache: &walmartclient.OrderLedger{ + OrderID: "TEST003-REFUND", + PaymentMethods: []walmartclient.PaymentMethodCharges{ + { + PaymentType: "CREDITCARD", + FinalCharges: []float64{100.00, -50.00, -5.58}, + TotalCharged: 44.42, + }, + { + PaymentType: "GIFTCARD", + FinalCharges: []float64{-3.00}, + TotalCharged: -3.00, + }, + }, + }, + } + + refunds, err := order.GetRefundCharges() + require.NoError(t, err) + assert.Equal(t, []float64{50.00, 5.58}, refunds, "should return credit-card refunds as positive Monarch credit amounts") + }) + t.Run("filters zero charge amounts", func(t *testing.T) { order := &Order{ walmartOrder: &walmartclient.Order{ID: "TEST004"}, diff --git a/internal/application/sync/handlers/amazon.go b/internal/application/sync/handlers/amazon.go index 52c2e4b..35dbaaa 100644 --- a/internal/application/sync/handlers/amazon.go +++ b/internal/application/sync/handlers/amazon.go @@ -90,6 +90,7 @@ type ProcessResult struct { SkipReason string Allocations *allocator.Result Splits []*monarch.TransactionSplit + Refunds []RefundProcessResult // Transaction tracking for audit trail - the matched/consolidated transaction Transaction *monarch.Transaction @@ -104,6 +105,13 @@ type ProcessResult struct { ReconciledTransactions []*monarch.Transaction } +// RefundProcessResult describes a refund transaction categorized for an order. +type RefundProcessResult struct { + Amount float64 + Transaction *monarch.Transaction + Splits []*monarch.TransactionSplit +} + // AmazonHandler processes Amazon orders with pro-rata allocation type AmazonHandler struct { matcher *matcher.Matcher diff --git a/internal/application/sync/handlers/walmart.go b/internal/application/sync/handlers/walmart.go index 9d7538a..ffa5b97 100644 --- a/internal/application/sync/handlers/walmart.go +++ b/internal/application/sync/handlers/walmart.go @@ -24,6 +24,17 @@ type WalmartOrder interface { IsMultiDelivery() (bool, error) } +// WalmartOrderWithRefunds extends WalmartOrder with refund ledger access. +type WalmartOrderWithRefunds interface { + WalmartOrder + GetRefundCharges() ([]float64, error) +} + +type WalmartOrderWithRefundItems interface { + WalmartOrder + GetRefundItems() ([]providers.OrderItem, error) +} + // WalmartOrderWithLedger extends WalmartOrder with ledger access for persistence type WalmartOrderWithLedger interface { WalmartOrder @@ -74,18 +85,29 @@ func (h *WalmartHandler) ProcessOrder( monarchCategories []*monarch.TransactionCategory, dryRun bool, ) (*ProcessResult, error) { - result := &ProcessResult{} - // Step 1: Get bank charges from ledger bankCharges, err := order.GetFinalCharges() + refundCharges := h.getRefundCharges(order) + refundItems := h.getRefundItems(order, len(refundCharges)) + + h.saveLedgerIfAvailable(order) + if err != nil { // Check if this is a pending order (not yet charged) - if strings.Contains(err.Error(), "payment pending") { + if strings.Contains(err.Error(), "payment pending") && len(refundCharges) == 0 { h.logInfo("Skipping order - not yet charged", "order_id", order.GetID()) + result := &ProcessResult{} result.Skipped = true result.SkipReason = "payment pending" return result, nil } + if strings.Contains(err.Error(), "no positive charges found") && len(refundCharges) > 0 { + h.logInfo("Processing refund-only order", + "order_id", order.GetID(), + "refund_count", len(refundCharges), + "refunds", refundCharges) + return h.processRefundOnlyOrder(ctx, order, monarchTxns, usedTxnIDs, catCategories, monarchCategories, refundCharges, refundItems, dryRun) + } // For other ledger errors, fall through to regular matching using order total h.logWarn("Failed to get ledger charges, falling back to order total", "order_id", order.GetID(), @@ -98,17 +120,66 @@ func (h *WalmartHandler) ProcessOrder( "charges", bankCharges, "charge_count", len(bankCharges)) - // Save ledger data if storage is configured - h.saveLedgerIfAvailable(order) - - // Step 2: Handle based on number of charges + var result *ProcessResult if len(bankCharges) > 1 { - // Multi-delivery order - return h.processMultiDeliveryOrder(ctx, order, monarchTxns, usedTxnIDs, catCategories, monarchCategories, bankCharges, dryRun) + result, err = h.processMultiDeliveryOrder(ctx, order, monarchTxns, usedTxnIDs, catCategories, monarchCategories, bankCharges, dryRun) + } else { + result, err = h.processSingleChargeOrder(ctx, order, monarchTxns, usedTxnIDs, catCategories, monarchCategories, bankCharges[0], dryRun) + } + if err != nil || len(refundCharges) == 0 || len(refundItems) == 0 { + if err == nil && len(refundCharges) > 0 && len(refundItems) == 0 { + h.logInfo("Skipping refund without item-level Walmart detail", "order_id", order.GetID()) + } + return result, err + } + + refundResults, refundErr := h.processRefundCharges(ctx, order, monarchTxns, usedTxnIDs, catCategories, monarchCategories, refundCharges, refundItems, dryRun) + if refundErr != nil { + h.logWarn("Failed to process refund charges", + "order_id", order.GetID(), + "error", refundErr) + return result, nil + } + result.Refunds = refundResults + if result.Skipped { + result.Skipped = false + result.SkipReason = "" + result.Processed = true + if len(refundResults) > 0 { + result.Transaction = refundResults[0].Transaction + result.Splits = refundResults[0].Splits + } } + return result, nil +} +func (h *WalmartHandler) getRefundItems(order WalmartOrder, refundCount int) []providers.OrderItem { + if refundCount != 1 { + return nil + } + refundOrder, ok := order.(WalmartOrderWithRefundItems) + if !ok { + return nil + } + items, err := refundOrder.GetRefundItems() + if err != nil { + h.logWarn("Failed to get refunded Walmart items", "order_id", order.GetID(), "error", err) + return nil + } + return items +} + +func (h *WalmartHandler) processSingleChargeOrder( + ctx context.Context, + order WalmartOrder, + monarchTxns []*monarch.Transaction, + usedTxnIDs map[string]bool, + catCategories []categorizer.Category, + monarchCategories []*monarch.TransactionCategory, + bankCharge float64, + dryRun bool, +) (*ProcessResult, error) { // Single charge - check if it differs from order total (gift card scenario) - bankCharge := bankCharges[0] orderTotal := order.GetTotal() const epsilon = 0.01 // Allow 1 cent difference for floating point @@ -126,6 +197,35 @@ func (h *WalmartHandler) ProcessOrder( return h.processWithOrderTotal(ctx, order, monarchTxns, usedTxnIDs, catCategories, monarchCategories, dryRun) } +func (h *WalmartHandler) getRefundCharges(order WalmartOrder) []float64 { + refundOrder, ok := order.(WalmartOrderWithRefunds) + if !ok { + return nil + } + + refunds, err := refundOrder.GetRefundCharges() + if err != nil { + if strings.Contains(err.Error(), "payment pending") { + h.logDebug("No refund charges available for pending order", + "order_id", order.GetID()) + return nil + } + h.logWarn("Failed to get refund charges", + "order_id", order.GetID(), + "error", err) + return nil + } + + if len(refunds) > 0 { + h.logInfo("Got refund charges", + "order_id", order.GetID(), + "refund_count", len(refunds), + "refunds", refunds) + } + + return refunds +} + // processWithOrderTotal handles standard orders using GetTotal() for matching func (h *WalmartHandler) processWithOrderTotal( ctx context.Context, @@ -369,6 +469,50 @@ func sumCharges(charges []float64) float64 { return math.Round(total*100) / 100 } +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 { + result.Skipped = true + result.SkipReason = "refund item not identified" + return result, nil + } + refunds, err := h.processRefundCharges(ctx, order, monarchTxns, usedTxnIDs, catCategories, monarchCategories, refundCharges, refundItems, dryRun) + if err != nil { + result.Skipped = true + result.SkipReason = err.Error() + return result, nil + } + result.Processed = true + result.Refunds = refunds + if len(refunds) > 0 { + result.Transaction = refunds[0].Transaction + result.Splits = refunds[0].Splits + } + return result, nil +} + +func (h *WalmartHandler) processRefundCharges(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) ([]RefundProcessResult, error) { + refunds := make([]RefundProcessResult, 0, len(refundCharges)) + for _, amount := range refundCharges { + refundOrder := newRefundOrderView(order, amount, refundItems) + matchResult, err := h.matcher.FindMatch(refundOrder, monarchTxns, usedTxnIDs) + if err != nil { + return refunds, fmt.Errorf("refund match error: %w", err) + } + if matchResult == nil { + return refunds, fmt.Errorf("no matching refund transaction found for amount %.2f", amount) + } + usedTxnIDs[matchResult.Transaction.ID] = true + h.logInfo("Matched refund transaction", "order_id", order.GetID(), "transaction_id", matchResult.Transaction.ID, "refund_amount", amount, "date_diff_days", matchResult.DateDiff) + refundResult, err := h.categorizeAndApplySplits(ctx, refundOrder, matchResult.Transaction, catCategories, monarchCategories, dryRun) + if err != nil { + return refunds, fmt.Errorf("refund split creation error: %w", err) + } + refunds = append(refunds, RefundProcessResult{Amount: amount, Transaction: matchResult.Transaction, Splits: refundResult.Splits}) + } + return refunds, nil +} + // categorizeAndApplySplits applies categorization and splits to a transaction func (h *WalmartHandler) categorizeAndApplySplits( ctx context.Context, @@ -446,6 +590,121 @@ func (l *ledgerAmountOrder) GetTotal() float64 { return l.ledgerAmount } +type refundOrderView struct { + WalmartOrder + refundAmount float64 + items []providers.OrderItem +} + +func newRefundOrderView(order WalmartOrder, refundAmount float64, items []providers.OrderItem) *refundOrderView { + return &refundOrderView{ + WalmartOrder: order, + refundAmount: refundAmount, + items: scaleRefundItems(items, refundAmount), + } +} + +func (r *refundOrderView) GetTotal() float64 { + return -math.Abs(r.refundAmount) +} + +func (r *refundOrderView) GetSubtotal() float64 { + return math.Abs(r.refundAmount) +} + +func (r *refundOrderView) GetTax() float64 { + return 0 +} + +func (r *refundOrderView) GetTip() float64 { + return 0 +} + +func (r *refundOrderView) GetFees() float64 { + return 0 +} + +func (r *refundOrderView) GetItems() []providers.OrderItem { + return r.items +} + +func scaleRefundItems(items []providers.OrderItem, refundAmount float64) []providers.OrderItem { + if len(items) == 0 { + return []providers.OrderItem{refundOrderItem{name: "Walmart refund", price: math.Abs(refundAmount), quantity: 1}} + } + + itemTotal := 0.0 + for _, item := range items { + itemTotal += math.Abs(item.GetPrice()) + } + if itemTotal == 0 { + return []providers.OrderItem{refundOrderItem{name: "Walmart refund", price: math.Abs(refundAmount), quantity: 1}} + } + + scaled := make([]providers.OrderItem, 0, len(items)) + for _, item := range items { + scaledPrice := math.Abs(item.GetPrice()) / itemTotal * math.Abs(refundAmount) + scaled = append(scaled, refundOrderItem{ + original: item, + name: item.GetName(), + price: scaledPrice, + quantity: item.GetQuantity(), + }) + } + return scaled +} + +type refundOrderItem struct { + original providers.OrderItem + name string + price float64 + quantity float64 +} + +func (i refundOrderItem) GetName() string { + return i.name +} + +func (i refundOrderItem) GetPrice() float64 { + return i.price +} + +func (i refundOrderItem) GetQuantity() float64 { + if i.quantity == 0 { + return 1 + } + return i.quantity +} + +func (i refundOrderItem) GetUnitPrice() float64 { + quantity := i.GetQuantity() + if quantity == 0 { + return i.price + } + return i.price / quantity +} + +func (i refundOrderItem) GetDescription() string { + if i.original == nil { + return "" + } + return i.original.GetDescription() +} + +func (i refundOrderItem) GetSKU() string { + if i.original == nil { + return "" + } + return i.original.GetSKU() +} + +func (i refundOrderItem) GetCategory() string { + if i.original == nil { + return "" + } + return i.original.GetCategory() +} + // IsWalmartOrder checks if an order is a Walmart order func IsWalmartOrder(order providers.Order) bool { _, ok := order.(*walmartprovider.Order) diff --git a/internal/application/sync/handlers/walmart_test.go b/internal/application/sync/handlers/walmart_test.go index f4e9b1a..662fe39 100644 --- a/internal/application/sync/handlers/walmart_test.go +++ b/internal/application/sync/handlers/walmart_test.go @@ -29,7 +29,10 @@ type walmartTestOrder struct { tax float64 items []providers.OrderItem charges []float64 + refundCharges []float64 + refundItems []providers.OrderItem chargesErr error + refundsErr error isMultiDeliver bool } @@ -51,6 +54,17 @@ func (m *walmartTestOrder) GetFinalCharges() ([]float64, error) { return m.charges, nil } +func (m *walmartTestOrder) GetRefundCharges() ([]float64, error) { + if m.refundsErr != nil { + return nil, m.refundsErr + } + return m.refundCharges, nil +} + +func (m *walmartTestOrder) GetRefundItems() ([]providers.OrderItem, error) { + return m.refundItems, nil +} + func (m *walmartTestOrder) IsMultiDelivery() (bool, error) { return m.isMultiDeliver, nil } @@ -76,12 +90,34 @@ type walmartTestSplitter struct { categoryID string notes string err error + calls []walmartSplitterCall +} + +type walmartSplitterCall struct { + orderTotal float64 + transactionID string + transactionAmount float64 + itemTotal float64 + itemNames []string } func (m *walmartTestSplitter) CreateSplits(ctx context.Context, order providers.Order, transaction *monarch.Transaction, catCategories []categorizer.Category, monarchCategories []*monarch.TransactionCategory) ([]*monarch.TransactionSplit, error) { if m.err != nil { return nil, m.err } + itemTotal := 0.0 + itemNames := make([]string, 0, len(order.GetItems())) + for _, item := range order.GetItems() { + itemTotal += item.GetPrice() + itemNames = append(itemNames, item.GetName()) + } + m.calls = append(m.calls, walmartSplitterCall{ + orderTotal: order.GetTotal(), + transactionID: transaction.ID, + transactionAmount: transaction.Amount, + itemTotal: itemTotal, + itemNames: itemNames, + }) return m.splits, nil } @@ -93,16 +129,20 @@ func (m *walmartTestSplitter) GetSingleCategoryInfo(ctx context.Context, order p type walmartTestMonarch struct { updateCalled bool updateSplitsCaled bool + updateCount int + updateSplitsCount int err error } func (m *walmartTestMonarch) UpdateTransaction(ctx context.Context, id string, params *monarch.UpdateTransactionParams) error { m.updateCalled = true + m.updateCount++ return m.err } func (m *walmartTestMonarch) UpdateSplits(ctx context.Context, id string, splits []*monarch.TransactionSplit) error { m.updateSplitsCaled = true + m.updateSplitsCount++ return m.err } @@ -292,6 +332,167 @@ func TestWalmartHandler_ProcessOrder_GiftCard_UsesLedgerAmount(t *testing.T) { assert.False(t, usedTxnIDs["txn-other"], "Should not match order total amount") } +func TestWalmartHandler_ProcessOrder_ProcessesRefundCharge(t *testing.T) { + splitter := &walmartTestSplitter{categoryID: "groceries", notes: "Groceries"} + monarchClient := &walmartTestMonarch{} + handler := createTestWalmartHandler(t, splitter, nil, monarchClient) + + orderDate := time.Now() + order := &walmartTestOrder{ + id: "ORDER-REFUND", + date: orderDate, + total: 86.06, + subtotal: 80.00, + tax: 6.06, + items: []providers.OrderItem{&walmartTestItem{name: "Milk", price: 50.00, quantity: 1}, &walmartTestItem{name: "Bread", price: 30.00, quantity: 1}}, + charges: []float64{86.06}, + refundCharges: []float64{5.58}, + refundItems: []providers.OrderItem{&walmartTestItem{name: "Milk", price: 5.00, quantity: 1}}, + } + + txns := []*monarch.Transaction{ + {ID: "purchase-txn", Amount: -86.06, Date: walmartToMonarchDate(orderDate)}, + {ID: "refund-txn", Amount: 5.58, Date: walmartToMonarchDate(orderDate.AddDate(0, 0, 1))}, + } + + usedTxnIDs := make(map[string]bool) + + result, err := handler.ProcessOrder( + context.Background(), + order, + txns, + usedTxnIDs, + nil, nil, + false, + ) + + require.NoError(t, err) + assert.True(t, result.Processed) + assert.True(t, usedTxnIDs["purchase-txn"], "purchase transaction should be matched") + assert.True(t, usedTxnIDs["refund-txn"], "refund credit transaction should be matched") + require.Len(t, result.Refunds, 1) + assert.Equal(t, "refund-txn", result.Refunds[0].Transaction.ID) + assert.Equal(t, 2, monarchClient.updateCount, "purchase and refund should both be categorized") + require.Len(t, splitter.calls, 2) + assert.Equal(t, -5.58, splitter.calls[1].orderTotal, "refund order view should match positive Monarch credits") + assert.InDelta(t, 5.58, splitter.calls[1].itemTotal, 0.01, "refund item prices should be scaled to the refund amount") +} + +func TestWalmartHandler_ProcessOrder_CategorizesIdentifiedRefundItemOnly(t *testing.T) { + splitter := &walmartTestSplitter{categoryID: "groceries", notes: "Groceries: Chobani creamer"} + monarchClient := &walmartTestMonarch{} + handler := createTestWalmartHandler(t, splitter, nil, monarchClient) + + orderDate := time.Now() + order := &walmartTestOrder{ + id: "ORDER-REFUND-ITEM", + date: orderDate, + total: 20, + items: []providers.OrderItem{&walmartTestItem{name: "Unrelated item", price: 14.74, quantity: 1}, &walmartTestItem{name: "Chobani Coffee Creamer", price: 5.26, quantity: 1}}, + charges: []float64{20}, + refundCharges: []float64{5.58}, + refundItems: []providers.OrderItem{&walmartTestItem{name: "Chobani Coffee Creamer", price: 5.26, quantity: 1}}, + } + txns := []*monarch.Transaction{ + {ID: "purchase", Amount: -20, Date: walmartToMonarchDate(orderDate)}, + {ID: "refund", Amount: 5.58, Date: walmartToMonarchDate(orderDate)}, + } + + _, err := handler.ProcessOrder(context.Background(), order, txns, map[string]bool{}, nil, nil, true) + require.NoError(t, err) + require.Len(t, splitter.calls, 2) + assert.Equal(t, []string{"Chobani Coffee Creamer"}, splitter.calls[1].itemNames) + assert.InDelta(t, 5.58, splitter.calls[1].itemTotal, 0.01, "refund amount should include the refunded item's tax") +} + +func TestWalmartHandler_ProcessOrder_ProcessesRefundOnlyLedger(t *testing.T) { + splitter := &walmartTestSplitter{categoryID: "groceries", notes: "Groceries"} + monarchClient := &walmartTestMonarch{} + handler := createTestWalmartHandler(t, splitter, nil, monarchClient) + + orderDate := time.Now() + order := &walmartTestOrder{ + id: "ORDER-FULL-REFUND", + date: orderDate, + total: 15.00, + subtotal: 15.00, + items: []providers.OrderItem{&walmartTestItem{name: "Returned item", price: 15.00, quantity: 1}}, + chargesErr: errors.New("no positive charges found (order may be fully refunded or paid entirely with gift card)"), + refundCharges: []float64{15.00}, + refundItems: []providers.OrderItem{&walmartTestItem{name: "Returned item", price: 15.00, quantity: 1}}, + } + + txns := []*monarch.Transaction{ + {ID: "refund-only-txn", Amount: 15.00, Date: walmartToMonarchDate(orderDate)}, + } + + usedTxnIDs := make(map[string]bool) + + result, err := handler.ProcessOrder( + context.Background(), + order, + txns, + usedTxnIDs, + nil, nil, + false, + ) + + require.NoError(t, err) + assert.True(t, result.Processed) + assert.False(t, result.Skipped) + assert.True(t, usedTxnIDs["refund-only-txn"]) + require.Len(t, result.Refunds, 1) + assert.Equal(t, "refund-only-txn", result.Transaction.ID) + assert.Equal(t, 1, monarchClient.updateCount) +} + +func TestWalmartHandler_ProcessOrder_ProcessesRefundWhenPurchaseAlreadyConsolidated(t *testing.T) { + splitter := &walmartTestSplitter{categoryID: "groceries", notes: "Groceries"} + monarchClient := &walmartTestMonarch{} + handler := createTestWalmartHandler(t, splitter, nil, monarchClient) + + orderDate := time.Now() + order := &walmartTestOrder{ + id: "ORDER-REFUND-AFTER-CONSOLIDATION", + date: orderDate, + total: 86.06, + subtotal: 80.00, + tax: 6.06, + items: []providers.OrderItem{&walmartTestItem{name: "Milk", price: 50.00, quantity: 1}, &walmartTestItem{name: "Bread", price: 30.00, quantity: 1}}, + charges: []float64{4.63, 3.48, 16.90, 5.80, 47.73, 5.27, 2.25}, + refundCharges: []float64{5.58}, + refundItems: []providers.OrderItem{&walmartTestItem{name: "Milk", price: 5.00, quantity: 1}}, + isMultiDeliver: true, + } + + txns := []*monarch.Transaction{ + // This is the already-consolidated purchase. Aggregate matching recognizes + // it when the individual ledger charge rows no longer exist in Monarch. + {ID: "consolidated-purchase", Amount: -86.06, Date: walmartToMonarchDate(orderDate)}, + {ID: "refund-txn", Amount: 5.58, Date: walmartToMonarchDate(orderDate.AddDate(0, 0, 1))}, + } + + usedTxnIDs := make(map[string]bool) + + result, err := handler.ProcessOrder( + context.Background(), + order, + txns, + usedTxnIDs, + nil, nil, + false, + ) + + require.NoError(t, err) + assert.True(t, result.Processed) + assert.False(t, result.Skipped) + assert.True(t, usedTxnIDs["consolidated-purchase"], "aggregate fallback should match the consolidated purchase") + assert.True(t, usedTxnIDs["refund-txn"], "refund should still be processed") + require.Len(t, result.Refunds, 1) + assert.Equal(t, "consolidated-purchase", result.Transaction.ID) + assert.Equal(t, 2, monarchClient.updateCount) +} + func TestWalmartHandler_ProcessOrder_NoMatch_ReturnsSkipped(t *testing.T) { handler := createTestWalmartHandler(t, nil, nil, nil) diff --git a/internal/application/sync/recording.go b/internal/application/sync/recording.go index 1648d86..97cbc90 100644 --- a/internal/application/sync/recording.go +++ b/internal/application/sync/recording.go @@ -220,6 +220,24 @@ func (o *Orchestrator) recordOrderTransactions(order providers.Order, record *st o.logger.Error("Failed to save order transaction", "order_id", order.GetID(), "transaction_id", result.Transaction.ID, "error", err) } } + for _, refund := range result.Refunds { + if refund.Transaction == nil { + continue + } + if result.Transaction != nil && refund.Transaction.ID == result.Transaction.ID { + continue + } + if err := o.storage.SaveOrderTransaction(&storage.OrderTransaction{ + RunID: o.runID, + OrderID: order.GetID(), + TransactionID: refund.Transaction.ID, + Role: "refund", + Amount: refund.Transaction.Amount, + Notes: refund.Transaction.Notes, + }); err != nil { + o.logger.Error("Failed to save refund order transaction", "order_id", order.GetID(), "transaction_id", refund.Transaction.ID, "error", err) + } + } } // extractFeesBreakdown extracts fee breakdown from provider-specific order data