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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions internal/adapters/providers/amazon/order.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package amazon
import (
"errors"
"log/slog"
"math"
"time"

"github.com/eshaffer321/itemize/internal/adapters/providers"
Expand Down Expand Up @@ -175,6 +176,58 @@ func (o *Order) GetNonBankAmount() (float64, error) {
return nonBankTotal, nil
}

// GetItemsForCharge returns the items belonging to the shipment that best
// matches the given bank charge amount. When shipment data is available this
// lets each Monarch transaction be split using only the items that were
// actually in that box rather than pro-rating the entire order.
//
// Matching works by estimating each shipment's charge as:
//
// shipmentSubtotal * (1 + taxRate)
//
// where taxRate = order.Tax / order.Subtotal. The shipment whose estimate is
// closest to chargeAmount wins. Falls back to all order items when no
// shipment data is present or when the order has only one shipment.
func (o *Order) GetItemsForCharge(chargeAmount float64) []providers.OrderItem {
if len(o.parsedOrder.Shipments) <= 1 {
return o.items
}

taxRate := 0.0
if o.parsedOrder.Subtotal > 0 {
taxRate = o.parsedOrder.Tax / o.parsedOrder.Subtotal
}

bestIdx := -1
bestDiff := math.MaxFloat64
for i, shipment := range o.parsedOrder.Shipments {
var subtotal float64
for _, item := range shipment.Items {
subtotal += item.Price * float64(item.Quantity)
}
estimated := subtotal * (1 + taxRate)
diff := math.Abs(estimated - chargeAmount)
if diff < bestDiff {
bestDiff = diff
bestIdx = i
}
}

if bestIdx < 0 {
return o.items
}

shipment := o.parsedOrder.Shipments[bestIdx]
items := make([]providers.OrderItem, 0, len(shipment.Items))
for _, item := range shipment.Items {
items = append(items, &OrderItem{parsedItem: item})
}
if len(items) == 0 {
return o.items
}
return items
}

// IsMultiDelivery checks if order was split into multiple shipments/charges
// Returns true if there are multiple final charges
func (o *Order) IsMultiDelivery() (bool, error) {
Expand Down
34 changes: 34 additions & 0 deletions internal/adapters/providers/amazon/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ func ConvertCLIOrder(cliOrder CLIOrder) (*ParsedOrder, error) {
order.Items = append(order.Items, item)
}

// Parse shipments
for i, cliShipment := range cliOrder.Shipments {
shipment, err := convertCLIShipment(cliShipment)
if err != nil {
return nil, fmt.Errorf("failed to parse shipment %d: %w", i, err)
}
order.Shipments = append(order.Shipments, shipment)
}

// Parse transactions - fail on any transaction parse error
for i, cliTx := range cliOrder.Transactions {
tx, err := convertCLITransaction(cliTx)
Expand All @@ -92,6 +101,31 @@ func ConvertCLIOrder(cliOrder CLIOrder) (*ParsedOrder, error) {
return order, nil
}

// convertCLIShipment converts a CLIShipment to a ParsedShipment
func convertCLIShipment(cliShipment CLIShipment) (*ParsedShipment, error) {
shipment := &ParsedShipment{
Status: cliShipment.Status,
}

if cliShipment.Date != "" {
date, err := parseDate(cliShipment.Date)
if err != nil {
return nil, fmt.Errorf("failed to parse shipment date %q: %w", cliShipment.Date, err)
}
shipment.Date = date
}

for i, cliItem := range cliShipment.Items {
item, err := convertCLIItem(cliItem)
if err != nil {
return nil, fmt.Errorf("failed to parse shipment item %d (%q): %w", i, cliItem.Name, err)
}
shipment.Items = append(shipment.Items, item)
}

return shipment, nil
}

// convertCLIItem converts a CLIOrderItem to a ParsedOrderItem
func convertCLIItem(cliItem CLIOrderItem) (*ParsedOrderItem, error) {
price, err := parseAmount(cliItem.Price)
Expand Down
3 changes: 2 additions & 1 deletion internal/adapters/providers/amazon/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"bytes"
"context"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
Expand Down Expand Up @@ -228,7 +229,7 @@ func (p *Provider) executeCLI(ctx context.Context, args []string) ([]byte, error

var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
cmd.Stderr = io.MultiWriter(&stderr, os.Stderr) // stream logs to terminal in real-time
if p.browserDataDir != "" {
cmd.Env = append(os.Environ(), "BROWSER_DATA_DIR="+p.browserDataDir)
}
Expand Down
16 changes: 16 additions & 0 deletions internal/adapters/providers/amazon/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ type CLIOutput struct {
Orders []CLIOrder `json:"orders"`
}

// CLIShipment represents a shipment group from the CLI output
type CLIShipment struct {
Status string `json:"status"` // "Delivered", "Arriving", "Shipped"
Date string `json:"date"` // ISO 8601: "2025-12-15"
Items []CLIOrderItem `json:"items"`
}

// CLIOrder represents an order from the CLI output
type CLIOrder struct {
OrderID string `json:"orderId"`
Expand All @@ -16,6 +23,7 @@ type CLIOrder struct {
Tax string `json:"tax"` // "$6.58"
Shipping string `json:"shipping"` // "$0.00"
Items []CLIOrderItem `json:"items"`
Shipments []CLIShipment `json:"shipments"`
Transactions []CLITransaction `json:"transactions"`
}

Expand All @@ -35,6 +43,13 @@ type CLITransaction struct {
Description string `json:"description"` // "Prime Visa ****1211..."
}

// ParsedShipment is the internal representation of a shipment group
type ParsedShipment struct {
Status string
Date time.Time
Items []*ParsedOrderItem
}

// ParsedOrder is the internal representation after parsing CLI output
type ParsedOrder struct {
ID string
Expand All @@ -44,6 +59,7 @@ type ParsedOrder struct {
Tax float64
Shipping float64
Items []*ParsedOrderItem
Shipments []*ParsedShipment
Transactions []*ParsedTransaction
}

Expand Down
Loading
Loading