From db31604b9c4824d1a7490dd39c6e620f3f9af6c6 Mon Sep 17 00:00:00 2001 From: Erick Shaffer Date: Sun, 31 May 2026 07:38:28 -0600 Subject: [PATCH] fix: match Costco mixed-tender card charges --- docs/bug-fixes.md | 29 +++++++- .../adapters/providers/costco/provider.go | 72 +++++++++++++++++-- .../providers/costco/provider_test.go | 46 ++++++++++++ 3 files changed, 141 insertions(+), 6 deletions(-) diff --git a/docs/bug-fixes.md b/docs/bug-fixes.md index 25d7662..a802444 100644 --- a/docs/bug-fixes.md +++ b/docs/bug-fixes.md @@ -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:** @@ -108,4 +135,4 @@ Explanation of why the bug occurred. ### Performance - Memory leaks - Inefficient queries -- N+1 problems \ No newline at end of file +- N+1 problems diff --git a/internal/adapters/providers/costco/provider.go b/internal/adapters/providers/costco/provider.go index a11661b..893372d 100644 --- a/internal/adapters/providers/costco/provider.go +++ b/internal/adapters/providers/costco/provider.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "log/slog" + "math" + "strings" "time" costcogo "github.com/eshaffer321/costco-go/pkg/costco" @@ -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) @@ -253,23 +257,32 @@ 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", @@ -277,6 +290,55 @@ func (p *Provider) convertReceipt(receipt *costcogo.Receipt, hasDetails bool) pr } } +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 diff --git a/internal/adapters/providers/costco/provider_test.go b/internal/adapters/providers/costco/provider_test.go index ec4d345..86f3af2 100644 --- a/internal/adapters/providers/costco/provider_test.go +++ b/internal/adapters/providers/costco/provider_test.go @@ -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) +}