360 lines
12 KiB
Go
360 lines
12 KiB
Go
package payments
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"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
|
|
}
|