Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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).

Expand Down
6 changes: 6 additions & 0 deletions cmd/itemize/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -249,6 +251,7 @@ func printUsage() {
fmt.Println(" amazon returns -account <name>")
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()
Expand Down Expand Up @@ -287,4 +290,7 @@ func printUsage() {
fmt.Println(" AMAZON_ACCOUNT_NAME Amazon cookie account name (optional)")
fmt.Println(" Run 'itemize amazon -import-browser-profile <profile-dir> -account <name>' 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)")
}
9 changes: 9 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
87 changes: 87 additions & 0 deletions docs/homedepot-specifics.md
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
137 changes: 137 additions & 0 deletions internal/adapters/providers/homedepot/order.go
Original file line number Diff line number Diff line change
@@ -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 "" }
Loading