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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Last Updated:** June 2026

CLI + API server that syncs Walmart, Costco, and Amazon purchases into Monarch:
CLI + API server that syncs Walmart, Costco, Amazon, and Target purchases into Monarch:
matches each order to its Monarch transaction, categorizes items with OpenAI, and splits
the transaction by category. Local single-user tool — not a SaaS, extension, or real-time service.

Expand Down Expand Up @@ -51,7 +51,7 @@ Layered, with the domain core kept dependency-free. Flow: **CLI → application
internal/
application/sync/ orchestrator + per-provider handlers (walmart, costco, amazon, simple)
domain/ pure logic: categorizer/ (pluggable LLM backend), matcher/ (fuzzy), splitter/ (splits + tax)
adapters/ providers/ (walmart, costco, amazon) and clients/ (Monarch, openai/, anthropic/)
adapters/ providers/ (walmart, costco, amazon, target) and clients/ (Monarch, openai/, anthropic/)
infrastructure/ config/, storage/ (SQLite + goose migrations), logging/ (slog)
cli/ flag parsing + output
```
Expand Down Expand Up @@ -104,7 +104,7 @@ in `config.go`, register in `internal/cli/providers.go`, add a handler in
- **"No matching transaction found"** — transaction not posted yet (wait 1–3 days), amount off by
>$0.01, or date off by >5 days. Use `-verbose` to see match scoring.
- **"Order already processed"** — expected; dedup working. `-force` to reprocess.
- **Provider auth** — Walmart cookies in `~/.walmart-api/cookies.json`; Costco creds saved by costco-go.
- **Provider auth** — Walmart cookies in `~/.walmart-api/cookies.json`; Costco creds saved by costco-go; Target logins in `~/.itemize/target/session-<account>.json` (renewed before each run; the refresh token rotates, so a saved login must not stay in use in a browser).
- **DB reset** — back up and delete `monarch_sync.db`; migrations (goose) re-run on startup.

## Deeper docs
Expand Down
38 changes: 37 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 Target with Monarch. Automatically splits transactions by category using AI.

## What it does

Expand Down Expand Up @@ -112,11 +112,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 target -dry-run -days 14

# Apply changes
./itemize walmart -days 14
./itemize costco -days 7
./itemize amazon -days 7
./itemize target -days 14
```

### Flags
Expand Down Expand Up @@ -178,6 +180,40 @@ an env var, or for cron jobs where the account name should be visible right in t
`AMAZON_ACCOUNT_NAME` still works and is used as the default when `-account` is omitted — cron
jobs relying on the env var need no changes.

### Target
Reads in-store receipts linked to your Target Circle account and online orders, through the JSON
APIs target.com's own order pages use ([target-go](https://github.com/daslicious/target-go); its
[PROTOCOL.md](https://github.com/daslicious/target-go/blob/main/PROTOCOL.md) documents them).
Target has no API login, so you save a browser login once and Itemize renews it before every run:

1. In Chrome, open an **Incognito window** and sign in at target.com. Target replaces the refresh
token on every renewal, so the login must not stay in use in a browser that would renew it too.
2. Open target.com/orders, then DevTools → Network, filter `history`, and reload.
3. Right-click the `history` request marked `{}` (not the preflight) → Copy → **Copy as cURL (bash)**.
4. Save it; Itemize replays the request and saves it only if Target accepts it:

```bash
pbpaste | ./itemize target set-session -account default # or: xclip -o -selection clipboard | ...
```

5. Close the Incognito window **without signing out** (signing out ends the login).

Logins are saved to `~/.itemize/target/session-<account>.json` (`ITEMIZE_TARGET_DIR` to change).
Each run renews the 24-hour access token with a refresh token that lasts 180 days. Use
`-account` for more than one login; receipts on a shared Target Circle account show up in each
and are synced once.

```bash
./itemize target orders -account default -days 30 # orders as Itemize reads them (JSON), no Monarch
./itemize target refresh -account default # renew the login now
```

An online order shipped in parts is charged once per shipment; each charge is matched and split on
its own, as `<order>`, `<order>-2`, … Gift cards, manufacturer coupons and the reusable-bag
discount are left out of the amount to match. An expired login fails with
`target auth check failed`; Target's bot protection with `target blocked the request` (wait an hour
or more). Requests are paced 3-5 seconds apart.

## Troubleshooting

**"No matching transaction found"**
Expand Down
71 changes: 69 additions & 2 deletions cmd/itemize/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"log"
"os"
"strings"
"time"

"github.com/eshaffer321/itemize/internal/adapters/clients"
"github.com/eshaffer321/itemize/internal/adapters/providers"
Expand Down Expand Up @@ -53,8 +54,12 @@ func main() {
providerName := command
amazonSetup := providerName == "amazon" && len(os.Args) > 2 && os.Args[2] == "setup"
amazonReturns := providerName == "amazon" && len(os.Args) > 2 && os.Args[2] == "returns"
targetSubcommand := ""
if providerName == "target" && len(os.Args) > 2 && (os.Args[2] == "set-session" || os.Args[2] == "orders" || os.Args[2] == "refresh") {
targetSubcommand = os.Args[2]
}
// Shift args for flag parsing
if amazonSetup || amazonReturns {
if amazonSetup || amazonReturns || targetSubcommand != "" {
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:]...)
Expand Down Expand Up @@ -87,6 +92,54 @@ func main() {
log.Fatalf("Invalid Amazon account arguments: %v", err)
}
}
targetAccount := flags.Account
if targetAccount == "" {
targetAccount = "default"
}
if providerName != "amazon" && providerName != "target" && flags.Account != "" {
log.Fatalf("-account is only supported for the amazon and target providers")
}

switch targetSubcommand {
case "refresh":
if err := cli.RunTargetRefresh(context.Background(), cfg, flags.Verbose, targetAccount, os.Stdout); err != nil {
log.Fatalf("Target login for %s: %v", targetAccount, err)
}
return
case "set-session":
if err := cli.RunTargetSetSession(context.Background(), targetAccount, os.Stdin, os.Stdout); err != nil {
log.Fatalf("Target session for %s: %v", targetAccount, err)
}
return
case "orders":
// The logger writes to stdout; send it to stderr so stdout is only the JSON.
jsonOut := os.Stdout
os.Stdout = os.Stderr
provider, createErr := cli.NewTargetProvider(cfg, flags.Verbose, targetAccount)
if createErr != nil {
log.Fatalf("Failed to create Target provider: %v", createErr)
}
var orders []providers.Order
var fetchErr error
if flags.OrderID != "" {
orders, fetchErr = provider.FetchOrder(context.Background(), flags.OrderID)
} else {
end := time.Now()
orders, fetchErr = provider.FetchOrders(context.Background(), providers.FetchOptions{
StartDate: end.AddDate(0, 0, -flags.LookbackDays),
EndDate: end,
MaxOrders: flags.MaxOrders,
IncludeDetails: true,
})
}
if fetchErr != nil {
log.Fatalf("Failed to fetch Target orders: %v", fetchErr)
}
if printErr := cli.PrintTargetOrders(jsonOut, orders); printErr != nil {
log.Fatalf("Failed to print Target orders: %v", printErr)
}
return
}

if flags.ListAccounts {
if providerName != "amazon" {
Expand Down Expand Up @@ -190,6 +243,8 @@ func main() {
provider, err = cli.NewWalmartProvider(cfg, flags.Verbose)
case "amazon":
provider, err = cli.NewAmazonProvider(cfg, flags.Verbose, amazonAccount)
case "target":
provider, err = cli.NewTargetProvider(cfg, flags.Verbose, targetAccount)
default:
fmt.Printf("Unknown provider: %s\n", providerName)
printUsage()
Expand All @@ -216,6 +271,9 @@ func main() {
resolvedAccount = "default"
}
}
if providerName == "target" {
resolvedAccount = targetAccount
}
cli.PrintConfiguration(provider.DisplayName(), flags.LookbackDays, flags.MaxOrders, flags.Force, resolvedAccount)

// Create orchestrator with sync-scoped logger and run
Expand Down Expand Up @@ -250,6 +308,13 @@ func printUsage() {
fmt.Println(" Read Amazon's return/refund ledger as JSON")
fmt.Println(" costco Sync Costco orders")
fmt.Println(" walmart Sync Walmart orders")
fmt.Println(" target Sync Target in-store and online orders")
fmt.Println(" target set-session -account <name>")
fmt.Println(" Save a target.com login from a DevTools 'Copy as cURL' read on stdin")
fmt.Println(" target refresh -account <name>")
fmt.Println(" Renew the saved Target login now (syncs renew it automatically)")
fmt.Println(" target orders -account <name> [-days N | -order-id ID]")
fmt.Println(" Print Target orders as itemize reads them (JSON); does not touch Monarch")
fmt.Println(" version Print version, commit, and build date (also: -version, --version)")
fmt.Println()
fmt.Println("Serve Flags:")
Expand All @@ -263,7 +328,7 @@ func printUsage() {
fmt.Println(" -force Force reprocess already processed orders")
fmt.Println(" -verbose Verbose output")
fmt.Println(" -order-id string Process only this specific order ID (limits blast radius)")
fmt.Println(" -account string Amazon cookie account name (amazon only)")
fmt.Println(" -account string Account name (amazon and target)")
fmt.Println(" -cookie-file string")
fmt.Println(" Explicit Amazon cookie file (amazon only)")
fmt.Println(" -list-accounts List saved Amazon cookie accounts and exit (amazon only)")
Expand All @@ -287,4 +352,6 @@ 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(" ITEMIZE_TARGET_DIR Where Target sessions are saved (default ~/.itemize/target)")
fmt.Println(" ITEMIZE_TARGET_DUMP_DIR Save every Target API response here (for debugging)")
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.26.10

require (
github.com/PuerkitoBio/goquery v1.13.0
github.com/daslicious/target-go v0.1.2
github.com/eshaffer321/amazon-go v0.5.0
github.com/eshaffer321/costco-go v0.3.11
github.com/eshaffer321/monarch-go/v2 v2.1.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ github.com/PuerkitoBio/goquery v1.13.0 h1:mqHbjD7Jmnul4DTR24LKTjo1uUmHUh072kteGV
github.com/PuerkitoBio/goquery v1.13.0/go.mod h1:Hip5mdBL8K2wEGKJdr27sRaNwIdDajmCwB/ExUPwW+g=
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
github.com/daslicious/target-go v0.1.2 h1:2mgbneyQE40IpQ1CWujCDiVBN45lI73NJFi+RsVp7bE=
github.com/daslicious/target-go v0.1.2/go.mod h1:TcfBvr8q3AT0HBPZ5cd6t+Rkw5DoH4UGo4jyMyVEDhg=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eshaffer321/amazon-go v0.5.0 h1:9/NZpZ55afx9OM9+ymaTUVU0oscLABlvHpIAyBViM5M=
Expand Down
Loading
Loading