Capítulo 9: Payment Service
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:
- Integración con gateway externo: Simula la conexión a un procesador de pagos real.
- Idempotencia crítica: Un pago duplicado significa cobrar dos veces al cliente.
- Compensación = Reembolso: Deshacer un pago significa devolver el dinero.
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:
- Latencia de red: Los pagos no son instantáneos.
- Fallos aleatorios: Timeouts, tarjetas rechazadas, fondos insuficientes.
- Idempotencia: El mismo
idempotencyKeydevuelve el mismo resultado sin procesar de nuevo.
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
- Dominio Payment con máquina de estados clara
- Gateway simulado con fallos realistas
- Idempotencia en procesamiento y reembolso
- Eventos PaymentProcessed y PaymentFailed
- Separación entre lógica de negocio y gateway externo
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 →