@miguelmorales13/nestkit
v0.31.3
Published
Reusable NestJS building blocks: i18n, error handling, standardized responses, request tracking, base entities, generic CRUD, Postgres/Supabase connection helpers, outbound Telegram/WhatsApp/Stripe/Meta clients, short-lived media hosting, batteries-includ
Readme
@miguelmorales13/nestkit
Bloques de infraestructura reutilizables para servicios NestJS. Pensado para eliminar el boilerplate que se repite al arrancar cada backend nuevo.
El esqueleto: entidades base, Repository<T> y CRUD genérico, respuestas estandarizadas, manejo
de errores, tracking de requests, i18n, y un applyNestKitDefaults que lo cablea todo en una línea.
Las conexiones: Postgres (con aislamiento por tenant vía RLS) y Supabase — el contrato de Mongo está listo, la implementación no.
Las integraciones, cada una en su subpath y sin arrastrar dependencias a quien no la use: acceso
con Google, Apple, Facebook, TikTok, GitHub, Microsoft, Discord, LinkedIn, enlace mágico, passkeys y verificación en dos pasos (auth/oauth) o con email y contraseña (auth), Stripe, cobro presencial en
terminal Mercado Pago Point o Clip (payments), facturación CFDI 4.0 (invoicing), Telegram,
WhatsApp, Instagram y Facebook Pages (meta), subida de archivos (storage), correo transaccional con Resend,
Brevo o SMTP tras un puerto (email), push a apps y navegadores con botones (push) y lectura de analítica (umami).
¿Para qué proyectos es esto?
Sí: cualquier proyecto NestJS nuevo que arranque con el patrón hexagonal/ports-and-adapters
(entidad → Repository<T> como puerto → adaptador de infraestructura), como hr-pymes-saas,
schools-saas, restaurant-inventory-saas, o cualquier proyecto futuro que empiece de cero.
No: proyectos existentes con convenciones ya establecidas y opuestas — por ejemplo ms-pigmento,
que usa Mongoose directo sin capa de Repository y sin DTOs con class-validator (ver la skill
nestjs-backend de ese proyecto). Meter nestkit ahí introduciría dos formas distintas de hacer lo
mismo en el mismo código base. Si dudas si un proyecto califica, pregúntate: ¿los servicios llaman al
ORM/cliente de DB directamente, o pasan por una interfaz de Repository? Si es lo primero, esta
librería no es para ese proyecto.
Instalación
bun add @miguelmorales13/nestkitnestkit declara sus dependencias de framework como peer dependencies — instala solo las que
tu proyecto realmente use:
| Siempre necesarios | Solo si usas... |
|---------------------|------------------|
| @nestjs/common, @nestjs/core, reflect-metadata, rxjs | pg → ./database/postgres |
| | @supabase/supabase-js → ./database/supabase |
| | helmet, @nestjs/swagger → ./bootstrap |
| | telegraf → ./telegram |
| | stripe → ./stripe |
| | resend → ./email/resend |
| | nodemailer → ./email/nodemailer |
| | @aws-sdk/client-s3, @aws-sdk/lib-storage, @aws-sdk/s3-request-presigner → ./storage (solo el adaptador S3) |
bun add @nestjs/common @nestjs/core reflect-metadata rxjs
bun add pg # si usas ./database/postgres
bun add @supabase/supabase-js # si usas ./database/supabase
bun add helmet @nestjs/swagger # si usas ./bootstrap
bun add telegraf # si usas ./telegram
bun add stripe # si usas ./stripe
bun add class-validator class-transformer # requeridos en runtime por ValidationPipemeta, media, umami y auth/oauth no piden nada extra: hablan con sus APIs por el fetch del
runtime. auth/oauth sí necesita cookies en el adaptador HTTP de tu app —@fastify/cookie o
cookie-parser—, que es tuyo, no del paquete; class-validator para sus DTOs, que ya tienes si usas
applyNestKitDefaults; y @simplewebauthn/server solo si activas passkeys.
bcryptjs y jsonwebtoken (los usan auth y auth/oauth) van como dependencias normales, ocultas:
no los instalas ni los importas vos.
Quickstart (main.ts)
import { NestFactory } from '@nestjs/core';
import { applyNestKitDefaults } from '@miguelmorales13/nestkit/bootstrap';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
applyNestKitDefaults(app, {
swagger: { title: 'Mi API' },
});
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();applyNestKitDefaults aplica, en orden: helmet, CORS, ValidationPipe global
(whitelist + transform), GlobalExceptionFilter, ResponseInterceptor y (salvo que pases
swagger: false) Swagger. Cada pieza también se puede usar suelta si no quieres el paquete completo
— ver cada módulo abajo.
No olvides importar TrackingModule en tu AppModule para que ResponseInterceptor y
GlobalExceptionFilter tengan un requestId que leer:
import { Module } from '@nestjs/common';
import { TrackingModule } from '@miguelmorales13/nestkit/tracking';
@Module({
imports: [TrackingModule],
})
export class AppModule {}Con esto ya tienes: cualquier respuesta de cualquier controller sale envuelta en
{ success: true, data, requestId, timestamp }, cualquier excepción sale como
{ success: false, error: { code, message, details? }, requestId, timestamp }, con el header
X-Request-Id reflejado en la respuesta.
Si la API habla más de un idioma, suma I18nModule.forRoot({ path: './i18n/' }) a esos imports:
desde ese momento los mensajes construidos con t() —en excepciones o en lo que devuelva un
controlador— salen en el idioma que pidió el request, y los errores de la propia librería se
traducen por su code. Ver i18n.
Módulos
entities — entidades y DTOs base
import type { BaseEntity, SoftDeleteEntity } from '@miguelmorales13/nestkit/entities';
import { BaseResponseDto } from '@miguelmorales13/nestkit/entities';
// Entidad de dominio normal — id/createdAt/updatedAt vienen de BaseEntity
export interface Widget extends BaseEntity {
name: string;
}
// Entidad con borrado lógico — agrega deletedAt: Date | null
export interface Widget extends SoftDeleteEntity {
name: string;
}
// DTO de respuesta que mapea 1:1 la entidad, sin repetir constructor
export class WidgetDto extends BaseResponseDto {
name!: string;
}
const dto = WidgetDto.fromEntity(widget); // instancia de WidgetDto, no de BaseResponseDtoSoftDeleteEntity es opcional — solo la usan las entidades que de verdad necesitan borrado lógico.
Una entidad con BaseEntity a secas no carga el campo deletedAt.
response — envelope estandarizado
Normalmente no construyes esto a mano: ResponseInterceptor (aplicado por applyNestKitDefaults o
manualmente con app.useGlobalInterceptors(new ResponseInterceptor())) envuelve automáticamente
cualquier valor que un controller retorne. Los tipos existen para cuando necesitas tipar el lado del
cliente/consumidor de la API:
import type { ApiResponse, ApiSuccessResponse, ApiErrorResponse } from '@miguelmorales13/nestkit/response';
async function fetchWidget(id: string): Promise<ApiResponse<Widget>> {
const res = await fetch(`/widgets/${id}`);
return res.json(); // { success: true, data: Widget, requestId, timestamp } | ApiErrorResponse
}errors — excepciones tipadas + filtro global
import { NotFoundAppException, ConflictAppException } from '@miguelmorales13/nestkit/errors';
async findOrFail(id: string): Promise<Widget> {
const widget = await this.repository.findById(id);
if (!widget) {
throw new NotFoundAppException(`Widget ${id} not found`, { id });
}
return widget;
}Subclases incluidas: NotFoundAppException (404), ConflictAppException (409),
ValidationAppException (400), UnauthorizedAppException (401), ForbiddenAppException (403) — cada
una acepta (message?, details?, code?), con un code por default (ej. 'NOT_FOUND') que puedes
sobreescribir si necesitas un código más específico del dominio. Para un error que no encaja en
ninguna, usa AppException directo: new AppException('INSUFFICIENT_STOCK', 'No hay suficiente inventario', HttpStatus.CONFLICT, { available: 3 }).
El message puede ser un t('widgets.not_found', { id }) de i18n: el filtro lo traduce al idioma del
request al responder, y details se recorre igual (un mapa campo → t('fields.required') sale
traducido). Ver i18n.
GlobalExceptionFilter (aplicado por applyNestKitDefaults) traduce cualquier excepción —
AppException, cualquier HttpException nativa de Nest, o un error no controlado — al mismo
ApiErrorResponse. Los errores 5xx se loggean con el stack completo vía el Logger de Nest, pero el
stack nunca llega al cliente en el body de la respuesta.
tracking — request id (y quién) por request
import { RequestContext } from '@miguelmorales13/nestkit/tracking';
@Injectable()
export class WidgetsService {
async create(data: CreateWidgetDto): Promise<Widget> {
this.logger.log(`Creating widget, request ${RequestContext.getRequestId()}`);
// ...
}
}Cualquier capa (servicio, repositorio, logger) puede leer RequestContext.getRequestId() — y
getActor() una vez que un guard autenticó, y getLang() una vez que I18nInterceptor resolvió el
idioma — sin que se lo pasen por parámetro — vive en un AsyncLocalStorage que RequestIdMiddleware inicializa al
principio de cada request (TrackingModule lo registra globalmente). El middleware respeta el header
X-Request-Id si el cliente ya lo mandó (útil para tracing distribuido entre servicios), o genera uno
nuevo con crypto.randomUUID().
idempotency — que un reintento no cobre dos veces
Un cliente con mala señal toca dos veces, o un proxy reintenta un POST que se pasó de tiempo.
Antes eso era una fila duplicada; desde que la librería cobra tarjetas y timbra CFDI, es un
cargo doble y una factura de más — y una factura no se borra, se cancela con motivo ante el SAT.
// app.module.ts
IdempotencyModule.forRoot() // memoria: una sola instancia
IdempotencyModule.forRoot({ store: new RedisIdempotencyStore(redis) })
// main.ts, o por controller con @UseInterceptors
app.useGlobalInterceptors(app.get(IdempotencyInterceptor));El cliente manda Idempotency-Key: <algo único por intento> en POST, PUT, PATCH o DELETE.
A partir de ahí hay tres finales para una llave ya vista:
| Situación | Respuesta |
|---|---|
| Terminó | se devuelve la respuesta guardada, con Idempotent-Replay: true y el mismo status |
| Sigue corriendo | 409 IDEMPOTENCY_IN_PROGRESS |
| Misma llave, cuerpo distinto | 422 IDEMPOTENCY_KEY_REUSED |
Sigue corriendo devuelve 409 en vez de esperar, a propósito: dejar la segunda petición colgada ata su tiempo de vida al de la primera, y quien reintentó porque todo iba lento es justo a quien no conviene hacer esperar de nuevo.
Misma llave con otro cuerpo falla en vez de reproducir. Devolver la respuesta de otra petición es peor que fallar: el cliente leería «creado» sobre algo que no existe. La huella es un SHA-256 de método, ruta y cuerpo, con las llaves del JSON ordenadas — dos cuerpos idénticos serializados en distinto orden son la misma huella.
Solo se recuerdan las respuestas exitosas. Un error libera la llave para que un reintento
reintente de verdad. Eso significa que un handler que falló después de su efecto lo volverá a
ejecutar: por eso el efecto lleva su propia llave, y payments.charge() acepta un idempotencyKey
que viaja hasta Mercado Pago. Esta capa protege el endpoint; aquella protege el cargo.
Las llaves se guardan por quien las manda. Por defecto, el sub del token que dejó
JwtAuthGuard — dos clientes distintos pueden mandar "orden-1" sin leerse la respuesta del otro.
En un endpoint público hay que pasar scope propio, o las llaves son globales.
El store de memoria es correcto con una sola instancia y equivocado en cuanto hay dos: cada
proceso tendría sus llaves, así que el reintento que cae en otra réplica vuelve a ejecutar. Detrás de
un balanceador, Redis o una tabla. Implementar IdempotencyStorePort es un begin atómico
(reclamar-si-no-existe en una operación), un complete y un release.
No se enchufa desde applyNestKitDefaults a propósito: necesita un store cuya vida controla la app, y
encenderlo global cambia el contrato de todos los endpoints que mutan de golpe. Montarlo es una
decisión, no un default.
audit — quién cambió qué
Todo SaaS multi-tenant termina escribiendo esto, y siempre tarde: el día que alguien pregunta quién borró un registro. La librería no guarda nada —el rastro vive en tu base, bajo tus reglas de retención, normalmente en una tabla que nadie puede actualizar—: define qué lleva una entrada y rellena el quién y el cuándo desde el contexto.
AuditModule.forRoot({ sink: new PostgresAuditSink(pool) })
AuditModule.forRoot() // al log de la aplicación, para empezarconstructor(@Inject(AuditService) private readonly auditoria: AuditService) {}
await this.auditoria.record({
action: 'refunded', // el verbo que el negocio reconoce
resource: 'invoice',
resourceId: id,
metadata: { motive: '02' },
});actorId, tenantId y requestId no se pasan: salen del contexto de la petición. Eso funciona
porque JwtAuthGuard ahora deja al usuario ahí además de en el request, así que un servicio que
nunca recibió el usuario como argumento igual puede decir quién fue. Fuera de una petición —un job,
un worker de cola— simplemente quedan vacíos y la entrada se escribe igual.
El CRUD genérico lo hace solo:
new BaseCrudService(widgetRepository, {
audit: { recorder: auditService, resource: 'widget', includeBefore: true },
})Registra created, updated, deleted y soft-deleted — nombrado por lo que le pasó al dato y no
por la llamada: una fila que sigue ahí no está «deleted», y quien lea el rastro dentro de un año no
tiene cómo saber qué estrategia estaba configurada. includeBefore lee la fila antes de tocarla para
que la entrada diga qué decía; está apagado por defecto porque es una consulta extra por escritura.
recorder es cualquier cosa con record(event): el AuditSinkPort pelado o el AuditService. Pasá
el servicio para heredar su política ante un sink caído, porque BaseCrudService no atrapa el
error: quien decide si una entrada perdida detiene la operación es una sola pieza, y no es esa.
Un sink caído no tumba la operación, por defecto: convertir una falla de bitácora en una falla de
negocio es peor, y la escritura que se estaba registrando ya ocurrió. Si tu regla dice lo contrario
—sin entrada no hubo operación— es onError: 'fail'.
El LoggerAuditSink que viene por defecto es un buen comienzo y un mal final: los logs rotan, y la
pregunta que un rastro existe para responder («quién borró esto, en marzo») se hace mucho después de
que el log ya no está. Sirve para desarrollo y para mandar a un pipeline; no sustituye una tabla.
database/postgres — Pool + aislamiento por tenant (RLS)
import { Module } from '@nestjs/common';
import { PgModule, PG_POOL, withTenantScope } from '@miguelmorales13/nestkit/database/postgres';
@Module({ imports: [PgModule] })
export class AppModule {}// dentro de un repository o guard
import { Inject, Injectable } from '@nestjs/common';
import { PG_POOL, withTenantScope } from '@miguelmorales13/nestkit/database/postgres';
import type { Pool } from 'pg';
@Injectable()
export class WidgetRepository {
constructor(@Inject(PG_POOL) private readonly pool: Pool) {}
async findAllForTenant(authUserId: string) {
return withTenantScope(this.pool, authUserId, (client) =>
client.query('SELECT * FROM widgets'), // RLS solo deja ver lo que auth.uid() puede ver
);
}
}PgModule lee DATABASE_URL del entorno y lanza un error explícito al arrancar si falta —
falla rápido en vez de fallar silenciosamente en el primer query. withTenantScope es la pieza que
hace que Row-Level-Security de Postgres vea el usuario correcto: abre una transacción, corre
SELECT set_config('request.jwt.claim.sub', authUserId, true), ejecuta tu función, y hace
commit/rollback — cualquier query que dependa de una policy RLS basada en auth.uid() debe pasar
por aquí, pool.query() directo no aplica el set_config y verá (o no verá) filas incorrectamente.
database/typeorm — DataSource + RLS por tenant, para el que quiere ORM
Mismo modelo que database/postgres, pero con TypeORM para quien prefiere entidades,
repositorios y migraciones del ORM. TypeOrmDbModule.forRoot({ entities, migrations }) provee un
DataSource inicializado bajo el token DATA_SOURCE (type: 'postgres' fijo, synchronize
siempre apagado — los cambios de esquema van por migraciones, jamás por auto-sync). Funciona con
cualquier Postgres, incluido Neon.
Lo importante para multi-tenant: el pool del ORM no tiene sesión por-request, así que la RLS no
vería el tenant. Por eso este subpath trae su propio withTenantScope(dataSource, session, fn),
que abre un QueryRunner, corre set_config(name, value, true) por cada variable de sesión, y te pasa
el EntityManager atado a esa conexión y transacción — dentro, manager.getRepository(X).find()
corre con la RLS aplicada; fuera, no. Es el equivalente en TypeORM del withTenantScope de pg, y
está probado: con una policy sobre un GUC, cada tenant ve solo lo suyo y sin scope no ve nada.
await withTenantScope(dataSource, {
'request.jwt.claim.sub': userId,
'request.jwt.claim.tenant': tenantId,
}, (manager) => manager.getRepository(Order).find());Peer dep opcional: typeorm (solo si se usa este módulo). Elegí ESTE o database/postgres, no los
dos a la vez — son dos formas de lo mismo.
database/supabase — clientes anon y service-role
import { Module } from '@nestjs/common';
import { SupabaseModule, SUPABASE_ANON_CLIENT, SUPABASE_SERVICE_ROLE_CLIENT } from '@miguelmorales13/nestkit/database/supabase';
@Module({ imports: [SupabaseModule] })
export class AppModule {}import { Inject, Injectable } from '@nestjs/common';
import { SUPABASE_SERVICE_ROLE_CLIENT } from '@miguelmorales13/nestkit/database/supabase';
import type { SupabaseClient } from '@supabase/supabase-js';
@Injectable()
export class StorageService {
constructor(@Inject(SUPABASE_SERVICE_ROLE_CLIENT) private readonly supabase: SupabaseClient) {}
// service-role: bypasea RLS — úsalo solo server-side, nunca lo expongas al cliente
}Requiere SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY en el entorno — igual que
PgModule, falla explícito al arrancar si falta alguna. Usa el cliente anon cuando quieras que las
policies RLS apliquen tal cual verían al usuario final; usa service-role solo para operaciones
administrativas server-side (nunca en un contexto que un cliente externo pueda influenciar).
database/mongo — solo el contrato, todavía
import type { MongoRepositoryPort } from '@miguelmorales13/nestkit/database/mongo';Es un alias del mismo Repository<T> de crud — no hay MongoModule ni una implementación
concreta todavía porque ningún proyecto del portafolio usa Mongo hoy. El día que haga falta, se
implementa un adaptador que satisfaga Repository<T> con Mongoose por debajo — el CRUD genérico y
las entidades base no cambian, porque nunca dependieron de Postgres/Supabase directamente.
crud — Repository, servicio y controller genéricos
// widget.repository.ts
import { Injectable, Inject } from '@nestjs/common';
import type { FindAllOptions, PageResult, Repository } from '@miguelmorales13/nestkit/crud';
import { PG_POOL } from '@miguelmorales13/nestkit/database/postgres';
import type { Pool } from 'pg';
import type { Widget } from './widget.entity';
@Injectable()
export class WidgetRepository implements Repository<Widget> {
constructor(@Inject(PG_POOL) private readonly pool: Pool) {}
async findById(id: string): Promise<Widget | null> {
const { rows } = await this.pool.query('SELECT * FROM widgets WHERE id = $1', [id]);
return rows[0] ?? null;
}
// Una página, nunca la tabla entera. `sortBy` ya viene comprobado contra la
// lista blanca del servicio — por eso se puede interpolar; jamás interpoles
// algo que venga del request.
async findAll(options: FindAllOptions<Widget> = {}): Promise<PageResult<Widget>> {
const { limit = 20, offset = 0, sortBy = 'created_at', sortOrder = 'desc', filter } = options;
const where = filter?.status ? 'WHERE status = $3' : '';
const params = filter?.status ? [limit, offset, filter.status] : [limit, offset];
const { rows } = await this.pool.query(
`SELECT * FROM widgets ${where} ORDER BY ${sortBy} ${sortOrder === 'asc' ? 'ASC' : 'DESC'} LIMIT $1 OFFSET $2`,
params,
);
const { rows: [{ count }] } = await this.pool.query(
`SELECT COUNT(*) FROM widgets ${where}`,
params.slice(2),
);
return { items: rows, total: Number(count) };
}
async create(data: Omit<Widget, 'id' | 'createdAt' | 'updatedAt'>): Promise<Widget> {
const { rows } = await this.pool.query(
'INSERT INTO widgets (name) VALUES ($1) RETURNING *',
[data.name],
);
return rows[0];
}
async update(id: string, data: Partial<Widget>): Promise<Widget> {
const { rows } = await this.pool.query(
'UPDATE widgets SET name = COALESCE($2, name) WHERE id = $1 RETURNING *',
[id, data.name],
);
return rows[0];
}
async delete(id: string): Promise<void> {
await this.pool.query('DELETE FROM widgets WHERE id = $1', [id]);
}
}// widgets.module.ts
import { Module } from '@nestjs/common';
import { BaseCrudService, createCrudController } from '@miguelmorales13/nestkit/crud';
import { WidgetRepository } from './widget.repository';
const WIDGETS_SERVICE = 'WIDGETS_SERVICE';
class WidgetsController extends createCrudController(WIDGETS_SERVICE as any, { path: 'widgets' }) {}
@Module({
controllers: [WidgetsController],
providers: [
WidgetRepository,
{ provide: WIDGETS_SERVICE, useFactory: (repo: WidgetRepository) => new BaseCrudService(repo), inject: [WidgetRepository] },
],
})
export class WidgetsModule {}En la práctica, usa una subclase concreta de
BaseCrudServicecomo provider/token (en vez de un string token) para quecreateCrudControllerinfiera los tipos sin castear.createCrudControlleres composición opcional: si prefieres tu propio controller, simplemente consumeBaseCrudService/Repository<T>directamente y no lo uses.
Listados: paginación, orden y filtros
findAll devuelve una página, siempre. El servicio pone un límite aunque nadie lo pida, porque
la versión sin límite de ese método es un SELECT * esperando a que la tabla crezca.
new BaseCrudService(widgetRepository, {
defaultLimit: 20, // por defecto 20
maxLimit: 100, // tope duro, pase lo que pase en la query string
filterable: ['status'], // por qué campos se puede filtrar
sortable: ['name', 'createdAt'],
})GET /widgets?page=2&limit=20&sortBy=name&sortOrder=asc&status=active
{
"success": true,
"data": [ { "id": "…" } ],
"meta": { "page": 2, "limit": 20, "total": 95, "pages": 5, "hasNext": true, "hasPrevious": true },
"requestId": "…",
"timestamp": "…"
}Los ítems quedan en data como arreglo plano y la paginación va al lado, en meta: un cliente que
no pagina lee exactamente la misma forma de antes. Eso lo hace el ResponseInterceptor al reconocer
un Paginated<T>; si escribís tu propio controller, devolvé paginated(items, buildPageMeta(...))
(en /response) y obtenés el mismo trato.
filterable y sortable están vacíos por defecto, y es a propósito. Un listado que reenvía la
query string entera al repositorio deja filtrar por cualquier columna de la entidad —el hash de una
contraseña, una bandera interna, el marcador de otro tenant— y eso convierte el endpoint en un
oráculo: se adivina fila por fila. Abrir un campo es una línea; dejarlo abierto sin querer es una
fuga. Lo que no está en la lista se descarta en silencio: contestar «ese campo no es filtrable»
confirma que el campo existe, que es justo lo que estaban preguntando.
Con sortBy hay además un motivo más filoso: el adaptador lo interpola en el SQL. Por eso lo
comprueba el servicio y no el controller — así llamar al servicio directamente es tan seguro
como pasar por HTTP.
Los valores de filtro llegan como cadenas, porque eso es una query string: convertirlos al tipo de la columna le toca al adaptador, que es el único que sabe de qué tipo es.
Borrado lógico: pasa deleteStrategy: 'soft' al construir el servicio para una entidad
SoftDeleteEntity — delete() hace un update(id, { deletedAt: new Date() }) en vez de borrar la
fila/documento:
new BaseCrudService(widgetRepository, { deleteStrategy: 'soft' })Por defecto ('hard' o sin especificar) delete() llama a repository.delete() tal cual. Si usas
'soft', tu Repository<T> debe estar tipado sobre una entidad que extienda SoftDeleteEntity y tu
findAll/findById deben filtrar deletedAt IS NULL por su cuenta — el servicio genérico no hace
ese filtro automáticamente, solo decide cómo borra.
Multi-tenant: por defecto createCrudController no aísla por tenant — llama al servicio sin
tenantId (igual que si lo omitieras a mano), correcto para un recurso single-tenant. Si tu
Repository<T>/tabla sí necesita aislar por tenant (ej. RLS de Postgres, o un filtro
WHERE company_id = ? en cualquier otro motor), pasa tenantIdExtractor para que los 5 métodos
generados lo saquen del request y se lo pasen al servicio automáticamente — normalmente lee un valor
que un guard de auth ya dejó en el request (como req.companyId en el guard de hr-pymes-saas):
class WidgetsController extends createCrudController(WIDGETS_SERVICE as any, {
path: 'widgets',
tenantIdExtractor: (req) => (req as AuthenticatedRequest).companyId,
}) {}Sin esta opción, un Repository<T> que solo filtra correctamente cuando recibe tenantId no
queda protegido automáticamente por el CRUD genérico — es responsabilidad de quien monta el
controller pasarla si el recurso es multi-tenant.
i18n — mensajes en el idioma del que pregunta
Un mensaje viaja como clave + argumentos y se traduce una sola vez, en el borde, al idioma que
pidió el request. Quien lanza el error o arma la respuesta no sabe el idioma; quien sí lo sabe
(el interceptor y el filtro global) no sabe qué mensajes hay adentro. t() junta las dos mitades.
// app.module.ts
import { Module } from '@nestjs/common';
import { I18nModule } from '@miguelmorales13/nestkit/i18n';
@Module({
imports: [
I18nModule.forRoot({
path: './i18n/',
fallbackLanguage: 'es',
locale: {
// De dónde sale el idioma, en orden: gana la primera fuente que trae uno soportado.
from: ['query', 'header', 'cookie', 'accept-language'],
query: 'lang', // ?lang=en
header: 'x-lang',
cookie: 'lang', // leída del header Cookie, sin cookie-parser
},
}),
],
})
export class AppModule {}// i18n/es/widgets.json // i18n/en/widgets.json
{ "not_found": "No se encontró el widget {id}" } { "not_found": "Widget {id} not found" }import { t } from '@miguelmorales13/nestkit/i18n';
import { NotFoundAppException } from '@miguelmorales13/nestkit/errors';
// En un servicio: la excepción lleva la clave; el filtro la traduce al responder.
throw new NotFoundAppException(t('widgets.not_found', { id }, `Widget ${id} not found`));
// En un controlador: cualquier t() dentro de lo que devuelvas sale traducido.
@Post()
async create(@Body() dto: CreateOrderDto) {
const order = await this.orders.create(dto);
return { order, message: t('orders.created') };
}Con solo importar el módulo pasan tres cosas:
I18nInterceptorse registra global. Resuelve el idioma con las fuentes delocale, lo deja enRequestContext.getLang()y recorre lo que devuelve el handler reemplazando cadat()— en objetos, arreglos, dentro de una páginapaginated(). No entra a instancias de clase (fechas, entidades de ORM, streams): las deja intactas.GlobalExceptionFiltertraduce elmessagey losdetailsde cualquierAppException. Y si la excepción se lanzó con un string plano, busca sucodeen el namespaceerrors(i18n/en/errors.json→{ "NOT_FOUND": "Not found", "UNAUTHORIZED": "..." }), así que los errores que lanza la propia librería (Invalid credentials,Insufficient role) se traducen sin tocar ningúnthrow. Se apaga conerrorCodes: false, o se mueve conerrorCodes: 'api.errors'.- Los consumidores que inyectan
I18nServicedenestjs-i18ndirecto obtienen el mismo idioma: el módulo le pasa un único resolver que delega en la misma resolución.
Fuera de un request —un worker que arma un correo— translate() traduce ahora, con el idioma que
le digas o el del request actual si lo hay:
import { translate, currentLanguage } from '@miguelmorales13/nestkit/i18n';
const subject = translate('emails.welcome.subject', { lang: user.locale, args: { name } });Nunca lanza: una clave que falta regresa el fallback del mensaje (o la clave), porque una
traducción faltante no puede convertirse en un 500 encima del error original. Un idioma que el
backend no tiene (?lang=fr con solo es y en) se ignora y se pasa a la siguiente fuente; un
valor que no parece etiqueta de idioma nunca llega al backend. Sin I18nModule importado nada de
esto corre: un t() se serializa como su fallback.
Otro backend. nestjs-i18n con JSON por idioma y namespace, cargado en memoria al arrancar, es la
opción por defecto (loader acepta el de YAML). Si las traducciones viven en otra parte, implementa
TranslatorPort —dos métodos: translate(key, { lang, args }) que regresa undefined cuando la
clave no existe, y supportedLanguages()— y pásalo en forRoot({ translator }): nestjs-i18n no se
carga y el resto (resolución de idioma, interceptor, filtro, translate()) funciona igual.
telegram — cliente de salida para un bot específico
import { Module } from '@nestjs/common';
import { TelegramModule } from '@miguelmorales13/nestkit/telegram';
@Module({ imports: [TelegramModule] })
export class AppModule {}import { Inject, Injectable } from '@nestjs/common';
import { TELEGRAM_BOT } from '@miguelmorales13/nestkit/telegram';
import type { Telegram as TelegramClient } from 'telegraf';
@Injectable()
export class NotificationsService {
constructor(@Inject(TELEGRAM_BOT) private readonly bot: TelegramClient) {}
async notify(chatId: number | string, text: string) {
await this.bot.sendMessage(chatId, text);
}
}Requiere TELEGRAM_BOT_TOKEN en el entorno — falla explícito al arrancar si falta, igual que
PgModule/SupabaseModule. TelegramModule solo expone el cliente de salida (Telegram, la
clase de telegraf para llamar directo a la Bot API): no hay polling, no hay webhook, no hay manejo
de updates entrantes — es a propósito, este paquete no enruta mensajes recibidos. Importa el tipo del
cliente como Telegram as TelegramClient si tipas tu propia inyección — telegraf exporta una clase
llamada Telegram, que choca de nombre con el concepto genérico "Telegram" en tu propio código.
whatsapp — cliente de salida sobre la Cloud API de Meta
import { Module } from '@nestjs/common';
import { WhatsAppModule } from '@miguelmorales13/nestkit/whatsapp';
@Module({ imports: [WhatsAppModule] })
export class AppModule {}import { Inject, Injectable } from '@nestjs/common';
import { WHATSAPP_CLIENT, WhatsAppClient } from '@miguelmorales13/nestkit/whatsapp';
@Injectable()
export class NotificationsService {
constructor(@Inject(WHATSAPP_CLIENT) private readonly whatsapp: WhatsAppClient) {}
async notify(to: string, body: string) {
await this.whatsapp.sendTextMessage(to, body);
}
}Requiere WHATSAPP_ACCESS_TOKEN y WHATSAPP_PHONE_NUMBER_ID; WHATSAPP_API_VERSION es opcional
(default v21.0). No hay dependencia npm de por medio — Meta no publica un SDK oficial de Node
razonable para la Cloud API, que de todos modos es un REST plano, así que WhatsAppClient usa el
fetch global de Node (18+) directo. Solo manda mensajes de texto (sendTextMessage) — sin
templates, sin media, sin manejo de mensajes entrantes.
meta — publicar en Instagram y Facebook Pages
Cliente de salida sobre la Graph API, solo para publicar: no lee insights, ni maneja webhooks, ni
comentarios. Mismo alcance que telegram.
import { MetaModule } from '@miguelmorales13/nestkit/meta';
@Module({ imports: [MetaModule.forRoot()] }) // lee META_ACCESS_TOKEN, META_PAGE_ID, META_IG_USER_ID
export class AppModule {}await meta.publishToInstagram(url, 'Texto del pie', 'feed'); // 'feed' | 'stories' | 'reels'
await meta.publishToPage(url, 'Texto');
await meta.publishStoryToPage(url);
await meta.describe(); // a qué cuenta apunta el token, para diagnosticarDos cosas de esta API mandan sobre todo lo demás, y conviene saberlas antes:
Las imágenes nunca se suben. Se pasa una URL pública y Meta la descarga. Solo la necesita
durante la publicación —después guarda su propia copia y sirve el archivo desde su CDN—, así que
alcanza con un hosting efímero. Para eso está media, en este mismo paquete.
Publicar en una Página exige un Page token, no un user token. Con un token de usuario Meta
asume que estás posteando como persona y pide publish_actions, un permiso que eliminó en 2018, así
que el error que devuelve no apunta ni de cerca a la causa real.
Permisos: instagram_basic e instagram_content_publish para Instagram, pages_manage_posts para
Facebook. Publicar en tus propias Páginas no necesita App Review; hacerlo en nombre de terceros sí.
Instagram publica en dos pasos (crear contenedor, publicar) y el servicio espera solo a que el contenedor esté listo — no hace falta encadenarlo a mano.
forRoot() sin argumentos toma las credenciales del entorno; pasándoselas explícitas se puede tener
un proceso publicando en varias cuentas.
pinterest — publicar pines, y por qué no es una red social más
Pinterest es un buscador visual, y eso cambia dos cosas frente a Instagram y Facebook.
Cada pin lleva su propio enlace. Hay un camino directo del contenido al sitio, cosa que en Instagram no existe porque el enlace solo vive en la biografía.
El contenido no caduca. Un pin sigue apareciendo en búsquedas meses después de publicarse, mientras que una historia dura 24 horas. Por eso el título y la descripción pesan mucho más que un pie de foto en cualquier otro destino: son lo que hace que alguien lo encuentre medio año después.
import { PinterestModule, PinterestService } from '@miguelmorales13/nestkit/pinterest';
@Module({ imports: [PinterestModule.forRoot()] }) // lee PINTEREST_ACCESS_TOKEN
export class AppModule {}await pinterest.listBoards(); // para sacar los ids
await pinterest.publish({
boardId, title, description,
link: 'https://tusitio.com/receta', // esto es lo que importa
imageUrl, // Pinterest la descarga él
});Igual que Meta, no acepta el binario: se le pasa una URL pública y va a
buscarla. media sirve justo para eso.
El servicio se puede usar suelto, fuera de Nest: recibe credenciales
planas y solo usa fetch, así que un script de CLI hace
new PinterestService({ accessToken }) sin nada de inyección de dependencias.
Es lo que hace nutrimx, donde quien publica es un script con Playwright y no el
backend.
El token se saca en developers.pinterest.com con los permisos boards:read,
pins:read y pins:write. Los títulos se recortan a 100 caracteres y las
descripciones a 800, que son los límites del API — pasarse devuelve un 400.
tiktok — publicar videos, y el modo que evita la auditoría
Publicar son dos llamadas: inicializar la subida y sondear hasta que TikTok termine de procesar.
import { TikTokModule, TikTokService } from '@miguelmorales13/nestkit/tiktok';
@Module({ imports: [TikTokModule.forRoot()] }) // lee TIKTOK_ACCESS_TOKEN
export class AppModule {}await tiktok.userInfo(); // comprueba el token sin publicar
const id = await tiktok.publish({ data, title }); // sin privacyLevel: a borradores
await tiktok.status(id); // hasta PUBLISH_COMPLETECuidado con creatorInfo(). Parece el paso previo obligatorio —así lo
sugiere la documentación— pero solo lo es para publicar en directo, y
además exige video.publish. Llamarlo con un token de borradores devuelve
scope_not_authorized, o sea que falla justo en la configuración más común.
Para comprobar que el token vive, userInfo(), que se conforma con
user.info.basic.
La diferencia entre los dos modos importa mucho al principio. En Direct
Post el video sale en vivo, pero exige el permiso video.publish, que solo se
consigue pasando una auditoría de TikTok de varias semanas —y hasta aprobarla
todo lo publicado queda en SELF_ONLY, o sea que no lo ve nadie—. En modo
borrador, que es el de por defecto aquí, el video cae en la bandeja de la
cuenta y una persona le da publicar desde el teléfono: llega con todo su
alcance y solo necesita video.upload.
Dicho de otro modo: publicar en directo antes de la auditoría es peor que no publicar, porque gastas la pieza sin que la vea nadie.
Sube el binario en vez de pasar una URL a propósito. La otra vía,
PULL_FROM_URL, obliga a verificar el dominio en el portal de TikTok, y
cambiar un trámite por otro no compensa cuando el archivo ya está en disco.
El video tiene que ir en MP4 H.264. Límite de 6 peticiones por minuto por token, así que el sondeo del estado va espaciado.
stripe — cliente de la SDK oficial
import { Module } from '@nestjs/common';
import { StripeModule } from '@miguelmorales13/nestkit/stripe';
@Module({ imports: [StripeModule] })
export class AppModule {}import { Inject, Injectable } from '@nestjs/common';
import { STRIPE_CLIENT } from '@miguelmorales13/nestkit/stripe';
import type Stripe from 'stripe';
@Injectable()
export class BillingService {
constructor(@Inject(STRIPE_CLIENT) private readonly stripe: Stripe) {}
async createCustomer(email: string) {
return this.stripe.customers.create({ email });
}
}Requiere STRIPE_SECRET_KEY. STRIPE_API_VERSION es opcional — si no la fijas, la SDK usa su propia
versión pineada por default (no la fuerces a mano salvo que sepas exactamente por qué). Como
Postgres/Supabase, solo expone el cliente crudo por DI — no envuelve Stripe en tipos propios de
nestkit, así que tipa tu inyección directo contra Stripe de la librería stripe.
Webhooks de Stripe
Requisito obligatorio: tu
main.tstiene que crear la app conNestFactory.create(AppModule, { rawBody: true }). La verificación de firma de Stripe necesita los bytes crudos del request tal cual llegaron —applyNestKitDefaultscorre después de crear la app y no puede habilitarrawBodyen retrospectiva. Sin esto, el controller lanza un error explícito en cuanto le llega un webhook, en vez de fallar silencioso.
// main.ts
const app = await NestFactory.create(AppModule, { rawBody: true });import { Module } from '@nestjs/common';
import { createStripeWebhookController } from '@miguelmorales13/nestkit/stripe';
class StripeWebhookController extends createStripeWebhookController({
handlers: {
'checkout.session.completed': async (event) => {
// event.data.object es el Checkout Session
},
'customer.subscription.updated': async (event) => {
// actualiza el plan del tenant en tu propia DB
},
},
}) {}
@Module({ controllers: [StripeWebhookController] })
export class BillingModule {}Requiere STRIPE_WEBHOOK_SECRET además de STRIPE_SECRET_KEY. Los tipos de evento sin handler
registrado se acusan recibo ({ received: true }) y se ignoran — Stripe espera un 200 rápido,
no que manejes cada tipo de evento que te manda.
payments — cobro presencial en terminal, con el proveedor como configuración
Cobrar con una terminal física —una Point de Mercado Pago o un lector de Clip— detrás de un solo
puerto, TerminalPaymentsPort. El módulo empuja el cobro a la terminal, consulta cómo terminó,
devuelve el dinero y recibe el webhook; el resto de tu código nunca aprende el vocabulario de un
proveedor.
Mismo patrón que storage: cambiar de proveedor es una línea en PaymentsModule.forRoot(...),
porque nada depende de un adaptador concreto. Y no suma dependencias: los dos adaptadores son
fetch y node:crypto, así que este subpath no tiene peer deps.
// En el módulo raíz — elegís el proveedor acá y nada más cambia:
PaymentsModule.forRoot({ provider: 'clip' }) // lee CLIP_* del entorno
PaymentsModule.forRoot({ provider: 'mercadopago' }) // lee MERCADOPAGO_* del entorno
PaymentsModule.forRootAsync({ inject: [Config], useFactory: (c) => ({ provider: c.paymentsProvider }) })import { Inject, Injectable } from '@nestjs/common';
import { TERMINAL_PAYMENTS, type TerminalPaymentsPort } from '@miguelmorales13/nestkit/payments';
@Injectable()
export class CobrosService {
constructor(@Inject(TERMINAL_PAYMENTS) private readonly pagos: TerminalPaymentsPort) {}
async cobrar(ordenId: string, totalCentavos: number, terminalId: string) {
const cobro = await this.pagos.charge({
amountCents: totalCentavos, // 24500 = $245.00
terminalId,
reference: ordenId, // tu id, es lo que te devuelven después
idempotencyKey: ordenId, // un reintento no cobra dos veces
});
return cobro.id; // guardalo: es con lo que consultás, cancelás y reembolsás
}
}await pagos.listTerminals(); // qué terminales hay, y cuáles están vivas
await pagos.get(cobro.id); // estado actual — la respuesta autoritativa, siempre
await pagos.cancel(cobro.id); // retirar el cobro, mientras la terminal no lo haya tomado
await pagos.refund(cobro.id); // devolver todo
await pagos.refund(cobro.id, { amountCents: 5000 }); // o una parteLos montos van en centavos, enteros. Los dos proveedores reciben decimal en el cable —Mercado
Pago un string ("24.50"), Clip un número (200.5)— y justamente por eso el puerto no lo acepta:
un float que llega hasta acá ya perdió la discusión (19.99 * 3 es 59.96999999999999, y la
terminal cobra lo que le mandes). Cada adaptador formatea una sola vez, al borde, desde un entero que
no se corre.
El orden real de una integración
- Dar de alta la terminal con el proveedor. En Mercado Pago, ponerla en modo PDV
(
setOperatingMode) y reiniciarla. En Clip, mandar el número de serie a[email protected]para que la habiliten, con el APK de Clip PinPad instalado. - Credenciales. Mercado Pago: access token de la aplicación (los de prueba empiezan con
APP_USR). Clip:apiKey+secretKey, que el adaptador combina en el token Basic. - Webhook. Mercado Pago: se configura una sola vez en el panel, tópico Order, y se guarda el
secreto de firma. Clip: se manda en cada cobro (
webhookUrl), con un token al azar adentro. - Cobrar, y no creerle al webhook:
get()manda.
Webhooks: el aviso dice cuál, nunca cómo terminó
import { createTerminalPaymentsWebhookController } from '@miguelmorales13/nestkit/payments';
class PagosWebhookController extends createTerminalPaymentsWebhookController({
handlers: {
approved: async (pago) => marcarPagada(pago.reference!, pago.paidCents!),
rejected: async (pago) => liberarOrden(pago.reference!),
cancelled: async (pago) => liberarOrden(pago.reference!),
},
}) {}El controller hace siempre lo mismo: verifica el request, consulta el cobro por API y recién
entonces despacha según el estado que volvió. El cuerpo del webhook no es fuente de verdad nunca, y
no es purismo: Clip no firma nada —su aviso son exactamente tres campos,
{ id, origin: "pinpad-payments-api", event_type: "PINPAD_INTENT_STATUS_CHANGED" }, que cualquiera
puede postear—, así que creerle un "approved" es dar por pagada una orden que nadie pagó. Es
también lo que manda su propia documentación: tomá el id y consultá el pago. Mercado Pago sí firma,
y aun así su cuerpo trae un id.
Mercado Pago manda order.processed, order.canceled, order.refunded, order.action_required,
order.failed y order.expired; el data.id es el id de la orden. Los estados sin handler se
acusan recibo y se ignoran. Los handlers corren antes de responder: los dos proveedores reintentan
ante un no-2xx y Mercado Pago se rinde a los 22 segundos, así que marcá la orden y encolá el resto.
Lo que cambia entre un proveedor y otro
| | Mercado Pago Point | Clip PinPad |
|---|---|---|
| API | Orders (api.mercadopago.com/v1/orders) | PinPad (api.payclip.io/f2f/pinpad/v1) + api.payclip.com para reembolsos |
| Credenciales | MERCADOPAGO_ACCESS_TOKEN | CLIP_API_KEY + CLIP_SECRET_KEY (Basic base64) |
| Id de terminal | TIPO__SERIE, p. ej. NEWLAND_N950__N950NCB801293324 | el número de serie del lector |
| Webhook | se configura en el panel, tópico Order; firmado | se manda en cada cobro (webhookUrl); sin firma |
| Propina | la terminal la cobra sola; tipCents se rechaza | tipCents, campo propio |
| Descripción | description | no existe: se rechaza, usá reference |
| Expiración | expiresInMinutes (ISO PT16M) | no hay por cobro: es el timeout de kiosko del equipo |
| at_terminal | sí | nunca: es PENDING hasta que termina |
| Reembolso | por orden, 90 días, solo si está processed | por transacción, en el otro host |
| Pruebas | terminal virtual SBX0000001 + endpoint de simulación | no hay sandbox: lector real, cobros de $1 |
Un adaptador nunca descarta en silencio una opción que no puede cumplir: lanza. Una propina que desaparece sin ruido es un cliente preguntando dónde quedó su dinero.
Mercado Pago: lo que cuesta una tarde averiguar
- Va sobre la Orders API, no sobre Payment Intents. La vieja sigue respondiendo, pero Mercado Pago construye todo lo nuevo de Point sobre Orders y documenta la mudanza como de ida: arrancar hoy en la vieja es comprarse una migración.
- La terminal tiene que estar en modo PDV. De fábrica viene en
standalone, donde corre la app del comercio e ignora en silencio lo que le mandes por API.setOperatingMode(['TERM'], 'PDV')lo cambia — y hay que reiniciar la terminal para que tome efecto. Por esolistTerminals()devuelveoperatingMode: un cobro que nunca aparece en una terminal que funciona es casi siempre esto. reference(elexternal_reference) admite 64 caracteres de letras, dígitos,-y_. El adaptador lo valida antes de mandar, porque el rechazo del API nombra el campo y no la regla.- De
print_on_terminalla documentación publica un solo valor,no_ticket, y ningún otro. Así queprintReceipt: falsemanda ese, yprintReceipt: trueno manda nada: decide la configuración de la terminal. Si tenés un valor que funciona en tu cuenta, pasalo crudo comoprintOnTerminalen las opciones del módulo; nestkit no lo inventa. - La firma del webhook se calcula sobre
id:<data.id>;request-id:<x-request-id>;ts:<ts>;con el secreto del panel, y el id va en minúsculas (lo dice su propia documentación). Los ids reales sonORD01J6…, así que hashearlo como viene rechaza todas las notificaciones legítimas — y el síntoma parece un ataque, no un bug. - Solo Point Smart 1 y Point Smart 2 pasan a modo PDV por API. Confirmado con soporte de
Mercado Pago, no es una limitación temporal: un Point Air trae hardware oficial de Mercado Pago,
aparece en
listTerminals()como cualquier otra, hasta puede traer su propiostoreId/posId— y aun así se queda enstandalonepara siempre, porque ese modelo simplemente no tiene modo PDV.setOperatingMode(ids, 'PDV')rechaza un id no integrable antes de llamar a la API, con un mensaje que nombra el modelo — la respuesta real de Mercado Pago es un400seco,"'$.terminals[0].id' - does not match pattern", que no menciona el modelo y parece un id mal formado.listTerminals()también marca cada terminal conintegrable, para filtrar la lista ANTES de que alguien la elija. - La terminal tiene que pertenecer a una jerarquía Sucursal → Caja → Terminal, o la Orders API
rechaza cualquier cobro con
PA_UNAUTHORIZED_RESULT_FROM_POLICIES— un 403 que no menciona Sucursales por ningún lado.createStore()ycreatePos()arman esa jerarquía. Antes de llamarlos, revisálistTerminals(): una terminal emparejada alguna vez desde la app de Mercado Pago (celular) ya puede traer su propiostoreId/posId— crear una Sucursal nueva en ese caso no falla, pero le deja al comercio una Sucursal de más en su panel que nadie usa.
const [terminal] = await pagos.listTerminals();
if (!terminal.integrable) throw new Error('Esta terminal no puede operar por API — es un Point Air.');
let { storeId, posId } = terminal;
if (!storeId || !posId) {
storeId = await pagos.createStore({
userId: sellerMpUserId, // el user_id de la CUENTA CONECTADA, no el de tu aplicación
name: 'Sucursal Centro',
location: { streetNumber: 'S/N', streetName: 'Centro', cityName: 'CDMX', stateName: 'CDMX', latitude: 19.4326, longitude: -99.1332 },
businessHours: { monday: [{ open: '00:00', close: '23:59' }], /* ...los 7 días */ },
});
posId = await pagos.createPos({ storeId, name: 'Caja 1' });
}
await pagos.setOperatingMode([terminal.id], 'PDV'); // y reiniciar el aparatoMercado Pago marketplace: conectar la cuenta de cada vendedor por OAuth
MercadoPagoTerminalPayments asume un MERCADOPAGO_ACCESS_TOKEN fijo — de una sola cuenta. Un
marketplace (vos operás la aplicación, cada vendedor conecta la SUYA) necesita, además, MercadoPagoConnect:
import { MercadoPagoConnect } from '@miguelmorales13/nestkit/payments';
import { signState, readState } from '@miguelmorales13/nestkit/security';
const connect = new MercadoPagoConnect({
clientId: process.env.MERCADOPAGO_CLIENT_ID!,
clientSecret: process.env.MERCADOPAGO_CLIENT_SECRET!,
redirectUri: 'https://api.tuapp.com/mercadopago/callback',
});
// 1. Arrancar la conexión — `state` viaja firmado, sin sesión ni cookie:
const state = signState({ tenantId, sellerId }, { secret: JWT_SECRET, ttlMs: 20 * 60_000 });
const url = connect.authorizationUrl(state);
// 2. En el callback:
const datos = readState(query.state, { secret: JWT_SECRET, ttlMs: 20 * 60_000 });
if (!datos) throw new Error('state inválido o caducado');
const tokens = await connect.exchangeCode(query.code);
await guardar(datos.tenantId, datos.sellerId, tokens); // cifrados — son las llaves del vendedor
// 3. Para cobrar, con el token de ESE vendedor (nunca uno fijo):
const pagosDelVendedor = new MercadoPagoTerminalPayments({ accessToken: tokens.accessToken });scope=offline_access va siempre (es el default): sin él, Mercado Pago entrega un access token de
un solo uso, sin refresh_token, y la conexión deja de funcionar sola el día que expira. El
refresh_token rota en cada renovación (connect.refresh(tokens.refreshToken)) — hay que
persistir el resultado completo, no solo el access token nuevo.
signState/readState (@miguelmorales13/nestkit/security) son genéricos, no específicos de
Mercado Pago: sirven para cualquier flujo de "conectar una cuenta de terceros por OAuth" que no
quiera depender de una cookie — que en un consent screen con pasos extra (2FA, verificación por
teléfono) puede desaparecer antes de que el navegador vuelva. El payload firmado es libre
(Record<string, string>); qué va adentro lo decide quien conecta, no la librería.
Probar sin terminal. Con credenciales de prueba existe un equipo virtual, serie SBX0000001
(NEWLAND_N950__SBX0000001), y un endpoint que le impone el estado final a una orden — el webhook
que llega es idéntico al de producción:
const cobro = await pagos.charge({ amountCents: 2450, terminalId: 'NEWLAND_N950__SBX0000001', reference: 'orden-7' });
await (pagos as MercadoPagoTerminalPayments).simulateStatus(cobro.id, {
status: 'processed', // o canceled, expired, action_required
paymentMethodType: 'credit_card',
paymentMethodId: 'master',
statusDetail: 'accredited',
});
// tarda hasta 10 segundos (40 para action_required)Clip: lo que no se puede arreglar desde el código
- No hay sandbox ni tarjetas de prueba — lo dice su propio FAQ. La API corre contra producción, así que la primera prueba necesita un lector real (Total 3, Ultra, Clip PinPad o Stand 2) con el APK de Clip PinPad instalado, y se hace con montos mínimos: hay bancos que aceptan cobros de MXN $1 o incluso de $0.01.
- El número de serie tiene que estar dado de alta con Clip (se manda a
[email protected]). Hasta entonces cada cobro responde «unable to connect to pinpad terminal», que parece un problema de red y no lo es. - Su webhook no viene firmado. Lo que queda es hacer de la URL la credencial:
CLIP_WEBHOOK_SECRETes un valor al azar que ponés en lawebhookUrlque registrás y queverifyWebhookexige de vuelta (como querytokeno cabecerax-webhook-token, comparado en tiempo constante). Eso no autentica a Clip —solo evita que un extraño te haga consultar ids—, y por eso el evento trae un id y jamás un estado. - Son dos hosts. Los cobros viven en
api.payclip.io; las transacciones y los reembolsos, enapi.payclip.com. Y un reembolso no toma el id que tenés: toma el de la transacción, que sale del detalle del cobro.refund()hace ese puente solo; si el cobro se partió en varias tarjetas (is_split_payment_enabled), se planta y te pide que elijas cuál, porque «devolver el cobro» ahí no significa nada.
Para terminales desatendidas está el modo kiosko, que deja al lector fijo en la pantalla de pago:
await (pagos as ClipTerminalPayments).setKioskMode(['PP35542235000286'], { enabled: true, timeoutSeconds: 30 });
await (pagos as ClipTerminalPayments).cancelByTerminal('PP35542235000286'); // si quedó algo colgadoConexiones de terminal: todo lo que rodea a los adaptadores (TerminalConnections)
Los adaptadores cobran; conectar a cada vendedor, guardar sus llaves, elegir su terminal, esperar el aviso y
devolver el dinero es lo que una app termina escribiendo a mano (~1.500 líneas). TerminalConnections lo trae
hecho para un sistema multi-cliente (tienda, sucursal, empresa): vos ponés dónde se guardan las filas y
la llave de cifrado; el resto es configuración.
import {
TerminalConnections, CredentialsCipher, MercadoPagoConnect,
type TerminalConnectionStore,
} from '@miguelmorales13/nestkit/payments';
// 1. Dónde viven las conexiones — cualquier tabla (el contrato está en el tipo).
class MiAlmacen implements TerminalConnectionStore { /* list, find, insert, update, delete, setPrimary,
findByWebhookToken, listActiveByProvider */ }
// 2. Armarlo una vez.
const terminales = new TerminalConnections({
store: new MiAlmacen(),
cipher: CredentialsCipher.fromHex(process.env.APP_ENCRYPTION_KEY), // null → la función se apaga sola
fetchImpl: (proveedor, tenantId) => miFetchConReintentos(proveedor, tenantId),
mercadoPagoConnect: () => new MercadoPagoConnect({ clientId, clientSecret, redirectUri }),
mercadoPagoWebhookSecret: process.env.MERCADOPAGO_WEBHOOK_SECRET,
stateSecret: process.env.JWT_ACCESS_SECRET,
publicWebhookUrl: (token) => `https://api.tuapp.com/terminal/clip/webhook/${token}`,
describeScope: async (tenantId) => ({ name: (await tiendas.get(tenantId)).nombre }),
onPaymentChanged: (tenantId, pagoId, cobro) => sse.publicar(tenantId, 'cobro', pagoId, cobro),
});
// 3. Usarlo.
await terminales.connectClip(tenantId, { apiKey, secretKey }); // verifica las llaves
const { url, csrf } = terminales.mercadoPagoAuthorizationUrl(tenantId, userId); // OAuth: csrf → cookie httpOnly
await terminales.completeMercadoPagoOAuth(code, state, cookie); // en el callback
await terminales.chooseTerminal(tenantId, id, terminalId); // MP: crea Sucursal/Caja y pasa a PDV
const cobro = await terminales.charge(tenantId, id, 12_500, ventaId); // mínimo por proveedor, idempotente
await terminales.handleClipNotice(token, body.id); // webhooks: nunca lanzan, siempre 200
await terminales.handleMercadoPagoNotice({ dataIdQuery, xSignature, xRequestId, body });
await terminales.refundMercadoPago(tenantId, { orderId, amountCents, key, providerRefundId: null, claimed: [] });Qué resuelve por vos, y por qué cada cosa existe:
- Secretos cifrados (AES-256-GCM,
CredentialsCipher); la API nunca devuelve una llave (ConnectionViewno las tiene). Sin llave de cifrado,available === falsey nada intenta cobrar. - Renovación del token de Mercado Pago 5 min antes de caducar, guardando el
refresh_tokennuevo (rota). - Aprovisionamiento: reutiliza la Sucursal/Caja que la terminal ya trae antes de crear otra, y rechaza con
MERCADOPAGO_TERMINAL_NO_SOPORTADAuna Point Air (solo Smart 1 y 2 operan por API). Si algo falla no bloquea la elección. - Estados que el adaptador no entiende (
processing,partially_refunded, unstatusnuevo de Clip) se resuelven conresolveTerminalStatusen vez de dejar la caja en «Esperando…» con el cobro ya hecho. - Avisos: el webhook solo dispara; se consulta al proveedor con las llaves del cliente y
onPaymentChangedcorre una vez por cambio de estado. Mercado Pago: firmax-signature(verifyMercadoPagoSignature) y eluser_idubica a los clientes; Clip: token en la URL, en tiempo constante. - Reembolsos de Mercado Pago (API de órdenes): llave de idempotencia estable por reembolso (24 h), parcial vs
total, errores oficiales clasificados en definitivos (con texto claro) y transitorios (quedan
pending). Nunca lanza por el proveedor y nunca da por hecho un reembolso que la orden no muestra. - Checkout en línea (
OnlineCheckoutClient+checkoutCredentials): enlace de Checkout Pro / Clip Checkout con las llaves del vendedor, y consulta del estado leyéndolo siempre del proveedor.
Errores con código estable: TERMINAL_NO_DISPONIBLE, TERMINAL_NO_CONECTADA, TERMINAL_NO_ENCONTRADA,
TERMINAL_SIN_ELEGIR, TERMINAL_CREDENCIALES, MONTO_MINIMO_TERMINAL, MERCADOPAGO_NO_CONFIGURADO,
MERCADOPAGO_STATE_INVALIDO, MERCADOPAGO_TERMINAL_NO_SOPORTADA, PAGO_SIN_PROVEEDOR, PAGO_PROVEEDOR_ERROR.
Los textos que lee una persona traen un valor en español y se pueden reemplazar (messages, refundMessages,
OnlineCheckoutClient({ messages })) para traducirlos con t().
Es una clase sin dependencia de Nest: envolvela en tu propio servicio para el plan, la auditoría y los eventos de tu
dominio. El formato de las credenciales guardadas (MercadoPagoCredentials: accessToken, refreshToken,
mpUserId, expira, mpStoreId, mpPosId) es el persistido: no lo renombres si ya tenés filas.
Cobro con Openpay: el comercio inicia sesión y listo (OpenpayConnections)
Para que cada comercio cobre en su propia cuenta de Openpay (BBVA) sin copiar llaves: el comercio toca «Conectar»,
Openpay le deja elegir «usar mi cuenta» (inicia sesión y autoriza) o «cuenta nueva» (se registra ahí mismo) y vuelve a tu
app ya conectado. Es el esquema de partners con OAuth 2 de Openpay: recibís el merchant_id y las llaves del comercio,
y cobrás a su nombre; el dinero cae en su cuenta, nunca pasa por la tuya.
import { OpenpayConnections, CredentialsCipher } from '@miguelmorales13/nestkit/payments';
const openpay = new OpenpayConnections({
store, // tu tabla: find / upsert / update / delete / findByWebhookToken
cipher: () => CredentialsCipher.fromHex(process.env.APP_ENCRYPTION_KEY),
partner: () => ({ clientId, clientSecret, redirectUri }), // el correo que manda Openpay al registrarte como integrador
sandbox: () => process.env.OPENPAY_SANDBOX === 'true',
stateSecret: () => process.env.JWT_SECRET,
publicWebhookUrl: (token) => `https://api.tuapp.mx/api/v1/openpay/avisos/${token}`,
fetchImpl: () => fetchResiliente, // tiempos límite / reintentos / alertas
});
openpay.authorizationUrl(tiendaId, { returnTo: '/ajustes' }); // → redirige al comercio
await openpay.complete({ code, state, error }); // callback: guarda llaves cifradas + registra el webhook
const p = await openpay.createPayment(tiendaId, {
method: 'transfer', // o 'card'
reference: pedido.id, description: 'Pedido 123', amountCents: 25_000,
returnUrl, // solo tarjeta
});
// card → p.url es el formulario de Openpay (se manda por WhatsApp, correo, QR…)
// transfer → p.transfer = { clabe, reference (20 dígitos), bank, agreement, dueDate }; p.url = recibo imprimible
await openpay.lookup(tiendaId, p.chargeId); // estado leído de Openpay: paid | pending | failed | refunded
await openpay.refund(tiendaId, chargeId, centavos?); // solo tarjeta (Openpay no devuelve SPEI)
await openpay.handleNotice(token, req.headers.authorization, req.body); // webhook: nunca le cree al cuerpo, relee el cargoCosas que conviene saber:
- La transferencia es SPEI con CLABE + referencia de 20 dígitos; la referencia va en el concepto y es lo que Openpay usa para saber de qué cobro es. Vence a los días que pongas (máximo 30) y Openpay expira en días hábiles.
- El webhook lo registra la librería con la llave del comercio y autenticación Basic (usuario = token de la URL,
contraseña = una por comercio). Si no se pudo registrar, la conexión queda y
lookupsigue diciendo la verdad;ensureNotices(tiendaId)lo reintenta. handleNoticeresponde siempre algo que debes contestar con 200; solo lanza ante una contraseña mala. Un abono de saldo del propio comercio (spei.receivedsin pedido) se ignora.- Las llaves del comercio van cifradas con
CredentialsCipher; el sandbox y producción no se mezclan (cada conexión recuerda de cuál es). - Las cifras y flujos salen de la documentación pública de Openpay; el registro como partner y el paso a producción son trámites tuyos con Openpay.
Agregar un tercer proveedor
Una clase que implemente TerminalPaymentsPort, un case en createTerminalPaymentsAdapter, su
bloque en PaymentsOptions. Nada más: ni los servicios que cobran ni el controller de webhooks se
enteran.
invoicing — facturación CFDI 4.0, con el PAC como configuración
Timbrar, cancelar y descargar facturas detrás de un solo puerto, InvoicingPort, con un
adaptador por PAC: Facturapi y Facturama. Cambiar de PAC es una línea, que acá importa más
que en otros módulos: los PAC se caen, suben precios y hasta pierden la autorización del SAT, y nada
de eso debería llegar al código que vende.
Es la continuación de payments: cobrás con la terminal y facturás con esto. Tampoco suma
dependencias — los dos adaptadores son fetch.
InvoicingModule.forRoot({ provider: 'facturapi' }) // lee FACTURAPI_API_KEY
InvoicingModule.forRoot({ provider: 'facturama' }) // lee FACTURAMA_* (sandbox por defecto)import { Inject, Injectable } from '@nestjs/common';
import { INVOICING, type InvoicingPort } from '@miguelmorales13/nestkit/invoicing';
@Injectable()
export class FacturasService {
constructor(@Inject(INVOICING) private readonly facturacion: InvoicingPort) {}
async facturar(ordenId: string) {
const factura = await this.facturacion.issue({
customer: {
taxId: 'URE180429TM6',
legalName: 'UNIVERSIDAD ROBOTICA ESPAÑOLA', // tal como la tiene el SAT
taxSystem: '601', // régimen fiscal
zip: '65000', // CP fiscal del receptor
use: 'G03',
},
items: [
{
description: 'Consultoría',
productKey: '84111506', // ClaveProdServ del catálogo del SAT
unitPriceCents: 100000, // $1,000.00
// sin `taxes` = IVA 16% trasladado
},
],
paymentForm: '03', // transferencia
paymentMethod: 'PUE',
externalId: ordenId,
});
return factura.uuid; // folio fiscal
}
}await facturacion.get(id);
await facturacion.cancel(id, { motive: '02' }); // error sin reemplazo
await facturacion.cancel(id, { motive: '01', substitutionId: otro }); // error con reemplazo
await facturacion.pdf(id); // bytes
await facturacion.xml(id); // el documento que importa
await facturacion.sendByEmail(id);