feat: implement checkout improvements, psychological fees, geocoding sync and seller dashboard updates
This commit is contained in:
parent
8b68bbb066
commit
2e0a65389d
25 changed files with 1291 additions and 156 deletions
6
TASKS.md
6
TASKS.md
|
|
@ -1,14 +1,14 @@
|
|||
# SaveInMed - Atividades
|
||||
|
||||
## 🎯 Backlog Prioritário
|
||||
1. [ ] Implementar listagem de pedidos na Distribuidora.
|
||||
2. [ ] Adicionar filtro por validade no catálogo de produtos.
|
||||
1. [x] Implementar listagem de pedidos na Distribuidora (Adicionado ao Dashboard do Vendedor).
|
||||
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).
|
||||
|
||||
## 🐛 Bugs Conhecidos
|
||||
- [x] Erro de acentuação (Encoding UTF-8) - 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
|
||||
- Setup do ambiente Docker com Hot Reload na VPS echo.
|
||||
|
|
|
|||
|
|
@ -26,6 +26,11 @@ type Config struct {
|
|||
BackendHost string
|
||||
SwaggerSchemes []string
|
||||
MercadoPagoPublicKey string
|
||||
AsaasAPIKey string
|
||||
AsaasWalletID string
|
||||
AsaasEnvironment string
|
||||
StripeAPIKey string
|
||||
PaymentGatewayProvider string // "mercadopago", "asaas", or "stripe"
|
||||
MapboxAccessToken string
|
||||
BootstrapAdminEmail string
|
||||
BootstrapAdminPassword string
|
||||
|
|
@ -51,6 +56,11 @@ func Load() (*Config, error) {
|
|||
BackendHost: getEnv("BACKEND_HOST", ""),
|
||||
SwaggerSchemes: getEnvStringSlice("SWAGGER_SCHEMES", []string{"http"}),
|
||||
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", ""),
|
||||
BootstrapAdminEmail: getEnv("BOOTSTRAP_ADMIN_EMAIL", "admin@saveinmed.com.br"),
|
||||
BootstrapAdminPassword: getEnv("BOOTSTRAP_ADMIN_PASSWORD", "sim-admin"),
|
||||
|
|
|
|||
|
|
@ -592,3 +592,15 @@ type Withdrawal struct {
|
|||
CreatedAt time.Time `db:"created_at" json:"created_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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,3 +55,17 @@ func (h *Handler) TestPaymentGateway(w http.ResponseWriter, r *http.Request) {
|
|||
w.WriteHeader(http.StatusOK)
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,44 @@ func (h *Handler) CreatePaymentPreference(w http.ResponseWriter, r *http.Request
|
|||
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
|
||||
// @Summary Processar pagamento direto (Cartão/Pix via Bricks)
|
||||
// @Router /api/v1/orders/{id}/pay [post]
|
||||
|
|
@ -135,21 +173,57 @@ func (h *Handler) GetShipmentByOrderID(w http.ResponseWriter, r *http.Request) {
|
|||
// @Tags Pagamentos
|
||||
// @Accept 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
|
||||
// @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 {
|
||||
var payload struct {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
// 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 {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, summary)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -216,14 +216,25 @@ func (h *Handler) SearchProducts(w http.ResponseWriter, r *http.Request) {
|
|||
filter.MaxDistanceKm = &dist
|
||||
}
|
||||
}
|
||||
// ExpiresBefore ignored for Catalog Search
|
||||
// if v := r.URL.Query().Get("expires_before"); v != "" {
|
||||
// if days, err := strconv.Atoi(v); err == nil && days > 0 {
|
||||
// expires := time.Now().AddDate(0, 0, days)
|
||||
// filter.ExpiresBefore = &expires
|
||||
// }
|
||||
// }
|
||||
if v := r.URL.Query().Get("min_expiry_days"); v != "" {
|
||||
if days, err := strconv.Atoi(v); err == nil && days > 0 {
|
||||
expires := time.Now().AddDate(0, 0, days)
|
||||
filter.ExpiresAfter = &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 {
|
||||
filter.ExcludeSellerID = claims.CompanyID
|
||||
}
|
||||
|
|
@ -234,8 +245,11 @@ func (h *Handler) SearchProducts(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if h.buyerFeeRate > 0 {
|
||||
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
|
||||
originalPrice := result.Products[i].PriceCents
|
||||
inflatedPrice := int64(float64(originalPrice) * (1 + h.buyerFeeRate))
|
||||
|
|
@ -267,6 +281,9 @@ func (h *Handler) GetProduct(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Business Rule: Mask seller until checkout
|
||||
product.SellerID = uuid.Nil
|
||||
|
||||
// Apply 12% fee for display to potential buyers
|
||||
if h.buyerFeeRate > 0 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
// @Summary Adicionar item ao estoque (venda)
|
||||
// @Tags Estoque
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ type directionsResponse struct {
|
|||
|
||||
// GetDrivingDistance returns distance in kilometers between two points
|
||||
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",
|
||||
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
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
package payments
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"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" {
|
||||
return "https://api.asaas.com/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) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
func (g *AsaasGateway) doRequest(ctx context.Context, method, path string, payload interface{}) (*http.Response, error) {
|
||||
var body []byte
|
||||
if payload != nil {
|
||||
var err error
|
||||
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:
|
||||
// 1. Create customer if not exists
|
||||
// 2. Create charge with split configuration
|
||||
// 3. Return payment URL or Pix QR code
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("access_token", g.APIKey)
|
||||
|
||||
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,
|
||||
Gateway: "asaas",
|
||||
PaymentID: res.PaymentID,
|
||||
PaymentURL: res.CopyPasta, // Or a hosted checkout link if available
|
||||
CommissionPct: g.MarketplaceCommission,
|
||||
MarketplaceFee: fee,
|
||||
SellerReceivable: order.TotalCents - fee,
|
||||
PaymentURL: fmt.Sprintf("%s/checkout/%s", g.BaseURL(), order.ID.String()),
|
||||
}
|
||||
MarketplaceFee: res.MarketplaceFee,
|
||||
SellerReceivable: res.SellerReceivable,
|
||||
}, nil
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
return pref, nil
|
||||
// CreatePayment executes a direct payment (Credit Card) via Asaas.
|
||||
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.
|
||||
func (g *AsaasGateway) CreatePixPayment(ctx context.Context, order *domain.Order) (*domain.PixPaymentResult, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
amount := float64(order.TotalCents+order.ShippingFeeCents) / 100.0
|
||||
fee := int64(float64(order.TotalCents) * (g.MarketplaceCommission / 100))
|
||||
|
||||
// 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))
|
||||
expiresAt := time.Now().Add(30 * time.Minute)
|
||||
resp, err := g.doRequest(ctx, "POST", "/payments", payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
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: uuid.Must(uuid.NewV7()).String(),
|
||||
PaymentID: result.ID,
|
||||
OrderID: order.ID,
|
||||
Gateway: "asaas",
|
||||
PixKey: "chave@saveinmed.com",
|
||||
QRCode: fmt.Sprintf("00020126580014BR.GOV.BCB.PIX0136%s", order.ID.String()),
|
||||
QRCodeBase64: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...", // Simulated
|
||||
CopyPasta: fmt.Sprintf("00020126580014BR.GOV.BCB.PIX0136%s52040000", order.ID.String()),
|
||||
AmountCents: order.TotalCents,
|
||||
QRCodeBase64: qrResult.EncodedImage,
|
||||
CopyPasta: qrResult.Payload,
|
||||
AmountCents: order.TotalCents + order.ShippingFeeCents,
|
||||
MarketplaceFee: fee,
|
||||
SellerReceivable: order.TotalCents - fee,
|
||||
ExpiresAt: expiresAt,
|
||||
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,
|
||||
Gateway: "asaas",
|
||||
BoletoURL: result.BankSlipUrl,
|
||||
DigitableLine: result.IdentificationField,
|
||||
AmountCents: order.TotalCents + order.ShippingFeeCents,
|
||||
MarketplaceFee: fee,
|
||||
SellerReceivable: (order.TotalCents + order.ShippingFeeCents) - fee,
|
||||
DueDate: time.Now().AddDate(0, 0, 3),
|
||||
Status: "pending",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateBoletoPayment generates a Boleto payment.
|
||||
func (g *AsaasGateway) CreateBoletoPayment(ctx context.Context, order *domain.Order, customer *domain.Customer) (*domain.BoletoPaymentResult, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
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,
|
||||
// GetPaymentStatus fetches payment details from Asaas.
|
||||
func (g *AsaasGateway) GetPaymentStatus(ctx context.Context, paymentID string) (*domain.PaymentWebhookEvent, error) {
|
||||
// In production, call GET /payments/{paymentID}
|
||||
return &domain.PaymentWebhookEvent{
|
||||
PaymentID: paymentID,
|
||||
Status: "approved",
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -256,3 +256,106 @@ func (g *MercadoPagoGateway) CreatePayment(ctx context.Context, order *domain.Or
|
|||
Message: res.StatusDetail,
|
||||
}, 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,56 @@ func (g *MockGateway) CreatePreference(ctx context.Context, order *domain.Order,
|
|||
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.
|
||||
func (g *MockGateway) ConfirmPayment(ctx context.Context, paymentID string) (*domain.PaymentResult, error) {
|
||||
select {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,60 @@ func (g *StripeGateway) CreatePreference(ctx context.Context, order *domain.Orde
|
|||
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) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -333,11 +333,11 @@ func (r *Repository) SearchProducts(ctx context.Context, filter domain.ProductSe
|
|||
args = append(args, *filter.MaxPriceCents)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if filter.ExcludeSellerID != nil {
|
||||
|
|
@ -1529,6 +1529,37 @@ func (r *Repository) UpsertShippingSettings(ctx context.Context, settings *domai
|
|||
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).
|
||||
func (r *Repository) GetPaymentGatewayConfig(ctx context.Context, provider string) (*domain.PaymentGatewayConfig, error) {
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) ListAddresses(ctx context.Context, entityID uuid.UUID) ([]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 WHERE entity_id = $1 ORDER BY created_at DESC`
|
||||
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?
|
||||
// sqlx returns error if slice is empty? No, SelectContext handles empty result by returning empty slice usually.
|
||||
return addresses, err
|
||||
|
|
|
|||
|
|
@ -42,13 +42,25 @@ func New(cfg config.Config) (*Server, error) {
|
|||
}
|
||||
|
||||
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)
|
||||
|
||||
// Services
|
||||
notifySvc := notifications.NewLoggerNotificationService()
|
||||
svc := usecase.NewService(repoInstance, paymentGateway, mapboxClient, notifySvc, cfg.MarketplaceCommission, cfg.BuyerFeeRate, cfg.JWTSecret, cfg.JWTExpiresIn, cfg.PasswordPepper)
|
||||
h := handler.New(svc, cfg.BuyerFeeRate)
|
||||
// MarketplaceCommission: 12% total (Split 6% buyer / 6% seller)
|
||||
// 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()
|
||||
|
||||
|
|
@ -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("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("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
|
||||
|
|
@ -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("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}/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("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("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/geocode-sync", chain(http.HandlerFunc(h.GeocodeSync), middleware.Logger, middleware.Gzip, adminOnly))
|
||||
|
||||
// Payment Config (Seller)
|
||||
mux.Handle("GET /api/v1/sellers/{id}/payment-config", chain(http.HandlerFunc(h.GetSellerPaymentConfig), middleware.Logger, middleware.Gzip, auth))
|
||||
|
|
|
|||
|
|
@ -11,6 +11,16 @@ import (
|
|||
|
||||
// CreateAddress generates an ID and persists a new address.
|
||||
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())
|
||||
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")
|
||||
}
|
||||
|
||||
// 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.ZipCode = addr.ZipCode
|
||||
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)
|
||||
}
|
||||
|
||||
// DeleteAddress verifies ownership and removes an address.
|
||||
func (s *Service) DeleteAddress(ctx context.Context, id uuid.UUID, requester *domain.User) 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")
|
||||
}
|
||||
|
||||
// DeleteAddress removes an address by ID.
|
||||
func (s *Service) DeleteAddress(ctx context.Context, id uuid.UUID) error {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,40 @@ func (s *Service) ProcessOrderPayment(ctx context.Context, id uuid.UUID, token,
|
|||
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
|
||||
// and records the split in the financial ledger.
|
||||
func (s *Service) HandlePaymentWebhook(ctx context.Context, event domain.PaymentWebhookEvent) (*domain.PaymentSplitResult, error) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,16 @@ import (
|
|||
|
||||
// RegisterProduct generates an ID and persists a new product.
|
||||
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())
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Seller info is anonymised until checkout.
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,8 +77,13 @@ GetBalance(ctx context.Context, companyID uuid.UUID) (int64, error)
|
|||
CreateWithdrawal(ctx context.Context, withdrawal *domain.Withdrawal) error
|
||||
ListWithdrawals(ctx context.Context, companyID uuid.UUID) ([]domain.Withdrawal, error)
|
||||
|
||||
GetPaymentGatewayConfig(ctx context.Context, provider string) (*domain.PaymentGatewayConfig, error)
|
||||
UpsertPaymentGatewayConfig(ctx context.Context, config *domain.PaymentGatewayConfig) error
|
||||
// Stock Reservations
|
||||
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)
|
||||
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)
|
||||
UpdateAddress(ctx context.Context, address *domain.Address) 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)
|
||||
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.).
|
||||
// Implementations live in internal/infrastructure/payments/.
|
||||
type PaymentGateway interface {
|
||||
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)
|
||||
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)
|
||||
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.
|
||||
// Methods are split across domain-specific files in this package:
|
||||
// - auth_usecase.go – authentication, JWT, password reset
|
||||
|
|
|
|||
171
docs/QA_BACKLOG_150.md
Normal file
171
docs/QA_BACKLOG_150.md
Normal 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).
|
||||
|
|
@ -39,7 +39,7 @@ interface DadosEntrega {
|
|||
}
|
||||
|
||||
interface DadosPagamento {
|
||||
metodo: 'mercadopago';
|
||||
metodo: 'mercadopago' | 'pix' | 'boleto' | 'asaas_credito' | 'stripe_credito';
|
||||
}
|
||||
|
||||
interface OpcaoFrete {
|
||||
|
|
@ -63,6 +63,8 @@ const CheckoutPage = () => {
|
|||
const [produtosPedido, setProdutosPedido] = useState<any[]>([])
|
||||
const [carregandoProdutos, setCarregandoProdutos] = useState(false)
|
||||
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 [faturaId, setFaturaId] = useState<string | null>(null)
|
||||
const [processandoFatura, setProcessandoFatura] = useState(false)
|
||||
|
|
@ -650,8 +652,27 @@ const CheckoutPage = () => {
|
|||
const proximaEtapa = async () => {
|
||||
|
||||
if (etapaAtual === 1 && validarEtapa1()) {
|
||||
try {
|
||||
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()) {
|
||||
// Atualizar pedido com valor do frete ao avançar para pagamento
|
||||
try {
|
||||
|
|
@ -683,12 +704,37 @@ const CheckoutPage = () => {
|
|||
try {
|
||||
setProcessandoPagamento(true);
|
||||
|
||||
// Verificar se há pedido ID
|
||||
if (!pedidoId) {
|
||||
toast.error('ID do pedido não encontrado');
|
||||
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)
|
||||
const metodoApi = 'credito';
|
||||
const valorTotal = calcularValorTotal();
|
||||
|
|
@ -1391,19 +1437,41 @@ const CheckoutPage = () => {
|
|||
|
||||
<div className="space-y-4">
|
||||
<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
|
||||
type="radio"
|
||||
name="metodo"
|
||||
value="mercadopago"
|
||||
checked={true}
|
||||
checked={dadosPagamento.metodo === 'mercadopago'}
|
||||
onChange={(e) => setDadosPagamento({ ...dadosPagamento, metodo: e.target.value as any })}
|
||||
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>
|
||||
<p className="font-semibold text-gray-900">Mercado Pago</p>
|
||||
<p className="text-sm text-gray-600">Cartão ÃÂ vista, PIX - Processamento seguro</p>
|
||||
<p className="font-semibold text-gray-900">Cartão de Crédito (Mercado Pago)</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>
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -1469,12 +1537,75 @@ const CheckoutPage = () => {
|
|||
<div className="mb-6">
|
||||
<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">
|
||||
<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">Opções:</strong> Cartão àvista, PIX</p>
|
||||
<p className="text-gray-800"><strong className="text-gray-900">Método:</strong> {
|
||||
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>
|
||||
</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 */}
|
||||
{pagamentoId && (
|
||||
<div className="mb-6">
|
||||
|
|
@ -1791,8 +1922,12 @@ const CheckoutPage = () => {
|
|||
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm text-gray-600">Subtotal:</span>
|
||||
<span className="text-sm text-gray-900">{formatarPreco(valorTotal)}</span>
|
||||
<span className="text-sm text-gray-600">Subtotal (Itens):</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 className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm text-gray-600">Frete:</span>
|
||||
|
|
|
|||
|
|
@ -60,11 +60,9 @@ const EmpresaModal: React.FC<EmpresaModalProps> = ({
|
|||
return (
|
||||
<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-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">
|
||||
<h2 className="text-lg font-semibold text-slate-900">
|
||||
|
|
|
|||
|
|
@ -55,10 +55,8 @@ const EmpresaModal: React.FC<EmpresaModalProps> = ({
|
|||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<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"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
{/* Header do Modal */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
|
|
|
|||
|
|
@ -28,8 +28,12 @@ export function SellerDashboardPage() {
|
|||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [recentOrders, setRecentOrders] = useState<any[]>([])
|
||||
const [loadingOrders, setLoadingOrders] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard()
|
||||
loadRecentOrders()
|
||||
}, [])
|
||||
|
||||
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 kpiCards = data ? [
|
||||
{
|
||||
label: 'Total de Vendas',
|
||||
label: 'Vendas Brutas',
|
||||
value: formatCurrency(data.total_sales_cents),
|
||||
icon: <FaChartLine size={28} color="black" />,
|
||||
},
|
||||
{
|
||||
label: 'Pedidos',
|
||||
value: String(data.orders_count),
|
||||
icon: <FaBoxOpen size={28} color="black" />,
|
||||
label: 'Comissão (6%)',
|
||||
value: formatCurrency(data.total_sales_cents * 0.06),
|
||||
icon: <FaReceipt size={28} color="black" />,
|
||||
},
|
||||
{
|
||||
label: 'Ticket Médio',
|
||||
value: data.orders_count > 0
|
||||
? formatCurrency(data.total_sales_cents / data.orders_count)
|
||||
: 'R$ 0,00',
|
||||
icon: <FaReceipt size={28} color="black" />,
|
||||
label: 'Valor Líquido',
|
||||
value: formatCurrency(data.total_sales_cents * 0.94),
|
||||
icon: <FaMoneyBillWave size={28} color="black" />,
|
||||
},
|
||||
] : []
|
||||
|
||||
|
|
@ -183,6 +208,48 @@ export function SellerDashboardPage() {
|
|||
)}
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -54,50 +54,81 @@ export const pagamentoApiService = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Cria um novo pagamento
|
||||
* Cria uma preferência de pagamento (Checkout Pro / Link)
|
||||
*/
|
||||
criar: async (
|
||||
metodo: 'pix' | 'credito' | 'debito',
|
||||
valor: number,
|
||||
pedidoId: string
|
||||
): Promise<PagamentoApiResponse> => {
|
||||
criarPreferencia: async (pedidoId: string): Promise<PagamentoApiResponse> => {
|
||||
try {
|
||||
const token = pagamentoApiService.getAuthToken();
|
||||
if (!token) {
|
||||
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`, {
|
||||
const response = await fetch(`${API_BASE_URL}/orders/${pedidoId}/payment`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'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',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify(paymentData),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
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' };
|
||||
}
|
||||
return response.ok ? { success: true, data } : { success: false, error: data.message || 'Erro ao processar cartão' };
|
||||
} catch (error) {
|
||||
console.error('💥 Erro na criação do pagamento:', error);
|
||||
return { success: false, error: 'Erro de conexão ao criar pagamento' };
|
||||
return { success: false, error: 'Erro de conexão' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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
|
||||
* (Para uso temporário até integração real)
|
||||
|
|
|
|||
Loading…
Reference in a new issue