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
16 changes: 14 additions & 2 deletions internal/adapters/providers/amazon/order.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package amazon

import (
"errors"
"fmt"
"log/slog"
"time"

"github.com/eshaffer321/monarchmoney-sync-backend/internal/adapters/providers"
)

// ErrPaymentPending indicates an order has no bank charges yet because it hasn't shipped
var ErrPaymentPending = errors.New("payment pending: order has not been charged yet (awaiting shipment)")

// Order wraps a ParsedOrder to implement the providers.Order interface
type Order struct {
parsedOrder *ParsedOrder
Expand Down Expand Up @@ -83,10 +87,12 @@ func (o *Order) GetRawData() interface{} {
// Filters out non-bank transactions like gift cards, points, etc.
func (o *Order) GetFinalCharges() ([]float64, error) {
if len(o.parsedOrder.Transactions) == 0 {
return nil, fmt.Errorf("no transactions found for order")
// No transactions at all - order hasn't been charged yet (awaiting shipment)
return nil, ErrPaymentPending
}

var bankCharges []float64
var hasNonBankPayments bool
for _, tx := range o.parsedOrder.Transactions {
// Skip refunds
if tx.Type == "refund" {
Expand All @@ -107,6 +113,7 @@ func (o *Order) GetFinalCharges() ([]float64, error) {
// Real bank charges have Last4 populated (card ending digits)
// Points, gift cards, etc. have empty Last4
if tx.Last4 == "" {
hasNonBankPayments = true
if o.logger != nil {
o.logger.Debug("Skipping non-bank transaction",
"order_id", o.GetID(),
Expand All @@ -128,7 +135,12 @@ func (o *Order) GetFinalCharges() ([]float64, error) {
}

if len(bankCharges) == 0 {
return nil, fmt.Errorf("no bank charges found (order may be paid with gift cards/points only)")
if hasNonBankPayments {
// Order was paid entirely with gift cards/points - no bank transaction to match
return nil, fmt.Errorf("no bank charges found (order paid entirely with gift cards/points)")
}
// No bank charges and no non-bank payments processed yet - still pending
return nil, ErrPaymentPending
}

return bankCharges, nil
Expand Down
36 changes: 35 additions & 1 deletion internal/adapters/providers/amazon/order_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,41 @@ func TestOrder_GetFinalCharges_OnlyGiftCard(t *testing.T) {
charges, err := order.GetFinalCharges()
assert.Error(t, err, "Should return error when no bank charges found")
assert.Nil(t, charges)
assert.Contains(t, err.Error(), "no bank charges found")
assert.Contains(t, err.Error(), "paid entirely with gift cards/points")
}

func TestOrder_GetFinalCharges_NoTransactions_ReturnsPending(t *testing.T) {
// Test order with no transactions (not yet shipped/charged)
parsedOrder := &ParsedOrder{
ID: "test-pending-order",
Date: time.Now(),
Total: 50.00,
Transactions: []*ParsedTransaction{}, // Empty - not charged yet
}

order := NewOrder(parsedOrder, nil)

charges, err := order.GetFinalCharges()
assert.Error(t, err, "Should return error when no transactions")
assert.Nil(t, charges)
assert.ErrorIs(t, err, ErrPaymentPending, "Should return ErrPaymentPending for orders not yet charged")
}

func TestOrder_GetFinalCharges_NilTransactions_ReturnsPending(t *testing.T) {
// Test order with nil transactions slice
parsedOrder := &ParsedOrder{
ID: "test-pending-order-nil",
Date: time.Now(),
Total: 50.00,
Transactions: nil, // Nil - not charged yet
}

order := NewOrder(parsedOrder, nil)

charges, err := order.GetFinalCharges()
assert.Error(t, err, "Should return error when no transactions")
assert.Nil(t, charges)
assert.ErrorIs(t, err, ErrPaymentPending, "Should return ErrPaymentPending for orders not yet charged")
}

func TestOrder_GetFinalCharges_SkipsRefunds(t *testing.T) {
Expand Down
8 changes: 4 additions & 4 deletions internal/adapters/providers/amazon/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ func TestNewProvider_NilLogger(t *testing.T) {
assert.NotNil(t, provider.logger)
}

// TestOrder_GetFinalCharges_NoTransactions tests GetFinalCharges returns error without transactions
// TestOrder_GetFinalCharges_NoTransactions tests GetFinalCharges returns ErrPaymentPending without transactions
func TestOrder_GetFinalCharges_NoTransactions(t *testing.T) {
parsedOrder := &ParsedOrder{
ID: "114-0000000-0000000",
Expand All @@ -201,10 +201,10 @@ func TestOrder_GetFinalCharges_NoTransactions(t *testing.T) {

assert.Error(t, err)
assert.Nil(t, charges)
assert.Contains(t, err.Error(), "no transactions found")
assert.ErrorIs(t, err, ErrPaymentPending, "Should return ErrPaymentPending for orders without transactions")
}

// TestOrder_IsMultiDelivery_NoTransactions tests IsMultiDelivery returns error without transactions
// TestOrder_IsMultiDelivery_NoTransactions tests IsMultiDelivery returns ErrPaymentPending without transactions
func TestOrder_IsMultiDelivery_NoTransactions(t *testing.T) {
parsedOrder := &ParsedOrder{
ID: "114-0000000-0000000",
Expand All @@ -217,7 +217,7 @@ func TestOrder_IsMultiDelivery_NoTransactions(t *testing.T) {

assert.Error(t, err)
assert.False(t, isMulti)
assert.Contains(t, err.Error(), "no transactions found")
assert.ErrorIs(t, err, ErrPaymentPending, "Should return ErrPaymentPending for orders without transactions")
}

// TestCalculateLookbackDays tests lookback days calculation
Expand Down
10 changes: 10 additions & 0 deletions internal/application/sync/handlers/amazon.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package handlers

import (
"context"
"errors"
"fmt"
"log/slog"
"math"
Expand Down Expand Up @@ -125,6 +126,15 @@ func (h *AmazonHandler) ProcessOrder(
// Step 1: Get bank charges
bankCharges, err := order.GetFinalCharges()
if err != nil {
// Check if this is a pending payment (order not yet shipped/charged)
if errors.Is(err, amazonprovider.ErrPaymentPending) {
h.logInfo("Order payment pending (not yet shipped)",
"order_id", order.GetID(),
"order_total", order.GetTotal())
result.Skipped = true
result.SkipReason = "payment pending"
return result, nil
}
return nil, fmt.Errorf("failed to get bank charges: %w", err)
}

Expand Down
5 changes: 4 additions & 1 deletion internal/application/sync/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,18 @@ func (o *Orchestrator) handleResult(order providers.Order, result *handlers.Proc
return false, false, err
}
if result.Skipped {
o.logger.Warn("Order skipped", "order_id", order.GetID(), "reason", result.SkipReason)
// Don't treat "payment pending" as an error - it's expected for new orders
if result.SkipReason == "payment pending" {
o.logger.Info("Order pending (awaiting shipment/charge)", "order_id", order.GetID())
o.recordPending(order, result.SkipReason)
return false, true, nil
}
// Don't treat "already has splits" as an error - just skip silently
if result.SkipReason == "transaction already has splits" {
o.logger.Debug("Order skipped (already has splits)", "order_id", order.GetID())
return false, true, nil
}
o.logger.Warn("Order skipped", "order_id", order.GetID(), "reason", result.SkipReason)
o.recordError(order, result.SkipReason)
return false, false, fmt.Errorf("skipped: %s", result.SkipReason)
}
Expand Down
46 changes: 35 additions & 11 deletions internal/application/sync/recording.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,25 +51,49 @@ func convertSplits(splits []*monarch.TransactionSplit) []storage.SplitDetail {
func (o *Orchestrator) recordError(order providers.Order, errorMsg string) {
if o.storage != nil {
record := &storage.ProcessingRecord{
OrderID: order.GetID(),
Provider: order.GetProviderName(),
OrderDate: order.GetDate(),
OrderTotal: order.GetTotal(),
OrderID: order.GetID(),
Provider: order.GetProviderName(),
OrderDate: order.GetDate(),
OrderTotal: order.GetTotal(),
OrderSubtotal: order.GetSubtotal(),
OrderTax: order.GetTax(),
OrderTip: order.GetTip(),
ItemCount: len(order.GetItems()),
ProcessedAt: time.Now(),
Status: "failed",
ErrorMessage: errorMsg,
Items: convertOrderItems(order.GetItems()),
OrderTax: order.GetTax(),
OrderTip: order.GetTip(),
ItemCount: len(order.GetItems()),
ProcessedAt: time.Now(),
Status: "failed",
ErrorMessage: errorMsg,
Items: convertOrderItems(order.GetItems()),
}
if err := o.storage.SaveRecord(record); err != nil {
o.logger.Error("Failed to save error record", "order_id", order.GetID(), "error", err)
}
}
}

// recordPending records an order that is pending (not yet charged/shipped)
// This allows tracking without blocking retries on future syncs
func (o *Orchestrator) recordPending(order providers.Order, reason string) {
if o.storage != nil {
record := &storage.ProcessingRecord{
OrderID: order.GetID(),
Provider: order.GetProviderName(),
OrderDate: order.GetDate(),
OrderTotal: order.GetTotal(),
OrderSubtotal: order.GetSubtotal(),
OrderTax: order.GetTax(),
OrderTip: order.GetTip(),
ItemCount: len(order.GetItems()),
ProcessedAt: time.Now(),
Status: "pending",
ErrorMessage: reason,
Items: convertOrderItems(order.GetItems()),
}
if err := o.storage.SaveRecord(record); err != nil {
o.logger.Error("Failed to save pending record", "order_id", order.GetID(), "error", err)
}
}
}

// recordSuccess records a successful processing to storage
func (o *Orchestrator) recordSuccess(order providers.Order, transaction *monarch.Transaction, splits []*monarch.TransactionSplit, confidence float64, dryRun bool) {
o.recordSuccessWithMultiDelivery(order, transaction, splits, confidence, dryRun, nil)
Expand Down
1 change: 1 addition & 0 deletions web/src/app/(app)/orders/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export default async function OrdersPage({ searchParams }: PageProps) {
<option value="">All Statuses</option>
<option value="success">Success</option>
<option value="failed">Failed</option>
<option value="pending">Pending</option>
<option value="dry-run">Dry Run</option>
</Select>
<Button type="submit">Filter</Button>
Expand Down
Loading