npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/nestkit

nestkit 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 ValidationPipe

meta, 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 BaseResponseDto

SoftDeleteEntity 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 empezar
constructor(@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 BaseCrudService como provider/token (en vez de un string token) para que createCrudController infiera los tipos sin castear. createCrudController es composición opcional: si prefieres tu propio controller, simplemente consume BaseCrudService/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:

  • I18nInterceptor se registra global. Resuelve el idioma con las fuentes de locale, lo deja en RequestContext.getLang() y recorre lo que devuelve el handler reemplazando cada t() — en objetos, arreglos, dentro de una página paginated(). No entra a instancias de clase (fechas, entidades de ORM, streams): las deja intactas.
  • GlobalExceptionFilter traduce el message y los details de cualquier AppException. Y si la excepción se lanzó con un string plano, busca su code en el namespace errors (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ún throw. Se apaga con errorCodes: false, o se mueve con errorCodes: 'api.errors'.
  • Los consumidores que inyectan I18nService de nestjs-i18n directo 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 diagnosticar

Dos 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_COMPLETE

Cuidado 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.ts tiene que crear la app con NestFactory.create(AppModule, { rawBody: true }). La verificación de firma de Stripe necesita los bytes crudos del request tal cual llegaron — applyNestKitDefaults corre después de crear la app y no puede habilitar rawBody en 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 parte

Los 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

  1. 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.
  2. 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.
  3. 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.
  4. 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 eso listTerminals() devuelve operatingMode: un cobro que nunca aparece en una terminal que funciona es casi siempre esto.
  • reference (el external_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_terminal la documentación publica un solo valor, no_ticket, y ningún otro. Así que printReceipt: false manda ese, y printReceipt: true no manda nada: decide la configuración de la terminal. Si tenés un valor que funciona en tu cuenta, pasalo crudo como printOnTerminal en 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 son ORD01J6…, 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 propio storeId/posId — y aun así se queda en standalone para 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 un 400 seco, "'$.terminals[0].id' - does not match pattern", que no menciona el modelo y parece un id mal formado. listTerminals() también marca cada terminal con integrable, 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() y createPos() 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 propio storeId/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 aparato

Mercado 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_SECRET es un valor al azar que ponés en la webhookUrl que registrás y que verifyWebhook exige de vuelta (como query token o cabecera x-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, en api.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 colgado

Conexiones 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 (ConnectionView no las tiene). Sin llave de cifrado, available === false y nada intenta cobrar.
  • Renovación del token de Mercado Pago 5 min antes de caducar, guardando el refresh_token nuevo (rota).
  • Aprovisionamiento: reutiliza la Sucursal/Caja que la terminal ya trae antes de crear otra, y rechaza con MERCADOPAGO_TERMINAL_NO_SOPORTADA una 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, un status nuevo de Clip) se resuelven con resolveTerminalStatus en 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 onPaymentChanged corre una vez por cambio de estado. Mercado Pago: firma x-signature (verifyMercadoPagoSignature) y el user_id ubica 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 cargo

Cosas 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 lookup sigue diciendo la verdad; ensureNotices(tiendaId) lo reintenta.
  • handleNotice responde siempre algo que debes contestar con 200; solo lanza ante una contraseña mala. Un abono de saldo del propio comercio (spei.received sin 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);

Lo que el módulo ca