package orders import ( "context" "crypto/rand" "database/sql" "encoding/hex" "errors" "fmt" "strings" "time" "github.com/jackc/pgx/v5" "royal-pop-backend/internal/database" ) var ErrOrderNotFound = errors.New("order not found") const ( StatusPending = "pending" StatusPaid = "paid" StatusFailed = "failed" StatusProcessing = "processing" StatusCanceled = "canceled" FulfillmentPending = "pending" FulfillmentPaid = "paid" FulfillmentProcessing = "processing" FulfillmentPacked = "packed" FulfillmentShipped = "shipped" FulfillmentDelivered = "delivered" FulfillmentCancelled = "cancelled" ) type CustomerDetails struct { Email string Phone string FirstName string LastName string AddressLine1 string AddressLine2 string City string Region string PostalCode string Country string Notes string } type LineItem struct { ItemID string Style string ColorwayID string FinishID string Quantity int64 UnitAmount int64 } type CreateOrderInput struct { OrderID string Customer CustomerDetails Items []LineItem Currency string Amount int64 StripePriceID string StripePaymentIntentID string } type Order struct { ID string Status string FulfillmentStatus string Email string Phone string FirstName string LastName string AddressLine1 string AddressLine2 string City string Region string PostalCode string Country string Notes string Currency string Amount int64 StripePriceID string StripePaymentIntentID string ShippingCarrier string TrackingNumber string FulfillmentNotes string ShippedAt *time.Time WebhookStatus string WebhookEventID string WebhookEventType string WebhookMessage string Items []LineItem CreatedAt time.Time UpdatedAt time.Time } type WebhookUpdate struct { Status string WebhookStatus string WebhookEventID string WebhookEventType string WebhookMessage string } type ListFilter struct { Limit int Search string FulfillmentStatus string } type UpdateFulfillmentInput struct { OrderID string FulfillmentStatus string ShippingCarrier string TrackingNumber string FulfillmentNotes string ClearShippedAt bool MarkShippedAtNow bool } type Store struct { db *database.DB } func NewStore(db *database.DB) *Store { return &Store{db: db} } func (s *Store) CreatePendingOrder(ctx context.Context, input CreateOrderInput) (*Order, error) { if s == nil || s.db == nil || s.db.Pool == nil { return nil, fmt.Errorf("postgres store is not configured") } tx, err := s.db.Pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) _, err = tx.Exec(ctx, ` INSERT INTO orders ( id, status, email, phone, first_name, last_name, address_line_1, address_line_2, city, region, postal_code, country, notes, currency, amount, stripe_price_id, stripe_payment_intent_id, webhook_status, webhook_message ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19 ) `, input.OrderID, StatusPending, input.Customer.Email, input.Customer.Phone, input.Customer.FirstName, input.Customer.LastName, input.Customer.AddressLine1, input.Customer.AddressLine2, input.Customer.City, input.Customer.Region, input.Customer.PostalCode, input.Customer.Country, input.Customer.Notes, input.Currency, input.Amount, input.StripePriceID, input.StripePaymentIntentID, "awaiting_webhook", "Waiting for Stripe webhook verification.", ) if err != nil { return nil, err } for _, item := range input.Items { quantity := item.Quantity if quantity <= 0 { quantity = 1 } _, err = tx.Exec(ctx, ` INSERT INTO order_items (order_id, item_id, style, colorway_id, finish_id, quantity, unit_amount) VALUES ($1, $2, $3, $4, $5, $6, $7) `, input.OrderID, item.ItemID, item.Style, item.ColorwayID, item.FinishID, quantity, item.UnitAmount) if err != nil { return nil, err } } if err := tx.Commit(ctx); err != nil { return nil, err } return s.GetByPaymentIntentID(ctx, input.StripePaymentIntentID) } func (s *Store) GetByPaymentIntentID(ctx context.Context, paymentIntentID string) (*Order, error) { if s == nil || s.db == nil || s.db.Pool == nil { return nil, fmt.Errorf("postgres store is not configured") } row := s.db.Pool.QueryRow(ctx, ` SELECT id, status, fulfillment_status, email, phone, first_name, last_name, address_line_1, address_line_2, city, region, postal_code, country, notes, currency, amount, stripe_price_id, stripe_payment_intent_id, shipping_carrier, tracking_number, fulfillment_notes, shipped_at, webhook_status, webhook_event_id, webhook_event_type, webhook_message, created_at, updated_at FROM orders WHERE stripe_payment_intent_id = $1 `, paymentIntentID) var order Order var shippedAt sql.NullTime if err := row.Scan( &order.ID, &order.Status, &order.FulfillmentStatus, &order.Email, &order.Phone, &order.FirstName, &order.LastName, &order.AddressLine1, &order.AddressLine2, &order.City, &order.Region, &order.PostalCode, &order.Country, &order.Notes, &order.Currency, &order.Amount, &order.StripePriceID, &order.StripePaymentIntentID, &order.ShippingCarrier, &order.TrackingNumber, &order.FulfillmentNotes, &shippedAt, &order.WebhookStatus, &order.WebhookEventID, &order.WebhookEventType, &order.WebhookMessage, &order.CreatedAt, &order.UpdatedAt, ); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrOrderNotFound } return nil, err } if shippedAt.Valid { order.ShippedAt = &shippedAt.Time } items, err := s.listItemsByOrderID(ctx, order.ID) if err != nil { return nil, err } order.Items = items return &order, nil } func (s *Store) ListRecent(ctx context.Context, limit int) ([]Order, error) { if s == nil || s.db == nil || s.db.Pool == nil { return nil, fmt.Errorf("postgres store is not configured") } if limit <= 0 { limit = 25 } if limit > 100 { limit = 100 } rows, err := s.db.Pool.Query(ctx, ` SELECT id, status, fulfillment_status, email, phone, first_name, last_name, address_line_1, address_line_2, city, region, postal_code, country, notes, currency, amount, stripe_price_id, stripe_payment_intent_id, shipping_carrier, tracking_number, fulfillment_notes, shipped_at, webhook_status, webhook_event_id, webhook_event_type, webhook_message, created_at, updated_at FROM orders ORDER BY created_at DESC LIMIT $1 `, limit) if err != nil { return nil, err } defer rows.Close() ordersList := make([]Order, 0, limit) for rows.Next() { var order Order var shippedAt sql.NullTime if err := rows.Scan( &order.ID, &order.Status, &order.FulfillmentStatus, &order.Email, &order.Phone, &order.FirstName, &order.LastName, &order.AddressLine1, &order.AddressLine2, &order.City, &order.Region, &order.PostalCode, &order.Country, &order.Notes, &order.Currency, &order.Amount, &order.StripePriceID, &order.StripePaymentIntentID, &order.ShippingCarrier, &order.TrackingNumber, &order.FulfillmentNotes, &shippedAt, &order.WebhookStatus, &order.WebhookEventID, &order.WebhookEventType, &order.WebhookMessage, &order.CreatedAt, &order.UpdatedAt, ); err != nil { return nil, err } if shippedAt.Valid { order.ShippedAt = &shippedAt.Time } items, err := s.listItemsByOrderID(ctx, order.ID) if err != nil { return nil, err } order.Items = items ordersList = append(ordersList, order) } if err := rows.Err(); err != nil { return nil, err } return ordersList, nil } func (s *Store) ListForDashboard(ctx context.Context, filter ListFilter) ([]Order, error) { if s == nil || s.db == nil || s.db.Pool == nil { return nil, fmt.Errorf("postgres store is not configured") } limit := filter.Limit if limit <= 0 { limit = 50 } if limit > 200 { limit = 200 } search := strings.TrimSpace(filter.Search) fulfillmentStatus := strings.TrimSpace(strings.ToLower(filter.FulfillmentStatus)) rows, err := s.db.Pool.Query(ctx, ` SELECT id, status, fulfillment_status, email, phone, first_name, last_name, address_line_1, address_line_2, city, region, postal_code, country, notes, currency, amount, stripe_price_id, stripe_payment_intent_id, shipping_carrier, tracking_number, fulfillment_notes, shipped_at, webhook_status, webhook_event_id, webhook_event_type, webhook_message, created_at, updated_at FROM orders WHERE ($1 = '' OR fulfillment_status = $1) AND ( $2 = '' OR id ILIKE '%' || $2 || '%' OR first_name ILIKE '%' || $2 || '%' OR last_name ILIKE '%' || $2 || '%' OR email ILIKE '%' || $2 || '%' OR tracking_number ILIKE '%' || $2 || '%' ) ORDER BY created_at DESC LIMIT $3 `, fulfillmentStatus, search, limit) if err != nil { return nil, err } defer rows.Close() ordersList := make([]Order, 0, limit) for rows.Next() { var order Order var shippedAt sql.NullTime if err := rows.Scan( &order.ID, &order.Status, &order.FulfillmentStatus, &order.Email, &order.Phone, &order.FirstName, &order.LastName, &order.AddressLine1, &order.AddressLine2, &order.City, &order.Region, &order.PostalCode, &order.Country, &order.Notes, &order.Currency, &order.Amount, &order.StripePriceID, &order.StripePaymentIntentID, &order.ShippingCarrier, &order.TrackingNumber, &order.FulfillmentNotes, &shippedAt, &order.WebhookStatus, &order.WebhookEventID, &order.WebhookEventType, &order.WebhookMessage, &order.CreatedAt, &order.UpdatedAt, ); err != nil { return nil, err } if shippedAt.Valid { order.ShippedAt = &shippedAt.Time } items, err := s.listItemsByOrderID(ctx, order.ID) if err != nil { return nil, err } order.Items = items ordersList = append(ordersList, order) } if err := rows.Err(); err != nil { return nil, err } return ordersList, nil } func (s *Store) GetByID(ctx context.Context, orderID string) (*Order, error) { if s == nil || s.db == nil || s.db.Pool == nil { return nil, fmt.Errorf("postgres store is not configured") } row := s.db.Pool.QueryRow(ctx, ` SELECT id, status, fulfillment_status, email, phone, first_name, last_name, address_line_1, address_line_2, city, region, postal_code, country, notes, currency, amount, stripe_price_id, stripe_payment_intent_id, shipping_carrier, tracking_number, fulfillment_notes, shipped_at, webhook_status, webhook_event_id, webhook_event_type, webhook_message, created_at, updated_at FROM orders WHERE id = $1 `, strings.TrimSpace(orderID)) var order Order var shippedAt sql.NullTime if err := row.Scan( &order.ID, &order.Status, &order.FulfillmentStatus, &order.Email, &order.Phone, &order.FirstName, &order.LastName, &order.AddressLine1, &order.AddressLine2, &order.City, &order.Region, &order.PostalCode, &order.Country, &order.Notes, &order.Currency, &order.Amount, &order.StripePriceID, &order.StripePaymentIntentID, &order.ShippingCarrier, &order.TrackingNumber, &order.FulfillmentNotes, &shippedAt, &order.WebhookStatus, &order.WebhookEventID, &order.WebhookEventType, &order.WebhookMessage, &order.CreatedAt, &order.UpdatedAt, ); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrOrderNotFound } return nil, err } if shippedAt.Valid { order.ShippedAt = &shippedAt.Time } items, err := s.listItemsByOrderID(ctx, order.ID) if err != nil { return nil, err } order.Items = items return &order, nil } func (s *Store) UpdateFulfillment(ctx context.Context, input UpdateFulfillmentInput) (*Order, error) { if s == nil || s.db == nil || s.db.Pool == nil { return nil, fmt.Errorf("postgres store is not configured") } status := normalizeFulfillmentStatus(input.FulfillmentStatus) if status == "" { return nil, fmt.Errorf("invalid fulfillment status") } trimmedOrderID := strings.TrimSpace(input.OrderID) shippingCarrier := strings.TrimSpace(input.ShippingCarrier) trackingNumber := strings.TrimSpace(input.TrackingNumber) fulfillmentNotes := strings.TrimSpace(input.FulfillmentNotes) var shippedAt any if input.MarkShippedAtNow { shippedAt = time.Now().UTC() } commandTag, err := s.db.Pool.Exec(ctx, ` UPDATE orders SET fulfillment_status = $2, shipping_carrier = $3, tracking_number = $4, fulfillment_notes = $5, shipped_at = CASE WHEN $6::timestamptz IS NOT NULL THEN $6::timestamptz WHEN $7 THEN NULL ELSE shipped_at END, updated_at = NOW() WHERE id = $1 `, trimmedOrderID, status, shippingCarrier, trackingNumber, fulfillmentNotes, shippedAt, input.ClearShippedAt) if err != nil { return nil, err } if commandTag.RowsAffected() == 0 { return nil, ErrOrderNotFound } return s.GetByID(ctx, trimmedOrderID) } func (s *Store) UpdateFromWebhook(ctx context.Context, paymentIntentID string, update WebhookUpdate) error { if s == nil || s.db == nil || s.db.Pool == nil { return fmt.Errorf("postgres store is not configured") } fulfillmentStatus := "" switch update.Status { case StatusPaid: fulfillmentStatus = FulfillmentPaid case StatusFailed, StatusCanceled: fulfillmentStatus = FulfillmentCancelled } commandTag, err := s.db.Pool.Exec(ctx, ` UPDATE orders SET status = $2, fulfillment_status = CASE WHEN $7 <> '' AND (fulfillment_status = '' OR fulfillment_status = $8 OR fulfillment_status = $9) THEN $7 ELSE fulfillment_status END, webhook_status = $3, webhook_event_id = $4, webhook_event_type = $5, webhook_message = $6, updated_at = NOW() WHERE stripe_payment_intent_id = $1 `, paymentIntentID, update.Status, update.WebhookStatus, update.WebhookEventID, update.WebhookEventType, update.WebhookMessage, fulfillmentStatus, FulfillmentPending, FulfillmentPaid) if err != nil { return err } if commandTag.RowsAffected() == 0 { return ErrOrderNotFound } return nil } func NewOrderID() string { raw := make([]byte, 8) _, _ = rand.Read(raw) return "rpo_" + hex.EncodeToString(raw) } func (s *Store) listItemsByOrderID(ctx context.Context, orderID string) ([]LineItem, error) { rows, err := s.db.Pool.Query(ctx, ` SELECT item_id, style, colorway_id, finish_id, quantity, unit_amount FROM order_items WHERE order_id = $1 ORDER BY id ASC `, orderID) if err != nil { return nil, err } defer rows.Close() items := make([]LineItem, 0) for rows.Next() { var item LineItem if err := rows.Scan( &item.ItemID, &item.Style, &item.ColorwayID, &item.FinishID, &item.Quantity, &item.UnitAmount, ); err != nil { return nil, err } items = append(items, item) } if err := rows.Err(); err != nil { return nil, err } return items, nil } func normalizeFulfillmentStatus(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case FulfillmentPending: return FulfillmentPending case FulfillmentPaid: return FulfillmentPaid case FulfillmentProcessing: return FulfillmentProcessing case FulfillmentPacked: return FulfillmentPacked case FulfillmentShipped: return FulfillmentShipped case FulfillmentDelivered: return FulfillmentDelivered case FulfillmentCancelled, StatusCanceled: return FulfillmentCancelled default: return "" } }