diff --git a/go.mod b/go.mod index 6889ed6..845410f 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/eshaffer321/monarchmoney-sync-backend go 1.25.10 require ( - github.com/eshaffer321/costco-go v0.3.6 + github.com/eshaffer321/costco-go v0.3.9 github.com/eshaffer321/monarchmoney-go v1.0.5 github.com/eshaffer321/walmart-client-go/v2 v2.0.1 github.com/go-chi/chi/v5 v5.2.5 diff --git a/go.sum b/go.sum index 450ec3f..c91b116 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/eshaffer321/costco-go v0.3.6 h1:C8SR/UvgMyFIGBoF8KlugpuTBoCo/khK6zm7QDprUco= -github.com/eshaffer321/costco-go v0.3.6/go.mod h1:pIIKOjw+KyiQ2+xn9EUdZTIRPbts3b4mU7+OzDY/Jdo= +github.com/eshaffer321/costco-go v0.3.9 h1:HoRcJHMljCJY1DQECQ4hsM3hzAEAqfzX8CckEwNin9o= +github.com/eshaffer321/costco-go v0.3.9/go.mod h1:ANJTHfRSPyot9cWKNlfs5qDKRKkA4tYORrorvWx/vbM= github.com/eshaffer321/monarchmoney-go v1.0.5 h1:V3iP0bvB1q3+m9Gkl6KBCFL44sNsiHDbTxSOU9fHZco= github.com/eshaffer321/monarchmoney-go v1.0.5/go.mod h1:ZKPCYT7NcsKGI+YpJ2EqPtfE3dKfuPbiTUrj6J84ot4= github.com/eshaffer321/walmart-client-go/v2 v2.0.1 h1:R8NFqKqfdri02Jhmr6jOMpCLAzjdiRbStLtjGKo6WaA= diff --git a/internal/adapters/providers/costco/provider.go b/internal/adapters/providers/costco/provider.go index 29a27f8..a11661b 100644 --- a/internal/adapters/providers/costco/provider.go +++ b/internal/adapters/providers/costco/provider.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "log/slog" - "strings" "time" costcogo "github.com/eshaffer321/costco-go/pkg/costco" @@ -245,51 +244,23 @@ func (p *Provider) convertReceipt(receipt *costcogo.Receipt, hasDetails bool) pr var items []providers.OrderItem if hasDetails { - // Build maps to net out discount line items - itemMap := make(map[string]*CostcoOrderItem) - descMap := make(map[string]*CostcoOrderItem) // fallback: keyed by uppercased description - - // First pass: collect all non-discount items - for _, item := range receipt.ItemArray { - if !item.IsDiscount() { - entry := &CostcoOrderItem{ - name: item.ItemDescription01, - price: item.Amount, - quantity: float64(item.Unit), - unitPrice: item.ItemUnitPriceAmount, - sku: item.ItemNumber, - description: fmt.Sprintf("%s %s", item.ItemDescription01, item.ItemDescription02), - } - itemMap[item.ItemNumber] = entry - descMap[strings.ToUpper(strings.TrimSpace(item.ItemDescription01))] = entry - } - } - - // Second pass: apply discounts to parent items - for _, item := range receipt.ItemArray { - if item.IsDiscount() { - parentRef := item.GetParentItemNumber() - parentItem, exists := itemMap[parentRef] - if !exists { - // Costco sometimes references the parent by description (e.g. "/AAA BATTERY") - // rather than by item number (e.g. "/1234567"). - parentItem, exists = descMap[strings.ToUpper(parentRef)] - } - if exists { - parentItem.price += item.Amount - } else { - p.logger.Warn("found orphaned discount with no matching parent item", - "discount_item", item.ItemNumber, - "parent_item_ref", parentRef, - "discount_amount", item.Amount, - ) - } - } + netted, orphaned := costcogo.NetDiscounts(receipt.ItemArray) + for _, disc := range orphaned { + p.logger.Warn("found orphaned discount with no matching parent item", + "discount_item", disc.ItemNumber, + "parent_item_ref", disc.GetParentItemNumber(), + "discount_amount", disc.Amount, + ) } - - // Convert map to slice - for _, item := range itemMap { - items = append(items, item) + for _, item := range netted { + items = append(items, &CostcoOrderItem{ + name: item.ItemDescription01, + price: item.Amount, + quantity: float64(item.Unit), + unitPrice: item.ItemUnitPriceAmount, + sku: item.ItemNumber, + description: fmt.Sprintf("%s %s", item.ItemDescription01, item.ItemDescription02), + }) } } diff --git a/internal/adapters/providers/costco/provider_test.go b/internal/adapters/providers/costco/provider_test.go index 899c2fc..ec4d345 100644 --- a/internal/adapters/providers/costco/provider_test.go +++ b/internal/adapters/providers/costco/provider_test.go @@ -500,4 +500,38 @@ func TestConvertReceipt_DiscountNetting(t *testing.T) { assert.Equal(t, "AAA BATTERY", items[0].GetName()) assert.Equal(t, 12.49, items[0].GetPrice(), "Discount should be applied via description fallback") }) + + t.Run("discount references parent by partial description substring", func(t *testing.T) { + // Real receipts sometimes have the parent item named e.g. "AA/AAA BATTERY" but the + // discount line references it as "/AAA BATTERY" — neither item-number nor exact + // description match will succeed, so we need a substring/contains fallback. + receipt := &costcogo.Receipt{ + TransactionBarcode: "TEST506", + TransactionDate: "2025-10-20", + Total: 12.49, + SubTotal: 12.49, + Taxes: 0.00, + ItemArray: []costcogo.ReceiptItem{ + { + ItemNumber: "379938", + ItemDescription01: "AA/AAA BATTERY", // full name differs from discount ref + Amount: 14.99, + Unit: 1, + }, + { + ItemNumber: "999001", + ItemDescription01: "/AAA BATTERY", // partial description ref + Amount: -2.50, + Unit: -1, + }, + }, + } + + order := provider.convertReceipt(receipt, true) + items := order.GetItems() + + require.Len(t, items, 1, "Should have 1 item after netting partial-description-referenced discount") + assert.Equal(t, "AA/AAA BATTERY", items[0].GetName()) + assert.Equal(t, 12.49, items[0].GetPrice(), "Discount should be applied via partial description fallback") + }) }