DEV Community

Roberto Luna
Roberto Luna

Posted on

Integrating Conekta Sandbox Payments for Condo‑Fee Quotas in a NestJS Monorepo

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)
Enter fullscreen mode Exit fullscreen mode

The root cause was two‑fold:

  1. Hard‑coded sandbox keys in the controller made the code brittle when the environment switched to production.
  2. No abstraction – the controller called axios directly, 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;
}
Enter fullscreen mode Exit fullscreen mode

What went wrong

  • The environment variable was missing in the CI container, so the request always hit the sandbox with an empty token.
  • Because axios was imported directly, Jest could not replace it without fiddling with jest.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
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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',
      );
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)