@nicogorga/medusa-payment-mercadopago
Version:
Mercado Pago payment provider plugin for MedusaJS
527 lines (462 loc) • 17.2 kB
text/typescript
import {
AbstractPaymentProvider,
isString,
MedusaError,
MedusaErrorTypes,
PaymentActions,
PaymentSessionStatus,
} from "@medusajs/framework/utils";
import {
AuthorizePaymentInput,
AuthorizePaymentOutput,
CancelPaymentInput,
CancelPaymentOutput,
CapturePaymentInput,
CapturePaymentOutput,
CreateAccountHolderInput,
CreateAccountHolderOutput,
DeletePaymentInput,
DeletePaymentOutput,
GetPaymentStatusInput,
GetPaymentStatusOutput,
InitiatePaymentInput,
InitiatePaymentOutput,
ListPaymentMethodsInput,
ListPaymentMethodsOutput,
PaymentCustomerDTO,
ProviderWebhookPayload,
RefundPaymentInput,
RefundPaymentOutput,
RetrievePaymentInput,
RetrievePaymentOutput,
SavePaymentMethodInput,
SavePaymentMethodOutput,
UpdateAccountHolderInput,
UpdateAccountHolderOutput,
UpdatePaymentInput,
UpdatePaymentOutput,
WebhookActionResult,
} from "@medusajs/types";
import MercadoPagoConfig, { Customer, CustomerCard, Payment, PaymentRefund } from "mercadopago";
import { PaymentCreateRequest } from "mercadopago/dist/clients/payment/create/types";
import { PaymentSearchResult } from "mercadopago/dist/clients/payment/search/types";
import { MercadopagoError, MercadopagoOptions, MercadopagoWebhookPayload } from "../../types";
import { createHmac } from "crypto";
import { Logger } from "@medusajs/medusa";
import { CustomerRequestBody, CustomerResponse } from "mercadopago/dist/clients/customer/commonTypes";
import { CustomerUpdateData } from "mercadopago/dist/clients/customer/update/types";
import { PostStoreMercadopagoPaymentType } from "../../api/store/mercadopago/payment/validators";
import { PaymentResponse } from "mercadopago/dist/clients/payment/commonTypes";
type InjectedDependencies = {
logger: Logger;
};
class MercadopagoProviderService extends AbstractPaymentProvider<MercadopagoOptions> {
static identifier = "mercadopago";
protected options_: MercadopagoOptions;
protected client_: MercadoPagoConfig;
protected logger_: Logger;
constructor(container: InjectedDependencies, options: MercadopagoOptions) {
super(container, options);
this.options_ = options;
this.client_ = new MercadoPagoConfig({
accessToken: options.accessToken,
});
this.logger_ = container.logger;
}
static validateOptions(options: Record<any, any>): void | never {
if (!options.accessToken || !isString(options.accessToken)) {
throw new MedusaError(
MedusaErrorTypes.INVALID_DATA,
"Mercado pago 'accessToken' needs to be a non empty string"
);
}
}
async initiatePayment(
input: InitiatePaymentInput
): Promise<InitiatePaymentOutput> {
return {
id: "",
data: {
session_id: input.data?.session_id,
amount: input.amount,
},
};
}
async deletePayment(input: DeletePaymentInput): Promise<DeletePaymentOutput> {
const paymentId = input.data?.id;
if (paymentId) {
const payment = new Payment(this.client_);
await payment.cancel({ id: paymentId as string });
}
return {};
}
async authorizePayment(
input: AuthorizePaymentInput
): Promise<AuthorizePaymentOutput> {
const paymentSessionId = input.data?.session_id as string | undefined;
let data: PaymentSearchResult = input.data ?? {};
if (!paymentSessionId) {
throw new MedusaError(
MedusaErrorTypes.INVALID_DATA,
"No payment session id found in data"
);
}
const payment = new Payment(this.client_);
const results =
((
await payment.search({
options: {
external_reference: paymentSessionId,
sort: 'date_approved',
criteria: 'desc'
},
})
// Ideally, this would be done in the call to Mercado Pago, but they don't expose an option
// to filter based on status
)?.results ?? []).filter(payment => payment.status === 'approved');
if (!results.length) {
this.logger_.warn(
`No payment found in Mercado Pago for payment session: ${paymentSessionId}\n This could be caused by lag in Mercado Pago's system and doesn't mean the payment was not created`
);
}
data = results[0] ?? data;
// Returning PaymentSessionStatus.CAPTURED for auto capture, since for UY card method they are captured automatically
// TODO: Should make it conditionally in cases where a method could be not auto captured (maybe cash or another country)
return {
data: data,
status: PaymentSessionStatus.CAPTURED,
};
}
async capturePayment(
input: CapturePaymentInput
): Promise<CapturePaymentOutput> {
// For now, we assume auto capture, so no need to do anything else with Mercado Pago
return { data: input.data };
}
async cancelPayment({ data, context }: CancelPaymentInput): Promise<CancelPaymentOutput> {
try {
const id = data?.id as string;
if (!id) {
return { data: data };
}
const { status } = await this.getPaymentStatus({ data: data });
switch(status) {
case 'authorized':
const paymentClient = new Payment(this.client_)
const paymentResponse = await paymentClient.cancel({ id, requestOptions: { idempotencyKey: context?.idempotency_key }});
return { data: paymentResponse as unknown as Record<string, unknown>};
case 'captured':
const refundClient = new PaymentRefund(this.client_)
const refundResponse = await refundClient.total({ payment_id: id, requestOptions: { idempotencyKey: context?.idempotency_key }});
return { data: refundResponse as unknown as Record<string, unknown>};
default:
return { data };
}
} catch (error) {
throw new Error(`An error occurred in cancelPayment: ${error}`);
}
}
async getPaymentStatus(
input: GetPaymentStatusInput
): Promise<GetPaymentStatusOutput> {
const paymentId = input.data?.id;
const payment = new Payment(this.client_);
const paymentData = await payment.get({ id: paymentId as string }) as unknown as Record<string, unknown>;
switch (paymentData.status) {
case "authorized":
return { status: "authorized", data: paymentData };
case "approved":
return { status: "captured", data: paymentData };
case "cancelled":
case "refunded":
return { status: "canceled", data: paymentData };
case "rejected":
return { status: "error", data: {
...paymentData,
error_message: this.sanitizeErrorMessage(paymentData as unknown as PaymentResponse)
}}
default:
return { status: "pending", data: paymentData };
}
}
async refundPayment(input: RefundPaymentInput): Promise<RefundPaymentOutput> {
const paymentId = this.getIdOrThrow(input.data);
const payment = new Payment(this.client_);
const paymentData = await payment.get({ id: paymentId });
const refundAmount = Number(input.amount);
const isPartial =
(paymentData.transaction_amount ?? refundAmount) > refundAmount;
const refund = new PaymentRefund(this.client_);
const refundData = await refund.create({
payment_id: paymentId,
body: {
amount: isPartial ? refundAmount : undefined,
},
});
return { data: refundData as unknown as Record<string, unknown> };
}
async retrievePayment(
input: RetrievePaymentInput
): Promise<RetrievePaymentOutput> {
const paymentId = this.getIdOrThrow(input.data);
const payment = new Payment(this.client_);
const paymentData = await payment.get({ id: paymentId });
return { data: paymentData as unknown as Record<string, unknown> };
}
updatePayment(input: UpdatePaymentInput): Promise<UpdatePaymentOutput> {
// TODO: Until working with preferences, we won't have a need for this method. After including preferences, it should update preference items and total amount
// when items are added / removed from cart
return Promise.resolve({ data: input.data });
}
async getWebhookActionAndData(
payload: ProviderWebhookPayload["payload"]
): Promise<WebhookActionResult> {
this.validateWebhookSignature(payload);
const mercadopagoData = payload.data as MercadopagoWebhookPayload;
const eventType = mercadopagoData.action;
try {
switch (eventType) {
case "payment.created":
case "payment.updated":
const payment = new Payment(this.client_);
const paymentData = await payment.get({
id: mercadopagoData.data.id as string,
});
const paymentSessionId = paymentData.external_reference;
if (!paymentSessionId) {
throw new Error(
"No external_reference found in mercadopago payload, unable to match against Medusa payment session"
);
}
if (["authorized", "approved"].includes(paymentData.status ?? "")) {
return {
action:
paymentData.status === "approved"
? PaymentActions.SUCCESSFUL
: PaymentActions.AUTHORIZED,
data: {
session_id: paymentSessionId,
amount: paymentData.transaction_amount!,
},
};
}
default:
return { action: "not_supported" };
}
} catch (error) {
return { action: "failed" };
}
}
async createAccountHolder?({
context
}: CreateAccountHolderInput
): Promise<CreateAccountHolderOutput> {
const { account_holder, customer, idempotency_key } = context
const id = account_holder?.data.id as string | undefined
if (id) {
return { id }
}
if (!customer) {
throw new MedusaError(MedusaErrorTypes.INVALID_DATA, "No customer provided while creating account holder")
}
const customerClient = new Customer(this.client_);
let mercadopagoCustomer: CustomerResponse | undefined
try {
const body: CustomerRequestBody = {
email: customer.email,
first_name: customer.first_name || undefined,
last_name: customer.last_name || undefined,
date_registered: new Date().toISOString(),
};
mercadopagoCustomer = await customerClient.create({
body: body,
requestOptions: {
idempotencyKey: idempotency_key,
},
});
} catch (e) {
const error = e as MercadopagoError
if (!!error.cause?.find(c => c.code === '101')) {
const { results } = await customerClient.search({ options: { email: customer.email}})
mercadopagoCustomer = results![0]
} else {
throw new MedusaError(MedusaErrorTypes.UNEXPECTED_STATE, "An error occurred while trying to create a Mercado Pago customer")
}
}
return {
id: mercadopagoCustomer!.id!,
data: mercadopagoCustomer as unknown as Record<string, unknown>
}
}
async updateAccountHolder({
context,
}: UpdateAccountHolderInput): Promise<UpdateAccountHolderOutput> {
const { account_holder, customer, idempotency_key } = context
const accountHolderId = account_holder.data?.id as string | undefined
if (!accountHolderId) {
throw new MedusaError(MedusaErrorTypes.INVALID_DATA, "No account holder provided while updating account holder")
}
// No customer, nothing to upodate with third party
if (!customer) {
return {}
}
try {
const customerClient = new Customer(this.client_);
const body: CustomerRequestBody = {
first_name: customer.first_name ?? undefined,
last_name: customer.last_name ?? undefined,
phone: { number: customer.phone ?? undefined },
identification: account_holder.data.identification ?? undefined,
}
if (!account_holder.data.email) {
body.email = customer.email
}
const payload: CustomerUpdateData = {
customerId: accountHolderId,
body: body,
requestOptions: {
idempotencyKey: idempotency_key,
}
}
const updatedCustomer = await customerClient.update(payload);
return {
data: updatedCustomer as unknown as Record<string, unknown>,
}
} catch (e) {
throw new MedusaError(MedusaErrorTypes.UNEXPECTED_STATE, "An error occurred in updateAccountHolder when updating a Stripe customer")
}
}
async savePaymentMethod({
context,
data,
}: SavePaymentMethodInput): Promise<SavePaymentMethodOutput> {
const accountHolderId = context?.account_holder?.data?.id as
| string
| undefined
if (!accountHolderId) {
throw new MedusaError(MedusaErrorTypes.INVALID_DATA, "Account holder not set while saving a payment method")
}
const paymentMethodData = data as PaymentCreateRequest
const card = new CustomerCard(this.client_)
// I opened a support ticket with Mercado Pago, as this call randomly fails or succeeds
// for now, the step that calls this method has a try / catch, to allow the Payment to cotinue even if this fails
const created = await card.create({
customerId: accountHolderId,
body: {
token: paymentMethodData.token,
},
requestOptions: {
idempotencyKey: context?.idempotency_key
}
})
return { id: created.id!, data: created as unknown as Record<string, unknown> }
}
async listPaymentMethods({
context,
}: ListPaymentMethodsInput): Promise<ListPaymentMethodsOutput> {
const accountHolderId = context?.account_holder?.data?.id as
| string
| undefined
if (!accountHolderId) {
return []
}
const cardClient = new CustomerCard(this.client_)
const paymentMethods = await cardClient.list({
customerId: accountHolderId
})
return paymentMethods.map((method) => ({
id: method.id!,
data: method as unknown as Record<string, unknown>,
}))
}
async createPayment({
paymentSessionId,
payload,
}: {
paymentSessionId: string;
payload: PostStoreMercadopagoPaymentType['paymentData'];
}) {
const payment = new Payment(this.client_);
const paymentResponse = await payment.create({
body: {
...payload,
external_reference: paymentSessionId,
},
});
const errorMessage = this.sanitizeErrorMessage(paymentResponse)
paymentResponse["error_message"] = errorMessage
return paymentResponse
}
protected sanitizeErrorMessage(payment: PaymentResponse) {
const status = payment.status;
const statusDetail = payment.status_detail;
if (status !== "rejected" || !statusDetail) {
return null;
}
switch (statusDetail) {
case "cc_rejected_bad_filled_card_number":
return "Numero de tarjeta incorrecto.";
case "cc_rejected_bad_filled_date":
return "Numero de expiracion incorrecto.";
case "cc_rejected_bad_filled_security_code":
return "Numero de seguridad incorrecto.";
case "cc_rejected_insufficient_amount":
case "insufficient_amount":
return "Saldo insuficiente.";
case "cc_rejected_max_attempts":
return "Has superado el maximo de intentos, prueba otra tarjeta.";
case "rejected_by_bank":
return "Tu banco ha rechazado el pago.";
default:
return "No hemos podido procesar el pago, intenta nuevamente o prueba otra tarjeta.";
}
}
protected validateWebhookSignature(data: ProviderWebhookPayload["payload"]) {
const secret = this.options_.webhookSecret;
// If no webhookSecret is set, the assumption is this protection is not required
if (!secret) {
return;
}
const headers = data.headers;
const xSignature = (headers["x-signature"] ?? "noop") as string;
const xRequestId = (headers["x-request-id"] ?? "noop") as string;
const body = data?.data as MercadopagoWebhookPayload;
const dataId = (body.data?.id ?? "noop") as string;
const parts = xSignature.split(",");
let timestamp: string | undefined;
let hash: string | undefined;
parts.forEach((part) => {
const [key, val] = part.split("=");
if (key && val) {
const [trimmedKey, trimmedVal] = [key.trim(), val.trim()];
if (trimmedKey === "ts") {
timestamp = trimmedVal;
} else if (trimmedKey === "v1") {
hash = trimmedVal;
}
}
});
const manifest = `id:${dataId};request-id:${xRequestId};ts:${timestamp};`;
const hmac = createHmac("sha256", secret);
hmac.update(manifest);
const sha = hmac.digest("hex");
if (sha !== hash) {
this.logger_
.warn(`Unable to verify Mercado Pago authenticity of request with headers:\n
${headers}
`);
throw new MedusaError(MedusaErrorTypes.INVALID_DATA, "Invalid signature");
}
}
getIdOrThrow(data?: Record<string, unknown>) {
const id = String(data?.id);
if (!id) {
throw new MedusaError(
MedusaErrorTypes.INVALID_DATA,
"No valid string stored against 'id' key of data object"
);
}
return id;
}
}
export default MercadopagoProviderService;