diff --git a/README.md b/README.md index 2a17520..140ad31 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Itemize -CLI tool that syncs purchases from Walmart, Costco, and Amazon with Monarch. Automatically splits transactions by category using AI. +CLI tool that syncs purchases from Walmart, Costco, Amazon, and Home Depot with Monarch. Automatically splits transactions by category using AI. ## What it does @@ -109,11 +109,13 @@ Override the model with `OPENAI_MODEL` or `ANTHROPIC_MODEL` per run. ./itemize walmart -dry-run -days 14 ./itemize costco -dry-run -days 7 ./itemize amazon -dry-run -days 7 +./itemize homedepot -dry-run -days 14 # Apply changes ./itemize walmart -days 14 ./itemize costco -days 7 ./itemize amazon -days 7 +./itemize homedepot -days 14 ``` ### Flags @@ -131,6 +133,9 @@ Override the model with `OPENAI_MODEL` or `ANTHROPIC_MODEL` per run. ### Walmart Requires cookies in `~/.walmart-api/cookies.json`. See [walmart-client-go](https://github.com/eshaffer321/walmart-client-go). +### Home Depot +Requires cookies in `~/.homedepot-api/cookies.json`. See [homedepot-go](https://github.com/fnziman/homedepot-go) for the exact cookie-export flow (paste a small DevTools snippet into the browser console while logged in to homedepot.com). Full walkthrough plus troubleshooting: [`docs/homedepot-specifics.md`](docs/homedepot-specifics.md). + ### Costco Uses credentials saved by [costco-go](https://github.com/eshaffer321/costco-go). diff --git a/cmd/itemize/main.go b/cmd/itemize/main.go index 5d67692..ba77f79 100644 --- a/cmd/itemize/main.go +++ b/cmd/itemize/main.go @@ -190,6 +190,8 @@ func main() { provider, err = cli.NewWalmartProvider(cfg, flags.Verbose) case "amazon": provider, err = cli.NewAmazonProvider(cfg, flags.Verbose, amazonAccount) + case "homedepot": + provider, err = cli.NewHomeDepotProvider(cfg, flags.Verbose) default: fmt.Printf("Unknown provider: %s\n", providerName) printUsage() @@ -249,6 +251,7 @@ func printUsage() { fmt.Println(" amazon returns -account ") fmt.Println(" Read Amazon's return/refund ledger as JSON") fmt.Println(" costco Sync Costco orders") + fmt.Println(" homedepot Sync Home Depot orders") fmt.Println(" walmart Sync Walmart orders") fmt.Println(" version Print version, commit, and build date (also: -version, --version)") fmt.Println() @@ -287,4 +290,7 @@ func printUsage() { fmt.Println(" AMAZON_ACCOUNT_NAME Amazon cookie account name (optional)") fmt.Println(" Run 'itemize amazon -import-browser-profile -account ' first") fmt.Println(" AMAZON_COOKIE_FILE Explicit amazon-go cookie file (optional)") + fmt.Println(" HOMEDEPOT_LOOKBACK_DAYS Days to look back for Home Depot orders (default 14)") + fmt.Println(" HOMEDEPOT_MAX_ORDERS Max Home Depot orders to process (0 = all)") + fmt.Println(" HOMEDEPOT_COOKIE_FILE Optional cookie file override (default ~/.homedepot-api/cookies.json)") } diff --git a/config.yaml b/config.yaml index e8fc38b..4028c34 100644 --- a/config.yaml +++ b/config.yaml @@ -29,6 +29,15 @@ providers: account_name: "${AMAZON_ACCOUNT_NAME}" cookie_file: "${AMAZON_COOKIE_FILE}" + homedepot: + enabled: true + rate_limit: 1s + lookback_days: 14 + max_orders: 0 + debug: false + # Optional override — empty means ~/.homedepot-api/cookies.json. + cookie_file: "${HOMEDEPOT_COOKIE_FILE}" + # Monarch API configuration monarch: api_key: "${MONARCH_TOKEN}" diff --git a/docs/homedepot-specifics.md b/docs/homedepot-specifics.md new file mode 100644 index 0000000..1fd8ebc --- /dev/null +++ b/docs/homedepot-specifics.md @@ -0,0 +1,87 @@ +# Home Depot provider + +Syncs purchases from homedepot.com into Monarch, splitting each transaction by category. Wraps [`github.com/fnziman/homedepot-go`](https://github.com/fnziman/homedepot-go), which talks to the internal `/oms/customer/order/v1` endpoints homedepot.com itself uses. + +**Unofficial API.** Home Depot doesn't publish this. Endpoint drift is possible; failures usually mean either the cookies expired or the schema changed. + +## One-time setup: export cookies + +You need a JSON file at `~/.homedepot-api/cookies.json` containing your logged-in browser cookies. Override with `HOMEDEPOT_COOKIE_FILE=/path/to/cookies.json` if you'd rather put it somewhere else. + +1. Log in at [homedepot.com](https://www.homedepot.com). +2. Open DevTools (`Cmd+Option+I` on Mac, `F12` on Windows/Linux), pick the **Console** tab. +3. Paste and run: + ```js + copy(JSON.stringify(document.cookie.split('; ').map(c => { + const i = c.indexOf('='); + return { name: c.slice(0, i), value: c.slice(i + 1) }; + }), null, 2)); + console.log('Cookies copied to clipboard.'); + ``` +4. Save clipboard to the file: + ```bash + mkdir -p ~/.homedepot-api + pbpaste > ~/.homedepot-api/cookies.json # macOS + ``` + +The file must include a `THD_CUSTOMER` entry — that's the cookie the client decodes for the API auth token. + +If the DevTools snippet doesn't grab enough (some anti-bot cookies are HTTP-only and won't appear in `document.cookie`), fall back to a browser extension like [EditThisCookie](https://www.editthiscookie.com/) to export cookies for `.homedepot.com`. See homedepot-go's README for the accepted JSON shapes. + +## Usage + +```bash +itemize homedepot -dry-run -days 14 -verbose # preview +itemize homedepot -days 14 # apply +itemize homedepot -days 14 -max 5 # cap at 5 orders +itemize homedepot -days 14 -force # reprocess already-processed orders +``` + +Config via `config.yaml`: + +```yaml +providers: + homedepot: + enabled: true + lookback_days: 14 + max_orders: 0 # 0 = no cap + cookie_file: "" # empty = ~/.homedepot-api/cookies.json +``` + +Or env vars: `HOMEDEPOT_LOOKBACK_DAYS`, `HOMEDEPOT_MAX_ORDERS`, `HOMEDEPOT_COOKIE_FILE`. + +## Both online and in-store orders are supported + +Home Depot's API returns two response shapes: + +- **Online** — `orderOrigin: "online"`, keyed by `orderNumber` (e.g. `WD00000000`). +- **In-store** — `orderOrigin: "instore"`, no `orderNumber`; the client uses a `hd-instore-{storeNumber}-{transactionId}` composite for dedup. + +Both flow through the same categorizer + splitter downstream. + +## Merchant matching + +itemize matches orders to Monarch transactions by substring on the merchant name. The Home Depot provider's `DisplayName()` is `"Home Depot"`, which case-insensitively substring-matches Monarch's canonical `"THE HOME DEPOT"`. No aliases required. + +If your bank labels Home Depot differently in Monarch (e.g. `"HD SUPPLY"`), file an issue and we'll add an alias mechanism. + +## Known limitations + +- **24-month history cap.** Home Depot's API only returns roughly the last 24 months regardless of `-days`. Older orders are unreachable. +- **No programmatic login.** homedepot.com is Akamai-protected and blocks headless browsers. Cookie replay works; automated login does not. +- **MFA step-up invalidates the cookie.** Any MFA event on your account (new-device flag, quarterly re-verify, etc.) invalidates the exported cookies. Symptom: `AuthError: home depot auth failed (status 401)`. Fix: re-export cookies from a fresh logged-in session. +- **`orginalOrderedQuantity` typo.** The upstream JSON key really is spelled that way (missing the "i"). Exposed via `LineItem.OriginalOrderedQty`. + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `THD_CUSTOMER cookie not found in jar` | Cookies exported while logged out. | Log in, re-export. | +| `home depot auth failed (status 401 or 403)` | Cookies expired or MFA event invalidated the session. | Re-export from a fresh logged-in session. | +| `home depot API rate-limited the request` | Too many requests too fast. | Client already paces itself; back off and retry after a minute. | +| `home depot API returned status 5xx` | Home Depot backend issue or schema change. | Retry; if persistent, file an issue with a scrubbed reproduction. | +| Orders older than 24 months not returned | API's own cap. | Expected — cannot be worked around. | + +## Attribution + +Schema mapping and endpoint discovery in the underlying homedepot-go client were reverse-engineered by [joshellissh/homedepot-history](https://github.com/joshellissh/homedepot-history) (MIT). diff --git a/go.mod b/go.mod index daa60e6..a5e9b30 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/eshaffer321/costco-go v0.3.11 github.com/eshaffer321/monarch-go/v2 v2.0.0 github.com/eshaffer321/walmart-client-go/v2 v2.2.1 + github.com/fnziman/homedepot-go v0.1.1 github.com/getsentry/sentry-go v0.36.0 github.com/go-chi/chi/v5 v5.3.0 github.com/pressly/goose/v3 v3.27.2 diff --git a/go.sum b/go.sum index 88c4f56..d12496e 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/eshaffer321/walmart-client-go/v2 v2.2.1 h1:FT6VAzEC6SNdQm7r2Mr26Q8/cR github.com/eshaffer321/walmart-client-go/v2 v2.2.1/go.mod h1:4PVK9TsqFscTZypC67dgCt/vnPxXdtaheJVM7HOnod0= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/fnziman/homedepot-go v0.1.1 h1:f/mzZ8OWqmtsArj10eer+Ug1ZcKHjzw4rTZ/1HevvAM= +github.com/fnziman/homedepot-go v0.1.1/go.mod h1:IqDKgBvKTq9+HiwpYas5ku2TtAS/7ThkYeM6Whr1zUE= github.com/getsentry/sentry-go v0.36.0 h1:UkCk0zV28PiGf+2YIONSSYiYhxwlERE5Li3JPpZqEns= github.com/getsentry/sentry-go v0.36.0/go.mod h1:p5Im24mJBeruET8Q4bbcMfCQ+F+Iadc4L48tB1apo2c= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= diff --git a/internal/adapters/providers/homedepot/order.go b/internal/adapters/providers/homedepot/order.go new file mode 100644 index 0000000..ab1fd89 --- /dev/null +++ b/internal/adapters/providers/homedepot/order.go @@ -0,0 +1,137 @@ +package homedepot + +import ( + "fmt" + "strings" + "time" + + hdgo "github.com/fnziman/homedepot-go" + + "github.com/eshaffer321/itemize/internal/adapters/providers" +) + +// Order wraps a Home Depot OrderDetail and its originating OrderSummary +// to implement providers.Order. The summary is retained because in-store +// orders don't populate orderNumber on the detail — the store/transaction +// composite from the summary is used as the stable ID instead. +type Order struct { + summary hdgo.OrderSummary + detail hdgo.OrderDetail +} + +// GetID returns a stable unique identifier. Online orders use orderNumber +// directly; in-store orders use a composite of store number and transaction +// ID (both surfaced on the summary but empty on the detail). +func (o *Order) GetID() string { + if o.detail.OrderNumber != "" { + return o.detail.OrderNumber + } + if o.summary.TransactionID != "" { + return fmt.Sprintf("hd-instore-%s-%s", o.summary.StoreNumber, o.summary.TransactionID) + } + return "" +} + +// GetDate parses the ISO-8601 salesDate the API returns. Home Depot has +// been observed to use full RFC3339 timestamps and date-only strings; +// try both. +func (o *Order) GetDate() time.Time { + raw := o.detail.SalesDate + if raw == "" { + raw = o.summary.SalesDate + } + if raw == "" { + return time.Time{} + } + for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05.000Z", "2006-01-02"} { + if t, err := time.Parse(layout, raw); err == nil { + return t + } + } + return time.Time{} +} + +func (o *Order) GetTotal() float64 { return o.detail.GrandTotalAmount } +func (o *Order) GetSubtotal() float64 { return o.detail.SubTotalAmount } +func (o *Order) GetTax() float64 { return o.detail.TaxTotalAmount } + +// GetTip returns 0 — Home Depot doesn't have driver tips. +func (o *Order) GetTip() float64 { return 0 } + +// GetFees returns the sum of shipping and delivery charges. Home Depot +// reports these separately on the order detail. +func (o *Order) GetFees() float64 { + return o.detail.ShippingCharge + o.detail.DeliveryCharge +} + +// GetItems flattens line items across every fulfillment group. +func (o *Order) GetItems() []providers.OrderItem { + all := o.detail.AllLineItems() + out := make([]providers.OrderItem, 0, len(all)) + for i := range all { + out = append(out, &OrderItem{item: all[i]}) + } + return out +} + +// GetProviderName is the human-readable provider identifier the domain +// layer uses in log lines and prompts. +func (o *Order) GetProviderName() string { return "Home Depot" } + +// GetRawData exposes the underlying detail for handlers that want to peek +// at provider-specific fields. +func (o *Order) GetRawData() interface{} { return o.detail } + +// OrderItem wraps a single homedepot-go LineItem to implement +// providers.OrderItem. +type OrderItem struct { + item hdgo.LineItem +} + +// GetName combines brand and description when both are present. Home Depot +// listings frequently split them ("RIDGID" + "18V Cordless Drill"), and +// concatenating gives the categorizer better signal than either alone. +func (i *OrderItem) GetName() string { + brand := strings.TrimSpace(i.item.BrandName) + desc := strings.TrimSpace(i.item.Description) + switch { + case brand != "" && desc != "": + return brand + " " + desc + case desc != "": + return desc + default: + return brand + } +} + +// GetPrice returns the line total. +func (i *OrderItem) GetPrice() float64 { return i.item.TotalPrice } + +// GetQuantity returns the purchased quantity (currentQuantity minus +// cancelledQuantity, clamped at zero). Cancellations happen fairly often +// on partial fulfillments; using CurrentQuantity as-is would over-report. +func (i *OrderItem) GetQuantity() float64 { return i.item.PurchasedQuantity() } + +// GetUnitPrice returns the per-unit price. +func (i *OrderItem) GetUnitPrice() float64 { return i.item.UnitPrice } + +// GetDescription returns the item description. +func (i *OrderItem) GetDescription() string { return i.item.Description } + +// GetSKU prefers Home Depot's own thdSku (globally unique on +// homedepot.com) and falls back to skuNumber, then modelNumber. +func (i *OrderItem) GetSKU() string { + switch { + case i.item.THDSKU != "": + return i.item.THDSKU + case i.item.SKUNumber != "": + return i.item.SKUNumber + default: + return i.item.ModelNumber + } +} + +// GetCategory returns "" — Home Depot's line-item response doesn't carry +// a category we can map cleanly to Monarch. The LLM categorizer does the +// mapping downstream. +func (i *OrderItem) GetCategory() string { return "" } diff --git a/internal/adapters/providers/homedepot/order_test.go b/internal/adapters/providers/homedepot/order_test.go new file mode 100644 index 0000000..d475010 --- /dev/null +++ b/internal/adapters/providers/homedepot/order_test.go @@ -0,0 +1,172 @@ +package homedepot + +import ( + "testing" + "time" + + hdgo "github.com/fnziman/homedepot-go" + "github.com/stretchr/testify/assert" + + "github.com/eshaffer321/itemize/internal/adapters/providers" +) + +func TestOrder_GetID_OnlineUsesOrderNumber(t *testing.T) { + o := &Order{ + summary: hdgo.OrderSummary{OrderNumbers: []string{"WD-0001"}, OrderOrigin: "online"}, + detail: hdgo.OrderDetail{OrderNumber: "WD-0001"}, + } + assert.Equal(t, "WD-0001", o.GetID()) +} + +func TestOrder_GetID_InstoreUsesComposite(t *testing.T) { + o := &Order{ + summary: hdgo.OrderSummary{StoreNumber: "0100", TransactionID: "TXN-42", OrderOrigin: "instore"}, + detail: hdgo.OrderDetail{OrderNumber: "", OrderOrigin: "instore"}, + } + assert.Equal(t, "hd-instore-0100-TXN-42", o.GetID()) +} + +func TestOrder_GetID_EmptyWhenNoIdentifier(t *testing.T) { + o := &Order{summary: hdgo.OrderSummary{}, detail: hdgo.OrderDetail{}} + assert.Empty(t, o.GetID()) +} + +func TestOrder_GetDate_ParsesMultipleFormats(t *testing.T) { + cases := []struct { + name string + raw string + }{ + {"RFC3339 with Z", "2026-07-01T00:00:00Z"}, + {"date only", "2026-07-01"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + o := &Order{detail: hdgo.OrderDetail{SalesDate: tc.raw}} + got := o.GetDate() + assert.False(t, got.IsZero(), "expected valid parse of %q", tc.raw) + assert.Equal(t, 2026, got.Year()) + assert.Equal(t, time.July, got.Month()) + }) + } +} + +func TestOrder_GetDate_FallsBackToSummary(t *testing.T) { + // Detail salesDate empty but summary has one. + o := &Order{ + summary: hdgo.OrderSummary{SalesDate: "2026-07-02T00:00:00Z"}, + detail: hdgo.OrderDetail{SalesDate: ""}, + } + got := o.GetDate() + assert.False(t, got.IsZero()) + assert.Equal(t, 2, got.Day()) +} + +func TestOrder_GetDate_UnparseableReturnsZero(t *testing.T) { + o := &Order{detail: hdgo.OrderDetail{SalesDate: "not-a-date"}} + assert.True(t, o.GetDate().IsZero()) +} + +func TestOrder_Amounts(t *testing.T) { + o := &Order{detail: hdgo.OrderDetail{ + SubTotalAmount: 39.99, + TaxTotalAmount: 3.00, + GrandTotalAmount: 42.99, + ShippingCharge: 5.00, + DeliveryCharge: 2.00, + }} + assert.InDelta(t, 42.99, o.GetTotal(), 0.001) + assert.InDelta(t, 39.99, o.GetSubtotal(), 0.001) + assert.InDelta(t, 3.00, o.GetTax(), 0.001) + assert.InDelta(t, 0, o.GetTip(), 0.001, "Home Depot doesn't have driver tips") + assert.InDelta(t, 7.00, o.GetFees(), 0.001, "fees should sum shipping + delivery") +} + +func TestOrder_GetItems_FlattensAcrossFulfillmentGroups(t *testing.T) { + o := &Order{detail: hdgo.OrderDetail{FulfillmentGroups: []hdgo.FulfillmentGroup{ + {LineItems: []hdgo.LineItem{{THDSKU: "1"}, {THDSKU: "2"}}}, + {LineItems: []hdgo.LineItem{{THDSKU: "3"}}}, + }}} + items := o.GetItems() + assert.Len(t, items, 3) + assert.Equal(t, "1", items[0].GetSKU()) + assert.Equal(t, "3", items[2].GetSKU()) +} + +func TestOrder_GetProviderName(t *testing.T) { + o := &Order{} + assert.Equal(t, "Home Depot", o.GetProviderName()) +} + +func TestOrder_GetRawData(t *testing.T) { + o := &Order{detail: hdgo.OrderDetail{OrderNumber: "WD-1"}} + raw, ok := o.GetRawData().(hdgo.OrderDetail) + assert.True(t, ok, "raw data should be the underlying OrderDetail") + assert.Equal(t, "WD-1", raw.OrderNumber) +} + +func TestOrderItem_GetName_BrandPlusDescription(t *testing.T) { + cases := []struct { + name string + item hdgo.LineItem + want string + }{ + {"brand + desc", hdgo.LineItem{BrandName: "RIDGID", Description: "Cordless Drill"}, "RIDGID Cordless Drill"}, + {"desc only", hdgo.LineItem{Description: "Sample Hammer"}, "Sample Hammer"}, + {"brand only", hdgo.LineItem{BrandName: "Kohler"}, "Kohler"}, + {"trims whitespace", hdgo.LineItem{BrandName: " Milwaukee ", Description: " Saw "}, "Milwaukee Saw"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + item := &OrderItem{item: tc.item} + assert.Equal(t, tc.want, item.GetName()) + }) + } +} + +func TestOrderItem_GetSKU_PrefersTHDSKU(t *testing.T) { + cases := []struct { + name string + item hdgo.LineItem + want string + }{ + {"prefers thdSku", hdgo.LineItem{THDSKU: "T-1", SKUNumber: "S-1", ModelNumber: "M-1"}, "T-1"}, + {"falls back to skuNumber", hdgo.LineItem{SKUNumber: "S-1", ModelNumber: "M-1"}, "S-1"}, + {"falls back to modelNumber", hdgo.LineItem{ModelNumber: "M-1"}, "M-1"}, + {"empty when nothing", hdgo.LineItem{}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + item := &OrderItem{item: tc.item} + assert.Equal(t, tc.want, item.GetSKU()) + }) + } +} + +func TestOrderItem_GetQuantity_HandlesCancellations(t *testing.T) { + item := &OrderItem{item: hdgo.LineItem{CurrentQuantity: 3, CancelledQuantity: 1}} + assert.InDelta(t, 2.0, item.GetQuantity(), 0.001) +} + +func TestOrderItem_Prices(t *testing.T) { + item := &OrderItem{item: hdgo.LineItem{UnitPrice: 12.50, TotalPrice: 25.00}} + assert.InDelta(t, 25.00, item.GetPrice(), 0.001) + assert.InDelta(t, 12.50, item.GetUnitPrice(), 0.001) +} + +func TestOrderItem_GetDescription(t *testing.T) { + item := &OrderItem{item: hdgo.LineItem{Description: "Sample Paint"}} + assert.Equal(t, "Sample Paint", item.GetDescription()) +} + +func TestOrderItem_GetCategory_AlwaysEmpty(t *testing.T) { + // Home Depot's response doesn't expose a Monarch-mappable category; + // the LLM categorizer does the work downstream. + item := &OrderItem{item: hdgo.LineItem{}} + assert.Empty(t, item.GetCategory()) +} + +// TestOrder_ImplementsInterface is a compile-time check. +func TestOrder_ImplementsInterface(t *testing.T) { + var _ providers.Order = (*Order)(nil) + var _ providers.OrderItem = (*OrderItem)(nil) +} diff --git a/internal/adapters/providers/homedepot/provider.go b/internal/adapters/providers/homedepot/provider.go new file mode 100644 index 0000000..3672c4c --- /dev/null +++ b/internal/adapters/providers/homedepot/provider.go @@ -0,0 +1,152 @@ +// Package homedepot implements providers.OrderProvider for Home Depot +// purchases. It wraps github.com/fnziman/homedepot-go, which speaks the +// internal /oms/customer/order/v1 endpoints homedepot.com itself uses via +// cookie-replay auth. +package homedepot + +import ( + "context" + "fmt" + "log/slog" + "time" + + hdgo "github.com/fnziman/homedepot-go" + + "github.com/eshaffer321/itemize/internal/adapters/providers" +) + +// hdClient is the subset of homedepot-go's *Client the provider depends on. +// Extracting it as an interface lets us mock the client in tests without +// spinning up an httptest server. +type hdClient interface { + ListOrders(ctx context.Context, start, end time.Time) ([]hdgo.OrderSummary, error) + GetOrder(ctx context.Context, summary hdgo.OrderSummary) (hdgo.OrderDetail, error) + HealthCheck(ctx context.Context) error +} + +// Provider is the Home Depot OrderProvider. +type Provider struct { + client hdClient + logger *slog.Logger + rateLimit time.Duration +} + +// NewProvider constructs a Home Depot provider from a homedepot-go client. +// Callers are responsible for scoping the logger (e.g. system="homedepot"). +func NewProvider(client *hdgo.Client, logger *slog.Logger) *Provider { + if client == nil { + return newProvider(nil, logger) + } + return newProvider(client, logger) +} + +func newProvider(client hdClient, logger *slog.Logger) *Provider { + if logger == nil { + logger = slog.Default() + } + return &Provider{ + client: client, + logger: logger, + rateLimit: 1 * time.Second, + } +} + +// Name is the CLI-facing identifier. +func (p *Provider) Name() string { return "homedepot" } + +// DisplayName is the human-readable identifier. It's also what the sync +// orchestrator uses when substring-matching against Monarch merchant +// names (see internal/application/sync/fetch.go), so "Home Depot" +// matches "THE HOME DEPOT" (case-insensitive) cleanly. +func (p *Provider) DisplayName() string { return "Home Depot" } + +// FetchOrders returns full-detail orders whose salesDate falls in the +// given range. Follows the same "always fetch details" pattern as the +// Walmart provider — the domain layer requires them for categorization. +func (p *Provider) FetchOrders(ctx context.Context, opts providers.FetchOptions) ([]providers.Order, error) { + p.logger.Info("fetching orders", + slog.Time("start_date", opts.StartDate), + slog.Time("end_date", opts.EndDate), + slog.Int("max_orders", opts.MaxOrders), + ) + + summaries, err := p.client.ListOrders(ctx, opts.StartDate, opts.EndDate) + if err != nil { + return nil, fmt.Errorf("failed to list home depot orders: %w", err) + } + + if opts.MaxOrders > 0 && len(summaries) > opts.MaxOrders { + summaries = summaries[:opts.MaxOrders] + } + + orders := make([]providers.Order, 0, len(summaries)) + for _, summary := range summaries { + if err := ctx.Err(); err != nil { + return orders, fmt.Errorf("cancelled during order fetch: %w", err) + } + + detail, err := p.client.GetOrder(ctx, summary) + if err != nil { + p.logger.Warn("failed to fetch home depot order details, skipping", + slog.String("order_ref", summaryRef(summary)), + slog.String("origin", summary.OrderOrigin), + slog.String("error", err.Error())) + continue + } + orders = append(orders, &Order{summary: summary, detail: detail}) + } + + p.logger.Info("fetched orders", slog.Int("total", len(orders))) + return orders, nil +} + +// GetOrderDetails is not supported for Home Depot: /orderdetails needs the +// orderOrigin plus store/register/transaction fields to route to the online +// vs in-store schema, none of which are derivable from a bare order ID. +// Callers should use FetchOrders, which already returns full details. +func (p *Provider) GetOrderDetails(ctx context.Context, orderID string) (providers.Order, error) { + p.logger.Warn("GetOrderDetails called without OrderSummary context", + slog.String("order_id", orderID)) + return nil, fmt.Errorf("GetOrderDetails not supported for Home Depot without OrderSummary") +} + +// SupportsDeliveryTips — Home Depot orders don't include driver tips. +func (p *Provider) SupportsDeliveryTips() bool { return false } + +// SupportsRefunds — refund handling is planned but not implemented in v1; +// the API exposes returnTotal on OrderDetail but we don't wire refund flows +// through the orchestrator yet. +func (p *Provider) SupportsRefunds() bool { return false } + +// SupportsBulkFetch — ListOrders returns everything in the range. +func (p *Provider) SupportsBulkFetch() bool { return true } + +// GetRateLimit is the desired pacing between successive syncs. The +// underlying homedepot-go client already paces itself between pages and +// per-order detail fetches, so this is a coarse outer-loop pacing hint. +func (p *Provider) GetRateLimit() time.Duration { return p.rateLimit } + +// HealthCheck confirms cookies are valid by issuing a small /orderhistory +// request via homedepot-go. +func (p *Provider) HealthCheck(ctx context.Context) error { + if p.client == nil { + return fmt.Errorf("home depot client not initialized") + } + if err := p.client.HealthCheck(ctx); err != nil { + return fmt.Errorf("home depot health check failed: %w", err) + } + return nil +} + +// summaryRef returns a short human-readable identifier for the summary, +// used only in log lines. Handles both online (orderNumber) and in-store +// (store + transactionId) cases. +func summaryRef(s hdgo.OrderSummary) string { + if len(s.OrderNumbers) > 0 && s.OrderNumbers[0] != "" { + return s.OrderNumbers[0] + } + if s.TransactionID != "" { + return fmt.Sprintf("store-%s/txn-%s", s.StoreNumber, s.TransactionID) + } + return "unknown" +} diff --git a/internal/adapters/providers/homedepot/provider_test.go b/internal/adapters/providers/homedepot/provider_test.go new file mode 100644 index 0000000..3f0d93b --- /dev/null +++ b/internal/adapters/providers/homedepot/provider_test.go @@ -0,0 +1,236 @@ +package homedepot + +import ( + "context" + "errors" + "testing" + "time" + + hdgo "github.com/fnziman/homedepot-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/eshaffer321/itemize/internal/adapters/providers" +) + +// mockClient implements the hdClient interface with configurable behavior. +type mockClient struct { + listErr error + summaries []hdgo.OrderSummary + details map[string]hdgo.OrderDetail // keyed by summary orderNumber or transactionId + detailErrs map[string]error + healthErr error + listCalls int + getOrderCalls int + healthCalls int +} + +func (m *mockClient) ListOrders(_ context.Context, _, _ time.Time) ([]hdgo.OrderSummary, error) { + m.listCalls++ + if m.listErr != nil { + return nil, m.listErr + } + return m.summaries, nil +} + +func (m *mockClient) GetOrder(_ context.Context, s hdgo.OrderSummary) (hdgo.OrderDetail, error) { + m.getOrderCalls++ + key := summaryKey(s) + if err := m.detailErrs[key]; err != nil { + return hdgo.OrderDetail{}, err + } + if d, ok := m.details[key]; ok { + return d, nil + } + return hdgo.OrderDetail{}, errors.New("no fixture for " + key) +} + +func (m *mockClient) HealthCheck(_ context.Context) error { + m.healthCalls++ + return m.healthErr +} + +func summaryKey(s hdgo.OrderSummary) string { + if len(s.OrderNumbers) > 0 && s.OrderNumbers[0] != "" { + return s.OrderNumbers[0] + } + return s.TransactionID +} + +func TestProvider_NameAndDisplayName(t *testing.T) { + p := newProvider(&mockClient{}, nil) + assert.Equal(t, "homedepot", p.Name()) + // DisplayName intentionally matches Monarch's "THE HOME DEPOT" merchant + // name via case-insensitive substring; do not change casually. + assert.Equal(t, "Home Depot", p.DisplayName()) +} + +func TestProvider_Capabilities(t *testing.T) { + p := newProvider(&mockClient{}, nil) + assert.False(t, p.SupportsDeliveryTips()) + assert.False(t, p.SupportsRefunds()) + assert.True(t, p.SupportsBulkFetch()) + assert.Equal(t, time.Second, p.GetRateLimit()) +} + +func TestProvider_FetchOrders_HappyPath(t *testing.T) { + summaries := []hdgo.OrderSummary{ + {OrderOrigin: "online", OrderNumbers: []string{"WD-000-0001"}, SalesDate: "2026-07-01T00:00:00Z", TotalAmount: 42.99}, + {OrderOrigin: "instore", StoreNumber: "0000", TransactionID: "TXN-000-0001", SalesDate: "2026-07-02T00:00:00Z", TotalAmount: 27.50}, + } + details := map[string]hdgo.OrderDetail{ + "WD-000-0001": { + OrderNumber: "WD-000-0001", OrderOrigin: "online", SalesDate: "2026-07-01T00:00:00Z", + SubTotalAmount: 39.99, TaxTotalAmount: 3.00, GrandTotalAmount: 42.99, + FulfillmentGroups: []hdgo.FulfillmentGroup{{LineItems: []hdgo.LineItem{ + {THDSKU: "000-000-000", BrandName: "Test Brand", Description: "Sample Hammer", UnitPrice: 19.99, TotalPrice: 19.99, CurrentQuantity: 1}, + }}}, + }, + "TXN-000-0001": { + OrderNumber: "", OrderOrigin: "instore", SalesDate: "2026-07-02T00:00:00Z", StoreNumber: "0000", + SubTotalAmount: 25.00, TaxTotalAmount: 2.50, GrandTotalAmount: 27.50, + FulfillmentGroups: []hdgo.FulfillmentGroup{{LineItems: []hdgo.LineItem{ + {THDSKU: "000-000-002", Description: "Sample Paint, 1 Gallon", UnitPrice: 25.00, TotalPrice: 25.00, CurrentQuantity: 1}, + }}}, + }, + } + m := &mockClient{summaries: summaries, details: details} + p := newProvider(m, nil) + + orders, err := p.FetchOrders(context.Background(), providers.FetchOptions{ + StartDate: time.Now().AddDate(0, -1, 0), + EndDate: time.Now(), + }) + + require.NoError(t, err) + require.Len(t, orders, 2) + assert.Equal(t, 1, m.listCalls) + assert.Equal(t, 2, m.getOrderCalls) + + // First order — online — uses orderNumber as ID. + assert.Equal(t, "WD-000-0001", orders[0].GetID()) + assert.InDelta(t, 42.99, orders[0].GetTotal(), 0.001) + + // Second order — in-store — uses composite ID because orderNumber is empty. + assert.Equal(t, "hd-instore-0000-TXN-000-0001", orders[1].GetID()) + assert.InDelta(t, 27.50, orders[1].GetTotal(), 0.001) +} + +func TestProvider_FetchOrders_MaxOrdersCap(t *testing.T) { + summaries := []hdgo.OrderSummary{ + {OrderOrigin: "online", OrderNumbers: []string{"A"}}, + {OrderOrigin: "online", OrderNumbers: []string{"B"}}, + {OrderOrigin: "online", OrderNumbers: []string{"C"}}, + } + details := map[string]hdgo.OrderDetail{ + "A": {OrderNumber: "A"}, "B": {OrderNumber: "B"}, "C": {OrderNumber: "C"}, + } + m := &mockClient{summaries: summaries, details: details} + p := newProvider(m, nil) + + orders, err := p.FetchOrders(context.Background(), providers.FetchOptions{ + StartDate: time.Now().AddDate(0, -1, 0), + EndDate: time.Now(), + MaxOrders: 2, + }) + + require.NoError(t, err) + assert.Len(t, orders, 2) + assert.Equal(t, 2, m.getOrderCalls, "should not fetch details beyond MaxOrders") +} + +func TestProvider_FetchOrders_ListError(t *testing.T) { + m := &mockClient{listErr: errors.New("boom")} + p := newProvider(m, nil) + + _, err := p.FetchOrders(context.Background(), providers.FetchOptions{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") + assert.Equal(t, 0, m.getOrderCalls) +} + +func TestProvider_FetchOrders_SkipsFailedDetail(t *testing.T) { + summaries := []hdgo.OrderSummary{ + {OrderOrigin: "online", OrderNumbers: []string{"OK"}}, + {OrderOrigin: "online", OrderNumbers: []string{"BAD"}}, + {OrderOrigin: "online", OrderNumbers: []string{"ALSO_OK"}}, + } + m := &mockClient{ + summaries: summaries, + details: map[string]hdgo.OrderDetail{ + "OK": {OrderNumber: "OK"}, + "ALSO_OK": {OrderNumber: "ALSO_OK"}, + }, + detailErrs: map[string]error{"BAD": errors.New("detail failed")}, + } + p := newProvider(m, nil) + + orders, err := p.FetchOrders(context.Background(), providers.FetchOptions{}) + require.NoError(t, err) + assert.Len(t, orders, 2, "the failed order should be skipped, not fatal") +} + +func TestProvider_FetchOrders_ContextCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // pre-cancel + + m := &mockClient{summaries: []hdgo.OrderSummary{{OrderNumbers: []string{"X"}, OrderOrigin: "online"}}} + p := newProvider(m, nil) + + orders, err := p.FetchOrders(ctx, providers.FetchOptions{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "cancelled") + assert.Empty(t, orders) +} + +func TestProvider_HealthCheck(t *testing.T) { + m := &mockClient{} + p := newProvider(m, nil) + assert.NoError(t, p.HealthCheck(context.Background())) + assert.Equal(t, 1, m.healthCalls) +} + +func TestProvider_HealthCheck_Error(t *testing.T) { + m := &mockClient{healthErr: errors.New("nope")} + p := newProvider(m, nil) + err := p.HealthCheck(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "nope") +} + +func TestProvider_HealthCheck_NilClient(t *testing.T) { + p := newProvider(nil, nil) + err := p.HealthCheck(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "not initialized") +} + +func TestProvider_GetOrderDetails_NotSupported(t *testing.T) { + p := newProvider(&mockClient{}, nil) + _, err := p.GetOrderDetails(context.Background(), "WD-000-0001") + require.Error(t, err) + assert.Contains(t, err.Error(), "not supported") +} + +func TestSummaryRef(t *testing.T) { + cases := []struct { + name string + s hdgo.OrderSummary + want string + }{ + {"online with order number", hdgo.OrderSummary{OrderNumbers: []string{"WD-1"}}, "WD-1"}, + {"instore with txn", hdgo.OrderSummary{StoreNumber: "0100", TransactionID: "TXN-9"}, "store-0100/txn-TXN-9"}, + {"neither", hdgo.OrderSummary{}, "unknown"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, summaryRef(tc.s)) + }) + } +} + +// TestProvider_ImplementsInterface is a compile-time check that Provider +// still satisfies providers.OrderProvider (guards against interface drift). +func TestProvider_ImplementsInterface(t *testing.T) { + var _ providers.OrderProvider = (*Provider)(nil) +} diff --git a/internal/cli/providers.go b/internal/cli/providers.go index 695e853..b6182c9 100644 --- a/internal/cli/providers.go +++ b/internal/cli/providers.go @@ -12,9 +12,11 @@ import ( "github.com/eshaffer321/itemize/internal/adapters/providers" amazonprovider "github.com/eshaffer321/itemize/internal/adapters/providers/amazon" "github.com/eshaffer321/itemize/internal/adapters/providers/costco" + homedepotprovider "github.com/eshaffer321/itemize/internal/adapters/providers/homedepot" "github.com/eshaffer321/itemize/internal/adapters/providers/walmart" "github.com/eshaffer321/itemize/internal/infrastructure/config" "github.com/eshaffer321/itemize/internal/infrastructure/logging" + hdgo "github.com/fnziman/homedepot-go" walmartclient "github.com/eshaffer321/walmart-client-go/v2" ) @@ -75,6 +77,27 @@ func NewWalmartProvider(cfg *config.Config, verbose bool) (providers.OrderProvid return walmart.NewProvider(walmartClient, walmartLogger), nil } +// NewHomeDepotProvider creates a new Home Depot provider with a system-scoped +// logger. Auth is cookie replay from ~/.homedepot-api/cookies.json (or the +// user's HOMEDEPOT_COOKIE_FILE override); the underlying homedepot-go client +// loads and decodes the THD_CUSTOMER cookie for the API auth token. +func NewHomeDepotProvider(cfg *config.Config, verbose bool) (providers.OrderProvider, error) { + loggingCfg := cfg.Observability.Logging + if verbose { + loggingCfg.Level = "debug" + } + hdLogger := logging.NewLoggerWithSystem(loggingCfg, "homedepot") + + client, err := hdgo.NewClient(hdgo.Config{ + CookieFile: cfg.Providers.HomeDepot.CookieFile, + Logger: hdLogger, + }) + if err != nil { + return nil, fmt.Errorf("failed to create Home Depot client: %w", err) + } + return homedepotprovider.NewProvider(client, hdLogger), nil +} + // 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. diff --git a/internal/infrastructure/config/config.go b/internal/infrastructure/config/config.go index 611cbb7..5610ca9 100644 --- a/internal/infrastructure/config/config.go +++ b/internal/infrastructure/config/config.go @@ -62,9 +62,10 @@ type CategorizerConfig struct { // ProvidersConfig holds provider-specific configuration type ProvidersConfig struct { - Walmart WalmartConfig `yaml:"walmart"` - Costco CostcoConfig `yaml:"costco"` - Amazon AmazonConfig `yaml:"amazon"` + Walmart WalmartConfig `yaml:"walmart"` + Costco CostcoConfig `yaml:"costco"` + Amazon AmazonConfig `yaml:"amazon"` + HomeDepot HomeDepotConfig `yaml:"homedepot"` } // WalmartConfig holds Walmart-specific settings @@ -99,6 +100,18 @@ type AmazonConfig struct { CookieFile string `yaml:"cookie_file"` // Optional amazon-go cookie file } +// HomeDepotConfig holds Home Depot-specific settings. +// Auth is cookie-replay via ~/.homedepot-api/cookies.json (override via +// CookieFile / HOMEDEPOT_COOKIE_FILE). +type HomeDepotConfig struct { + Enabled bool `yaml:"enabled"` + RateLimit string `yaml:"rate_limit"` + LookbackDays int `yaml:"lookback_days"` + MaxOrders int `yaml:"max_orders"` + Debug bool `yaml:"debug"` + CookieFile string `yaml:"cookie_file"` // Optional path override +} + // ObservabilityConfig holds observability settings type ObservabilityConfig struct { Logging LoggingConfig `yaml:"logging"` @@ -213,6 +226,12 @@ func LoadFromEnv() *Config { AccountName: getEnv("AMAZON_ACCOUNT_NAME", ""), CookieFile: getEnv("AMAZON_COOKIE_FILE", ""), }, + HomeDepot: HomeDepotConfig{ + Enabled: true, + LookbackDays: getEnvInt("HOMEDEPOT_LOOKBACK_DAYS", 14), + MaxOrders: getEnvInt("HOMEDEPOT_MAX_ORDERS", 0), + CookieFile: getEnv("HOMEDEPOT_COOKIE_FILE", ""), + }, }, Observability: ObservabilityConfig{ Logging: LoggingConfig{