Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/bug-fixes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
106 changes: 67 additions & 39 deletions internal/adapters/providers/walmart/order.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,44 +195,16 @@ 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
if len(ledger.PaymentMethods) == 0 {
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
Expand All @@ -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)
}
}
}
}
Expand All @@ -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) {
Expand Down
48 changes: 48 additions & 0 deletions internal/adapters/providers/walmart/order_multi_delivery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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"},
Expand Down
8 changes: 8 additions & 0 deletions internal/application/sync/handlers/amazon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading