diff --git a/AGENTS.md b/AGENTS.md index 8f18d42..b69eca7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. @@ -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 ``` @@ -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-.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 diff --git a/README.md b/README.md index 7a4cfe4..4690b03 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 Target with Monarch. Automatically splits transactions by category using AI. ## What it does @@ -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 @@ -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-.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 ``, `-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"** diff --git a/cmd/itemize/main.go b/cmd/itemize/main.go index cd1d341..42abe7f 100644 --- a/cmd/itemize/main.go +++ b/cmd/itemize/main.go @@ -6,6 +6,7 @@ import ( "log" "os" "strings" + "time" "github.com/eshaffer321/itemize/internal/adapters/clients" "github.com/eshaffer321/itemize/internal/adapters/providers" @@ -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:]...) @@ -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" { @@ -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() @@ -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 @@ -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 ") + fmt.Println(" Save a target.com login from a DevTools 'Copy as cURL' read on stdin") + fmt.Println(" target refresh -account ") + fmt.Println(" Renew the saved Target login now (syncs renew it automatically)") + fmt.Println(" target orders -account [-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:") @@ -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)") @@ -287,4 +352,6 @@ 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(" 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)") } diff --git a/go.mod b/go.mod index 0a6dc74..4ac07fc 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index bbe9473..05426ca 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/adapters/providers/target/order.go b/internal/adapters/providers/target/order.go new file mode 100644 index 0000000..13c1921 --- /dev/null +++ b/internal/adapters/providers/target/order.go @@ -0,0 +1,286 @@ +package target + +import ( + "fmt" + "math" + "strings" + "time" + + targetgo "github.com/daslicious/target-go" + "github.com/eshaffer321/itemize/internal/adapters/providers" +) + +// Order kinds. +const ( + KindOnline = "online" + KindStore = "store" +) + +// Order implements providers.Order for a Target online order or in-store receipt. +type Order struct { + id string + kind string + date time.Time + total float64 + subtotal float64 + tax float64 + items []providers.OrderItem + raw interface{} +} + +func (o *Order) GetID() string { return o.id } +func (o *Order) GetDate() time.Time { return o.date } +func (o *Order) GetTotal() float64 { return o.total } +func (o *Order) GetSubtotal() float64 { return o.subtotal } +func (o *Order) GetTax() float64 { return o.tax } +func (o *Order) GetTip() float64 { return 0 } +func (o *Order) GetFees() float64 { return 0 } +func (o *Order) GetItems() []providers.OrderItem { return o.items } +func (o *Order) GetProviderName() string { return "Target" } +func (o *Order) GetRawData() interface{} { return o.raw } + +// Kind is KindOnline or KindStore. +func (o *Order) Kind() string { return o.kind } + +// OrderItem implements providers.OrderItem. +type OrderItem struct { + name string + price float64 + quantity float64 + unitPrice float64 + sku string + category string +} + +func (i *OrderItem) GetName() string { return i.name } +func (i *OrderItem) GetPrice() float64 { return i.price } +func (i *OrderItem) GetQuantity() float64 { return i.quantity } +func (i *OrderItem) GetUnitPrice() float64 { return i.unitPrice } +func (i *OrderItem) GetDescription() string { return i.name } +func (i *OrderItem) GetSKU() string { return i.sku } +func (i *OrderItem) GetCategory() string { return i.category } + +// convertStore turns an in-store receipt into an Order. The total is what the cards were charged: +// a gift card or a discount taken after the total (e.g. bringing your own bag) makes the card charge +// smaller than grand_total, and the card charge is what appears in Monarch. +func convertStore(s *targetgo.StoreOrder, fallback targetgo.OrderSummary) (*Order, error) { + id := firstNonEmpty(s.StoreReceiptID, fallback.ID()) + date := orderDate(firstNonEmpty(s.OrderPurchaseDate, fallback.Date())) + total := firstNonZero(float64(s.GrandTotal), float64(s.Summary.GrandTotal), float64(fallback.Summary.GrandTotal)) + lines, err := nameLines(s.OrderLines, fallback.Lines()) + if err != nil { + return nil, err + } + return buildOrder(id, KindStore, date, total, cardCharge(s.PaymentTransactions, total), lines, s), nil +} + +// convertOnline turns an online order into an Order. Items from every package are combined, and +// as with receipts, a gift-card share is left out of the total. +func convertOnline(o *targetgo.OnlineOrder, fallback targetgo.OrderSummary) (*Order, error) { + id := firstNonEmpty(o.OrderNumber, fallback.ID()) + date := orderDate(firstNonEmpty(o.OrderDate, fallback.Date())) + total := firstNonZero(float64(o.Summary.GrandTotal), float64(fallback.Summary.GrandTotal)) + lines := o.Lines() + if len(lines) == 0 { + lines = fallback.Lines() + } + lines, err := nameLines(lines, fallback.Lines()) + if err != nil { + return nil, err + } + return buildOrder(id, KindOnline, date, total, onlineCardCharge(o.Payments, total), lines, o), nil +} + +// convertInvoice turns one charge of an online order charged in parts into an Order with the given +// id, dated by the charge. Its items are the invoice's lines priced at their effective amount (after +// discounts, with their own tax), so only rounding and fees are left over as tax. Target's product +// type (and a name missing from the invoice) comes from the order's line for the same item. A line +// that isn't on the order is a fee (Target adds the bag fee as a PAPER_BAG line), not an item. As +// elsewhere, gift-card and coupon shares are left out of the total. +func convertInvoice(inv *targetgo.Invoice, online *targetgo.OnlineOrder, id string) (*Order, error) { + types := map[string]targetgo.ProductClassification{} + for _, line := range online.Lines() { + if line.OrderLineKey != "" { + types[line.OrderLineKey] = line.Item.ProductClassification + } + } + lines := make([]targetgo.OrderLine, 0, len(inv.Lines)) + for _, l := range inv.Lines { + if _, onOrder := types[l.OrderLineKey]; !onOrder && len(types) > 0 { + continue + } + qty := l.Quantity + if qty <= 0 { + qty = 1 + } + item := l.Item + item.UnitPrice = l.EffectiveAmount / targetgo.Money(qty) + item.ProductClassification = types[l.OrderLineKey] + lines = append(lines, targetgo.OrderLine{OrderLineKey: l.OrderLineKey, Quantity: &qty, Item: item}) + } + tenders := make([]targetgo.PaymentTransaction, 0, len(inv.Payments)) + for _, p := range inv.Payments { + tenders = append(tenders, targetgo.PaymentTransaction{PaymentType: p.Type, PaymentSubType: p.SubType, Amount: p.TotalCharged}) + } + lines, err := nameLines(lines, online.Lines()) + if err != nil { + return nil, err + } + date := orderDate(firstNonEmpty(inv.Date, online.OrderDate)) + total := float64(inv.TotalAmount) + return buildOrder(id, KindOnline, date, total, cardCharge(tenders, total), lines, inv), nil +} + +// nameLines gives a line without a description the name of the same item (by TCIN) in other. Target +// sometimes sends an item line without its catalog fields (seen 2026-10-11 on a receipt whose history +// entry still had the name). A priced line left without a name is an error: buildOrder would drop it, +// and its price would end up in tax, split over the other items. The order then waits for the next run. +func nameLines(lines, other []targetgo.OrderLine) ([]targetgo.OrderLine, error) { + names := map[string]string{} + for _, l := range other { + if l.Item.TCIN != "" && l.Item.Name() != "" { + names[l.Item.TCIN] = l.Item.Description + } + } + named := make([]targetgo.OrderLine, len(lines)) + for i, l := range lines { + if l.Item.Name() == "" { + l.Item.Description = names[l.Item.TCIN] + } + if l.Item.Name() == "" && l.Qty() != 0 && l.Item.UnitPrice != 0 { + return nil, fmt.Errorf("target: item %s ($%.2f) came without a name", l.Item.TCIN, float64(l.Item.UnitPrice)*l.Qty()) + } + named[i] = l + } + return named, nil +} + +// buildOrder prices the items for the splitter. Item prices are the receipt's unit price times +// quantity, scaled by charge/total when only part of the total went on a card. The difference +// between the charge and the items (tax, minus any order-level discounts) is reported as tax, which +// the splitter spreads over the categories in proportion to their items. +// +// Lines priced $0 (paper bags) or with quantity 0 (cancelled) are dropped. If no line has a price, the items are merged into one +// item carrying the whole charge, so the order still gets a category and a note listing everything. +func buildOrder(id, kind string, date time.Time, total, charge float64, lines []targetgo.OrderLine, raw interface{}) *Order { + ratio := 1.0 + if total != 0 && charge != total { + ratio = charge / total + } + + var items []providers.OrderItem + var names []string + subtotal := 0.0 + for _, line := range lines { + name := line.Item.Name() + qty := line.Qty() + if name == "" || qty == 0 { + continue + } + names = append(names, name) + unit := float64(line.Item.UnitPrice) + if unit == 0 { + continue + } + price := roundCents(unit * qty * ratio) + subtotal += price + items = append(items, &OrderItem{ + name: name, + price: price, + quantity: qty, + unitPrice: roundCents(unit * ratio), + sku: line.Item.TCIN, + category: line.Item.ProductClassification.ProductTypeName, + }) + } + + if len(items) == 0 && len(names) > 0 { + joined := strings.Join(names, "; ") + items = []providers.OrderItem{&OrderItem{name: joined, price: charge, quantity: 1, unitPrice: charge}} + subtotal = charge + } + + subtotal = roundCents(subtotal) + return &Order{ + id: id, + kind: kind, + date: date, + total: roundCents(charge), + subtotal: subtotal, + tax: roundCents(charge - subtotal), + items: items, + raw: raw, + } +} + +// cardCharge returns the part of the receipt paid by card: not gift cards, cash, manufacturer +// coupons (MANUFACTURERCOUPON) or adjustments such as the reusable-bag discount +// (GENERALLEDGERADJUSTMENT / BAGDISCOUNT), which Target lists as tenders (see +// targetgo.IsCardTender). It's 0 if those paid it all; without tender amounts it's the total. +func cardCharge(tenders []targetgo.PaymentTransaction, total float64) float64 { + card, other := 0.0, 0.0 + for _, t := range tenders { + if t.IsCard() { + card += float64(t.Amount) + } else { + other += float64(t.Amount) + } + } + card = roundCents(card) + switch { + case card <= 0 && other > 0: + return 0 + case card <= 0 || card > total: + return total + } + return card +} + +// onlineCardCharge returns what an online order charged to cards: the total less what gift cards and +// other non-card tenders paid (0 if they paid it all). The card amounts aren't added up as for a +// receipt: Target lists an online order's payments net of later refunds, and a bag fee refunded days +// after the charge left them below what the bank was charged. +func onlineCardCharge(tenders []targetgo.PaymentTransaction, total float64) float64 { + other := 0.0 + for _, t := range tenders { + if !t.IsCard() { + other += float64(t.Amount) + } + } + return math.Max(0, roundCents(total-other)) +} + +// orderDate is the day an order was placed, as seen where itemize runs, at midnight UTC like the +// other providers' order dates. Target's timestamps carry numeric offsets (in-store receipts come in +// Central time even for California stores), and a time whose zone is only an offset is stored in the +// dedup DB as "… -0500 -0500", which the SQLite driver can't read back. +func orderDate(s string) time.Time { + t := targetgo.ParseTime(s) + if t.IsZero() { + return t + } + y, m, d := t.In(time.Local).Date() + return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} + +func firstNonZero(values ...float64) float64 { + for _, v := range values { + if v != 0 { + return v + } + } + return 0 +} + +func roundCents(v float64) float64 { + return math.Round(v*100) / 100 +} diff --git a/internal/adapters/providers/target/provider.go b/internal/adapters/providers/target/provider.go new file mode 100644 index 0000000..6947821 --- /dev/null +++ b/internal/adapters/providers/target/provider.go @@ -0,0 +1,256 @@ +package target + +import ( + "context" + "errors" + "fmt" + "log/slog" + "regexp" + "strings" + "time" + + targetgo "github.com/daslicious/target-go" + "github.com/eshaffer321/itemize/internal/adapters/providers" +) + +// API is the part of targetgo.Client the provider uses (replaced in tests). +type API interface { + OrderHistory(ctx context.Context, purchaseType string, page, pageSize int) (*targetgo.OrderHistoryResponse, error) + OnlineOrder(ctx context.Context, orderNumber string) (*targetgo.OnlineOrder, error) + StoreOrder(ctx context.Context, receiptID string) (*targetgo.StoreOrder, error) + Invoices(ctx context.Context, orderNumber string) (*targetgo.InvoiceList, error) + Invoice(ctx context.Context, orderNumber, invoiceID string) (*targetgo.Invoice, error) +} + +// Provider implements providers.OrderProvider for Target: in-store receipts (linked to the Target +// Circle account) and online orders. +type Provider struct { + api API + logger *slog.Logger + pageSize int + maxPages int +} + +// NewProvider creates a Target provider. +func NewProvider(api API, logger *slog.Logger) *Provider { + if logger == nil { + logger = slog.Default() + } + return &Provider{api: api, logger: logger, pageSize: 10, maxPages: 10} +} + +func (p *Provider) Name() string { return "target" } +func (p *Provider) DisplayName() string { return "Target" } +func (p *Provider) SupportsDeliveryTips() bool { return false } +func (p *Provider) SupportsRefunds() bool { return false } +func (p *Provider) SupportsBulkFetch() bool { return true } +func (p *Provider) GetRateLimit() time.Duration { return 3 * time.Second } + +// FetchOrders lists in-store and online orders placed between opts.StartDate and opts.EndDate and, +// with IncludeDetails, reads each one's items (an online order charged in parts gives one Order per +// charge). A rejected login or a bot challenge fails the whole fetch; any other error on a single +// order is logged and that order is left for the next run. +func (p *Provider) FetchOrders(ctx context.Context, opts providers.FetchOptions) ([]providers.Order, error) { + var orders []providers.Order + for _, purchaseType := range []string{targetgo.PurchaseStore, targetgo.PurchaseOnline} { + summaries, err := p.listSince(ctx, purchaseType, opts.StartDate) + if err != nil { + return nil, err + } + for _, s := range summaries { + if opts.MaxOrders > 0 && len(orders) >= opts.MaxOrders { + return orders, nil + } + if skip := p.skipReason(s, opts); skip != "" { + p.logger.Debug("skipping Target order", "order_id", s.ID(), "reason", skip) + continue + } + if !opts.IncludeDetails { + orders = append(orders, convertSummary(s, purchaseType)) + continue + } + parts, err := p.details(ctx, s, purchaseType) + if err != nil { + if fatal(err) { + return nil, err + } + p.logger.Error("failed to fetch Target order", "order_id", s.ID(), "error", err) + continue + } + for _, order := range parts { + orders = append(orders, order) + } + } + } + p.logger.Info("fetched Target orders", "count", len(orders)) + return orders, nil +} + +// laterCharge matches the id of a later charge of an online order charged in parts: -. +var laterCharge = regexp.MustCompile(`^([0-9]+)-[0-9]+$`) + +// GetOrderDetails fetches one order as the sync sees it: an in-store receipt, an online order, or one +// charge of an online order charged in parts. +func (p *Provider) GetOrderDetails(ctx context.Context, orderID string) (providers.Order, error) { + orders, err := p.FetchOrder(ctx, orderID) + if err != nil { + return nil, err + } + for _, order := range orders { + if order.GetID() == orderID { + return order, nil + } + } + if laterCharge.MatchString(orderID) { + return nil, fmt.Errorf("target: no charge %s (the order has %d charged to a card)", orderID, len(orders)) + } + if len(orders) == 0 || laterCharge.MatchString(orders[0].GetID()) { + return nil, fmt.Errorf("target: %s wasn't charged to a card (paid with gift cards, or cancelled)", orderID) + } + return orders[0], nil +} + +// FetchOrder fetches the order orderID belongs to: one Order, or one per charge for an online order +// charged in parts. Receipt ids are digit groups joined by dashes, online order numbers are digits, +// and later charges of an online order are -2, -3, ... +func (p *Provider) FetchOrder(ctx context.Context, orderID string) ([]providers.Order, error) { + summary, purchaseType := targetgo.OrderSummary{OrderNumber: orderID}, targetgo.PurchaseOnline + if m := laterCharge.FindStringSubmatch(orderID); m != nil { + summary.OrderNumber = m[1] + } else if strings.Contains(orderID, "-") { + summary, purchaseType = targetgo.OrderSummary{StoreReceiptID: orderID}, targetgo.PurchaseStore + } + parts, err := p.details(ctx, summary, purchaseType) + if err != nil { + return nil, err + } + orders := make([]providers.Order, len(parts)) + for i, order := range parts { + orders[i] = order + } + return orders, nil +} + +// HealthCheck reads the first page of online orders, which fails if the login has expired. +func (p *Provider) HealthCheck(ctx context.Context) error { + _, err := p.api.OrderHistory(ctx, targetgo.PurchaseOnline, 1, 1) + return err +} + +// details reads an order's items. An online order shipped in several packages can be charged once +// per shipment, and each charge is its own transaction in Monarch, so an order charged more than once +// is read from its invoices: one Order per charge, oldest first. The first keeps the order number as +// its id, so the id doesn't change from when the order had one charge; later charges are +// -2, -3, ... An order charged once (or not yet) is read whole: until a later shipment +// is charged, its total won't match the first charge, and it waits like an order not charged yet. +// Orders and charges paid without a card are left out (see charged). +func (p *Provider) details(ctx context.Context, s targetgo.OrderSummary, purchaseType string) ([]*Order, error) { + if purchaseType == targetgo.PurchaseStore { + receipt, err := p.api.StoreOrder(ctx, s.ID()) + if err != nil { + return nil, err + } + order, err := convertStore(receipt, s) + if err != nil { + return nil, err + } + return p.charged([]*Order{order}), nil + } + online, err := p.api.OnlineOrder(ctx, s.ID()) + if err != nil { + return nil, err + } + number := firstNonEmpty(online.OrderNumber, s.ID()) + list, err := p.api.Invoices(ctx, number) + if err != nil { + return nil, fmt.Errorf("reading the invoices of order %s: %w", number, err) + } + charges := list.Charges() + if len(charges) < 2 { + whole, err := convertOnline(online, s) + if err != nil { + return nil, err + } + return p.charged([]*Order{whole}), nil + } + + orders := make([]*Order, 0, len(charges)) + for i, charge := range charges { + invoice, err := p.api.Invoice(ctx, number, charge.ID) + if err != nil { + return nil, fmt.Errorf("reading invoice %s of order %s: %w", charge.ID, number, err) + } + id := number + if i > 0 { + id = fmt.Sprintf("%s-%d", number, i+1) + } + order, err := convertInvoice(invoice, online, id) + if err != nil { + return nil, fmt.Errorf("invoice %s of order %s: %w", charge.ID, number, err) + } + orders = append(orders, order) + } + p.logger.Debug("Target order charged in parts", "order_id", number, "charges", len(orders)) + return p.charged(orders), nil +} + +// charged leaves out the orders (or charges) nothing was charged to a card for: paid with gift cards, +// or cancelled. No bank transaction will ever match them. Later charges keep their ids. +func (p *Provider) charged(orders []*Order) []*Order { + kept := make([]*Order, 0, len(orders)) + for _, order := range orders { + if order.total <= 0 { + p.logger.Info("Target order not charged to a card, nothing to match", "order_id", order.id) + continue + } + kept = append(kept, order) + } + return kept +} + +// listSince pages through the order history (newest first) until it passes start. +func (p *Provider) listSince(ctx context.Context, purchaseType string, start time.Time) ([]targetgo.OrderSummary, error) { + all, complete, err := targetgo.HistorySince(ctx, p.api, purchaseType, start, p.pageSize, p.maxPages) + if err != nil { + return nil, fmt.Errorf("listing Target %s orders: %w", strings.ToLower(purchaseType), err) + } + if !complete { + p.logger.Warn("stopped listing Target orders at the page limit", "purchase_type", purchaseType, "pages", p.maxPages) + } + return all, nil +} + +func (p *Provider) skipReason(s targetgo.OrderSummary, opts providers.FetchOptions) string { + if s.ID() == "" { + return "no order id" + } + if strings.Contains(strings.ToLower(s.OrderType), "return") { + return "return" + } + placed := targetgo.ParseTime(s.Date()) + if placed.IsZero() { + return "unreadable date " + s.Date() + } + if placed.Before(opts.StartDate) || (!opts.EndDate.IsZero() && placed.After(opts.EndDate)) { + return "outside date range" + } + if s.Summary.GrandTotal <= 0 && len(s.Lines()) == 0 { + return "no total" + } + return "" +} + +// convertSummary builds an Order from a history entry alone (no item prices). +func convertSummary(s targetgo.OrderSummary, purchaseType string) *Order { + kind := KindOnline + if purchaseType == targetgo.PurchaseStore { + kind = KindStore + } + total := float64(s.Summary.GrandTotal) + return buildOrder(s.ID(), kind, orderDate(s.Date()), total, total, s.Lines(), s) +} + +func fatal(err error) bool { + return errors.Is(err, targetgo.ErrNotAuthenticated) || errors.Is(err, targetgo.ErrBlocked) || + errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) +} diff --git a/internal/adapters/providers/target/provider_test.go b/internal/adapters/providers/target/provider_test.go new file mode 100644 index 0000000..23119fd --- /dev/null +++ b/internal/adapters/providers/target/provider_test.go @@ -0,0 +1,710 @@ +package target + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + targetgo "github.com/daslicious/target-go" + "github.com/eshaffer321/itemize/internal/adapters/providers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The testdata files are synthetic: they follow the response shapes other projects recorded from +// target.com in 2026, not captured responses. + +func loadJSON(t *testing.T, name string, out any) { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", name)) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(data, out)) +} + +type fakeAPI struct { + history map[string][]*targetgo.OrderHistoryResponse // purchase type -> pages + online map[string]*targetgo.OnlineOrder + store map[string]*targetgo.StoreOrder + invoices map[string]*targetgo.InvoiceList // order number -> its invoices; none if missing + invoice map[string]*targetgo.Invoice // invoice id -> details + detailErr map[string]error + calls []string +} + +func (f *fakeAPI) OrderHistory(_ context.Context, purchaseType string, page, _ int) (*targetgo.OrderHistoryResponse, error) { + f.calls = append(f.calls, fmt.Sprintf("history %s %d", purchaseType, page)) + pages := f.history[purchaseType] + if page > len(pages) { + return &targetgo.OrderHistoryResponse{}, nil + } + return pages[page-1], nil +} + +func (f *fakeAPI) OnlineOrder(_ context.Context, id string) (*targetgo.OnlineOrder, error) { + f.calls = append(f.calls, "online "+id) + if err := f.detailErr[id]; err != nil { + return nil, err + } + return f.online[id], nil +} + +func (f *fakeAPI) StoreOrder(_ context.Context, id string) (*targetgo.StoreOrder, error) { + f.calls = append(f.calls, "store "+id) + if err := f.detailErr[id]; err != nil { + return nil, err + } + return f.store[id], nil +} + +func (f *fakeAPI) Invoices(_ context.Context, orderNumber string) (*targetgo.InvoiceList, error) { + f.calls = append(f.calls, "invoices "+orderNumber) + if list := f.invoices[orderNumber]; list != nil { + return list, nil + } + return &targetgo.InvoiceList{}, nil +} + +func (f *fakeAPI) Invoice(_ context.Context, orderNumber, invoiceID string) (*targetgo.Invoice, error) { + f.calls = append(f.calls, "invoice "+orderNumber+" "+invoiceID) + if err := f.detailErr[invoiceID]; err != nil { + return nil, err + } + if inv := f.invoice[invoiceID]; inv != nil { + return inv, nil + } + return nil, fmt.Errorf("target: HTTP 404 on invoice %s", invoiceID) +} + +func fixtureAPI(t *testing.T) *fakeAPI { + var storeHistory, onlineHistory targetgo.OrderHistoryResponse + var store targetgo.StoreOrder + var online targetgo.OnlineOrder + loadJSON(t, "history_store.json", &storeHistory) + loadJSON(t, "history_online.json", &onlineHistory) + loadJSON(t, "store_order.json", &store) + loadJSON(t, "online_order.json", &online) + return &fakeAPI{ + history: map[string][]*targetgo.OrderHistoryResponse{targetgo.PurchaseStore: {&storeHistory}, targetgo.PurchaseOnline: {&onlineHistory}}, + online: map[string]*targetgo.OnlineOrder{online.OrderNumber: &online}, + store: map[string]*targetgo.StoreOrder{store.StoreReceiptID: &store}, + } +} + +var window = providers.FetchOptions{ + StartDate: time.Date(2026, 9, 9, 0, 0, 0, 0, time.UTC), + EndDate: time.Date(2026, 10, 9, 23, 0, 0, 0, time.UTC), + IncludeDetails: true, +} + +func itemSummary(order providers.Order) []string { + var out []string + for _, item := range order.GetItems() { + out = append(out, fmt.Sprintf("%s x%g $%.2f [%s]", item.GetName(), item.GetQuantity(), item.GetPrice(), item.GetCategory())) + } + return out +} + +func TestFetchOrders(t *testing.T) { + api := fixtureAPI(t) + orders, err := NewProvider(api, nil).FetchOrders(context.Background(), window) + require.NoError(t, err) + + // The return and the July receipt are skipped without reading their details. + assert.Equal(t, []string{ + "history STORE 1", "store 6032-2290-0172-8918", + "history ONLINE 1", "online 912002749053897", "invoices 912002749053897", + }, api.calls) + require.Len(t, orders, 2) + + store := orders[0] + assert.Equal(t, "6032-2290-0172-8918", store.GetID()) + assert.Equal(t, "Target", store.GetProviderName()) + assert.Equal(t, KindStore, store.(*Order).Kind()) + assert.Equal(t, "2026-09-26", store.GetDate().Format("2006-01-02")) + assert.InDelta(t, 24.42, store.GetTotal(), 1e-9) + assert.InDelta(t, 22.77, store.GetSubtotal(), 1e-9) + assert.InDelta(t, 1.65, store.GetTax(), 1e-9) + assert.Equal(t, []string{ + "Whole Wheat Penne Pasta - 16oz x2 $6.98 [GROCERY]", + "Men's Crew Socks 6pk - Gray x1 $12.00 [APPAREL]", + "Freezer Storage Bags - 40ct - up&up™ x1 $3.79 [HOME]", + }, itemSummary(store), "paper bag dropped, entities decoded") + + online := orders[1] + assert.Equal(t, "912002749053897", online.GetID()) + assert.Equal(t, KindOnline, online.(*Order).Kind()) + assert.Equal(t, "2026-09-20", online.GetDate().Format("2006-01-02")) + assert.InDelta(t, 35.67, online.GetTotal(), 1e-9) + assert.InDelta(t, 2.19, online.GetTax(), 1e-9) + assert.Equal(t, []string{ + "LEGO City F1 Williams Racing & Haas F1 Race Cars 60464 x1 $19.99 []", + "Big Coloring Book x1 $13.49 []", + }, itemSummary(online), "items from both packages, markup stripped") +} + +func TestFetchOrdersPaging(t *testing.T) { + page := func(dates ...string) *targetgo.OrderHistoryResponse { + resp := &targetgo.OrderHistoryResponse{TotalPages: 5} + for i, d := range dates { + resp.Orders = append(resp.Orders, targetgo.OrderSummary{OrderNumber: fmt.Sprintf("9120%s%d", d[5:7]+d[8:10], i), PlacedDate: d, Summary: targetgo.Totals{GrandTotal: 10}}) + } + return resp + } + api := &fakeAPI{history: map[string][]*targetgo.OrderHistoryResponse{targetgo.PurchaseOnline: { + page("2026-10-01", "2026-09-25"), + page("2026-09-12", "2026-09-01"), // passes the start date: stop here + page("2026-08-20", "2026-08-10"), + }}} + p := NewProvider(api, nil) + p.pageSize = 2 + + opts := window + opts.IncludeDetails = false + orders, err := p.FetchOrders(context.Background(), opts) + require.NoError(t, err) + assert.Equal(t, []string{"history STORE 1", "history ONLINE 1", "history ONLINE 2"}, api.calls) + assert.Len(t, orders, 3) + + opts.MaxOrders = 2 + orders, err = p.FetchOrders(context.Background(), opts) + require.NoError(t, err) + assert.Len(t, orders, 2) +} + +func TestFetchOrdersErrors(t *testing.T) { + t.Run("one bad order is skipped", func(t *testing.T) { + api := fixtureAPI(t) + api.detailErr = map[string]error{"6032-2290-0172-8918": errors.New("target: HTTP 500")} + orders, err := NewProvider(api, nil).FetchOrders(context.Background(), window) + require.NoError(t, err) + require.Len(t, orders, 1) + assert.Equal(t, "912002749053897", orders[0].GetID()) + }) + + for _, fatalErr := range []error{targetgo.ErrNotAuthenticated, targetgo.ErrBlocked} { + t.Run(fatalErr.Error(), func(t *testing.T) { + api := fixtureAPI(t) + api.detailErr = map[string]error{"6032-2290-0172-8918": fmt.Errorf("%w (HTTP 401)", fatalErr)} + _, err := NewProvider(api, nil).FetchOrders(context.Background(), window) + assert.True(t, errors.Is(err, fatalErr)) + }) + } +} + +func TestGetOrderDetails(t *testing.T) { + api := fixtureAPI(t) + p := NewProvider(api, nil) + store, err := p.GetOrderDetails(context.Background(), "6032-2290-0172-8918") + require.NoError(t, err) + assert.InDelta(t, 24.42, store.GetTotal(), 1e-9) + online, err := p.GetOrderDetails(context.Background(), "912002749053897") + require.NoError(t, err) + assert.Len(t, online.GetItems(), 2) + assert.Equal(t, []string{"store 6032-2290-0172-8918", "online 912002749053897", "invoices 912002749053897"}, api.calls) +} + +// splitAPI serves an online order shipped in two packages and charged once per shipment. +func splitAPI(t *testing.T) *fakeAPI { + var order targetgo.OnlineOrder + var list targetgo.InvoiceList + var first, second targetgo.Invoice + loadJSON(t, "split_order.json", &order) + loadJSON(t, "split_invoices.json", &list) + loadJSON(t, "split_invoice_2001.json", &first) + loadJSON(t, "split_invoice_2002.json", &second) + summary := targetgo.OrderSummary{OrderNumber: order.OrderNumber, OrderDate: order.OrderDate, Summary: order.Summary, Packages: order.Packages} + return &fakeAPI{ + history: map[string][]*targetgo.OrderHistoryResponse{targetgo.PurchaseOnline: {{Orders: []targetgo.OrderSummary{summary}}}}, + online: map[string]*targetgo.OnlineOrder{order.OrderNumber: &order}, + invoices: map[string]*targetgo.InvoiceList{order.OrderNumber: &list}, + invoice: map[string]*targetgo.Invoice{"2001": &first, "2002": &second}, + } +} + +func TestOrderChargedPerShipment(t *testing.T) { + api := splitAPI(t) + orders, err := NewProvider(api, nil).FetchOrders(context.Background(), window) + require.NoError(t, err) + + assert.Equal(t, []string{ + "history STORE 1", "history ONLINE 1", "online 912002800000001", "invoices 912002800000001", + "invoice 912002800000001 2001", "invoice 912002800000001 2002", + }, api.calls, "invoices read oldest first; the return is not read") + require.Len(t, orders, 2) + + first := orders[0] + assert.Equal(t, "912002800000001", first.GetID(), "the first charge keeps the order number") + assert.Equal(t, KindOnline, first.(*Order).Kind()) + assert.Equal(t, "2026-09-21", first.GetDate().Format("2006-01-02"), "dated when it was charged") + assert.InDelta(t, 25.82, first.GetTotal(), 1e-9) + assert.InDelta(t, 25.62, first.GetSubtotal(), 1e-9) + assert.InDelta(t, 0.20, first.GetTax(), 1e-9, "the bag fee is spread like tax") + assert.Equal(t, []string{ + "LEGO City F1 Williams Racing & Haas F1 Race Cars 60464 x1 $21.54 [TOYS]", + "Freezer Storage Bags - 40ct - up&up™ x1 $4.08 [HOME]", + }, itemSummary(first), "items priced with their own tax, typed from the order lines; the bag fee line isn't an item") + + second := orders[1] + assert.Equal(t, "912002800000001-2", second.GetID()) + assert.Equal(t, "2026-10-02", second.GetDate().Format("2006-01-02")) + assert.InDelta(t, 46.87, second.GetTotal(), 1e-9) + assert.InDelta(t, 0, second.GetTax(), 1e-9) + assert.Equal(t, []string{ + "Big Coloring Book x1 $14.54 [BOOKS]", + "Boys' Pajama Set - Blue 8 x2 $32.33 [APPAREL]", + }, itemSummary(second)) +} + +func TestOrderChargedInParts(t *testing.T) { + t.Run("an order charged once is read whole", func(t *testing.T) { + // A digital manufacturer coupon is a tender on the invoice, so the invoice total is the + // order total plus the coupon: one charge is read whole whatever its amount. + api := splitAPI(t) + api.invoices["912002800000001"] = &targetgo.InvoiceList{Invoices: []targetgo.InvoiceSummary{{ID: "2000", Type: "SHIPMENT", Date: "2026-09-21T15:00:00.000Z", Amount: 73.69}}} + orders, err := NewProvider(api, nil).FetchOrders(context.Background(), window) + require.NoError(t, err) + require.Len(t, orders, 1) + assert.Equal(t, "912002800000001", orders[0].GetID()) + assert.InDelta(t, 72.69, orders[0].GetTotal(), 1e-9) + assert.Len(t, orders[0].GetItems(), 4) + assert.NotContains(t, api.calls, "invoice 912002800000001 2000") + }) + + t.Run("a coupon's share left out of a charge", func(t *testing.T) { + api := splitAPI(t) + api.invoice["2002"].Payments = []targetgo.InvoicePayment{ + {Type: "MANUFACTURERCOUPON", SubType: "DIGITAL", TotalCharged: 1}, + {Type: "TARGETDEBIT", SubType: "NONE", TotalCharged: 45.87}, + } + orders, err := NewProvider(api, nil).FetchOrders(context.Background(), window) + require.NoError(t, err) + require.Len(t, orders, 2) + assert.InDelta(t, 45.87, orders[1].GetTotal(), 1e-9) + }) + + t.Run("gift card share left out of a charge", func(t *testing.T) { + api := splitAPI(t) + api.invoice["2002"].Payments = []targetgo.InvoicePayment{ + {Type: "GIFTCARD", TotalCharged: 10}, + {Type: "VISA", TotalCharged: 36.87}, + } + orders, err := NewProvider(api, nil).FetchOrders(context.Background(), window) + require.NoError(t, err) + require.Len(t, orders, 2) + second := orders[1] + assert.InDelta(t, 36.87, second.GetTotal(), 1e-9) + sum := 0.0 + for _, item := range second.GetItems() { + sum += item.GetPrice() + } + assert.InDelta(t, second.GetSubtotal(), sum, 0.005) + assert.InDelta(t, second.GetTotal(), second.GetSubtotal()+second.GetTax(), 0.005) + }) + + t.Run("an unreadable invoice fails the order", func(t *testing.T) { + api := splitAPI(t) + api.detailErr = map[string]error{"2002": errors.New("target: HTTP 500")} + orders, err := NewProvider(api, nil).FetchOrders(context.Background(), window) + require.NoError(t, err) + assert.Empty(t, orders, "neither charge is synced until both can be read") + + api.detailErr = map[string]error{"2002": fmt.Errorf("%w (HTTP 401)", targetgo.ErrNotAuthenticated)} + _, err = NewProvider(api, nil).FetchOrders(context.Background(), window) + assert.ErrorIs(t, err, targetgo.ErrNotAuthenticated) + }) +} + +func TestGetOrderDetailsCharge(t *testing.T) { + p := NewProvider(splitAPI(t), nil) + ctx := context.Background() + + second, err := p.GetOrderDetails(ctx, "912002800000001-2") + require.NoError(t, err) + assert.Equal(t, "912002800000001-2", second.GetID()) + assert.InDelta(t, 46.87, second.GetTotal(), 1e-9) + + first, err := p.GetOrderDetails(ctx, "912002800000001") + require.NoError(t, err) + assert.InDelta(t, 25.82, first.GetTotal(), 1e-9) + + _, err = p.GetOrderDetails(ctx, "912002800000001-3") + assert.Error(t, err) + + all, err := p.FetchOrder(ctx, "912002800000001-2") + require.NoError(t, err) + assert.Len(t, all, 2, "every charge of the order") +} + +// Target sometimes sends an item line without its catalog fields (description, product type), as on +// a receipt read on 2026-10-11 whose history entry still had the name. Dropping the line would put its +// price into tax and split it over the other items. +func TestItemWithoutName(t *testing.T) { + const receipt = "6032-2290-0172-8918" + unnamed := targetgo.Item{TCIN: "54191097", UnitPrice: 12} + + t.Run("named from the history entry", func(t *testing.T) { + api := fixtureAPI(t) + api.store[receipt].OrderLines[1].Item = unnamed + api.history[targetgo.PurchaseStore][0].Orders[0].OrderLines = []targetgo.OrderLine{ + {Item: targetgo.Item{TCIN: "54191097", Description: "Men's Crew Socks 6pk - Gray"}}, + } + orders, err := NewProvider(api, nil).FetchOrders(context.Background(), window) + require.NoError(t, err) + require.Len(t, orders, 2) + assert.Equal(t, receipt, orders[0].GetID()) + assert.InDelta(t, 1.65, orders[0].GetTax(), 1e-9) + assert.Equal(t, []string{ + "Whole Wheat Penne Pasta - 16oz x2 $6.98 [GROCERY]", + "Men's Crew Socks 6pk - Gray x1 $12.00 []", + "Freezer Storage Bags - 40ct - up&up™ x1 $3.79 [HOME]", + }, itemSummary(orders[0])) + }) + + t.Run("an order with an unnamed item waits for the next run", func(t *testing.T) { + api := fixtureAPI(t) + api.store[receipt].OrderLines[1].Item = unnamed + p := NewProvider(api, nil) + orders, err := p.FetchOrders(context.Background(), window) + require.NoError(t, err) + require.Len(t, orders, 1) + assert.Equal(t, "912002749053897", orders[0].GetID()) + + _, err = p.GetOrderDetails(context.Background(), receipt) + assert.ErrorContains(t, err, "item 54191097 ($12.00) came without a name") + }) + + t.Run("unpriced and cancelled lines need no name", func(t *testing.T) { + api := fixtureAPI(t) + zero := 0.0 + lines := api.store[receipt].OrderLines + lines[2].Item.Description = "" + api.store[receipt].OrderLines = append(lines, targetgo.OrderLine{Quantity: &zero, Item: targetgo.Item{TCIN: "5", UnitPrice: 9}}) + order, err := NewProvider(api, nil).GetOrderDetails(context.Background(), receipt) + require.NoError(t, err) + assert.Len(t, order.GetItems(), 3) + }) + + t.Run("an online order line named from the history entry", func(t *testing.T) { + api := fixtureAPI(t) + api.online["912002749053897"].Packages[0].OrderLines[0].Item = targetgo.Item{TCIN: "89120321", UnitPrice: 19.99} + _, err := NewProvider(api, nil).GetOrderDetails(context.Background(), "912002749053897") + require.Error(t, err, "nothing to name it from without the history entry") + + orders, err := NewProvider(api, nil).FetchOrders(context.Background(), window) + require.NoError(t, err) + require.Len(t, orders, 2) + assert.Equal(t, []string{ + "LEGO City F1 Williams Racing & Haas F1 Race Cars 60464 x1 $19.99 []", + "Big Coloring Book x1 $13.49 []", + }, itemSummary(orders[1])) + }) + + t.Run("an invoice line named from its order line", func(t *testing.T) { + api := splitAPI(t) + api.invoice["2002"].Lines[0].Item.Description = "" + orders, err := NewProvider(api, nil).FetchOrders(context.Background(), window) + require.NoError(t, err) + require.Len(t, orders, 2) + assert.Equal(t, []string{ + "Big Coloring Book x1 $14.54 [BOOKS]", + "Boys' Pajama Set - Blue 8 x2 $32.33 [APPAREL]", + }, itemSummary(orders[1])) + + api.online["912002800000001"].Packages[1].OrderLines[0].Item.Description = "" + _, err = NewProvider(api, nil).GetOrderDetails(context.Background(), "912002800000001-2") + assert.ErrorContains(t, err, "item 77777777 ($14.54) came without a name") + }) + + t.Run("a name missing only from an order charged in parts", func(t *testing.T) { + // The charges are read from the invoices, which have every name. + api := splitAPI(t) + api.online["912002800000001"].Packages[0].OrderLines[0].Item.Description = "" + orders, err := NewProvider(api, nil).FetchOrders(context.Background(), window) + require.NoError(t, err) + assert.Len(t, orders, 2) + }) +} + +func TestGiftCardShareLeavesCardCharge(t *testing.T) { + var receipt targetgo.StoreOrder + loadJSON(t, "store_order.json", &receipt) + receipt.PaymentTransactions = []targetgo.PaymentTransaction{ + {PaymentType: "GIFT_CARD", Amount: 4.42}, + {PaymentType: "CREDIT_CARD", PaymentSubType: "VISA", Amount: 20.00}, + } + order, err := convertStore(&receipt, targetgo.OrderSummary{}) + require.NoError(t, err) + assert.InDelta(t, 20.00, order.GetTotal(), 1e-9, "Monarch sees the card charge") + + receipt.PaymentTransactions = []targetgo.PaymentTransaction{ + {PaymentType: "MANUFACTURERCOUPON", Amount: 1.00}, + {PaymentType: "TARGETDEBIT", Amount: 23.42}, + } + couponed, err := convertStore(&receipt, targetgo.OrderSummary{}) + require.NoError(t, err) + assert.InDelta(t, 23.42, couponed.GetTotal(), 1e-9, "a coupon isn't a card charge") + + sum := 0.0 + for _, item := range order.GetItems() { + sum += item.GetPrice() + } + assert.InDelta(t, order.GetSubtotal(), sum, 0.005) + assert.InDelta(t, order.GetTotal(), order.GetSubtotal()+order.GetTax(), 0.005) + assert.Less(t, order.GetSubtotal(), 22.77) +} + +// Bug (found 2026-10-11 in a May receipt): the reusable-bag discount is taken after the total and +// listed as a tender, GENERALLEDGERADJUSTMENT / BAGDISCOUNT. It was counted as card money, so the +// order's total was grand_total, 15 cents more than the card charge, and it could never match. +func TestBagDiscountIsNotACardCharge(t *testing.T) { + var receipt targetgo.StoreOrder + loadJSON(t, "store_order.json", &receipt) + require.NoError(t, json.Unmarshal([]byte(`[ + {"amount": "0.15", "payment_type": "GENERALLEDGERADJUSTMENT", "payment_sub_type": "BAGDISCOUNT", "payment_sub_type_value": "Reusable Bag Discount"}, + {"amount": "24.27", "payment_type": "TARGETDEBIT", "payment_sub_type": "NONE", "payment_sub_type_value": "Target Circle Debit Card"} + ]`), &receipt.PaymentTransactions)) + order, err := convertStore(&receipt, targetgo.OrderSummary{}) + require.NoError(t, err) + assert.InDelta(t, 24.27, order.GetTotal(), 1e-9, "Monarch sees the card charge") + assert.InDelta(t, order.GetTotal(), order.GetSubtotal()+order.GetTax(), 0.005) +} + +// Bug (found 2026-10-13 by reading the whole order history): when Target cancels part of an online +// order (out of stock at the store, or the guest declined a later date), the cancelled items move to +// a package of their own with status STAT_CANCELED, and their quantity stays at 1. They were synced as +// bought: listed in the notes and given a share of the charge. 15 of 94 online orders had one. +func TestCancelledPackageIsLeftOut(t *testing.T) { + var order targetgo.OnlineOrder + require.NoError(t, json.Unmarshal([]byte(`{ + "order_number": "912002900000001", "order_date": "2026-09-20T18:00:00.000Z", + "summary": {"grand_total": "26.75", "total_product_price": "25.00", "total_taxes": "1.75"}, + "payments": [{"payment_type": "TARGETDEBIT", "payment_subtype": "NONE", "amount": "26.75"}], + "packages": [ + {"grouping_metadata": {"status": "STAT_ORDER_PICKED_UP"}, "fulfillment": {"status": {"key": "STAT_ORDER_PICKED_UP", "code": "3700.003"}}, + "order_lines": [{"order_line_key": "k1", "quantity": 1, "original_quantity": 1, + "item": {"tcin": "11111111", "description": "Ceramic Coffee Mug", "unit_price": 25.00, "product_classification": {"product_type_name": "APPAREL"}}}]}, + {"grouping_metadata": {"status": "STAT_CANCELED", "cancellation": {"cancel_reason_code": "CANCEL_REPROMTIMEEXCEEDED", "cancel_reason_code_description": "Out of stock at selected store"}}, + "fulfillment": {"status": {"key": "STAT_CANCELED", "code": "9000"}}, + "order_lines": [{"order_line_key": "k2", "quantity": 1, "original_quantity": 1, "cancellation": {"cancel_reason_code": "CANCEL_REPROMTIMEEXCEEDED"}, + "item": {"tcin": "22222222", "description": "Glass Food Storage Set", "unit_price": 19.99, "product_classification": {"product_type_name": "HOME"}}}]} + ]}`), &order)) + + o, err := convertOnline(&order, targetgo.OrderSummary{}) + require.NoError(t, err) + assert.Equal(t, []string{"Ceramic Coffee Mug x1 $25.00 [APPAREL]"}, itemSummary(o)) + assert.InDelta(t, 26.75, o.GetTotal(), 1e-9) + assert.InDelta(t, 1.75, o.GetTax(), 1e-9) +} + +// Bug (found 2026-10-13): an online order's payments are listed net of later refunds. When Target +// refunded a pickup order's bag fee days after charging it, the card payment read $83.00 while the +// bank had been charged the full $83.40 (7 orders from 2024-10 to 2025-04), so the order could never +// match. What the card was charged is the total less what gift cards paid (71 of 72 orders matched +// Monarch this way, against 65 using the card payment). +func TestOnlineChargeIsTotalLessGiftCards(t *testing.T) { + var order targetgo.OnlineOrder + loadJSON(t, "online_order.json", &order) + + order.Payments = []targetgo.PaymentTransaction{{PaymentType: "TARGETDEBIT", PaymentSubtype: "NONE", Amount: 35.17}} + o, err := convertOnline(&order, targetgo.OrderSummary{}) + require.NoError(t, err) + assert.InDelta(t, 35.67, o.GetTotal(), 1e-9, "a refund 50 cents later doesn't lower the charge") + + order.Payments = []targetgo.PaymentTransaction{ + {PaymentType: "TARGETDEBIT", PaymentSubtype: "NONE", Amount: 25.17}, + {PaymentType: "TARGETGIFTCARD", PaymentSubtype: "NONE", Amount: 10}, + } + o, err = convertOnline(&order, targetgo.OrderSummary{}) + require.NoError(t, err) + assert.InDelta(t, 25.67, o.GetTotal(), 1e-9, "the gift card's share is still left out") +} + +// Bug (found 2026-10-13): an order paid entirely with gift cards has no card charge, but its total was +// taken as the amount to match, so it waited for a bank transaction that never comes and could +// take another Target charge of the same amount. The history had 13 such orders +// and charges, and a cancelled online order with a $0 total that would have waited the same way. +func TestOrdersNotChargedToACardAreLeftOut(t *testing.T) { + giftCard := func(amount float64) []targetgo.PaymentTransaction { + return []targetgo.PaymentTransaction{{PaymentType: "TARGETGIFTCARD", PaymentSubType: "NONE", Amount: targetgo.Money(amount)}} + } + + t.Run("a receipt paid with a gift card", func(t *testing.T) { + api := fixtureAPI(t) + api.store["6032-2290-0172-8918"].PaymentTransactions = giftCard(24.42) + p := NewProvider(api, nil) + orders, err := p.FetchOrders(context.Background(), window) + require.NoError(t, err) + require.Len(t, orders, 1) + assert.Equal(t, "912002749053897", orders[0].GetID()) + + _, err = p.GetOrderDetails(context.Background(), "6032-2290-0172-8918") + assert.ErrorContains(t, err, "card") + }) + + t.Run("an online order paid with a gift card", func(t *testing.T) { + api := fixtureAPI(t) + api.online["912002749053897"].Payments = giftCard(35.67) + orders, err := NewProvider(api, nil).FetchOrders(context.Background(), window) + require.NoError(t, err) + require.Len(t, orders, 1) + assert.Equal(t, "6032-2290-0172-8918", orders[0].GetID()) + }) + + t.Run("a cancelled online order", func(t *testing.T) { + api := fixtureAPI(t) + order := api.online["912002749053897"] + order.Summary = targetgo.Totals{} + order.Payments = []targetgo.PaymentTransaction{{PaymentType: "TARGETDEBIT", PaymentSubtype: "NONE", Amount: 6.12}} + for i, entry := range api.history[targetgo.PurchaseOnline][0].Orders { + if entry.OrderNumber == order.OrderNumber { + api.history[targetgo.PurchaseOnline][0].Orders[i].Summary = targetgo.Totals{} + } + } + orders, err := NewProvider(api, nil).FetchOrders(context.Background(), window) + require.NoError(t, err) + require.Len(t, orders, 1) + assert.Equal(t, "6032-2290-0172-8918", orders[0].GetID()) + }) + + t.Run("a charge paid with a gift card", func(t *testing.T) { + api := splitAPI(t) + api.invoice["2001"].Payments = []targetgo.InvoicePayment{{Type: "TARGETGIFTCARD", SubType: "NONE", TotalCharged: 25.82}} + p := NewProvider(api, nil) + orders, err := p.FetchOrders(context.Background(), window) + require.NoError(t, err) + require.Len(t, orders, 1) + assert.Equal(t, "912002800000001-2", orders[0].GetID(), "the other charge keeps its id") + assert.InDelta(t, 46.87, orders[0].GetTotal(), 1e-9) + + _, err = p.GetOrderDetails(context.Background(), "912002800000001") + assert.Error(t, err) + }) +} + +func TestUnpricedItemsMerge(t *testing.T) { + var history targetgo.OrderHistoryResponse + loadJSON(t, "history_online.json", &history) + order := convertSummary(history.Orders[0], targetgo.PurchaseOnline) + require.Len(t, order.GetItems(), 1) + item := order.GetItems()[0] + assert.Equal(t, "LEGO City F1 Williams Racing & Haas F1 Race Cars 60464", item.GetName()) + assert.InDelta(t, 35.67, item.GetPrice(), 1e-9) + assert.InDelta(t, 0, order.GetTax(), 1e-9) +} + +// The sample_*.json files keep the shape of target.com's responses (cut down to the fields the +// parser reads, with Target's HTML entities and string amounts); ids, stores, dates and items are made up. + +func TestSampleStoreReceipt(t *testing.T) { + var receipt targetgo.StoreOrder + loadJSON(t, "sample_store_order.json", &receipt) + order, err := convertStore(&receipt, targetgo.OrderSummary{}) + require.NoError(t, err) + + assert.Equal(t, "6000-1234-0000-0001", order.GetID()) + assert.Equal(t, "2026-09-24", order.GetDate().Format("2006-01-02")) + assert.InDelta(t, 20.46, order.GetTotal(), 1e-9, "charged to the Target Circle Debit Card") + assert.InDelta(t, 19.99, order.GetSubtotal(), 1e-9) + assert.InDelta(t, 0.47, order.GetTax(), 1e-9, "$1.47 tax less the $1.00 Circle Card discount") + assert.Equal(t, []string{"Kid's Building Blocks Set - 100pc x1 $19.99 [TOYS]"}, itemSummary(order)) +} + +func TestSampleOnlineOrder(t *testing.T) { + var online targetgo.OnlineOrder + loadJSON(t, "sample_online_order.json", &online) + order, err := convertOnline(&online, targetgo.OrderSummary{}) + require.NoError(t, err) + + assert.Equal(t, "102000000000001", order.GetID()) + assert.Equal(t, "2026-08-05", order.GetDate().Format("2006-01-02")) + assert.InDelta(t, 22.72, order.GetTotal(), 1e-9) + assert.InDelta(t, 23.92, order.GetSubtotal(), 1e-9) + assert.InDelta(t, -1.20, order.GetTax(), 1e-9, "no tax on groceries, 5% Circle Card discount") + assert.Equal(t, []string{ + "Sliced Sharp Cheddar Cheese - 8oz - Good & Gather™ x2 $7.18 [GROCERY]", + "Gala Apples - 3lb Bag x2 $8.38 [GROCERY]", + "Organic Baby Spinach - 5oz - Good & Gather™ x3 $5.37 [GROCERY]", + "Fresh Blueberries - 1pt x1 $2.99 [GROCERY]", + }, itemSummary(order)) +} + +func TestSampleInvoice(t *testing.T) { + var online targetgo.OnlineOrder + var list targetgo.InvoiceList + var invoice targetgo.Invoice + loadJSON(t, "sample_online_order.json", &online) + loadJSON(t, "sample_invoices.json", &list) + loadJSON(t, "sample_invoice.json", &invoice) + + require.Len(t, list.Invoices, 1) + assert.Equal(t, "62000000000000001", list.Invoices[0].ID) + assert.InDelta(t, 22.72, float64(list.Invoices[0].Amount), 1e-9) + + order, err := convertInvoice(&invoice, &online, "102000000000001") + require.NoError(t, err) + assert.Equal(t, "102000000000001", order.GetID()) + assert.Equal(t, "2026-08-05", order.GetDate().Format("2006-01-02")) + assert.InDelta(t, 22.72, order.GetTotal(), 1e-9) + assert.InDelta(t, 22.72, order.GetSubtotal(), 1e-9) + assert.InDelta(t, 0, order.GetTax(), 1e-9) + assert.Equal(t, []string{ + "Fresh Blueberries - 1pt x1 $2.84 [GROCERY]", + "Organic Baby Spinach - 5oz - Good & Gather™ x3 $5.10 [GROCERY]", + "Gala Apples - 3lb Bag x2 $7.96 [GROCERY]", + "Sliced Sharp Cheddar Cheese - 8oz - Good & Gather™ x2 $6.82 [GROCERY]", + }, itemSummary(order), "5% Circle Card discount already taken off each line") +} + +func TestSampleHistories(t *testing.T) { + var online, store targetgo.OrderHistoryResponse + loadJSON(t, "sample_history_online.json", &online) + loadJSON(t, "sample_history_store.json", &store) + + require.Len(t, online.Orders, 3) + first := online.Orders[0] + assert.Equal(t, "102000000000001", first.ID()) + assert.Equal(t, "2026-08-05", targetgo.ParseTime(first.Date()).Format("2006-01-02")) + assert.Len(t, first.Lines(), 4, "online history keeps lines in packages") + assert.InDelta(t, 22.72, float64(first.Summary.GrandTotal), 1e-9) + + require.Len(t, store.Orders, 3) + assert.Equal(t, "6000-1234-0000-0002", store.Orders[0].ID()) + assert.Equal(t, "2026-10-06", targetgo.ParseTime(store.Orders[0].Date()).Format("2006-01-02")) + assert.Equal(t, 3, store.TotalPages) +} + +func TestCancelledLineIsDropped(t *testing.T) { + zero, two := 0.0, 2.0 + order := buildOrder("1", KindOnline, time.Now(), 10, 10, []targetgo.OrderLine{ + {Quantity: &zero, OriginalQuantity: 1, Item: targetgo.Item{Description: "Out of stock", UnitPrice: 5}}, + {Quantity: &two, Item: targetgo.Item{Description: "Kept", UnitPrice: 4.5}}, + }, nil) + assert.Equal(t, []string{"Kept x2 $9.00 []"}, itemSummary(order)) +} + +func TestOrderDateIsLocalDay(t *testing.T) { + saved := time.Local + t.Cleanup(func() { time.Local = saved }) + la, err := time.LoadLocation("America/Los_Angeles") + require.NoError(t, err) + time.Local = la + + // 23:17 Central is 21:17 in California: still the 6th, not the 7th as in UTC. + assert.Equal(t, time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC), orderDate("2026-10-06T23:17:42-05:00")) + assert.Equal(t, time.Date(2026, 8, 5, 0, 0, 0, 0, time.UTC), orderDate("2026-08-06T01:09:52.000Z")) + assert.True(t, orderDate("not a date").IsZero()) +} + +// TestMain pins the local zone: order dates are the day as seen where itemize runs, so the +// expected dates in these tests depend on it. +func TestMain(m *testing.M) { + if loc, err := time.LoadLocation("America/Denver"); err == nil { + time.Local = loc + } + os.Exit(m.Run()) +} diff --git a/internal/adapters/providers/target/storage_roundtrip_test.go b/internal/adapters/providers/target/storage_roundtrip_test.go new file mode 100644 index 0000000..3873d36 --- /dev/null +++ b/internal/adapters/providers/target/storage_roundtrip_test.go @@ -0,0 +1,48 @@ +package target + +import ( + "path/filepath" + "testing" + "time" + + targetgo "github.com/daslicious/target-go" + "github.com/eshaffer321/itemize/internal/infrastructure/storage" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Bug (2026-10-11): Target timestamps carry a numeric offset ("…-05:00"), which Go formats with a +// numeric zone name ("2026-10-06 23:17:42 -0500 -0500"). The SQLite driver can't parse that back, +// so every later run failed on the order with "load processing record: sql: Scan error on column +// index 4, name "order_date"". +func TestOrderDatesSurviveTheDedupDB(t *testing.T) { + store, err := storage.NewStorage(filepath.Join(t.TempDir(), "sync.db")) + require.NoError(t, err) + defer func() { _ = store.Close() }() + + var receipt targetgo.StoreOrder + var online targetgo.OnlineOrder + loadJSON(t, "sample_store_order.json", &receipt) + loadJSON(t, "sample_online_order.json", &online) + + storeOrder, err := convertStore(&receipt, targetgo.OrderSummary{}) + require.NoError(t, err) + onlineOrder, err := convertOnline(&online, targetgo.OrderSummary{}) + require.NoError(t, err) + + for _, order := range []*Order{storeOrder, onlineOrder} { + require.NoError(t, store.SaveRecord(&storage.ProcessingRecord{ + OrderID: order.GetID(), + Provider: order.GetProviderName(), + OrderDate: order.GetDate(), + ProcessedAt: time.Now(), + OrderTotal: order.GetTotal(), + Status: "dry-run", + DryRun: true, + })) + record, err := store.GetRecord(order.GetID()) + require.NoError(t, err, order.GetID()) + require.NotNil(t, record) + assert.True(t, record.OrderDate.Equal(order.GetDate()), "%s: stored %v, order %v", order.GetID(), record.OrderDate, order.GetDate()) + } +} diff --git a/internal/adapters/providers/target/testdata/history_online.json b/internal/adapters/providers/target/testdata/history_online.json new file mode 100644 index 0000000..b6ba828 --- /dev/null +++ b/internal/adapters/providers/target/testdata/history_online.json @@ -0,0 +1,8 @@ +{ + "total_orders": 1, + "total_pages": 1, + "orders": [ + {"order_number": "912002749053897", "placed_date": "2026-09-20T10:02:11.000Z", "order_purchase_type": "ONLINE", "order_type": "Sales", "summary": {"grand_total": "35.67"}, + "order_lines": [{"original_quantity": 1, "item": {"tcin": "89120321", "description": "LEGO City F1 Williams Racing & Haas F1 Race Cars 60464"}}]} + ] +} diff --git a/internal/adapters/providers/target/testdata/history_store.json b/internal/adapters/providers/target/testdata/history_store.json new file mode 100644 index 0000000..845edcc --- /dev/null +++ b/internal/adapters/providers/target/testdata/history_store.json @@ -0,0 +1,9 @@ +{ + "total_orders": 3, + "total_pages": 1, + "orders": [ + {"store_receipt_id": "6032-2290-0172-8918", "placed_date": "2026-09-26T18:31:36-07:00", "order_purchase_type": "STORE", "order_type": "Sales", "summary": {"grand_total": "24.42"}}, + {"store_receipt_id": "6032-2290-0172-0001", "placed_date": "2026-09-20T09:00:00-07:00", "order_purchase_type": "STORE", "order_type": "Return", "summary": {"grand_total": "12.00"}}, + {"store_receipt_id": "6032-2290-0170-0002", "placed_date": "2026-07-01T09:00:00-07:00", "order_purchase_type": "STORE", "order_type": "Sales", "summary": {"grand_total": "8.12"}} + ] +} diff --git a/internal/adapters/providers/target/testdata/online_order.json b/internal/adapters/providers/target/testdata/online_order.json new file mode 100644 index 0000000..ff775cb --- /dev/null +++ b/internal/adapters/providers/target/testdata/online_order.json @@ -0,0 +1,9 @@ +{ + "order_number": "912002749053897", + "order_date": "2026-09-20T10:02:11.000Z", + "summary": {"grand_total": "35.67", "total_product_price": "33.48", "total_taxes": "2.19"}, + "packages": [ + {"order_lines": [{"line_number": 1, "quantity": 1, "item": {"tcin": "89120321", "description": "LEGO City F1 Williams Racing & Haas F1 Race Cars 60464", "unit_price": 19.99}}]}, + {"order_lines": [{"line_number": 2, "quantity": 1, "item": {"tcin": "77777777", "description": "Big Coloring Book", "unit_price": 13.49}}]} + ] +} diff --git a/internal/adapters/providers/target/testdata/sample_history_online.json b/internal/adapters/providers/target/testdata/sample_history_online.json new file mode 100644 index 0000000..91daf87 --- /dev/null +++ b/internal/adapters/providers/target/testdata/sample_history_online.json @@ -0,0 +1,216 @@ +{ + "total_orders": 6, + "total_pages": 1, + "orders": [ + { + "order_number": "102000000000001", + "order_date": "2026-08-05T20:47:05.000Z", + "order_type": "Sales", + "summary": { + "grand_total": 22.72 + }, + "packages": [ + { + "order_lines": [ + { + "quantity": 2.0, + "original_quantity": 2.0, + "item": { + "tcin": "10000001", + "description": "Sliced Sharp Cheddar Cheese - 8oz - Good & Gather™" + } + }, + { + "quantity": 2.0, + "original_quantity": 2.0, + "item": { + "tcin": "10000002", + "description": "Gala Apples - 3lb Bag" + } + }, + { + "quantity": 3.0, + "original_quantity": 3.0, + "item": { + "tcin": "10000003", + "description": "Organic Baby Spinach - 5oz - Good & Gather™" + } + }, + { + "quantity": 1.0, + "original_quantity": 1.0, + "item": { + "tcin": "10000004", + "description": "Fresh Blueberries - 1pt" + } + } + ] + } + ] + }, + { + "order_number": "102000000000002", + "order_date": "2026-07-28T19:02:48.000Z", + "order_type": "Sales", + "summary": { + "grand_total": 37.84 + }, + "packages": [ + { + "order_lines": [ + { + "quantity": 1.0, + "original_quantity": 1.0, + "item": { + "tcin": "10000001", + "description": "Sliced Sharp Cheddar Cheese - 8oz - Good & Gather™" + } + }, + { + "quantity": 1.0, + "original_quantity": 1.0, + "item": { + "tcin": "10000005", + "description": "Whole Wheat Penne Pasta - 16oz" + } + }, + { + "quantity": 1.0, + "original_quantity": 1.0, + "item": { + "tcin": "10000002", + "description": "Gala Apples - 3lb Bag" + } + }, + { + "quantity": 1.0, + "original_quantity": 1.0, + "item": { + "tcin": "10000006", + "description": "Fresh Grape Tomatoes - 10oz - Good & Gather™ (Packaging May Vary)" + } + }, + { + "quantity": 1.0, + "original_quantity": 1.0, + "item": { + "tcin": "10000003", + "description": "Organic Baby Spinach - 5oz - Good & Gather™" + } + }, + { + "quantity": 1.0, + "original_quantity": 1.0, + "item": { + "tcin": "10000004", + "description": "Fresh Blueberries - 1pt" + } + }, + { + "quantity": 1.0, + "original_quantity": 1.0, + "item": { + "tcin": "10000007", + "description": "2% Reduced Fat Milk - 1gal - Good & Gather™" + } + }, + { + "quantity": 1.0, + "original_quantity": 1.0, + "item": { + "tcin": "10000008", + "description": "Lime Sparkling Water - 8pk/12 fl oz Cans" + } + }, + { + "quantity": 2.0, + "original_quantity": 2.0, + "item": { + "tcin": "10000009", + "description": "Grapefruit Sparkling Water - 8pk/12 fl oz Cans" + } + } + ] + } + ] + }, + { + "order_number": "912000000000001", + "order_date": "2026-07-19T21:55:10.000Z", + "order_type": "Sales", + "summary": { + "grand_total": 29.30 + }, + "packages": [ + { + "order_lines": [ + { + "quantity": 1.0, + "original_quantity": 1.0, + "item": { + "tcin": "10000005", + "description": "Whole Wheat Penne Pasta - 16oz" + } + }, + { + "quantity": 2.0, + "original_quantity": 2.0, + "item": { + "tcin": "10000002", + "description": "Gala Apples - 3lb Bag" + } + }, + { + "quantity": 1.0, + "original_quantity": 1.0, + "item": { + "tcin": "10000010", + "description": "Fresh Celery Hearts - 16oz - Good & Gather™" + } + }, + { + "quantity": 12.0, + "original_quantity": 12.0, + "item": { + "tcin": "10000011", + "description": "Fresh Lemon - each - Good & Gather™" + } + }, + { + "quantity": 1.0, + "original_quantity": 1.0, + "item": { + "tcin": "10000006", + "description": "Fresh Grape Tomatoes - 10oz - Good & Gather™ (Packaging May Vary)" + } + }, + { + "quantity": 1.0, + "original_quantity": 1.0, + "item": { + "tcin": "10000012", + "description": "Fresh Yellow Onions 3lb - Good & Gather™" + } + }, + { + "quantity": 1.0, + "original_quantity": 1.0, + "item": { + "tcin": "10000013", + "description": "Original Cream Cheese - 8oz" + } + }, + { + "quantity": 1.0, + "original_quantity": 1.0, + "item": { + "tcin": "10000007", + "description": "2% Reduced Fat Milk - 1gal - Good & Gather™" + } + } + ] + } + ] + } + ] +} diff --git a/internal/adapters/providers/target/testdata/sample_history_store.json b/internal/adapters/providers/target/testdata/sample_history_store.json new file mode 100644 index 0000000..1d51c0b --- /dev/null +++ b/internal/adapters/providers/target/testdata/sample_history_store.json @@ -0,0 +1,74 @@ +{ + "total_orders": 23, + "total_pages": 3, + "orders": [ + { + "store_receipt_id": "6000-1234-0000-0002", + "placed_date": "2026-10-06T23:17:42-05:00", + "order_purchase_type": "STORE", + "order_type": "STORES", + "summary": { + "grand_total": "494.77" + }, + "order_lines": [ + { + "original_quantity": 1, + "item": { + "tcin": "10000014", + "description": "Wheat Crackers 9oz - Market Pantry™" + } + }, + { + "original_quantity": 2, + "item": { + "tcin": "10000015", + "description": "Frozen Buttermilk Waffles - 12.3oz" + } + } + ] + }, + { + "store_receipt_id": "6000-1234-0000-0001", + "placed_date": "2026-09-24T14:51:08-05:00", + "order_purchase_type": "STORE", + "order_type": "STORES", + "summary": { + "grand_total": "20.46" + }, + "order_lines": [ + { + "original_quantity": 1, + "item": { + "tcin": "10000016", + "description": "Kid's Building Blocks Set - 100pc" + } + } + ] + }, + { + "store_receipt_id": "6000-1234-0000-0003", + "placed_date": "2026-08-19T12:06:21-05:00", + "order_purchase_type": "STORE", + "order_type": "STORES", + "summary": { + "grand_total": "152.13" + }, + "order_lines": [ + { + "original_quantity": 1, + "item": { + "tcin": "10000017", + "description": "Oats N' Crème Granola Bars - 10ct" + } + }, + { + "original_quantity": 1, + "item": { + "tcin": "10000018", + "description": "Cinnamon Granola Bars - 10ct" + } + } + ] + } + ] +} diff --git a/internal/adapters/providers/target/testdata/sample_invoice.json b/internal/adapters/providers/target/testdata/sample_invoice.json new file mode 100644 index 0000000..1bc3609 --- /dev/null +++ b/internal/adapters/providers/target/testdata/sample_invoice.json @@ -0,0 +1,77 @@ +{ + "id": "62000000000000001", + "type": "SHIPMENT", + "date": "2026-08-05T21:39:16.000Z", + "total_amount": 22.72, + "discount": -1.2000000000000002, + "status": "CLOSED", + "payments": [ + { + "type": "TARGETDEBIT", + "sub_type": "NONE", + "total_charged": 22.72 + } + ], + "lines": [ + { + "id": "2", + "order_line_key": "200000000000000000000000000001", + "quantity": 1.0, + "shipped_quantity": 1.0, + "unit_price": 2.99, + "discount": -0.15, + "amount": 2.99, + "effective_amount": 2.84, + "total_tax": 0.0, + "item": { + "tcin": "10000004", + "description": "Fresh Blueberries - 1pt" + } + }, + { + "id": "1", + "order_line_key": "200000000000000000000000000002", + "quantity": 3.0, + "shipped_quantity": 3.0, + "unit_price": 1.79, + "discount": -0.27, + "amount": 5.37, + "effective_amount": 5.1, + "total_tax": 0.0, + "item": { + "tcin": "10000003", + "description": "Organic Baby Spinach - 5oz - Good & Gather™" + } + }, + { + "id": "3", + "order_line_key": "200000000000000000000000000003", + "quantity": 2.0, + "shipped_quantity": 2.0, + "unit_price": 4.19, + "discount": -0.42, + "amount": 8.38, + "effective_amount": 7.96, + "total_tax": 0.0, + "item": { + "tcin": "10000002", + "description": "Gala Apples - 3lb Bag" + } + }, + { + "id": "4", + "order_line_key": "200000000000000000000000000004", + "quantity": 2.0, + "shipped_quantity": 2.0, + "unit_price": 3.59, + "discount": -0.36, + "amount": 7.18, + "effective_amount": 6.82, + "total_tax": 0.0, + "item": { + "tcin": "10000001", + "description": "Sliced Sharp Cheddar Cheese - 8oz - Good & Gather™" + } + } + ] +} \ No newline at end of file diff --git a/internal/adapters/providers/target/testdata/sample_invoices.json b/internal/adapters/providers/target/testdata/sample_invoices.json new file mode 100644 index 0000000..fea9c10 --- /dev/null +++ b/internal/adapters/providers/target/testdata/sample_invoices.json @@ -0,0 +1,11 @@ +{ + "invoices": [ + { + "id": "62000000000000001", + "type": "SHIPMENT", + "date": "2026-08-05T21:39:16.000Z", + "amount": 22.72, + "receipt_id": "6200000000000001" + } + ] +} \ No newline at end of file diff --git a/internal/adapters/providers/target/testdata/sample_online_order.json b/internal/adapters/providers/target/testdata/sample_online_order.json new file mode 100644 index 0000000..340feca --- /dev/null +++ b/internal/adapters/providers/target/testdata/sample_online_order.json @@ -0,0 +1,85 @@ +{ + "order_number": "102000000000001", + "order_date": "2026-08-05T20:47:05.000Z", + "order_type": "Sales", + "summary": { + "grand_total": 22.72, + "total_product_price": 23.92, + "total_taxes": 0.00, + "total_adjustments": 1.20, + "total_shipping_charges": 0.00 + }, + "packages": [ + { + "order_lines": [ + { + "order_line_key": "200000000000000000000000000004", + "quantity": 2.0, + "original_quantity": 2.0, + "item": { + "tcin": "10000001", + "description": "Sliced Sharp Cheddar Cheese - 8oz - Good & Gather™", + "unit_price": 3.59, + "list_price": 3.59, + "product_classification": { + "product_type_name": "GROCERY", + "product_subtype_name": "MEAT, POULTRY AND SEAFOOD" + } + } + }, + { + "order_line_key": "200000000000000000000000000003", + "quantity": 2.0, + "original_quantity": 2.0, + "item": { + "tcin": "10000002", + "description": "Gala Apples - 3lb Bag", + "unit_price": 4.19, + "list_price": 4.19, + "product_classification": { + "product_type_name": "GROCERY", + "product_subtype_name": "VEGETABLES AND FRUITS" + } + } + }, + { + "order_line_key": "200000000000000000000000000002", + "quantity": 3.0, + "original_quantity": 3.0, + "item": { + "tcin": "10000003", + "description": "Organic Baby Spinach - 5oz - Good & Gather™", + "unit_price": 1.79, + "list_price": 1.79, + "product_classification": { + "product_type_name": "GROCERY", + "product_subtype_name": "VEGETABLES AND FRUITS" + } + } + }, + { + "order_line_key": "200000000000000000000000000001", + "quantity": 1.0, + "original_quantity": 1.0, + "item": { + "tcin": "10000004", + "description": "Fresh Blueberries - 1pt", + "unit_price": 2.99, + "list_price": 2.99, + "product_classification": { + "product_type_name": "GROCERY", + "product_subtype_name": "VEGETABLES AND FRUITS" + } + } + } + ] + } + ], + "payments": [ + { + "amount": 22.72, + "payment_type": "TARGETDEBIT", + "guest_display_payment_type": "Target Circle Debit Card" + } + ] +} diff --git a/internal/adapters/providers/target/testdata/sample_store_order.json b/internal/adapters/providers/target/testdata/sample_store_order.json new file mode 100644 index 0000000..a62a0d5 --- /dev/null +++ b/internal/adapters/providers/target/testdata/sample_store_order.json @@ -0,0 +1,44 @@ +{ + "store_receipt_id": "6000-1234-0000-0001", + "store_id": "1234", + "order_purchase_date": "2026-09-24T14:51:08-05:00", + "order_type": "STORES", + "grand_total": "20.46", + "summary": { + "grand_total": "20.46", + "total_product_price": "19.99", + "total_taxes": "1.47", + "total_adjustment": "1.00", + "adjustments": [ + { + "promo_description": "Target Circle Card 5%", + "promo_value": "1.00" + } + ] + }, + "order_lines": [ + { + "quantity": 1, + "status": { + "key": "STAT_STORE_PURCHASE" + }, + "item": { + "tcin": "10000016", + "description": "Kid's Building Blocks Set - 100pc", + "unit_price": "19.99", + "list_price": "19.99", + "product_classification": { + "product_type_name": "TOYS", + "product_subtype_name": "INDOOR TOYS" + } + } + } + ], + "payment_transactions": [ + { + "amount": "20.46", + "payment_type": "TARGETDEBIT", + "payment_sub_type_value": "Target Circle Debit Card" + } + ] +} diff --git a/internal/adapters/providers/target/testdata/split_invoice_2001.json b/internal/adapters/providers/target/testdata/split_invoice_2001.json new file mode 100644 index 0000000..4a430c5 --- /dev/null +++ b/internal/adapters/providers/target/testdata/split_invoice_2001.json @@ -0,0 +1,15 @@ +{ + "id": "2001", + "type": "SHIPMENT", + "date": "2026-09-21T15:00:00.000Z", + "total_amount": 25.82, + "payments": [{"type": "VISA", "sub_type": "NONE", "total_charged": 25.82}], + "lines": [ + {"id": "1", "order_line_key": "k1", "quantity": 1.0, "shipped_quantity": 1.0, "unit_price": 19.99, "amount": 19.99, "effective_amount": 21.54, "total_tax": 1.55, + "item": {"tcin": "89120321", "description": "LEGO City F1 Williams Racing & Haas F1 Race Cars 60464"}}, + {"id": "2", "order_line_key": "k2", "quantity": 1.0, "shipped_quantity": 1.0, "unit_price": 3.79, "amount": 3.79, "effective_amount": 4.08, "total_tax": 0.29, + "item": {"tcin": "13306410", "description": "Freezer Storage Bags - 40ct - up&up™"}}, + {"id": "3", "order_line_key": "k9", "quantity": 2.0, "shipped_quantity": 2.0, "unit_price": 0.10, "amount": 0.20, "effective_amount": 0.20, "total_tax": 0.0, + "item": {"tcin": "10000019", "description": "PAPER_BAG"}} + ] +} diff --git a/internal/adapters/providers/target/testdata/split_invoice_2002.json b/internal/adapters/providers/target/testdata/split_invoice_2002.json new file mode 100644 index 0000000..468edd3 --- /dev/null +++ b/internal/adapters/providers/target/testdata/split_invoice_2002.json @@ -0,0 +1,13 @@ +{ + "id": "2002", + "type": "SHIPMENT", + "date": "2026-10-02T15:00:00.000Z", + "total_amount": 46.87, + "payments": [{"type": "VISA", "sub_type": "NONE", "total_charged": 46.87}], + "lines": [ + {"id": "3", "order_line_key": "k3", "quantity": 1.0, "shipped_quantity": 1.0, "unit_price": 13.49, "amount": 13.49, "effective_amount": 14.54, "total_tax": 1.05, + "item": {"tcin": "77777777", "description": "Big Coloring Book"}}, + {"id": "4", "order_line_key": "k4", "quantity": 2.0, "shipped_quantity": 2.0, "unit_price": 15.00, "amount": 30.00, "effective_amount": 32.33, "total_tax": 2.33, + "item": {"tcin": "10000020", "description": "Boys' Pajama Set - Blue 8"}} + ] +} diff --git a/internal/adapters/providers/target/testdata/split_invoices.json b/internal/adapters/providers/target/testdata/split_invoices.json new file mode 100644 index 0000000..575397c --- /dev/null +++ b/internal/adapters/providers/target/testdata/split_invoices.json @@ -0,0 +1,7 @@ +{ + "invoices": [ + {"id": "2002", "type": "SHIPMENT", "date": "2026-10-02T15:00:00.000Z", "amount": 46.87}, + {"id": "2001", "type": "SHIPMENT", "date": "2026-09-21T15:00:00.000Z", "amount": 25.82}, + {"id": "2003", "type": "RETURN", "date": "2026-10-05T15:00:00.000Z", "amount": 4.08} + ] +} diff --git a/internal/adapters/providers/target/testdata/split_order.json b/internal/adapters/providers/target/testdata/split_order.json new file mode 100644 index 0000000..9e1de57 --- /dev/null +++ b/internal/adapters/providers/target/testdata/split_order.json @@ -0,0 +1,16 @@ +{ + "order_number": "912002800000001", + "order_date": "2026-09-20T18:02:11.000Z", + "summary": {"grand_total": 72.69, "total_product_price": 67.27, "total_taxes": 5.22, "total_regional_fees": 0.20}, + "packages": [ + {"order_lines": [ + {"order_line_key": "k1", "line_number": 1, "quantity": 1, "item": {"tcin": "89120321", "description": "LEGO City F1 Williams Racing & Haas F1 Race Cars 60464", "unit_price": 19.99, "product_classification": {"product_type_name": "TOYS"}}}, + {"order_line_key": "k2", "line_number": 2, "quantity": 1, "item": {"tcin": "13306410", "description": "Freezer Storage Bags - 40ct - up&up™", "unit_price": 3.79, "product_classification": {"product_type_name": "HOME"}}} + ]}, + {"order_lines": [ + {"order_line_key": "k3", "line_number": 3, "quantity": 1, "item": {"tcin": "77777777", "description": "Big Coloring Book", "unit_price": 13.49, "product_classification": {"product_type_name": "BOOKS"}}}, + {"order_line_key": "k4", "line_number": 4, "quantity": 2, "item": {"tcin": "10000020", "description": "Boys' Pajama Set - Blue 8", "unit_price": 15.00, "product_classification": {"product_type_name": "APPAREL"}}} + ]} + ], + "payments": [{"payment_type": "VISA", "amount": 72.69}] +} diff --git a/internal/adapters/providers/target/testdata/store_order.json b/internal/adapters/providers/target/testdata/store_order.json new file mode 100644 index 0000000..17d8272 --- /dev/null +++ b/internal/adapters/providers/target/testdata/store_order.json @@ -0,0 +1,17 @@ +{ + "store_receipt_id": "6032-2290-0172-8918", + "store_id": "1234", + "order_purchase_date": "2026-09-26T18:31:36-07:00", + "grand_total": "24.42", + "summary": {"grand_total": "24.42"}, + "order_lines": [ + {"line_number": 1, "quantity": 2, "item": {"tcin": "13202943", "description": "Whole Wheat Penne Pasta - 16oz", "unit_price": "3.49", "list_price": "3.49", "product_classification": {"product_type_name": "GROCERY"}}}, + {"line_number": 2, "quantity": 1, "item": {"tcin": "54191097", "description": "Men's Crew Socks 6pk - Gray", "unit_price": "12.00", "list_price": "15.00", "product_classification": {"product_type_name": "APPAREL"}}}, + {"line_number": 3, "quantity": 1, "item": {"tcin": "1", "description": "PAPER_BAG", "unit_price": "0.00"}}, + {"line_number": 4, "quantity": 1, "item": {"tcin": "14917313", "description": "Freezer Storage Bags - 40ct - up&up™", "unit_price": "3.79", "product_classification": {"product_type_name": "HOME"}}} + ], + "payment_transactions": [ + {"payment_type": "CREDIT_CARD", "card_type": "TARGET_CIRCLE_DEBIT", "amount": "24.42"} + ], + "receipts": ["data:image/gif;base64,R0lGODlhAQABAAAAACw="] +} diff --git a/internal/api/dto/sync.go b/internal/api/dto/sync.go index 11bcd03..3d0073f 100644 --- a/internal/api/dto/sync.go +++ b/internal/api/dto/sync.go @@ -2,7 +2,7 @@ package dto // StartSyncRequest is the request body for starting a sync. type StartSyncRequest struct { - Provider string `json:"provider"` // "walmart", "costco", "amazon" + Provider string `json:"provider"` // "walmart", "costco", "amazon", "target" DryRun bool `json:"dry_run"` // Preview mode LookbackDays int `json:"lookback_days"` // How many days to look back (default 14) MaxOrders int `json:"max_orders"` // Max orders to process (0 = all) diff --git a/internal/application/service/sync_service.go b/internal/application/service/sync_service.go index 21fe92f..8a7a519 100644 --- a/internal/application/service/sync_service.go +++ b/internal/application/service/sync_service.go @@ -40,7 +40,7 @@ const ( // SyncRequest holds parameters for starting a sync. type SyncRequest struct { - Provider string // "walmart", "costco", "amazon" + Provider string // "walmart", "costco", "amazon", "target" DryRun bool LookbackDays int MaxOrders int diff --git a/internal/cli/serve.go b/internal/cli/serve.go index ec76a58..eca92f3 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -68,6 +68,9 @@ func RunServe(cfg *config.Config, flags *ServeFlags) error { "amazon": func(c *config.Config, verbose bool) (providers.OrderProvider, error) { return NewAmazonProvider(c, verbose, "") }, + "target": func(c *config.Config, verbose bool) (providers.OrderProvider, error) { + return NewTargetProvider(c, verbose, "default") + }, } // Create sync service @@ -76,7 +79,7 @@ func RunServe(cfg *config.Config, flags *ServeFlags) error { // Start background cleanup for stale jobs (checks every 5 minutes) syncService.StartBackgroundCleanup(5 * time.Minute) - logger.Info("sync service initialized", "providers", []string{"walmart", "costco", "amazon"}) + logger.Info("sync service initialized", "providers", []string{"walmart", "costco", "amazon", "target"}) } // Create API config diff --git a/internal/cli/target.go b/internal/cli/target.go new file mode 100644 index 0000000..13fd493 --- /dev/null +++ b/internal/cli/target.go @@ -0,0 +1,181 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "time" + + targetgo "github.com/daslicious/target-go" + "github.com/eshaffer321/itemize/internal/adapters/providers" + "github.com/eshaffer321/itemize/internal/adapters/providers/target" + "github.com/eshaffer321/itemize/internal/infrastructure/config" + "github.com/eshaffer321/itemize/internal/infrastructure/logging" +) + +// TargetSessionPath returns where the Target session for account is saved: +// /session-.json, with ITEMIZE_TARGET_DIR defaulting to ~/.itemize/target. +func TargetSessionPath(account string) (string, error) { + dir := os.Getenv("ITEMIZE_TARGET_DIR") + if dir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + dir = filepath.Join(home, ".itemize", "target") + } + return targetgo.SessionPath(dir, account) +} + +// targetRenewWindow: a token with less than this left is renewed before a run. Target only renews +// an expired token, so a token about to expire is waited out first (at most this long). +const targetRenewWindow = 15 * time.Minute + +// NewTargetProvider creates a Target provider from the account's saved session, renewed first if +// its token wouldn't last through a run. Renewed tokens last 24 hours and runs are daily, so most +// runs renew; the client saves the renewed login at once (the refresh token rotates on renewal). +// ITEMIZE_TARGET_DUMP_DIR, if set, keeps a copy of every Target response there. +func NewTargetProvider(cfg *config.Config, verbose bool, account string) (*target.Provider, error) { + loggingCfg := cfg.Observability.Logging + if verbose { + loggingCfg.Level = "debug" + } + targetLogger := logging.NewLoggerWithSystem(loggingCfg, "target") + + path, err := TargetSessionPath(account) + if err != nil { + return nil, err + } + opts := []targetgo.ClientOption{targetgo.WithLogger(targetLogger)} + if dir := os.Getenv("ITEMIZE_TARGET_DUMP_DIR"); dir != "" { + opts = append(opts, targetgo.WithDumpDir(dir)) + } + client, err := targetgo.Open(path, opts...) + if err != nil { + return nil, err + } + if _, err := client.EnsureFresh(context.Background(), targetRenewWindow); err != nil { + return nil, err + } + return target.NewProvider(client, targetLogger), nil +} + +// RunTargetRefresh renews the account's login now and saves it. +func RunTargetRefresh(ctx context.Context, cfg *config.Config, verbose bool, account string, out io.Writer) error { + loggingCfg := cfg.Observability.Logging + if verbose { + loggingCfg.Level = "debug" + } + path, err := TargetSessionPath(account) + if err != nil { + return err + } + client, err := targetgo.Open(path, targetgo.WithLogger(logging.NewLoggerWithSystem(loggingCfg, "target"))) + if err != nil { + return err + } + if err := client.Refresh(ctx); err != nil { + return err + } + exp, _ := client.Session().AccessTokenExpiry() + _, _ = fmt.Fprintf(out, "Renewed and saved %s; the new access token expires %s.\n", path, exp.Local().Format("2006-01-02 15:04 MST")) + return nil +} + +// RunTargetSetSession reads a "Copy as cURL" (or a Cookie header) from in, checks that Target accepts +// it, and only then saves it as the account's session. +func RunTargetSetSession(ctx context.Context, account string, in io.Reader, out io.Writer) error { + path, err := TargetSessionPath(account) + if err != nil { + return err + } + raw, err := io.ReadAll(io.LimitReader(in, 1<<20)) + if err != nil { + return err + } + session, err := targetgo.ParseSession(string(raw), time.Now().UTC()) + if err != nil { + return err + } + + // Test the login by replaying the request it was copied from, which target.com itself just made. + client := targetgo.NewClient(session) + if session.SourceURL != "" { + err = client.Check(ctx, session.SourceURL) + } else { + _, err = client.OrderHistory(ctx, targetgo.PurchaseOnline, 1, 10) + } + if err != nil { + return fmt.Errorf("not saved, Target refused the login: %w", err) + } + if err := targetgo.SaveSession(path, session); err != nil { + return err + } + _, _ = fmt.Fprintf(out, "Saved to %s. Target accepted the login.\n", path) + + // Report whether the order lists itemize reads still answer (their paths have changed before). + for _, purchaseType := range []string{targetgo.PurchaseOnline, targetgo.PurchaseStore} { + if history, err := client.OrderHistory(ctx, purchaseType, 1, 10); err != nil { + _, _ = fmt.Fprintf(out, "Warning: the %s order list failed: %v\n", purchaseType, err) + } else { + _, _ = fmt.Fprintf(out, "%s order list: %d orders on record.\n", purchaseType, history.TotalOrders) + } + } + if exp, ok := session.AccessTokenExpiry(); ok { + _, _ = fmt.Fprintf(out, "The access token in these cookies expires %s (in %s).\n", + exp.Local().Format("2006-01-02 15:04 MST"), time.Until(exp).Round(time.Minute)) + } + return nil +} + +// TargetOrderJSON is how `itemize target orders` prints an order. +type TargetOrderJSON struct { + ID string `json:"id"` + Kind string `json:"kind"` + Date string `json:"date"` + Total float64 `json:"total"` + Subtotal float64 `json:"subtotal"` + Tax float64 `json:"tax"` + Items []TargetItemRow `json:"items"` +} + +// TargetItemRow is one item of TargetOrderJSON. +type TargetItemRow struct { + Name string `json:"name"` + Quantity float64 `json:"quantity"` + Price float64 `json:"price"` + Category string `json:"target_category,omitempty"` +} + +// PrintTargetOrders writes orders as JSON, the way itemize would see them, without touching Monarch. +func PrintTargetOrders(w io.Writer, orders []providers.Order) error { + rows := make([]TargetOrderJSON, 0, len(orders)) + for _, order := range orders { + row := TargetOrderJSON{ + ID: order.GetID(), + Date: order.GetDate().Format(time.RFC3339), + Total: order.GetTotal(), + Subtotal: order.GetSubtotal(), + Tax: order.GetTax(), + Items: []TargetItemRow{}, + } + if o, ok := order.(*target.Order); ok { + row.Kind = o.Kind() + } + for _, item := range order.GetItems() { + row.Items = append(row.Items, TargetItemRow{ + Name: item.GetName(), + Quantity: item.GetQuantity(), + Price: item.GetPrice(), + Category: item.GetCategory(), + }) + } + rows = append(rows, row) + } + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + return encoder.Encode(rows) +} diff --git a/internal/cli/target_test.go b/internal/cli/target_test.go new file mode 100644 index 0000000..91b2b8d --- /dev/null +++ b/internal/cli/target_test.go @@ -0,0 +1,65 @@ +package cli + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "path/filepath" + "testing" + "time" + + targetgo "github.com/daslicious/target-go" + "github.com/eshaffer321/itemize/internal/infrastructure/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTargetSessionPath(t *testing.T) { + t.Setenv("ITEMIZE_TARGET_DIR", "/srv/itemize/target") + path, err := TargetSessionPath("partner") + require.NoError(t, err) + assert.Equal(t, "/srv/itemize/target/session-partner.json", path) + + t.Setenv("ITEMIZE_TARGET_DIR", "") + t.Setenv("HOME", "/home/someone") + path, err = TargetSessionPath("") + require.NoError(t, err) + assert.Equal(t, filepath.Join("/home/someone", ".itemize", "target", "session-default.json"), path) + + _, err = TargetSessionPath("a/b") + assert.Error(t, err) +} + +func TestPrintTargetOrdersEmpty(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, PrintTargetOrders(&buf, nil)) + var rows []TargetOrderJSON + require.NoError(t, json.Unmarshal(buf.Bytes(), &rows)) + assert.Empty(t, rows) + assert.Equal(t, "[]\n", buf.String()) +} + +func cliTestJWT(sub string, exp time.Time) string { + payload := fmt.Sprintf(`{"sub":%q,"sut":"R","exp":%d}`, sub, exp.Unix()) + return "eyJhbGciOiJSUzI1NiJ9." + base64.RawURLEncoding.EncodeToString([]byte(payload)) + ".c2ln" +} + +// The renewal itself is tested in target-go (TestEnsureFresh); this checks the wiring and the +// error message ("target auth check failed") scripts can match on to tell an expired login apart. +func TestNewTargetProvider(t *testing.T) { + dir := t.TempDir() + t.Setenv("ITEMIZE_TARGET_DIR", dir) + cfg := &config.Config{} + + _, err := NewTargetProvider(cfg, false, "partner") + require.Error(t, err) + assert.Contains(t, err.Error(), "target auth check failed") + + path := filepath.Join(dir, "session-partner.json") + valid := &targetgo.Session{Cookie: "accessToken=" + cliTestJWT("1000", time.Now().Add(20*time.Hour)) + "; refreshToken=TGT.old"} + require.NoError(t, targetgo.SaveSession(path, valid)) + provider, err := NewTargetProvider(cfg, false, "partner") + require.NoError(t, err, "a token good for the run is used without contacting Target") + assert.Equal(t, "target", provider.Name()) +}