Integrating Conekta Sandbox Payments for Condo‑Fee Quotas in a NestJS Monorepo
TL;DR: I added a full Conekta sandbox payment flow to our NestJS monorepo and locked it down with 109 unit tests (apps/api/src/__tests__/conekta.test.ts). The new ConektaService abstracts the API, handles sandbox keys, and makes the payment endpoint stable for production.
The Problem
Our condo‑management app needed a way for residents to pay their monthly quotas online. We chose Conekta because it supports Mexican cards and offers a sandbox environment. The first attempt to call Conekta’s /charges endpoint threw Invalid token errors during CI runs, and the coverage report dropped because the payment code was untestable.
Symptoms:
Error: Request failed with status code 401
at AxiosError (node_modules/axios/lib/core/AxiosError.js:151:13)
at createError (node_modules/axios/lib/core/createError.js:16:5)
at settle (node_modules/axios/lib/core/settle.js:17:12)
at IncomingMessage.handleStreamEnd (node_modules/axios/lib/adapters/http.js:438:11)
The root cause was two‑fold:
- Hard‑coded sandbox keys in the controller made the code brittle when the environment switched to production.
-
No abstraction – the controller called
axiosdirectly, so the test suite could not mock the HTTP layer cleanly, resulting in flaky tests and low coverage.
What I Tried First
I started by adding a simple POST /payments/conekta route in apps/api/src/payments/payments.controller.ts that called axios.post with the sandbox key pulled from process.env.CONEKTA_PRIVATE_KEY.
// apps/api/src/payments/payments.controller.ts (first attempt)
@Post('conekta')
async createCharge(@Body() dto: CreateChargeDto) {
const response = await axios.post(
'https://sandbox.api.conekta.io/charges',
{
amount: dto.amount,
currency: 'MXN',
description: dto.description,
source_id: dto.sourceId,
},
{
headers: {
Authorization: `Bearer ${process.env.CONEKTA_PRIVATE_KEY}`,
},
},
);
return response.data;
}
What went wrong
- The environment variable was missing in the CI container, so the request always hit the sandbox with an empty token.
- Because
axioswas imported directly, Jest could not replace it without fiddling withjest.mock('axios')in every test file. - The controller returned the raw Conekta response, leaking internal fields to the client.
Result: the test suite crashed on import, and the coverage for payments.controller.ts stayed at 71 %, below our 80 % threshold.
The Implementation
1. Create a dedicated Conekta module
apps/api/
├─ conekta/
│ ├─ conekta.module.ts
│ ├─ conekta.service.ts
│ ├─ conekta.controller.ts
│ └─ dto/
│ └─ create-charge.dto.ts
conekta.module.ts
// apps/api/src/conekta/conekta.module.ts
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ConektaService } from './conekta.service';
import { ConektaController } from './conekta.controller';
@Module({
imports: [
HttpModule,
ConfigModule,
],
providers: [ConektaService],
controllers: [ConektaController],
exports: [ConektaService],
})
export class ConektaModule {}
Why? Using Nest’s HttpModule gives us an injectable HttpService that can be mocked with HttpModule.forRoot({}) in tests. The ConfigModule centralizes env handling.
create-charge.dto.ts
// apps/api/src/conekta/dto/create-charge.dto.ts
import { IsString, IsNumber, IsNotEmpty } from 'class-validator';
export class CreateChargeDto {
@IsNumber()
amount: number; // in cents
@IsString()
@IsNotEmpty()
sourceId: string; // token from Conekta.js
@IsString()
@IsNotEmpty()
description: string;
}
conekta.service.ts
// apps/api/src/conekta/conekta.service.ts
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';
import { CreateChargeDto } from './dto/create-charge.dto';
@Injectable()
export class ConektaService {
private readonly baseUrl: string;
private readonly privateKey: string;
constructor(
private readonly http: HttpService,
private readonly config: ConfigService,
) {
const env = this.config.get<string>('NODE_ENV') ?? 'development';
this.baseUrl = env === 'production'
? 'https://api.conekta.io'
: 'https://sandbox.api.conekta.io';
this.privateKey = this.config.get<string>('CONEKTA_PRIVATE_KEY');
if (!this.privateKey) {
throw new InternalServerErrorException('Conekta private key missing');
}
}
async createCharge(dto: CreateChargeDto) {
const payload = {
amount: dto.amount,
currency: 'MXN',
description: dto.description,
source_id: dto.sourceId,
};
try {
const { data } = await firstValueFrom(
this.http.post(`${this.baseUrl}/charges`, payload, {
headers: {
Authorization: `Bearer ${this.privateKey}`,
'Content-Type': 'application/json',
},
}),
);
return data;
} catch (err) {
// Surface a clean error for the controller
throw new InternalServerErrorException(
err?.response?.data?.message ?? 'Conekta request failed',
);
}
}
}
Key points:
- Environment‑aware base URL – automatically switches to sandbox in dev.
- Early failure – throws if the private key is missing, so CI catches config errors
Part of my Build in Public series — sharing the real process of building SaaS projects from Playa del Carmen, México.
Repo: zaerohell/content-automation · 2026-09-16
#playadev #buildinpublic
Top comments (0)