├── .gitignore ├── .env.local ├── prisma ├── migrations │ ├── migration_lock.toml │ └── 20240102231142_create_token_table │ │ └── migration.sql └── schema.prisma ├── infra ├── database.ts ├── logger.ts ├── mp.ts └── errors.ts ├── routes └── v1 │ ├── index.ts │ ├── connect │ └── index.ts │ └── webhooks │ └── index.ts ├── package.json ├── main.ts ├── LICENSE ├── README.md └── cron.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | .env 4 | dev.db -------------------------------------------------------------------------------- /.env.local: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | MP_APP_ID= 3 | MP_CLIENT_SECRET= 4 | MP_REDIRECT_URI= 5 | MP_ACCESS_TOKEN= 6 | DATABASE_URL=file:./dev.db -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /infra/database.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | const client = new PrismaClient(); 4 | 5 | export default Object.freeze({ 6 | instace: client, 7 | }); -------------------------------------------------------------------------------- /infra/logger.ts: -------------------------------------------------------------------------------- 1 | const { 2 | log, 3 | warn, 4 | info, 5 | error 6 | } = console; 7 | 8 | export default Object.freeze({ 9 | log, 10 | warn, 11 | info, 12 | error, 13 | }); -------------------------------------------------------------------------------- /infra/mp.ts: -------------------------------------------------------------------------------- 1 | import { MercadoPagoConfig, OAuth } from "mercadopago"; 2 | 3 | const client = new MercadoPagoConfig({ accessToken: process.env.MP_ACCESS_TOKEN || ""}); 4 | const oAuth = new OAuth(client); 5 | 6 | export default Object.freeze({ 7 | oAuth, 8 | }); -------------------------------------------------------------------------------- /routes/v1/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | 3 | import ConnectRouter from "./connect"; 4 | import WebhookRouter from "./webhooks"; 5 | 6 | const CurrentRouter: Router = Router(); 7 | 8 | CurrentRouter.use("/connect", ConnectRouter); 9 | CurrentRouter.use("/webhook", WebhookRouter); 10 | 11 | export default CurrentRouter; -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "sqlite" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | model Tokens { 11 | id String @unique @id @default(cuid()) 12 | client String @unique 13 | token String @unique 14 | refresh_token String @unique 15 | public_key String 16 | mp_user_id String 17 | 18 | created_at DateTime @default(now()) 19 | updated_at DateTime @updatedAt 20 | 21 | @@map("tokens") 22 | } -------------------------------------------------------------------------------- /routes/v1/connect/index.ts: -------------------------------------------------------------------------------- 1 | import Express, { Router } from "express"; 2 | 3 | import Errors from "../../../infra/errors"; 4 | import MP from "../../../infra/mp"; 5 | 6 | const CurrentRouter: Router = Router(); 7 | 8 | CurrentRouter.get( 9 | "/:userId", 10 | (request: Express.Request, response: Express.Response) => { 11 | try { 12 | return response.status(200).json({ 13 | url: MP.oAuth.getAuthorizationURL({ options: {client_id: process.env.MP_APP_ID, redirect_uri: process.env.MP_REDIRECT_URI, state: request.params.userId as string}}) 14 | }); 15 | } catch (error) { 16 | return Errors.InternalServerError(); 17 | } 18 | } 19 | ); 20 | 21 | export default CurrentRouter; 22 | -------------------------------------------------------------------------------- /prisma/migrations/20240102231142_create_token_table/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "tokens" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "client" TEXT NOT NULL, 5 | "token" TEXT NOT NULL, 6 | "refresh_token" TEXT NOT NULL, 7 | "public_key" TEXT NOT NULL, 8 | "mp_user_id" TEXT NOT NULL, 9 | "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 10 | "updated_at" DATETIME NOT NULL 11 | ); 12 | 13 | -- CreateIndex 14 | CREATE UNIQUE INDEX "tokens_id_key" ON "tokens"("id"); 15 | 16 | -- CreateIndex 17 | CREATE UNIQUE INDEX "tokens_client_key" ON "tokens"("client"); 18 | 19 | -- CreateIndex 20 | CREATE UNIQUE INDEX "tokens_token_key" ON "tokens"("token"); 21 | 22 | -- CreateIndex 23 | CREATE UNIQUE INDEX "tokens_refresh_token_key" ON "tokens"("refresh_token"); 24 | -------------------------------------------------------------------------------- /infra/errors.ts: -------------------------------------------------------------------------------- 1 | function InternalServerError(message?: string): Error { 2 | throw new Error(JSON.stringify({ 3 | status_code: 500, 4 | hint: "internal_server_error", 5 | message: message || "Something happened on our servers. Please, report this issue.", 6 | })); 7 | }; 8 | 9 | function UnauthorizedError(message?: string): Error { 10 | throw new Error(JSON.stringify({ 11 | status_code: 401, 12 | hint: "unauthorized_error", 13 | message: message || "You dont have the permission to access this route.", 14 | })); 15 | }; 16 | 17 | function ForbiddenError(message?: string): Error { 18 | throw new Error(JSON.stringify({ 19 | status_code: 403, 20 | hint: "forbidden_error", 21 | message: message || "You not allowed to access this route since is protected.", 22 | })); 23 | }; 24 | 25 | export default Object.freeze({ 26 | InternalServerError, 27 | UnauthorizedError, 28 | ForbiddenError, 29 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start:app": "ts-node main.ts", 4 | "start:cron": "ts-node-dev cron.ts", 5 | "dev:app": "ts-node-dev main.ts", 6 | "dev:cron": "ts-node-dev cron.ts", 7 | "start": "concurrently \"npm run start:cron\" \"npm run start:app\"", 8 | "dev": "concurrently \"npm run dev:cron\" \"npm run dev:app\"", 9 | "commit": "cz" 10 | }, 11 | "dependencies": { 12 | "@prisma/client": "^5.7.1", 13 | "cors": "^2.8.5", 14 | "express": "^4.18.2", 15 | "mercadopago": "^2.0.6", 16 | "node-cron": "^3.0.3", 17 | "prisma": "^5.7.1", 18 | "ts-node": "^10.9.2", 19 | "ts-node-dev": "^2.0.0" 20 | }, 21 | "config": { 22 | "commitizen": { 23 | "path": "cz-conventional-changelog" 24 | } 25 | }, 26 | "devDependencies": { 27 | "@types/cors": "^2.8.17", 28 | "@types/express": "^4.17.21", 29 | "@types/node-cron": "^3.0.11", 30 | "commitizen": "^4.3.0", 31 | "concurrently": "^8.2.2", 32 | "dotenv": "^16.4.5" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | if (process.env.NODE_ENV != "production") { 3 | dotenv.config(); 4 | } 5 | 6 | import ExpressLib from "express"; 7 | import cors from "cors"; 8 | 9 | import Logger from "./infra/logger"; 10 | 11 | import V1_Router from "./routes/v1/index"; 12 | 13 | const port = process.env.PORT || 3000; 14 | const ApplicationInstance: ExpressLib.Application = ExpressLib(); 15 | 16 | //setup cors 17 | ApplicationInstance.use(cors({ 18 | origin: "*" 19 | })); 20 | 21 | //Router 22 | ApplicationInstance.use("/v1", V1_Router); 23 | 24 | //error handling 25 | ApplicationInstance.use( 26 | ( 27 | error: Error, 28 | request: ExpressLib.Request, 29 | response: ExpressLib.Response, 30 | next: ExpressLib.NextFunction 31 | ) => { 32 | const errorData = JSON.parse(error.message); 33 | Logger.warn(error.stack); 34 | response.status(errorData.status_code).json(errorData); 35 | } 36 | ); 37 | 38 | //init 39 | ApplicationInstance.listen(port, () => { 40 | Logger.log(`Server listening at http://localhost:${port}`); 41 | }); 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ytalo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /routes/v1/webhooks/index.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | if (process.env.NODE_ENV != "production") { 3 | dotenv.config(); 4 | } 5 | 6 | import Express, { Router } from "express"; 7 | 8 | import Errors from "../../../infra/errors"; 9 | import MP from "../../../infra/mp"; 10 | import DB from "../../../infra/database"; 11 | 12 | const CurrentRouter: Router = Router(); 13 | 14 | CurrentRouter.get( 15 | "/", 16 | async (request: Express.Request, response: Express.Response) => { 17 | try { 18 | const code = request.query.code; 19 | const state = request.query.state; 20 | const data = await DB.instace.tokens.findUnique({ 21 | where: { 22 | client: state as string 23 | } 24 | }) 25 | 26 | if(!code || !state || data) return Errors.ForbiddenError(); 27 | const userResponse = await MP.oAuth.create({ 28 | body: { 29 | client_id: process.env.MP_APP_ID, 30 | client_secret: process.env.MP_CLIENT_SECRET, 31 | code: code as string, 32 | redirect_uri: process.env.MP_REDIRECT_URI, 33 | } 34 | }); 35 | 36 | await DB.instace.tokens.create({ 37 | data: { 38 | client: state as string, 39 | mp_user_id: Number(userResponse.user_id).toString(), 40 | public_key: userResponse.public_key as string, 41 | refresh_token: userResponse.refresh_token as string, 42 | token: userResponse.access_token as string, 43 | } 44 | }); 45 | return response.status(200).end(); 46 | } catch (err) { 47 | console.log(err) 48 | return Errors.InternalServerError(); 49 | } 50 | } 51 | ); 52 | 53 | export default CurrentRouter; 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Projeto Node para Integração OAuth - Mercado Pago Split de Pagamento 2 | 3 | Este projeto Node.js tem como objetivo facilitar a integração do OAuth para split de pagamento do Mercado Pago. Utiliza o Prisma como banco de dado. O sistema possui duas rotas principais: `/v1/connect/userId` para gerar uma URL de autorização e `/v1/webhook` para receber os webhooks do Mercado Pago. Além disso, conta com um cron job para realizar o refresh dos tokens a cada 1º dia do mês. 4 | 5 | ## Requisitos 6 | 7 | - Node.js 8 | - Prisma 9 | - SQLite (Atualmente configurado) 10 | 11 | ## Configuração 12 | 13 | 1. Clone o repositório 14 | 2. Instale as dependências usando `npm install` 15 | 3. Copie o arquivo `.env.example` para `.env` e preencha com suas credenciais do Mercado Pago 16 | 4. Execute as migrações do Prisma com `npx prisma migrate dev` 17 | 18 | ## Uso 19 | 20 | ### Rota `/v1/connect/{userId}` 21 | 22 | Esta rota é responsável por gerar a URL de conexão OAuth para o Mercado Pago. Lembre-se de passar um id unico para seu úsuario. 23 | 24 | Exemplo de uso: 25 | ```bash 26 | curl -X GET http://localhost:3000/v1/connect/12312398721983712 27 | ``` 28 | 29 | ### Rota /v1/webhook 30 | Esta rota é utilizada para receber os webhooks do Mercado Pago. Certifique-se de configurar corretamente o endpoint no painel de integração do Mercado Pago. 31 | 32 | ### Cron Job 33 | O cron job está configurado para fazer o refresh dos tokens a cada 1º dia do mês. Certifique-se de que o serviço está sendo executado corretamente. 34 | 35 | ## Como rodar 36 | ### Separadamente 37 | - npm run start:app 38 | - npm run start:cron 39 | - npm run dev:app 40 | - npm run dev:cron 41 | 42 | ### Junto 43 | Usei a lib **concurrently** para isto. 44 | 45 | - npm run start 46 | - npm run dev 47 | 48 | 49 | ### Contribuições 50 | Sinta-se à vontade para contribuir para o projeto. Faça um fork, implemente suas alterações e envie um pull request. 51 | 52 | ### Licença 53 | Este projeto está sob a licença MIT. -------------------------------------------------------------------------------- /cron.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | if (process.env.NODE_ENV != "production") { 3 | dotenv.config(); 4 | } 5 | 6 | import cron from "node-cron"; 7 | 8 | import Logger from "./infra/logger"; 9 | import DB from "./infra/database"; 10 | import MP from "./infra/mp"; 11 | import { Tokens } from "@prisma/client"; 12 | 13 | async function Run() { 14 | const currentDate = new Date(); 15 | Logger.log(`[${currentDate.toISOString()}] Running the refresh for database tokens that has minimum of 60 days`); 16 | 17 | const toRefresh = await DB.instace.tokens.findMany({ 18 | where: { 19 | updated_at: { 20 | gte: new Date(currentDate.getTime() - 60 * 24 * 60 * 60 * 1000) // 60 days 21 | } 22 | } 23 | }) 24 | 25 | await processTokensInBatches(toRefresh); 26 | } 27 | 28 | function processTokensInBatches(tokens: Tokens[]) { 29 | const batchSize = 10; 30 | 31 | const batches: any = []; 32 | 33 | for (let i = 0; i < tokens.length; i += batchSize) { 34 | batches.push(tokens.slice(i, i + batchSize)); 35 | } 36 | 37 | const processBatch = async (batch: any) => { 38 | const promises = batch.map(async (token: Tokens) => { 39 | await processToken(token); 40 | }); 41 | 42 | await Promise.all(promises); 43 | }; 44 | 45 | const processBatchesSequentially = async () => { 46 | for (const batch of batches) { 47 | await processBatch(batch); 48 | } 49 | }; 50 | 51 | return processBatchesSequentially(); 52 | } 53 | 54 | async function processToken(token: Tokens) { 55 | const MPResponse = await MP.oAuth.refresh({ 56 | body: { 57 | client_id: process.env.MP_APP_ID, 58 | client_secret: process.env.MP_CLIENT_SECRET, 59 | refresh_token: token.refresh_token 60 | } 61 | }); 62 | 63 | await DB.instace.tokens.update({ 64 | where: { 65 | client: token.client 66 | }, 67 | data: { 68 | mp_user_id: Number(MPResponse.user_id).toString(), 69 | public_key: MPResponse.public_key as string, 70 | refresh_token: MPResponse.refresh_token as string, 71 | token: MPResponse.access_token as string, 72 | } 73 | }); 74 | } 75 | 76 | //first day of the month 77 | Logger.log(`Running the refresh system!`); 78 | cron.schedule("0 0 1 * *", Run); --------------------------------------------------------------------------------