diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go index 41ac72d..2f52f6a 100644 --- a/backend/internal/domain/models.go +++ b/backend/internal/domain/models.go @@ -87,14 +87,15 @@ type InventoryAdjustment struct { // Order captures the status lifecycle and payment intent. type Order struct { - ID uuid.UUID `db:"id" json:"id"` - BuyerID uuid.UUID `db:"buyer_id" json:"buyer_id"` - SellerID uuid.UUID `db:"seller_id" json:"seller_id"` - Status OrderStatus `db:"status" json:"status"` - TotalCents int64 `db:"total_cents" json:"total_cents"` - Items []OrderItem `json:"items"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID uuid.UUID `db:"id" json:"id"` + BuyerID uuid.UUID `db:"buyer_id" json:"buyer_id"` + SellerID uuid.UUID `db:"seller_id" json:"seller_id"` + Status OrderStatus `db:"status" json:"status"` + TotalCents int64 `db:"total_cents" json:"total_cents"` + Items []OrderItem `json:"items"` + Shipping ShippingAddress `json:"shipping"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } // OrderItem stores SKU-level batch tracking. @@ -118,6 +119,51 @@ type PaymentPreference struct { PaymentURL string `json:"payment_url"` } +// PaymentWebhookEvent represents Mercado Pago notifications with split amounts. +type PaymentWebhookEvent struct { + PaymentID string `json:"payment_id"` + OrderID uuid.UUID `json:"order_id"` + Status string `json:"status"` + MarketplaceFee int64 `json:"marketplace_fee"` + SellerAmount int64 `json:"seller_amount"` + TotalPaidAmount int64 `json:"total_paid_amount"` +} + +// PaymentSplitResult echoes the amounts distributed between actors. +type PaymentSplitResult struct { + OrderID uuid.UUID `json:"order_id"` + PaymentID string `json:"payment_id"` + Status string `json:"status"` + MarketplaceFee int64 `json:"marketplace_fee"` + SellerReceivable int64 `json:"seller_receivable"` + TotalPaidAmount int64 `json:"total_paid_amount"` +} + +// ShippingAddress captures delivery details at order time. +type ShippingAddress struct { + RecipientName string `json:"recipient_name" db:"shipping_recipient_name"` + Street string `json:"street" db:"shipping_street"` + Number string `json:"number" db:"shipping_number"` + Complement string `json:"complement,omitempty" db:"shipping_complement"` + District string `json:"district" db:"shipping_district"` + City string `json:"city" db:"shipping_city"` + State string `json:"state" db:"shipping_state"` + ZipCode string `json:"zip_code" db:"shipping_zip_code"` + Country string `json:"country" db:"shipping_country"` +} + +// Shipment stores freight label data and tracking linkage. +type Shipment struct { + ID uuid.UUID `db:"id" json:"id"` + OrderID uuid.UUID `db:"order_id" json:"order_id"` + Carrier string `db:"carrier" json:"carrier"` + TrackingCode string `db:"tracking_code" json:"tracking_code"` + ExternalTracking string `db:"external_tracking" json:"external_tracking"` + Status string `db:"status" json:"status"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + // OrderStatus enumerates supported transitions. type OrderStatus string diff --git a/backend/internal/http/handler/handler.go b/backend/internal/http/handler/handler.go index 1145f04..ae8ed8d 100644 --- a/backend/internal/http/handler/handler.go +++ b/backend/internal/http/handler/handler.go @@ -290,6 +290,7 @@ func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) { BuyerID: req.BuyerID, SellerID: req.SellerID, Items: req.Items, + Shipping: req.Shipping, } var total int64 @@ -454,6 +455,83 @@ func (h *Handler) CreatePaymentPreference(w http.ResponseWriter, r *http.Request writeJSON(w, http.StatusCreated, pref) } +// CreateShipment godoc +// @Summary Gera guia de postagem/transporte +// @Tags Logistica +// @Accept json +// @Produce json +// @Param shipment body createShipmentRequest true "Dados de envio" +// @Success 201 {object} domain.Shipment +// @Router /api/v1/shipments [post] +func (h *Handler) CreateShipment(w http.ResponseWriter, r *http.Request) { + var req createShipmentRequest + if err := decodeJSON(r.Context(), r, &req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + shipment := &domain.Shipment{ + OrderID: req.OrderID, + Carrier: req.Carrier, + TrackingCode: req.TrackingCode, + ExternalTracking: req.ExternalTracking, + } + + if err := h.svc.CreateShipment(r.Context(), shipment); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + writeJSON(w, http.StatusCreated, shipment) +} + +// GetShipmentByOrderID godoc +// @Summary Rastreia entrega +// @Tags Logistica +// @Produce json +// @Param order_id path string true "Order ID" +// @Success 200 {object} domain.Shipment +// @Router /api/v1/shipments/{order_id} [get] +func (h *Handler) GetShipmentByOrderID(w http.ResponseWriter, r *http.Request) { + orderID, err := parseUUIDFromPath(r.URL.Path) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + shipment, err := h.svc.GetShipmentByOrderID(r.Context(), orderID) + if err != nil { + writeError(w, http.StatusNotFound, err) + return + } + + writeJSON(w, http.StatusOK, shipment) +} + +// HandlePaymentWebhook godoc +// @Summary Recebe notificações do Mercado Pago +// @Tags Pagamentos +// @Accept json +// @Produce json +// @Param notification body domain.PaymentWebhookEvent true "Evento do gateway" +// @Success 200 {object} domain.PaymentSplitResult +// @Router /api/v1/payments/webhook [post] +func (h *Handler) HandlePaymentWebhook(w http.ResponseWriter, r *http.Request) { + var event domain.PaymentWebhookEvent + if err := decodeJSON(r.Context(), r, &event); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + summary, err := h.svc.HandlePaymentWebhook(r.Context(), event) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + writeJSON(w, http.StatusOK, summary) +} + // CreateUser handles the creation of platform users. func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) { requester, err := getRequester(r) @@ -772,9 +850,17 @@ type registerProductRequest struct { } type createOrderRequest struct { - BuyerID uuid.UUID `json:"buyer_id"` - SellerID uuid.UUID `json:"seller_id"` - Items []domain.OrderItem `json:"items"` + BuyerID uuid.UUID `json:"buyer_id"` + SellerID uuid.UUID `json:"seller_id"` + Items []domain.OrderItem `json:"items"` + Shipping domain.ShippingAddress `json:"shipping"` +} + +type createShipmentRequest struct { + OrderID uuid.UUID `json:"order_id"` + Carrier string `json:"carrier"` + TrackingCode string `json:"tracking_code"` + ExternalTracking string `json:"external_tracking"` } type updateStatusRequest struct { diff --git a/backend/internal/repository/postgres/postgres.go b/backend/internal/repository/postgres/postgres.go index 1247951..9b1291a 100644 --- a/backend/internal/repository/postgres/postgres.go +++ b/backend/internal/repository/postgres/postgres.go @@ -114,9 +114,9 @@ func (r *Repository) CreateOrder(ctx context.Context, order *domain.Order) error return err } - orderQuery := `INSERT INTO orders (id, buyer_id, seller_id, status, total_cents, created_at, updated_at) -VALUES ($1, $2, $3, $4, $5, $6, $7)` - if _, err := tx.ExecContext(ctx, orderQuery, order.ID, order.BuyerID, order.SellerID, order.Status, order.TotalCents, order.CreatedAt, order.UpdatedAt); err != nil { + orderQuery := `INSERT INTO orders (id, buyer_id, seller_id, status, total_cents, shipping_recipient_name, shipping_street, shipping_number, shipping_complement, shipping_district, shipping_city, shipping_state, shipping_zip_code, shipping_country, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)` + if _, err := tx.ExecContext(ctx, orderQuery, order.ID, order.BuyerID, order.SellerID, order.Status, order.TotalCents, order.Shipping.RecipientName, order.Shipping.Street, order.Shipping.Number, order.Shipping.Complement, order.Shipping.District, order.Shipping.City, order.Shipping.State, order.Shipping.ZipCode, order.Shipping.Country, order.CreatedAt, order.UpdatedAt); err != nil { _ = tx.Rollback() return err } @@ -136,9 +136,26 @@ VALUES ($1, $2, $3, $4, $5, $6, $7)` } func (r *Repository) GetOrder(ctx context.Context, id uuid.UUID) (*domain.Order, error) { - var order domain.Order - orderQuery := `SELECT id, buyer_id, seller_id, status, total_cents, created_at, updated_at FROM orders WHERE id = $1` - if err := r.db.GetContext(ctx, &order, orderQuery, id); err != nil { + var row struct { + ID uuid.UUID `db:"id"` + BuyerID uuid.UUID `db:"buyer_id"` + SellerID uuid.UUID `db:"seller_id"` + Status domain.OrderStatus `db:"status"` + TotalCents int64 `db:"total_cents"` + ShippingRecipientName string `db:"shipping_recipient_name"` + ShippingStreet string `db:"shipping_street"` + ShippingNumber string `db:"shipping_number"` + ShippingComplement string `db:"shipping_complement"` + ShippingDistrict string `db:"shipping_district"` + ShippingCity string `db:"shipping_city"` + ShippingState string `db:"shipping_state"` + ShippingZipCode string `db:"shipping_zip_code"` + ShippingCountry string `db:"shipping_country"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + orderQuery := `SELECT id, buyer_id, seller_id, status, total_cents, shipping_recipient_name, shipping_street, shipping_number, shipping_complement, shipping_district, shipping_city, shipping_state, shipping_zip_code, shipping_country, created_at, updated_at FROM orders WHERE id = $1` + if err := r.db.GetContext(ctx, &row, orderQuery, id); err != nil { return nil, err } @@ -147,8 +164,28 @@ func (r *Repository) GetOrder(ctx context.Context, id uuid.UUID) (*domain.Order, if err := r.db.SelectContext(ctx, &items, itemQuery, id); err != nil { return nil, err } - order.Items = items - return &order, nil + order := &domain.Order{ + ID: row.ID, + BuyerID: row.BuyerID, + SellerID: row.SellerID, + Status: row.Status, + TotalCents: row.TotalCents, + Items: items, + Shipping: domain.ShippingAddress{ + RecipientName: row.ShippingRecipientName, + Street: row.ShippingStreet, + Number: row.ShippingNumber, + Complement: row.ShippingComplement, + District: row.ShippingDistrict, + City: row.ShippingCity, + State: row.ShippingState, + ZipCode: row.ShippingZipCode, + Country: row.ShippingCountry, + }, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } + return order, nil } func (r *Repository) UpdateOrderStatus(ctx context.Context, id uuid.UUID, status domain.OrderStatus) error { @@ -167,6 +204,27 @@ func (r *Repository) UpdateOrderStatus(ctx context.Context, id uuid.UUID, status return nil } +func (r *Repository) CreateShipment(ctx context.Context, shipment *domain.Shipment) error { + now := time.Now().UTC() + shipment.CreatedAt = now + shipment.UpdatedAt = now + + query := `INSERT INTO shipments (id, order_id, carrier, tracking_code, external_tracking, status, created_at, updated_at) +VALUES (:id, :order_id, :carrier, :tracking_code, :external_tracking, :status, :created_at, :updated_at)` + + _, err := r.db.NamedExecContext(ctx, query, shipment) + return err +} + +func (r *Repository) GetShipmentByOrderID(ctx context.Context, orderID uuid.UUID) (*domain.Shipment, error) { + var shipment domain.Shipment + query := `SELECT id, order_id, carrier, tracking_code, external_tracking, status, created_at, updated_at FROM shipments WHERE order_id = $1` + if err := r.db.GetContext(ctx, &shipment, query, orderID); err != nil { + return nil, err + } + return &shipment, nil +} + func (r *Repository) AdjustInventory(ctx context.Context, productID uuid.UUID, delta int64, reason string) (*domain.InventoryItem, error) { tx, err := r.db.BeginTxx(ctx, nil) if err != nil { @@ -452,6 +510,15 @@ CREATE TABLE IF NOT EXISTS orders ( seller_id UUID NOT NULL REFERENCES companies(id), status TEXT NOT NULL, total_cents BIGINT NOT NULL, + shipping_recipient_name TEXT, + shipping_street TEXT, + shipping_number TEXT, + shipping_complement TEXT, + shipping_district TEXT, + shipping_city TEXT, + shipping_state TEXT, + shipping_zip_code TEXT, + shipping_country TEXT, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); @@ -478,6 +545,17 @@ CREATE TABLE IF NOT EXISTS cart_items ( updated_at TIMESTAMPTZ NOT NULL, UNIQUE (buyer_id, product_id) ); + +CREATE TABLE IF NOT EXISTS shipments ( + id UUID PRIMARY KEY, + order_id UUID NOT NULL UNIQUE REFERENCES orders(id), + carrier TEXT NOT NULL, + tracking_code TEXT, + external_tracking TEXT, + status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); ` if _, err := r.db.ExecContext(ctx, schema); err != nil { diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index aba9603..e73f34d 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -37,7 +37,7 @@ func New(cfg config.Config) (*Server, error) { repo := postgres.New(db) gateway := payments.NewMercadoPagoGateway(cfg.MercadoPagoBaseURL, cfg.MarketplaceCommission) - svc := usecase.NewService(repo, gateway, cfg.JWTSecret, cfg.JWTExpiresIn) + svc := usecase.NewService(repo, gateway, cfg.MarketplaceCommission, cfg.JWTSecret, cfg.JWTExpiresIn) h := handler.New(svc) mux := http.NewServeMux() @@ -78,6 +78,11 @@ func New(cfg config.Config) (*Server, error) { mux.Handle("PATCH /api/orders/", chain(http.HandlerFunc(h.UpdateOrderStatus), middleware.Logger, middleware.Gzip, auth)) mux.Handle("POST /api/orders/", chain(http.HandlerFunc(h.CreatePaymentPreference), middleware.Logger, middleware.Gzip, auth)) + mux.Handle("POST /api/v1/shipments", chain(http.HandlerFunc(h.CreateShipment), middleware.Logger, middleware.Gzip, auth)) + mux.Handle("GET /api/v1/shipments/", chain(http.HandlerFunc(h.GetShipmentByOrderID), middleware.Logger, middleware.Gzip, auth)) + + mux.Handle("POST /api/v1/payments/webhook", chain(http.HandlerFunc(h.HandlePaymentWebhook), middleware.Logger, middleware.Gzip)) + mux.Handle("POST /api/v1/auth/register", chain(http.HandlerFunc(h.Register), middleware.Logger, middleware.Gzip)) mux.Handle("POST /api/v1/auth/login", chain(http.HandlerFunc(h.Login), middleware.Logger, middleware.Gzip)) diff --git a/backend/internal/usecase/usecase.go b/backend/internal/usecase/usecase.go index fb477d0..c227ce0 100644 --- a/backend/internal/usecase/usecase.go +++ b/backend/internal/usecase/usecase.go @@ -3,6 +3,7 @@ package usecase import ( "context" "errors" + "strings" "time" "github.com/golang-jwt/jwt/v5" @@ -29,6 +30,8 @@ type Repository interface { CreateOrder(ctx context.Context, order *domain.Order) error GetOrder(ctx context.Context, id uuid.UUID) (*domain.Order, error) UpdateOrderStatus(ctx context.Context, id uuid.UUID, status domain.OrderStatus) error + CreateShipment(ctx context.Context, shipment *domain.Shipment) error + GetShipmentByOrderID(ctx context.Context, orderID uuid.UUID) (*domain.Shipment, error) CreateUser(ctx context.Context, user *domain.User) error ListUsers(ctx context.Context, filter domain.UserFilter) ([]domain.User, int64, error) @@ -48,15 +51,16 @@ type PaymentGateway interface { } type Service struct { - repo Repository - pay PaymentGateway - jwtSecret []byte - tokenTTL time.Duration + repo Repository + pay PaymentGateway + jwtSecret []byte + tokenTTL time.Duration + marketplaceCommission float64 } // NewService wires use cases together. -func NewService(repo Repository, pay PaymentGateway, jwtSecret string, tokenTTL time.Duration) *Service { - return &Service{repo: repo, pay: pay, jwtSecret: []byte(jwtSecret), tokenTTL: tokenTTL} +func NewService(repo Repository, pay PaymentGateway, commissionPct float64, jwtSecret string, tokenTTL time.Duration) *Service { + return &Service{repo: repo, pay: pay, jwtSecret: []byte(jwtSecret), tokenTTL: tokenTTL, marketplaceCommission: commissionPct} } func (s *Service) RegisterCompany(ctx context.Context, company *domain.Company) error { @@ -103,6 +107,22 @@ func (s *Service) UpdateOrderStatus(ctx context.Context, id uuid.UUID, status do return s.repo.UpdateOrderStatus(ctx, id, status) } +// CreateShipment persists a freight label for an order if not already present. +func (s *Service) CreateShipment(ctx context.Context, shipment *domain.Shipment) error { + if _, err := s.repo.GetOrder(ctx, shipment.OrderID); err != nil { + return err + } + + shipment.ID = uuid.Must(uuid.NewV7()) + shipment.Status = "Label gerada" + return s.repo.CreateShipment(ctx, shipment) +} + +// GetShipmentByOrderID returns freight details for an order. +func (s *Service) GetShipmentByOrderID(ctx context.Context, orderID uuid.UUID) (*domain.Shipment, error) { + return s.repo.GetShipmentByOrderID(ctx, orderID) +} + func (s *Service) CreatePaymentPreference(ctx context.Context, id uuid.UUID) (*domain.PaymentPreference, error) { order, err := s.repo.GetOrder(ctx, id) if err != nil { @@ -111,6 +131,40 @@ func (s *Service) CreatePaymentPreference(ctx context.Context, id uuid.UUID) (*d return s.pay.CreatePreference(ctx, order) } +// HandlePaymentWebhook processes Mercado Pago notifications ensuring split consistency. +func (s *Service) HandlePaymentWebhook(ctx context.Context, event domain.PaymentWebhookEvent) (*domain.PaymentSplitResult, error) { + order, err := s.repo.GetOrder(ctx, event.OrderID) + if err != nil { + return nil, err + } + + expectedMarketplaceFee := int64(float64(order.TotalCents) * (s.marketplaceCommission / 100)) + marketplaceFee := event.MarketplaceFee + if marketplaceFee == 0 { + marketplaceFee = expectedMarketplaceFee + } + + sellerReceivable := order.TotalCents - marketplaceFee + if event.SellerAmount > 0 { + sellerReceivable = event.SellerAmount + } + + if strings.EqualFold(event.Status, "approved") || strings.EqualFold(event.Status, "paid") { + if err := s.repo.UpdateOrderStatus(ctx, order.ID, domain.OrderStatusPaid); err != nil { + return nil, err + } + } + + return &domain.PaymentSplitResult{ + OrderID: order.ID, + PaymentID: event.PaymentID, + Status: event.Status, + MarketplaceFee: marketplaceFee, + SellerReceivable: sellerReceivable, + TotalPaidAmount: event.TotalPaidAmount, + }, nil +} + func (s *Service) CreateUser(ctx context.Context, user *domain.User, password string) error { hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil {