diff --git a/docs/bug-fixes.md b/docs/bug-fixes.md index d92941b..cf346e9 100644 --- a/docs/bug-fixes.md +++ b/docs/bug-fixes.md @@ -29,6 +29,31 @@ The pull-request vulnerability scan failed with seven reachable vulnerabilities **Commit:** Included in the pull request for this fix. +### 2026-10-13: Amazon refunds split between a card and points were never categorized + +**Description:** +A +$31.24 Amazon credit stayed in `[TEMP] Amazon`. It refunded order `112-8070743-2513824`, which was paid with $31.24 on a card plus $11.15 in Amazon Visa points. Every sync logged `Amazon refund left untouched ... no unique temporary Amazon credit found for $42.39`. + +**Test Case:** +- `TestRefundOrderUsesCardPortionWhenRefundWasSplitAcrossTenders`, `TestAttachRefundTendersAssignsOrderRefundRowsToReturns`, and `TestRefundedOrderDoesNotCountRefundRowsAsCharges` (Amazon provider) +- `TestAmazonHandler_ProcessRefundMatchesCardPortionOfSplitTenderRefund`, `TestAmazonHandler_ProcessRefundSkipsRefundWithNoBankCredit`, and `TestAmazonHandler_ProcessRefundGroupUsesCardPortion` (handler) +- `TestProcessAmazonReturnsMatchesCardPortionOfSplitTenderRefund` (orchestrator) + +**Root Cause:** +The Return Center reports only the refund total across all payment methods ($42.39), and the matcher used that total as the expected bank credit. The per-tender split appears only on Amazon's payment transactions page, and `amazon-go v0.4.0` discarded it there: it dropped the `+` sign and the `Refund:` label, so refund rows looked like extra charges, and it gave every row the page's last date. + +**Fix Applied:** +- `amazon-go v0.5.0` adds `Transaction.IsRefund` and dates each row from its own heading. +- Itemize maps `IsRefund` to refund transactions, so a refunded order no longer appears to have duplicate card charges. +- `FetchReturns` fetches each refunded order's transactions and attaches a `RefundTenders` breakdown to the return. It does this only when the refund rows can be tied to that return unambiguously and add up to the Return Center total. +- Refund matching, item pricing, and same-day grouping now use `BankRefundAmount()`, the card portion of the refund. A refund that went entirely to points or gift-card balance is skipped with an explicit reason. A return without a breakdown keeps the previous behavior. + +**Verification:** +- With the fix disabled, all seven new tests failed; with it enabled, they pass. `go test ./... -race` passes. +- `./itemize amazon -account wife -dry-run -days 14` now reports `Categorized Amazon refund order_id=112-8070743-2513824 ... refund_amount=42.39 bank_refund_amount=31.24` against the +$31.24 Monarch credit. + +**Commit:** See PR. + ### 2026-10-03: Amazon multi-category splits added allocated tax twice **Description:** diff --git a/go.mod b/go.mod index e476862..999b97e 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.9 require ( github.com/PuerkitoBio/goquery v1.13.0 - github.com/eshaffer321/amazon-go v0.4.0 + github.com/eshaffer321/amazon-go v0.5.0 github.com/eshaffer321/costco-go v0.3.11 github.com/eshaffer321/monarch-go/v2 v2.1.0 github.com/eshaffer321/walmart-client-go/v2 v2.2.1 diff --git a/go.sum b/go.sum index ae051d9..3fc15ae 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q3 github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM= 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/amazon-go v0.4.0 h1:HMNcXCHkEsbdJze0gTu4oBLhMiy1qEl7+TaC3kCA8bg= -github.com/eshaffer321/amazon-go v0.4.0/go.mod h1:4nXAvyIk9EvcAAe/oTA66izwLdu0LAoyEp3A24nAaBs= +github.com/eshaffer321/amazon-go v0.5.0 h1:9/NZpZ55afx9OM9+ymaTUVU0oscLABlvHpIAyBViM5M= +github.com/eshaffer321/amazon-go v0.5.0/go.mod h1:4nXAvyIk9EvcAAe/oTA66izwLdu0LAoyEp3A24nAaBs= github.com/eshaffer321/costco-go v0.3.11 h1:vOEXcSj/vGlwE/4AbXK3uUdXG7XYg1qfUPViujJRS7Q= github.com/eshaffer321/costco-go v0.3.11/go.mod h1:ANJTHfRSPyot9cWKNlfs5qDKRKkA4tYORrorvWx/vbM= github.com/eshaffer321/monarch-go/v2 v2.1.0 h1:qsYIPv5ha/WEcwQLnbTdup2sg5F9L1724aM5FS3s2uI= diff --git a/internal/adapters/providers/amazon/provider.go b/internal/adapters/providers/amazon/provider.go index 694650f..5ac5365 100644 --- a/internal/adapters/providers/amazon/provider.go +++ b/internal/adapters/providers/amazon/provider.go @@ -382,7 +382,7 @@ func convertGoOrder(order *amazongo.Order, transactions []*amazongo.Transaction) func convertGoTransaction(tx *amazongo.Transaction) *ParsedTransaction { txType := "charge" status := strings.ToLower(tx.Status) - if strings.Contains(status, "refund") || strings.Contains(status, "refunded") { + if tx.IsRefund || strings.Contains(status, "refund") { txType = "refund" } if strings.Contains(status, "pending") { diff --git a/internal/adapters/providers/amazon/provider_test.go b/internal/adapters/providers/amazon/provider_test.go index d4d2efe..d733c1e 100644 --- a/internal/adapters/providers/amazon/provider_test.go +++ b/internal/adapters/providers/amazon/provider_test.go @@ -422,6 +422,30 @@ func TestConvertGoTransactionMapsRefundAndPendingStatuses(t *testing.T) { assert.Equal(t, "charge", charge.Type) } +func TestRefundedOrderDoesNotCountRefundRowsAsCharges(t *testing.T) { + charged := time.Date(2026, time.September, 28, 0, 0, 0, 0, time.UTC) + refunded := time.Date(2026, time.October, 3, 0, 0, 0, 0, time.UTC) + parsed := convertGoOrder(&amazongo.Order{ID: "112-1111111-2222222", Total: 31.24}, []*amazongo.Transaction{ + {Date: refunded, Amount: 31.24, LastFour: "4242", Status: "Completed", IsRefund: true}, + {Date: refunded, Amount: 11.15, Status: "Completed", IsRefund: true}, + {Date: charged, Amount: 31.24, LastFour: "4242", Status: "Completed"}, + {Date: charged, Amount: 11.15, Status: "Completed"}, + }) + require.Len(t, parsed.Transactions, 4) + assert.Equal(t, "refund", parsed.Transactions[0].Type) + assert.Equal(t, "refund", parsed.Transactions[1].Type) + assert.Equal(t, "charge", parsed.Transactions[2].Type) + + order := NewOrder(parsed, nil) + charges, err := order.GetFinalCharges() + require.NoError(t, err) + assert.Equal(t, []float64{31.24}, charges) + nonBank, err := order.GetNonBankAmount() + require.NoError(t, err) + assert.InDelta(t, 11.15, nonBank, 0.001) + assert.Equal(t, []time.Time{charged, charged}, order.GetTransactionDates()) +} + func TestLoginCommandUsesExplicitCookieFile(t *testing.T) { provider := NewProvider(nil, &ProviderConfig{CookieFile: "/tmp/amazon-cookies.json"}) @@ -443,6 +467,7 @@ type fakeAmazonClient struct { healthErr error cookiesSaved bool saveCookiesErr error + transactionFetches map[string]int } type roundTripFunc func(*http.Request) (*http.Response, error) @@ -483,6 +508,13 @@ func (f *fakeAmazonClient) SaveCookies() error { } func (f *fakeAmazonClient) FetchTransactions(_ context.Context, orderID string) ([]*amazongo.Transaction, error) { + if f.transactionFetches == nil { + f.transactionFetches = make(map[string]int) + } + f.transactionFetches[orderID]++ + if orderID == "failing-order" { + return nil, errors.New("transactions unavailable") + } if f.fetchTransactionsErr != nil { return nil, f.fetchTransactionsErr } diff --git a/internal/adapters/providers/amazon/returns.go b/internal/adapters/providers/amazon/returns.go index 728d384..62a9e1e 100644 --- a/internal/adapters/providers/amazon/returns.go +++ b/internal/adapters/providers/amazon/returns.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "io" + "log/slog" "math" "net/http" "net/url" @@ -34,7 +35,9 @@ var ( ) // ReturnRecord is one return event reported by Amazon's Return Center. -// RefundAmount is authoritative only when HasRefundTotal is true. +// RefundAmount is authoritative only when HasRefundTotal is true. It is the +// total across every payment method; RefundTenders, when known, says how much +// of it went back to a bank card. type ReturnRecord struct { OrderID string `json:"order_id"` RMAID string `json:"rma_id,omitempty"` @@ -44,9 +47,36 @@ type ReturnRecord struct { RefundIssuedAt *time.Time `json:"refund_issued_at,omitempty"` Status string `json:"status"` Items []ReturnedItem `json:"items"` + RefundTenders []RefundTender `json:"refund_tenders,omitempty"` statusURL string } +// RefundTender is the part of a refund Amazon credited to one payment method. +// Last4 is empty for points, gift-card balance, and other non-bank tenders. +type RefundTender struct { + Date time.Time `json:"date"` + Amount float64 `json:"amount"` + Last4 string `json:"last4,omitempty"` + Description string `json:"description,omitempty"` +} + +// BankRefundAmount is the credit expected in the bank feed. Points and +// gift-card refunds never reach the bank, so when the tender breakdown is known +// only its card rows count; otherwise the Return Center total is assumed to +// have gone to a card. +func (r ReturnRecord) BankRefundAmount() float64 { + if len(r.RefundTenders) == 0 { + return r.RefundAmount + } + cents := 0.0 + for _, tender := range r.RefundTenders { + if tender.Last4 != "" { + cents += math.Round(tender.Amount * 100) + } + } + return cents / 100 +} + // ReturnedItem identifies an item included in an Amazon return event. type ReturnedItem struct { ASIN string `json:"asin"` @@ -72,8 +102,8 @@ func (o *RefundOrder) GetDate() time.Time { } return o.record.CreatedAt } -func (o *RefundOrder) GetTotal() float64 { return -o.record.RefundAmount } -func (o *RefundOrder) GetSubtotal() float64 { return o.record.RefundAmount } +func (o *RefundOrder) GetTotal() float64 { return -o.record.BankRefundAmount() } +func (o *RefundOrder) GetSubtotal() float64 { return o.record.BankRefundAmount() } func (o *RefundOrder) GetTax() float64 { return 0 } func (o *RefundOrder) GetTip() float64 { return 0 } func (o *RefundOrder) GetFees() float64 { return 0 } @@ -85,7 +115,7 @@ func (o *RefundOrder) GetItems() []providers.OrderItem { for _, item := range o.record.Items { price := item.Price if len(o.record.Items) == 1 && o.record.HasRefundTotal { - price = o.record.RefundAmount + price = o.record.BankRefundAmount() } items = append(items, refundOrderItem{item: item, price: price}) } @@ -140,9 +170,96 @@ func (p *Provider) FetchReturns(ctx context.Context) ([]ReturnRecord, error) { if err != nil { return nil, fmt.Errorf("failed to fetch Amazon returns: %w", err) } + p.attachRefundTenders(ctx, returns) return returns, nil } +// attachRefundTenders records how each issued refund was divided between +// payment methods. The Return Center reports only a refund total; the order's +// payment transactions list the per-tender credits. A return is left without a +// breakdown whenever its refund rows cannot be identified with certainty. +func (p *Provider) attachRefundTenders(ctx context.Context, returns []ReturnRecord) { + byOrder := make(map[string][]int) + orderIDs := make([]string, 0) + for i := range returns { + if !returns[i].HasRefundTotal || returns[i].RefundIssuedAt == nil || returns[i].OrderID == "" { + continue + } + if _, seen := byOrder[returns[i].OrderID]; !seen { + orderIDs = append(orderIDs, returns[i].OrderID) + } + byOrder[returns[i].OrderID] = append(byOrder[returns[i].OrderID], i) + } + if len(orderIDs) == 0 { + return + } + client, err := p.getClient() + if err != nil { + p.logger.Warn("refund tender breakdown unavailable", slog.String("error", err.Error())) + return + } + + for _, orderID := range orderIDs { + transactions, err := client.FetchTransactions(ctx, orderID) + if err != nil { + p.logger.Warn("failed to fetch Amazon refund transactions", + slog.String("order_id", orderID), + slog.String("error", err.Error())) + continue + } + tenders := make([]RefundTender, 0) + for _, tx := range transactions { + if tx == nil || !tx.IsRefund || tx.Amount <= 0 { + continue + } + tenders = append(tenders, RefundTender{Date: tx.Date, Amount: tx.Amount, Last4: tx.LastFour, Description: tx.PaymentMethod}) + } + if len(tenders) == 0 { + continue + } + + indexes := byOrder[orderID] + for _, i := range indexes { + issuedDay := returns[i].RefundIssuedAt.Format("2006-01-02") + onlyReturnThatDay := true + for _, other := range indexes { + if other != i && returns[other].RefundIssuedAt.Format("2006-01-02") == issuedDay { + onlyReturnThatDay = false + } + } + sameDay := make([]RefundTender, 0, len(tenders)) + for _, tender := range tenders { + if tender.Date.Format("2006-01-02") == issuedDay { + sameDay = append(sameDay, tender) + } + } + switch { + case onlyReturnThatDay && tendersTotal(sameDay, returns[i].RefundAmount): + returns[i].RefundTenders = sameDay + case len(indexes) == 1 && tendersTotal(tenders, returns[i].RefundAmount): + // Amazon may date the payment credit a day off the Return + // Center's issued date; with one return there is no ambiguity. + returns[i].RefundTenders = tenders + default: + p.logger.Debug("Amazon refund rows do not identify this return's tenders", + slog.String("order_id", orderID), + slog.String("rma_id", returns[i].RMAID)) + } + } + } +} + +func tendersTotal(tenders []RefundTender, want float64) bool { + if len(tenders) == 0 { + return false + } + cents := 0.0 + for _, tender := range tenders { + cents += math.Round(tender.Amount * 100) + } + return math.Abs(cents-math.Round(want*100)) < 0.5 +} + func (p *Provider) resolvedCookieFile() (string, error) { if p.cookieFile != "" { return p.cookieFile, nil diff --git a/internal/adapters/providers/amazon/returns_test.go b/internal/adapters/providers/amazon/returns_test.go index 6d3bbd1..a23e0fc 100644 --- a/internal/adapters/providers/amazon/returns_test.go +++ b/internal/adapters/providers/amazon/returns_test.go @@ -361,3 +361,108 @@ func TestReturnParsingRejectsIncompleteRecordsAndHandlesURLVariants(t *testing.T func jsonMarshal(value any) ([]byte, error) { return json.Marshal(value) } + +func TestRefundOrderUsesCardPortionWhenRefundWasSplitAcrossTenders(t *testing.T) { + issuedAt := time.Date(2026, time.October, 3, 0, 0, 0, 0, time.UTC) + record := ReturnRecord{ + OrderID: "112-1111111-2222222", + RMAID: "DsplitRRMA", + RefundAmount: 42.39, + HasRefundTotal: true, + RefundIssuedAt: &issuedAt, + Items: []ReturnedItem{{ASIN: "B0EXAMPLE1", Name: "Tumbler", Price: 39.99}}, + RefundTenders: []RefundTender{ + {Date: issuedAt, Amount: 31.24, Last4: "4242", Description: "Prime Visa ****4242"}, + {Date: issuedAt, Amount: 11.15, Description: "Amazon Visa points"}, + }, + } + + assert.InDelta(t, 31.24, record.BankRefundAmount(), 0.001) + + order := NewRefundOrder(record) + assert.InDelta(t, -31.24, order.GetTotal(), 0.001) + assert.InDelta(t, 31.24, order.GetSubtotal(), 0.001) + require.Len(t, order.GetItems(), 1) + assert.InDelta(t, 31.24, order.GetItems()[0].GetPrice(), 0.001) + assert.InDelta(t, 31.24, order.GetItems()[0].GetUnitPrice(), 0.001) + + pointsOnly := ReturnRecord{RefundAmount: 11.15, HasRefundTotal: true, RefundTenders: []RefundTender{{Amount: 11.15, Description: "Amazon Visa points"}}} + assert.Zero(t, pointsOnly.BankRefundAmount()) + + withoutBreakdown := ReturnRecord{RefundAmount: 11.36, HasRefundTotal: true} + assert.InDelta(t, 11.36, withoutBreakdown.BankRefundAmount(), 0.001) +} + +func TestAttachRefundTendersAssignsOrderRefundRowsToReturns(t *testing.T) { + oct3 := time.Date(2026, time.October, 3, 0, 0, 0, 0, time.UTC) + oct5 := time.Date(2026, time.October, 5, 0, 0, 0, 0, time.UTC) + refundRow := func(date time.Time, amount float64, last4 string) *amazongo.Transaction { + method := "Amazon Visa points" + if last4 != "" { + method = "Prime Visa ****" + last4 + } + return &amazongo.Transaction{Date: date, Amount: amount, LastFour: last4, PaymentMethod: method, IsRefund: true} + } + charge := func(date time.Time, amount float64, last4 string) *amazongo.Transaction { + return &amazongo.Transaction{Date: date, Amount: amount, LastFour: last4} + } + client := &fakeAmazonClient{transactionsByOrderID: map[string][]*amazongo.Transaction{ + "split-order": { + refundRow(oct3, 31.24, "4242"), refundRow(oct3, 11.15, ""), + charge(oct3.AddDate(0, 0, -5), 31.24, "4242"), charge(oct3.AddDate(0, 0, -6), 11.15, ""), + }, + // The refund row is dated a day after the Return Center's issued date. + "late-row-order": {refundRow(oct3.AddDate(0, 0, 1), 20.00, "4242")}, + "two-returns-order": { + refundRow(oct3, 10.00, "4242"), + refundRow(oct5, 15.00, "4242"), refundRow(oct5, 5.00, ""), + }, + "same-day-order": {refundRow(oct3, 30.00, "4242"), refundRow(oct3, 10.00, "")}, + "mismatch-order": {refundRow(oct3, 7.00, "4242")}, + "no-refund-order": {charge(oct3, 9.00, "4242")}, + }} + provider := NewProvider(nil, nil) + provider.client = client + record := func(orderID, rma string, amount float64, issued *time.Time) ReturnRecord { + return ReturnRecord{OrderID: orderID, RMAID: rma, RefundAmount: amount, HasRefundTotal: issued != nil, RefundIssuedAt: issued} + } + returns := []ReturnRecord{ + record("split-order", "split", 42.39, &oct3), + record("late-row-order", "late", 20.00, &oct3), + record("two-returns-order", "first", 10.00, &oct3), + record("two-returns-order", "second", 20.00, &oct5), + record("same-day-order", "same-a", 20.00, &oct3), + record("same-day-order", "same-b", 20.00, &oct3), + record("mismatch-order", "mismatch", 9.00, &oct3), + record("no-refund-order", "none", 9.00, &oct3), + record("pending-order", "pending", 0, nil), + record("failing-order", "failing", 5.00, &oct3), + } + + provider.attachRefundTenders(context.Background(), returns) + + byRMA := map[string]ReturnRecord{} + for _, r := range returns { + byRMA[r.RMAID] = r + } + require.Len(t, byRMA["split"].RefundTenders, 2) + assert.Equal(t, RefundTender{Date: oct3, Amount: 31.24, Last4: "4242", Description: "Prime Visa ****4242"}, byRMA["split"].RefundTenders[0]) + assert.InDelta(t, 31.24, byRMA["split"].BankRefundAmount(), 0.001) + assert.InDelta(t, 20.00, byRMA["late"].BankRefundAmount(), 0.001) + require.Len(t, byRMA["late"].RefundTenders, 1) + assert.InDelta(t, 10.00, byRMA["first"].BankRefundAmount(), 0.001) + require.Len(t, byRMA["first"].RefundTenders, 1) + assert.InDelta(t, 15.00, byRMA["second"].BankRefundAmount(), 0.001) + require.Len(t, byRMA["second"].RefundTenders, 2) + // Two returns refunded the same day cannot be told apart, and a breakdown + // that disagrees with the Return Center total is not trusted. + for _, rma := range []string{"same-a", "same-b", "mismatch", "none", "pending", "failing"} { + assert.Empty(t, byRMA[rma].RefundTenders, rma) + } + assert.InDelta(t, 9.00, byRMA["mismatch"].BankRefundAmount(), 0.001) + // One request per order, and none for returns without an issued refund. + assert.Equal(t, map[string]int{ + "split-order": 1, "late-row-order": 1, "two-returns-order": 1, "same-day-order": 1, + "mismatch-order": 1, "no-refund-order": 1, "failing-order": 1, + }, client.transactionFetches) +} diff --git a/internal/application/sync/amazon_returns.go b/internal/application/sync/amazon_returns.go index acc48c5..51d795f 100644 --- a/internal/application/sync/amazon_returns.go +++ b/internal/application/sync/amazon_returns.go @@ -63,7 +63,8 @@ func (o *Orchestrator) processAmazonReturns( o.logger.Debug("Skipping already processed Amazon refund", "refund_id", refund.GetID()) continue } - key := fmt.Sprintf("%s|%.2f", record.RefundIssuedAt.Format("2006-01-02"), record.RefundAmount) + // Group by the credit the bank feed shows, not the Return Center total. + key := fmt.Sprintf("%s|%.2f", record.RefundIssuedAt.Format("2006-01-02"), record.BankRefundAmount()) group := groupsByKey[key] if group == nil { group = &refundGroup{key: key} diff --git a/internal/application/sync/amazon_returns_test.go b/internal/application/sync/amazon_returns_test.go index fc88aab..548f0a0 100644 --- a/internal/application/sync/amazon_returns_test.go +++ b/internal/application/sync/amazon_returns_test.go @@ -74,6 +74,41 @@ func TestProcessAmazonReturnsIncludesCalendarDayAtLookbackBoundary(t *testing.T) assert.Equal(t, 0, result.RefundSkippedCount) } +func TestProcessAmazonReturnsMatchesCardPortionOfSplitTenderRefund(t *testing.T) { + now := time.Date(2026, time.October, 13, 15, 30, 0, 0, time.UTC) + issuedAt := time.Date(2026, time.October, 3, 0, 0, 0, 0, time.UTC) + tenders := func(points float64) []amazonprovider.RefundTender { + return []amazonprovider.RefundTender{ + {Date: issuedAt, Amount: 31.24, Last4: "4242", Description: "Prime Visa ****4242"}, + {Date: issuedAt, Amount: points, Description: "Amazon Visa points"}, + } + } + // The Return Center totals differ, but both card credits are $31.24, so the + // two refunds are indistinguishable in the bank feed and must be grouped. + records := []amazonprovider.ReturnRecord{ + {OrderID: "112-1111111-2222222", RMAID: "DfirstRRMA", RefundAmount: 42.39, HasRefundTotal: true, RefundIssuedAt: &issuedAt, RefundTenders: tenders(11.15), Items: []amazonprovider.ReturnedItem{{ASIN: "B0CUPONE", Name: "Tumbler", Price: 39.99}}}, + {OrderID: "112-3333333-4444444", RMAID: "DsecondRRMA", RefundAmount: 36.24, HasRefundTotal: true, RefundIssuedAt: &issuedAt, RefundTenders: tenders(5.00), Items: []amazonprovider.ReturnedItem{{ASIN: "B0CUPTWO", Name: "Mug", Price: 34.00}}}, + } + temporary := &monarch.TransactionCategory{ID: "temp-amazon", Name: "[TEMP] Amazon"} + transactions := []*monarch.Transaction{ + {ID: "refund-1", Amount: 31.24, Date: toMonarchDate(issuedAt), Category: temporary}, + {ID: "refund-2", Amount: 31.24, Date: toMonarchDate(issuedAt), Category: temporary}, + } + transactionMatcher := matcher.NewMatcher(matcher.Config{AmountTolerance: 0.01, DateTolerance: 5}) + spl := splitter.NewSplitter(&mockCategorizer{categoryID: "home", categoryName: "Home Spending"}) + orchestrator := &Orchestrator{ + amazonHandler: handlers.NewAmazonHandler(transactionMatcher, nil, &mockSplitterAdapter{splitter: spl}, &processOrderTestMonarch{}, slog.Default()), + logger: slog.Default(), + storage: storage.NewMockRepository(), + } + result := &Result{} + + orchestrator.processAmazonReturns(context.Background(), records, transactions, map[string]bool{}, []categorizer.Category{{ID: "home", Name: "Home Spending"}}, nil, Options{LookbackDays: 14}, now, result) + + assert.Equal(t, 2, result.RefundProcessedCount) + assert.Equal(t, 0, result.RefundSkippedCount) +} + func TestReturnFallsWithinLookbackUsesIssuedDateNotCreatedDate(t *testing.T) { now := time.Date(2026, time.July, 17, 15, 30, 0, 0, time.UTC) createdAt := time.Date(2026, time.June, 14, 0, 0, 0, 0, time.UTC) diff --git a/internal/application/sync/handlers/amazon_refunds.go b/internal/application/sync/handlers/amazon_refunds.go index 70544eb..8a1610f 100644 --- a/internal/application/sync/handlers/amazon_refunds.go +++ b/internal/application/sync/handlers/amazon_refunds.go @@ -51,13 +51,21 @@ func (h *AmazonHandler) ProcessRefund( result.SkipReason = "multiple returned items lack an authoritative item-level refund allocation" return result, nil } + // The bank feed shows only the card portion of a refund; anything returned + // to points or gift-card balance never appears in Monarch. + bankAmount := record.BankRefundAmount() + if bankAmount <= 0 { + result.Skipped = true + result.SkipReason = fmt.Sprintf("Amazon refunded $%.2f to points or gift-card balance; there is no bank credit to categorize", record.RefundAmount) + return result, nil + } eligible := eligibleAmazonRefundTransactions(monarchTxns) matchResult, err := h.matcher.FindUniqueMatch(refund, eligible, usedTxnIDs) if errors.Is(err, matcher.ErrAmbiguousMatch) { result.Skipped = true - result.SkipReason = fmt.Sprintf("ambiguous Amazon refund credit for $%.2f on %s", record.RefundAmount, record.RefundIssuedAt.Format("2006-01-02")) + result.SkipReason = fmt.Sprintf("ambiguous Amazon refund credit for $%.2f on %s", bankAmount, record.RefundIssuedAt.Format("2006-01-02")) return result, nil } if err != nil { @@ -65,7 +73,7 @@ func (h *AmazonHandler) ProcessRefund( } if matchResult == nil { result.Skipped = true - result.SkipReason = fmt.Sprintf("no unique temporary Amazon credit found for $%.2f", record.RefundAmount) + result.SkipReason = fmt.Sprintf("no unique temporary Amazon credit found for $%.2f", bankAmount) return result, nil } @@ -114,6 +122,7 @@ func (h *AmazonHandler) ProcessRefund( "rma_id", record.RMAID, "transaction_id", transaction.ID, "refund_amount", record.RefundAmount, + "bank_refund_amount", bankAmount, "item_asin", record.Items[0].ASIN, "dry_run", dryRun) return result, nil @@ -139,6 +148,7 @@ func (h *AmazonHandler) ProcessRefundGroup( return nil, "Amazon did not report a refund-issued date", nil } issuedDate := base.RefundIssuedAt.Format("2006-01-02") + bankAmount := base.BankRefundAmount() for _, refund := range refunds { if refund == nil { return nil, "missing Amazon refund record", nil @@ -147,14 +157,14 @@ func (h *AmazonHandler) ProcessRefundGroup( if !record.HasRefundTotal || record.RefundIssuedAt == nil || record.RMAID == "" || len(record.Items) != 1 { return nil, "refund group lacks authoritative single-item detail", nil } - if math.Abs(record.RefundAmount-base.RefundAmount) > 0.001 || record.RefundIssuedAt.Format("2006-01-02") != issuedDate { + if math.Abs(record.BankRefundAmount()-bankAmount) > 0.001 || record.RefundIssuedAt.Format("2006-01-02") != issuedDate { return nil, "refund group does not share one amount and issued date", nil } } candidates := make([]*monarch.Transaction, 0, len(refunds)) for _, tx := range eligibleAmazonRefundTransactions(monarchTxns) { - if usedTxnIDs[tx.ID] || math.Abs(tx.Amount-base.RefundAmount) > 0.011 || tx.Date.Format("2006-01-02") != issuedDate { + if usedTxnIDs[tx.ID] || math.Abs(tx.Amount-bankAmount) > 0.011 || tx.Date.Format("2006-01-02") != issuedDate { continue } candidates = append(candidates, tx) @@ -196,7 +206,7 @@ func (h *AmazonHandler) ProcessRefundGroup( noteLines := []string{ fmt.Sprintf("%s:", categoryName), - fmt.Sprintf("- %d indistinguishable Amazon refunds of $%.2f issued %s", len(refunds), base.RefundAmount, issuedDate), + fmt.Sprintf("- %d indistinguishable Amazon refunds of $%.2f issued %s", len(refunds), bankAmount, issuedDate), } for _, refund := range refunds { record := refund.Record() @@ -228,7 +238,7 @@ func (h *AmazonHandler) ProcessRefundGroup( } h.logInfo("Categorized indistinguishable Amazon refund group", "refund_count", len(refunds), - "refund_amount", base.RefundAmount, + "refund_amount", bankAmount, "issued_date", issuedDate, "category_id", categoryID, "dry_run", dryRun) diff --git a/internal/application/sync/handlers/amazon_test.go b/internal/application/sync/handlers/amazon_test.go index b613e9d..c0437d9 100644 --- a/internal/application/sync/handlers/amazon_test.go +++ b/internal/application/sync/handlers/amazon_test.go @@ -657,6 +657,93 @@ func TestAmazonHandler_ProcessRefundCategorizesUniqueTemporaryCredit(t *testing. assert.Equal(t, 11.36, splitter.lastOrder.GetItems()[0].GetPrice()) } +func TestAmazonHandler_ProcessRefundMatchesCardPortionOfSplitTenderRefund(t *testing.T) { + issuedAt := time.Date(2026, time.October, 3, 0, 0, 0, 0, time.UTC) + refund := amazonprovider.NewRefundOrder(amazonprovider.ReturnRecord{ + OrderID: "112-1111111-2222222", + RMAID: "DsplitRRMA", + RefundAmount: 42.39, + HasRefundTotal: true, + RefundIssuedAt: &issuedAt, + Items: []amazonprovider.ReturnedItem{{ASIN: "B0EXAMPLE1", Name: "Tumbler", Price: 39.99}}, + RefundTenders: []amazonprovider.RefundTender{ + {Date: issuedAt, Amount: 31.24, Last4: "4242", Description: "Prime Visa ****4242"}, + {Date: issuedAt, Amount: 11.15, Description: "Amazon Visa points"}, + }, + }) + temporary := &monarch.TransactionCategory{ID: "temp-amazon", Name: "[TEMP] Amazon"} + transactions := []*monarch.Transaction{ + {ID: "card-credit", Amount: 31.24, Date: toMonarchDate(issuedAt), Category: temporary}, + {ID: "other-credit", Amount: 87.68, Date: toMonarchDate(issuedAt), Category: temporary}, + } + splitter := &mockSplitter{categoryID: "home", notes: "Home Spending:\n- Tumbler $31.24"} + monarchClient := &mockMonarch{} + handler := NewAmazonHandler(matcher.NewMatcher(matcher.Config{AmountTolerance: 0.01, DateTolerance: 5}), nil, splitter, monarchClient, nil) + + result, err := handler.ProcessRefund(context.Background(), refund, transactions, map[string]bool{}, nil, nil, false) + + require.NoError(t, err) + require.True(t, result.Processed, result.SkipReason) + assert.Equal(t, "card-credit", result.Transaction.ID) + assert.Equal(t, "card-credit", monarchClient.updatedID) + require.NotNil(t, splitter.lastOrder) + assert.InDelta(t, 31.24, splitter.lastOrder.GetItems()[0].GetPrice(), 0.001) +} + +func TestAmazonHandler_ProcessRefundSkipsRefundWithNoBankCredit(t *testing.T) { + issuedAt := time.Date(2026, time.October, 3, 0, 0, 0, 0, time.UTC) + refund := amazonprovider.NewRefundOrder(amazonprovider.ReturnRecord{ + OrderID: "112-1111111-2222222", + RMAID: "DpointsRRMA", + RefundAmount: 11.15, + HasRefundTotal: true, + RefundIssuedAt: &issuedAt, + Items: []amazonprovider.ReturnedItem{{ASIN: "B0EXAMPLE1", Name: "Tumbler", Price: 11.15}}, + RefundTenders: []amazonprovider.RefundTender{{Date: issuedAt, Amount: 11.15, Description: "Amazon Visa points"}}, + }) + temporary := &monarch.TransactionCategory{ID: "temp-amazon", Name: "[TEMP] Amazon"} + transactions := []*monarch.Transaction{{ID: "unrelated", Amount: 11.15, Date: toMonarchDate(issuedAt), Category: temporary}} + monarchClient := &mockMonarch{} + handler := NewAmazonHandler(matcher.NewMatcher(matcher.Config{AmountTolerance: 0.01, DateTolerance: 5}), nil, &mockSplitter{}, monarchClient, nil) + + result, err := handler.ProcessRefund(context.Background(), refund, transactions, map[string]bool{}, nil, nil, false) + + require.NoError(t, err) + assert.True(t, result.Skipped) + assert.Contains(t, result.SkipReason, "no bank credit") + assert.False(t, monarchClient.updateCalled) +} + +func TestAmazonHandler_ProcessRefundGroupUsesCardPortion(t *testing.T) { + issuedAt := time.Date(2026, time.October, 3, 0, 0, 0, 0, time.UTC) + record := func(rma string, total float64) *amazonprovider.RefundOrder { + return amazonprovider.NewRefundOrder(amazonprovider.ReturnRecord{ + OrderID: "order-" + rma, RMAID: rma, RefundAmount: total, HasRefundTotal: true, RefundIssuedAt: &issuedAt, + Items: []amazonprovider.ReturnedItem{{ASIN: "B0" + rma, Name: "Cup " + rma, Price: 14.41}}, + RefundTenders: []amazonprovider.RefundTender{ + {Date: issuedAt, Amount: 14.41, Last4: "4242"}, + {Date: issuedAt, Amount: total - 14.41}, + }, + }) + } + temporary := &monarch.TransactionCategory{ID: "temp-amazon", Name: "[TEMP] Amazon"} + transactions := []*monarch.Transaction{ + {ID: "refund-1", Amount: 14.41, Date: toMonarchDate(issuedAt), Category: temporary}, + {ID: "refund-2", Amount: 14.41, Date: toMonarchDate(issuedAt), Category: temporary}, + } + monarchClient := &mockMonarch{} + splitter := &mockSplitter{categoryID: "kid-needs", notes: "Kid Needs:\n- Cup"} + handler := NewAmazonHandler(matcher.NewMatcher(matcher.Config{AmountTolerance: 0.01, DateTolerance: 5}), nil, splitter, monarchClient, nil) + + // Different Return Center totals, identical card credits. + results, skipReason, err := handler.ProcessRefundGroup(context.Background(), []*amazonprovider.RefundOrder{record("A", 16.41), record("B", 19.41)}, transactions, map[string]bool{}, nil, nil, false) + + require.NoError(t, err) + assert.Empty(t, skipReason) + require.Len(t, results, 2) + assert.Contains(t, results[0].MonarchNotes, "$14.41") +} + func TestAmazonHandler_ProcessRefundSkipsIndistinguishableCredits(t *testing.T) { issuedAt := time.Date(2026, time.July, 3, 0, 0, 0, 0, time.UTC) refund := amazonprovider.NewRefundOrder(amazonprovider.ReturnRecord{