Backend Architecture Series — Jocoso.cl eCommerce · #04
Payment State Machine: Cómo modelar pagos que no mienten
FSM en el dominio, transacciones atómicas y auditoría completa con PaymentEventLog
Los pagos son la parte más crítica
Un bug en el módulo de stock puede generar un pedido sin stock. Un bug en pagos puede cobrar dos veces, no cobrar nunca, o confirmar un pedido con pago rechazado. Los pagos son dinero real — cada línea de código tiene consecuencias directas para el negocio y para la confianza del cliente.
■ Decisión Técnica
Modelar los pagos como una Finite State Machine (FSM) en el dominio. Cualquier transición inválida lanza una excepción en dominio antes de tocar la BD. Cada cambio de estado queda registrado en
PaymentEventLogconfromStatus,toStatus,triggeredByypayload.
Los estados y transiciones
La FSM define explícitamente qué transiciones son legales. No hay if/else dispersos por el código — hay una tabla de transiciones:
// domain/payments/entities/payment.entity.ts
export enum PaymentStatus {
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
CANCELLED = 'CANCELLED',
SETTLED = 'SETTLED',
}
const TRANSITIONS: Partial<Record<PaymentStatus, PaymentStatus[]>> = {
[PaymentStatus.PENDING]: [APPROVED, REJECTED, CANCELLED],
[PaymentStatus.APPROVED]: [SETTLED],
};
private transition(next: PaymentStatus): void {
const allowed = TRANSITIONS[this.status] ?? [];
if (!allowed.includes(next)) {
throw new Error(`Transición inválida: ${this.status} -> ${next}`);
}
this.status = next;
this.touch();
}PaymentEventLog — auditoría inmutable
Cada transición genera un registro de auditoría. El log es append-only: nunca se modifica ni elimina. Esto permite reconstruir el historial completo de cualquier pago para disputas, debugging y reportes regulatorios:
// domain/payments/entities/payment-event-log.entity.ts
export class PaymentEventLog {
private constructor(private props: PaymentEventLogProps) {}
static create(
paymentId: string,
fromStatus: PaymentStatus,
toStatus: PaymentStatus,
triggeredBy: string, // 'webhook' | 'user:uuid' | 'system'
payload?: object, // raw webhook response
): PaymentEventLog {
return new PaymentEventLog({
id: crypto.randomUUID(),
paymentId, fromStatus, toStatus, triggeredBy,
payload: payload ? JSON.stringify(payload) : null,
});
}
}La operación atómica en infraestructura
Actualizar el estado del pago e insertar el evento de auditoría ocurre en una sola transacción de base de datos. Si cualquiera falla, ambas operaciones se revierten — nunca queda el estado actualizado sin el log, ni el log sin el estado:
// infrastructure/payments/payment.prisma-repo.ts
async update(payment: Payment, eventLog: PaymentEventLog): Promise<void> {
const d = payment.toPersistence();
const log = eventLog.toPersistence();
await this.prisma.$transaction([
this.prisma.payment.update({
where: { id: d.id },
data: { status: d.status as any, gatewayId: d.gatewayId },
}),
this.prisma.paymentEventLog.create({
data: {
id: log.id, paymentId: log.paymentId,
fromStatus: log.fromStatus as any,
toStatus: log.toStatus as any,
triggeredBy: log.triggeredBy,
payload: log.payload ?? undefined,
},
}),
]);
}Webhook handling e idempotencia
MercadoPago envía webhooks con el resultado del pago. Los webhooks pueden llegar duplicados — el mismo pago puede notificarse dos o tres veces. El HandleWebhookUseCase maneja esto: si el pago ya tiene el estado final, la transición falla en dominio (transición inválida) y el endpoint responde 200 OK sin procesar dos veces:
// application/payments/use-cases/handle-webhook.usecase.ts
async execute(dto: WebhookPayload): Promise<void> {
const payment = await this.paymentRepo.findById(dto.paymentId);
if (!payment) throw new NotFoundException();
if (dto.status === 'approved') {
await this.approve.execute(payment.getId(), dto.gatewayId, dto.raw);
} else if (dto.status === 'rejected') {
await this.reject.execute(payment.getId(), dto.gatewayId, dto.raw);
}
}La cascada: pago aprobado dispara el negocio
Cuando un pago se aprueba, el ApprovePaymentUseCase actualiza el estado del pago y confirma la orden asociada. La confirmación de la orden es el disparador para el decremento de stock. Esta cascada garantiza que el stock solo se decrementa cuando hay dinero real confirmado.
- Webhook aprobado →
ApprovePaymentUseCase ApprovePaymentUseCase→ConfirmOrderUseCaseConfirmOrderUseCase→DecreaseStockUseCase(FOR UPDATE)
■ Trade-offs
Event Sourcing completo vs PaymentEventLog (event sourcing light). Event sourcing completo requiere reconstruir estado desde eventos — poderoso pero complejo. PaymentEventLog es un compromiso: guardamos los eventos como auditoría pero mantenemos el estado actual en el registro principal. Cubre el 95% de los casos de uso (auditoría, debugging, disputas) con 30% de la complejidad de event sourcing completo.