Skip to content
Closed
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
6 changes: 6 additions & 0 deletions internal/adapters/providers/walmart/order.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,3 +274,9 @@ func (o *Order) IsMultiDelivery() (bool, error) {
}
return len(charges) > 1, nil
}

// GetRawLedger returns the cached ledger data for persistence
// Returns nil if ledger hasn't been fetched yet
func (o *Order) GetRawLedger() *walmartclient.OrderLedger {
return o.ledgerCache
}
41 changes: 41 additions & 0 deletions internal/api/dto/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,44 @@ func NewHealthResponse() HealthResponse {
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}

// LedgerResponse represents a ledger snapshot in API responses.
type LedgerResponse struct {
ID int64 `json:"id"`
OrderID string `json:"order_id"`
SyncRunID int64 `json:"sync_run_id,omitempty"`
Provider string `json:"provider"`
FetchedAt string `json:"fetched_at"`
LedgerState string `json:"ledger_state"`
LedgerVersion int `json:"ledger_version"`
TotalCharged float64 `json:"total_charged"`
ChargeCount int `json:"charge_count"`
PaymentMethodTypes string `json:"payment_method_types"`
HasRefunds bool `json:"has_refunds"`
IsValid bool `json:"is_valid"`
ValidationNotes string `json:"validation_notes,omitempty"`
Charges []ChargeResponse `json:"charges,omitempty"`
}

// ChargeResponse represents a single charge within a ledger.
type ChargeResponse struct {
ID int64 `json:"id"`
ChargeSequence int `json:"charge_sequence"`
ChargeAmount float64 `json:"charge_amount"`
ChargeType string `json:"charge_type"`
PaymentMethod string `json:"payment_method"`
CardType string `json:"card_type,omitempty"`
CardLastFour string `json:"card_last_four,omitempty"`
MonarchTransactionID string `json:"monarch_transaction_id,omitempty"`
IsMatched bool `json:"is_matched"`
MatchConfidence float64 `json:"match_confidence,omitempty"`
SplitCount int `json:"split_count,omitempty"`
}

// LedgerListResponse is returned when listing ledgers.
type LedgerListResponse struct {
Ledgers []LedgerResponse `json:"ledgers"`
TotalCount int `json:"total_count"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
175 changes: 175 additions & 0 deletions internal/api/handlers/ledgers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package handlers

import (
"net/http"
"strconv"

"github.com/go-chi/chi/v5"

"github.com/eshaffer321/monarchmoney-sync-backend/internal/api/dto"
"github.com/eshaffer321/monarchmoney-sync-backend/internal/infrastructure/storage"
)

// LedgersHandler handles ledger-related HTTP requests.
type LedgersHandler struct {
*Base
}

// NewLedgersHandler creates a new ledgers handler.
func NewLedgersHandler(repo storage.Repository) *LedgersHandler {
return &LedgersHandler{
Base: NewBase(repo),
}
}

// List handles GET /api/ledgers - returns paginated list of ledgers.
func (h *LedgersHandler) List(w http.ResponseWriter, r *http.Request) {
filters := storage.LedgerFilters{
OrderID: r.URL.Query().Get("order_id"),
Provider: r.URL.Query().Get("provider"),
Limit: ParseIntParam(r, "limit", 50),
Offset: ParseIntParam(r, "offset", 0),
}

// Parse state filter
if state := r.URL.Query().Get("state"); state != "" {
filters.State = storage.LedgerState(state)
}

result, err := h.repo.ListLedgers(filters)
if err != nil {
h.WriteError(w, http.StatusInternalServerError, dto.InternalError())
return
}

response := dto.LedgerListResponse{
Ledgers: make([]dto.LedgerResponse, 0, len(result.Ledgers)),
TotalCount: result.TotalCount,
Limit: result.Limit,
Offset: result.Offset,
}

for _, ledger := range result.Ledgers {
response.Ledgers = append(response.Ledgers, toLedgerResponse(ledger))
}

h.WriteJSON(w, http.StatusOK, response)
}

// Get handles GET /api/ledgers/{id} - returns a single ledger by ID.
func (h *LedgersHandler) Get(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
if idStr == "" {
h.WriteError(w, http.StatusBadRequest, dto.BadRequestError("ledger ID is required"))
return
}

id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
h.WriteError(w, http.StatusBadRequest, dto.BadRequestError("invalid ledger ID"))
return
}

ledger, err := h.repo.GetLedgerByID(id)
if err != nil {
h.WriteError(w, http.StatusInternalServerError, dto.InternalError())
return
}

if ledger == nil {
h.WriteError(w, http.StatusNotFound, dto.NotFoundError("ledger"))
return
}

response := toLedgerResponse(ledger)
h.WriteJSON(w, http.StatusOK, response)
}

// GetByOrderID handles GET /api/orders/{orderID}/ledger - returns the latest ledger for an order.
func (h *LedgersHandler) GetByOrderID(w http.ResponseWriter, r *http.Request) {
orderID := chi.URLParam(r, "orderID")
if orderID == "" {
h.WriteError(w, http.StatusBadRequest, dto.BadRequestError("order ID is required"))
return
}

ledger, err := h.repo.GetLatestLedger(orderID)
if err != nil {
h.WriteError(w, http.StatusInternalServerError, dto.InternalError())
return
}

if ledger == nil {
h.WriteError(w, http.StatusNotFound, dto.NotFoundError("ledger"))
return
}

response := toLedgerResponse(ledger)
h.WriteJSON(w, http.StatusOK, response)
}

// GetHistoryByOrderID handles GET /api/orders/{orderID}/ledgers - returns all ledgers for an order.
func (h *LedgersHandler) GetHistoryByOrderID(w http.ResponseWriter, r *http.Request) {
orderID := chi.URLParam(r, "orderID")
if orderID == "" {
h.WriteError(w, http.StatusBadRequest, dto.BadRequestError("order ID is required"))
return
}

ledgers, err := h.repo.GetLedgerHistory(orderID)
if err != nil {
h.WriteError(w, http.StatusInternalServerError, dto.InternalError())
return
}

response := dto.LedgerListResponse{
Ledgers: make([]dto.LedgerResponse, 0, len(ledgers)),
TotalCount: len(ledgers),
Limit: len(ledgers),
Offset: 0,
}

for _, ledger := range ledgers {
response.Ledgers = append(response.Ledgers, toLedgerResponse(ledger))
}

h.WriteJSON(w, http.StatusOK, response)
}

// toLedgerResponse converts a storage OrderLedger to an API response.
func toLedgerResponse(ledger *storage.OrderLedger) dto.LedgerResponse {
response := dto.LedgerResponse{
ID: ledger.ID,
OrderID: ledger.OrderID,
SyncRunID: ledger.SyncRunID,
Provider: ledger.Provider,
FetchedAt: ledger.FetchedAt.Format("2006-01-02T15:04:05Z"),
LedgerState: string(ledger.LedgerState),
LedgerVersion: ledger.LedgerVersion,
TotalCharged: ledger.TotalCharged,
ChargeCount: ledger.ChargeCount,
PaymentMethodTypes: ledger.PaymentMethodTypes,
HasRefunds: ledger.HasRefunds,
IsValid: ledger.IsValid,
ValidationNotes: ledger.ValidationNotes,
Charges: make([]dto.ChargeResponse, 0, len(ledger.Charges)),
}

for _, charge := range ledger.Charges {
response.Charges = append(response.Charges, dto.ChargeResponse{
ID: charge.ID,
ChargeSequence: charge.ChargeSequence,
ChargeAmount: charge.ChargeAmount,
ChargeType: charge.ChargeType,
PaymentMethod: charge.PaymentMethod,
CardType: charge.CardType,
CardLastFour: charge.CardLastFour,
MonarchTransactionID: charge.MonarchTransactionID,
IsMatched: charge.IsMatched,
MatchConfidence: charge.MatchConfidence,
SplitCount: charge.SplitCount,
})
}

return response
}
7 changes: 7 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ func (s *Server) setupRoutes() {
statsHandler := handlers.NewStatsHandler(s.repo)
r.Get("/stats", statsHandler.Get)

// Ledgers
ledgersHandler := handlers.NewLedgersHandler(s.repo)
r.Get("/ledgers", ledgersHandler.List)
r.Get("/ledgers/{id}", ledgersHandler.Get)
r.Get("/orders/{orderID}/ledger", ledgersHandler.GetByOrderID)
r.Get("/orders/{orderID}/ledgers", ledgersHandler.GetHistoryByOrderID)

// Sync operations (live sync jobs)
if s.syncService != nil {
syncHandler := handlers.NewSyncHandler(s.syncService)
Expand Down
Loading
Loading