Add shipment endpoints and payment webhook split handling

This commit is contained in:
Tiago Yamamoto 2025-12-18 12:58:05 -03:00
parent 94c27ec7dc
commit ce825fd1d5
5 changed files with 295 additions and 26 deletions

View file

@ -87,14 +87,15 @@ type InventoryAdjustment struct {
// Order captures the status lifecycle and payment intent. // Order captures the status lifecycle and payment intent.
type Order struct { type Order struct {
ID uuid.UUID `db:"id" json:"id"` ID uuid.UUID `db:"id" json:"id"`
BuyerID uuid.UUID `db:"buyer_id" json:"buyer_id"` BuyerID uuid.UUID `db:"buyer_id" json:"buyer_id"`
SellerID uuid.UUID `db:"seller_id" json:"seller_id"` SellerID uuid.UUID `db:"seller_id" json:"seller_id"`
Status OrderStatus `db:"status" json:"status"` Status OrderStatus `db:"status" json:"status"`
TotalCents int64 `db:"total_cents" json:"total_cents"` TotalCents int64 `db:"total_cents" json:"total_cents"`
Items []OrderItem `json:"items"` Items []OrderItem `json:"items"`
CreatedAt time.Time `db:"created_at" json:"created_at"` Shipping ShippingAddress `json:"shipping"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
} }
// OrderItem stores SKU-level batch tracking. // OrderItem stores SKU-level batch tracking.
@ -118,6 +119,51 @@ type PaymentPreference struct {
PaymentURL string `json:"payment_url"` 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. // OrderStatus enumerates supported transitions.
type OrderStatus string type OrderStatus string

View file

@ -290,6 +290,7 @@ func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
BuyerID: req.BuyerID, BuyerID: req.BuyerID,
SellerID: req.SellerID, SellerID: req.SellerID,
Items: req.Items, Items: req.Items,
Shipping: req.Shipping,
} }
var total int64 var total int64
@ -454,6 +455,83 @@ func (h *Handler) CreatePaymentPreference(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusCreated, pref) 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. // CreateUser handles the creation of platform users.
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) { func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
requester, err := getRequester(r) requester, err := getRequester(r)
@ -772,9 +850,17 @@ type registerProductRequest struct {
} }
type createOrderRequest struct { type createOrderRequest struct {
BuyerID uuid.UUID `json:"buyer_id"` BuyerID uuid.UUID `json:"buyer_id"`
SellerID uuid.UUID `json:"seller_id"` SellerID uuid.UUID `json:"seller_id"`
Items []domain.OrderItem `json:"items"` 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 { type updateStatusRequest struct {

View file

@ -114,9 +114,9 @@ func (r *Repository) CreateOrder(ctx context.Context, order *domain.Order) error
return err return err
} }
orderQuery := `INSERT INTO orders (id, buyer_id, seller_id, status, total_cents, created_at, updated_at) 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)` 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.CreatedAt, order.UpdatedAt); err != nil { 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() _ = tx.Rollback()
return err 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) { func (r *Repository) GetOrder(ctx context.Context, id uuid.UUID) (*domain.Order, error) {
var order domain.Order var row struct {
orderQuery := `SELECT id, buyer_id, seller_id, status, total_cents, created_at, updated_at FROM orders WHERE id = $1` ID uuid.UUID `db:"id"`
if err := r.db.GetContext(ctx, &order, orderQuery, id); err != nil { 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 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 { if err := r.db.SelectContext(ctx, &items, itemQuery, id); err != nil {
return nil, err return nil, err
} }
order.Items = items order := &domain.Order{
return &order, nil 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 { 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 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) { func (r *Repository) AdjustInventory(ctx context.Context, productID uuid.UUID, delta int64, reason string) (*domain.InventoryItem, error) {
tx, err := r.db.BeginTxx(ctx, nil) tx, err := r.db.BeginTxx(ctx, nil)
if err != nil { if err != nil {
@ -452,6 +510,15 @@ CREATE TABLE IF NOT EXISTS orders (
seller_id UUID NOT NULL REFERENCES companies(id), seller_id UUID NOT NULL REFERENCES companies(id),
status TEXT NOT NULL, status TEXT NOT NULL,
total_cents BIGINT 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, created_at TIMESTAMPTZ NOT NULL,
updated_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, updated_at TIMESTAMPTZ NOT NULL,
UNIQUE (buyer_id, product_id) 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 { if _, err := r.db.ExecContext(ctx, schema); err != nil {

View file

@ -37,7 +37,7 @@ func New(cfg config.Config) (*Server, error) {
repo := postgres.New(db) repo := postgres.New(db)
gateway := payments.NewMercadoPagoGateway(cfg.MercadoPagoBaseURL, cfg.MarketplaceCommission) 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) h := handler.New(svc)
mux := http.NewServeMux() 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("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/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/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)) mux.Handle("POST /api/v1/auth/login", chain(http.HandlerFunc(h.Login), middleware.Logger, middleware.Gzip))

View file

@ -3,6 +3,7 @@ package usecase
import ( import (
"context" "context"
"errors" "errors"
"strings"
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@ -29,6 +30,8 @@ type Repository interface {
CreateOrder(ctx context.Context, order *domain.Order) error CreateOrder(ctx context.Context, order *domain.Order) error
GetOrder(ctx context.Context, id uuid.UUID) (*domain.Order, error) GetOrder(ctx context.Context, id uuid.UUID) (*domain.Order, error)
UpdateOrderStatus(ctx context.Context, id uuid.UUID, status domain.OrderStatus) 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 CreateUser(ctx context.Context, user *domain.User) error
ListUsers(ctx context.Context, filter domain.UserFilter) ([]domain.User, int64, error) ListUsers(ctx context.Context, filter domain.UserFilter) ([]domain.User, int64, error)
@ -48,15 +51,16 @@ type PaymentGateway interface {
} }
type Service struct { type Service struct {
repo Repository repo Repository
pay PaymentGateway pay PaymentGateway
jwtSecret []byte jwtSecret []byte
tokenTTL time.Duration tokenTTL time.Duration
marketplaceCommission float64
} }
// NewService wires use cases together. // NewService wires use cases together.
func NewService(repo Repository, pay PaymentGateway, jwtSecret string, tokenTTL time.Duration) *Service { 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} return &Service{repo: repo, pay: pay, jwtSecret: []byte(jwtSecret), tokenTTL: tokenTTL, marketplaceCommission: commissionPct}
} }
func (s *Service) RegisterCompany(ctx context.Context, company *domain.Company) error { 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) 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) { func (s *Service) CreatePaymentPreference(ctx context.Context, id uuid.UUID) (*domain.PaymentPreference, error) {
order, err := s.repo.GetOrder(ctx, id) order, err := s.repo.GetOrder(ctx, id)
if err != nil { if err != nil {
@ -111,6 +131,40 @@ func (s *Service) CreatePaymentPreference(ctx context.Context, id uuid.UUID) (*d
return s.pay.CreatePreference(ctx, order) 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 { func (s *Service) CreateUser(ctx context.Context, user *domain.User, password string) error {
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil { if err != nil {