From d36cdeaf6452a6cf56f8c1f81aced73ac3a6d88d Mon Sep 17 00:00:00 2001 From: Erick Shaffer Date: Fri, 10 Jul 2026 22:07:48 -0600 Subject: [PATCH 1/5] fix: categorize walmart refund items --- docs/bug-fixes.md | 38 +++ internal/adapters/providers/walmart/order.go | 119 ++++--- .../walmart/order_multi_delivery_test.go | 25 ++ .../adapters/providers/walmart/provider.go | 20 +- .../adapters/providers/walmart/refunds.go | 206 +++++++++++ .../providers/walmart/refunds_test.go | 23 ++ internal/application/sync/handlers/amazon.go | 8 + internal/application/sync/handlers/walmart.go | 319 +++++++++++++++++- .../application/sync/handlers/walmart_test.go | 205 ++++++++++- internal/application/sync/recording.go | 18 + 10 files changed, 920 insertions(+), 61 deletions(-) create mode 100644 internal/adapters/providers/walmart/refunds.go create mode 100644 internal/adapters/providers/walmart/refunds_test.go diff --git a/docs/bug-fixes.md b/docs/bug-fixes.md index 0be2d77..9e9f15b 100644 --- a/docs/bug-fixes.md +++ b/docs/bug-fixes.md @@ -12,6 +12,44 @@ Each bug fix entry should include: ## Bug Fixes +### 2026-07-10: Walmart ledger refunds were skipped instead of matched to Monarch credits + +**Description:** +Walmart order ledgers can include negative credit-card final charges for refunds. Itemize logged `Skipping refund in ledger (not yet supported)`, matched only the purchase charges, and left the corresponding positive Monarch refund transaction uncategorized. Refund-only ledgers could also fall back toward normal order-total matching after `no positive charges found`. + +**Test Case:** +```go +// internal/adapters/providers/walmart/order_multi_delivery_test.go: TestOrder_GetFinalCharges/returns_refund_charges_separately +// Expected: negative CREDITCARD ledger entries are exposed as positive refund amounts, while gift-card credits are ignored. + +// internal/application/sync/handlers/walmart_test.go: TestWalmartHandler_ProcessOrder_ProcessesRefundCharge +// Expected: a purchase plus refund ledger matches and categorizes both the negative purchase transaction and positive refund credit. + +// internal/application/sync/handlers/walmart_test.go: TestWalmartHandler_ProcessOrder_ProcessesRefundOnlyLedger +// Expected: a ledger with no positive charges can still process a matching refund credit instead of falling back to purchase matching. + +// internal/application/sync/handlers/walmart_test.go: TestWalmartHandler_ProcessOrder_ProcessesRefundWhenPurchaseAlreadyConsolidated +// Expected: if a forced historical rerun can no longer match original multi-delivery component charges, +// a matching refund credit is still categorized. + +// internal/application/sync/handlers/walmart_test.go: TestWalmartHandler_ProcessOrder_CategorizesIdentifiedRefundItemOnly +// Expected: when Walmart marks one item with returnId, the refund categorizer receives only that item. + +// internal/adapters/providers/walmart/refunds_test.go: TestFindReturnedItems_UsesWalmartReturnIDAndDeduplicatesUIViews +// Expected: the duplicated UI representations of one returned item resolve to one refunded item. +``` + +**Fix Applied:** +Added Walmart refund charge extraction, refund-aware handler processing, and refund audit rows in `order_transactions`. Refunds are matched as positive Monarch credits by wrapping the order with a negative total for matching. Walmart's `getOrder` response carries `returnId` on refunded items, but the upstream typed client discards it; Itemize now makes a refund-only supplemental request and extracts those items. An identified refund is categorized and noted from that item alone; ambiguous or multi-refund cases remain untouched instead of being split across the original cart. Refund processing still runs when a forced historical rerun cannot re-match already-consolidated multi-delivery purchase components. + +**Verification:** +- `go test ./internal/adapters/providers/walmart -count=1` passes. +- `go test ./internal/application/sync/handlers -count=1` passes. +- `go test ./internal/application/sync -count=1` passes. +- `./itemize walmart -dry-run -force -days 14 -order-id 200014872726122 -verbose` detected refund `5.58`, extracted the returned Chobani Coffee Creamer item, matched positive Monarch transaction `248897036973870035`, and dry-ran a single-category update. + +--- + ### 2026-07-08: Costco split success could be overwritten by later no-match failure **Description:** diff --git a/internal/adapters/providers/walmart/order.go b/internal/adapters/providers/walmart/order.go index 8f10075..79a0092 100644 --- a/internal/adapters/providers/walmart/order.go +++ b/internal/adapters/providers/walmart/order.go @@ -20,7 +20,11 @@ type Order struct { // ledgerCache stores the order ledger to avoid duplicate API calls. // Note: Assumes single-threaded access per Order instance. // The sync orchestrator processes orders sequentially, so no mutex needed. - ledgerCache *walmartclient.OrderLedger + ledgerCache *walmartclient.OrderLedger + isInStore bool + refundItemFetcher refundItemFetcher + refundItemsCache []providers.OrderItem + refundItemsFetched bool } // GetID returns the order ID @@ -195,30 +199,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 +209,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 +225,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 +241,75 @@ 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) { + if o.refundItemsFetched { + return o.refundItemsCache, nil + } + o.refundItemsFetched = true + if o.refundItemFetcher == nil { + return nil, nil + } + items, err := o.refundItemFetcher(o.ctx, o.GetID(), o.isInStore) + if err != nil { + return nil, err + } + o.refundItemsCache = items + 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..883b3a7 100644 --- a/internal/adapters/providers/walmart/order_multi_delivery_test.go +++ b/internal/adapters/providers/walmart/order_multi_delivery_test.go @@ -150,6 +150,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/adapters/providers/walmart/provider.go b/internal/adapters/providers/walmart/provider.go index cbbbb5f..18a2614 100644 --- a/internal/adapters/providers/walmart/provider.go +++ b/internal/adapters/providers/walmart/provider.go @@ -84,10 +84,12 @@ func (p *Provider) FetchOrders(ctx context.Context, opts providers.FetchOptions) } providerOrders = append(providerOrders, &Order{ - walmartOrder: fullOrder, - client: p.client, - logger: p.logger, - ctx: ctx, + walmartOrder: fullOrder, + client: p.client, + logger: p.logger, + ctx: ctx, + isInStore: isInStore, + refundItemFetcher: newRefundItemFetcher(), }) } else { // For basic listing, we'd need to create a minimal Order @@ -102,10 +104,12 @@ func (p *Provider) FetchOrders(ctx context.Context, opts providers.FetchOptions) } providerOrders = append(providerOrders, &Order{ - walmartOrder: fullOrder, - client: p.client, - logger: p.logger, - ctx: ctx, + walmartOrder: fullOrder, + client: p.client, + logger: p.logger, + ctx: ctx, + isInStore: isInStore, + refundItemFetcher: newRefundItemFetcher(), }) } } diff --git a/internal/adapters/providers/walmart/refunds.go b/internal/adapters/providers/walmart/refunds.go new file mode 100644 index 0000000..cc7dd1e --- /dev/null +++ b/internal/adapters/providers/walmart/refunds.go @@ -0,0 +1,206 @@ +package walmart + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/eshaffer321/itemize/internal/adapters/providers" +) + +type refundItemFetcher func(context.Context, string, bool) ([]providers.OrderItem, error) + +type cookieFile struct { + Cookies map[string]struct { + Value string `json:"value"` + } `json:"cookies"` +} + +// newRefundItemFetcher reads the item-level returnId fields that Walmart's Go +// client currently omits from its typed Order model. +func newRefundItemFetcher() refundItemFetcher { + home, err := os.UserHomeDir() + if err != nil { + return func(context.Context, string, bool) ([]providers.OrderItem, error) { + return nil, fmt.Errorf("locating Walmart cookie file: %w", err) + } + } + cookiePath := filepath.Join(home, ".walmart-api", "cookies.json") + + return func(ctx context.Context, orderID string, isInStore bool) ([]providers.OrderItem, error) { + cookies, err := loadCookies(cookiePath) + if err != nil { + return nil, err + } + + variables, err := json.Marshal(map[string]interface{}{ + "orderId": orderID, "orderIsInStore": isInStore, "clickThroughGroupId": "0", + "enableIsWcpOrder": false, "enabledFeatures": []string{"csat-northstar-v1", "tips", "delivery-fees"}, + "enableSignOnDelivery": true, "includeTipDetails": true, "includeFeesDetails": true, + }) + if err != nil { + return nil, fmt.Errorf("encoding Walmart order request: %w", err) + } + endpoint, err := url.Parse("https://www.walmart.com/orchestra/orders/graphql/getOrder/d0622497daef19150438d07c506739d451cad6749cf45c3b4db95f2f5a0a65c4") + if err != nil { + return nil, fmt.Errorf("parsing Walmart order endpoint: %w", err) + } + query := endpoint.Query() + query.Set("variables", string(variables)) + endpoint.RawQuery = query.Encode() + + client := &http.Client{Timeout: 30 * time.Second} + for attempt := 0; attempt < 3; attempt++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, fmt.Errorf("creating Walmart refund-item request: %w", err) + } + setRefundItemHeaders(req, cookies) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching Walmart refund items: %w", err) + } + if resp.StatusCode == http.StatusTooManyRequests && attempt < 2 { + resp.Body.Close() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(2 * time.Second): + continue + } + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf("fetching Walmart refund items: HTTP %d", resp.StatusCode) + } + var payload json.RawMessage + decodeErr := json.NewDecoder(resp.Body).Decode(&payload) + resp.Body.Close() + if decodeErr != nil { + return nil, fmt.Errorf("decoding Walmart refund items: %w", decodeErr) + } + return findReturnedItems(payload), nil + } + return nil, fmt.Errorf("fetching Walmart refund items: rate limit retries exhausted") + } +} + +func setRefundItemHeaders(req *http.Request, cookies string) { + req.Header.Set("Accept", "application/json") + req.Header.Set("Accept-Language", "en-US") + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36") + req.Header.Set("X-Apollo-Operation-Name", "getOrder") + req.Header.Set("X-O-Gql-Query", "query getOrder") + req.Header.Set("X-O-Platform", "rweb") + req.Header.Set("X-O-Bu", "WALMART-US") + req.Header.Set("X-O-Mart", "B2C") + req.Header.Set("X-O-Segment", "oaoh") + correlationID := fmt.Sprintf("walmart-go-%d", time.Now().Unix()) + req.Header.Set("X-O-Correlation-Id", correlationID) + req.Header.Set("Wm-Qos.Correlation_Id", correlationID) + req.Header.Set("Wm-Mp", "true") + req.Header.Set("Sec-Fetch-Site", "same-origin") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Dest", "empty") + req.Header.Set("Dnt", "1") + req.Header.Set("X-O-Platform-Version", "usweb-1.221.0") + req.Header.Set("X-Enable-Server-Timing", "1") + req.Header.Set("X-Latency-Trace", "1") + req.Header.Set("Cookie", cookies) +} + +func loadCookies(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("reading Walmart cookies: %w", err) + } + var parsed cookieFile + if err := json.Unmarshal(data, &parsed); err != nil { + return "", fmt.Errorf("decoding Walmart cookies: %w", err) + } + names := make([]string, 0, len(parsed.Cookies)) + for name := range parsed.Cookies { + names = append(names, name) + } + sort.Strings(names) + pairs := make([]string, 0, len(names)) + for _, name := range names { + pairs = append(pairs, name+"="+parsed.Cookies[name].Value) + } + return strings.Join(pairs, "; "), nil +} + +func findReturnedItems(payload json.RawMessage) []providers.OrderItem { + seen := make(map[string]bool) + var items []providers.OrderItem + var walk func(json.RawMessage) + walk = func(value json.RawMessage) { + var object map[string]json.RawMessage + if json.Unmarshal(value, &object) == nil { + if returnedItem, ok := parseReturnedItem(object); ok && !seen[returnedItem.key] { + seen[returnedItem.key] = true + items = append(items, returnedItem) + } + for _, child := range object { + walk(child) + } + return + } + var array []json.RawMessage + if json.Unmarshal(value, &array) == nil { + for _, child := range array { + walk(child) + } + } + } + walk(payload) + return items +} + +type returnedItem struct { + key, name, sku string + price, quantity float64 +} + +func (i returnedItem) GetName() string { return i.name } +func (i returnedItem) GetPrice() float64 { return i.price } +func (i returnedItem) GetQuantity() float64 { return i.quantity } +func (i returnedItem) GetUnitPrice() float64 { + if i.quantity == 0 { + return i.price + } + return i.price / i.quantity +} +func (i returnedItem) GetDescription() string { return i.name } +func (i returnedItem) GetSKU() string { return i.sku } +func (i returnedItem) GetCategory() string { return "" } + +func parseReturnedItem(object map[string]json.RawMessage) (returnedItem, bool) { + var returnID string + if err := json.Unmarshal(object["returnId"], &returnID); err != nil || returnID == "" { + return returnedItem{}, false + } + var product struct { + Name string `json:"name"` + SKU string `json:"usItemId"` + } + var price struct { + LinePrice struct { + Value float64 `json:"value"` + } `json:"linePrice"` + } + var quantity float64 + if json.Unmarshal(object["productInfo"], &product) != nil || json.Unmarshal(object["priceInfo"], &price) != nil || product.Name == "" { + return returnedItem{}, false + } + _ = json.Unmarshal(object["quantity"], &quantity) + return returnedItem{key: returnID + ":" + product.SKU, name: product.Name, sku: product.SKU, price: price.LinePrice.Value, quantity: quantity}, true +} diff --git a/internal/adapters/providers/walmart/refunds_test.go b/internal/adapters/providers/walmart/refunds_test.go new file mode 100644 index 0000000..01aa833 --- /dev/null +++ b/internal/adapters/providers/walmart/refunds_test.go @@ -0,0 +1,23 @@ +package walmart + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindReturnedItems_UsesWalmartReturnIDAndDeduplicatesUIViews(t *testing.T) { + payload := []byte(`{ + "data": {"order": {"groups_2101": [{ + "categories": [{"items": [{"returnId": "1", "quantity": 1, "productInfo": {"name": "Chobani Coffee Creamer", "usItemId": "15162760729"}, "priceInfo": {"linePrice": {"value": 5.26}}}]}], + "subGroups": [{"categories": [{"items": [{"returnId": "1", "quantity": 1, "productInfo": {"name": "Chobani Coffee Creamer", "usItemId": "15162760729"}, "priceInfo": {"linePrice": {"value": 5.26}}}]}]}] + }]}} +}`) + + items := findReturnedItems(payload) + require.Len(t, items, 1) + assert.Equal(t, "Chobani Coffee Creamer", items[0].GetName()) + assert.Equal(t, "15162760729", items[0].GetSKU()) + assert.InDelta(t, 5.26, items[0].GetPrice(), 0.001) +} 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 121960b..6fd27e3 100644 --- a/internal/application/sync/handlers/walmart.go +++ b/internal/application/sync/handlers/walmart.go @@ -10,11 +10,11 @@ import ( "strings" "time" - "github.com/eshaffer321/monarch-go/v2/pkg/monarch" "github.com/eshaffer321/itemize/internal/adapters/providers" walmartprovider "github.com/eshaffer321/itemize/internal/adapters/providers/walmart" "github.com/eshaffer321/itemize/internal/domain/categorizer" "github.com/eshaffer321/itemize/internal/domain/matcher" + "github.com/eshaffer321/monarch-go/v2/pkg/monarch" ) // WalmartOrder extends providers.Order with Walmart-specific methods @@ -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, @@ -279,6 +379,88 @@ func (h *WalmartHandler) processMultiDeliveryOrder( return h.categorizeAndApplySplits(ctx, order, consolidatedTxn, catCategories, monarchCategories, dryRun) } +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, @@ -356,6 +538,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 5bb0ae7..c3f79a5 100644 --- a/internal/application/sync/handlers/walmart_test.go +++ b/internal/application/sync/handlers/walmart_test.go @@ -8,10 +8,10 @@ import ( "testing" "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/domain/matcher" + "github.com/eshaffer321/monarch-go/v2/pkg/monarch" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -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 } @@ -266,7 +306,7 @@ func TestWalmartHandler_ProcessOrder_GiftCard_UsesLedgerAmount(t *testing.T) { } txns := []*monarch.Transaction{ - {ID: "txn-gc", Amount: -70.00, Date: walmartToMonarchDate(orderDate)}, // Matches ledger amount + {ID: "txn-gc", Amount: -70.00, Date: walmartToMonarchDate(orderDate)}, // Matches ledger amount {ID: "txn-other", Amount: -100.00, Date: walmartToMonarchDate(orderDate)}, // Would match order total } @@ -288,6 +328,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. It no longer matches the + // individual ledger charge rows during a forced historical rerun. + {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.False(t, usedTxnIDs["consolidated-purchase"], "already-consolidated purchase should not be forced into component matching") + assert.True(t, usedTxnIDs["refund-txn"], "refund should still be processed") + require.Len(t, result.Refunds, 1) + assert.Equal(t, "refund-txn", result.Transaction.ID) + assert.Equal(t, 1, 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 From ae06700d0dd4ee8aeb4783374b924d5c5bcc2f6b Mon Sep 17 00:00:00 2001 From: Erick Shaffer Date: Fri, 10 Jul 2026 22:14:45 -0600 Subject: [PATCH 2/5] fix: handle walmart refund response closes --- internal/adapters/providers/walmart/refunds.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/internal/adapters/providers/walmart/refunds.go b/internal/adapters/providers/walmart/refunds.go index cc7dd1e..b7825bd 100644 --- a/internal/adapters/providers/walmart/refunds.go +++ b/internal/adapters/providers/walmart/refunds.go @@ -69,7 +69,9 @@ func newRefundItemFetcher() refundItemFetcher { return nil, fmt.Errorf("fetching Walmart refund items: %w", err) } if resp.StatusCode == http.StatusTooManyRequests && attempt < 2 { - resp.Body.Close() + if err := resp.Body.Close(); err != nil { + return nil, fmt.Errorf("closing rate-limited Walmart refund response: %w", err) + } select { case <-ctx.Done(): return nil, ctx.Err() @@ -78,15 +80,20 @@ func newRefundItemFetcher() refundItemFetcher { } } if resp.StatusCode != http.StatusOK { - resp.Body.Close() + if err := resp.Body.Close(); err != nil { + return nil, fmt.Errorf("closing Walmart refund response: %w", err) + } return nil, fmt.Errorf("fetching Walmart refund items: HTTP %d", resp.StatusCode) } var payload json.RawMessage decodeErr := json.NewDecoder(resp.Body).Decode(&payload) - resp.Body.Close() + closeErr := resp.Body.Close() if decodeErr != nil { return nil, fmt.Errorf("decoding Walmart refund items: %w", decodeErr) } + if closeErr != nil { + return nil, fmt.Errorf("closing Walmart refund response: %w", closeErr) + } return findReturnedItems(payload), nil } return nil, fmt.Errorf("fetching Walmart refund items: rate limit retries exhausted") @@ -118,7 +125,7 @@ func setRefundItemHeaders(req *http.Request, cookies string) { } func loadCookies(path string) (string, error) { - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) // #nosec G304 -- path is built from the current user's home directory. if err != nil { return "", fmt.Errorf("reading Walmart cookies: %w", err) } From 8d4e721c28b6dd71bef803a3b08a9f331b826f33 Mon Sep 17 00:00:00 2001 From: Erick Shaffer Date: Fri, 10 Jul 2026 22:16:10 -0600 Subject: [PATCH 3/5] test: cover walmart refund item fetch --- .../walmart/order_multi_delivery_test.go | 22 ++++++++++++ .../adapters/providers/walmart/refunds.go | 4 ++- .../providers/walmart/refunds_test.go | 34 +++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/internal/adapters/providers/walmart/order_multi_delivery_test.go b/internal/adapters/providers/walmart/order_multi_delivery_test.go index 883b3a7..ac16bdc 100644 --- a/internal/adapters/providers/walmart/order_multi_delivery_test.go +++ b/internal/adapters/providers/walmart/order_multi_delivery_test.go @@ -1,13 +1,35 @@ package walmart import ( + "context" "testing" + "github.com/eshaffer321/itemize/internal/adapters/providers" walmartclient "github.com/eshaffer321/walmart-client-go/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestOrder_GetRefundItems_CachesFetchedItems(t *testing.T) { + calls := 0 + order := &Order{ + walmartOrder: &walmartclient.Order{ID: "REFUND-ITEMS"}, + ctx: context.Background(), + refundItemFetcher: func(context.Context, string, bool) ([]providers.OrderItem, error) { + calls++ + return []providers.OrderItem{returnedItem{name: "Refunded item", price: 4.5, quantity: 1}}, nil + }, + } + + items, err := order.GetRefundItems() + require.NoError(t, err) + require.Len(t, items, 1) + items, err = order.GetRefundItems() + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, 1, calls) +} + // TestOrder_GetFinalCharges tests retrieving final charges from order ledger func TestOrder_GetFinalCharges(t *testing.T) { t.Run("single delivery order", func(t *testing.T) { diff --git a/internal/adapters/providers/walmart/refunds.go b/internal/adapters/providers/walmart/refunds.go index b7825bd..ba63e60 100644 --- a/internal/adapters/providers/walmart/refunds.go +++ b/internal/adapters/providers/walmart/refunds.go @@ -17,6 +17,8 @@ import ( type refundItemFetcher func(context.Context, string, bool) ([]providers.OrderItem, error) +var refundItemEndpoint = "https://www.walmart.com/orchestra/orders/graphql/getOrder/d0622497daef19150438d07c506739d451cad6749cf45c3b4db95f2f5a0a65c4" + type cookieFile struct { Cookies map[string]struct { Value string `json:"value"` @@ -48,7 +50,7 @@ func newRefundItemFetcher() refundItemFetcher { if err != nil { return nil, fmt.Errorf("encoding Walmart order request: %w", err) } - endpoint, err := url.Parse("https://www.walmart.com/orchestra/orders/graphql/getOrder/d0622497daef19150438d07c506739d451cad6749cf45c3b4db95f2f5a0a65c4") + endpoint, err := url.Parse(refundItemEndpoint) if err != nil { return nil, fmt.Errorf("parsing Walmart order endpoint: %w", err) } diff --git a/internal/adapters/providers/walmart/refunds_test.go b/internal/adapters/providers/walmart/refunds_test.go index 01aa833..e205053 100644 --- a/internal/adapters/providers/walmart/refunds_test.go +++ b/internal/adapters/providers/walmart/refunds_test.go @@ -1,12 +1,46 @@ package walmart import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestNewRefundItemFetcher_FetchesReturnedItem(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + cookieDir := filepath.Join(home, ".walmart-api") + require.NoError(t, os.MkdirAll(cookieDir, 0700)) + require.NoError(t, os.WriteFile(filepath.Join(cookieDir, "cookies.json"), []byte(`{"cookies":{"auth":{"value":"test-cookie"}}}`), 0600)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "test-cookie", r.Header.Get("Cookie")[5:]) + assert.Equal(t, "getOrder", r.Header.Get("X-Apollo-Operation-Name")) + _, err := w.Write([]byte(`{"data":{"order":{"groups_2101":[{"categories":[{"items":[{"returnId":"1","quantity":1,"productInfo":{"name":"Refunded creamer","usItemId":"sku-1"},"priceInfo":{"linePrice":{"value":5.26}}}]}]}]}}}`)) + require.NoError(t, err) + })) + defer server.Close() + + originalEndpoint := refundItemEndpoint + refundItemEndpoint = server.URL + t.Cleanup(func() { refundItemEndpoint = originalEndpoint }) + + items, err := newRefundItemFetcher()(context.Background(), "order-1", false) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "Refunded creamer", items[0].GetName()) + assert.Equal(t, 1.0, items[0].GetQuantity()) + assert.InDelta(t, 5.26, items[0].GetUnitPrice(), 0.001) + assert.Equal(t, "Refunded creamer", items[0].GetDescription()) + assert.Empty(t, items[0].GetCategory()) +} + func TestFindReturnedItems_UsesWalmartReturnIDAndDeduplicatesUIViews(t *testing.T) { payload := []byte(`{ "data": {"order": {"groups_2101": [{ From 8cb6db7a1a72c65cc405d3bfe5cc6ec3f63b9ff0 Mon Sep 17 00:00:00 2001 From: Erick Shaffer Date: Fri, 10 Jul 2026 22:28:30 -0600 Subject: [PATCH 4/5] refactor: use walmart returned item API --- docs/bug-fixes.md | 2 +- go.mod | 2 +- go.sum | 2 + internal/adapters/providers/walmart/order.go | 21 +- .../walmart/order_multi_delivery_test.go | 33 +-- .../adapters/providers/walmart/provider.go | 20 +- .../adapters/providers/walmart/refunds.go | 215 ------------------ .../providers/walmart/refunds_test.go | 57 ----- 8 files changed, 34 insertions(+), 318 deletions(-) delete mode 100644 internal/adapters/providers/walmart/refunds.go delete mode 100644 internal/adapters/providers/walmart/refunds_test.go diff --git a/docs/bug-fixes.md b/docs/bug-fixes.md index 5a7106b..a02993c 100644 --- a/docs/bug-fixes.md +++ b/docs/bug-fixes.md @@ -18,7 +18,7 @@ Each bug fix entry should include: 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, make a supplemental order request to extract its `returnId` item, and categorize/note only that item. Ambiguous or multi-refund orders remain untouched. +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. 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..ad2aae4 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/eshaffer321/monarch-go/v2 v2.0.0 h1:3reQ15D0K/Btc3mu+sfWdKJnU0hmNb0/Z 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 79a0092..b91aae2 100644 --- a/internal/adapters/providers/walmart/order.go +++ b/internal/adapters/providers/walmart/order.go @@ -20,11 +20,7 @@ type Order struct { // ledgerCache stores the order ledger to avoid duplicate API calls. // Note: Assumes single-threaded access per Order instance. // The sync orchestrator processes orders sequentially, so no mutex needed. - ledgerCache *walmartclient.OrderLedger - isInStore bool - refundItemFetcher refundItemFetcher - refundItemsCache []providers.OrderItem - refundItemsFetched bool + ledgerCache *walmartclient.OrderLedger } // GetID returns the order ID @@ -272,18 +268,11 @@ func (o *Order) GetRefundCharges() ([]float64, error) { // GetRefundItems returns the item(s) that Walmart explicitly marked as refunded. func (o *Order) GetRefundItems() ([]providers.OrderItem, error) { - if o.refundItemsFetched { - return o.refundItemsCache, nil + refunded := o.walmartOrder.GetRefundedItems() + items := make([]providers.OrderItem, 0, len(refunded)) + for _, item := range refunded { + items = append(items, &OrderItem{item: item}) } - o.refundItemsFetched = true - if o.refundItemFetcher == nil { - return nil, nil - } - items, err := o.refundItemFetcher(o.ctx, o.GetID(), o.isInStore) - if err != nil { - return nil, err - } - o.refundItemsCache = items return items, nil } diff --git a/internal/adapters/providers/walmart/order_multi_delivery_test.go b/internal/adapters/providers/walmart/order_multi_delivery_test.go index ac16bdc..8cf2e46 100644 --- a/internal/adapters/providers/walmart/order_multi_delivery_test.go +++ b/internal/adapters/providers/walmart/order_multi_delivery_test.go @@ -1,33 +1,34 @@ package walmart import ( - "context" "testing" - "github.com/eshaffer321/itemize/internal/adapters/providers" walmartclient "github.com/eshaffer321/walmart-client-go/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestOrder_GetRefundItems_CachesFetchedItems(t *testing.T) { - calls := 0 - order := &Order{ - walmartOrder: &walmartclient.Order{ID: "REFUND-ITEMS"}, - ctx: context.Background(), - refundItemFetcher: func(context.Context, string, bool) ([]providers.OrderItem, error) { - calls++ - return []providers.OrderItem{returnedItem{name: "Refunded item", price: 4.5, quantity: 1}}, nil - }, - } +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) - items, err = order.GetRefundItems() - require.NoError(t, err) - require.Len(t, items, 1) - assert.Equal(t, 1, calls) + 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 diff --git a/internal/adapters/providers/walmart/provider.go b/internal/adapters/providers/walmart/provider.go index 4656a33..73b873e 100644 --- a/internal/adapters/providers/walmart/provider.go +++ b/internal/adapters/providers/walmart/provider.go @@ -90,12 +90,10 @@ func (p *Provider) FetchOrders(ctx context.Context, opts providers.FetchOptions) } providerOrders = append(providerOrders, &Order{ - walmartOrder: fullOrder, - client: p.client, - logger: p.logger, - ctx: ctx, - isInStore: isInStore, - refundItemFetcher: newRefundItemFetcher(), + walmartOrder: fullOrder, + client: p.client, + logger: p.logger, + ctx: ctx, }) } else { // For basic listing, we'd need to create a minimal Order @@ -110,12 +108,10 @@ func (p *Provider) FetchOrders(ctx context.Context, opts providers.FetchOptions) } providerOrders = append(providerOrders, &Order{ - walmartOrder: fullOrder, - client: p.client, - logger: p.logger, - ctx: ctx, - isInStore: isInStore, - refundItemFetcher: newRefundItemFetcher(), + walmartOrder: fullOrder, + client: p.client, + logger: p.logger, + ctx: ctx, }) } } diff --git a/internal/adapters/providers/walmart/refunds.go b/internal/adapters/providers/walmart/refunds.go deleted file mode 100644 index ba63e60..0000000 --- a/internal/adapters/providers/walmart/refunds.go +++ /dev/null @@ -1,215 +0,0 @@ -package walmart - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "os" - "path/filepath" - "sort" - "strings" - "time" - - "github.com/eshaffer321/itemize/internal/adapters/providers" -) - -type refundItemFetcher func(context.Context, string, bool) ([]providers.OrderItem, error) - -var refundItemEndpoint = "https://www.walmart.com/orchestra/orders/graphql/getOrder/d0622497daef19150438d07c506739d451cad6749cf45c3b4db95f2f5a0a65c4" - -type cookieFile struct { - Cookies map[string]struct { - Value string `json:"value"` - } `json:"cookies"` -} - -// newRefundItemFetcher reads the item-level returnId fields that Walmart's Go -// client currently omits from its typed Order model. -func newRefundItemFetcher() refundItemFetcher { - home, err := os.UserHomeDir() - if err != nil { - return func(context.Context, string, bool) ([]providers.OrderItem, error) { - return nil, fmt.Errorf("locating Walmart cookie file: %w", err) - } - } - cookiePath := filepath.Join(home, ".walmart-api", "cookies.json") - - return func(ctx context.Context, orderID string, isInStore bool) ([]providers.OrderItem, error) { - cookies, err := loadCookies(cookiePath) - if err != nil { - return nil, err - } - - variables, err := json.Marshal(map[string]interface{}{ - "orderId": orderID, "orderIsInStore": isInStore, "clickThroughGroupId": "0", - "enableIsWcpOrder": false, "enabledFeatures": []string{"csat-northstar-v1", "tips", "delivery-fees"}, - "enableSignOnDelivery": true, "includeTipDetails": true, "includeFeesDetails": true, - }) - if err != nil { - return nil, fmt.Errorf("encoding Walmart order request: %w", err) - } - endpoint, err := url.Parse(refundItemEndpoint) - if err != nil { - return nil, fmt.Errorf("parsing Walmart order endpoint: %w", err) - } - query := endpoint.Query() - query.Set("variables", string(variables)) - endpoint.RawQuery = query.Encode() - - client := &http.Client{Timeout: 30 * time.Second} - for attempt := 0; attempt < 3; attempt++ { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) - if err != nil { - return nil, fmt.Errorf("creating Walmart refund-item request: %w", err) - } - setRefundItemHeaders(req, cookies) - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("fetching Walmart refund items: %w", err) - } - if resp.StatusCode == http.StatusTooManyRequests && attempt < 2 { - if err := resp.Body.Close(); err != nil { - return nil, fmt.Errorf("closing rate-limited Walmart refund response: %w", err) - } - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(2 * time.Second): - continue - } - } - if resp.StatusCode != http.StatusOK { - if err := resp.Body.Close(); err != nil { - return nil, fmt.Errorf("closing Walmart refund response: %w", err) - } - return nil, fmt.Errorf("fetching Walmart refund items: HTTP %d", resp.StatusCode) - } - var payload json.RawMessage - decodeErr := json.NewDecoder(resp.Body).Decode(&payload) - closeErr := resp.Body.Close() - if decodeErr != nil { - return nil, fmt.Errorf("decoding Walmart refund items: %w", decodeErr) - } - if closeErr != nil { - return nil, fmt.Errorf("closing Walmart refund response: %w", closeErr) - } - return findReturnedItems(payload), nil - } - return nil, fmt.Errorf("fetching Walmart refund items: rate limit retries exhausted") - } -} - -func setRefundItemHeaders(req *http.Request, cookies string) { - req.Header.Set("Accept", "application/json") - req.Header.Set("Accept-Language", "en-US") - req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36") - req.Header.Set("X-Apollo-Operation-Name", "getOrder") - req.Header.Set("X-O-Gql-Query", "query getOrder") - req.Header.Set("X-O-Platform", "rweb") - req.Header.Set("X-O-Bu", "WALMART-US") - req.Header.Set("X-O-Mart", "B2C") - req.Header.Set("X-O-Segment", "oaoh") - correlationID := fmt.Sprintf("walmart-go-%d", time.Now().Unix()) - req.Header.Set("X-O-Correlation-Id", correlationID) - req.Header.Set("Wm-Qos.Correlation_Id", correlationID) - req.Header.Set("Wm-Mp", "true") - req.Header.Set("Sec-Fetch-Site", "same-origin") - req.Header.Set("Sec-Fetch-Mode", "cors") - req.Header.Set("Sec-Fetch-Dest", "empty") - req.Header.Set("Dnt", "1") - req.Header.Set("X-O-Platform-Version", "usweb-1.221.0") - req.Header.Set("X-Enable-Server-Timing", "1") - req.Header.Set("X-Latency-Trace", "1") - req.Header.Set("Cookie", cookies) -} - -func loadCookies(path string) (string, error) { - data, err := os.ReadFile(path) // #nosec G304 -- path is built from the current user's home directory. - if err != nil { - return "", fmt.Errorf("reading Walmart cookies: %w", err) - } - var parsed cookieFile - if err := json.Unmarshal(data, &parsed); err != nil { - return "", fmt.Errorf("decoding Walmart cookies: %w", err) - } - names := make([]string, 0, len(parsed.Cookies)) - for name := range parsed.Cookies { - names = append(names, name) - } - sort.Strings(names) - pairs := make([]string, 0, len(names)) - for _, name := range names { - pairs = append(pairs, name+"="+parsed.Cookies[name].Value) - } - return strings.Join(pairs, "; "), nil -} - -func findReturnedItems(payload json.RawMessage) []providers.OrderItem { - seen := make(map[string]bool) - var items []providers.OrderItem - var walk func(json.RawMessage) - walk = func(value json.RawMessage) { - var object map[string]json.RawMessage - if json.Unmarshal(value, &object) == nil { - if returnedItem, ok := parseReturnedItem(object); ok && !seen[returnedItem.key] { - seen[returnedItem.key] = true - items = append(items, returnedItem) - } - for _, child := range object { - walk(child) - } - return - } - var array []json.RawMessage - if json.Unmarshal(value, &array) == nil { - for _, child := range array { - walk(child) - } - } - } - walk(payload) - return items -} - -type returnedItem struct { - key, name, sku string - price, quantity float64 -} - -func (i returnedItem) GetName() string { return i.name } -func (i returnedItem) GetPrice() float64 { return i.price } -func (i returnedItem) GetQuantity() float64 { return i.quantity } -func (i returnedItem) GetUnitPrice() float64 { - if i.quantity == 0 { - return i.price - } - return i.price / i.quantity -} -func (i returnedItem) GetDescription() string { return i.name } -func (i returnedItem) GetSKU() string { return i.sku } -func (i returnedItem) GetCategory() string { return "" } - -func parseReturnedItem(object map[string]json.RawMessage) (returnedItem, bool) { - var returnID string - if err := json.Unmarshal(object["returnId"], &returnID); err != nil || returnID == "" { - return returnedItem{}, false - } - var product struct { - Name string `json:"name"` - SKU string `json:"usItemId"` - } - var price struct { - LinePrice struct { - Value float64 `json:"value"` - } `json:"linePrice"` - } - var quantity float64 - if json.Unmarshal(object["productInfo"], &product) != nil || json.Unmarshal(object["priceInfo"], &price) != nil || product.Name == "" { - return returnedItem{}, false - } - _ = json.Unmarshal(object["quantity"], &quantity) - return returnedItem{key: returnID + ":" + product.SKU, name: product.Name, sku: product.SKU, price: price.LinePrice.Value, quantity: quantity}, true -} diff --git a/internal/adapters/providers/walmart/refunds_test.go b/internal/adapters/providers/walmart/refunds_test.go deleted file mode 100644 index e205053..0000000 --- a/internal/adapters/providers/walmart/refunds_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package walmart - -import ( - "context" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNewRefundItemFetcher_FetchesReturnedItem(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - cookieDir := filepath.Join(home, ".walmart-api") - require.NoError(t, os.MkdirAll(cookieDir, 0700)) - require.NoError(t, os.WriteFile(filepath.Join(cookieDir, "cookies.json"), []byte(`{"cookies":{"auth":{"value":"test-cookie"}}}`), 0600)) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "test-cookie", r.Header.Get("Cookie")[5:]) - assert.Equal(t, "getOrder", r.Header.Get("X-Apollo-Operation-Name")) - _, err := w.Write([]byte(`{"data":{"order":{"groups_2101":[{"categories":[{"items":[{"returnId":"1","quantity":1,"productInfo":{"name":"Refunded creamer","usItemId":"sku-1"},"priceInfo":{"linePrice":{"value":5.26}}}]}]}]}}}`)) - require.NoError(t, err) - })) - defer server.Close() - - originalEndpoint := refundItemEndpoint - refundItemEndpoint = server.URL - t.Cleanup(func() { refundItemEndpoint = originalEndpoint }) - - items, err := newRefundItemFetcher()(context.Background(), "order-1", false) - require.NoError(t, err) - require.Len(t, items, 1) - assert.Equal(t, "Refunded creamer", items[0].GetName()) - assert.Equal(t, 1.0, items[0].GetQuantity()) - assert.InDelta(t, 5.26, items[0].GetUnitPrice(), 0.001) - assert.Equal(t, "Refunded creamer", items[0].GetDescription()) - assert.Empty(t, items[0].GetCategory()) -} - -func TestFindReturnedItems_UsesWalmartReturnIDAndDeduplicatesUIViews(t *testing.T) { - payload := []byte(`{ - "data": {"order": {"groups_2101": [{ - "categories": [{"items": [{"returnId": "1", "quantity": 1, "productInfo": {"name": "Chobani Coffee Creamer", "usItemId": "15162760729"}, "priceInfo": {"linePrice": {"value": 5.26}}}]}], - "subGroups": [{"categories": [{"items": [{"returnId": "1", "quantity": 1, "productInfo": {"name": "Chobani Coffee Creamer", "usItemId": "15162760729"}, "priceInfo": {"linePrice": {"value": 5.26}}}]}]}] - }]}} -}`) - - items := findReturnedItems(payload) - require.Len(t, items, 1) - assert.Equal(t, "Chobani Coffee Creamer", items[0].GetName()) - assert.Equal(t, "15162760729", items[0].GetSKU()) - assert.InDelta(t, 5.26, items[0].GetPrice(), 0.001) -} From 00f6d5824476b622aaba72619e4309c01217bd5c Mon Sep 17 00:00:00 2001 From: Erick Shaffer Date: Fri, 10 Jul 2026 22:31:11 -0600 Subject: [PATCH 5/5] chore: tidy walmart client checksums --- go.sum | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.sum b/go.sum index ad2aae4..6837fa7 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,6 @@ 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=