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.
258 lines
8 KiB
Go
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
|
|
}
|