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
25 changes: 25 additions & 0 deletions docs/bug-fixes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 1 addition & 1 deletion internal/adapters/providers/amazon/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
32 changes: 32 additions & 0 deletions internal/adapters/providers/amazon/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"})

Expand All @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down
125 changes: 121 additions & 4 deletions internal/adapters/providers/amazon/returns.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"net/url"
Expand Down Expand Up @@ -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"`
Expand All @@ -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"`
Expand All @@ -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 }
Expand All @@ -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})
}
Expand Down Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions internal/adapters/providers/amazon/returns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading
Loading