saveinmed/backend-old/internal/payments/mercadopago.go
NANDO9322 78a95e3263 feat: reestruturação do checkout, correções de pagamento e melhorias visuais
Backend:
- Renomeado BACKEND_URL para BACKEND_HOST no .env e nas configs para consistência.
- Atualizado MercadoPagoGateway para usar o BACKEND_HOST correto na notification_url.
- Atualizado payment_handler para receber e processar informações do Pagador (email/doc).
- Corrigido erro 500 ao buscar dados de compradores B2B.

Frontend:
- Criado componente Header reutilizável e aplicado nas páginas internas.
- Implementada nova página "Meus Pedidos" com lógica de listagem correta.
- Implementada página de "Detalhes do Pedido" (/pedidos/[id]) com alto contraste visual.
- Melhorada a legibilidade da página de detalhes (textos pretos/escuros).
- Corrigido bug onde pagamentos rejeitados eram tratados como sucesso (agora verifica status 'rejected' no serviço).
- Adicionado componente <Toaster /> ao layout principal para corrigir notificações invisíveis.
- Adicionado feedback visual persistente de erro na tela de checkout para falhas de pagamento.
2026-01-28 16:37:21 -03:00

258 lines
8 KiB
Go

package payments
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/saveinmed/backend-go/internal/domain"
)
type MercadoPagoGateway struct {
BaseURL string
AccessToken string
BackendURL string
MarketplaceCommission float64
}
func NewMercadoPagoGateway(baseURL, accessToken, backendURL string, commission float64) *MercadoPagoGateway {
return &MercadoPagoGateway{
BaseURL: baseURL,
AccessToken: accessToken,
BackendURL: backendURL,
MarketplaceCommission: commission,
}
}
func (g *MercadoPagoGateway) CreatePreference(ctx context.Context, order *domain.Order, payer *domain.User, sellerAcc *domain.SellerPaymentAccount) (*domain.PaymentPreference, error) {
// Construct items
var items []map[string]interface{}
for _, i := range order.Items {
items = append(items, map[string]interface{}{
"id": i.ProductID.String(),
"title": "Produto", // Fallback
"description": fmt.Sprintf("Product ID %s", i.ProductID),
"quantity": int(i.Quantity),
"unit_price": float64(i.UnitCents) / 100.0,
"currency_id": "BRL",
})
}
shipmentCost := float64(order.ShippingFeeCents) / 100.0
notificationURL := g.BackendURL + "/api/v1/payments/webhook"
payerData := map[string]interface{}{
"email": payer.Email,
"name": payer.Name,
}
if payer.CPF != "" {
payerData["identification"] = map[string]interface{}{
"type": "CPF",
"number": payer.CPF,
}
}
payload := map[string]interface{}{
"items": items,
"payer": payerData,
"shipments": map[string]interface{}{
"cost": shipmentCost,
"mode": "not_specified",
},
"external_reference": order.ID.String(),
"notification_url": notificationURL,
"binary_mode": true,
"back_urls": map[string]string{
"success": g.BackendURL + "/checkout/success",
"failure": g.BackendURL + "/checkout/failure",
"pending": g.BackendURL + "/checkout/pending",
},
"auto_return": "approved",
}
// Calculate Fee
svcFeeCents := int64(float64(order.TotalCents) * (g.MarketplaceCommission / 100))
sellerRecCents := order.TotalCents - svcFeeCents
// Add Split Payment logic (Transfers) if SellerAccount is provided
if sellerAcc != nil && sellerAcc.AccountID != "" {
// Try to parse AccountID (e.g. 123456789)
// If AccountID contains non-digits, this might fail, MP User IDs are integers.
// We'll trust it's a valid string representation of int.
sellerMPID, err := strconv.ParseInt(sellerAcc.AccountID, 10, 64)
if err == nil {
// We transfer the seller's share to them.
// Marketplace keeps the rest (which equals svcFee + Shipping if shipping is ours? Or Seller pays shipping?)
// Usually, total = items + shipping.
// If Seller pays commission on Total, then Seller gets (Total - Fee).
// If Shipping is pass-through, we need to be careful.
// Simple logic: Seller receives 'sellerRecCents'.
payload["purpose"] = "wallet_purchase"
payload["transfers"] = []map[string]interface{}{
{
"amount": float64(sellerRecCents) / 100.0,
"collector_id": sellerMPID,
"description": fmt.Sprintf("Venda SaveInMed #%s", order.ID.String()),
},
}
} else {
// Log error but proceed without split? Or fail?
// Ideally we fail if we can't split.
return nil, fmt.Errorf("invalid seller account id for split: %w", err)
}
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", g.BaseURL+"/checkout/preferences", bytes.NewBuffer(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+g.AccessToken)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to call MP API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("MP API failed with status %d", resp.StatusCode)
}
var result struct {
ID string `json:"id"`
InitPoint string `json:"init_point"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &domain.PaymentPreference{
OrderID: order.ID,
Gateway: "mercadopago",
PaymentID: result.ID,
PaymentURL: result.InitPoint,
CommissionPct: g.MarketplaceCommission,
MarketplaceFee: svcFeeCents,
SellerReceivable: sellerRecCents,
}, nil
}
// CreatePayment executes a direct payment (Card/Pix) using a token (Bricks).
func (g *MercadoPagoGateway) CreatePayment(ctx context.Context, order *domain.Order, token, issuerID, paymentMethodID string, installments int, payer *domain.User, sellerAcc *domain.SellerPaymentAccount) (*domain.PaymentResult, error) {
// shipmentCost folded into transaction_amount
payerData := map[string]interface{}{
"email": payer.Email,
"first_name": strings.Split(payer.Name, " ")[0],
}
// Handle Last Name if possible, or just send email. MP is lenient.
if payer.CPF != "" {
docType := "CPF"
cleanDoc := strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(payer.CPF, ".", ""), "-", ""), "/", "")
if len(cleanDoc) > 11 {
docType = "CNPJ"
}
payerData["identification"] = map[string]interface{}{
"type": docType,
"number": cleanDoc,
}
}
payload := map[string]interface{}{
"transaction_amount": float64(order.TotalCents+order.ShippingFeeCents) / 100.0,
"token": token,
"description": fmt.Sprintf("Pedido #%s", order.ID.String()),
"installments": installments,
"payment_method_id": paymentMethodID,
"issuer_id": issuerID,
"payer": payerData,
"external_reference": order.ID.String(),
"notification_url": g.BackendURL + "/api/v1/payments/webhook",
"binary_mode": true,
}
// [Remains unchanged...]
// Determine Logic for Total Amount
// If order.TotalCents is items, and ShippingFee is extra:
// realTotal = TotalCents + ShippingFeeCents.
// In CreatePreference, cost was separate. Here it's one blob.
// Fee Calculation
chargeAmountCents := order.TotalCents + order.ShippingFeeCents
payload["transaction_amount"] = float64(chargeAmountCents) / 100.0
// Split Payment Logic
svcFeeCents := int64(float64(order.TotalCents) * (g.MarketplaceCommission / 100))
sellerRecCents := chargeAmountCents - svcFeeCents
if sellerAcc != nil && sellerAcc.AccountID != "" {
sellerMPID, err := strconv.ParseInt(sellerAcc.AccountID, 10, 64)
if err == nil {
payload["application_fee"] = nil
payload["transfers"] = []map[string]interface{}{
{
"amount": float64(sellerRecCents) / 100.0,
"collector_id": sellerMPID,
"description": fmt.Sprintf("Venda SaveInMed #%s", order.ID.String()),
},
}
}
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal error: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", g.BaseURL+"/v1/payments", bytes.NewBuffer(body))
if err != nil {
return nil, err
}
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, fmt.Errorf("api error: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
buf := new(bytes.Buffer)
buf.ReadFrom(resp.Body)
bodyStr := buf.String()
fmt.Printf("MP Error Body: %s\n", bodyStr) // Log to stdout
return nil, fmt.Errorf("mp status %d: %s", resp.StatusCode, bodyStr)
}
var res struct {
ID int64 `json:"id"`
Status string `json:"status"`
StatusDetail string `json:"status_detail"`
}
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return nil, err
}
return &domain.PaymentResult{
PaymentID: fmt.Sprintf("%d", res.ID),
Status: res.Status,
Gateway: "mercadopago",
Message: res.StatusDetail,
}, nil
}