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

## Bug Fixes

### 2026-09-26: Amazon orders misreported, missed, or matched to other orders' charges

**Description:**
An Amazon sync reported two errors:
- `112-3421348-5157859` was skipped with "could not find all transactions: expected 2, found 2". Only the $19.48 of its $13.77 + $19.48 charges had posted to Monarch.
- `112-7815140-3755432` (order total $55.31) was skipped because Amazon charged the card $59.36; a $4.05 discount on the order was not applied to the charge. The exact $59.36 transaction was in Monarch.

When `112-7815140-3755432` was synced on its own, Monarch-side discovery matched $25.44 + $29.87, two charges belonging to other orders that happened to sum to $55.31.

**Test Cases:**
- `TestAmazonHandler_ProcessOrder_PartialMultiChargeMatchIsPending`
- `TestAmazonHandler_ProcessOrder_NoMultiChargeMatchesReportsActualFoundCount`
- `TestAmazonHandler_ProcessOrder_OverchargeMatchesReportedCardCharge`
- `TestAmazonHandler_ProcessOrder_DiscoveryIgnoresSubsetsWithoutOrderCharge`
- `TestFindSubsetByTotalIncluding_RejectsSubsetWithoutOrderCharge` (`internal/domain/matcher/subset_test.go`)

**Root Cause:**
- `FindMultipleMatches` keeps `nil` placeholders for unmatched charges, and the Amazon handler counted `len(Matches)` as the number found.
- A partial multi-charge match was reported as an error instead of waiting for the remaining charges to post.
- When charges failed validation, the handler went straight to discovery by order total and never tried the card charges Amazon reported for the order.
- Discovery accepted any Monarch subset summing to the order total, so the only guard against using other orders' charges was the processing order of `usedTxnIDs`.

**Fix Applied:**
- The Amazon handler counts only non-nil matches, and a partial multi-charge match is skipped as `payment pending`.
- When card charges meet or exceed the expected amount, they are matched against Monarch directly and allocated using the charged amount.
- Discovery uses `FindSubsetByTotalIncluding`, which requires the subset to contain at least one charge Amazon reported for the order.

**Commit:** Included in the pull request for this fix.

### 2026-09-23: GPT-6 models would be sent `temperature` instead of `reasoning_effort`

**Description:**
Expand Down
184 changes: 117 additions & 67 deletions internal/application/sync/handlers/amazon.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,16 +196,83 @@ func (h *AmazonHandler) ProcessOrder(
var consolidatedTxn *monarch.Transaction
monarchDiscovered := false // true when we matched via subset search rather than provider charges

if !validation.Valid {
// Provider charges are incomplete (common for multi-shipment orders where later
// charges post after the provider visited the order details page). Try to find the
// matching Monarch transactions by searching for a subset that sums to the order total.
// Charges that meet or exceed the expected amount fully cover the order, so
// the card charges Amazon attributes to it are the ground truth for what
// hit the bank. Overcharges happen when a discount shown on the order is
// not applied to the card charge.
chargesCoverOrder := validation.Valid || validation.Difference > 0
var reportedTxns []*monarch.Transaction
reportedFound := 0
if chargesCoverOrder {
reportedTxns, reportedFound, err = h.matchReportedCharges(order, monarchTxns, usedTxnIDs, bankCharges)
if err != nil {
return nil, err
}
}

switch {
case reportedTxns != nil:
matchedTxns = reportedTxns
for _, t := range matchedTxns {
usedTxnIDs[t.ID] = true
}
if validation.Valid {
h.logDebug("Matched reported charges",
"order_id", order.GetID(),
"transaction_count", len(matchedTxns))
} else {
h.logInfo("Matched reported card charges that differ from order total",
"order_id", order.GetID(),
"bank_sum", validation.BankChargesSum,
"expected", validation.ExpectedSum,
"difference", validation.Difference)
}
if len(matchedTxns) > 1 {
h.logInfo("Matched all transactions for multi-delivery order",
"order_id", order.GetID(),
"transaction_count", len(matchedTxns))
}

case validation.Valid && len(bankCharges) == 1:
result.Skipped = true
result.SkipReason = "no matching transaction found"
h.logWarn("No matching transaction found",
"order_id", order.GetID(),
"expected_amount", bankCharges[0])
return result, nil

case validation.Valid && reportedFound > 0:
// Some of the order's charges are in Monarch; the rest have not posted yet.
result.Skipped = true
result.SkipReason = "payment pending"
h.logInfo("Waiting for remaining Amazon charges to post",
"order_id", order.GetID(),
"expected", len(bankCharges),
"found", reportedFound)
return result, nil

case validation.Valid:
result.Skipped = true
result.SkipReason = fmt.Sprintf("could not find all transactions: expected %d, found %d",
len(bankCharges), reportedFound)
h.logWarn("Not all transactions found",
"order_id", order.GetID(),
"expected", len(bankCharges),
"found", reportedFound)
return result, nil

default:
// Provider charges don't reconcile with the order total (common for
// multi-shipment orders where later charges post after the provider
// visited the order details page). Search Monarch for a subset that sums
// to the order total and includes at least one charge Amazon reported
// for this order, so unrelated orders' charges can't fill the total.
h.logDebug("Provider charges incomplete, attempting Monarch-side discovery",
"order_id", order.GetID(),
"provider_charge_sum", validation.BankChargesSum,
"expected", validation.ExpectedSum)

discovered, discoverErr := h.matcher.FindSubsetByTotal(order, monarchTxns, usedTxnIDs)
discovered, discoverErr := h.matcher.FindSubsetByTotalIncluding(order, monarchTxns, usedTxnIDs, bankCharges)
if discoverErr != nil {
h.logWarn("Charge validation failed and Monarch discovery found no match",
"order_id", order.GetID(),
Expand All @@ -226,68 +293,6 @@ func (h *AmazonHandler) ProcessOrder(
h.logInfo("Monarch-side discovery found matching transactions",
"order_id", order.GetID(),
"count", len(matchedTxns))
} else {
h.logDebug("Charge validation passed",
"order_id", order.GetID(),
"bank_sum", validation.BankChargesSum,
"expected", validation.ExpectedSum)

if len(bankCharges) > 1 {
// Multi-delivery order - find multiple matches
multiResult, err := h.matcher.FindMultipleMatches(order, monarchTxns, usedTxnIDs, bankCharges)
if err != nil {
return nil, fmt.Errorf("multi-match error: %w", err)
}

if !multiResult.AllFound {
result.Skipped = true
result.SkipReason = fmt.Sprintf("could not find all transactions: expected %d, found %d",
len(bankCharges), len(multiResult.Matches))
h.logWarn("Not all transactions found",
"order_id", order.GetID(),
"expected", len(bankCharges),
"found", len(multiResult.Matches))
return result, nil
}

for _, match := range multiResult.Matches {
matchedTxns = append(matchedTxns, match.Transaction)
usedTxnIDs[match.Transaction.ID] = true
}
h.logInfo("Matched all transactions for multi-delivery order",
"order_id", order.GetID(),
"transaction_count", len(matchedTxns))
} else {
// Single charge - find one match
// Use a wrapper order that returns the bank charge amount for matching
// This handles gift card orders where order total differs from bank charge
matchOrder := &bankChargeOrder{
Order: order,
bankCharge: bankCharges[0],
}

matchResult, err := h.matcher.FindMatch(matchOrder, monarchTxns, usedTxnIDs)
if err != nil {
return nil, fmt.Errorf("match error: %w", err)
}

if matchResult == nil {
result.Skipped = true
result.SkipReason = "no matching transaction found"
h.logWarn("No matching transaction found",
"order_id", order.GetID(),
"expected_amount", bankCharges[0])
return result, nil
}

consolidatedTxn = matchResult.Transaction
usedTxnIDs[consolidatedTxn.ID] = true

h.logDebug("Matched single transaction",
"order_id", order.GetID(),
"transaction_id", consolidatedTxn.ID,
"amount", math.Abs(consolidatedTxn.Amount))
}
}

// Never consolidate multiple pending bank-feed rows. Pending transactions
Expand Down Expand Up @@ -457,6 +462,51 @@ func (h *AmazonHandler) ProcessOrder(
return result, nil
}

// matchReportedCharges finds a Monarch transaction for each card charge Amazon
// reported for the order. It returns the matches only when every charge was
// found, along with how many were found, and does not mark anything as used.
func (h *AmazonHandler) matchReportedCharges(
order AmazonOrder,
monarchTxns []*monarch.Transaction,
usedTxnIDs map[string]bool,
bankCharges []float64,
) ([]*monarch.Transaction, int, error) {
if len(bankCharges) == 1 {
// Match on the bank charge rather than the order total, which can differ
// for gift card, points, and discount adjustments.
matchOrder := &bankChargeOrder{
Order: order,
bankCharge: bankCharges[0],
}
matchResult, err := h.matcher.FindMatch(matchOrder, monarchTxns, usedTxnIDs)
if err != nil {
return nil, 0, fmt.Errorf("match error: %w", err)
}
if matchResult == nil {
return nil, 0, nil
}
h.logDebug("Matched single transaction",
"order_id", order.GetID(),
"transaction_id", matchResult.Transaction.ID,
"amount", math.Abs(matchResult.Transaction.Amount))
return []*monarch.Transaction{matchResult.Transaction}, 1, nil
}

multiResult, err := h.matcher.FindMultipleMatches(order, monarchTxns, usedTxnIDs, bankCharges)
if err != nil {
return nil, 0, fmt.Errorf("multi-match error: %w", err)
}
found := countFoundMatches(multiResult.Matches)
if !multiResult.AllFound {
return nil, found, nil
}
matched := make([]*monarch.Transaction, 0, len(multiResult.Matches))
for _, match := range multiResult.Matches {
matched = append(matched, match.Transaction)
}
return matched, found, nil
}

// bankChargeOrder wraps an order to return the bank charge amount for matching
// This handles gift card orders where the order total differs from the bank charge
type bankChargeOrder struct {
Expand Down
125 changes: 124 additions & 1 deletion internal/application/sync/handlers/amazon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,130 @@ func TestAmazonHandler_ProcessOrder_MissingTransactions(t *testing.T) {

require.NoError(t, err)
assert.True(t, result.Skipped)
assert.Contains(t, result.SkipReason, "could not find all transactions")
// One of two charges matched, so the other has not posted yet.
assert.Equal(t, "payment pending", result.SkipReason)
}

// Regression: order 112-3421348-5157859 split into $13.77 + $19.48 card charges.
// Only $19.48 had posted to Monarch. The skip was reported as an error saying
// "expected 2, found 2" because nil placeholders were counted as matches.
func TestAmazonHandler_ProcessOrder_PartialMultiChargeMatchIsPending(t *testing.T) {
orderDate := time.Now()
order := &mockAmazonOrder{
id: "112-3421348-5157859",
date: orderDate,
total: 33.25,
items: []providers.OrderItem{&mockItem{name: "Item A", price: 14.00}, &mockItem{name: "Item B", price: 19.25}},
bankCharges: []float64{13.77, 19.48},
nonBankAmount: 1.50, // Visa points earned, not a payment
}
monarchTxns := []*monarch.Transaction{
{ID: "posted", Amount: -19.48, Date: toMonarchDate(orderDate), Pending: true},
}
splitter := &mockSplitter{}
monarchClient := &mockMonarch{}
handler := NewAmazonHandler(
matcher.NewMatcher(matcher.Config{AmountTolerance: 0.01, DateTolerance: 5}),
&mockConsolidator{}, splitter, monarchClient, nil,
)
usedTxnIDs := make(map[string]bool)

result, err := handler.ProcessOrder(context.Background(), order, monarchTxns, usedTxnIDs, nil, nil, false)

require.NoError(t, err)
assert.True(t, result.Skipped)
assert.Equal(t, "payment pending", result.SkipReason)
assert.False(t, usedTxnIDs["posted"], "a pending order must not claim transactions")
assert.Nil(t, splitter.lastOrder)
assert.False(t, monarchClient.updateCalled)
}

func TestAmazonHandler_ProcessOrder_NoMultiChargeMatchesReportsActualFoundCount(t *testing.T) {
order := &mockAmazonOrder{
id: "no-charges-posted",
date: time.Now(),
total: 33.25,
items: []providers.OrderItem{&mockItem{name: "Item", price: 33.25}},
bankCharges: []float64{13.77, 19.48},
}
handler := NewAmazonHandler(
matcher.NewMatcher(matcher.Config{AmountTolerance: 0.01, DateTolerance: 5}),
nil, nil, nil, nil,
)

result, err := handler.ProcessOrder(context.Background(), order, nil, make(map[string]bool), nil, nil, false)

require.NoError(t, err)
assert.True(t, result.Skipped)
assert.Equal(t, "could not find all transactions: expected 2, found 0", result.SkipReason)
}

// Regression: order 112-7815140-3755432 had an order total of $55.31 but Amazon
// charged the card $59.36 (a $4.05 discount was not applied to the charge).
// The exact $59.36 transaction was in Monarch but was never tried.
func TestAmazonHandler_ProcessOrder_OverchargeMatchesReportedCardCharge(t *testing.T) {
orderDate := time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC)
order := &mockAmazonOrder{
id: "112-7815140-3755432",
date: orderDate,
total: 55.31,
subtotal: 56.00,
tax: 3.36,
items: []providers.OrderItem{
&mockItem{name: "Leggings", price: 32.00},
&mockItem{name: "T-Shirt", price: 24.00},
},
bankCharges: []float64{59.36},
}
monarchTxns := []*monarch.Transaction{
{ID: "other-order-a", Amount: -25.44, Date: toMonarchDate(orderDate)},
{ID: "other-order-b", Amount: -29.87, Date: toMonarchDate(orderDate)},
{ID: "own-charge", Amount: -59.36, Date: toMonarchDate(orderDate.AddDate(0, 0, 1))},
}
splitter := &mockSplitter{categoryID: "clothing", notes: "Clothing:\n- Leggings\n- T-Shirt"}
monarchClient := &mockMonarch{}
handler := NewAmazonHandler(
matcher.NewMatcher(matcher.Config{AmountTolerance: 0.01, DateTolerance: 5}),
&mockConsolidator{}, splitter, monarchClient, nil,
)
usedTxnIDs := make(map[string]bool)

result, err := handler.ProcessOrder(context.Background(), order, monarchTxns, usedTxnIDs, nil, nil, false)

require.NoError(t, err)
require.True(t, result.Processed, "skip reason: %s", result.SkipReason)
assert.Equal(t, "own-charge", result.Transaction.ID)
assert.Equal(t, "own-charge", monarchClient.updatedID)
assert.InDelta(t, 59.36, result.Allocations.TotalAllocated, 0.001)
assert.False(t, usedTxnIDs["other-order-a"])
assert.False(t, usedTxnIDs["other-order-b"])
}

func TestAmazonHandler_ProcessOrder_DiscoveryIgnoresSubsetsWithoutOrderCharge(t *testing.T) {
orderDate := time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC)
order := &mockAmazonOrder{
id: "112-7815140-3755432",
date: orderDate,
total: 55.31,
items: []providers.OrderItem{&mockItem{name: "Leggings", price: 56.00}},
bankCharges: []float64{59.36}, // not yet in Monarch
}
monarchTxns := []*monarch.Transaction{
{ID: "other-order-a", Amount: -25.44, Date: toMonarchDate(orderDate)},
{ID: "other-order-b", Amount: -29.87, Date: toMonarchDate(orderDate)},
}
handler := NewAmazonHandler(
matcher.NewMatcher(matcher.Config{AmountTolerance: 0.01, DateTolerance: 5}),
&mockConsolidator{}, &mockSplitter{}, &mockMonarch{}, nil,
)
usedTxnIDs := make(map[string]bool)

result, err := handler.ProcessOrder(context.Background(), order, monarchTxns, usedTxnIDs, nil, nil, false)

require.NoError(t, err)
assert.True(t, result.Skipped)
assert.Contains(t, result.SkipReason, "exceed expected")
assert.Empty(t, usedTxnIDs, "unrelated transactions must not be claimed")
}

func TestAmazonHandler_ProcessOrder_DoesNotConsolidatePendingMultiChargeTransactions(t *testing.T) {
Expand Down
Loading
Loading