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
29 changes: 28 additions & 1 deletion docs/bug-fixes.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,33 @@ Each bug fix entry should include:

## Bug Fixes

### 2026-05-31: Costco mixed-tender receipts matched against full receipt total

**Description:**
Costco in-warehouse receipts can be paid with multiple tenders, such as part Costco Visa and part cash/rebate. Itemize matched the full receipt total against Monarch, so mixed-tender receipts were skipped when Monarch only had the card-funded portion.

**Test Case:**
```go
// provider_test.go: TestConvertReceipt_MixedTenderUsesCardAmount
// Receipt total is $150.00, paid $100.00 by COSTCO VISA and $50.00 cash.
// Expected: order total is $100.00 and items/subtotal/tax are scaled to the card-paid portion.
// Actual before fix: order total remained $150.00, so no Monarch transaction matched.
```

**Root Cause:**
`convertReceipt` always used `receipt.Total` for `Order.GetTotal()`. For mixed tender purchases, the matchable Monarch transaction is the bank-card tender amount, not the full Costco receipt total.

**Fix Applied:**
Added Costco tender detection for bank-card payments, use the card tender total as the order total when present, and proportionally allocate item prices, subtotal, and tax to that card-paid amount.

**Verification:**
- New test `TestConvertReceipt_MixedTenderUsesCardAmount` fails before the fix and passes after.
- Existing Costco discount-netting tests pass.
- `go test ./...` passes.
- Temporary-db dry-run for receipt `21134300601232605091211` matched Monarch transaction `243571290918193554` at `$209.65`.

---

### 2026-05-24: Costco discount applied by description instead of item number

**Description:**
Expand Down Expand Up @@ -108,4 +135,4 @@ Explanation of why the bug occurred.
### Performance
- Memory leaks
- Inefficient queries
- N+1 problems
- N+1 problems
72 changes: 67 additions & 5 deletions internal/adapters/providers/costco/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"fmt"
"log/slog"
"math"
"strings"
"time"

costcogo "github.com/eshaffer321/costco-go/pkg/costco"
Expand Down Expand Up @@ -242,6 +244,8 @@ func (p *Provider) convertReceipt(receipt *costcogo.Receipt, hasDetails bool) pr
}
}

chargeTotal, allocationRatio := receiptChargeTotal(receipt)

var items []providers.OrderItem
if hasDetails {
netted, orphaned := costcogo.NetDiscounts(receipt.ItemArray)
Expand All @@ -253,30 +257,88 @@ func (p *Provider) convertReceipt(receipt *costcogo.Receipt, hasDetails bool) pr
)
}
for _, item := range netted {
itemAmount := roundCurrency(item.Amount * allocationRatio)
unitPrice := item.ItemUnitPriceAmount
if unitPrice != 0 {
unitPrice = roundCurrency(unitPrice * allocationRatio)
}

items = append(items, &CostcoOrderItem{
name: item.ItemDescription01,
price: item.Amount,
price: itemAmount,
quantity: float64(item.Unit),
unitPrice: item.ItemUnitPriceAmount,
unitPrice: unitPrice,
sku: item.ItemNumber,
description: fmt.Sprintf("%s %s", item.ItemDescription01, item.ItemDescription02),
})
}
}

subtotal := roundCurrency(receipt.SubTotal * allocationRatio)
tax := roundCurrency(chargeTotal - subtotal)

return &CostcoOrder{
id: receipt.TransactionBarcode,
date: receiptDate,
total: receipt.Total,
subtotal: receipt.SubTotal,
tax: receipt.Taxes,
total: chargeTotal,
subtotal: subtotal,
tax: tax,
items: items,
providerName: "Costco",
orderType: "receipt",
rawData: receipt,
}
}

func receiptChargeTotal(receipt *costcogo.Receipt) (float64, float64) {
total := roundCurrency(receipt.Total)
cardTotal := 0.0

for _, tender := range receipt.TenderArray {
if isBankCardTender(tender) {
cardTotal += tender.AmountTender
}
}

if cardTotal == 0 || receipt.Total == 0 {
return total, 1.0
}

if receipt.Total < 0 {
cardTotal = -math.Abs(cardTotal)
} else {
cardTotal = math.Abs(cardTotal)
}

chargeTotal := roundCurrency(cardTotal)
return chargeTotal, math.Abs(chargeTotal / receipt.Total)
}

func isBankCardTender(tender costcogo.Tender) bool {
description := strings.ToUpper(strings.TrimSpace(tender.TenderDescription))
typeName := strings.ToUpper(strings.TrimSpace(tender.TenderTypeName))
value := description + " " + typeName

excluded := []string{"CASH", "CHANGE", "REBATE", "SHOP CARD", "GIFT CARD"}
for _, term := range excluded {
if strings.Contains(value, term) {
return false
}
}

cardTerms := []string{"VISA", "MASTERCARD", "DEBIT", "DISCOVER", "AMEX", "AMERICAN EXPRESS"}
for _, term := range cardTerms {
if strings.Contains(value, term) {
return true
}
}
return false
}

func roundCurrency(amount float64) float64 {
return math.Round(amount*100) / 100
}

// CostcoOrder implements the Order interface for Costco
type CostcoOrder struct {
id string
Expand Down
46 changes: 46 additions & 0 deletions internal/adapters/providers/costco/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -535,3 +535,49 @@ func TestConvertReceipt_DiscountNetting(t *testing.T) {
assert.Equal(t, 12.49, items[0].GetPrice(), "Discount should be applied via partial description fallback")
})
}

func TestConvertReceipt_MixedTenderUsesCardAmount(t *testing.T) {
logger := slog.Default()
provider := NewProvider(nil, logger)

receipt := &costcogo.Receipt{
TransactionBarcode: "MIXED123",
TransactionDate: "2026-05-09",
Total: 150.00,
SubTotal: 140.00,
Taxes: 10.00,
ItemArray: []costcogo.ReceiptItem{
{
ItemNumber: "111111",
ItemDescription01: "ITEM A",
Amount: 70.00,
Unit: 1,
ItemUnitPriceAmount: 70.00,
},
{
ItemNumber: "222222",
ItemDescription01: "ITEM B",
Amount: 70.00,
Unit: 1,
ItemUnitPriceAmount: 70.00,
},
},
TenderArray: []costcogo.Tender{
{TenderDescription: "COSTCO VISA", AmountTender: 100.00},
{TenderDescription: "CASH", AmountTender: 50.00},
},
}

order := provider.convertReceipt(receipt, true)

assert.InDelta(t, 100.00, order.GetTotal(), 0.001, "Order total should match the card charge, not the receipt total")
assert.InDelta(t, 93.33, order.GetSubtotal(), 0.001, "Subtotal should be allocated to the card-paid portion")
assert.InDelta(t, 6.67, order.GetTax(), 0.001, "Tax should be allocated to the card-paid portion")

items := order.GetItems()
require.Len(t, items, 2)
assert.InDelta(t, 46.67, items[0].GetPrice(), 0.001)
assert.InDelta(t, 46.67, items[0].GetUnitPrice(), 0.001)
assert.InDelta(t, 46.67, items[1].GetPrice(), 0.001)
assert.InDelta(t, 46.67, items[1].GetUnitPrice(), 0.001)
}
Loading