diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index a86472c..75459e3 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -47,6 +47,7 @@ dependencies { implementation("org.jsoup:jsoup:1.14.3") implementation("com.stripe:stripe-java:20.3.0") implementation("com.paypal.sdk:checkout-sdk:1.0.5") + implementation("com.mercadopago:sdk-java:2.1.22") implementation("club.minnced:discord-webhooks:0.7.5") implementation(libs.kotlin.logging) diff --git a/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/PerfectPayments.kt b/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/PerfectPayments.kt index 0e873a7..55ef73b 100644 --- a/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/PerfectPayments.kt +++ b/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/PerfectPayments.kt @@ -2,6 +2,7 @@ package net.perfectdreams.perfectpayments.backend import club.minnced.discord.webhook.WebhookClient import com.github.benmanes.caffeine.cache.Caffeine +import com.mercadopago.MercadoPagoConfig import com.zaxxer.hikari.HikariConfig import com.zaxxer.hikari.HikariDataSource import io.ktor.client.* @@ -24,22 +25,14 @@ import kotlinx.coroutines.withContext import mu.KotlinLogging import net.perfectdreams.perfectpayments.backend.config.AppConfig import net.perfectdreams.perfectpayments.backend.config.FocusNFeConfig -import net.perfectdreams.perfectpayments.backend.processors.creators.PagSeguroPaymentCreator -import net.perfectdreams.perfectpayments.backend.processors.creators.PayPalPaymentCreator -import net.perfectdreams.perfectpayments.backend.processors.creators.PicPayPaymentCreator -import net.perfectdreams.perfectpayments.backend.processors.creators.SandboxPaymentCreator -import net.perfectdreams.perfectpayments.backend.processors.creators.StripePaymentCreator +import net.perfectdreams.perfectpayments.backend.processors.creators.* import net.perfectdreams.perfectpayments.backend.routes.CancelledRoute import net.perfectdreams.perfectpayments.backend.routes.HomeRoute import net.perfectdreams.perfectpayments.backend.routes.MissingPartialPaymentRoute import net.perfectdreams.perfectpayments.backend.routes.SuccessRoute import net.perfectdreams.perfectpayments.backend.routes.api.v1.GetAvailableGatewaysRoute import net.perfectdreams.perfectpayments.backend.routes.api.v1.GetStringsRoute -import net.perfectdreams.perfectpayments.backend.routes.api.v1.callbacks.PostFocusNFeCallbackRoute -import net.perfectdreams.perfectpayments.backend.routes.api.v1.callbacks.PostPagSeguroCallbackRoute -import net.perfectdreams.perfectpayments.backend.routes.api.v1.callbacks.PostPayPalCallbackRoute -import net.perfectdreams.perfectpayments.backend.routes.api.v1.callbacks.PostPicPayCallbackRoute -import net.perfectdreams.perfectpayments.backend.routes.api.v1.callbacks.PostStripeCallbackRoute +import net.perfectdreams.perfectpayments.backend.routes.api.v1.callbacks.* import net.perfectdreams.perfectpayments.backend.routes.api.v1.payments.GetPartialPaymentInfoRoute import net.perfectdreams.perfectpayments.backend.routes.api.v1.payments.GetReissueNotaFiscalForPaymentRoute import net.perfectdreams.perfectpayments.backend.routes.api.v1.payments.GetRenotifyPaymentRoute @@ -103,7 +96,8 @@ class PerfectPayments( PaymentGateway.PAGSEGURO to PagSeguroPaymentCreator(this), PaymentGateway.STRIPE to StripePaymentCreator(this), PaymentGateway.PAYPAL to PayPalPaymentCreator(this), - PaymentGateway.SANDBOX to SandboxPaymentCreator(this) + PaymentGateway.SANDBOX to SandboxPaymentCreator(this), + PaymentGateway.MERCADOPAGO to MercadoPagoPaymentCreator(this) ) val focusNFe = focusNFeConfig?.let { @@ -151,6 +145,10 @@ class PerfectPayments( it.add(PostPayPalCallbackRoute(this)) } + if (config.gateways.contains(PaymentGateway.MERCADOPAGO)) { + it.add(PostMercadoPagoCallbackRoute(this)) + } + /* if (config.gateways.contains(PaymentGateway.SANDBOX)) it.add(PostCheckoutSandboxRoute(this)) */ } @@ -203,6 +201,10 @@ class PerfectPayments( scheduleCoroutineAtFixedRate(UpdatePagSeguroPaymentsTask::class.simpleName!!, tasksScope, 1.minutes, action = UpdatePagSeguroPaymentsTask(this)) } + if (config.gateways.contains(PaymentGateway.MERCADOPAGO)) { + MercadoPagoConfig.setAccessToken(gateway.mercadoPago.accessToken) // Nasty!! + } + val server = embeddedServer(Netty, host = config.website.host, port = config.website.port) { install(CORS) { anyHost() diff --git a/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/PerfectPaymentsLauncher.kt b/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/PerfectPaymentsLauncher.kt index 08046ae..ab038f1 100644 --- a/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/PerfectPaymentsLauncher.kt +++ b/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/PerfectPaymentsLauncher.kt @@ -6,12 +6,7 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.hocon.Hocon import kotlinx.serialization.hocon.decodeFromConfig import mu.KotlinLogging -import net.perfectdreams.perfectpayments.backend.config.AppConfig -import net.perfectdreams.perfectpayments.backend.config.FocusNFeConfig -import net.perfectdreams.perfectpayments.backend.config.PagSeguroConfig -import net.perfectdreams.perfectpayments.backend.config.PayPalConfig -import net.perfectdreams.perfectpayments.backend.config.PicPayConfig -import net.perfectdreams.perfectpayments.backend.config.StripeConfig +import net.perfectdreams.perfectpayments.backend.config.* import net.perfectdreams.perfectpayments.common.payments.PaymentGateway import java.io.File @@ -41,6 +36,9 @@ object PerfectPaymentsLauncher { if (gateway == PaymentGateway.PAYPAL) { configs[gateway] = loadConfig("./paypal.conf") } + if (gateway == PaymentGateway.MERCADOPAGO) { + configs[gateway] = loadConfig("./mercadopago.conf") + } } val focusNFeConfig = loadConfig("./focusnfe.conf") diff --git a/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/config/MercadoPagoConfig.kt b/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/config/MercadoPagoConfig.kt new file mode 100644 index 0000000..9beda84 --- /dev/null +++ b/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/config/MercadoPagoConfig.kt @@ -0,0 +1,10 @@ +package net.perfectdreams.perfectpayments.backend.config + +import kotlinx.serialization.Serializable + +@Serializable +class MercadoPagoConfig( + val accessToken: String, + val webhookSecretSignature: String, + val callbackUrl: String +) \ No newline at end of file diff --git a/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/processors/creators/CreatedPaymentInfo.kt b/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/processors/creators/CreatedPaymentInfo.kt index c43c29e..7a508f1 100644 --- a/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/processors/creators/CreatedPaymentInfo.kt +++ b/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/processors/creators/CreatedPaymentInfo.kt @@ -26,4 +26,9 @@ class CreatedPicPayPaymentInfo( class CreatedSandboxPaymentInfo( id: String -) : CreatedPaymentInfo(id) \ No newline at end of file +) : CreatedPaymentInfo(id) + +class CreatedMercadoPagoPaymentInfo( + id: String, + url: String +) : CreatedPaymentInfoWithUrl(id, url) \ No newline at end of file diff --git a/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/processors/creators/MercadoPagoPaymentCreator.kt b/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/processors/creators/MercadoPagoPaymentCreator.kt new file mode 100644 index 0000000..9a9dbff --- /dev/null +++ b/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/processors/creators/MercadoPagoPaymentCreator.kt @@ -0,0 +1,37 @@ +package net.perfectdreams.perfectpayments.backend.processors.creators + +import com.mercadopago.client.preference.PreferenceClient +import com.mercadopago.client.preference.PreferenceItemRequest +import com.mercadopago.client.preference.PreferenceRequest +import kotlinx.serialization.json.JsonObject +import net.perfectdreams.perfectpayments.backend.PerfectPayments +import net.perfectdreams.perfectpayments.backend.utils.PartialPayment +import net.perfectdreams.perfectpayments.backend.utils.TextUtils +import java.math.BigDecimal + +class MercadoPagoPaymentCreator(val m: PerfectPayments) : PaymentCreator { + val client = PreferenceClient() + + override suspend fun createPayment(paymentId: Long, partialPayment: PartialPayment, data: JsonObject): CreatedMercadoPagoPaymentInfo { + val itemRequest = + PreferenceItemRequest.builder() + .title(TextUtils.cleanTitle(partialPayment.title)) + .quantity(1) + .currencyId("BRL") + .unitPrice(BigDecimal(partialPayment.amount / 100.0)) + .build() + val items: MutableList = ArrayList() + items.add(itemRequest) + val preferenceRequest = PreferenceRequest.builder() + .externalReference(partialPayment.externalReference.format(paymentId)) + .notificationUrl(m.gateway.mercadoPago.callbackUrl) + .items(items) + .build() + val preference = client.create(preferenceRequest) + + return CreatedMercadoPagoPaymentInfo( + preference.id, + preference.initPoint + ) + } +} \ No newline at end of file diff --git a/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/routes/api/v1/callbacks/PostMercadoPagoCallbackRoute.kt b/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/routes/api/v1/callbacks/PostMercadoPagoCallbackRoute.kt new file mode 100644 index 0000000..b2b7c83 --- /dev/null +++ b/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/routes/api/v1/callbacks/PostMercadoPagoCallbackRoute.kt @@ -0,0 +1,154 @@ +package net.perfectdreams.perfectpayments.backend.routes.api.v1.callbacks + +import com.mercadopago.client.payment.PaymentClient +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import mu.KotlinLogging +import net.perfectdreams.perfectpayments.backend.PerfectPayments +import net.perfectdreams.perfectpayments.backend.dao.Payment +import net.perfectdreams.perfectpayments.backend.payments.PaymentStatus +import net.perfectdreams.perfectpayments.backend.utils.PaymentUtils +import net.perfectdreams.perfectpayments.backend.utils.extensions.respondEmptyJson +import net.perfectdreams.sequins.ktor.BaseRoute +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + + +class PostMercadoPagoCallbackRoute(val m: PerfectPayments) : BaseRoute("/api/v1/callbacks/mercadopago") { + companion object { + private val logger = KotlinLogging.logger {} + } + + private val paymentClient = PaymentClient() + + override suspend fun onRequest(call: ApplicationCall) { + logger.info { "Received MercadoPago Webhook Request" } + + val parameters = call.request.queryParameters + val body = call.receiveText() + val type = parameters["type"] + + logger.info { "MercadoPago type: $type, params: ${parameters.entries()}; body: $body" } + + val xSignature = call.request.header("x-signature") + val xRequestId = call.request.header("x-request-id") + + if (xSignature == null) { + logger.warn { "MercadoPago request is missing the x-signature header!" } + call.respondEmptyJson(HttpStatusCode.Forbidden) + return + } + + if (xRequestId == null) { + logger.warn { "MercadoPago request is missing the x-request-id header!" } + call.respondEmptyJson(HttpStatusCode.Forbidden) + return + } + + if (!validate(parameters["data.id"], xSignature, xRequestId)) { + logger.warn { "MercadoPago request didn't match our signature!" } + call.respondEmptyJson(HttpStatusCode.Forbidden) + return + } + + when (type) { + "payment" -> { + // Get payment info + val dataId = parameters["data.id"]?.toLongOrNull() ?: error("Missing data.id!") + val payment = paymentClient.get(dataId) + + val reference = payment.externalReference + val internalTransactionId = reference.split("-").last() + + val internalPayment = m.newSuspendedTransaction { + Payment.findById(internalTransactionId.toLong()) + } + + if (internalPayment == null) { + logger.warn { "MercadoPago Payment with Reference ID: $reference ($internalTransactionId) doesn't have a matching internal ID! Bug?" } + call.respondEmptyJson() + return + } + + when (payment.status) { + "approved" -> { + PaymentUtils.updatePaymentStatus( + m, + internalPayment, + PaymentStatus.APPROVED + ) + } + "in_mediation" -> { + PaymentUtils.updatePaymentStatus( + m, + internalPayment, + PaymentStatus.CHARGED_BACK + ) + } + "charged_back" -> { + PaymentUtils.updatePaymentStatus( + m, + internalPayment, + PaymentStatus.CHARGED_BACK + ) + } + } + } + } + + call.respondEmptyJson() + } + + fun validate(dataID: String?, xSignature: String, xRequestId: String): Boolean { + // Separating the x-signature into parts + val parts = xSignature.split(",".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + + // Initializing variables to store ts and hash + var ts: String? = null + var hash: String? = null + + // Iterate over the values to obtain ts and v1 + for (part in parts) { + val keyValue = part.trim { it <= ' ' }.split("=".toRegex()).dropLastWhile { it.isEmpty() } + .toTypedArray() + if (keyValue.size == 2) { + val key = keyValue[0].trim { it <= ' ' } + val value = keyValue[1].trim { it <= ' ' } + if ("ts" == key) { + ts = value + } else if ("v1" == key) { + hash = value + } + } + } + + // Generate the manifest string + val manifest = String.format("id:%s;request-id:%s;ts:%s;", dataID, xRequestId, ts) + + val mac = Mac.getInstance("HmacSHA256") + + val signingKey = SecretKeySpec(m.gateway.mercadoPago.webhookSecretSignature.toByteArray(Charsets.UTF_8), "HmacSHA256") + mac.init(signingKey) + val doneFinal = mac.doFinal(manifest.toByteArray(Charsets.UTF_8)) + + return hash == doneFinal.bytesToHex() + } + + /** + * Converts a ByteArray to a hexadecimal string + * + * @return the byte array in hexadecimal format + */ + private fun ByteArray.bytesToHex(): String { + val hexString = StringBuffer() + for (i in this.indices) { + val hex = Integer.toHexString(0xff and this[i].toInt()) + if (hex.length == 1) { + hexString.append('0') + } + hexString.append(hex) + } + return hexString.toString() + } +} \ No newline at end of file diff --git a/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/utils/GatewayConfigs.kt b/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/utils/GatewayConfigs.kt index 399f1d5..778df41 100644 --- a/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/utils/GatewayConfigs.kt +++ b/backend/src/main/kotlin/net/perfectdreams/perfectpayments/backend/utils/GatewayConfigs.kt @@ -1,9 +1,6 @@ package net.perfectdreams.perfectpayments.backend.utils -import net.perfectdreams.perfectpayments.backend.config.PagSeguroConfig -import net.perfectdreams.perfectpayments.backend.config.PayPalConfig -import net.perfectdreams.perfectpayments.backend.config.PicPayConfig -import net.perfectdreams.perfectpayments.backend.config.StripeConfig +import net.perfectdreams.perfectpayments.backend.config.* import net.perfectdreams.perfectpayments.common.payments.PaymentGateway class GatewayConfigs(val map: Map) { @@ -15,4 +12,6 @@ class GatewayConfigs(val map: Map) { get() = map[PaymentGateway.STRIPE] as StripeConfig val payPal: PayPalConfig get() = map[PaymentGateway.PAYPAL] as PayPalConfig + val mercadoPago: MercadoPagoConfig + get() = map[PaymentGateway.MERCADOPAGO] as MercadoPagoConfig } \ No newline at end of file diff --git a/backend/src/main/resources/static/assets/img/gateways/mercadopago.svg b/backend/src/main/resources/static/assets/img/gateways/mercadopago.svg new file mode 100644 index 0000000..cf99674 --- /dev/null +++ b/backend/src/main/resources/static/assets/img/gateways/mercadopago.svg @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/common/src/commonMain/kotlin/net/perfectdreams/perfectpayments/common/payments/PaymentGateway.kt b/common/src/commonMain/kotlin/net/perfectdreams/perfectpayments/common/payments/PaymentGateway.kt index 010c02a..d348dad 100644 --- a/common/src/commonMain/kotlin/net/perfectdreams/perfectpayments/common/payments/PaymentGateway.kt +++ b/common/src/commonMain/kotlin/net/perfectdreams/perfectpayments/common/payments/PaymentGateway.kt @@ -44,4 +44,12 @@ enum class PaymentGateway(val imageUrl: String, val methods: List PaymentMethod.SANDBOX ) ), + MERCADOPAGO( + "/assets/img/gateways/mercadopago.svg", + listOf( + PaymentMethod.BRAZIL_BANK_TICKET, + PaymentMethod.CREDIT_CARD, + PaymentMethod.PIX + ) + ), } \ No newline at end of file diff --git a/common/src/commonMain/kotlin/net/perfectdreams/perfectpayments/common/payments/UserFacingPaymentMethod.kt b/common/src/commonMain/kotlin/net/perfectdreams/perfectpayments/common/payments/UserFacingPaymentMethod.kt index f9d1d7c..cbffb5b 100644 --- a/common/src/commonMain/kotlin/net/perfectdreams/perfectpayments/common/payments/UserFacingPaymentMethod.kt +++ b/common/src/commonMain/kotlin/net/perfectdreams/perfectpayments/common/payments/UserFacingPaymentMethod.kt @@ -33,7 +33,8 @@ sealed class UserFacingPaymentMethod( I18nKeysData.Methods.Pix.Description, I18nKeysData.PaymentWillBeProcessedWithinOneHour, "/assets/img/methods/pix.svg", - PaymentGateway.PAGSEGURO, + // PaymentGateway.PAGSEGURO, + PaymentGateway.MERCADOPAGO, PaymentMethodCountry.BRAZIL ) object BrazilBankTicket : UserFacingPaymentMethod( @@ -41,7 +42,8 @@ sealed class UserFacingPaymentMethod( I18nKeysData.Methods.BrazilBankTicket.Description, I18nKeysData.PaymentWillBeProcessedWithinThreeBusinessDays, "/assets/img/methods/boleto.svg", - PaymentGateway.PAGSEGURO, + // PaymentGateway.PAGSEGURO, + PaymentGateway.MERCADOPAGO, PaymentMethodCountry.BRAZIL ) object CreditCardPagSeguro : UserFacingPaymentMethod( @@ -49,7 +51,8 @@ sealed class UserFacingPaymentMethod( I18nKeysData.Methods.CreditCardPagSeguro.Description, I18nKeysData.PaymentWillBeProcessedWithinOneHour, "/assets/img/methods/credit-card.svg", - PaymentGateway.PAGSEGURO, + // PaymentGateway.PAGSEGURO, + PaymentGateway.MERCADOPAGO, PaymentMethodCountry.GLOBAL ) object CreditCardPayPal : UserFacingPaymentMethod( @@ -73,7 +76,8 @@ sealed class UserFacingPaymentMethod( I18nKeysData.Methods.DebitCardCaixa.Description, I18nKeysData.PaymentWillBeProcessedWithinOneHour, "/assets/img/methods/debit-card-caixa.svg", - PaymentGateway.PAGSEGURO, + // PaymentGateway.PAGSEGURO, + PaymentGateway.MERCADOPAGO, PaymentMethodCountry.BRAZIL ) object PicPay : UserFacingPaymentMethod( diff --git a/resources/languages/en/strings.yml b/resources/languages/en/strings.yml index a6b33cf..8657d9a 100644 --- a/resources/languages/en/strings.yml +++ b/resources/languages/en/strings.yml @@ -51,6 +51,8 @@ gateways: name: "Stripe" pagseguro: name: "PagSeguro" + mercadoPago: + name: "MercadoPago" paypal: name: "PayPal" sandbox: