UNPKG

@nicogorga/medusa-payment-mercadopago

Version:

Mercado Pago payment provider plugin for MedusaJS

527 lines (462 loc) 17.2 kB
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;