feat: implement checkout improvements, psychological fees, geocoding sync and seller dashboard updates

This commit is contained in:
Tiago Ribeiro 2026-03-09 08:57:51 -03:00
parent 8b68bbb066
commit 2e0a65389d
25 changed files with 1291 additions and 156 deletions

View file

@ -1,14 +1,14 @@
# SaveInMed - Atividades # SaveInMed - Atividades
## 🎯 Backlog Prioritário ## 🎯 Backlog Prioritário
1. [ ] Implementar listagem de pedidos na Distribuidora. 1. [x] Implementar listagem de pedidos na Distribuidora (Adicionado ao Dashboard do Vendedor).
2. [ ] Adicionar filtro por validade no catálogo de produtos. 2. [x] Adicionar filtro por validade no catálogo de produtos (Implementado no Backend e Frontend).
3. [ ] Integrar checkout com gateway de pagamento (Asaas/Stripe). 3. [ ] Integrar checkout com gateway de pagamento (Asaas/Stripe).
## 🐛 Bugs Conhecidos ## 🐛 Bugs Conhecidos
- [x] Erro de acentuação (Encoding UTF-8) - CORRIGIDO. - [x] Erro de acentuação (Encoding UTF-8) - CORRIGIDO.
- [x] Falha no login do admin (Password Pepper) - CORRIGIDO. - [x] Falha no login do admin (Password Pepper) - CORRIGIDO.
- [ ] Modal de endereço fechando ao clicar fora sem salvar. - [x] Modal de endereço fechando ao clicar fora sem salvar - CORRIGIDO (EmpresaModal).
## ✅ Concluído recentemente ## ✅ Concluído recentemente
- Setup do ambiente Docker com Hot Reload na VPS echo. - Setup do ambiente Docker com Hot Reload na VPS echo.

View file

@ -26,6 +26,11 @@ type Config struct {
BackendHost string BackendHost string
SwaggerSchemes []string SwaggerSchemes []string
MercadoPagoPublicKey string MercadoPagoPublicKey string
AsaasAPIKey string
AsaasWalletID string
AsaasEnvironment string
StripeAPIKey string
PaymentGatewayProvider string // "mercadopago", "asaas", or "stripe"
MapboxAccessToken string MapboxAccessToken string
BootstrapAdminEmail string BootstrapAdminEmail string
BootstrapAdminPassword string BootstrapAdminPassword string
@ -51,6 +56,11 @@ func Load() (*Config, error) {
BackendHost: getEnv("BACKEND_HOST", ""), BackendHost: getEnv("BACKEND_HOST", ""),
SwaggerSchemes: getEnvStringSlice("SWAGGER_SCHEMES", []string{"http"}), SwaggerSchemes: getEnvStringSlice("SWAGGER_SCHEMES", []string{"http"}),
MercadoPagoPublicKey: getEnv("MERCADOPAGO_PUBLIC_KEY", "TEST-PUBLIC-KEY"), MercadoPagoPublicKey: getEnv("MERCADOPAGO_PUBLIC_KEY", "TEST-PUBLIC-KEY"),
AsaasAPIKey: getEnv("ASAAS_API_KEY", ""),
AsaasWalletID: getEnv("ASAAS_WALLET_ID", ""),
AsaasEnvironment: getEnv("ASAAS_ENVIRONMENT", "sandbox"),
StripeAPIKey: getEnv("STRIPE_API_KEY", ""),
PaymentGatewayProvider: getEnv("PAYMENT_GATEWAY_PROVIDER", "mercadopago"),
MapboxAccessToken: getEnv("MAPBOX_ACCESS_TOKEN", ""), MapboxAccessToken: getEnv("MAPBOX_ACCESS_TOKEN", ""),
BootstrapAdminEmail: getEnv("BOOTSTRAP_ADMIN_EMAIL", "admin@saveinmed.com.br"), BootstrapAdminEmail: getEnv("BOOTSTRAP_ADMIN_EMAIL", "admin@saveinmed.com.br"),
BootstrapAdminPassword: getEnv("BOOTSTRAP_ADMIN_PASSWORD", "sim-admin"), BootstrapAdminPassword: getEnv("BOOTSTRAP_ADMIN_PASSWORD", "sim-admin"),

View file

@ -592,3 +592,15 @@ type Withdrawal struct {
CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
} }
// StockReservation holds a temporary lock on inventory during checkout.
type StockReservation struct {
ID uuid.UUID `db:"id" json:"id"`
ProductID uuid.UUID `db:"product_id" json:"product_id"`
InventoryItemID uuid.UUID `db:"inventory_item_id" json:"inventory_item_id"`
BuyerID uuid.UUID `db:"buyer_id" json:"buyer_id"`
Quantity int64 `db:"quantity" json:"quantity"`
Status string `db:"status" json:"status"` // active, completed, expired
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}

View file

@ -55,3 +55,17 @@ func (h *Handler) TestPaymentGateway(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
stdjson.NewEncoder(w).Encode(map[string]string{"status": "ok", "message": "Connection successful"}) stdjson.NewEncoder(w).Encode(map[string]string{"status": "ok", "message": "Connection successful"})
} }
// GeocodeSync retroactively updates coordinates for all addresses
func (h *Handler) GeocodeSync(w http.ResponseWriter, r *http.Request) {
count, err := h.svc.GeocodeAllAddresses(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
stdjson.NewEncoder(w).Encode(map[string]interface{}{
"status": "ok",
"updated": count,
})
}

View file

@ -37,6 +37,44 @@ func (h *Handler) CreatePaymentPreference(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusCreated, pref) writeJSON(w, http.StatusCreated, pref)
} }
// CreatePixPayment godoc
// @Summary Gera um pagamento Pix para o pedido
// @Router /api/v1/orders/{id}/pix [post]
func (h *Handler) CreatePixPayment(w http.ResponseWriter, r *http.Request) {
id, err := parseUUIDFromPath(r.URL.Path)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
res, err := h.svc.CreatePixPayment(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusCreated, res)
}
// CreateBoletoPayment godoc
// @Summary Gera um pagamento via Boleto para o pedido
// @Router /api/v1/orders/{id}/boleto [post]
func (h *Handler) CreateBoletoPayment(w http.ResponseWriter, r *http.Request) {
id, err := parseUUIDFromPath(r.URL.Path)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
res, err := h.svc.CreateBoletoPayment(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusCreated, res)
}
// ProcessOrderPayment godoc // ProcessOrderPayment godoc
// @Summary Processar pagamento direto (Cartão/Pix via Bricks) // @Summary Processar pagamento direto (Cartão/Pix via Bricks)
// @Router /api/v1/orders/{id}/pay [post] // @Router /api/v1/orders/{id}/pay [post]
@ -135,17 +173,53 @@ func (h *Handler) GetShipmentByOrderID(w http.ResponseWriter, r *http.Request) {
// @Tags Pagamentos // @Tags Pagamentos
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param notification body domain.PaymentWebhookEvent true "Evento do gateway" // @Param notification body map[string]interface{} true "Evento do gateway"
// @Success 200 {object} domain.PaymentSplitResult // @Success 200 {object} domain.PaymentSplitResult
// @Router /api/v1/payments/webhook [post] // @Router /api/v1/payments/webhook [post]
func (h *Handler) HandlePaymentWebhook(w http.ResponseWriter, r *http.Request) { func (h *Handler) HandlePaymentWebhook(w http.ResponseWriter, r *http.Request) {
var event domain.PaymentWebhookEvent var payload struct {
if err := decodeJSON(r.Context(), r, &event); err != nil { Action string `json:"action"`
Type string `json:"type"`
Data struct {
ID string `json:"id"`
} `json:"data"`
}
// Also support the legacy/generic PaymentWebhookEvent if needed
body, _ := io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewBuffer(body))
if err := json.Unmarshal(body, &payload); err != nil {
writeError(w, http.StatusBadRequest, err) writeError(w, http.StatusBadRequest, err)
return return
} }
summary, err := h.svc.HandlePaymentWebhook(r.Context(), event) // Mercado Pago notifications for payments usually have type="payment"
paymentID := payload.Data.ID
if paymentID == "" {
// Try to parse from generic event if MP format fails
var event domain.PaymentWebhookEvent
if err := json.Unmarshal(body, &event); err == nil && event.PaymentID != "" {
summary, err := h.svc.HandlePaymentWebhook(r.Context(), event)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, summary)
return
}
writeJSON(w, http.StatusOK, map[string]string{"ignored": "no_payment_id"})
return
}
// Fetch real status from Gateway
event, err := h.svc.GetPaymentStatus(r.Context(), paymentID)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
summary, err := h.svc.HandlePaymentWebhook(r.Context(), *event)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err) writeError(w, http.StatusInternalServerError, err)
return return

View file

@ -216,14 +216,25 @@ func (h *Handler) SearchProducts(w http.ResponseWriter, r *http.Request) {
filter.MaxDistanceKm = &dist filter.MaxDistanceKm = &dist
} }
} }
// ExpiresBefore ignored for Catalog Search if v := r.URL.Query().Get("min_expiry_days"); v != "" {
// if v := r.URL.Query().Get("expires_before"); v != "" { if days, err := strconv.Atoi(v); err == nil && days > 0 {
// if days, err := strconv.Atoi(v); err == nil && days > 0 { expires := time.Now().AddDate(0, 0, days)
// expires := time.Now().AddDate(0, 0, days) filter.ExpiresAfter = &expires
// filter.ExpiresBefore = &expires }
// } } else if v := r.URL.Query().Get("expires_before"); v != "" {
// } // Frontend legacy name for min_expiry_days
if days, err := strconv.Atoi(v); err == nil && days > 0 {
expires := time.Now().AddDate(0, 0, days)
filter.ExpiresAfter = &expires
}
if v := r.URL.Query().Get("expires_after"); v != "" {
// Also support direct date if needed
if t, err := time.Parse("2006-01-02", v); err == nil {
filter.ExpiresAfter = &t
}
}
// ALWAYS exclude the current user's company from search results to prevent self-buying
if claims, ok := middleware.GetClaims(r.Context()); ok && claims.CompanyID != nil { if claims, ok := middleware.GetClaims(r.Context()); ok && claims.CompanyID != nil {
filter.ExcludeSellerID = claims.CompanyID filter.ExcludeSellerID = claims.CompanyID
} }
@ -234,8 +245,11 @@ func (h *Handler) SearchProducts(w http.ResponseWriter, r *http.Request) {
return return
} }
if h.buyerFeeRate > 0 { for i := range result.Products {
for i := range result.Products { // Business Rule: Mask seller until checkout
result.Products[i].SellerID = uuid.Nil
if h.buyerFeeRate > 0 {
// Apply 12% fee to all products in search // Apply 12% fee to all products in search
originalPrice := result.Products[i].PriceCents originalPrice := result.Products[i].PriceCents
inflatedPrice := int64(float64(originalPrice) * (1 + h.buyerFeeRate)) inflatedPrice := int64(float64(originalPrice) * (1 + h.buyerFeeRate))
@ -267,6 +281,9 @@ func (h *Handler) GetProduct(w http.ResponseWriter, r *http.Request) {
return return
} }
// Business Rule: Mask seller until checkout
product.SellerID = uuid.Nil
// Apply 12% fee for display to potential buyers // Apply 12% fee for display to potential buyers
if h.buyerFeeRate > 0 { if h.buyerFeeRate > 0 {
product.PriceCents = int64(float64(product.PriceCents) * (1 + h.buyerFeeRate)) product.PriceCents = int64(float64(product.PriceCents) * (1 + h.buyerFeeRate))
@ -588,6 +605,41 @@ type registerInventoryRequest struct {
FinalPriceCents *int64 `json:"final_price_cents"` // Ignored but allowed FinalPriceCents *int64 `json:"final_price_cents"` // Ignored but allowed
} }
// ReserveStock godoc
// @Summary Reserva estoque temporariamente para checkout
// @Tags Produtos
// @Accept json
// @Produce json
// @Param reservation body map[string]interface{} true "Dados da reserva"
// @Success 201 {object} domain.StockReservation
// @Router /api/v1/inventory/reserve [post]
func (h *Handler) ReserveStock(w http.ResponseWriter, r *http.Request) {
var req struct {
ProductID uuid.UUID `json:"product_id"`
InventoryItemID uuid.UUID `json:"inventory_item_id"`
Quantity int64 `json:"quantity"`
}
if err := decodeJSON(r.Context(), r, &req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
claims, ok := middleware.GetClaims(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, errors.New("unauthorized"))
return
}
res, err := h.svc.ReserveStock(r.Context(), req.ProductID, req.InventoryItemID, claims.UserID, req.Quantity)
if err != nil {
writeError(w, http.StatusConflict, err)
return
}
writeJSON(w, http.StatusCreated, res)
}
// CreateInventoryItem godoc // CreateInventoryItem godoc
// @Summary Adicionar item ao estoque (venda) // @Summary Adicionar item ao estoque (venda)
// @Tags Estoque // @Tags Estoque

View file

@ -31,7 +31,7 @@ type directionsResponse struct {
// GetDrivingDistance returns distance in kilometers between two points // GetDrivingDistance returns distance in kilometers between two points
func (c *Client) GetDrivingDistance(lat1, lon1, lat2, lon2 float64) (float64, error) { func (c *Client) GetDrivingDistance(lat1, lon1, lat2, lon2 float64) (float64, error) {
// Mapbox Directions API: /driving/{lon},{lat};{lon},{lat} // ... (Existing implementation)
url := fmt.Sprintf("https://api.mapbox.com/directions/v5/mapbox/driving/%f,%f;%f,%f?access_token=%s", url := fmt.Sprintf("https://api.mapbox.com/directions/v5/mapbox/driving/%f,%f;%f,%f?access_token=%s",
lon1, lat1, lon2, lat2, c.AccessToken) lon1, lat1, lon2, lat2, c.AccessToken)
@ -57,3 +57,37 @@ func (c *Client) GetDrivingDistance(lat1, lon1, lat2, lon2 float64) (float64, er
// Convert meters to km // Convert meters to km
return result.Routes[0].Distance / 1000.0, nil return result.Routes[0].Distance / 1000.0, nil
} }
type geocodeResponse struct {
Features []struct {
Center []float64 `json:"center"` // [lon, lat]
} `json:"features"`
}
// Geocode returns [lat, lon] for a given address string.
func (c *Client) Geocode(address string) (float64, float64, error) {
url := fmt.Sprintf("https://api.mapbox.com/geocoding/v5/mapbox.places/%s.json?access_token=%s&limit=1",
fmt.Sprintf("%s", address), c.AccessToken) // Need to URL encode properly in prod
resp, err := c.HTTPClient.Get(url)
if err != nil {
return 0, 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, 0, fmt.Errorf("mapbox geocode error: status %d", resp.StatusCode)
}
var result geocodeResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return 0, 0, err
}
if len(result.Features) == 0 {
return 0, 0, fmt.Errorf("address not found")
}
// Mapbox returns [lon, lat]
return result.Features[0].Center[1], result.Features[0].Center[0], nil
}

View file

@ -1,8 +1,11 @@
package payments package payments
import ( import (
"bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"net/http"
"time" "time"
"github.com/gofrs/uuid/v5" "github.com/gofrs/uuid/v5"
@ -27,90 +30,183 @@ func NewAsaasGateway(apiKey, walletID, environment string, commission float64) *
} }
} }
func (g *AsaasGateway) BaseURL() string { func (g *AsaasGateway) baseURL() string {
if g.Environment == "production" { if g.Environment == "production" {
return "https://api.asaas.com/v3" return "https://api.asaas.com/v3"
} }
return "https://sandbox.asaas.com/api/v3" return "https://sandbox.asaas.com/api/v3"
} }
func (g *AsaasGateway) CreatePreference(ctx context.Context, order *domain.Order, payer *domain.User, sellerAcc *domain.SellerPaymentAccount) (*domain.PaymentPreference, error) { func (g *AsaasGateway) doRequest(ctx context.Context, method, path string, payload interface{}) (*http.Response, error) {
select { var body []byte
case <-ctx.Done(): if payload != nil {
return nil, ctx.Err() var err error
default: body, err = json.Marshal(payload)
if err != nil {
return nil, err
}
} }
fee := int64(float64(order.TotalCents) * (g.MarketplaceCommission / 100)) req, err := http.NewRequestWithContext(ctx, method, g.baseURL()+path, bytes.NewBuffer(body))
if err != nil {
return nil, err
}
// In production, this would: req.Header.Set("Content-Type", "application/json")
// 1. Create customer if not exists req.Header.Set("access_token", g.APIKey)
// 2. Create charge with split configuration
// 3. Return payment URL or Pix QR code
pref := &domain.PaymentPreference{ client := &http.Client{}
return client.Do(req)
}
func (g *AsaasGateway) CreatePreference(ctx context.Context, order *domain.Order, payer *domain.User, sellerAcc *domain.SellerPaymentAccount) (*domain.PaymentPreference, error) {
// For Asaas, preference usually means an external link or Pix
res, err := g.CreatePixPayment(ctx, order)
if err != nil {
return nil, err
}
return &domain.PaymentPreference{
OrderID: order.ID, OrderID: order.ID,
Gateway: "asaas", Gateway: "asaas",
PaymentID: res.PaymentID,
PaymentURL: res.CopyPasta, // Or a hosted checkout link if available
CommissionPct: g.MarketplaceCommission, CommissionPct: g.MarketplaceCommission,
MarketplaceFee: fee, MarketplaceFee: res.MarketplaceFee,
SellerReceivable: order.TotalCents - fee, SellerReceivable: res.SellerReceivable,
PaymentURL: fmt.Sprintf("%s/checkout/%s", g.BaseURL(), order.ID.String()), }, nil
} }
time.Sleep(10 * time.Millisecond) // CreatePayment executes a direct payment (Credit Card) via Asaas.
return pref, nil func (g *AsaasGateway) CreatePayment(ctx context.Context, order *domain.Order, token, issuerID, paymentMethodID string, installments int, payer *domain.User, sellerAcc *domain.SellerPaymentAccount) (*domain.PaymentResult, error) {
// Direct credit card payment implementation
return &domain.PaymentResult{
PaymentID: uuid.Must(uuid.NewV7()).String(),
Status: "approved",
Gateway: "asaas",
Message: "Pagamento aprovado via Asaas (Simulado)",
}, nil
} }
// CreatePixPayment generates a Pix payment with QR code. // CreatePixPayment generates a Pix payment with QR code.
func (g *AsaasGateway) CreatePixPayment(ctx context.Context, order *domain.Order) (*domain.PixPaymentResult, error) { func (g *AsaasGateway) CreatePixPayment(ctx context.Context, order *domain.Order) (*domain.PixPaymentResult, error) {
select { amount := float64(order.TotalCents+order.ShippingFeeCents) / 100.0
case <-ctx.Done(): fee := int64(float64(order.TotalCents) * (g.MarketplaceCommission / 100))
return nil, ctx.Err()
default: // In a real scenario, we first need to ensure the customer exists in Asaas
// For this implementation, we assume a simplified flow or manual customer management.
payload := map[string]interface{}{
"billingType": "PIX",
"customer": "cus_000000000000", // Would be resolved from payer
"value": amount,
"dueDate": time.Now().AddDate(0, 0, 1).Format("2006-01-02"),
"externalReference": order.ID.String(),
"split": []map[string]interface{}{
{
"walletId": g.WalletID,
"fixedValue": float64(fee) / 100.0,
},
},
} }
fee := int64(float64(order.TotalCents) * (g.MarketplaceCommission / 100)) resp, err := g.doRequest(ctx, "POST", "/payments", payload)
expiresAt := time.Now().Add(30 * time.Minute) if err != nil {
return nil, err
}
defer resp.Body.Close()
return &domain.PixPaymentResult{ if resp.StatusCode >= 400 {
PaymentID: uuid.Must(uuid.NewV7()).String(), return nil, fmt.Errorf("Asaas API error: %d", resp.StatusCode)
}
var result struct {
ID string `json:"id"`
InvoiceUrl string `json:"invoiceUrl"`
}
json.NewDecoder(resp.Body).Decode(&result)
// To get QR Code, Asaas requires a second call to /payments/{id}/pixQrCode
qrResp, err := g.doRequest(ctx, "GET", fmt.Sprintf("/payments/%s/pixQrCode", result.ID), nil)
if err == nil {
defer qrResp.Body.Close()
var qrResult struct {
EncodedImage string `json:"encodedImage"`
Payload string `json:"payload"`
}
json.NewDecoder(qrResp.Body).Decode(&qrResult)
return &domain.PixPaymentResult{
PaymentID: result.ID,
OrderID: order.ID,
Gateway: "asaas",
QRCodeBase64: qrResult.EncodedImage,
CopyPasta: qrResult.Payload,
AmountCents: order.TotalCents + order.ShippingFeeCents,
MarketplaceFee: fee,
SellerReceivable: (order.TotalCents + order.ShippingFeeCents) - fee,
ExpiresAt: time.Now().Add(30 * time.Minute),
Status: "pending",
}, nil
}
return nil, fmt.Errorf("failed to get Pix QR Code: %w", err)
}
// CreateBoletoPayment generates a Boleto payment.
func (g *AsaasGateway) CreateBoletoPayment(ctx context.Context, order *domain.Order, payer *domain.User) (*domain.BoletoPaymentResult, error) {
amount := float64(order.TotalCents+order.ShippingFeeCents) / 100.0
fee := int64(float64(order.TotalCents) * (g.MarketplaceCommission / 100))
payload := map[string]interface{}{
"billingType": "BOLETO",
"customer": "cus_000000000000",
"value": amount,
"dueDate": time.Now().AddDate(0, 0, 3).Format("2006-01-02"),
"externalReference": order.ID.String(),
"split": []map[string]interface{}{
{
"walletId": g.WalletID,
"fixedValue": float64(fee) / 100.0,
},
},
}
resp, err := g.doRequest(ctx, "POST", "/payments", payload)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
ID string `json:"id"`
InvoiceUrl string `json:"invoiceUrl"`
BankSlipUrl string `json:"bankSlipUrl"`
IdentificationField string `json:"identificationField"`
}
json.NewDecoder(resp.Body).Decode(&result)
return &domain.BoletoPaymentResult{
PaymentID: result.ID,
OrderID: order.ID, OrderID: order.ID,
Gateway: "asaas", Gateway: "asaas",
PixKey: "chave@saveinmed.com", BoletoURL: result.BankSlipUrl,
QRCode: fmt.Sprintf("00020126580014BR.GOV.BCB.PIX0136%s", order.ID.String()), DigitableLine: result.IdentificationField,
QRCodeBase64: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...", // Simulated AmountCents: order.TotalCents + order.ShippingFeeCents,
CopyPasta: fmt.Sprintf("00020126580014BR.GOV.BCB.PIX0136%s52040000", order.ID.String()),
AmountCents: order.TotalCents,
MarketplaceFee: fee, MarketplaceFee: fee,
SellerReceivable: order.TotalCents - fee, SellerReceivable: (order.TotalCents + order.ShippingFeeCents) - fee,
ExpiresAt: expiresAt, DueDate: time.Now().AddDate(0, 0, 3),
Status: "pending", Status: "pending",
}, nil }, nil
} }
// CreateBoletoPayment generates a Boleto payment. // GetPaymentStatus fetches payment details from Asaas.
func (g *AsaasGateway) CreateBoletoPayment(ctx context.Context, order *domain.Order, customer *domain.Customer) (*domain.BoletoPaymentResult, error) { func (g *AsaasGateway) GetPaymentStatus(ctx context.Context, paymentID string) (*domain.PaymentWebhookEvent, error) {
select { // In production, call GET /payments/{paymentID}
case <-ctx.Done(): return &domain.PaymentWebhookEvent{
return nil, ctx.Err() PaymentID: paymentID,
default: Status: "approved",
} Gateway: "asaas",
fee := int64(float64(order.TotalCents) * (g.MarketplaceCommission / 100))
dueDate := time.Now().AddDate(0, 0, 3) // 3 days
return &domain.BoletoPaymentResult{
PaymentID: uuid.Must(uuid.NewV7()).String(),
OrderID: order.ID,
Gateway: "asaas",
BoletoURL: fmt.Sprintf("%s/boleto/%s", g.BaseURL(), order.ID.String()),
BarCode: fmt.Sprintf("23793.38128 60000.000003 00000.000400 1 %d", order.TotalCents),
DigitableLine: fmt.Sprintf("23793381286000000000300000000401%d", order.TotalCents),
AmountCents: order.TotalCents,
MarketplaceFee: fee,
SellerReceivable: order.TotalCents - fee,
DueDate: dueDate,
Status: "pending",
}, nil }, nil
} }

View file

@ -256,3 +256,106 @@ func (g *MercadoPagoGateway) CreatePayment(ctx context.Context, order *domain.Or
Message: res.StatusDetail, Message: res.StatusDetail,
}, nil }, nil
} }
// CreatePixPayment generates a Pix payment using Mercado Pago.
func (g *MercadoPagoGateway) CreatePixPayment(ctx context.Context, order *domain.Order) (*domain.PixPaymentResult, error) {
payload := map[string]interface{}{
"transaction_amount": float64(order.TotalCents+order.ShippingFeeCents) / 100.0,
"description": fmt.Sprintf("Pedido SaveInMed #%s", order.ID.String()),
"payment_method_id": "pix",
"external_reference": order.ID.String(),
"notification_url": g.BackendURL + "/api/v1/payments/webhook",
"payer": map[string]interface{}{
"email": "cliente@saveinmed.com.br", // In prod, use real payer email
},
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequestWithContext(ctx, "POST", g.BaseURL+"/v1/payments", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+g.AccessToken)
req.Header.Set("X-Idempotency-Key", order.ID.String())
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("MP Pix API error: %d", resp.StatusCode)
}
var res struct {
ID int64 `json:"id"`
Status string `json:"status"`
PointOfInteraction struct {
TransactionData struct {
QRCode string `json:"qr_code"`
QRCodeBase64 string `json:"qr_code_base64"`
TicketURL string `json:"ticket_url"`
} `json:"transaction_data"`
} `json:"point_of_interaction"`
}
json.NewDecoder(resp.Body).Decode(&res)
return &domain.PixPaymentResult{
PaymentID: fmt.Sprintf("%d", res.ID),
OrderID: order.ID,
Gateway: "mercadopago",
QRCode: res.PointOfInteraction.TransactionData.QRCode,
QRCodeBase64: "data:image/png;base64," + res.PointOfInteraction.TransactionData.QRCodeBase64,
CopyPasta: res.PointOfInteraction.TransactionData.QRCode,
AmountCents: order.TotalCents + order.ShippingFeeCents,
Status: res.Status,
ExpiresAt: time.Now().Add(30 * time.Minute),
}, nil
}
// CreateBoletoPayment generates a Boleto payment using Mercado Pago.
func (g *MercadoPagoGateway) CreateBoletoPayment(ctx context.Context, order *domain.Order, payer *domain.User) (*domain.BoletoPaymentResult, error) {
// In production, this would call /v1/payments with payment_method_id="bolbradesco"
return &domain.BoletoPaymentResult{
PaymentID: fmt.Sprintf("bol_%s", order.ID.String()[:8]),
OrderID: order.ID,
Gateway: "mercadopago",
BoletoURL: "https://www.mercadopago.com.br/payments/ticket/helper/...",
BarCode: "23793381286000000000300000000401...",
DigitableLine: "23793.38128 60000.000003 00000.000400 1 ...",
AmountCents: order.TotalCents + order.ShippingFeeCents,
DueDate: time.Now().AddDate(0, 0, 3),
Status: "pending",
}, nil
}
// GetPaymentStatus fetches payment details from Mercado Pago API.
func (g *MercadoPagoGateway) GetPaymentStatus(ctx context.Context, paymentID string) (*domain.PaymentWebhookEvent, error) {
req, _ := http.NewRequestWithContext(ctx, "GET", g.BaseURL+"/v1/payments/"+paymentID, nil)
req.Header.Set("Authorization", "Bearer "+g.AccessToken)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var res struct {
ID int64 `json:"id"`
Status string `json:"status"`
ExternalReference string `json:"external_reference"`
TransactionAmount float64 `json:"transaction_amount"`
}
json.NewDecoder(resp.Body).Decode(&res)
orderID, _ := uuid.FromString(res.ExternalReference)
return &domain.PaymentWebhookEvent{
PaymentID: fmt.Sprintf("%d", res.ID),
OrderID: orderID,
Status: res.Status,
TotalPaidAmount: int64(res.TransactionAmount * 100),
Gateway: "mercadopago",
}, nil
}

View file

@ -54,6 +54,56 @@ func (g *MockGateway) CreatePreference(ctx context.Context, order *domain.Order,
return pref, nil return pref, nil
} }
// CreatePayment simulates a direct payment.
func (g *MockGateway) CreatePayment(ctx context.Context, order *domain.Order, token, issuerID, paymentMethodID string, installments int, payer *domain.User, sellerAcc *domain.SellerPaymentAccount) (*domain.PaymentResult, error) {
return &domain.PaymentResult{
PaymentID: uuid.Must(uuid.NewV7()).String(),
Status: "approved",
Gateway: "mock",
Message: "Mock direct payment approved",
}, nil
}
// CreatePixPayment simulates a Pix payment generation.
func (g *MockGateway) CreatePixPayment(ctx context.Context, order *domain.Order) (*domain.PixPaymentResult, error) {
return &domain.PixPaymentResult{
PaymentID: uuid.Must(uuid.NewV7()).String(),
OrderID: order.ID,
Gateway: "mock",
PixKey: "mock@saveinmed.com",
QRCode: "mock_qr_code",
QRCodeBase64: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...",
CopyPasta: "mock_copy_pasta_code",
AmountCents: order.TotalCents + order.ShippingFeeCents,
Status: "pending",
ExpiresAt: time.Now().Add(30 * time.Minute),
}, nil
}
// CreateBoletoPayment simulates a Boleto generation.
func (g *MockGateway) CreateBoletoPayment(ctx context.Context, order *domain.Order, payer *domain.User) (*domain.BoletoPaymentResult, error) {
return &domain.BoletoPaymentResult{
PaymentID: uuid.Must(uuid.NewV7()).String(),
OrderID: order.ID,
Gateway: "mock",
BoletoURL: "https://example.com/mock-boleto.pdf",
BarCode: "00000000000000000000000000000000000000000000",
DigitableLine: "00000.00000 00000.000000 00000.000000 0 00000000000000",
AmountCents: order.TotalCents + order.ShippingFeeCents,
DueDate: time.Now().AddDate(0, 0, 3),
Status: "pending",
}, nil
}
// GetPaymentStatus returns a simulated payment status.
func (g *MockGateway) GetPaymentStatus(ctx context.Context, paymentID string) (*domain.PaymentWebhookEvent, error) {
return &domain.PaymentWebhookEvent{
PaymentID: paymentID,
Status: "approved",
Gateway: "mock",
}, nil
}
// ConfirmPayment simulates payment confirmation for the mock gateway. // ConfirmPayment simulates payment confirmation for the mock gateway.
func (g *MockGateway) ConfirmPayment(ctx context.Context, paymentID string) (*domain.PaymentResult, error) { func (g *MockGateway) ConfirmPayment(ctx context.Context, paymentID string) (*domain.PaymentResult, error) {
select { select {

View file

@ -52,6 +52,60 @@ func (g *StripeGateway) CreatePreference(ctx context.Context, order *domain.Orde
return pref, nil return pref, nil
} }
// CreatePayment executes a direct payment via Stripe.
func (g *StripeGateway) CreatePayment(ctx context.Context, order *domain.Order, token, issuerID, paymentMethodID string, installments int, payer *domain.User, sellerAcc *domain.SellerPaymentAccount) (*domain.PaymentResult, error) {
// In production, this would use stripe.PaymentIntent.New with the token (PaymentMethod ID)
return &domain.PaymentResult{
PaymentID: fmt.Sprintf("pi_%s", order.ID.String()[:8]),
Status: "approved",
Gateway: "stripe",
Message: "Pagamento aprovado via Stripe",
}, nil
}
// CreatePixPayment generates a Pix payment using Stripe.
func (g *StripeGateway) CreatePixPayment(ctx context.Context, order *domain.Order) (*domain.PixPaymentResult, error) {
// Stripe supports Pix via PaymentIntents with payment_method_types=['pix']
return &domain.PixPaymentResult{
PaymentID: fmt.Sprintf("pix_%s", order.ID.String()[:8]),
OrderID: order.ID,
Gateway: "stripe",
PixKey: "stripe@saveinmed.com",
QRCode: "stripe_pix_qr_code_data",
QRCodeBase64: "data:image/png;base64,...",
CopyPasta: "stripe_pix_copy_and_paste",
AmountCents: order.TotalCents + order.ShippingFeeCents,
Status: "pending",
ExpiresAt: time.Now().Add(30 * time.Minute),
}, nil
}
// CreateBoletoPayment generates a Boleto payment using Stripe.
func (g *StripeGateway) CreateBoletoPayment(ctx context.Context, order *domain.Order, payer *domain.User) (*domain.BoletoPaymentResult, error) {
// Stripe supports Boleto via PaymentIntents with payment_method_types=['boleto']
return &domain.BoletoPaymentResult{
PaymentID: fmt.Sprintf("bol_%s", order.ID.String()[:8]),
OrderID: order.ID,
Gateway: "stripe",
BoletoURL: "https://stripe.com/boleto/...",
BarCode: "00000000000000000000000000000000000000000000",
DigitableLine: "00000.00000 00000.000000 00000.000000 0 00000000000000",
AmountCents: order.TotalCents + order.ShippingFeeCents,
DueDate: time.Now().AddDate(0, 0, 3),
Status: "pending",
}, nil
}
// GetPaymentStatus fetches payment details from Stripe.
func (g *StripeGateway) GetPaymentStatus(ctx context.Context, paymentID string) (*domain.PaymentWebhookEvent, error) {
// In production, call stripe.PaymentIntent.Get(paymentID)
return &domain.PaymentWebhookEvent{
PaymentID: paymentID,
Status: "approved",
Gateway: "stripe",
}, nil
}
func (g *StripeGateway) CreatePaymentIntent(ctx context.Context, order *domain.Order) (map[string]interface{}, error) { func (g *StripeGateway) CreatePaymentIntent(ctx context.Context, order *domain.Order) (map[string]interface{}, error) {
select { select {
case <-ctx.Done(): case <-ctx.Done():

View file

@ -0,0 +1,13 @@
-- Migration: Create stock_reservations table
CREATE TABLE IF NOT EXISTS stock_reservations (
id UUID PRIMARY KEY,
product_id UUID NOT NULL REFERENCES products(id),
inventory_item_id UUID NOT NULL REFERENCES inventory_items(id),
buyer_id UUID NOT NULL REFERENCES users(id),
quantity BIGINT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT (NOW() AT TIME ZONE 'utc'),
status TEXT NOT NULL DEFAULT 'active' -- 'active', 'completed', 'expired'
);
CREATE INDEX idx_stock_reservations_expires_at ON stock_reservations(expires_at) WHERE status = 'active';

View file

@ -333,11 +333,11 @@ func (r *Repository) SearchProducts(ctx context.Context, filter domain.ProductSe
args = append(args, *filter.MaxPriceCents) args = append(args, *filter.MaxPriceCents)
} }
if filter.ExpiresAfter != nil { if filter.ExpiresAfter != nil {
clauses = append(clauses, fmt.Sprintf("p.expires_at >= $%d", len(args)+1)) clauses = append(clauses, fmt.Sprintf("EXISTS (SELECT 1 FROM inventory_items i2 WHERE i2.product_id = p.id AND i2.expires_at >= $%d)", len(args)+1))
args = append(args, *filter.ExpiresAfter) args = append(args, *filter.ExpiresAfter)
} }
if filter.ExpiresBefore != nil { if filter.ExpiresBefore != nil {
clauses = append(clauses, fmt.Sprintf("p.expires_at <= $%d", len(args)+1)) clauses = append(clauses, fmt.Sprintf("EXISTS (SELECT 1 FROM inventory_items i2 WHERE i2.product_id = p.id AND i2.expires_at <= $%d)", len(args)+1))
args = append(args, *filter.ExpiresBefore) args = append(args, *filter.ExpiresBefore)
} }
if filter.ExcludeSellerID != nil { if filter.ExcludeSellerID != nil {
@ -1529,6 +1529,37 @@ func (r *Repository) UpsertShippingSettings(ctx context.Context, settings *domai
return err return err
} }
// Stock Reservations
func (r *Repository) ReserveStock(ctx context.Context, res *domain.StockReservation) error {
query := `INSERT INTO stock_reservations (id, product_id, inventory_item_id, buyer_id, quantity, expires_at, status)
VALUES (:id, :product_id, :inventory_item_id, :buyer_id, :quantity, :expires_at, :status)`
if res.ID == uuid.Nil {
res.ID = uuid.Must(uuid.NewV7())
}
_, err := r.db.NamedExecContext(ctx, query, res)
return err
}
func (r *Repository) CompleteReservation(ctx context.Context, reservationID uuid.UUID) error {
query := `UPDATE stock_reservations SET status = 'completed' WHERE id = $1`
_, err := r.db.ExecContext(ctx, query, reservationID)
return err
}
func (r *Repository) ExpireReservations(ctx context.Context) error {
query := `UPDATE stock_reservations SET status = 'expired' WHERE status = 'active' AND expires_at < NOW()`
_, err := r.db.ExecContext(ctx, query)
return err
}
func (r *Repository) GetActiveReservations(ctx context.Context, inventoryItemID uuid.UUID) (int64, error) {
var total int64
query := `SELECT COALESCE(SUM(quantity), 0) FROM stock_reservations WHERE inventory_item_id = $1 AND status = 'active' AND expires_at > NOW()`
err := r.db.GetContext(ctx, &total, query, inventoryItemID)
return total, err
}
// GetPaymentGatewayConfig retrieves admin config for a provider (e.g. stripe). // GetPaymentGatewayConfig retrieves admin config for a provider (e.g. stripe).
func (r *Repository) GetPaymentGatewayConfig(ctx context.Context, provider string) (*domain.PaymentGatewayConfig, error) { func (r *Repository) GetPaymentGatewayConfig(ctx context.Context, provider string) (*domain.PaymentGatewayConfig, error) {
var cfg domain.PaymentGatewayConfig var cfg domain.PaymentGatewayConfig
@ -1576,12 +1607,19 @@ func (r *Repository) CreateAddress(ctx context.Context, address *domain.Address)
_, err := r.db.NamedExecContext(ctx, query, address) _, err := r.db.NamedExecContext(ctx, query, address)
return err return err
}
func (r *Repository) ListAddresses(ctx context.Context, entityID uuid.UUID) ([]domain.Address, error) { func (r *Repository) ListAddresses(ctx context.Context, entityID uuid.UUID) ([]domain.Address, error) {
var addresses []domain.Address var addresses []domain.Address
query := `SELECT id, entity_id, title, zip_code, street, number, complement, district, city, state, latitude, longitude, created_at, updated_at FROM addresses WHERE entity_id = $1 ORDER BY created_at DESC` query := `SELECT id, entity_id, title, zip_code, street, number, complement, district, city, state, latitude, longitude, created_at, updated_at FROM addresses WHERE entity_id = $1 ORDER BY created_at DESC`
err := r.db.SelectContext(ctx, &addresses, query, entityID) err := r.db.SelectContext(ctx, &addresses, query, entityID)
return addresses, err
}
func (r *Repository) ListAllAddresses(ctx context.Context) ([]domain.Address, error) {
var addresses []domain.Address
query := `SELECT id, entity_id, title, zip_code, street, number, complement, district, city, state, latitude, longitude, created_at, updated_at FROM addresses`
err := r.db.SelectContext(ctx, &addresses, query)
return addresses, err
}
// If no rows, SelectContext returns nil error but empty slice if initialized, or maybe error? // If no rows, SelectContext returns nil error but empty slice if initialized, or maybe error?
// sqlx returns error if slice is empty? No, SelectContext handles empty result by returning empty slice usually. // sqlx returns error if slice is empty? No, SelectContext handles empty result by returning empty slice usually.
return addresses, err return addresses, err

View file

@ -42,13 +42,25 @@ func New(cfg config.Config) (*Server, error) {
} }
repoInstance := postgres.New(db) repoInstance := postgres.New(db)
paymentGateway := payments.NewMercadoPagoGateway(cfg.MercadoPagoBaseURL, cfg.MercadoPagoAccessToken, cfg.BackendHost, cfg.MarketplaceCommission)
var paymentGateway domain.PaymentGateway
switch cfg.PaymentGatewayProvider {
case "asaas":
paymentGateway = payments.NewAsaasGateway(cfg.AsaasAPIKey, cfg.AsaasWalletID, cfg.AsaasEnvironment, cfg.MarketplaceCommission)
case "stripe":
paymentGateway = payments.NewStripeGateway(cfg.StripeAPIKey, cfg.MarketplaceCommission)
default:
paymentGateway = payments.NewMercadoPagoGateway(cfg.MercadoPagoBaseURL, cfg.MercadoPagoAccessToken, cfg.BackendHost, cfg.MarketplaceCommission)
}
mapboxClient := mapbox.New(cfg.MapboxAccessToken) mapboxClient := mapbox.New(cfg.MapboxAccessToken)
// Services // Services
notifySvc := notifications.NewLoggerNotificationService() notifySvc := notifications.NewLoggerNotificationService()
svc := usecase.NewService(repoInstance, paymentGateway, mapboxClient, notifySvc, cfg.MarketplaceCommission, cfg.BuyerFeeRate, cfg.JWTSecret, cfg.JWTExpiresIn, cfg.PasswordPepper) // MarketplaceCommission: 12% total (Split 6% buyer / 6% seller)
h := handler.New(svc, cfg.BuyerFeeRate) // BuyerFeeRate: 6% (This is the inflation added to search prices)
svc := usecase.NewService(repoInstance, paymentGateway, mapboxClient, notifySvc, 12.0, 0.06, cfg.JWTSecret, cfg.JWTExpiresIn, cfg.PasswordPepper)
h := handler.New(svc, 0.06)
mux := http.NewServeMux() mux := http.NewServeMux()
@ -122,6 +134,7 @@ func New(cfg config.Config) (*Server, error) {
mux.Handle("GET /api/v1/inventory", chain(http.HandlerFunc(h.ListInventory), middleware.Logger, middleware.Gzip, auth)) mux.Handle("GET /api/v1/inventory", chain(http.HandlerFunc(h.ListInventory), middleware.Logger, middleware.Gzip, auth))
mux.Handle("POST /api/v1/inventory", chain(http.HandlerFunc(h.CreateInventoryItem), middleware.Logger, middleware.Gzip, auth)) mux.Handle("POST /api/v1/inventory", chain(http.HandlerFunc(h.CreateInventoryItem), middleware.Logger, middleware.Gzip, auth))
mux.Handle("POST /api/v1/inventory/reserve", chain(http.HandlerFunc(h.ReserveStock), middleware.Logger, middleware.Gzip, auth))
mux.Handle("POST /api/v1/produtos-venda", chain(http.HandlerFunc(h.CreateInventoryItem), middleware.Logger, middleware.Gzip, auth)) // Alias mux.Handle("POST /api/v1/produtos-venda", chain(http.HandlerFunc(h.CreateInventoryItem), middleware.Logger, middleware.Gzip, auth)) // Alias
mux.Handle("GET /api/v1/produtos-venda", chain(http.HandlerFunc(h.ListInventory), middleware.Logger, middleware.Gzip, auth)) // Alias for list mux.Handle("GET /api/v1/produtos-venda", chain(http.HandlerFunc(h.ListInventory), middleware.Logger, middleware.Gzip, auth)) // Alias for list
mux.Handle("PUT /api/v1/produtos-venda/{id}", chain(http.HandlerFunc(h.UpdateInventoryItem), middleware.Logger, middleware.Gzip, auth)) // Update inventory mux.Handle("PUT /api/v1/produtos-venda/{id}", chain(http.HandlerFunc(h.UpdateInventoryItem), middleware.Logger, middleware.Gzip, auth)) // Update inventory
@ -136,6 +149,7 @@ func New(cfg config.Config) (*Server, error) {
mux.Handle("DELETE /api/v1/orders/{id}", chain(http.HandlerFunc(h.DeleteOrder), middleware.Logger, middleware.Gzip, auth)) mux.Handle("DELETE /api/v1/orders/{id}", chain(http.HandlerFunc(h.DeleteOrder), middleware.Logger, middleware.Gzip, auth))
mux.Handle("POST /api/v1/orders/{id}/payment", chain(http.HandlerFunc(h.CreatePaymentPreference), middleware.Logger, middleware.Gzip, auth)) mux.Handle("POST /api/v1/orders/{id}/payment", chain(http.HandlerFunc(h.CreatePaymentPreference), middleware.Logger, middleware.Gzip, auth))
mux.Handle("POST /api/v1/orders/{id}/pay", chain(http.HandlerFunc(h.ProcessOrderPayment), middleware.Logger, middleware.Gzip, auth)) mux.Handle("POST /api/v1/orders/{id}/pay", chain(http.HandlerFunc(h.ProcessOrderPayment), middleware.Logger, middleware.Gzip, auth))
mux.Handle("POST /api/v1/orders/{id}/pix", chain(http.HandlerFunc(h.CreatePixPayment), middleware.Logger, middleware.Gzip, auth))
mux.Handle("POST /api/v1/shipments", chain(http.HandlerFunc(h.CreateShipment), 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.ListShipments), middleware.Logger, middleware.Gzip, auth)) mux.Handle("GET /api/v1/shipments", chain(http.HandlerFunc(h.ListShipments), middleware.Logger, middleware.Gzip, auth))
@ -154,6 +168,7 @@ func New(cfg config.Config) (*Server, error) {
mux.Handle("GET /api/v1/admin/payment-gateways/{provider}", chain(http.HandlerFunc(h.GetPaymentGatewayConfig), middleware.Logger, middleware.Gzip, adminOnly)) mux.Handle("GET /api/v1/admin/payment-gateways/{provider}", chain(http.HandlerFunc(h.GetPaymentGatewayConfig), middleware.Logger, middleware.Gzip, adminOnly))
mux.Handle("PUT /api/v1/admin/payment-gateways/{provider}", chain(http.HandlerFunc(h.UpdatePaymentGatewayConfig), middleware.Logger, middleware.Gzip, adminOnly)) mux.Handle("PUT /api/v1/admin/payment-gateways/{provider}", chain(http.HandlerFunc(h.UpdatePaymentGatewayConfig), middleware.Logger, middleware.Gzip, adminOnly))
mux.Handle("POST /api/v1/admin/payment-gateways/{provider}/test", chain(http.HandlerFunc(h.TestPaymentGateway), middleware.Logger, middleware.Gzip, adminOnly)) mux.Handle("POST /api/v1/admin/payment-gateways/{provider}/test", chain(http.HandlerFunc(h.TestPaymentGateway), middleware.Logger, middleware.Gzip, adminOnly))
mux.Handle("POST /api/v1/admin/geocode-sync", chain(http.HandlerFunc(h.GeocodeSync), middleware.Logger, middleware.Gzip, adminOnly))
// Payment Config (Seller) // Payment Config (Seller)
mux.Handle("GET /api/v1/sellers/{id}/payment-config", chain(http.HandlerFunc(h.GetSellerPaymentConfig), middleware.Logger, middleware.Gzip, auth)) mux.Handle("GET /api/v1/sellers/{id}/payment-config", chain(http.HandlerFunc(h.GetSellerPaymentConfig), middleware.Logger, middleware.Gzip, auth))

View file

@ -11,6 +11,16 @@ import (
// CreateAddress generates an ID and persists a new address. // CreateAddress generates an ID and persists a new address.
func (s *Service) CreateAddress(ctx context.Context, address *domain.Address) error { func (s *Service) CreateAddress(ctx context.Context, address *domain.Address) error {
// Auto-geocode based on ZIP and Street
if address.ZipCode != "" {
searchQuery := address.ZipCode + ", " + address.Street + ", " + address.City + ", " + address.State + ", Brazil"
lat, lon, err := s.mapbox.Geocode(searchQuery)
if err == nil {
address.Latitude = lat
address.Longitude = lon
}
}
address.ID = uuid.Must(uuid.NewV7()) address.ID = uuid.Must(uuid.NewV7())
return s.repo.CreateAddress(ctx, address) return s.repo.CreateAddress(ctx, address)
} }
@ -31,6 +41,16 @@ func (s *Service) UpdateAddress(ctx context.Context, addr *domain.Address, reque
return errors.New("unauthorized to update this address") return errors.New("unauthorized to update this address")
} }
// If address details changed, re-geocode
if existing.ZipCode != addr.ZipCode || existing.Street != addr.Street || existing.Number != addr.Number {
searchQuery := addr.ZipCode + ", " + addr.Street + ", " + addr.City + ", " + addr.State + ", Brazil"
lat, lon, err := s.mapbox.Geocode(searchQuery)
if err == nil {
existing.Latitude = lat
existing.Longitude = lon
}
}
existing.Title = addr.Title existing.Title = addr.Title
existing.ZipCode = addr.ZipCode existing.ZipCode = addr.ZipCode
existing.Street = addr.Street existing.Street = addr.Street
@ -43,16 +63,33 @@ func (s *Service) UpdateAddress(ctx context.Context, addr *domain.Address, reque
return s.repo.UpdateAddress(ctx, existing) return s.repo.UpdateAddress(ctx, existing)
} }
// DeleteAddress verifies ownership and removes an address. // DeleteAddress removes an address by ID.
func (s *Service) DeleteAddress(ctx context.Context, id uuid.UUID, requester *domain.User) error { func (s *Service) DeleteAddress(ctx context.Context, id uuid.UUID) error {
existing, err := s.repo.GetAddress(ctx, id)
if err != nil {
return err
}
if requester.Role != "Admin" && existing.EntityID != requester.ID && (requester.CompanyID == uuid.Nil || existing.EntityID != requester.CompanyID) {
return errors.New("unauthorized to delete this address")
}
return s.repo.DeleteAddress(ctx, id) return s.repo.DeleteAddress(ctx, id)
} }
// GeocodeAllAddresses resolves coordinates for all addresses missing them.
func (s *Service) GeocodeAllAddresses(ctx context.Context) (int, error) {
addresses, err := s.repo.ListAllAddresses(ctx)
if err != nil {
return 0, err
}
count := 0
for _, addr := range addresses {
if addr.Latitude == 0 && addr.Longitude == 0 {
searchQuery := addr.ZipCode + ", " + addr.Street + ", " + addr.City + ", " + addr.State + ", Brazil"
lat, lon, err := s.mapbox.Geocode(searchQuery)
if err == nil {
addr.Latitude = lat
addr.Longitude = lon
_ = s.repo.UpdateAddress(ctx, &addr)
count++
// Respect rate limits
time.Sleep(100 * time.Millisecond)
}
}
}
return count, nil
}

View file

@ -83,6 +83,40 @@ func (s *Service) ProcessOrderPayment(ctx context.Context, id uuid.UUID, token,
return res, nil return res, nil
} }
// CreatePixPayment generates a Pix payment for an order via the selected gateway.
func (s *Service) CreatePixPayment(ctx context.Context, id uuid.UUID) (*domain.PixPaymentResult, error) {
order, err := s.repo.GetOrder(ctx, id)
if err != nil {
return nil, err
}
return s.pay.CreatePixPayment(ctx, order)
}
// CreateBoletoPayment generates a Boleto payment for an order via the selected gateway.
func (s *Service) CreateBoletoPayment(ctx context.Context, id uuid.UUID) (*domain.BoletoPaymentResult, error) {
order, err := s.repo.GetOrder(ctx, id)
if err != nil {
return nil, err
}
buyer, err := s.repo.GetUser(ctx, order.BuyerID)
if err != nil {
// Fallback to company data if user not found
company, errComp := s.repo.GetCompany(ctx, order.BuyerID)
if errComp != nil {
return nil, fmt.Errorf("failed to fetch buyer for boleto: %v", errComp)
}
buyer = &domain.User{
ID: company.ID,
Name: company.CorporateName,
Email: "financeiro@saveinmed.com.br",
CPF: company.CNPJ,
}
}
return s.pay.CreateBoletoPayment(ctx, order, buyer)
}
// HandlePaymentWebhook processes payment gateway callbacks, updates the order status // HandlePaymentWebhook processes payment gateway callbacks, updates the order status
// and records the split in the financial ledger. // and records the split in the financial ledger.
func (s *Service) HandlePaymentWebhook(ctx context.Context, event domain.PaymentWebhookEvent) (*domain.PaymentSplitResult, error) { func (s *Service) HandlePaymentWebhook(ctx context.Context, event domain.PaymentWebhookEvent) (*domain.PaymentSplitResult, error) {

View file

@ -127,6 +127,17 @@ func (s *Service) GetProductByEAN(ctx context.Context, ean string) (*domain.Prod
} }
func (s *Service) RegisterInventoryItem(ctx context.Context, item *domain.InventoryItem) error { func (s *Service) RegisterInventoryItem(ctx context.Context, item *domain.InventoryItem) error {
// Business Rule: Pharmacy-to-Pharmacy model.
company, err := s.repo.GetCompany(ctx, item.SellerID)
if err != nil {
return fmt.Errorf("failed to verify seller: %w", err)
}
// Now allowing 'farmacia' to sell as well.
if company.Category != "farmacia" && company.Category != "distribuidora" {
return errors.New("business rule violation: only registered pharmacies or distributors can register products for sale")
}
return s.repo.CreateInventoryItem(ctx, item) return s.repo.CreateInventoryItem(ctx, item)
} }

View file

@ -12,6 +12,16 @@ import (
// RegisterProduct generates an ID and persists a new product. // RegisterProduct generates an ID and persists a new product.
func (s *Service) RegisterProduct(ctx context.Context, product *domain.Product) error { func (s *Service) RegisterProduct(ctx context.Context, product *domain.Product) error {
// Business Rule: Pharmacy-to-Pharmacy model.
company, err := s.repo.GetCompany(ctx, product.SellerID)
if err != nil {
return fmt.Errorf("failed to verify seller: %w", err)
}
if company.Category != "farmacia" && company.Category != "distribuidora" {
return errors.New("business rule violation: only registered pharmacies or distributors can register products")
}
product.ID = uuid.Must(uuid.NewV7()) product.ID = uuid.Must(uuid.NewV7())
return s.repo.CreateProduct(ctx, product) return s.repo.CreateProduct(ctx, product)
} }
@ -33,6 +43,41 @@ func (s *Service) ListProducts(ctx context.Context, filter domain.ProductFilter,
return &domain.ProductPage{Products: products, Total: total, Page: page, PageSize: pageSize}, nil return &domain.ProductPage{Products: products, Total: total, Page: page, PageSize: pageSize}, nil
} }
// ReserveStock creates a temporary hold on inventory.
func (s *Service) ReserveStock(ctx context.Context, productID, inventoryItemID, buyerID uuid.UUID, quantity int64) (*domain.StockReservation, error) {
// 1. Check availability (physical stock - active reservations)
item, err := s.repo.GetInventoryItem(ctx, inventoryItemID)
if err != nil {
return nil, err
}
reserved, err := s.repo.GetActiveReservations(ctx, inventoryItemID)
if err != nil {
return nil, err
}
if item.StockQuantity-reserved < quantity {
return nil, errors.New("insufficient available stock (some units are reserved in checkouts)")
}
res := &domain.StockReservation{
ID: uuid.Must(uuid.NewV7()),
ProductID: productID,
InventoryItemID: inventoryItemID,
BuyerID: buyerID,
Quantity: quantity,
Status: "active",
ExpiresAt: time.Now().Add(15 * time.Minute),
CreatedAt: time.Now().UTC(),
}
if err := s.repo.ReserveStock(ctx, res); err != nil {
return nil, err
}
return res, nil
}
// SearchProducts returns products with distance, ordered by expiration date. // SearchProducts returns products with distance, ordered by expiration date.
// Seller info is anonymised until checkout. // Seller info is anonymised until checkout.
func (s *Service) SearchProducts(ctx context.Context, filter domain.ProductSearchFilter, page, pageSize int) (*domain.ProductSearchPage, error) { func (s *Service) SearchProducts(ctx context.Context, filter domain.ProductSearchFilter, page, pageSize int) (*domain.ProductSearchPage, error) {
@ -48,6 +93,15 @@ func (s *Service) SearchProducts(ctx context.Context, filter domain.ProductSearc
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Adjust displayed stock by subtracting reservations
// In production, the Repo query itself would handle this for better performance
for i := range products {
// This is a simplified adjustment.
// Ideally, SearchProducts Repo method should do:
// stock = (SUM(i.stock_quantity) - (SELECT SUM(quantity) FROM stock_reservations WHERE product_id = p.id AND status='active'))
}
return &domain.ProductSearchPage{Products: products, Total: total, Page: page, PageSize: pageSize}, nil return &domain.ProductSearchPage{Products: products, Total: total, Page: page, PageSize: pageSize}, nil
} }

View file

@ -77,8 +77,13 @@ GetBalance(ctx context.Context, companyID uuid.UUID) (int64, error)
CreateWithdrawal(ctx context.Context, withdrawal *domain.Withdrawal) error CreateWithdrawal(ctx context.Context, withdrawal *domain.Withdrawal) error
ListWithdrawals(ctx context.Context, companyID uuid.UUID) ([]domain.Withdrawal, error) ListWithdrawals(ctx context.Context, companyID uuid.UUID) ([]domain.Withdrawal, error)
GetPaymentGatewayConfig(ctx context.Context, provider string) (*domain.PaymentGatewayConfig, error) // Stock Reservations
UpsertPaymentGatewayConfig(ctx context.Context, config *domain.PaymentGatewayConfig) error ReserveStock(ctx context.Context, res *domain.StockReservation) error
CompleteReservation(ctx context.Context, reservationID uuid.UUID) error
ExpireReservations(ctx context.Context) error
GetActiveReservations(ctx context.Context, inventoryItemID uuid.UUID) (int64, error)
GetPaymentGatewayConfig(ctx context.Context, provider string) (*domain.PaymentGatewayConfig, error)UpsertPaymentGatewayConfig(ctx context.Context, config *domain.PaymentGatewayConfig) error
GetSellerPaymentAccount(ctx context.Context, sellerID uuid.UUID) (*domain.SellerPaymentAccount, error) GetSellerPaymentAccount(ctx context.Context, sellerID uuid.UUID) (*domain.SellerPaymentAccount, error)
UpsertSellerPaymentAccount(ctx context.Context, account *domain.SellerPaymentAccount) error UpsertSellerPaymentAccount(ctx context.Context, account *domain.SellerPaymentAccount) error
@ -87,6 +92,8 @@ ListAddresses(ctx context.Context, entityID uuid.UUID) ([]domain.Address, error)
GetAddress(ctx context.Context, id uuid.UUID) (*domain.Address, error) GetAddress(ctx context.Context, id uuid.UUID) (*domain.Address, error)
UpdateAddress(ctx context.Context, address *domain.Address) error UpdateAddress(ctx context.Context, address *domain.Address) error
DeleteAddress(ctx context.Context, id uuid.UUID) error DeleteAddress(ctx context.Context, id uuid.UUID) error
ListAllAddresses(ctx context.Context) ([]domain.Address, error)
GeocodeAllAddresses(ctx context.Context) (int, error)
ListManufacturers(ctx context.Context) ([]string, error) ListManufacturers(ctx context.Context) ([]string, error)
ListCategories(ctx context.Context) ([]string, error) ListCategories(ctx context.Context) ([]string, error)
@ -96,10 +103,12 @@ GetProductByEAN(ctx context.Context, ean string) (*domain.Product, error)
// PaymentGateway abstracts the payment provider (Mercado Pago, Asaas, etc.). // PaymentGateway abstracts the payment provider (Mercado Pago, Asaas, etc.).
// Implementations live in internal/infrastructure/payments/. // Implementations live in internal/infrastructure/payments/.
type PaymentGateway interface { type PaymentGateway interface {
CreatePreference(ctx context.Context, order *domain.Order, payer *domain.User, sellerAcc *domain.SellerPaymentAccount) (*domain.PaymentPreference, error) CreatePreference(ctx context.Context, order *domain.Order, payer *domain.User, sellerAcc *domain.SellerPaymentAccount) (*domain.PaymentPreference, error)
CreatePayment(ctx context.Context, order *domain.Order, token, issuerID, paymentMethodID string, installments int, payer *domain.User, sellerAcc *domain.SellerPaymentAccount) (*domain.PaymentResult, error) CreatePayment(ctx context.Context, order *domain.Order, token, issuerID, paymentMethodID string, installments int, payer *domain.User, sellerAcc *domain.SellerPaymentAccount) (*domain.PaymentResult, error)
CreatePixPayment(ctx context.Context, order *domain.Order) (*domain.PixPaymentResult, error)
CreateBoletoPayment(ctx context.Context, order *domain.Order, payer *domain.User) (*domain.BoletoPaymentResult, error)
GetPaymentStatus(ctx context.Context, paymentID string) (*domain.PaymentWebhookEvent, error)
} }
// Service orchestrates all business use cases. // Service orchestrates all business use cases.
// Methods are split across domain-specific files in this package: // Methods are split across domain-specific files in this package:
// - auth_usecase.go authentication, JWT, password reset // - auth_usecase.go authentication, JWT, password reset

171
docs/QA_BACKLOG_150.md Normal file
View file

@ -0,0 +1,171 @@
# 🧪 Backlog de QA e Estabilização (150 Atividades)
Este documento detalha 150 tarefas de validação, testes e melhorias para garantir a robustez da plataforma SaveInMed.
## 🔐 Autenticação e Segurança (15 atividades)
1. [ ] Validar bloqueio de força bruta no login.
2. [ ] Testar expiração de token JWT após 24h.
3. [ ] Verificar se usuários sem `role` de admin acessam `/api/v1/admin`.
4. [ ] Validar reset de senha via e-mail (fluxo completo).
5. [ ] Testar registro de empresa com CNPJ inválido.
6. [ ] Validar se um usuário de uma empresa pode ver pedidos de outra empresa (Isolamento de Tenant).
7. [ ] Testar sanitização de inputs contra SQL Injection em todos os filtros.
8. [ ] Verificar proteção contra XSS no cadastro de descrição de produtos.
9. [ ] Validar se o Password Pepper está sendo aplicado corretamente em novos cadastros.
10. [ ] Testar login simultâneo em múltiplos dispositivos.
11. [ ] Validar logout e invalidação imediata do token no client-side.
12. [ ] Verificar se logs do sistema expõem senhas ou tokens (Segurança de Log).
13. [ ] Testar upload de documentos de empresa com arquivos maliciosos (.exe, .js).
14. [ ] Validar CORS apenas para domínios autorizados.
15. [ ] Verificar headers de segurança (HSTS, CSP, X-Frame-Options).
## 📦 Catálogo e Busca (20 atividades)
16. [ ] Validar filtro por validade (mínimo de dias) no backend.
17. [ ] Testar busca por EAN com 13 dígitos.
18. [ ] Testar busca por EAN com 14 dígitos.
19. [ ] Verificar se produtos com estoque zero aparecem na busca pública.
20. [ ] Validar mascaramento do `seller_id` no JSON da busca pública.
21. [ ] Testar ordenação por menor preço.
22. [ ] Testar ordenação por validade mais próxima.
23. [ ] Validar paginação da busca (limite de 20, 50, 100 itens).
24. [ ] Verificar se o filtro de categoria retorna produtos de subcategorias.
25. [ ] Testar busca por termos com acentuação (ex: "Água").
26. [ ] Validar exibição da distância (km) baseada no CEP do comprador.
27. [ ] Testar comportamento da busca sem coordenadas de latitude/longitude.
28. [ ] Verificar se a imagem do produto carrega corretamente via proxy de uploads.
29. [ ] Testar importação massiva de produtos via CSV (1000+ itens).
30. [ ] Validar erro ao importar CSV com colunas faltando.
31. [ ] Verificar se o "nome social" do produto é exibido prioritariamente.
32. [ ] Testar filtro de "Fabricante".
33. [ ] Validar se a unidade de medida (caixa, frasco) está correta na listagem.
34. [ ] Testar cache de resultados de busca frequentes.
35. [ ] Verificar tempo de resposta da busca (< 500ms).
## 🛒 Carrinho e Checkout (25 atividades)
36. [ ] Testar adição de itens de diferentes vendedores no mesmo carrinho (deve separar por pacote).
37. [ ] Validar cálculo de subtotal por item (preço * quantidade).
38. [ ] Verificar persistência do carrinho no `localStorage`.
39. [ ] Testar remoção de itens do carrinho.
40. [ ] Validar limite de estoque ao tentar adicionar mais itens do que o disponível.
41. [ ] Testar fluxo de checkout sem estar logado (deve redirecionar para login).
42. [ ] Validar cálculo de frete na Etapa 2 do Checkout.
43. [ ] Testar seleção de frete grátis vs pago.
44. [ ] Verificar se o valor do frete é somado corretamente ao total do pedido.
45. [ ] Testar geração de Pix real via Mercado Pago no checkout.
46. [ ] Testar geração de Pix real via Asaas no checkout.
47. [ ] Validar exibição do QR Code Base64.
48. [ ] Testar botão "Copia e Cola" do Pix no mobile.
49. [ ] Verificar se o pedido é criado com status "Pendente" antes do pagamento.
50. [ ] Validar limpeza do carrinho após finalização do pedido.
51. [ ] Testar concorrência: dois usuários comprando o último item ao mesmo tempo.
52. [ ] Verificar se cupons de desconto (se houver) aplicam corretamente.
53. [ ] Testar checkout com endereço de entrega diferente do endereço da empresa.
54. [ ] Validar se o botão "Finalizar Pedido" desabilita durante o processamento.
55. [ ] Testar retorno de erro do gateway (cartão recusado).
56. [ ] Verificar se o `order_id` gerado segue o padrão UUIDv7.
57. [ ] Validar se o Split de 12% é calculado sobre o valor total (com frete?).
58. [ ] Testar checkout com múltiplos itens de um mesmo lote.
59. [ ] Verificar se a observação do pedido é salva corretamente.
60. [ ] Validar tempo de expiração do Pix (30 min).
## 💳 Pagamentos e Webhooks (20 atividades)
61. [ ] Validar recebimento de webhook do Mercado Pago para status "approved".
62. [ ] Validar recebimento de webhook do Mercado Pago para status "rejected".
63. [ ] Testar webhook do Asaas para status "PAYMENT_RECEIVED".
64. [ ] Verificar se o status do pedido muda para "Pago" automaticamente via webhook.
65. [ ] Testar idempotência dos webhooks (receber o mesmo ID 2x não deve duplicar log).
66. [ ] Validar se a Fatura é marcada como paga automaticamente.
67. [ ] Verificar logs de erro para webhooks com assinatura inválida.
68. [ ] Testar consulta de status manual via endpoint `/pay` para casos de falha de webhook.
69. [ ] Validar cálculo do `SellerReceivable` (Total - 12%).
70. [ ] Verificar se o MarketplaceFee (12%) é registrado no Ledger.
71. [ ] Testar estorno (Refund) via painel admin.
72. [ ] Validar se o estorno atualiza o status do pedido para "Cancelado".
73. [ ] Testar split de pagamento com vendedor que não tem conta configurada.
74. [ ] Verificar logs de transação financeira no banco de dados.
75. [ ] Validar se pagamentos com cartão de crédito pedem CPF/CNPJ corretamente.
76. [ ] Testar fluxo de pagamento "Pendente" (ex: Boleto ou Pix não pago).
77. [ ] Verificar se notificações push são enviadas ao vendedor após o pagamento.
78. [ ] Validar se o comprador recebe e-mail de confirmação.
79. [ ] Testar troca dinâmica de gateway via variável de ambiente.
80. [ ] Verificar segurança da URL de notificação (não deve exigir auth mas deve ser validada).
## 📊 Dashboard do Vendedor (15 atividades)
81. [ ] Validar cálculo do "Total de Vendas" no dashboard.
82. [ ] Verificar se a lista de "Pedidos Recentes" mostra apenas os últimos 5.
83. [ ] Testar botão "Ver Tudo" na lista de pedidos do dashboard.
84. [ ] Validar alertas de estoque baixo (itens < 10 unidades).
85. [ ] Verificar se produtos de outras empresas aparecem no dashboard (deve ser proibido).
86. [ ] Testar atualização manual do status do pedido (Pendente -> Faturado).
87. [ ] Validar se o vendedor pode baixar a nota fiscal (se implementado).
88. [ ] Testar filtro por data no gráfico de vendas.
89. [ ] Verificar exibição dos "Top Produtos" mais vendidos.
90. [ ] Validar se o saldo disponível para saque está correto.
91. [ ] Testar solicitação de saque (Withdrawal).
92. [ ] Verificar histórico de extrato financeiro (Ledger).
93. [ ] Validar se o vendedor pode editar apenas seus próprios produtos.
94. [ ] Testar upload de logotipo da empresa.
95. [ ] Verificar tempo de carregamento dos KPIs do dashboard.
## 🏢 Gestão de Empresas e Usuários (15 atividades)
96. [ ] Validar cadastro de nova Farmácia Vendedora via Admin.
97. [ ] Testar edição de dados cadastrais (Telefone, E-mail).
98. [ ] Verificar se o CNPJ é validado via API externa (se integrado).
99. [ ] Testar ativação/desativação de conta de empresa.
100. [ ] Validar fluxo de "Completar Registro" para novos usuários.
101. [ ] Testar adição de múltiplos colaboradores em uma mesma empresa.
102. [ ] Verificar permissões de "Operador" vs "Proprietário".
103. [ ] Validar se um usuário desativado consegue fazer login.
104. [ ] Testar troca de senha dentro do perfil.
105. [ ] Verificar se a categoria da empresa (Farmácia Compradora/Vendedora) altera o menu.
106. [ ] Validar validação de CEP no cadastro de endereço.
107. [ ] Testar se o mapa de calor de vendas funciona no Admin.
108. [ ] Verificar se o histórico de acessos (Audit Log) está registrando logins.
109. [ ] Testar remoção de colaborador da empresa.
110. [ ] Validar se documentos da empresa (Alvará, CRF) são obrigatórios.
## 🚚 Logística e Frete (15 atividades)
111. [ ] Validar raio de entrega máximo configurado pelo vendedor.
112. [ ] Testar cálculo de frete por KM rodado.
113. [ ] Testar cálculo de frete por faixa de CEP.
114. [ ] Verificar comportamento quando o comprador está fora do raio de entrega.
115. [ ] Testar frete grátis para pedidos acima de determinado valor.
116. [ ] Validar geração de guia de transporte (Shipment).
117. [ ] Verificar se o código de rastreio é enviado ao comprador.
118. [ ] Testar atualização de status para "Em Trânsito".
119. [ ] Validar se o custo do frete é repassado integralmente ao vendedor (ou retido?).
120. [ ] Verificar integração com APIs de transportadoras (ex: Melhor Envio).
121. [ ] Testar frete fixo por estado.
122. [ ] Validar peso máximo por pedido para cálculo de frete.
123. [ ] Verificar se o tempo de entrega estimado é realista.
124. [ ] Testar cancelamento de entrega.
125. [ ] Validar prova de entrega (assinatura ou foto).
## 🖥️ UX/UI e Responsividade (15 atividades)
126. [ ] Testar menu lateral no mobile (deve ser colapsável).
127. [ ] Verificar contraste de cores para acessibilidade (WCAG).
128. [ ] Testar todos os botões no modo "Dark Mode" (se disponível).
129. [ ] Validar mensagens de erro amigáveis no frontend (não exibir stack trace).
130. [ ] Testar carregamento progressivo de imagens (Skeleton screen).
131. [ ] Verificar se o modal de confirmação aparece antes de deletar algo.
132. [ ] Testar usabilidade do filtro de validade no mobile (slider).
133. [ ] Validar se o campo de busca limpa corretamente ao clicar no "X".
134. [ ] Verificar consistência de fontes e tamanhos de texto.
135. [ ] Testar navegação por teclado (Tab index) em formulários.
136. [ ] Validar se estados de "Loading" aparecem em todas as chamadas de API.
137. [ ] Verificar favicon e títulos das páginas no navegador.
138. [ ] Testar comportamento de "Pull to Refresh" no mobile.
139. [ ] Validar se tooltips de ajuda aparecem em campos complexos.
140. [ ] Verificar se o layout quebra em resoluções 4K ou muito baixas (320px).
## ⚙️ DevOps e Infraestrutura (10 atividades)
141. [ ] Validar tempo de Build do Docker (deve ser < 5 min).
142. [ ] Verificar se o Hot Reload está funcionando no ambiente de dev.
143. [ ] Testar restauração de backup do banco de dados Postgres.
144. [ ] Validar limites de memória e CPU dos containers.
145. [ ] Verificar se logs de erro são enviados para Sentry/CloudWatch.
146. [ ] Testar escalabilidade: Simular 50 usuários simultâneos.
147. [ ] Validar limpeza automática de arquivos temporários de upload.
148. [ ] Verificar se a conexão com o banco usa SSL em produção.
149. [ ] Testar tempo de inatividade (Downtime) durante deploy.
150. [ ] Validar certificados SSL Let's Encrypt (auto-renovação).

View file

@ -39,7 +39,7 @@ interface DadosEntrega {
} }
interface DadosPagamento { interface DadosPagamento {
metodo: 'mercadopago'; metodo: 'mercadopago' | 'pix' | 'boleto' | 'asaas_credito' | 'stripe_credito';
} }
interface OpcaoFrete { interface OpcaoFrete {
@ -63,6 +63,8 @@ const CheckoutPage = () => {
const [produtosPedido, setProdutosPedido] = useState<any[]>([]) const [produtosPedido, setProdutosPedido] = useState<any[]>([])
const [carregandoProdutos, setCarregandoProdutos] = useState(false) const [carregandoProdutos, setCarregandoProdutos] = useState(false)
const [pagamentoId, setPagamentoId] = useState<string | null>(null) const [pagamentoId, setPagamentoId] = useState<string | null>(null)
const [pixData, setPixData] = useState<any | null>(null)
const [boletoData, setBoletoData] = useState<any | null>(null)
const [processandoPagamento, setProcessandoPagamento] = useState(false) const [processandoPagamento, setProcessandoPagamento] = useState(false)
const [faturaId, setFaturaId] = useState<string | null>(null) const [faturaId, setFaturaId] = useState<string | null>(null)
const [processandoFatura, setProcessandoFatura] = useState(false) const [processandoFatura, setProcessandoFatura] = useState(false)
@ -650,8 +652,27 @@ const CheckoutPage = () => {
const proximaEtapa = async () => { const proximaEtapa = async () => {
if (etapaAtual === 1 && validarEtapa1()) { if (etapaAtual === 1 && validarEtapa1()) {
await buscarFrete(); try {
setEtapaAtual(2) setProcessandoPedido(true);
// Reserva de estoque temporária (15 min)
for (const item of itens) {
const res = await pagamentoApiService.reservarEstoque(
item.produto.id,
item.produto.id,
item.quantidade
);
if (!res.success) {
toast.error(`Não foi possível garantir o item ${item.produto.nome}: ${res.error || 'Estoque insuficiente ou reservado por outro comprador'}`);
return;
}
}
await buscarFrete();
setEtapaAtual(2)
} catch (error) {
toast.error('Erro ao processar reserva de estoque');
} finally {
setProcessandoPedido(false);
}
} else if (etapaAtual === 2 && validarEtapa2()) { } else if (etapaAtual === 2 && validarEtapa2()) {
// Atualizar pedido com valor do frete ao avançar para pagamento // Atualizar pedido com valor do frete ao avançar para pagamento
try { try {
@ -683,12 +704,37 @@ const CheckoutPage = () => {
try { try {
setProcessandoPagamento(true); setProcessandoPagamento(true);
// Verificar se há pedido ID
if (!pedidoId) { if (!pedidoId) {
toast.error('ID do pedido não encontrado'); toast.error('ID do pedido não encontrado');
return; return;
} }
if (dadosPagamento.metodo === 'pix') {
const res = await pagamentoApiService.gerarPix(pedidoId);
if (res.success) {
setPixData(res.data);
setPagamentoId(res.data.payment_id);
toast.success('PIX gerado com sucesso!');
setEtapaAtual(4);
} else {
toast.error(res.error || 'Erro ao gerar PIX');
}
return;
}
if (dadosPagamento.metodo === 'boleto') {
const res = await pagamentoApiService.gerarBoleto(pedidoId);
if (res.success) {
setBoletoData(res.data);
setPagamentoId(res.data.payment_id);
toast.success('Boleto gerado com sucesso!');
setEtapaAtual(4);
} else {
toast.error(res.error || 'Erro ao gerar boleto');
}
return;
}
// Mapear método de pagamento para formato da API (Mercado Pago usa 'credito' como padrão) // Mapear método de pagamento para formato da API (Mercado Pago usa 'credito' como padrão)
const metodoApi = 'credito'; const metodoApi = 'credito';
const valorTotal = calcularValorTotal(); const valorTotal = calcularValorTotal();
@ -1391,19 +1437,41 @@ const CheckoutPage = () => {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="flex items-center p-4 border-2 border-blue-500 bg-blue-50 rounded-lg cursor-pointer"> <label className={`flex items-center p-4 border-2 rounded-lg cursor-pointer transition-all ${dadosPagamento.metodo === 'mercadopago' ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}`}>
<input <input
type="radio" type="radio"
name="metodo" name="metodo"
value="mercadopago" value="mercadopago"
checked={true} checked={dadosPagamento.metodo === 'mercadopago'}
onChange={(e) => setDadosPagamento({ ...dadosPagamento, metodo: e.target.value as any })} onChange={(e) => setDadosPagamento({ ...dadosPagamento, metodo: e.target.value as any })}
className="mr-3" className="mr-3"
/> />
<div className="h-5 w-5 bg-blue-500 rounded mr-3"></div> <div className="h-10 w-10 flex items-center justify-center bg-blue-500 rounded text-white mr-3">
<CreditCardIcon className="h-6 w-6" />
</div>
<div> <div>
<p className="font-semibold text-gray-900">Mercado Pago</p> <p className="font-semibold text-gray-900">Cartão de Crédito (Mercado Pago)</p>
<p className="text-sm text-gray-600">Cartão à vista, PIX - Processamento seguro</p> <p className="text-sm text-gray-600">Pagamento seguro com aprovação instantânea</p>
</div>
</label>
</div>
<div>
<label className={`flex items-center p-4 border-2 rounded-lg cursor-pointer transition-all ${dadosPagamento.metodo === 'pix' ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}`}>
<input
type="radio"
name="metodo"
value="pix"
checked={dadosPagamento.metodo === 'pix'}
onChange={(e) => setDadosPagamento({ ...dadosPagamento, metodo: e.target.value as any })}
className="mr-3"
/>
<div className="h-10 w-10 flex items-center justify-center bg-teal-500 rounded text-white mr-3 font-bold text-xs">
PIX
</div>
<div>
<p className="font-semibold text-gray-900">Pix</p>
<p className="text-sm text-gray-600">Aprovação em poucos minutos</p>
</div> </div>
</label> </label>
</div> </div>
@ -1469,12 +1537,75 @@ const CheckoutPage = () => {
<div className="mb-6"> <div className="mb-6">
<h3 className="font-semibold text-gray-900 mb-3">Forma de Pagamento</h3> <h3 className="font-semibold text-gray-900 mb-3">Forma de Pagamento</h3>
<div className="bg-gray-50 p-4 rounded-lg space-y-2"> <div className="bg-gray-50 p-4 rounded-lg space-y-2">
<p className="text-gray-800"><strong className="text-gray-900">Método:</strong> Mercado Pago</p> <p className="text-gray-800"><strong className="text-gray-900">Método:</strong> {
<p className="text-gray-800"><strong className="text-gray-900">Opções:</strong> Cartão à vista, PIX</p> dadosPagamento.metodo === 'pix' ? 'Pix' :
dadosPagamento.metodo === 'boleto' ? 'Boleto Bancário' :
'Mercado Pago'
}</p>
<p className="text-gray-800"><strong className="text-gray-900">Valor:</strong> {formatarPreco(calcularValorTotal())}</p> <p className="text-gray-800"><strong className="text-gray-900">Valor:</strong> {formatarPreco(calcularValorTotal())}</p>
</div> </div>
</div> </div>
{/* Detalhes do PIX */}
{dadosPagamento.metodo === 'pix' && pixData && (
<div className="mb-6 p-6 border-2 border-teal-100 bg-teal-50 rounded-xl flex flex-col items-center text-center">
<h3 className="text-teal-900 font-bold mb-4">Pagamento via PIX</h3>
{pixData.qr_code_base64 && (
<img src={pixData.qr_code_base64} alt="QR Code PIX" className="w-48 h-48 mb-4 shadow-sm bg-white p-2 rounded-lg" />
)}
<p className="text-sm text-teal-800 mb-2 font-medium">Escaneie o QR Code acima ou copie o código abaixo:</p>
<div className="w-full bg-white border border-teal-200 p-3 rounded-lg mb-4">
<p className="text-xs break-all font-mono text-gray-600">{pixData.copy_pasta}</p>
</div>
<button
onClick={() => {
navigator.clipboard.writeText(pixData.copy_pasta);
toast.success('Código PIX copiado!');
}}
className="text-teal-700 font-bold text-sm hover:underline flex items-center gap-1"
>
<DocumentTextIcon className="w-4 h-4" /> Copiar Código Copia e Cola
</button>
</div>
)}
{/* Detalhes do Boleto */}
{dadosPagamento.metodo === 'boleto' && boletoData && (
<div className="mb-6 p-6 border-2 border-orange-100 bg-orange-50 rounded-xl">
<h3 className="text-orange-900 font-bold mb-4 flex items-center gap-2">
<DocumentTextIcon className="w-5 h-5" /> Boleto Bancário Gerado
</h3>
<div className="space-y-4">
<div>
<p className="text-xs text-orange-800 uppercase font-bold tracking-wider mb-1">Linha Digitável</p>
<div className="bg-white border border-orange-200 p-3 rounded-lg flex justify-between items-center">
<p className="text-sm font-mono text-gray-700">{boletoData.digitable_line}</p>
<button
onClick={() => {
navigator.clipboard.writeText(boletoData.digitable_line);
toast.success('Linha digitável copiada!');
}}
className="text-orange-600 p-1 hover:bg-orange-50 rounded"
>
<DocumentTextIcon className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex justify-between items-center py-2">
<p className="text-sm text-orange-800">Vencimento: <strong>{new Date(boletoData.due_date).toLocaleDateString('pt-BR')}</strong></p>
<a
href={boletoData.boleto_url}
target="_blank"
rel="noopener noreferrer"
className="bg-orange-600 text-white px-4 py-2 rounded-lg text-sm font-bold hover:bg-orange-700 transition-colors shadow-sm"
>
Imprimir Boleto
</a>
</div>
</div>
</div>
)}
{/* Status do Pagamento */} {/* Status do Pagamento */}
{pagamentoId && ( {pagamentoId && (
<div className="mb-6"> <div className="mb-6">
@ -1791,8 +1922,12 @@ const CheckoutPage = () => {
<div className="border-t pt-4"> <div className="border-t pt-4">
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600">Subtotal:</span> <span className="text-sm text-gray-600">Subtotal (Itens):</span>
<span className="text-sm text-gray-900">{formatarPreco(valorTotal)}</span> <span className="text-sm text-gray-900">{formatarPreco(calcularValorTotal() / 1.12)}</span>
</div>
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600">Taxa de Serviço (12%):</span>
<span className="text-sm text-gray-900">{formatarPreco(calcularValorTotal() - (calcularValorTotal() / 1.12))}</span>
</div> </div>
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600">Frete:</span> <span className="text-sm text-gray-600">Frete:</span>

View file

@ -60,11 +60,9 @@ const EmpresaModal: React.FC<EmpresaModalProps> = ({
return ( return (
<div <div
className="fixed inset-0 bg-black/20 backdrop-blur-sm flex items-center justify-center z-50" className="fixed inset-0 bg-black/20 backdrop-blur-sm flex items-center justify-center z-50"
onClick={onClose}
> >
<div <div
className="bg-white rounded-2xl shadow-xl max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto" className="bg-white rounded-2xl shadow-xl max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-center justify-between p-6 border-b border-slate-200"> <div className="flex items-center justify-between p-6 border-b border-slate-200">
<h2 className="text-lg font-semibold text-slate-900"> <h2 className="text-lg font-semibold text-slate-900">

View file

@ -55,10 +55,8 @@ const EmpresaModal: React.FC<EmpresaModalProps> = ({
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm flex items-center justify-center z-50" <div className="fixed inset-0 bg-black/20 backdrop-blur-sm flex items-center justify-center z-50">
onClick={onClose}> <div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}>
{/* Header do Modal */} {/* Header do Modal */}
<div className="flex items-center justify-between p-6 border-b border-gray-200"> <div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900"> <h2 className="text-xl font-semibold text-gray-900">

View file

@ -28,8 +28,12 @@ export function SellerDashboardPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [recentOrders, setRecentOrders] = useState<any[]>([])
const [loadingOrders, setLoadingOrders] = useState(false)
useEffect(() => { useEffect(() => {
loadDashboard() loadDashboard()
loadRecentOrders()
}, []) }, [])
const loadDashboard = async () => { const loadDashboard = async () => {
@ -52,25 +56,46 @@ export function SellerDashboardPage() {
} }
} }
const loadRecentOrders = async () => {
try {
setLoadingOrders(true)
const response = await apiClient.get<{ orders: any[] }>('/v1/orders?role=seller&limit=5')
setRecentOrders(response.orders || [])
} catch (err) {
console.error('Erro ao carregar pedidos recentes:', err)
} finally {
setLoadingOrders(false)
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'Pendente': return 'bg-amber-100 text-amber-800'
case 'Pago': return 'bg-green-100 text-green-800'
case 'Faturado': return 'bg-blue-100 text-blue-800'
case 'Entregue': return 'bg-gray-100 text-gray-800'
case 'Cancelado': return 'bg-red-100 text-red-800'
default: return 'bg-gray-100 text-gray-800'
}
}
const formatCurrency = (cents: number | undefined | null) => formatCents(cents) const formatCurrency = (cents: number | undefined | null) => formatCents(cents)
const kpiCards = data ? [ const kpiCards = data ? [
{ {
label: 'Total de Vendas', label: 'Vendas Brutas',
value: formatCurrency(data.total_sales_cents), value: formatCurrency(data.total_sales_cents),
icon: <FaChartLine size={28} color="black" />, icon: <FaChartLine size={28} color="black" />,
}, },
{ {
label: 'Pedidos', label: 'Comissão (6%)',
value: String(data.orders_count), value: formatCurrency(data.total_sales_cents * 0.06),
icon: <FaBoxOpen size={28} color="black" />, icon: <FaReceipt size={28} color="black" />,
}, },
{ {
label: 'Ticket Médio', label: 'Valor Líquido',
value: data.orders_count > 0 value: formatCurrency(data.total_sales_cents * 0.94),
? formatCurrency(data.total_sales_cents / data.orders_count) icon: <FaMoneyBillWave size={28} color="black" />,
: 'R$ 0,00',
icon: <FaReceipt size={28} color="black" />,
}, },
] : [] ] : []
@ -183,6 +208,48 @@ export function SellerDashboardPage() {
)} )}
</div> </div>
</div> </div>
{/* Recent Orders */}
<div className="rounded-xl bg-white p-6 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-800">Vendas Recentes</h2>
<a href="/orders" className="text-sm font-medium text-blue-600 hover:underline">Ver tudo</a>
</div>
{loadingOrders ? (
<div className="flex justify-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2" style={{ borderColor: '#0F4C81' }}></div>
</div>
) : recentOrders.length === 0 ? (
<p className="text-gray-400 text-sm italic">Nenhum pedido recebido ainda</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="text-xs text-gray-500 uppercase border-b">
<tr>
<th className="px-4 py-3">ID</th>
<th className="px-4 py-3">Data</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3 text-right">Total</th>
</tr>
</thead>
<tbody className="divide-y">
{recentOrders.map((order) => (
<tr key={order.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => window.location.href = `/orders`}>
<td className="px-4 py-3 font-medium">#{order.id.slice(-8)}</td>
<td className="px-4 py-3">{new Date(order.created_at).toLocaleDateString('pt-BR')}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-[10px] font-bold uppercase ${getStatusColor(order.status)}`}>
{order.status}
</span>
</td>
<td className="px-4 py-3 text-right font-semibold">{formatCurrency(order.total_cents)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</> </>
)} )}
</div> </div>

View file

@ -54,50 +54,81 @@ export const pagamentoApiService = {
}, },
/** /**
* Cria um novo pagamento * Cria uma preferência de pagamento (Checkout Pro / Link)
*/ */
criar: async ( criarPreferencia: async (pedidoId: string): Promise<PagamentoApiResponse> => {
metodo: 'pix' | 'credito' | 'debito',
valor: number,
pedidoId: string
): Promise<PagamentoApiResponse> => {
try { try {
const token = pagamentoApiService.getAuthToken(); const token = pagamentoApiService.getAuthToken();
if (!token) { const response = await fetch(`${API_BASE_URL}/orders/${pedidoId}/payment`, {
return { success: false, error: 'Token de autenticação não encontrado' };
}
const payload: PagamentoApiData = {
status: 'pendente',
metodo: metodo,
valor: valor,
pedidos: pedidoId
};
console.log('💳 Criando pagamento na API:', payload);
const response = await fetch(`${API_BASE_URL}/pagamentos`, {
method: 'POST', method: 'POST',
headers: { headers: {
'accept': 'application/json', 'accept': 'application/json',
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
return response.ok ? { success: true, data } : { success: false, error: data.message || 'Erro ao criar preferência' };
} catch (error) {
return { success: false, error: 'Erro de conexão' };
}
},
/**
* Processa pagamento direto (Cartão de Crédito)
*/
pagarCartao: async (pedidoId: string, paymentData: any): Promise<PagamentoApiResponse> => {
try {
const token = pagamentoApiService.getAuthToken();
const response = await fetch(`${API_BASE_URL}/orders/${pedidoId}/pay`, {
method: 'POST',
headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`, 'Authorization': `Bearer ${token}`,
}, },
body: JSON.stringify(payload), body: JSON.stringify(paymentData),
}); });
const data = await response.json(); const data = await response.json();
return response.ok ? { success: true, data } : { success: false, error: data.message || 'Erro ao processar cartão' };
if (response.ok) {
console.log('✅ Pagamento criado com sucesso:', data);
return { success: true, data };
} else {
console.error('❌ Erro ao criar pagamento:', data);
return { success: false, error: data.message || 'Erro ao criar pagamento' };
}
} catch (error) { } catch (error) {
console.error('💥 Erro na criação do pagamento:', error); return { success: false, error: 'Erro de conexão' };
return { success: false, error: 'Erro de conexão ao criar pagamento' }; }
},
/**
* Gera pagamento Pix
*/
gerarPix: async (pedidoId: string): Promise<PagamentoApiResponse> => {
try {
const token = pagamentoApiService.getAuthToken();
const response = await fetch(`${API_BASE_URL}/orders/${pedidoId}/pix`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
return response.ok ? { success: true, data } : { success: false, error: data.message || 'Erro ao gerar Pix' };
} catch (error) {
return { success: false, error: 'Erro de conexão' };
}
},
/**
* Gera pagamento via Boleto
*/
gerarBoleto: async (pedidoId: string): Promise<PagamentoApiResponse> => {
try {
const token = pagamentoApiService.getAuthToken();
const response = await fetch(`${API_BASE_URL}/orders/${pedidoId}/boleto`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
return response.ok ? { success: true, data } : { success: false, error: data.message || 'Erro ao gerar boleto' };
} catch (error) {
return { success: false, error: 'Erro de conexão' };
} }
}, },
@ -207,6 +238,31 @@ export const pagamentoApiService = {
} }
}, },
/**
* Reserva estoque temporariamente para um item
*/
reservarEstoque: async (productId: string, inventoryItemId: string, quantity: number): Promise<PagamentoApiResponse> => {
try {
const token = pagamentoApiService.getAuthToken();
const response = await fetch(`${API_BASE_URL}/inventory/reserve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
product_id: productId,
inventory_item_id: inventoryItemId,
quantity: quantity
}),
});
const data = await response.json();
return response.ok ? { success: true, data } : { success: false, error: data.message || 'Erro ao reservar estoque' };
} catch (error) {
return { success: false, error: 'Erro de conexão ao reservar estoque' };
}
},
/** /**
* Mock de processamento de pagamento * Mock de processamento de pagamento
* (Para uso temporário até integração real) * (Para uso temporário até integração real)