saveinmed/backend/internal/infrastructure/payments/mercadopago.go

361 lines
12 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
}
// 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
}