diff --git a/docs/bug-fixes.md b/docs/bug-fixes.md index e9dc214..3e0b885 100644 --- a/docs/bug-fixes.md +++ b/docs/bug-fixes.md @@ -12,6 +12,29 @@ Each bug fix entry should include: ## Bug Fixes +### 2026-07-16: Completed Walmart in-store receipts were treated as payment pending + +**Description:** +Walmart returned completed in-store receipts with a credit-card payment method and final total, but its separate order-ledger endpoint returned no payment methods for those receipts. Itemize treated every empty ledger as an uncharged online order and skipped the in-store purchases indefinitely as `payment pending`. + +**Test Case:** +```go +// internal/adapters/providers/walmart/order_multi_delivery_test.go: +// TestOrder_GetFinalCharges/completed_in-store_order_falls_back_to_its_credit-card_total_when_ledger_is_empty +// Expected: a completed IN_STORE order with one CREDITCARD payment returns its receipt total as the bank charge. +``` + +**Root Cause:** +`GetFinalCharges()` assumed Walmart's order-ledger endpoint was populated for every fulfillment type. Live completed in-store orders retain their payment metadata on the order payload but have an empty ledger response. + +**Fix Applied:** +When the ledger is empty, Itemize now uses the receipt total only for an `IN_STORE` order with exactly one `CREDITCARD` payment method and a positive total. Other empty-ledger orders keep the existing payment-pending behavior, avoiding unsafe guesses for split-tender or gift-card purchases. + +**Verification:** +- The regression test failed before the fix with `order not yet charged (payment pending)` and passes after it. +- A live run of the rebuilt v0.2.1 source reproduced all three completed in-store receipts being skipped with an empty ledger before the fix. +- The fixed binary dry-ran all three affected receipts against live Walmart and Monarch data, matched `$9.88`, `$32.10`, and `$10.69` transactions, and finished with `Processed=3 Skipped=0 Errors=0` without writing to Monarch. + ### 2026-07-15: Walmart detail requests were rejected with HTTP 456 **Description:** diff --git a/internal/adapters/providers/walmart/order.go b/internal/adapters/providers/walmart/order.go index 5ec5ee4..ffd9cbb 100644 --- a/internal/adapters/providers/walmart/order.go +++ b/internal/adapters/providers/walmart/order.go @@ -202,6 +202,14 @@ func (o *Order) GetFinalCharges() ([]float64, error) { // Extract and validate charges if len(ledger.PaymentMethods) == 0 { + if charge, ok := o.inStoreCreditCardCharge(); ok { + if o.logger != nil { + o.logger.Debug("Using completed in-store order total because Walmart ledger is empty", + "order_id", o.GetID(), + "charge", charge) + } + return []float64{charge}, nil + } return nil, fmt.Errorf("order not yet charged (payment pending)") } @@ -237,6 +245,22 @@ func (o *Order) GetFinalCharges() ([]float64, error) { return positiveCharges, nil } +// inStoreCreditCardCharge returns the single bank charge represented by a +// completed in-store receipt. Walmart does not populate the order-ledger API +// for these receipts, so the receipt total is the authoritative charge when +// its order payload identifies one credit-card payment method. +func (o *Order) inStoreCreditCardCharge() (float64, bool) { + if o.walmartOrder.Type != "IN_STORE" || len(o.walmartOrder.PaymentMethods) != 1 { + return 0, false + } + if o.walmartOrder.PaymentMethods[0].PaymentType != "CREDITCARD" { + return 0, false + } + + charge := o.GetTotal() + return charge, charge > 0 +} + // 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. diff --git a/internal/adapters/providers/walmart/order_multi_delivery_test.go b/internal/adapters/providers/walmart/order_multi_delivery_test.go index 8cf2e46..4b77e12 100644 --- a/internal/adapters/providers/walmart/order_multi_delivery_test.go +++ b/internal/adapters/providers/walmart/order_multi_delivery_test.go @@ -1,6 +1,8 @@ package walmart import ( + "io" + "log/slog" "testing" walmartclient "github.com/eshaffer321/walmart-client-go/v2" @@ -120,6 +122,84 @@ func TestOrder_GetFinalCharges(t *testing.T) { assert.Contains(t, err.Error(), "payment pending") }) + t.Run("completed in-store order falls back to its credit-card total when ledger is empty", func(t *testing.T) { + order := &Order{ + walmartOrder: &walmartclient.Order{ + ID: "IN-STORE-123", + Type: "IN_STORE", + PriceDetails: &walmartclient.OrderPriceDetails{ + GrandTotal: &walmartclient.PriceLineItem{Value: 9.88}, + }, + PaymentMethods: []walmartclient.OrderPaymentMethod{{PaymentType: "CREDITCARD"}}, + }, + ledgerCache: &walmartclient.OrderLedger{ + OrderID: "IN-STORE-123", + PaymentMethods: []walmartclient.PaymentMethodCharges{}, + }, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + + charges, err := order.GetFinalCharges() + require.NoError(t, err) + assert.Equal(t, []float64{9.88}, charges) + }) + + t.Run("empty in-store ledger does not guess an ambiguous charge", func(t *testing.T) { + tests := []struct { + name string + orderType string + paymentMethods []walmartclient.OrderPaymentMethod + total float64 + }{ + { + name: "online order", + orderType: "GLASS", + paymentMethods: []walmartclient.OrderPaymentMethod{{PaymentType: "CREDITCARD"}}, + total: 9.88, + }, + { + name: "split tender", + orderType: "IN_STORE", + paymentMethods: []walmartclient.OrderPaymentMethod{ + {PaymentType: "CREDITCARD"}, + {PaymentType: "GIFTCARD"}, + }, + total: 9.88, + }, + { + name: "gift card only", + orderType: "IN_STORE", + paymentMethods: []walmartclient.OrderPaymentMethod{{PaymentType: "GIFTCARD"}}, + total: 9.88, + }, + { + name: "non-positive total", + orderType: "IN_STORE", + paymentMethods: []walmartclient.OrderPaymentMethod{{PaymentType: "CREDITCARD"}}, + total: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + order := &Order{ + walmartOrder: &walmartclient.Order{ + ID: "AMBIGUOUS-IN-STORE", + Type: tt.orderType, + PaymentMethods: tt.paymentMethods, + PriceDetails: &walmartclient.OrderPriceDetails{ + GrandTotal: &walmartclient.PriceLineItem{Value: tt.total}, + }, + }, + ledgerCache: &walmartclient.OrderLedger{OrderID: "AMBIGUOUS-IN-STORE"}, + } + + _, err := order.GetFinalCharges() + require.ErrorContains(t, err, "payment pending") + }) + } + }) + t.Run("error - client not available", func(t *testing.T) { order := &Order{ walmartOrder: &walmartclient.Order{ID: "TEST001"},