diff --git a/apps/web/.gitignore b/apps/web/.gitignore index e77579ff4..e18d7cc15 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -26,6 +26,7 @@ yarn-error.log* .pnpm-debug.log* # local env files +.env .env.local .env.development.local .env.test.local @@ -39,3 +40,4 @@ yarn-error.log* # analyze /analyze +.yarn/* \ No newline at end of file diff --git a/apps/web/app/(with-contexts)/(with-layout)/checkout/verify/page.tsx b/apps/web/app/(with-contexts)/(with-layout)/checkout/verify/page.tsx index 506036d38..41c6c5583 100644 --- a/apps/web/app/(with-contexts)/(with-layout)/checkout/verify/page.tsx +++ b/apps/web/app/(with-contexts)/(with-layout)/checkout/verify/page.tsx @@ -21,20 +21,21 @@ export default function Page() { const verifyPayment = async () => { setPaymentStatus("pending"); // Hide check status again const fetch = new FetchBuilder() - .setUrl(`${address.backend}/api/payment/verify-new`) + .setUrl(`${address.backend}/api/payment/verify`) .setHeaders({ "Content-Type": "application/json", }) - .setPayload(JSON.stringify({ id })) + .setPayload(JSON.stringify({ purchaseId: id })) .build(); try { setLoading(true); const response = await fetch.exec(); if (response.status) { - setPaymentStatus(response.status); + setPaymentStatus(response.status === "success" ? "paid" : response.status); } } catch (error) { + console.error("Error verifying payment:", error); } finally { setLoading(false); } diff --git a/apps/web/app/api/payment/verify-new/route.ts b/apps/web/app/api/payment/verify-new/route.ts index 5c469042a..4425f8305 100644 --- a/apps/web/app/api/payment/verify-new/route.ts +++ b/apps/web/app/api/payment/verify-new/route.ts @@ -12,6 +12,8 @@ interface RequestPayload { export async function POST(req: NextRequest) { const body: RequestPayload = await req.json(); const domainName = req.headers.get("domain"); + console.log("Domain:", domainName); + console.log("Body:", body); try { const domain = await getDomain(domainName); @@ -34,8 +36,10 @@ export async function POST(req: NextRequest) { if (!id) { return Response.json({ message: "Bad request" }, { status: 400 }); } - + console.log("Invoice ID:", id); + console.log("Invoices", await InvoiceModel.find()); const invoice = await InvoiceModel.findOne({ invoiceId: id }); + console.log("Invoice:", invoice); if (!invoice) { return Response.json( diff --git a/apps/web/components/admin/dashboard/metric.tsx b/apps/web/components/admin/dashboard/metric.tsx index 3580525cf..885206ed9 100644 --- a/apps/web/components/admin/dashboard/metric.tsx +++ b/apps/web/components/admin/dashboard/metric.tsx @@ -56,7 +56,8 @@ export const Metric = ({ query { activities: getActivities( type: ${type.toUpperCase()}, - duration: _${internalDuration.toUpperCase()} + duration: _${internalDuration.toUpperCase()}, + points: true ) { count, points { @@ -77,24 +78,46 @@ export const Metric = ({ setLoading(true); const response = await fetch.exec(); if (response.activities) { - const pointsWithDate = response.activities.points.map( - (point: { date: string; count: number }) => { - return { - date: new Date( - +point.date, - ).toLocaleDateString(), - count: point.count, - }; - }, - ); + const pointsWithDate = + response.activities.points?.map( + (point: { + date: string | number | Date; + count: number; + }) => { + console.log("Processing point:", point); + // Verificar el tipo de date y convertirlo apropiadamente + let dateObj: Date; + + if (typeof point.date === "object") { + // Si es un objeto Date + dateObj = new Date(point.date.toString()); + } else if (typeof point.date === "string") { + // Si es un string ISO + dateObj = new Date(point.date); + } else { + // Si es un timestamp numérico + dateObj = new Date(point.date); + } + + return { + date: dateObj.toLocaleDateString(), + count: point.count, + }; + }, + ) || []; + + console.log(`Processed points:`, pointsWithDate); + console.log(`Total count:`, response.activities.count); setData({ count: response.activities.count, points: pointsWithDate, }); + } else { + console.log(`No activities data returned from API`); } } catch (err: any) { - console.log("Error in fetching activities"); // eslint-disable-line + console.error("Error in fetching activities:", err); // eslint-disable-line } finally { setLoading(false); } diff --git a/apps/web/components/admin/settings/index.tsx b/apps/web/components/admin/settings/index.tsx index d899bf5a2..db80880c6 100644 --- a/apps/web/components/admin/settings/index.tsx +++ b/apps/web/components/admin/settings/index.tsx @@ -85,6 +85,7 @@ import { Copy, Info } from "lucide-react"; import { Label } from "@components/ui/label"; import { Input } from "@components/ui/input"; import Resources from "@components/resources"; +import { PAYMENT_METHOD_MERCADOPAGO } from "@courselit/common-models/dist/ui-constants"; const { PAYMENT_METHOD_PAYPAL, @@ -163,6 +164,7 @@ const Settings = (props: SettingsProps) => { settings.lemonsqueezySubscriptionMonthlyVariantId, lemonsqueezySubscriptionYearlyVariantId: settings.lemonsqueezySubscriptionYearlyVariantId, + mercadopagoAccessToken: settings.mercadopagoAccessToken, }), ); }, [settings]); @@ -195,7 +197,8 @@ const Settings = (props: SettingsProps) => { codeInjectionHead, codeInjectionBody, mailingAddress, - hideCourseLitBranding + hideCourseLitBranding, + mercadopagoAccessToken } }, apikeys: getApikeys { @@ -247,6 +250,7 @@ const Settings = (props: SettingsProps) => { settingsResponse.lemonsqueezySubscriptionMonthlyVariantId || "", lemonsqueezySubscriptionYearlyVariantId: settingsResponse.lemonsqueezySubscriptionYearlyVariantId || "", + mercadopagoAccessToken: settingsResponse.mercadopagoAccessToken, }; setSettings( Object.assign({}, settings, settingsResponseWithNullsRemoved), @@ -291,7 +295,8 @@ const Settings = (props: SettingsProps) => { codeInjectionHead, codeInjectionBody, mailingAddress, - hideCourseLitBranding + hideCourseLitBranding, + mercadopagoAccessToken } } }`; @@ -358,7 +363,8 @@ const Settings = (props: SettingsProps) => { codeInjectionHead, codeInjectionBody, mailingAddress, - hideCourseLitBranding + hideCourseLitBranding, + mercadopagoAccessToken } } }`; @@ -431,7 +437,8 @@ const Settings = (props: SettingsProps) => { codeInjectionHead, codeInjectionBody, mailingAddress, - hideCourseLitBranding + hideCourseLitBranding, + mercadopagoAccessToken } } }`; @@ -496,7 +503,8 @@ const Settings = (props: SettingsProps) => { codeInjectionHead, codeInjectionBody, mailingAddress, - hideCourseLitBranding + hideCourseLitBranding, + mercadopagoAccessToken } } }`; @@ -554,7 +562,8 @@ const Settings = (props: SettingsProps) => { $lemonsqueezyWebhookSecret: String $lemonsqueezyOneTimeVariantId: String, $lemonsqueezySubscriptionMonthlyVariantId: String, - $lemonsqueezySubscriptionYearlyVariantId: String + $lemonsqueezySubscriptionYearlyVariantId: String, + $mercadopagoAccessToken: String ) { settings: updatePaymentInfo(siteData: { currencyISOCode: $currencyISOCode, @@ -569,7 +578,8 @@ const Settings = (props: SettingsProps) => { lemonsqueezyWebhookSecret: $lemonsqueezyWebhookSecret, lemonsqueezyOneTimeVariantId: $lemonsqueezyOneTimeVariantId, lemonsqueezySubscriptionMonthlyVariantId: $lemonsqueezySubscriptionMonthlyVariantId, - lemonsqueezySubscriptionYearlyVariantId: $lemonsqueezySubscriptionYearlyVariantId + lemonsqueezySubscriptionYearlyVariantId: $lemonsqueezySubscriptionYearlyVariantId, + mercadopagoAccessToken: $mercadopagoAccessToken }) { settings { title, @@ -595,11 +605,18 @@ const Settings = (props: SettingsProps) => { codeInjectionHead, codeInjectionBody, mailingAddress, - hideCourseLitBranding + hideCourseLitBranding, + mercadopagoAccessToken } } }`; + console.log("Mutation para guardar configuración:", query); + console.log( + "Token de Mercado Pago:", + newSettings.mercadopagoAccessToken, + ); + try { const fetchRequest = fetch .setPayload({ @@ -623,6 +640,8 @@ const Settings = (props: SettingsProps) => { newSettings.lemonsqueezySubscriptionMonthlyVariantId, lemonsqueezySubscriptionYearlyVariantId: newSettings.lemonsqueezySubscriptionYearlyVariantId, + mercadopagoAccessToken: + newSettings.mercadopagoAccessToken, }, }) .build(); @@ -690,6 +709,9 @@ const Settings = (props: SettingsProps) => { lemonsqueezySubscriptionYearlyVariantId: getNewSettings ? newSettings.lemonsqueezySubscriptionYearlyVariantId : settings.lemonsqueezySubscriptionYearlyVariantId, + mercadopagoAccessToken: getNewSettings + ? newSettings.mercadopagoAccessToken + : settings.mercadopagoAccessToken, }); const removeApikey = async (keyId: string) => { @@ -920,6 +942,12 @@ const Settings = (props: SettingsProps) => { !x.lemonsqueezy, ), }, + { + label: capitalize( + PAYMENT_METHOD_MERCADOPAGO.toLowerCase(), + ), + value: PAYMENT_METHOD_MERCADOPAGO, + }, ]} onChange={(value) => setNewSettings( @@ -1042,6 +1070,22 @@ const Settings = (props: SettingsProps) => { /> )} + {newSettings.paymentMethod === + PAYMENT_METHOD_MERCADOPAGO && ( + <> + + + )} {newSettings.paymentMethod === PAYMENT_METHOD_PAYPAL && ( import("./stripe")); const Razorpay = dynamic(() => import("./razorpay")); const Free = dynamic(() => import("./free")); const Lemonsqueezy = dynamic(() => import("./lemonsqueezy")); +const MercadoPago = dynamic(() => import("./mercadopago")); interface CheckoutExternalProps { course: Course; @@ -39,6 +40,9 @@ const CheckoutExternal = (props: CheckoutExternalProps) => { {paymentMethod === UIConstants.PAYMENT_METHOD_PAYPAL && ( <> )} + {paymentMethod === UIConstants.PAYMENT_METHOD_MERCADOPAGO && ( + + )} )} diff --git a/apps/web/components/public/checkout/mercadopago.tsx b/apps/web/components/public/checkout/mercadopago.tsx new file mode 100644 index 000000000..cbc19ae44 --- /dev/null +++ b/apps/web/components/public/checkout/mercadopago.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { Button, useToast } from "@courselit/components-library"; +import { + ENROLL_BUTTON_TEXT, + TOAST_TITLE_ERROR, +} from "../../../ui-config/strings"; +import { connect } from "react-redux"; +import { useRouter } from "next/router"; +import type { AppState, AppDispatch } from "@courselit/state-management"; +import { Address, Course, SiteInfo } from "@courselit/common-models"; +import { FetchBuilder } from "@courselit/utils"; +import { actionCreators } from "@courselit/state-management"; + +const { networkAction } = actionCreators; + +interface MercadoPagoProps { + course: Course; + siteInfo: SiteInfo; + address: Address; + dispatch: AppDispatch; +} + +const MercadoPago = (props: MercadoPagoProps) => { + const { course, siteInfo, address, dispatch } = props; + const router = useRouter(); + const { toast } = useToast(); + + const handleClick = async () => { + const payload = { + courseid: course.courseId, + metadata: JSON.stringify({ + cancelUrl: `${address.frontend}${router.asPath}`, + successUrl: `${address.frontend}/checkout/${course.courseId}`, + sourceUrl: `/course/${course.slug}/${course.courseId}`, + }), + }; + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/payment/initiate`) + .setHeaders({ + "Content-Type": "application/json", + }) + .setPayload(JSON.stringify(payload)) + .build(); + + try { + dispatch(networkAction(true)); + const response = await fetch.exec({ + redirectToOnUnAuth: router.asPath, + }); + dispatch(networkAction(false)); + + if (response.status === "initiated") { + // If the API returns a payment URL, use it for redirection + if (response.paymentUrl) { + window.location.href = response.paymentUrl; + } else { + // Fallback to the old behavior + router.push(`/checkout/${course.courseId}?id=${response.paymentTracker}`); + } + } else if (response.status === "success") { + router.replace(`/course/${course.slug}/${course.courseId}`); + } + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } finally { + dispatch(networkAction(false)); + } + }; + + return ; +}; + +const mapStateToProps = (state: AppState) => ({ + siteInfo: state.siteinfo, + address: state.address, +}); + +const mapDispatchToProps = (dispatch: AppDispatch) => ({ dispatch }); + +export default connect(mapStateToProps, mapDispatchToProps)(MercadoPago); diff --git a/apps/web/graphql/settings/helpers.ts b/apps/web/graphql/settings/helpers.ts index d3ff38b22..cf971cf64 100644 --- a/apps/web/graphql/settings/helpers.ts +++ b/apps/web/graphql/settings/helpers.ts @@ -96,6 +96,13 @@ export const checkForInvalidPaymentMethodSettings = ( failedPaymentMethod = UIConstants.PAYMENT_METHOD_LEMONSQUEEZY; } + if ( + siteInfo.paymentMethod === UIConstants.PAYMENT_METHOD_MERCADOPAGO && + !siteInfo.mercadopagoAccessToken + ) { + failedPaymentMethod = UIConstants.PAYMENT_METHOD_MERCADOPAGO; + } + return failedPaymentMethod; }; diff --git a/apps/web/graphql/settings/types.ts b/apps/web/graphql/settings/types.ts index 1b32c2500..47d416b40 100644 --- a/apps/web/graphql/settings/types.ts +++ b/apps/web/graphql/settings/types.ts @@ -60,6 +60,7 @@ const siteType = new GraphQLObjectType({ codeInjectionBody: { type: GraphQLString }, mailingAddress: { type: GraphQLString }, hideCourseLitBranding: { type: GraphQLBoolean }, + mercadopagoAccessToken: { type: GraphQLString }, }, }); @@ -95,6 +96,7 @@ const sitePaymentUpdateType = new GraphQLInputObjectType({ lemonsqueezyOneTimeVariantId: { type: GraphQLString }, lemonsqueezySubscriptionMonthlyVariantId: { type: GraphQLString }, lemonsqueezySubscriptionYearlyVariantId: { type: GraphQLString }, + mercadopagoAccessToken: { type: GraphQLString }, }, }); diff --git a/apps/web/models/SiteInfo.ts b/apps/web/models/SiteInfo.ts index d716b3ad6..514da5c0e 100644 --- a/apps/web/models/SiteInfo.ts +++ b/apps/web/models/SiteInfo.ts @@ -25,6 +25,7 @@ const SettingsSchema = new mongoose.Schema({ lemonsqueezyOneTimeVariantId: { type: String }, lemonsqueezySubscriptionMonthlyVariantId: { type: String }, lemonsqueezySubscriptionYearlyVariantId: { type: String }, + mercadopagoAccessToken: { type: String }, }); export default SettingsSchema; diff --git a/apps/web/pages/api/graph.ts b/apps/web/pages/api/graph.ts index 54a2bdb62..cd3a4415f 100644 --- a/apps/web/pages/api/graph.ts +++ b/apps/web/pages/api/graph.ts @@ -1,4 +1,4 @@ -import type { NextApiRequest } from "next"; +import type { NextApiRequest, NextApiResponse } from "next"; import schema from "../../graphql"; import { graphql } from "graphql"; import { getAddress } from "../../lib/utils"; @@ -68,12 +68,12 @@ export default async function handler( subdomain: domain, address: getAddress(hostname, protocol), }; - const response = await graphql({ - schema, - source: query || req.body.query, - rootValue: null, - contextValue, - variableValues: variables, - }); - return res.status(200).json(response); + const response = await graphql({ + schema, + source: query || req.body.query, + rootValue: null, + contextValue, + variableValues: variables, + }); + return res.status(200).json(response); } diff --git a/apps/web/pages/api/payment/initiate.ts b/apps/web/pages/api/payment/initiate.ts index 99ea26eff..0916590b3 100644 --- a/apps/web/pages/api/payment/initiate.ts +++ b/apps/web/pages/api/payment/initiate.ts @@ -93,19 +93,61 @@ export default async function handler( currencyISOCode: siteinfo.currencyISOCode, }); - const paymentTracker = await paymentMethod.initiate({ - course, - metadata: JSON.parse(metadata), - purchaseId: purchase.orderId, - }); + // Prepare the metadata with the required information + const metadataObj = JSON.parse(metadata); + const invoiceId = purchase.orderId; + + console.log("Payment method:", paymentMethod.getName()); + + let paymentTracker; + + if (paymentMethod.getName() === 'mercadopago') { + // For Mercado Pago, we need to pass the parameters in the format expected by MercadoPagoPayment + paymentTracker = await paymentMethod.initiate({ + metadata: { + invoiceId, + ...metadataObj + }, + paymentPlan: { + type: "onetime", + oneTimeAmount: course.cost + }, + // Use type assertion for the entire product object to handle type compatibility + product: { + id: course.courseId, + title: course.title, + type: (course.type || "course") as any, + cost: course.cost + } as any, + origin: metadataObj.cancelUrl?.split('/checkout')[0] || '' + }); + } else { + // For other payment methods, use the existing format + paymentTracker = await paymentMethod.initiate({ + course, + metadata: metadataObj, + purchaseId: invoiceId, + }); + } - purchase.paymentId = paymentTracker; + purchase.paymentId = typeof paymentTracker === 'object' && paymentTracker.id + ? paymentTracker.id + : paymentTracker; await purchase.save(); - res.status(200).json({ - status: transactionInitiated, - paymentTracker, - }); + // If Mercado Pago returns a URL, use it for redirection + if (typeof paymentTracker === 'object' && paymentTracker.url) { + res.status(200).json({ + status: transactionInitiated, + paymentTracker: purchase.paymentId, + paymentUrl: paymentTracker.url + }); + } else { + res.status(200).json({ + status: transactionInitiated, + paymentTracker: purchase.paymentId + }); + } } catch (err: any) { error(err.message, { stack: err.stack }); res.status(500).json({ diff --git a/apps/web/pages/api/payment/verify.ts b/apps/web/pages/api/payment/verify.ts index 3e57138b5..8ca69ca85 100644 --- a/apps/web/pages/api/payment/verify.ts +++ b/apps/web/pages/api/payment/verify.ts @@ -41,22 +41,15 @@ export default async function handler( return res.status(400).json({ message: responses.invalid_input }); } - try { - const purchaseRecord: Purchase | null = await PurchaseModel.findOne({ - orderId: purchaseId, - }); + const purchaseRecord: Purchase | null = await PurchaseModel.findOne({ + orderId: purchaseId, + }); - if (!purchaseRecord || user!.userId !== purchaseRecord.purchasedBy) { + if (!purchaseRecord || user!.userId !== purchaseRecord.purchasedBy) { return res.status(404).json({ message: responses.item_not_found }); - } - - res.status(200).json({ - status: purchaseRecord.status, - }); - } catch (err: any) { - error(err.message, { stack: err.stack }); - res.status(500).json({ - message: err.message, - }); } + + res.status(200).json({ + status: purchaseRecord.status, + }); } diff --git a/apps/web/pages/api/payment/webhook-old.ts b/apps/web/pages/api/payment/webhook-old.ts index dc03c245a..8e927624d 100644 --- a/apps/web/pages/api/payment/webhook-old.ts +++ b/apps/web/pages/api/payment/webhook-old.ts @@ -5,7 +5,8 @@ import PurchaseModel, { Purchase } from "@/models/Purchase"; import { getPaymentMethod } from "@/payments"; const { transactionSuccess } = constants; import DomainModel, { Domain } from "@models/Domain"; -import { info } from "@/services/logger"; +import { info, error } from "@/services/logger"; +import ActivityModel from "@/models/Activity"; export default async function handler( req: NextApiRequest, @@ -16,24 +17,28 @@ export default async function handler( } info(`POST /api/payment/webhook: domain detected: ${req.headers.domain}`); - const domain = await DomainModel.findOne({ name: req.headers.domain, }); if (!domain) { + error(`Domain not found: ${req.headers.domain}`); return res.status(404).json({ message: "Domain not found" }); } const { body } = req; const paymentMethod = await getPaymentMethod(domain._id.toString()); - const paymentVerified = paymentMethod.verify(body); + + const paymentVerified = await paymentMethod.verify(body); + if (paymentVerified) { - const purchaseId = paymentMethod.getPaymentIdentifier(body); + const purchaseId = await paymentMethod.getPaymentIdentifier(body); + const purchaseRecord: Purchase | null = await PurchaseModel.findOne({ orderId: purchaseId, }); - + if (!purchaseRecord) { + error(`Purchase record not found for ID: ${purchaseId}`); return res.status(200).json({ message: "fail", }); @@ -42,17 +47,34 @@ export default async function handler( purchaseRecord.status = transactionSuccess; purchaseRecord.webhookPayload = body; await (purchaseRecord as any).save(); + info(`Purchase record updated with success status for ID: ${purchaseId}`); + + // Check for existing activities before finalizing purchase + const existingActivities = await ActivityModel.find({ + domain: domain._id, + entityId: purchaseRecord.courseId + }); + info(`Existing activities before finalize: ${existingActivities.length}`); await finalizePurchase( purchaseRecord.purchasedBy, purchaseRecord.courseId, purchaseRecord.orderId, ); + console.log("Purchase finalized"); + info(`Purchase finalized for user: ${purchaseRecord.purchasedBy}, course: ${purchaseRecord.courseId}`); + + // Check for activities after finalizing purchase + const newActivities = await ActivityModel.find({ + domain: domain._id, + entityId: purchaseRecord.courseId + }); res.status(200).json({ message: "success", }); } else { + error("Payment verification failed"); res.status(200).json({ message: "fail", }); diff --git a/apps/web/payments-new/index.ts b/apps/web/payments-new/index.ts index 576ad92a5..10af763d3 100644 --- a/apps/web/payments-new/index.ts +++ b/apps/web/payments-new/index.ts @@ -4,6 +4,7 @@ import DomainModel, { Domain } from "../models/Domain"; import StripePayment from "./stripe-payment"; import RazorpayPayment from "./razorpay-payment"; import LemonSqueezyPayment from "./lemonsqueezy-payment"; +import MercadoPagoPayment from "./mercadopago-payment"; const { error_unrecognised_payment_method: unrecognisedPaymentMethod, @@ -36,6 +37,8 @@ export const getPaymentMethodFromSettings = async ( return await new RazorpayPayment(siteInfo).setup(); case UIConstants.PAYMENT_METHOD_LEMONSQUEEZY: return await new LemonSqueezyPayment(siteInfo).setup(); + case UIConstants.PAYMENT_METHOD_MERCADOPAGO: + return await new MercadoPagoPayment(siteInfo).setup(); case UIConstants.PAYMENT_METHOD_PAYTM: throw new Error(notYetSupported); default: diff --git a/apps/web/payments-new/mercadopago-payment.ts b/apps/web/payments-new/mercadopago-payment.ts new file mode 100644 index 000000000..1190e50f1 --- /dev/null +++ b/apps/web/payments-new/mercadopago-payment.ts @@ -0,0 +1,163 @@ +import { Constants, SiteInfo, UIConstants } from "@courselit/common-models"; +import Payment, { InitiateProps } from "./payment"; +import { responses } from "../config/strings"; +import { getUnitAmount } from "./helpers"; +import { MercadoPagoConfig, Payment as MPPayment, Preference } from 'mercadopago'; + +const { + payment_invalid_settings: paymentInvalidSettings, + currency_iso_not_set: currencyISONotSet, +} = responses; + +export default class MercadoPagoPayment implements Payment { + public siteinfo: SiteInfo; + public name: string; + private client: MercadoPagoConfig; + private mpPayment: MPPayment; + private preference: Preference; + + constructor(siteinfo: SiteInfo) { + this.siteinfo = siteinfo; + this.name = UIConstants.PAYMENT_METHOD_MERCADOPAGO; + } + + async setup() { + if (!this.siteinfo.currencyISOCode) { + console.error("Error: Currency ISO code not set"); + throw new Error(currencyISONotSet); + } + + console.log("Mercado Pago setup - SiteInfo:", JSON.stringify({ + currencyISOCode: this.siteinfo.currencyISOCode, + paymentMethod: this.siteinfo.paymentMethod, + hasAccessToken: !!this.siteinfo.mercadopagoAccessToken + })); + + if (!this.siteinfo.mercadopagoAccessToken) { + console.error("Error: Mercado Pago access token not set"); + throw new Error(`${this.name} ${paymentInvalidSettings}`); + } + + try { + this.client = new MercadoPagoConfig({ + accessToken: this.siteinfo.mercadopagoAccessToken, + options: { timeout: 5000 } + }); + + this.mpPayment = new MPPayment(this.client); + this.preference = new Preference(this.client); + + console.log("Mercado Pago setup successful"); + return this; + } catch (error) { + console.error("Error setting up Mercado Pago:", error); + throw new Error(`${this.name} ${paymentInvalidSettings}: ${(error as Error).message}`); + } + } + + async initiate({ metadata, paymentPlan, product, origin }: InitiateProps) { + const unit_amount = getUnitAmount(paymentPlan); + + try { + console.log("Mercado Pago initiate - paymentPlan:", JSON.stringify(paymentPlan || {}, null, 2)); + console.log("Mercado Pago initiate - product:", JSON.stringify(product || {}, null, 2)); + + const preferenceData = { + items: [ + { + id: product.id, + title: product.title, + quantity: 1, + unit_price: unit_amount, + } + ], + back_urls: { + success: `${origin}/checkout/verify?id=${metadata.invoiceId}`, + failure: `${origin}/checkout?type=${product.type}&id=${product.id}`, + pending: `${origin}/checkout?type=${product.type}&id=${product.id}` + }, + auto_return: "approved", + external_reference: JSON.stringify(metadata), + ...(paymentPlan && paymentPlan.type === Constants.PaymentPlanType.SUBSCRIPTION && { + payment_methods: { + installments: 1, + recurring: true + } + }), + ...(paymentPlan && paymentPlan.type === Constants.PaymentPlanType.EMI && { + payment_methods: { + installments: paymentPlan.emiTotalInstallments || 1 + } + }), + metadata + }; + + const response = await this.preference.create({ body: preferenceData }); + + // Return an object with both the ID and the URL + return { + id: response.id, + url: response.init_point + }; + } catch (error) { + console.error("Error creating Mercado Pago preference:", error); + throw new Error(`Error creating payment: ${(error as Error).message}`); + } + } + + async verify(event: any) { + if (!event || !event.data || !event.data.id) { + return false; + } + + try { + const paymentId = event.data.id; + const response = await this.mpPayment.get({ id: paymentId }); + return response.status === 'approved'; + } catch (error) { + console.error("Error verifying Mercado Pago payment:", error); + return false; + } + } + + async getPaymentIdentifier(event: any) { + const metadata = await this.getMetadata(event); + console.log(metadata) + return metadata.invoice_id; + } + + async getMetadata(event: any) { + try { + return (await this.mpPayment.get({ id: event.data.id })).metadata as unknown as Record; + } catch (e) { + console.error("Error parsing Mercado Pago metadata:", e); + return {}; + } + } + + getName() { + return this.name; + } + + async cancel(id: string) { + if (!id) return; + + try { + await this.mpPayment.cancel({ id }); + } catch (e) { + console.error("Error canceling Mercado Pago payment:", e); + } + } + + getSubscriptionId(event: any): string { + return ''; + } + + async validateSubscription(subscriptionId: string): Promise { + return false; + } + + async getCurrencyISOCode() { + return this.siteinfo.currencyISOCode!; + } +} diff --git a/apps/web/payments-new/payment.ts b/apps/web/payments-new/payment.ts index ba9d60e3d..e260a180d 100644 --- a/apps/web/payments-new/payment.ts +++ b/apps/web/payments-new/payment.ts @@ -21,7 +21,7 @@ export default interface Payment { initiate: (obj: InitiateProps) => void; verify: (event: any) => Promise; getPaymentIdentifier: (event: any) => unknown; - getMetadata: (event: any) => Record; + getMetadata: (event: any) => Promise>; getName: () => string; cancel: (id: string) => void; getSubscriptionId: (event: any) => string; diff --git a/apps/web/payments/index.ts b/apps/web/payments/index.ts index 5e782e0e3..6203a6ad8 100644 --- a/apps/web/payments/index.ts +++ b/apps/web/payments/index.ts @@ -4,6 +4,7 @@ import DomainModel, { Domain } from "../models/Domain"; import StripePayment from "./stripe-payment"; import RazorpayPayment from "./razorpay-payment"; import LemonSqueezyPayment from "./lemonsqueezy-payment"; +import MercadoPagoPayment from "../payments-new/mercadopago-payment"; const { error_unrecognised_payment_method: unrecognisedPaymentMethod, @@ -17,6 +18,7 @@ export const getPaymentMethod = async (domainName: string) => { }); const siteInfo: SiteInfo | null = domain && domain.settings; + return await getPaymentMethodFromSettings(siteInfo); }; @@ -38,6 +40,8 @@ export const getPaymentMethodFromSettings = async ( return await new LemonSqueezyPayment(siteInfo).setup(); case UIConstants.PAYMENT_METHOD_PAYTM: throw new Error(notYetSupported); + case UIConstants.PAYMENT_METHOD_MERCADOPAGO: + return await new MercadoPagoPayment(siteInfo).setup(); default: throw new Error(unrecognisedPaymentMethod); } diff --git a/package.json b/package.json index 90f6ba3f6..2921703c4 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "dev": "yarn workspace @courselit/web dev", "prepare": "husky", "release:patch": "./release.sh patch", - "release:minor": "./release.sh minor" + "release:minor": "./release.sh minor", + "start": "yarn workspace @courselit/web start" }, "packageManager": "yarn@3.2.0", "devDependencies": { @@ -49,5 +50,9 @@ "resolutions": { "prosemirror-model": "^1.22.3", "prosemirror-view": "^1.34.2" + }, + "dependencies": { + "mercadopago": "^2.3.0", + "react-icons": "^5.5.0" } } diff --git a/packages/common-models/src/constants.ts b/packages/common-models/src/constants.ts index e5e05d4c7..02b0dfdde 100644 --- a/packages/common-models/src/constants.ts +++ b/packages/common-models/src/constants.ts @@ -1,5 +1,6 @@ import { PAYMENT_METHOD_LEMONSQUEEZY, + PAYMENT_METHOD_MERCADOPAGO, PAYMENT_METHOD_NONE, PAYMENT_METHOD_PAYPAL, PAYMENT_METHOD_PAYTM, @@ -56,6 +57,7 @@ export const paymentMethods = [ PAYMENT_METHOD_RAZORPAY, PAYMENT_METHOD_STRIPE, PAYMENT_METHOD_LEMONSQUEEZY, + PAYMENT_METHOD_MERCADOPAGO, ] as const; export const PageType = { PRODUCT: "product", diff --git a/packages/common-models/src/site-info.ts b/packages/common-models/src/site-info.ts index a465030fd..3b9da2ad3 100644 --- a/packages/common-models/src/site-info.ts +++ b/packages/common-models/src/site-info.ts @@ -25,4 +25,7 @@ export default interface SiteInfo { lemonsqueezySubscriptionMonthlyVariantId?: string; lemonsqueezySubscriptionYearlyVariantId?: string; lemonsqueezyWebhookSecret?: string; + mercadopagoKey?: string; + mercadopagoSecret?: string; + mercadopagoAccessToken?: string; } diff --git a/packages/common-models/src/ui-constants.ts b/packages/common-models/src/ui-constants.ts index 85c1bce77..b914263f3 100644 --- a/packages/common-models/src/ui-constants.ts +++ b/packages/common-models/src/ui-constants.ts @@ -23,6 +23,7 @@ export const PAYMENT_METHOD_LEMONSQUEEZY = "lemonsqueezy"; export const PAYMENT_METHOD_PAYPAL = "paypal"; export const PAYMENT_METHOD_PAYTM = "paytm"; export const PAYMENT_METHOD_RAZORPAY = "razorpay"; +export const PAYMENT_METHOD_MERCADOPAGO = "mercadopago"; export const PAYMENT_METHOD_NONE = ""; // transaction statuses from backend diff --git a/yarn.lock b/yarn.lock index 6209d23da..920aed851 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9938,7 +9938,9 @@ __metadata: eslint-plugin-unused-imports: ^2.0.0 husky: ^9.0.11 lint-staged: ^15.2.2 + mercadopago: ^2.3.0 prettier: ^3.0.2 + react-icons: ^5.5.0 languageName: unknown linkType: soft @@ -14324,6 +14326,16 @@ __metadata: languageName: node linkType: hard +"mercadopago@npm:^2.3.0": + version: 2.3.0 + resolution: "mercadopago@npm:2.3.0" + dependencies: + node-fetch: ^2.6.12 + uuid: ^9.0.0 + checksum: 5c35f36ed576b396bc05716a80231dc819e43e4f0dfbd49ac67ad62e810e2e43aea89397f0f23a40a7d5ba70b29c2664fe5a6bdc7ee981b2b542bc3a9a83fb35 + languageName: node + linkType: hard + "merge-descriptors@npm:1.0.3": version: 1.0.3 resolution: "merge-descriptors@npm:1.0.3" @@ -15424,6 +15436,20 @@ __metadata: languageName: node linkType: hard +"node-fetch@npm:^2.6.12": + version: 2.7.0 + resolution: "node-fetch@npm:2.7.0" + dependencies: + whatwg-url: ^5.0.0 + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + checksum: d76d2f5edb451a3f05b15115ec89fc6be39de37c6089f1b6368df03b91e1633fd379a7e01b7ab05089a25034b2023d959b47e59759cb38d88341b2459e89d6e5 + languageName: node + linkType: hard + "node-fetch@npm:^3.2.5": version: 3.3.2 resolution: "node-fetch@npm:3.3.2" @@ -16874,6 +16900,15 @@ __metadata: languageName: node linkType: hard +"react-icons@npm:^5.5.0": + version: 5.5.0 + resolution: "react-icons@npm:5.5.0" + peerDependencies: + react: "*" + checksum: cbd74f4b7982e6e18d59798a6b578268c8eb0909d78d87bcf9b25f99b3e544fd189a76551cb5e770d17f154a60b668551aee108aaf8471309b23f7af3b2c5b07 + languageName: node + linkType: hard + "react-is@npm:^16.13.1, react-is@npm:^16.7.0": version: 16.13.1 resolution: "react-is@npm:16.13.1" @@ -18864,6 +18899,13 @@ __metadata: languageName: node linkType: hard +"tr46@npm:~0.0.3": + version: 0.0.3 + resolution: "tr46@npm:0.0.3" + checksum: 726321c5eaf41b5002e17ffbd1fb7245999a073e8979085dacd47c4b4e8068ff5777142fc6726d6ca1fd2ff16921b48788b87225cbc57c72636f6efa8efbffe3 + languageName: node + linkType: hard + "tree-kill@npm:^1.2.2": version: 1.2.2 resolution: "tree-kill@npm:1.2.2" @@ -19853,6 +19895,13 @@ __metadata: languageName: node linkType: hard +"webidl-conversions@npm:^3.0.0": + version: 3.0.1 + resolution: "webidl-conversions@npm:3.0.1" + checksum: c92a0a6ab95314bde9c32e1d0a6dfac83b578f8fa5f21e675bc2706ed6981bc26b7eb7e6a1fab158e5ce4adf9caa4a0aee49a52505d4d13c7be545f15021b17c + languageName: node + linkType: hard + "webidl-conversions@npm:^4.0.2": version: 4.0.2 resolution: "webidl-conversions@npm:4.0.2" @@ -19887,6 +19936,16 @@ __metadata: languageName: node linkType: hard +"whatwg-url@npm:^5.0.0": + version: 5.0.0 + resolution: "whatwg-url@npm:5.0.0" + dependencies: + tr46: ~0.0.3 + webidl-conversions: ^3.0.0 + checksum: b8daed4ad3356cc4899048a15b2c143a9aed0dfae1f611ebd55073310c7b910f522ad75d727346ad64203d7e6c79ef25eafd465f4d12775ca44b90fa82ed9e2c + languageName: node + linkType: hard + "whatwg-url@npm:^7.0.0": version: 7.1.0 resolution: "whatwg-url@npm:7.1.0"