← Volver al listado de tecnologías

Capítulo 9: Payment Service

Por: SiempreListo
sagapayment-servicetypescriptmicroservicios

Capítulo 9: Payment Service

“Donde el dinero cambia de manos”

Introducción

El Payment Service gestiona el procesamiento de pagos. Es uno de los servicios más críticos porque involucra dinero real y debe integrarse con sistemas externos (pasarelas de pago).

Características clave de este servicio:

Estructura del Servicio

packages/payment-service/
├── src/
│   ├── domain/
│   │   └── payment.ts
│   ├── repository/
│   │   └── payment-repository.ts
│   ├── service/
│   │   └── payment-service.ts
│   ├── gateway/
│   │   └── payment-gateway.ts
│   ├── api/
│   │   └── routes.ts
│   └── index.ts
└── package.json

Dominio

packages/payment-service/src/domain/payment.ts

export type PaymentStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'refunded';

export class Payment {
  constructor(
    public readonly id: string,
    public readonly orderId: string,
    public readonly customerId: string,
    public readonly amount: number,
    public status: PaymentStatus,
    public transactionId: string | null,
    public readonly createdAt: Date,
    public processedAt: Date | null,
    public refundedAt: Date | null
  ) {}

  static create(orderId: string, customerId: string, amount: number): Payment {
    return new Payment(
      crypto.randomUUID(),
      orderId,
      customerId,
      amount,
      'pending',
      null,
      new Date(),
      null,
      null
    );
  }

  startProcessing(): void {
    if (this.status !== 'pending') {
      throw new Error(`Cannot process payment in status ${this.status}`);
    }
    this.status = 'processing';
  }

  complete(transactionId: string): void {
    if (this.status !== 'processing') {
      throw new Error(`Cannot complete payment in status ${this.status}`);
    }
    this.status = 'completed';
    this.transactionId = transactionId;
    this.processedAt = new Date();
  }

  fail(): void {
    if (this.status !== 'processing') {
      throw new Error(`Cannot fail payment in status ${this.status}`);
    }
    this.status = 'failed';
  }

  refund(): void {
    if (this.status !== 'completed') {
      throw new Error(`Cannot refund payment in status ${this.status}`);
    }
    this.status = 'refunded';
    this.refundedAt = new Date();
  }

  canBeRefunded(): boolean {
    return this.status === 'completed';
  }
}

export class PaymentError extends Error {
  constructor(
    message: string,
    public readonly code: string,
    public readonly retryable: boolean
  ) {
    super(message);
    this.name = 'PaymentError';
  }
}

Gateway (Simulado)

El Payment Gateway es una abstracción del sistema externo de procesamiento de pagos. En producción, esto sería Stripe, PayPal, MercadoPago, etc.

El gateway simulado incluye comportamientos realistas:

packages/payment-service/src/gateway/payment-gateway.ts

import { PaymentError } from '../domain/payment.js';

export interface ChargeResult {
  transactionId: string;
  status: 'success' | 'failed';
}

export interface PaymentGateway {
  charge(customerId: string, amount: number, idempotencyKey: string): Promise<ChargeResult>;
  refund(transactionId: string, amount: number): Promise<void>;
}

export class MockPaymentGateway implements PaymentGateway {
  private processedKeys = new Set<string>();

  async charge(customerId: string, amount: number, idempotencyKey: string): Promise<ChargeResult> {
    // Simular idempotencia
    if (this.processedKeys.has(idempotencyKey)) {
      return { transactionId: `txn_${idempotencyKey}`, status: 'success' };
    }

    // Simular latencia de red
    await this.delay(100 + Math.random() * 200);

    // Simular fallos aleatorios (10% de probabilidad)
    if (Math.random() < 0.1) {
      throw new PaymentError('Gateway timeout', 'GATEWAY_TIMEOUT', true);
    }

    // Simular rechazo de tarjeta (5% de probabilidad)
    if (Math.random() < 0.05) {
      throw new PaymentError('Card declined', 'CARD_DECLINED', false);
    }

    // Simular fondos insuficientes si el monto es muy alto
    if (amount > 10000) {
      throw new PaymentError('Insufficient funds', 'INSUFFICIENT_FUNDS', false);
    }

    this.processedKeys.add(idempotencyKey);

    return {
      transactionId: `txn_${crypto.randomUUID()}`,
      status: 'success'
    };
  }

  async refund(transactionId: string, amount: number): Promise<void> {
    await this.delay(50 + Math.random() * 100);

    // Simular fallo de reembolso (5% de probabilidad)
    if (Math.random() < 0.05) {
      throw new PaymentError('Refund failed', 'REFUND_FAILED', true);
    }
  }

  private delay(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

Repositorio

packages/payment-service/src/repository/payment-repository.ts

import postgres from 'postgres';
import { Payment, type PaymentStatus } from '../domain/payment.js';

export class PaymentRepository {
  constructor(private sql: postgres.Sql) {}

  async save(payment: Payment): Promise<void> {
    await this.sql`
      INSERT INTO payments (
        id, order_id, customer_id, amount, status,
        transaction_id, created_at, processed_at, refunded_at
      )
      VALUES (
        ${payment.id}, ${payment.orderId}, ${payment.customerId},
        ${payment.amount}, ${payment.status}, ${payment.transactionId},
        ${payment.createdAt}, ${payment.processedAt}, ${payment.refundedAt}
      )
      ON CONFLICT (id) DO UPDATE SET
        status = ${payment.status},
        transaction_id = ${payment.transactionId},
        processed_at = ${payment.processedAt},
        refunded_at = ${payment.refundedAt}
    `;
  }

  async findById(id: string): Promise<Payment | null> {
    const [row] = await this.sql`
      SELECT id, order_id, customer_id, amount, status,
             transaction_id, created_at, processed_at, refunded_at
      FROM payments WHERE id = ${id}
    `;
    return row ? this.mapToPayment(row) : null;
  }

  async findByOrderId(orderId: string): Promise<Payment | null> {
    const [row] = await this.sql`
      SELECT id, order_id, customer_id, amount, status,
             transaction_id, created_at, processed_at, refunded_at
      FROM payments WHERE order_id = ${orderId}
      ORDER BY created_at DESC LIMIT 1
    `;
    return row ? this.mapToPayment(row) : null;
  }

  async updateStatus(id: string, status: PaymentStatus, transactionId?: string): Promise<void> {
    await this.sql`
      UPDATE payments
      SET status = ${status},
          transaction_id = COALESCE(${transactionId ?? null}, transaction_id),
          processed_at = CASE WHEN ${status} = 'completed' THEN NOW() ELSE processed_at END,
          refunded_at = CASE WHEN ${status} = 'refunded' THEN NOW() ELSE refunded_at END
      WHERE id = ${id}
    `;
  }

  private mapToPayment(row: Record<string, unknown>): Payment {
    return new Payment(
      row.id as string,
      row.order_id as string,
      row.customer_id as string,
      Number(row.amount),
      row.status as PaymentStatus,
      row.transaction_id as string | null,
      row.created_at as Date,
      row.processed_at as Date | null,
      row.refunded_at as Date | null
    );
  }
}

Servicio

packages/payment-service/src/service/payment-service.ts

import { Payment, PaymentError } from '../domain/payment.js';
import { PaymentRepository } from '../repository/payment-repository.js';
import type { PaymentGateway } from '../gateway/payment-gateway.js';
import type { PaymentProcessedEvent, PaymentFailedEvent } from '@orderflow/shared';

interface ProcessPaymentInput {
  orderId: string;
  customerId: string;
  amount: number;
}

export class PaymentService {
  constructor(
    private repository: PaymentRepository,
    private gateway: PaymentGateway,
    private publishEvent: (event: PaymentProcessedEvent | PaymentFailedEvent) => Promise<void>
  ) {}

  async processPayment(input: ProcessPaymentInput): Promise<{ paymentId: string }> {
    // Verificar pago existente (idempotencia)
    const existing = await this.repository.findByOrderId(input.orderId);
    if (existing?.status === 'completed') {
      return { paymentId: existing.id };
    }

    const payment = existing || Payment.create(input.orderId, input.customerId, input.amount);

    try {
      payment.startProcessing();
      await this.repository.save(payment);

      const result = await this.gateway.charge(
        input.customerId,
        input.amount,
        `order_${input.orderId}`
      );

      payment.complete(result.transactionId);
      await this.repository.save(payment);

      await this.publishEvent({
        id: crypto.randomUUID(),
        type: 'PaymentProcessed',
        timestamp: new Date(),
        correlationId: input.orderId,
        payload: {
          orderId: input.orderId,
          paymentId: payment.id,
          amount: input.amount
        }
      });

      return { paymentId: payment.id };
    } catch (error) {
      payment.fail();
      await this.repository.save(payment);

      const paymentError = error as PaymentError;
      await this.publishEvent({
        id: crypto.randomUUID(),
        type: 'PaymentFailed',
        timestamp: new Date(),
        correlationId: input.orderId,
        payload: {
          orderId: input.orderId,
          reason: paymentError.message
        }
      });

      throw error;
    }
  }

  async refundPayment(orderId: string): Promise<void> {
    const payment = await this.repository.findByOrderId(orderId);
    if (!payment) {
      return; // Idempotente: no hay pago que reembolsar
    }

    if (!payment.canBeRefunded()) {
      return; // Idempotente: ya fue reembolsado o nunca se completó
    }

    await this.gateway.refund(payment.transactionId!, payment.amount);
    payment.refund();
    await this.repository.save(payment);
  }

  async getPayment(paymentId: string): Promise<Payment | null> {
    return this.repository.findById(paymentId);
  }

  async getPaymentByOrder(orderId: string): Promise<Payment | null> {
    return this.repository.findByOrderId(orderId);
  }
}

API

packages/payment-service/src/api/routes.ts

import { Hono } from 'hono';
import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';
import { PaymentService } from '../service/payment-service.js';

const ProcessPaymentSchema = z.object({
  orderId: z.string().uuid(),
  customerId: z.string().uuid(),
  amount: z.number().positive()
});

export function createRoutes(paymentService: PaymentService): Hono {
  const app = new Hono();

  app.post('/payments', zValidator('json', ProcessPaymentSchema), async (c) => {
    const input = c.req.valid('json');
    try {
      const result = await paymentService.processPayment(input);
      return c.json(result, 201);
    } catch (error) {
      const err = error as Error & { code?: string; retryable?: boolean };
      return c.json({
        error: err.message,
        code: err.code || 'UNKNOWN',
        retryable: err.retryable ?? false
      }, 400);
    }
  });

  app.post('/payments/:orderId/refund', async (c) => {
    await paymentService.refundPayment(c.req.param('orderId'));
    return c.json({ success: true });
  });

  app.get('/payments/:paymentId', async (c) => {
    const payment = await paymentService.getPayment(c.req.param('paymentId'));
    if (!payment) {
      return c.json({ error: 'Payment not found' }, 404);
    }
    return c.json(payment);
  });

  app.get('/payments/order/:orderId', async (c) => {
    const payment = await paymentService.getPaymentByOrder(c.req.param('orderId'));
    if (!payment) {
      return c.json({ error: 'Payment not found' }, 404);
    }
    return c.json(payment);
  });

  app.get('/health', (c) => c.json({ status: 'healthy' }));

  return app;
}

Entry Point

packages/payment-service/src/index.ts

import { serve } from '@hono/node-server';
import postgres from 'postgres';
import { PaymentRepository } from './repository/payment-repository.js';
import { PaymentService } from './service/payment-service.js';
import { MockPaymentGateway } from './gateway/payment-gateway.js';
import { createRoutes } from './api/routes.js';

const DATABASE_URL = process.env.DATABASE_URL || 'postgres://orderflow:orderflow@localhost:5432/orderflow';
const PORT = parseInt(process.env.PORT || '3000');
const ORCHESTRATOR_URL = process.env.ORCHESTRATOR_URL || 'http://localhost:3000';

const sql = postgres(DATABASE_URL);
const repository = new PaymentRepository(sql);
const gateway = new MockPaymentGateway();

const publishEvent = async (event: unknown) => {
  await fetch(`${ORCHESTRATOR_URL}/events`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(event)
  });
};

const paymentService = new PaymentService(repository, gateway, publishEvent);
const app = createRoutes(paymentService);

console.log(`Payment Service running on port ${PORT}`);
serve({ fetch: app.fetch, port: PORT });

Resumen

Glosario

Payment Gateway

Definición: Servicio externo que procesa transacciones de pago, comunicándose con bancos y redes de tarjetas para autorizar y capturar pagos.

Por qué es importante: Abstrae la complejidad de interactuar con múltiples bancos y redes de pago. El servicio solo necesita llamar al gateway, no entender los protocolos bancarios.

Ejemplo práctico: Stripe, PayPal, MercadoPago son payment gateways. Tu servicio envía los datos de pago al gateway, el gateway se comunica con el banco, y te devuelve si fue aprobado o rechazado.


Idempotency Key (en Pagos)

Definición: Identificador único que se envía con cada solicitud de pago para garantizar que el mismo pago no se procese dos veces.

Por qué es importante: Si la conexión se corta después de que el gateway procesó el pago pero antes de recibir la respuesta, un reintento sin idempotency key cobraría dos veces al cliente.

Ejemplo práctico: Usamos order_${orderId} como idempotency key. Si el gateway ya procesó un pago con esa clave, devuelve el resultado anterior sin volver a cobrar.


Transaction ID

Definición: Identificador único generado por el payment gateway para cada transacción procesada, usado para referencia y para operaciones posteriores como reembolsos.

Por qué es importante: Permite rastrear pagos en el sistema del gateway, consultar su estado, y ejecutar operaciones relacionadas (reembolsos, disputas).

Ejemplo práctico: El gateway devuelve transactionId: "txn_abc123". Guardamos este ID. Cuando necesitamos reembolsar, llamamos a gateway.refund("txn_abc123", amount).


Reembolso (Refund)

Definición: Operación que devuelve el dinero al cliente después de un pago exitoso. Es la compensación del paso de pago en la saga.

Por qué es importante: Es irreversible desde el punto de vista del cliente (ya recibió su dinero de vuelta). Debe ejecutarse solo cuando estamos seguros de que la saga falló.

Ejemplo práctico: El envío no pudo programarse después del pago. El orquestador ejecuta refundPayment(orderId). El gateway devuelve el dinero a la tarjeta del cliente.


Error Reintenatable vs No Reintenatable

Definición: Errores que tienen sentido reintentar (transitorios) vs errores que fallarán siempre con los mismos datos (permanentes).

Por qué es importante: Reintentar un error permanente desperdicia recursos y retrasa la compensación. Identificar el tipo de error permite actuar correctamente.

Ejemplo práctico: GATEWAY_TIMEOUT es reintenatable (el gateway puede recuperarse). CARD_DECLINED no lo es (la tarjeta seguirá siendo rechazada).


Estado del Pago

Definición: El estado actual de un pago en su ciclo de vida: pending, processing, completed, failed, refunded.

Por qué es importante: Los estados definen qué operaciones son válidas. Solo puedes reembolsar un pago completed. Solo puedes procesar un pago pending.

Ejemplo práctico: El pago inicia como pending. Al llamar al gateway, pasa a processing. Si el gateway aprueba, pasa a completed. Si falla, a failed. Si se reembolsa, a refunded.


Simulación de Fallos

Definición: Inyección intencional de errores en un sistema para probar su comportamiento ante fallos.

Por qué es importante: En producción, los fallos ocurren. Simularlos en desarrollo permite verificar que el sistema los maneja correctamente (reintentos, compensaciones, alertas).

Ejemplo práctico: El MockPaymentGateway falla el 10% de las veces con timeout y el 5% con tarjeta rechazada. Esto permite probar que el orquestador ejecuta compensaciones correctamente.


Verificación de Pago Existente

Definición: Antes de procesar un pago, verificar si ya existe uno para el mismo pedido y está completado.

Por qué es importante: Es una capa adicional de idempotencia a nivel de aplicación. Si ya hay un pago exitoso, devolvemos ese resultado sin llamar al gateway.

Ejemplo práctico: findByOrderId(orderId) encuentra un pago con status: 'completed'. En lugar de procesar de nuevo, retornamos { paymentId: existing.id }.


Atomicidad en Actualizaciones

Definición: Garantía de que múltiples cambios relacionados se aplican todos o ninguno, sin estados intermedios visibles.

Por qué es importante: Si actualizamos el estado del pago a completed pero fallamos al guardar el transactionId, quedaría inconsistente.

Ejemplo práctico: El método save() actualiza estado, transactionId, processedAt y refundedAt en una sola operación SQL, garantizando atomicidad.


← Capítulo 8: Inventory Service | Capítulo 10: Compensaciones →