diff --git a/cmd/itemize/main.go b/cmd/itemize/main.go index 73ec398..5d67692 100644 --- a/cmd/itemize/main.go +++ b/cmd/itemize/main.go @@ -52,8 +52,9 @@ func main() { // Provider sync commands providerName := command amazonSetup := providerName == "amazon" && len(os.Args) > 2 && os.Args[2] == "setup" + amazonReturns := providerName == "amazon" && len(os.Args) > 2 && os.Args[2] == "returns" // Shift args for flag parsing - if amazonSetup { + if amazonSetup || amazonReturns { os.Args = append([]string{os.Args[0]}, os.Args[3:]...) } else if providerName == "amazon" && len(os.Args) > 2 && !strings.HasPrefix(os.Args[2], "-") { os.Args = append([]string{os.Args[0], "-account", os.Args[2]}, os.Args[3:]...) @@ -129,6 +130,21 @@ func main() { return } + if amazonReturns { + provider, createErr := cli.NewAmazonProvider(cfg, flags.Verbose, amazonAccount) + if createErr != nil { + log.Fatalf("Failed to create Amazon provider: %v", createErr) + } + returns, fetchErr := provider.FetchReturns(context.Background()) + if fetchErr != nil { + log.Fatalf("Failed to fetch Amazon returns: %v", fetchErr) + } + if printErr := cli.PrintAmazonReturns(os.Stdout, returns); printErr != nil { + log.Fatalf("Failed to print Amazon returns: %v", printErr) + } + return + } + if flags.ImportBrowserProfile != "" { if providerName != "amazon" { fmt.Printf("-import-browser-profile is only supported for the amazon provider\n") @@ -230,6 +246,8 @@ func printUsage() { fmt.Println(" amazon Sync Amazon orders") fmt.Println(" amazon setup -account ") fmt.Println(" Create an Amazon account and open Chromium for sign-in") + fmt.Println(" amazon returns -account ") + fmt.Println(" Read Amazon's return/refund ledger as JSON") fmt.Println(" costco Sync Costco orders") fmt.Println(" walmart Sync Walmart orders") fmt.Println(" version Print version, commit, and build date (also: -version, --version)") diff --git a/docs/bug-fixes.md b/docs/bug-fixes.md index c3dd188..2cf1d0c 100644 --- a/docs/bug-fixes.md +++ b/docs/bug-fixes.md @@ -35,6 +35,43 @@ When the ledger is empty, Itemize now uses the receipt total only for an `IN_STO - A live run of the rebuilt v0.2.1 source reproduced all three completed in-store receipts being skipped with an empty ledger before the fix. - The fixed binary dry-ran all three affected receipts against live Walmart and Monarch data, matched `$9.88`, `$32.10`, and `$10.69` transactions, and finished with `Processed=3 Skipped=0 Errors=0` without writing to Monarch. +--- + +### 2026-07-17: Amazon refund credits remained in the temporary category + +**Description:** +Amazon refund credits could be fetched from Monarch but Itemize did not have an authoritative source connecting a refund amount to its returned item. The Amazon provider claimed refund support while its normal payment-transaction endpoint supplied no usable refund entries, leaving positive credits in the temporary Amazon category. + +**Root Cause:** +Amazon exposes return-created date, refund total, order ID, RMA, ASIN, item, and status in the Return Center rather than the normal order-payment ledger. The refund-issued date is on each return status page and its sentence is split across HTML elements. Monarch also stored the temporary category with repeated whitespace, which a literal category-name comparison rejected. + +**Fix Applied:** +Itemize now reads the Return Center and status pages directly with saved account cookies, parses the explicit refund-issued date, and adapts a single-item return into a refund order. Normal Amazon sync matches only positive, non-pending, unsplit, temporary/uncategorized Monarch credits using amount and issued date. A unique credit is categorized from the returned item. Indistinguishable same-date/same-amount credits are updated only when Amazon record count equals Monarch credit count and every returned item independently resolves to the same category; their shared note explicitly says the bank feed cannot identify the individual credit-to-item mapping. Multi-item returns without an Amazon allocation and all other ambiguous cases remain untouched. + +**Verification:** +- Issued-date extraction, quoted-cookie normalization, whitespace-normalized temporary categories, unique matching, ambiguity rejection, calendar-day lookback, and invariant-category groups have regression tests that failed before their fixes. +- A full wife-account dry-run reported `Amazon refunds: Categorized=4 Left untouched=0` before production writes. +- The production run categorized the `$11.36` credit as `Kid Needs`, the `$165.95` credit as `Kid Wants`, and both `$14.41` credits as `Kid Needs` with a truthful two-return group note. +- A fresh Monarch query after the run confirmed all four transaction IDs, categories, and notes. + +--- + +### 2026-07-16: Browser-exported Amazon cookie produced an invalid Cookie header warning + +**Description:** +Amazon's browser profile can export a cookie whose value retains surrounding quotes. Go's HTTP client stripped those bytes while constructing the direct Return Center request and emitted a warning on every return-ledger fetch. + +**Test Case:** +`TestNormalizeReturnCookieValueUnquotesBrowserCookie` reproduces the quoted browser-cookie value and requires the normalized HTTP cookie to retain the exact inner value. + +**Fix Applied:** +The read-only Amazon return-history client now decodes a syntactically quoted cookie value before adding it to the request. Ordinary unquoted cookie values are unchanged. + +**Verification:** +The regression test failed before the helper existed and passes after the fix. A live `itemize amazon returns -account wife` fetch completed without the invalid-cookie warning. + +--- + ### 2026-07-15: Walmart detail requests were rejected with HTTP 456 **Description:** @@ -405,6 +442,48 @@ Added a parallel `descMap` keyed by uppercased `ItemDescription01`. When the ite --- +### 2026-07-17: Imported Amazon cookies lacked explicit request security attributes + +**Description:** +The Amazon Return Center client correctly reused browser-exported cookies over HTTPS, but the imported `http.Cookie` values did not explicitly carry `Secure`, `HttpOnly`, or `SameSite` attributes. The security scan therefore rejected the release candidate. + +**Test Case:** +`TestNormalizeReturnCookieValueSecuresAndUnquotesBrowserCookie` first reproduced the issue by asserting that a quoted browser cookie is both normalized and marked with conservative security attributes. + +**Root Cause:** +The normalization helper only unquoted the stored value. Browser-export metadata is not guaranteed to include every Go security field even though this client sends the cookie only to Amazon over HTTPS. + +**Fix Applied:** +The helper now marks imported cookies `Secure`, `HttpOnly`, and `SameSite=Lax` before adding them to Return Center requests. `http.Request.AddCookie` still serializes only the cookie name and value, so authentication behavior is unchanged. + +**Verification:** +- The regression test failed before the fix and passes after it. +- gosec v2.27.1 reports zero findings. +- Return Center error-path and refund safety tests cover authentication, malformed data, ambiguity, grouping, and write failures. + +--- + +### 2026-07-17: Indistinguishable Amazon refund groups were not deduplicated + +**Description:** +Successfully categorized same-day, same-amount Amazon refund groups were counted but not saved to Itemize's processing ledger. Later syncs therefore reconsidered the same returns and warned that no temporary credits remained. + +**Test Case:** +`TestProcessAmazonReturnsGroupsIndistinguishableCredits` first reproduced the issue by requiring both Amazon RMA IDs to be marked processed while verifying that no individual credit-to-item association was stored. + +**Root Cause:** +The single-refund path called `recordSuccessWithResult`, but the group path only incremented `RefundProcessedCount`. + +**Fix Applied:** +The group path now records each authoritative Amazon return as successfully processed. It deliberately removes the individual transaction from the audit result so the database does not claim a mapping that Amazon and the bank feed cannot prove. + +**Verification:** +- The regression test failed before the fix and passes after it. +- Both RMA IDs participate in normal deduplication. +- No Monarch transaction association is stored for either indistinguishable item. + +--- + ### 2025-09-01: No bugs discovered yet The project was developed using strict TDD methodology from the start, preventing bugs through test-first development. diff --git a/go.mod b/go.mod index 2eaee7f..daa60e6 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/eshaffer321/itemize go 1.25.12 require ( + github.com/PuerkitoBio/goquery v1.8.1 github.com/eshaffer321/amazon-go v0.3.0 github.com/eshaffer321/costco-go v0.3.11 github.com/eshaffer321/monarch-go/v2 v2.0.0 @@ -16,7 +17,6 @@ require ( ) require ( - github.com/PuerkitoBio/goquery v1.8.1 // indirect github.com/andybalholm/cascadia v1.3.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect diff --git a/internal/adapters/providers/amazon/returns.go b/internal/adapters/providers/amazon/returns.go new file mode 100644 index 0000000..cc9f991 --- /dev/null +++ b/internal/adapters/providers/amazon/returns.go @@ -0,0 +1,385 @@ +package amazon + +import ( + "bytes" + "context" + "fmt" + "io" + "math" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" + amazongo "github.com/eshaffer321/amazon-go" + "github.com/eshaffer321/itemize/internal/adapters/providers" +) + +const ( + amazonReturnHistoryURL = "https://www.amazon.com/spr/returns/history" + amazonReturnUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +) + +var ( + returnOrderIDPattern = regexp.MustCompile(`\bORDER\s*#\s*(\d{3}-\d{7}-\d{7})\b`) + returnRMAIDPattern = regexp.MustCompile(`\bRMA\s+ID\s*:\s*([A-Za-z0-9]+RRMA)\b`) + returnCreatedPattern = regexp.MustCompile(`\bRETURN\s+CREATED\s+([A-Z][a-z]{2}\s+\d{1,2},\s+\d{4})\b`) + refundTotalPattern = regexp.MustCompile(`\bREFUND\s+TOTAL\s+\$([\d,]+\.\d{2})\b`) + refundIssuedPattern = regexp.MustCompile(`(?i)\$([\d,]+\.\d{2})\s+refund issued on\s+([A-Z][a-z]{2}\s+\d{1,2},\s+\d{4})`) + returnPricePattern = regexp.MustCompile(`\$([\d,]+\.\d{2})`) + returnASINPattern = regexp.MustCompile(`/gp/product/([A-Z0-9]+)`) +) + +// ReturnRecord is one return event reported by Amazon's Return Center. +// RefundAmount is authoritative only when HasRefundTotal is true. +type ReturnRecord struct { + OrderID string `json:"order_id"` + RMAID string `json:"rma_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + RefundAmount float64 `json:"refund_amount,omitempty"` + HasRefundTotal bool `json:"has_refund_total"` + RefundIssuedAt *time.Time `json:"refund_issued_at,omitempty"` + Status string `json:"status"` + Items []ReturnedItem `json:"items"` + statusURL string +} + +// ReturnedItem identifies an item included in an Amazon return event. +type ReturnedItem struct { + ASIN string `json:"asin"` + Name string `json:"name"` + Price float64 `json:"price"` +} + +// RefundOrder adapts one authoritative Amazon return record to the generic +// order interface used by categorization and matching. +type RefundOrder struct { + record ReturnRecord +} + +// NewRefundOrder creates a read-only order view for a return record. +func NewRefundOrder(record ReturnRecord) *RefundOrder { + return &RefundOrder{record: record} +} + +func (o *RefundOrder) GetID() string { return "amazon-refund:" + o.record.RMAID } +func (o *RefundOrder) GetDate() time.Time { + if o.record.RefundIssuedAt != nil { + return *o.record.RefundIssuedAt + } + 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) GetTax() float64 { return 0 } +func (o *RefundOrder) GetTip() float64 { return 0 } +func (o *RefundOrder) GetFees() float64 { return 0 } +func (o *RefundOrder) GetProviderName() string { return "Amazon" } +func (o *RefundOrder) GetRawData() interface{} { return o.record } +func (o *RefundOrder) Record() ReturnRecord { return o.record } +func (o *RefundOrder) GetItems() []providers.OrderItem { + items := make([]providers.OrderItem, 0, len(o.record.Items)) + for _, item := range o.record.Items { + price := item.Price + if len(o.record.Items) == 1 && o.record.HasRefundTotal { + price = o.record.RefundAmount + } + items = append(items, refundOrderItem{item: item, price: price}) + } + return items +} + +type refundOrderItem struct { + item ReturnedItem + price float64 +} + +func (i refundOrderItem) GetName() string { return i.item.Name } +func (i refundOrderItem) GetPrice() float64 { return i.price } +func (i refundOrderItem) GetQuantity() float64 { return 1 } +func (i refundOrderItem) GetUnitPrice() float64 { return i.price } +func (i refundOrderItem) GetDescription() string { return "" } +func (i refundOrderItem) GetSKU() string { return i.item.ASIN } +func (i refundOrderItem) GetCategory() string { return "" } + +type returnHistoryClient struct { + httpClient *http.Client + cookieFile string + historyURL string +} + +func newReturnHistoryClient(cookieFile string) *returnHistoryClient { + return &returnHistoryClient{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + cookieFile: cookieFile, + historyURL: amazonReturnHistoryURL, + } +} + +// FetchReturns reads Amazon's Return Center without mutating the saved cookie file. +func (p *Provider) FetchReturns(ctx context.Context) ([]ReturnRecord, error) { + cookieFile, err := p.resolvedCookieFile() + if err != nil { + return nil, err + } + returns, err := newReturnHistoryClient(cookieFile).Fetch(ctx) + if err != nil { + return nil, fmt.Errorf("failed to fetch Amazon returns: %w", err) + } + return returns, nil +} + +func (p *Provider) resolvedCookieFile() (string, error) { + if p.cookieFile != "" { + return p.cookieFile, nil + } + if p.profile != "" { + path, err := amazongo.CookiePathForAccount(p.profile) + if err != nil { + return "", fmt.Errorf("failed to resolve Amazon account cookie file: %w", err) + } + return path, nil + } + path, err := amazongo.DefaultCookiePath() + if err != nil { + return "", fmt.Errorf("failed to resolve default Amazon cookie file: %w", err) + } + return path, nil +} + +func (c *returnHistoryClient) Fetch(ctx context.Context) ([]ReturnRecord, error) { + store, err := amazongo.NewCookieStore(c.cookieFile) + if err != nil { + return nil, fmt.Errorf("failed to load Amazon cookies: %w", err) + } + + cookies := store.ToHTTPCookies() + for _, cookie := range cookies { + normalizeReturnCookieValue(cookie) + } + body, finalURL, err := c.fetchPage(ctx, c.historyURL, cookies) + if err != nil { + return nil, fmt.Errorf("amazon return-history request failed: %w", err) + } + if isReturnSignInPage(finalURL, body) { + return nil, fmt.Errorf("return-center authentication required: open Amazon's Return Center with the account browser profile and sign in again") + } + + baseURL, err := url.Parse(c.historyURL) + if err != nil { + return nil, fmt.Errorf("invalid Amazon return-history URL: %w", err) + } + returns, err := parseReturnHistory(bytes.NewReader(body), baseURL) + if err != nil { + return nil, err + } + for i := range returns { + if !returns[i].HasRefundTotal || returns[i].statusURL == "" { + continue + } + detail, detailURL, detailErr := c.fetchPage(ctx, returns[i].statusURL, cookies) + if detailErr != nil { + return nil, fmt.Errorf("amazon return status request failed for RMA %s: %w", returns[i].RMAID, detailErr) + } + if isReturnSignInPage(detailURL, detail) { + return nil, fmt.Errorf("return-center authentication required while reading RMA %s", returns[i].RMAID) + } + if issuedAt, ok := parseRefundIssuedAt(detail, returns[i].RefundAmount); ok { + returns[i].RefundIssuedAt = &issuedAt + } + } + return returns, nil +} + +func (c *returnHistoryClient) fetchPage(ctx context.Context, target string, cookies []*http.Cookie) ([]byte, *url.URL, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return nil, nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + req.Header.Set("Cache-Control", "no-cache") + req.Header.Set("Pragma", "no-cache") + req.Header.Set("User-Agent", amazonReturnUserAgent) + for _, cookie := range cookies { + req.AddCookie(cookie) + } + httpClient := c.httpClient + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + resp, err := httpClient.Do(req) + if err != nil { + return nil, nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, responseURL(resp), fmt.Errorf("returned status %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) + if err != nil { + return nil, responseURL(resp), fmt.Errorf("failed to read response: %w", err) + } + return body, responseURL(resp), nil +} + +func responseURL(resp *http.Response) *url.URL { + if resp != nil && resp.Request != nil { + return resp.Request.URL + } + return nil +} + +func parseReturnHistory(reader io.Reader, baseURL *url.URL) ([]ReturnRecord, error) { + doc, err := goquery.NewDocumentFromReader(reader) + if err != nil { + return nil, fmt.Errorf("failed to parse Amazon return history: %w", err) + } + + returns := make([]ReturnRecord, 0) + doc.Find(".a-box-group.a-spacing-extra-large").Each(func(_ int, group *goquery.Selection) { + text := normalizedReturnText(group.Text()) + orderMatch := returnOrderIDPattern.FindStringSubmatch(text) + createdMatch := returnCreatedPattern.FindStringSubmatch(text) + if len(orderMatch) != 2 || len(createdMatch) != 2 { + return + } + + createdAt, parseErr := time.ParseInLocation("Jan 2, 2006", createdMatch[1], time.UTC) + if parseErr != nil { + return + } + + record := ReturnRecord{ + OrderID: orderMatch[1], + CreatedAt: createdAt, + Status: normalizedReturnText(group.Find("h4").First().Text()), + Items: parseReturnedItems(group), + } + if len(record.Items) == 0 { + return + } + if rmaMatch := returnRMAIDPattern.FindStringSubmatch(text); len(rmaMatch) == 2 { + record.RMAID = rmaMatch[1] + } + if refundMatch := refundTotalPattern.FindStringSubmatch(text); len(refundMatch) == 2 { + if amount, amountErr := parseReturnAmount(refundMatch[1]); amountErr == nil { + record.RefundAmount = amount + record.HasRefundTotal = true + } + } + group.Find("a").EachWithBreak(func(_ int, link *goquery.Selection) bool { + if !strings.EqualFold(normalizedReturnText(link.Text()), "View return/refund status") { + return true + } + href, exists := link.Attr("href") + if exists { + record.statusURL = resolveReturnURL(baseURL, href) + } + return false + }) + returns = append(returns, record) + }) + + return returns, nil +} + +func parseReturnedItems(group *goquery.Selection) []ReturnedItem { + items := make([]ReturnedItem, 0) + seen := make(map[string]bool) + group.Find(`a[href*="/gp/product/"]`).Each(func(_ int, link *goquery.Selection) { + href, exists := link.Attr("href") + if !exists { + return + } + asinMatch := returnASINPattern.FindStringSubmatch(href) + if len(asinMatch) != 2 || seen[asinMatch[1]] { + return + } + name := normalizedReturnText(link.Text()) + if name == "" { + return + } + item := ReturnedItem{ASIN: asinMatch[1], Name: name} + itemText := normalizedReturnText(link.Closest(".a-fixed-left-grid-col.a-col-right").Text()) + priceMatches := returnPricePattern.FindAllStringSubmatch(itemText, -1) + if len(priceMatches) > 0 { + item.Price, _ = parseReturnAmount(priceMatches[len(priceMatches)-1][1]) + } + seen[item.ASIN] = true + items = append(items, item) + }) + return items +} + +func parseReturnAmount(value string) (float64, error) { + return strconv.ParseFloat(strings.ReplaceAll(value, ",", ""), 64) +} + +func parseRefundIssuedAt(body []byte, refundAmount float64) (time.Time, bool) { + doc, err := goquery.NewDocumentFromReader(bytes.NewReader(body)) + if err != nil { + return time.Time{}, false + } + text := normalizedReturnText(doc.Text()) + for _, match := range refundIssuedPattern.FindAllStringSubmatch(text, -1) { + if len(match) != 3 { + continue + } + amount, err := parseReturnAmount(match[1]) + if err != nil || math.Abs(amount-refundAmount) > 0.001 { + continue + } + issuedAt, err := time.ParseInLocation("Jan 2, 2006", match[2], time.UTC) + if err == nil { + return issuedAt, true + } + } + return time.Time{}, false +} + +func resolveReturnURL(baseURL *url.URL, href string) string { + parsed, err := url.Parse(href) + if err != nil { + return "" + } + if baseURL == nil { + return parsed.String() + } + return baseURL.ResolveReference(parsed).String() +} + +func normalizeReturnCookieValue(cookie *http.Cookie) { + if cookie == nil { + return + } + // These cookies are only sent back to Amazon over HTTPS. Mark the imported + // browser values with conservative attributes before attaching them to a + // request; AddCookie serializes only the name and value. + cookie.Secure = true + cookie.HttpOnly = true + cookie.SameSite = http.SameSiteLaxMode + if len(cookie.Value) < 2 || cookie.Value[0] != '"' || cookie.Value[len(cookie.Value)-1] != '"' { + return + } + unquoted, err := strconv.Unquote(cookie.Value) + if err == nil { + cookie.Value = unquoted + } +} + +func normalizedReturnText(value string) string { + return strings.Join(strings.Fields(value), " ") +} + +func isReturnSignInPage(finalURL *url.URL, body []byte) bool { + if finalURL != nil && strings.Contains(finalURL.Path, "/ap/signin") { + return true + } + lower := strings.ToLower(string(body)) + return strings.Contains(lower, "amazon sign-in") || + (strings.Contains(lower, "ap_email") && strings.Contains(lower, "ap_password")) +} diff --git a/internal/adapters/providers/amazon/returns_test.go b/internal/adapters/providers/amazon/returns_test.go new file mode 100644 index 0000000..e0c5781 --- /dev/null +++ b/internal/adapters/providers/amazon/returns_test.go @@ -0,0 +1,317 @@ +package amazon + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + amazongo "github.com/eshaffer321/amazon-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const returnHistoryFixture = ` +Online Return Center +
+
+
RETURN CREATED Jun 29, 2026
+
REFUND TOTAL $11.36
+
ORDER # 112-1111111-2222222
+
RMA ID : D8ExampleRRMA
+
+
+

Return in transit

+
+ +
$10.72
+
+
+ +
+
+
+
+
+
RETURN CREATED Jun 14, 2026
+
REFUND TOTAL $165.95
+
ORDER # 112-3333333-4444444
+
RMA ID : DrExampleRRMA
+
+
+

Refund issued

+
+ +
$156.56
+
+
+ View return/refund status +
+
+
+
+
+
RETURN CREATED Jul 6, 2026
+
ORDER # 112-5555555-6666666
+
RMA ID : D2PendingRRMA
+

Return requested

+
+ Replacement Power Cord + $11.95 +
+ View return/refund status +
+
+` + +func TestParseReturnHistory_MapsAmazonReturnRecords(t *testing.T) { + base, err := url.Parse("https://www.amazon.com") + require.NoError(t, err) + + returns, err := parseReturnHistory(strings.NewReader(returnHistoryFixture), base) + + require.NoError(t, err) + require.Len(t, returns, 3) + assert.Equal(t, ReturnRecord{ + OrderID: "112-1111111-2222222", + RMAID: "D8ExampleRRMA", + CreatedAt: time.Date(2026, time.June, 29, 0, 0, 0, 0, time.UTC), + RefundAmount: 11.36, + HasRefundTotal: true, + Status: "Return in transit", + Items: []ReturnedItem{{ + ASIN: "B0EXAMPLE1", + Name: "Insulated Sporty Cup", + Price: 10.72, + }}, + statusURL: "https://www.amazon.com/spr/returns/cart?orderId=112-1111111-2222222&returnSessionId=session", + }, returns[0]) + assert.Equal(t, "Refund issued", returns[1].Status) + assert.Equal(t, 165.95, returns[1].RefundAmount) + assert.Equal(t, "B0EXAMPLE2", returns[1].Items[0].ASIN) + assert.Equal(t, "Caterpillar Play Tunnel", returns[1].Items[0].Name) + assert.False(t, returns[2].HasRefundTotal) + assert.Zero(t, returns[2].RefundAmount) + assert.Equal(t, "Return requested", returns[2].Status) +} + +func TestReturnHistoryClient_FetchesWithSavedAmazonCookies(t *testing.T) { + cookieFile := filepath.Join(t.TempDir(), "cookies-wife.json") + data := `{"cookies":[{"name":"session-id","value":"saved-session","domain":".amazon.com","path":"/"}],"updated_at":"2026-07-16T00:00:00Z"}` + require.NoError(t, os.WriteFile(cookieFile, []byte(data), 0o600)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session-id") + require.NoError(t, err) + assert.Equal(t, "saved-session", cookie.Value) + if r.URL.Path == "/spr/returns/cart" { + amount := "$11.36" + if r.URL.Query().Get("orderId") == "112-3333333-4444444" { + amount = "$165.95" + } + _, _ = fmt.Fprintf(w, `
%s refund issued on Jul 3, 2026
`, amount) + return + } + _, _ = w.Write([]byte(returnHistoryFixture)) + })) + t.Cleanup(server.Close) + + client := &returnHistoryClient{ + httpClient: &http.Client{Timeout: time.Second}, + cookieFile: cookieFile, + historyURL: server.URL, + } + returns, err := client.Fetch(context.Background()) + + require.NoError(t, err) + assert.Len(t, returns, 3) + require.NotNil(t, returns[0].RefundIssuedAt) + assert.Equal(t, time.Date(2026, time.July, 3, 0, 0, 0, 0, time.UTC), *returns[0].RefundIssuedAt) + require.NotNil(t, returns[1].RefundIssuedAt) + assert.Equal(t, time.Date(2026, time.July, 3, 0, 0, 0, 0, time.UTC), *returns[1].RefundIssuedAt) + assert.Nil(t, returns[2].RefundIssuedAt) +} + +func TestParseRefundIssuedAtRequiresMatchingRefundAmount(t *testing.T) { + body := []byte(`
$14.41 refund issued on Jul 3, 2026
+
$11.36 refund issued on Jul 5, 2026
`) + + issuedAt, ok := parseRefundIssuedAt(body, 11.36) + + require.True(t, ok) + assert.Equal(t, time.Date(2026, time.July, 5, 0, 0, 0, 0, time.UTC), issuedAt) +} + +func TestNewRefundOrderUsesIssuedDateAndSoleRefundItem(t *testing.T) { + issuedAt := time.Date(2026, time.July, 3, 0, 0, 0, 0, time.UTC) + record := ReturnRecord{ + OrderID: "112-1111111-2222222", + RMAID: "D8ExampleRRMA", + RefundAmount: 11.36, + HasRefundTotal: true, + RefundIssuedAt: &issuedAt, + Items: []ReturnedItem{{ASIN: "B0EXAMPLE1", Name: "Insulated Sporty Cup", Price: 10.72}}, + } + + order := NewRefundOrder(record) + + assert.Equal(t, "amazon-refund:D8ExampleRRMA", order.GetID()) + assert.Equal(t, issuedAt, order.GetDate()) + assert.Equal(t, -11.36, order.GetTotal()) + assert.Equal(t, 11.36, order.GetSubtotal()) + assert.Zero(t, order.GetTax()) + assert.Zero(t, order.GetTip()) + assert.Zero(t, order.GetFees()) + assert.Equal(t, "Amazon", order.GetProviderName()) + assert.Equal(t, record, order.GetRawData()) + assert.Equal(t, record, order.Record()) + require.Len(t, order.GetItems(), 1) + item := order.GetItems()[0] + assert.Equal(t, "Insulated Sporty Cup", item.GetName()) + assert.Equal(t, 11.36, item.GetPrice()) + assert.Equal(t, 1.0, item.GetQuantity()) + assert.Equal(t, 11.36, item.GetUnitPrice()) + assert.Empty(t, item.GetDescription()) + assert.Equal(t, "B0EXAMPLE1", item.GetSKU()) + assert.Empty(t, item.GetCategory()) + + createdAt := time.Date(2026, time.June, 29, 0, 0, 0, 0, time.UTC) + multiple := NewRefundOrder(ReturnRecord{ + CreatedAt: createdAt, + Items: []ReturnedItem{ + {ASIN: "one", Name: "One", Price: 3.25}, + {ASIN: "two", Name: "Two", Price: 4.75}, + }, + }) + assert.Equal(t, createdAt, multiple.GetDate()) + assert.Equal(t, 3.25, multiple.GetItems()[0].GetPrice()) +} + +func TestReturnHistoryClientConstructionAndCookieResolution(t *testing.T) { + explicit := filepath.Join(t.TempDir(), "cookies.json") + provider := &Provider{cookieFile: explicit} + + resolved, err := provider.resolvedCookieFile() + require.NoError(t, err) + assert.Equal(t, explicit, resolved) + + client := newReturnHistoryClient(explicit) + assert.Equal(t, explicit, client.cookieFile) + assert.Equal(t, amazonReturnHistoryURL, client.historyURL) + assert.Equal(t, 30*time.Second, client.httpClient.Timeout) + + home := t.TempDir() + t.Setenv("HOME", home) + provider = &Provider{profile: "wife"} + resolved, err = provider.resolvedCookieFile() + require.NoError(t, err) + assert.Contains(t, resolved, "wife") + + provider = &Provider{} + resolved, err = provider.resolvedCookieFile() + require.NoError(t, err) + assert.NotEmpty(t, resolved) +} + +func TestReturnHistoryClient_RejectsAmazonSignInPage(t *testing.T) { + cookieFile := filepath.Join(t.TempDir(), "cookies-wife.json") + stored := amazongo.CookieFile{Cookies: []*amazongo.Cookie{{Name: "session-id", Value: "expired", Domain: ".amazon.com", Path: "/"}}} + data, err := jsonMarshal(stored) + require.NoError(t, err) + require.NoError(t, os.WriteFile(cookieFile, data, 0o600)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`Amazon Sign-In`)) + })) + t.Cleanup(server.Close) + + client := &returnHistoryClient{httpClient: server.Client(), cookieFile: cookieFile, historyURL: server.URL} + _, err = client.Fetch(context.Background()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "return-center authentication required") +} + +func TestNormalizeReturnCookieValueSecuresAndUnquotesBrowserCookie(t *testing.T) { + cookie := &http.Cookie{Name: "browser-cookie", Value: `"saved-session"`} + + normalizeReturnCookieValue(cookie) + + assert.Equal(t, "saved-session", cookie.Value) + assert.True(t, cookie.Secure) + assert.True(t, cookie.HttpOnly) + assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite) +} + +func TestReturnHistoryClientReportsRequestFailures(t *testing.T) { + cookieFile := filepath.Join(t.TempDir(), "cookies.json") + require.NoError(t, os.WriteFile(cookieFile, []byte(`{"cookies":[]}`), 0o600)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + t.Cleanup(server.Close) + + client := &returnHistoryClient{httpClient: server.Client(), cookieFile: cookieFile, historyURL: server.URL} + _, err := client.Fetch(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "return-history request failed") + assert.Contains(t, err.Error(), "status 503") + + client.historyURL = "://bad-url" + _, _, err = client.fetchPage(context.Background(), client.historyURL, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create request") +} + +func TestReturnHistoryClientRejectsSignInOnReturnDetail(t *testing.T) { + cookieFile := filepath.Join(t.TempDir(), "cookies.json") + require.NoError(t, os.WriteFile(cookieFile, []byte(`{"cookies":[]}`), 0o600)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/spr/returns/cart" { + _, _ = w.Write([]byte(``)) + return + } + _, _ = w.Write([]byte(returnHistoryFixture)) + })) + t.Cleanup(server.Close) + + client := &returnHistoryClient{httpClient: server.Client(), cookieFile: cookieFile, historyURL: server.URL} + _, err := client.Fetch(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "authentication required while reading RMA D8ExampleRRMA") +} + +func TestReturnParsingRejectsIncompleteRecordsAndHandlesURLVariants(t *testing.T) { + returns, err := parseReturnHistory(strings.NewReader(`
+
RETURN CREATED Not a date
ORDER # 112-1111111-2222222
+ +
RETURN CREATED Jul 1, 2026
`), nil) + require.NoError(t, err) + assert.Empty(t, returns) + + assert.Equal(t, "https://example.com/detail", resolveReturnURL(nil, "https://example.com/detail")) + assert.Empty(t, resolveReturnURL(nil, "://bad-url")) + assert.Nil(t, responseURL(nil)) + assert.False(t, isReturnSignInPage(nil, []byte("ordinary return page"))) + assert.True(t, isReturnSignInPage(&url.URL{Path: "/ap/signin"}, nil)) + + normalizeReturnCookieValue(nil) + plain := &http.Cookie{Name: "plain", Value: "value"} + normalizeReturnCookieValue(plain) + assert.Equal(t, "value", plain.Value) +} + +func jsonMarshal(value any) ([]byte, error) { + return json.Marshal(value) +} diff --git a/internal/application/sync/amazon_returns.go b/internal/application/sync/amazon_returns.go new file mode 100644 index 0000000..acc48c5 --- /dev/null +++ b/internal/application/sync/amazon_returns.go @@ -0,0 +1,163 @@ +package sync + +import ( + "context" + "fmt" + "time" + + amazonprovider "github.com/eshaffer321/itemize/internal/adapters/providers/amazon" + "github.com/eshaffer321/itemize/internal/domain/categorizer" + "github.com/eshaffer321/monarch-go/v2/pkg/monarch" +) + +type amazonReturnProvider interface { + FetchReturns(ctx context.Context) ([]amazonprovider.ReturnRecord, error) +} + +func (o *Orchestrator) fetchAmazonReturns(ctx context.Context) ([]amazonprovider.ReturnRecord, error) { + provider, ok := o.provider.(amazonReturnProvider) + if !ok { + return nil, nil + } + started := time.Now() + returns, err := provider.FetchReturns(ctx) + o.logProviderFetch("amazon_returns", map[string]any{}, map[string]any{ + "return_count": len(returns), + }, err, time.Since(started), 0, 0) + if err != nil { + return nil, fmt.Errorf("failed to fetch Amazon returns: %w", err) + } + o.logger.Info("Fetched Amazon return ledger", "return_count", len(returns)) + return returns, nil +} + +func (o *Orchestrator) processAmazonReturns( + ctx context.Context, + returns []amazonprovider.ReturnRecord, + transactions []*monarch.Transaction, + usedTxnIDs map[string]bool, + catCategories []categorizer.Category, + monarchCategories []*monarch.TransactionCategory, + opts Options, + now time.Time, + result *Result, +) { + if o.amazonHandler == nil || result == nil { + return + } + type refundGroup struct { + key string + records []amazonprovider.ReturnRecord + } + groupsByKey := make(map[string]*refundGroup) + groups := make([]*refundGroup, 0) + for _, record := range returns { + if opts.OrderID != "" && record.OrderID != opts.OrderID && record.RMAID != opts.OrderID { + continue + } + if !returnFallsWithinLookback(record, opts.LookbackDays, now) { + continue + } + refund := amazonprovider.NewRefundOrder(record) + if !opts.Force && o.storage != nil && o.storage.IsProcessed(refund.GetID()) { + 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 := groupsByKey[key] + if group == nil { + group = &refundGroup{key: key} + groupsByKey[key] = group + groups = append(groups, group) + } + group.records = append(group.records, record) + } + + for _, group := range groups { + if len(group.records) > 1 { + o.processAmazonRefundGroup(ctx, group.key, group.records, transactions, usedTxnIDs, catCategories, monarchCategories, opts, result) + continue + } + record := group.records[0] + refund := amazonprovider.NewRefundOrder(record) + refundCtx := withAuditContext(ctx, refund.GetID(), opts.DryRun) + processed, err := o.amazonHandler.ProcessRefund(refundCtx, refund, transactions, usedTxnIDs, catCategories, monarchCategories, opts.DryRun) + if err != nil { + result.ErrorCount++ + wrapped := fmt.Errorf("amazon refund %s (order %s, $%.2f): %w", record.RMAID, record.OrderID, record.RefundAmount, err) + result.Errors = append(result.Errors, wrapped) + o.logger.Error("Amazon refund processing failed", "order_id", record.OrderID, "rma_id", record.RMAID, "error", err) + o.recordError(refund, err.Error(), nil) + continue + } + if processed.Skipped { + result.RefundSkippedCount++ + o.logger.Warn("Amazon refund left untouched", "order_id", record.OrderID, "rma_id", record.RMAID, "reason", processed.SkipReason) + continue + } + if processed.Processed { + result.RefundProcessedCount++ + o.recordSuccessWithResult(refund, processed.Transaction, processed.Splits, 1, opts.DryRun, processed, nil) + } + } +} + +func (o *Orchestrator) processAmazonRefundGroup( + ctx context.Context, + groupKey string, + records []amazonprovider.ReturnRecord, + transactions []*monarch.Transaction, + usedTxnIDs map[string]bool, + catCategories []categorizer.Category, + monarchCategories []*monarch.TransactionCategory, + opts Options, + result *Result, +) { + refunds := make([]*amazonprovider.RefundOrder, 0, len(records)) + for _, record := range records { + refunds = append(refunds, amazonprovider.NewRefundOrder(record)) + } + groupCtx := withAuditContext(ctx, "amazon-refund-group:"+groupKey, opts.DryRun) + processed, skipReason, err := o.amazonHandler.ProcessRefundGroup(groupCtx, refunds, transactions, usedTxnIDs, catCategories, monarchCategories, opts.DryRun) + if err != nil { + result.ErrorCount++ + wrapped := fmt.Errorf("amazon refund group %s: %w", groupKey, err) + result.Errors = append(result.Errors, wrapped) + o.logger.Error("Amazon refund group processing failed", "group", groupKey, "error", err) + return + } + if skipReason != "" { + result.RefundSkippedCount += len(records) + o.logger.Warn("Amazon refund group left untouched", "group", groupKey, "refund_count", len(records), "reason", skipReason) + return + } + if len(processed) != len(records) { + result.ErrorCount++ + result.Errors = append(result.Errors, fmt.Errorf("amazon refund group %s returned %d results for %d records", groupKey, len(processed), len(records))) + return + } + for i, refund := range refunds { + // Record each authoritative return as complete for deduplication, but do + // not persist an individual Monarch transaction association: Amazon and + // the bank feed cannot identify which indistinguishable credit belongs + // to which returned item. + auditResult := *processed[i] + auditResult.Transaction = nil + o.recordSuccessWithResult(refund, nil, processed[i].Splits, 1, opts.DryRun, &auditResult, nil) + } + result.RefundProcessedCount += len(processed) +} + +func returnFallsWithinLookback(record amazonprovider.ReturnRecord, lookbackDays int, now time.Time) bool { + if record.RefundIssuedAt == nil { + return false + } + cutoff := now.AddDate(0, 0, -lookbackDays) + issuedKey := dateKey(*record.RefundIssuedAt) + return issuedKey >= dateKey(cutoff) && issuedKey <= dateKey(now) +} + +func dateKey(value time.Time) int { + year, month, day := value.Date() + return year*10000 + int(month)*100 + day +} diff --git a/internal/application/sync/amazon_returns_test.go b/internal/application/sync/amazon_returns_test.go new file mode 100644 index 0000000..fc88aab --- /dev/null +++ b/internal/application/sync/amazon_returns_test.go @@ -0,0 +1,180 @@ +package sync + +import ( + "context" + "errors" + "log/slog" + "testing" + "time" + + "github.com/eshaffer321/itemize/internal/adapters/providers" + amazonprovider "github.com/eshaffer321/itemize/internal/adapters/providers/amazon" + "github.com/eshaffer321/itemize/internal/application/sync/handlers" + "github.com/eshaffer321/itemize/internal/domain/categorizer" + "github.com/eshaffer321/itemize/internal/domain/matcher" + "github.com/eshaffer321/itemize/internal/domain/splitter" + "github.com/eshaffer321/itemize/internal/infrastructure/storage" + "github.com/eshaffer321/monarch-go/v2/pkg/monarch" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type failingRefundSplitter struct{} + +func (failingRefundSplitter) CreateSplits(context.Context, providers.Order, *monarch.Transaction, []categorizer.Category, []*monarch.TransactionCategory) ([]*monarch.TransactionSplit, error) { + return nil, errors.New("categorizer unavailable") +} + +func (failingRefundSplitter) GetSingleCategoryInfo(context.Context, providers.Order, []categorizer.Category) (string, string, error) { + return "", "", errors.New("categorizer unavailable") +} + +func TestFetchAmazonReturnsUsesProviderCapability(t *testing.T) { + records := []amazonprovider.ReturnRecord{{RMAID: "DfetchRRMA", RefundAmount: 12.34}} + provider := &MockProvider{returnRecords: records} + orchestrator := &Orchestrator{provider: provider, logger: slog.Default()} + + got, err := orchestrator.fetchAmazonReturns(context.Background()) + require.NoError(t, err) + assert.Equal(t, records, got) + + provider.returnErr = errors.New("return center unavailable") + got, err = orchestrator.fetchAmazonReturns(context.Background()) + assert.Nil(t, got) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to fetch Amazon returns") +} + +func TestProcessAmazonReturnsIncludesCalendarDayAtLookbackBoundary(t *testing.T) { + now := time.Date(2026, time.July, 17, 15, 30, 0, 0, time.UTC) + issuedAt := time.Date(2026, time.July, 3, 0, 0, 0, 0, time.UTC) + records := []amazonprovider.ReturnRecord{{ + OrderID: "112-1111111-2222222", + RMAID: "D8ExampleRRMA", + RefundAmount: 11.36, + HasRefundTotal: true, + RefundIssuedAt: &issuedAt, + Items: []amazonprovider.ReturnedItem{{ASIN: "B0EXAMPLE1", Name: "Insulated Sporty Cup", Price: 10.72}}, + }} + temporary := &monarch.TransactionCategory{ID: "temp-amazon", Name: "[TEMP] Amazon"} + transactions := []*monarch.Transaction{{ + ID: "refund-credit", Amount: 11.36, Date: toMonarchDate(issuedAt), Category: temporary, + }} + transactionMatcher := matcher.NewMatcher(matcher.Config{AmountTolerance: 0.01, DateTolerance: 5}) + spl := splitter.NewSplitter(&mockCategorizer{categoryID: "kid-needs", categoryName: "Kid Needs"}) + orchestrator := &Orchestrator{ + amazonHandler: handlers.NewAmazonHandler(transactionMatcher, nil, &mockSplitterAdapter{splitter: spl}, &processOrderTestMonarch{}, slog.Default()), + logger: slog.Default(), + } + result := &Result{} + + orchestrator.processAmazonReturns(context.Background(), records, transactions, map[string]bool{}, []categorizer.Category{{ID: "kid-needs", Name: "Kid Needs"}}, nil, Options{DryRun: true, LookbackDays: 14}, now, result) + + assert.Equal(t, 1, 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) + issuedAt := time.Date(2026, time.July, 3, 0, 0, 0, 0, time.UTC) + record := amazonprovider.ReturnRecord{CreatedAt: createdAt, RefundIssuedAt: &issuedAt} + + assert.True(t, returnFallsWithinLookback(record, 14, now)) +} + +func TestProcessAmazonReturnsGroupsIndistinguishableCredits(t *testing.T) { + now := time.Date(2026, time.July, 17, 15, 30, 0, 0, time.UTC) + issuedAt := time.Date(2026, time.July, 3, 0, 0, 0, 0, time.UTC) + records := []amazonprovider.ReturnRecord{ + {OrderID: "112-1111111-2222222", RMAID: "DfirstRRMA", RefundAmount: 14.41, HasRefundTotal: true, RefundIssuedAt: &issuedAt, Items: []amazonprovider.ReturnedItem{{ASIN: "B0CUPONE", Name: "Sesame Street Kids Cup", Price: 13.59}}}, + {OrderID: "112-1111111-2222222", RMAID: "DsecondRRMA", RefundAmount: 14.41, HasRefundTotal: true, RefundIssuedAt: &issuedAt, Items: []amazonprovider.ReturnedItem{{ASIN: "B0CUPTWO", Name: "Disney Kids Cup", Price: 13.59}}}, + } + 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}, + } + transactionMatcher := matcher.NewMatcher(matcher.Config{AmountTolerance: 0.01, DateTolerance: 5}) + spl := splitter.NewSplitter(&mockCategorizer{categoryID: "kid-needs", categoryName: "Kid Needs"}) + 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: "kid-needs", Name: "Kid Needs"}}, nil, Options{LookbackDays: 14}, now, result) + + assert.Equal(t, 2, result.RefundProcessedCount) + assert.Equal(t, 0, result.RefundSkippedCount) + assert.True(t, orchestrator.storage.IsProcessed("amazon-refund:DfirstRRMA")) + assert.True(t, orchestrator.storage.IsProcessed("amazon-refund:DsecondRRMA")) + for _, refundID := range []string{"amazon-refund:DfirstRRMA", "amazon-refund:DsecondRRMA"} { + associations, err := orchestrator.storage.GetOrderTransactions(refundID) + require.NoError(t, err) + assert.Empty(t, associations, "the indistinguishable group must not assert a credit-to-item mapping") + } +} + +func TestProcessAmazonReturnsLeavesUnmatchedRefundUntouched(t *testing.T) { + now := time.Date(2026, time.July, 17, 15, 30, 0, 0, time.UTC) + issuedAt := time.Date(2026, time.July, 10, 0, 0, 0, 0, time.UTC) + record := amazonprovider.ReturnRecord{ + OrderID: "112-1111111-2222222", RMAID: "DunmatchedRRMA", RefundAmount: 9.99, + HasRefundTotal: true, RefundIssuedAt: &issuedAt, + Items: []amazonprovider.ReturnedItem{{ASIN: "B0UNMATCHED", Name: "Unmatched item", Price: 9.99}}, + } + handler := handlers.NewAmazonHandler(matcher.NewMatcher(matcher.Config{AmountTolerance: 0.01, DateTolerance: 5}), nil, failingRefundSplitter{}, &processOrderTestMonarch{}, slog.Default()) + orchestrator := &Orchestrator{amazonHandler: handler, logger: slog.Default()} + result := &Result{} + + orchestrator.processAmazonReturns(context.Background(), []amazonprovider.ReturnRecord{record}, nil, map[string]bool{}, nil, nil, Options{DryRun: true, LookbackDays: 14}, now, result) + + assert.Equal(t, 1, result.RefundSkippedCount) + assert.Zero(t, result.ErrorCount) +} + +func TestProcessAmazonReturnsReportsCategorizationAndGroupCardinalityFailures(t *testing.T) { + now := time.Date(2026, time.July, 17, 15, 30, 0, 0, time.UTC) + issuedAt := time.Date(2026, time.July, 10, 0, 0, 0, 0, time.UTC) + makeRecord := func(rma, asin string) amazonprovider.ReturnRecord { + return amazonprovider.ReturnRecord{ + OrderID: "112-1111111-2222222", RMAID: rma, RefundAmount: 14.41, + HasRefundTotal: true, RefundIssuedAt: &issuedAt, + Items: []amazonprovider.ReturnedItem{{ASIN: asin, Name: "Returned item", Price: 14.41}}, + } + } + temporary := &monarch.TransactionCategory{ID: "temp-amazon", Name: "[TEMP] Amazon"} + transaction := &monarch.Transaction{ID: "credit", Amount: 14.41, Date: toMonarchDate(issuedAt), Category: temporary} + handler := handlers.NewAmazonHandler(matcher.NewMatcher(matcher.Config{AmountTolerance: 0.01, DateTolerance: 5}), nil, failingRefundSplitter{}, &processOrderTestMonarch{}, slog.Default()) + orchestrator := &Orchestrator{amazonHandler: handler, logger: slog.Default()} + + singleResult := &Result{} + orchestrator.processAmazonReturns(context.Background(), []amazonprovider.ReturnRecord{makeRecord("DsingleRRMA", "B0SINGLE")}, []*monarch.Transaction{transaction}, map[string]bool{}, nil, nil, Options{DryRun: true, LookbackDays: 14}, now, singleResult) + assert.Equal(t, 1, singleResult.ErrorCount) + require.Len(t, singleResult.Errors, 1) + assert.Contains(t, singleResult.Errors[0].Error(), "categorizer unavailable") + + groupResult := &Result{} + records := []amazonprovider.ReturnRecord{makeRecord("DoneRRMA", "B0ONE"), makeRecord("DtwoRRMA", "B0TWO")} + orchestrator.processAmazonReturns(context.Background(), records, []*monarch.Transaction{transaction}, map[string]bool{}, nil, nil, Options{DryRun: true, LookbackDays: 14}, now, groupResult) + assert.Equal(t, 2, groupResult.RefundSkippedCount) + assert.Zero(t, groupResult.ErrorCount) +} + +func TestProcessAmazonReturnsHonorsFiltersAndNilGuards(t *testing.T) { + now := time.Date(2026, time.July, 17, 0, 0, 0, 0, time.UTC) + issuedAt := now.AddDate(0, 0, -30) + record := amazonprovider.ReturnRecord{OrderID: "order", RMAID: "DfilterRRMA", RefundIssuedAt: &issuedAt} + orchestrator := &Orchestrator{logger: slog.Default()} + result := &Result{} + + orchestrator.processAmazonReturns(context.Background(), []amazonprovider.ReturnRecord{record}, nil, map[string]bool{}, nil, nil, Options{OrderID: "different", LookbackDays: 14}, now, result) + orchestrator.processAmazonReturns(context.Background(), []amazonprovider.ReturnRecord{record}, nil, map[string]bool{}, nil, nil, Options{OrderID: "order", LookbackDays: 14}, now, result) + orchestrator.processAmazonReturns(context.Background(), nil, nil, nil, nil, nil, Options{}, now, nil) + + assert.Zero(t, result.RefundProcessedCount) + assert.Zero(t, result.RefundSkippedCount) + assert.False(t, returnFallsWithinLookback(amazonprovider.ReturnRecord{}, 14, now)) +} diff --git a/internal/application/sync/handlers/amazon_refunds.go b/internal/application/sync/handlers/amazon_refunds.go new file mode 100644 index 0000000..70544eb --- /dev/null +++ b/internal/application/sync/handlers/amazon_refunds.go @@ -0,0 +1,254 @@ +package handlers + +import ( + "context" + "errors" + "fmt" + "math" + "strings" + + amazonprovider "github.com/eshaffer321/itemize/internal/adapters/providers/amazon" + "github.com/eshaffer321/itemize/internal/domain/categorizer" + "github.com/eshaffer321/itemize/internal/domain/matcher" + "github.com/eshaffer321/monarch-go/v2/pkg/monarch" +) + +const temporaryAmazonCategory = "[TEMP] Amazon" + +// ProcessRefund categorizes one directly reported Amazon refund only when its +// item and matching Monarch credit are both unambiguous. +func (h *AmazonHandler) ProcessRefund( + ctx context.Context, + refund *amazonprovider.RefundOrder, + monarchTxns []*monarch.Transaction, + usedTxnIDs map[string]bool, + catCategories []categorizer.Category, + monarchCategories []*monarch.TransactionCategory, + dryRun bool, +) (*ProcessResult, error) { + result := &ProcessResult{} + if refund == nil { + result.Skipped = true + result.SkipReason = "missing Amazon refund record" + return result, nil + } + record := refund.Record() + switch { + case !record.HasRefundTotal || record.RefundAmount <= 0: + result.Skipped = true + result.SkipReason = "Amazon did not report a refund total" + return result, nil + case record.RefundIssuedAt == nil: + result.Skipped = true + result.SkipReason = "Amazon did not report a refund-issued date" + return result, nil + case record.RMAID == "": + result.Skipped = true + result.SkipReason = "Amazon did not report an RMA ID" + return result, nil + case len(record.Items) != 1: + result.Skipped = true + result.SkipReason = "multiple returned items lack an authoritative item-level refund allocation" + 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")) + return result, nil + } + if err != nil { + return nil, fmt.Errorf("amazon refund match error: %w", err) + } + if matchResult == nil { + result.Skipped = true + result.SkipReason = fmt.Sprintf("no unique temporary Amazon credit found for $%.2f", record.RefundAmount) + return result, nil + } + + transaction := matchResult.Transaction + splits, err := h.splitter.CreateSplits(ctx, refund, transaction, catCategories, monarchCategories) + if err != nil { + return nil, fmt.Errorf("amazon refund split creation error: %w", err) + } + result.Splits = splits + if splits == nil { + categoryID, notes, categoryErr := h.splitter.GetSingleCategoryInfo(ctx, refund, catCategories) + if categoryErr != nil { + return nil, fmt.Errorf("amazon refund category error: %w", categoryErr) + } + if categoryID == "" { + result.Skipped = true + result.SkipReason = "categorizer did not return a valid Monarch category" + return result, nil + } + result.CategoryID = categoryID + result.MonarchNotes = notes + if idx := strings.Index(notes, ":"); idx > 0 { + result.CategoryName = notes[:idx] + } + if !dryRun { + reviewed := false + if updateErr := h.monarch.UpdateTransaction(ctx, transaction.ID, &monarch.UpdateTransactionParams{ + CategoryID: &categoryID, + Notes: ¬es, + NeedsReview: &reviewed, + }); updateErr != nil { + return nil, fmt.Errorf("amazon refund transaction update error: %w", updateErr) + } + } + } else if !dryRun { + if updateErr := h.monarch.UpdateSplits(ctx, transaction.ID, splits); updateErr != nil { + return nil, fmt.Errorf("amazon refund split update error: %w", updateErr) + } + } + + usedTxnIDs[transaction.ID] = true + result.Transaction = transaction + result.Processed = true + h.logInfo("Categorized Amazon refund", + "order_id", record.OrderID, + "rma_id", record.RMAID, + "transaction_id", transaction.ID, + "refund_amount", record.RefundAmount, + "item_asin", record.Items[0].ASIN, + "dry_run", dryRun) + return result, nil +} + +// ProcessRefundGroup handles indistinguishable same-day/same-amount credits +// only when the group cardinality matches and every returned item resolves to +// the same category. No transaction-to-ASIN assignment is asserted. +func (h *AmazonHandler) ProcessRefundGroup( + ctx context.Context, + refunds []*amazonprovider.RefundOrder, + monarchTxns []*monarch.Transaction, + usedTxnIDs map[string]bool, + catCategories []categorizer.Category, + monarchCategories []*monarch.TransactionCategory, + dryRun bool, +) ([]*ProcessResult, string, error) { + if len(refunds) < 2 { + return nil, "refund group requires at least two records", nil + } + base := refunds[0].Record() + if base.RefundIssuedAt == nil { + return nil, "Amazon did not report a refund-issued date", nil + } + issuedDate := base.RefundIssuedAt.Format("2006-01-02") + for _, refund := range refunds { + if refund == nil { + return nil, "missing Amazon refund record", nil + } + record := refund.Record() + 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 { + 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 { + continue + } + candidates = append(candidates, tx) + } + if len(candidates) != len(refunds) { + return nil, fmt.Sprintf("refund group has %d Amazon records but %d exact temporary credits", len(refunds), len(candidates)), nil + } + + categoryID := "" + categoryName := "" + for _, refund := range refunds { + splits, err := h.splitter.CreateSplits(ctx, refund, candidates[0], catCategories, monarchCategories) + if err != nil { + return nil, "", fmt.Errorf("amazon refund group categorization failed: %w", err) + } + if splits != nil { + return nil, "returned item did not resolve to one category", nil + } + itemCategoryID, notes, err := h.splitter.GetSingleCategoryInfo(ctx, refund, catCategories) + if err != nil { + return nil, "", fmt.Errorf("amazon refund group category failed: %w", err) + } + if itemCategoryID == "" { + return nil, "categorizer did not return a valid Monarch category", nil + } + itemCategoryName := notes + if idx := strings.Index(notes, ":"); idx > 0 { + itemCategoryName = notes[:idx] + } + if categoryID == "" { + categoryID = itemCategoryID + categoryName = itemCategoryName + continue + } + if itemCategoryID != categoryID { + return nil, "indistinguishable refund items have different categories", nil + } + } + + noteLines := []string{ + fmt.Sprintf("%s:", categoryName), + fmt.Sprintf("- %d indistinguishable Amazon refunds of $%.2f issued %s", len(refunds), base.RefundAmount, issuedDate), + } + for _, refund := range refunds { + record := refund.Record() + noteLines = append(noteLines, fmt.Sprintf("- Returned: %s (ASIN %s)", record.Items[0].Name, record.Items[0].ASIN)) + } + noteLines = append(noteLines, "- Amazon identifies the return group; the bank feed does not identify the individual credit-to-item mapping.") + notes := strings.Join(noteLines, "\n") + + results := make([]*ProcessResult, 0, len(candidates)) + for _, transaction := range candidates { + if !dryRun { + reviewed := false + if err := h.monarch.UpdateTransaction(ctx, transaction.ID, &monarch.UpdateTransactionParams{ + CategoryID: &categoryID, + Notes: ¬es, + NeedsReview: &reviewed, + }); err != nil { + return results, "", fmt.Errorf("amazon refund group transaction update failed: %w", err) + } + } + usedTxnIDs[transaction.ID] = true + results = append(results, &ProcessResult{ + Processed: true, + Transaction: transaction, + CategoryID: categoryID, + CategoryName: categoryName, + MonarchNotes: notes, + }) + } + h.logInfo("Categorized indistinguishable Amazon refund group", + "refund_count", len(refunds), + "refund_amount", base.RefundAmount, + "issued_date", issuedDate, + "category_id", categoryID, + "dry_run", dryRun) + return results, "", nil +} + +func eligibleAmazonRefundTransactions(monarchTxns []*monarch.Transaction) []*monarch.Transaction { + eligible := make([]*monarch.Transaction, 0, len(monarchTxns)) + for _, tx := range monarchTxns { + if tx == nil || tx.Pending || tx.Amount <= 0 || tx.HasSplits { + continue + } + if tx.Category != nil && !strings.EqualFold(normalizedCategoryName(tx.Category.Name), temporaryAmazonCategory) { + continue + } + eligible = append(eligible, tx) + } + return eligible +} + +func normalizedCategoryName(name string) string { + return strings.Join(strings.Fields(name), " ") +} diff --git a/internal/application/sync/handlers/amazon_test.go b/internal/application/sync/handlers/amazon_test.go index 2984264..b250973 100644 --- a/internal/application/sync/handlers/amazon_test.go +++ b/internal/application/sync/handlers/amazon_test.go @@ -2,14 +2,15 @@ package handlers import ( "context" + "errors" "testing" "time" - "github.com/eshaffer321/monarch-go/v2/pkg/monarch" "github.com/eshaffer321/itemize/internal/adapters/providers" amazonprovider "github.com/eshaffer321/itemize/internal/adapters/providers/amazon" "github.com/eshaffer321/itemize/internal/domain/categorizer" "github.com/eshaffer321/itemize/internal/domain/matcher" + "github.com/eshaffer321/monarch-go/v2/pkg/monarch" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -33,16 +34,16 @@ type mockAmazonOrder struct { nonBankErr error } -func (m *mockAmazonOrder) GetID() string { return m.id } -func (m *mockAmazonOrder) GetDate() time.Time { return m.date } -func (m *mockAmazonOrder) GetTotal() float64 { return m.total } -func (m *mockAmazonOrder) GetSubtotal() float64 { return m.subtotal } -func (m *mockAmazonOrder) GetTax() float64 { return m.tax } -func (m *mockAmazonOrder) GetTip() float64 { return 0 } -func (m *mockAmazonOrder) GetFees() float64 { return 0 } -func (m *mockAmazonOrder) GetItems() []providers.OrderItem { return m.items } -func (m *mockAmazonOrder) GetProviderName() string { return "Amazon" } -func (m *mockAmazonOrder) GetRawData() interface{} { return nil } +func (m *mockAmazonOrder) GetID() string { return m.id } +func (m *mockAmazonOrder) GetDate() time.Time { return m.date } +func (m *mockAmazonOrder) GetTotal() float64 { return m.total } +func (m *mockAmazonOrder) GetSubtotal() float64 { return m.subtotal } +func (m *mockAmazonOrder) GetTax() float64 { return m.tax } +func (m *mockAmazonOrder) GetTip() float64 { return 0 } +func (m *mockAmazonOrder) GetFees() float64 { return 0 } +func (m *mockAmazonOrder) GetItems() []providers.OrderItem { return m.items } +func (m *mockAmazonOrder) GetProviderName() string { return "Amazon" } +func (m *mockAmazonOrder) GetRawData() interface{} { return nil } func (m *mockAmazonOrder) GetFinalCharges() ([]float64, error) { return m.bankCharges, m.bankChargesErr } @@ -94,30 +95,43 @@ func (m *mockConsolidator) ConsolidateTransactions(ctx context.Context, transact // mockSplitter implements CategorySplitter type mockSplitter struct { - splits []*monarch.TransactionSplit - categoryID string - notes string - err error + splits []*monarch.TransactionSplit + categoryID string + notes string + err error + lastOrder providers.Order + categoryIDByOrder map[string]string + notesByOrder map[string]string } func (m *mockSplitter) CreateSplits(ctx context.Context, order providers.Order, transaction *monarch.Transaction, catCategories []categorizer.Category, monarchCategories []*monarch.TransactionCategory) ([]*monarch.TransactionSplit, error) { + m.lastOrder = order return m.splits, m.err } func (m *mockSplitter) GetSingleCategoryInfo(ctx context.Context, order providers.Order, categories []categorizer.Category) (string, string, error) { + if categoryID, ok := m.categoryIDByOrder[order.GetID()]; ok { + return categoryID, m.notesByOrder[order.GetID()], m.err + } return m.categoryID, m.notes, m.err } // mockMonarch implements MonarchClient type mockMonarch struct { - updateCalled bool + updateCalled bool updateSplitsCalled bool - updateErr error - updateSplitsErr error + updateErr error + updateSplitsErr error + updatedID string + updatedParams *monarch.UpdateTransactionParams + updatedIDs []string } func (m *mockMonarch) UpdateTransaction(ctx context.Context, id string, params *monarch.UpdateTransactionParams) error { m.updateCalled = true + m.updatedID = id + m.updatedParams = params + m.updatedIDs = append(m.updatedIDs, id) return m.updateErr } @@ -187,13 +201,13 @@ func TestAmazonHandler_ProcessOrder_ValidOrder(t *testing.T) { order, monarchTxns, usedTxnIDs, - nil, // catCategories - nil, // monarchCategories + nil, // catCategories + nil, // monarchCategories false, // dryRun ) require.NoError(t, err) - assert.True(t, result.Processed) + require.True(t, result.Processed) assert.False(t, result.Skipped) assert.NotNil(t, result.Allocations) assert.Len(t, result.Splits, 2) @@ -437,6 +451,292 @@ func TestAmazonHandler_ProcessOrder_FullyGiftCardOrder(t *testing.T) { assert.Contains(t, result.SkipReason, "gift card") } +func TestAmazonHandler_ProcessRefundCategorizesUniqueTemporaryCredit(t *testing.T) { + issuedAt := time.Date(2026, time.July, 3, 0, 0, 0, 0, time.UTC) + refund := amazonprovider.NewRefundOrder(amazonprovider.ReturnRecord{ + OrderID: "112-1111111-2222222", + RMAID: "D8ExampleRRMA", + RefundAmount: 11.36, + HasRefundTotal: true, + RefundIssuedAt: &issuedAt, + Items: []amazonprovider.ReturnedItem{{ASIN: "B0EXAMPLE1", Name: "Insulated Sporty Cup", Price: 10.72}}, + }) + temporary := &monarch.TransactionCategory{ID: "temp-amazon", Name: "[TEMP] Amazon "} + alreadyCategorized := &monarch.TransactionCategory{ID: "kid-needs", Name: "Kid Needs"} + transactions := []*monarch.Transaction{ + {ID: "target", Amount: 11.36, Date: toMonarchDate(issuedAt), Category: temporary}, + {ID: "do-not-overwrite", Amount: 11.36, Date: toMonarchDate(issuedAt), Category: alreadyCategorized}, + } + splitter := &mockSplitter{categoryID: "kid-needs", notes: "Kid Needs:\n- Insulated Sporty Cup $11.36"} + monarchClient := &mockMonarch{} + handler := NewAmazonHandler(matcher.NewMatcher(matcher.Config{AmountTolerance: 0.01, DateTolerance: 5}), nil, splitter, monarchClient, nil) + used := map[string]bool{} + + result, err := handler.ProcessRefund(context.Background(), refund, transactions, used, nil, nil, false) + + require.NoError(t, err) + assert.True(t, result.Processed) + assert.Equal(t, "target", result.Transaction.ID) + assert.True(t, used["target"]) + assert.Equal(t, "target", monarchClient.updatedID) + require.NotNil(t, monarchClient.updatedParams) + require.NotNil(t, monarchClient.updatedParams.CategoryID) + assert.Equal(t, "kid-needs", *monarchClient.updatedParams.CategoryID) + require.NotNil(t, splitter.lastOrder) + assert.Equal(t, "Insulated Sporty Cup", splitter.lastOrder.GetItems()[0].GetName()) + assert.Equal(t, 11.36, splitter.lastOrder.GetItems()[0].GetPrice()) +} + +func TestAmazonHandler_ProcessRefundSkipsIndistinguishableCredits(t *testing.T) { + issuedAt := time.Date(2026, time.July, 3, 0, 0, 0, 0, time.UTC) + refund := amazonprovider.NewRefundOrder(amazonprovider.ReturnRecord{ + OrderID: "112-1111111-2222222", + RMAID: "DfExampleRRMA", + RefundAmount: 14.41, + HasRefundTotal: true, + RefundIssuedAt: &issuedAt, + Items: []amazonprovider.ReturnedItem{{ASIN: "B0EXAMPLE2", Name: "Kids Cup", Price: 13.59}}, + }) + 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{} + 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, "ambiguous") + assert.False(t, monarchClient.updateCalled) +} + +func TestAmazonHandler_ProcessRefundSkipsMultipleItemsWithoutAllocation(t *testing.T) { + issuedAt := time.Date(2026, time.July, 3, 0, 0, 0, 0, time.UTC) + refund := amazonprovider.NewRefundOrder(amazonprovider.ReturnRecord{ + OrderID: "112-1111111-2222222", + RMAID: "DmultiRRMA", + RefundAmount: 30.00, + HasRefundTotal: true, + RefundIssuedAt: &issuedAt, + Items: []amazonprovider.ReturnedItem{ + {ASIN: "B0EXAMPLE1", Name: "Item one", Price: 10}, + {ASIN: "B0EXAMPLE2", Name: "Item two", Price: 15}, + }, + }) + handler := NewAmazonHandler(matcher.NewMatcher(matcher.Config{AmountTolerance: 0.01, DateTolerance: 5}), nil, &mockSplitter{}, &mockMonarch{}, nil) + + result, err := handler.ProcessRefund(context.Background(), refund, nil, map[string]bool{}, nil, nil, false) + + require.NoError(t, err) + assert.True(t, result.Skipped) + assert.Contains(t, result.SkipReason, "multiple returned items") +} + +func TestAmazonHandler_ProcessRefundValidatesAuthoritativeFields(t *testing.T) { + issuedAt := time.Date(2026, time.July, 3, 0, 0, 0, 0, time.UTC) + tests := []struct { + name string + refund *amazonprovider.RefundOrder + reason string + }{ + {name: "missing record", reason: "missing Amazon refund record"}, + {name: "missing total", refund: amazonprovider.NewRefundOrder(amazonprovider.ReturnRecord{RefundIssuedAt: &issuedAt, RMAID: "DtotalRRMA", Items: []amazonprovider.ReturnedItem{{Name: "item"}}}), reason: "refund total"}, + {name: "missing issued date", refund: amazonprovider.NewRefundOrder(amazonprovider.ReturnRecord{HasRefundTotal: true, RefundAmount: 9.99, RMAID: "DdateRRMA", Items: []amazonprovider.ReturnedItem{{Name: "item"}}}), reason: "refund-issued date"}, + {name: "missing RMA", refund: amazonprovider.NewRefundOrder(amazonprovider.ReturnRecord{HasRefundTotal: true, RefundAmount: 9.99, RefundIssuedAt: &issuedAt, Items: []amazonprovider.ReturnedItem{{Name: "item"}}}), reason: "RMA ID"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + handler := NewAmazonHandler(nil, nil, nil, nil, nil) + result, err := handler.ProcessRefund(context.Background(), test.refund, nil, map[string]bool{}, nil, nil, false) + require.NoError(t, err) + assert.True(t, result.Skipped) + assert.Contains(t, result.SkipReason, test.reason) + }) + } +} + +func TestAmazonHandler_ProcessRefundHandlesNoMatchAndCategorizationFailures(t *testing.T) { + issuedAt := time.Date(2026, time.July, 3, 0, 0, 0, 0, time.UTC) + refund := amazonprovider.NewRefundOrder(amazonprovider.ReturnRecord{ + RMAID: "DsafeRRMA", RefundAmount: 9.99, HasRefundTotal: true, RefundIssuedAt: &issuedAt, + Items: []amazonprovider.ReturnedItem{{ASIN: "B0SAFE", Name: "Safe item", Price: 9.99}}, + }) + transactionMatcher := matcher.NewMatcher(matcher.Config{AmountTolerance: 0.01, DateTolerance: 5}) + + t.Run("no matching temporary credit", func(t *testing.T) { + handler := NewAmazonHandler(transactionMatcher, nil, &mockSplitter{}, &mockMonarch{}, nil) + result, err := handler.ProcessRefund(context.Background(), refund, nil, map[string]bool{}, nil, nil, false) + require.NoError(t, err) + assert.True(t, result.Skipped) + assert.Contains(t, result.SkipReason, "no unique temporary Amazon credit") + }) + + temporary := &monarch.TransactionCategory{ID: "temp-amazon", Name: "[TEMP] Amazon"} + transactions := []*monarch.Transaction{{ID: "credit", Amount: 9.99, Date: toMonarchDate(issuedAt), Category: temporary}} + t.Run("splitter error", func(t *testing.T) { + handler := NewAmazonHandler(transactionMatcher, nil, &mockSplitter{err: errors.New("categorizer unavailable")}, &mockMonarch{}, nil) + result, err := handler.ProcessRefund(context.Background(), refund, transactions, map[string]bool{}, nil, nil, false) + assert.Nil(t, result) + require.Error(t, err) + assert.Contains(t, err.Error(), "split creation") + }) + + t.Run("invalid category", func(t *testing.T) { + handler := NewAmazonHandler(transactionMatcher, nil, &mockSplitter{}, &mockMonarch{}, 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, "valid Monarch category") + }) +} + +func TestAmazonHandler_ProcessRefundUpdatesSplitsAndReportsWriteFailure(t *testing.T) { + issuedAt := time.Date(2026, time.July, 3, 0, 0, 0, 0, time.UTC) + refund := amazonprovider.NewRefundOrder(amazonprovider.ReturnRecord{ + RMAID: "DsplitRRMA", RefundAmount: 20, HasRefundTotal: true, RefundIssuedAt: &issuedAt, + Items: []amazonprovider.ReturnedItem{{ASIN: "B0SPLIT", Name: "Split item", Price: 20}}, + }) + temporary := &monarch.TransactionCategory{ID: "temp-amazon", Name: "[TEMP] Amazon"} + transactions := []*monarch.Transaction{{ID: "credit", Amount: 20, Date: toMonarchDate(issuedAt), Category: temporary}} + monarchClient := &mockMonarch{updateSplitsErr: errors.New("write failed")} + handler := NewAmazonHandler( + matcher.NewMatcher(matcher.Config{AmountTolerance: 0.01, DateTolerance: 5}), nil, + &mockSplitter{splits: []*monarch.TransactionSplit{{Amount: 20}}}, monarchClient, nil, + ) + + result, err := handler.ProcessRefund(context.Background(), refund, transactions, map[string]bool{}, nil, nil, false) + assert.Nil(t, result) + require.Error(t, err) + assert.Contains(t, err.Error(), "split update") + assert.True(t, monarchClient.updateSplitsCalled) +} + +func TestAmazonHandler_ProcessRefundGroupCategorizesWhenCategoryIsInvariant(t *testing.T) { + issuedAt := time.Date(2026, time.July, 3, 0, 0, 0, 0, time.UTC) + first := amazonprovider.NewRefundOrder(amazonprovider.ReturnRecord{ + OrderID: "112-1111111-2222222", RMAID: "DfirstRRMA", RefundAmount: 14.41, HasRefundTotal: true, RefundIssuedAt: &issuedAt, + Items: []amazonprovider.ReturnedItem{{ASIN: "B0CUPONE", Name: "Sesame Street Kids Cup", Price: 13.59}}, + }) + second := amazonprovider.NewRefundOrder(amazonprovider.ReturnRecord{ + OrderID: "112-1111111-2222222", RMAID: "DsecondRRMA", RefundAmount: 14.41, HasRefundTotal: true, RefundIssuedAt: &issuedAt, + Items: []amazonprovider.ReturnedItem{{ASIN: "B0CUPTWO", Name: "Disney Kids Cup", Price: 13.59}}, + }) + 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}, + } + splitter := &mockSplitter{ + categoryIDByOrder: map[string]string{first.GetID(): "kid-needs", second.GetID(): "kid-needs"}, + notesByOrder: map[string]string{ + first.GetID(): "Kid Needs:\n- Sesame Street Kids Cup $14.41", + second.GetID(): "Kid Needs:\n- Disney Kids Cup $14.41", + }, + } + monarchClient := &mockMonarch{} + handler := NewAmazonHandler(matcher.NewMatcher(matcher.Config{AmountTolerance: 0.01, DateTolerance: 5}), nil, splitter, monarchClient, nil) + used := map[string]bool{} + + results, skipReason, err := handler.ProcessRefundGroup(context.Background(), []*amazonprovider.RefundOrder{first, second}, transactions, used, nil, nil, false) + + require.NoError(t, err) + assert.Empty(t, skipReason) + require.Len(t, results, 2) + assert.ElementsMatch(t, []string{"refund-1", "refund-2"}, monarchClient.updatedIDs) + assert.True(t, used["refund-1"]) + assert.True(t, used["refund-2"]) + for _, result := range results { + assert.Equal(t, "kid-needs", result.CategoryID) + assert.Contains(t, result.MonarchNotes, "2 indistinguishable Amazon refunds") + assert.Contains(t, result.MonarchNotes, "Sesame Street Kids Cup") + assert.Contains(t, result.MonarchNotes, "Disney Kids Cup") + } +} + +func TestAmazonHandler_ProcessRefundGroupSkipsDifferentCategories(t *testing.T) { + issuedAt := time.Date(2026, time.July, 3, 0, 0, 0, 0, time.UTC) + first := amazonprovider.NewRefundOrder(amazonprovider.ReturnRecord{ + OrderID: "112-1111111-2222222", RMAID: "DfirstRRMA", RefundAmount: 14.41, HasRefundTotal: true, RefundIssuedAt: &issuedAt, + Items: []amazonprovider.ReturnedItem{{ASIN: "B0ITEMONE", Name: "Kids Cup", Price: 13.59}}, + }) + second := amazonprovider.NewRefundOrder(amazonprovider.ReturnRecord{ + OrderID: "112-1111111-2222222", RMAID: "DsecondRRMA", RefundAmount: 14.41, HasRefundTotal: true, RefundIssuedAt: &issuedAt, + Items: []amazonprovider.ReturnedItem{{ASIN: "B0ITEMTWO", Name: "USB Cable", Price: 13.59}}, + }) + 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}, + } + splitter := &mockSplitter{ + categoryIDByOrder: map[string]string{first.GetID(): "kid-needs", second.GetID(): "electronics"}, + notesByOrder: map[string]string{first.GetID(): "Kid Needs:\n- Kids Cup", second.GetID(): "Electronics:\n- USB Cable"}, + } + monarchClient := &mockMonarch{} + handler := NewAmazonHandler(matcher.NewMatcher(matcher.Config{AmountTolerance: 0.01, DateTolerance: 5}), nil, splitter, monarchClient, nil) + + results, skipReason, err := handler.ProcessRefundGroup(context.Background(), []*amazonprovider.RefundOrder{first, second}, transactions, map[string]bool{}, nil, nil, false) + + require.NoError(t, err) + assert.Nil(t, results) + assert.Contains(t, skipReason, "different categories") + assert.False(t, monarchClient.updateCalled) +} + +func TestAmazonHandler_ProcessRefundGroupValidatesAuthoritativeGrouping(t *testing.T) { + issuedAt := time.Date(2026, time.July, 3, 0, 0, 0, 0, time.UTC) + nextDay := issuedAt.AddDate(0, 0, 1) + valid := func(rma string, amount float64, date *time.Time) *amazonprovider.RefundOrder { + return amazonprovider.NewRefundOrder(amazonprovider.ReturnRecord{ + RMAID: rma, RefundAmount: amount, HasRefundTotal: true, RefundIssuedAt: date, + Items: []amazonprovider.ReturnedItem{{ASIN: "B0VALID", Name: "Valid item", Price: amount}}, + }) + } + tests := []struct { + name string + refunds []*amazonprovider.RefundOrder + reason string + }{ + {name: "too few records", refunds: []*amazonprovider.RefundOrder{valid("DoneRRMA", 14.41, &issuedAt)}, reason: "at least two"}, + {name: "missing issued date", refunds: []*amazonprovider.RefundOrder{valid("DoneRRMA", 14.41, nil), valid("DtwoRRMA", 14.41, &issuedAt)}, reason: "refund-issued date"}, + {name: "missing record", refunds: []*amazonprovider.RefundOrder{valid("DoneRRMA", 14.41, &issuedAt), nil}, reason: "missing Amazon refund record"}, + {name: "incomplete detail", refunds: []*amazonprovider.RefundOrder{valid("DoneRRMA", 14.41, &issuedAt), amazonprovider.NewRefundOrder(amazonprovider.ReturnRecord{RefundAmount: 14.41, RefundIssuedAt: &issuedAt})}, reason: "authoritative single-item detail"}, + {name: "different amount", refunds: []*amazonprovider.RefundOrder{valid("DoneRRMA", 14.41, &issuedAt), valid("DtwoRRMA", 14.42, &issuedAt)}, reason: "one amount and issued date"}, + {name: "different date", refunds: []*amazonprovider.RefundOrder{valid("DoneRRMA", 14.41, &issuedAt), valid("DtwoRRMA", 14.41, &nextDay)}, reason: "one amount and issued date"}, + } + + handler := NewAmazonHandler(nil, nil, nil, nil, nil) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + results, reason, err := handler.ProcessRefundGroup(context.Background(), test.refunds, nil, map[string]bool{}, nil, nil, true) + require.NoError(t, err) + assert.Nil(t, results) + assert.Contains(t, reason, test.reason) + }) + } +} + +func TestEligibleAmazonRefundTransactionsRejectsUnsafeCredits(t *testing.T) { + temporary := &monarch.TransactionCategory{ID: "temp", Name: " [TEMP] Amazon "} + other := &monarch.TransactionCategory{ID: "other", Name: "Income"} + eligible := &monarch.Transaction{ID: "eligible", Amount: 10, Category: temporary} + + got := eligibleAmazonRefundTransactions([]*monarch.Transaction{ + nil, + {ID: "pending", Amount: 10, Pending: true, Category: temporary}, + {ID: "debit", Amount: -10, Category: temporary}, + {ID: "split", Amount: 10, HasSplits: true, Category: temporary}, + {ID: "categorized", Amount: 10, Category: other}, + eligible, + }) + + assert.Equal(t, []*monarch.Transaction{eligible}, got) +} + func TestAllocatedItem(t *testing.T) { item := &allocatedItem{ name: "Test Item", diff --git a/internal/application/sync/orchestrator.go b/internal/application/sync/orchestrator.go index c73d71c..284a1d4 100644 --- a/internal/application/sync/orchestrator.go +++ b/internal/application/sync/orchestrator.go @@ -3,6 +3,7 @@ package sync import ( "context" "fmt" + "time" "github.com/eshaffer321/itemize/internal/adapters/providers" "github.com/eshaffer321/itemize/internal/application/sync/handlers" @@ -151,6 +152,13 @@ func (o *Orchestrator) Run(ctx context.Context, opts Options) (*Result, error) { return nil, err } + amazonReturns, returnsErr := o.fetchAmazonReturns(ctx) + if returnsErr != nil { + result.ErrorCount++ + result.Errors = append(result.Errors, returnsErr) + o.logger.Error("Amazon return ledger unavailable; continuing with order sync", "error", returnsErr) + } + // 5. Process orders usedTransactionIDs := make(map[string]bool) @@ -184,6 +192,10 @@ func (o *Orchestrator) Run(ctx context.Context, opts Options) (*Result, error) { } } + if returnsErr == nil && len(amazonReturns) > 0 { + o.processAmazonReturns(ctx, amazonReturns, providerTransactions, usedTransactionIDs, catCategories, monarchCategories, opts, time.Now(), result) + } + // 6. Complete sync run if o.storage != nil && o.runID > 0 { if err := o.storage.CompleteSyncRun(o.runID, len(orders), result.ProcessedCount, result.SkippedCount, result.ErrorCount); err != nil { diff --git a/internal/application/sync/orchestrator_test.go b/internal/application/sync/orchestrator_test.go index 944254c..769f480 100644 --- a/internal/application/sync/orchestrator_test.go +++ b/internal/application/sync/orchestrator_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/eshaffer321/itemize/internal/adapters/providers" + amazonprovider "github.com/eshaffer321/itemize/internal/adapters/providers/amazon" "github.com/eshaffer321/itemize/internal/infrastructure/storage" "github.com/eshaffer321/monarch-go/v2/pkg/monarch" "github.com/stretchr/testify/assert" @@ -19,6 +20,12 @@ import ( // MockProvider implements providers.OrderProvider for testing type MockProvider struct { mock.Mock + returnRecords []amazonprovider.ReturnRecord + returnErr error +} + +func (m *MockProvider) FetchReturns(context.Context) ([]amazonprovider.ReturnRecord, error) { + return m.returnRecords, m.returnErr } func (m *MockProvider) Name() string { diff --git a/internal/application/sync/types.go b/internal/application/sync/types.go index 23be552..1e41cb9 100644 --- a/internal/application/sync/types.go +++ b/internal/application/sync/types.go @@ -41,10 +41,12 @@ type Options struct { // Result holds sync results type Result struct { - ProcessedCount int - SkippedCount int - ErrorCount int - Errors []error + ProcessedCount int + SkippedCount int + RefundProcessedCount int + RefundSkippedCount int + ErrorCount int + Errors []error } // Orchestrator runs the sync process diff --git a/internal/cli/amazon_returns.go b/internal/cli/amazon_returns.go new file mode 100644 index 0000000..4c10d54 --- /dev/null +++ b/internal/cli/amazon_returns.go @@ -0,0 +1,19 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + + amazonprovider "github.com/eshaffer321/itemize/internal/adapters/providers/amazon" +) + +// PrintAmazonReturns writes the direct Amazon return ledger as stable JSON. +func PrintAmazonReturns(w io.Writer, returns []amazonprovider.ReturnRecord) error { + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + if err := encoder.Encode(returns); err != nil { + return fmt.Errorf("failed to write Amazon return ledger: %w", err) + } + return nil +} diff --git a/internal/cli/amazon_returns_test.go b/internal/cli/amazon_returns_test.go new file mode 100644 index 0000000..530f9f6 --- /dev/null +++ b/internal/cli/amazon_returns_test.go @@ -0,0 +1,52 @@ +package cli + +import ( + "bytes" + "encoding/json" + "errors" + "testing" + "time" + + amazonprovider "github.com/eshaffer321/itemize/internal/adapters/providers/amazon" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type failingReturnWriter struct{} + +func (failingReturnWriter) Write([]byte) (int, error) { + return 0, errors.New("disk full") +} + +func TestPrintAmazonReturnsWritesStableJSON(t *testing.T) { + records := []amazonprovider.ReturnRecord{{ + OrderID: "112-1111111-2222222", + RMAID: "D8ExampleRRMA", + CreatedAt: time.Date(2026, time.June, 29, 0, 0, 0, 0, time.UTC), + RefundAmount: 11.36, + HasRefundTotal: true, + Status: "Return in transit", + Items: []amazonprovider.ReturnedItem{{ + ASIN: "B0EXAMPLE1", + Name: "Insulated Sporty Cup", + Price: 10.72, + }}, + }} + + var output bytes.Buffer + require.NoError(t, PrintAmazonReturns(&output, records)) + + var decoded []map[string]any + require.NoError(t, json.Unmarshal(output.Bytes(), &decoded)) + require.Len(t, decoded, 1) + assert.Equal(t, "112-1111111-2222222", decoded[0]["order_id"]) + assert.Equal(t, 11.36, decoded[0]["refund_amount"]) + assert.NotContains(t, decoded[0], "status_url") +} + +func TestPrintAmazonReturnsReportsWriterFailure(t *testing.T) { + err := PrintAmazonReturns(failingReturnWriter{}, []amazonprovider.ReturnRecord{{RMAID: "DwriteRRMA"}}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to write Amazon return ledger") +} diff --git a/internal/cli/flags.go b/internal/cli/flags.go index f152082..5ebde7b 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -77,6 +77,7 @@ func ParseSyncFlags(providerName string) SyncFlags { func PrintAmazonUsage(w io.Writer) { _, _ = fmt.Fprint(w, `Usage: itemize amazon setup -account + itemize amazon returns -account itemize amazon -account [sync options] First-time setup: @@ -86,6 +87,9 @@ First-time setup: Account management: -list-accounts List saved Amazon accounts +Direct Amazon data: + returns Print Amazon return/refund records as JSON; does not write to Monarch + Sync options: -dry-run Preview without making Monarch changes -days int Number of days to look back (default 14) @@ -104,6 +108,7 @@ Advanced authentication: Examples: itemize amazon setup -account wife + itemize amazon returns -account wife itemize amazon -account wife -dry-run -days 14 -max 1 `) } diff --git a/internal/cli/output.go b/internal/cli/output.go index 843644d..45bd454 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -40,6 +40,11 @@ func PrintSyncSummary(result *sync.Result, store storage.Repository, dryRun bool result.ProcessedCount, result.SkippedCount, result.ErrorCount) + if result.RefundProcessedCount > 0 || result.RefundSkippedCount > 0 { + fmt.Printf("Amazon refunds: Categorized=%d Left untouched=%d\n", + result.RefundProcessedCount, + result.RefundSkippedCount) + } // Print errors if any if len(result.Errors) > 0 { @@ -65,7 +70,7 @@ func PrintSyncSummary(result *sync.Result, store storage.Repository, dryRun bool } } - if !dryRun && result.ProcessedCount > 0 { + if !dryRun && (result.ProcessedCount > 0 || result.RefundProcessedCount > 0) { fmt.Println("\nSync completed successfully.") } } diff --git a/internal/cli/output_test.go b/internal/cli/output_test.go new file mode 100644 index 0000000..0f5ea5c --- /dev/null +++ b/internal/cli/output_test.go @@ -0,0 +1,29 @@ +package cli + +import ( + "io" + "os" + "testing" + + "github.com/eshaffer321/itemize/internal/application/sync" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPrintSyncSummaryReportsAmazonRefundOutcome(t *testing.T) { + previous := os.Stdout + reader, writer, err := os.Pipe() + require.NoError(t, err) + os.Stdout = writer + t.Cleanup(func() { os.Stdout = previous }) + + PrintSyncSummary(&sync.Result{RefundProcessedCount: 3, RefundSkippedCount: 1}, nil, false) + require.NoError(t, writer.Close()) + os.Stdout = previous + output, err := io.ReadAll(reader) + require.NoError(t, err) + require.NoError(t, reader.Close()) + + assert.Contains(t, string(output), "Amazon refunds: Categorized=3 Left untouched=1") + assert.Contains(t, string(output), "Sync completed successfully.") +} diff --git a/internal/cli/providers.go b/internal/cli/providers.go index dc560f9..695e853 100644 --- a/internal/cli/providers.go +++ b/internal/cli/providers.go @@ -78,7 +78,7 @@ func NewWalmartProvider(cfg *config.Config, verbose bool) (providers.OrderProvid // NewAmazonProvider creates a new Amazon provider with a system-scoped logger. // account, if non-empty, overrides cfg.Providers.Amazon.AccountName (and thus // AMAZON_ACCOUNT_NAME) — it's the value of the -account flag. -func NewAmazonProvider(cfg *config.Config, verbose bool, account string) (providers.OrderProvider, error) { +func NewAmazonProvider(cfg *config.Config, verbose bool, account string) (*amazonprovider.Provider, error) { // Create an amazon-scoped logger with verbose flag loggingCfg := cfg.Observability.Logging if verbose { diff --git a/internal/domain/matcher/matcher.go b/internal/domain/matcher/matcher.go index 5dc2904..cb54bc6 100644 --- a/internal/domain/matcher/matcher.go +++ b/internal/domain/matcher/matcher.go @@ -18,12 +18,17 @@ package matcher import ( + "errors" "math" - "github.com/eshaffer321/monarch-go/v2/pkg/monarch" "github.com/eshaffer321/itemize/internal/adapters/providers" + "github.com/eshaffer321/monarch-go/v2/pkg/monarch" ) +// ErrAmbiguousMatch indicates that two or more transactions are equally good +// matches and choosing one would require guessing. +var ErrAmbiguousMatch = errors.New("ambiguous transaction match") + // Matcher matches orders with Monarch transactions type Matcher struct { config Config @@ -115,3 +120,70 @@ func (m *Matcher) FindMatch( return result, nil } + +// FindUniqueMatch returns a match only when one candidate is strictly better +// than every other eligible transaction by amount difference, then date. +func (m *Matcher) FindUniqueMatch( + order providers.Order, + transactions []*monarch.Transaction, + usedTransactionIDs map[string]bool, +) (*MatchResult, error) { + orderAmount := order.GetTotal() + orderDate := order.GetDate() + isReturn := orderAmount < 0 + orderAmount = math.Abs(orderAmount) + + var best *monarch.Transaction + bestAmountDiff := math.Inf(1) + bestDateDiff := math.Inf(1) + bestCount := 0 + const epsilon = 0.0000001 + + for _, tx := range transactions { + if tx == nil || usedTransactionIDs[tx.ID] { + continue + } + if isReturn && tx.Amount < 0 { + continue + } + if !isReturn && tx.Amount > 0 { + continue + } + + amountDiff := math.Abs(orderAmount - math.Abs(tx.Amount)) + if amountDiff > m.config.AmountTolerance+epsilon { + continue + } + dateDiff := math.Abs(tx.Date.Time.Sub(orderDate).Hours() / 24) + if dateDiff > float64(m.config.DateTolerance) { + continue + } + + betterAmount := amountDiff < bestAmountDiff-epsilon + equalAmount := math.Abs(amountDiff-bestAmountDiff) <= epsilon + betterDate := dateDiff < bestDateDiff-epsilon + equalDate := math.Abs(dateDiff-bestDateDiff) <= epsilon + switch { + case betterAmount || (equalAmount && betterDate): + best = tx + bestAmountDiff = amountDiff + bestDateDiff = dateDiff + bestCount = 1 + case equalAmount && equalDate: + bestCount++ + } + } + + if best == nil { + return nil, nil + } + if bestCount > 1 { + return nil, ErrAmbiguousMatch + } + return &MatchResult{ + Transaction: best, + DateDiff: bestDateDiff, + AmountDiff: bestAmountDiff, + Confidence: 1.0, + }, nil +} diff --git a/internal/domain/matcher/matcher_test.go b/internal/domain/matcher/matcher_test.go index b934ac1..8d4df86 100644 --- a/internal/domain/matcher/matcher_test.go +++ b/internal/domain/matcher/matcher_test.go @@ -4,8 +4,8 @@ import ( "testing" "time" - "github.com/eshaffer321/monarch-go/v2/pkg/monarch" "github.com/eshaffer321/itemize/internal/adapters/providers" + "github.com/eshaffer321/monarch-go/v2/pkg/monarch" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -258,6 +258,41 @@ func TestMatcher_EmptyTransactions(t *testing.T) { assert.Nil(t, result) } +func TestMatcher_FindUniqueMatchRejectsEqualCandidates(t *testing.T) { + order := &mockOrder{ + total: -14.41, + date: time.Date(2026, time.July, 3, 0, 0, 0, 0, time.UTC), + } + transactions := []*monarch.Transaction{ + makeTransaction("refund-1", 14.41, order.date), + makeTransaction("refund-2", 14.41, order.date), + } + m := NewMatcher(Config{AmountTolerance: 0.01, DateTolerance: 5}) + + result, err := m.FindUniqueMatch(order, transactions, map[string]bool{}) + + assert.Nil(t, result) + assert.ErrorIs(t, err, ErrAmbiguousMatch) +} + +func TestMatcher_FindUniqueMatchSelectsSoleClosestCandidate(t *testing.T) { + order := &mockOrder{ + total: -11.36, + date: time.Date(2026, time.July, 3, 0, 0, 0, 0, time.UTC), + } + transactions := []*monarch.Transaction{ + makeTransaction("closest", 11.36, order.date), + makeTransaction("later", 11.36, order.date.AddDate(0, 0, 2)), + } + m := NewMatcher(Config{AmountTolerance: 0.01, DateTolerance: 5}) + + result, err := m.FindUniqueMatch(order, transactions, map[string]bool{}) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "closest", result.Transaction.ID) +} + func TestMatcher_CustomConfig(t *testing.T) { // Arrange - Custom config with tighter tolerance config := Config{