Backend Architecture Series — Jocoso.cl eCommerce · #04

Payment State Machine: How to Model Payments That Don't Lie

FSM in the domain, atomic transactions, and complete audit trail with PaymentEventLog


Payments Are the Most Critical Part

A bug in the stock module can create an order without stock. A bug in payments can charge twice, never charge, or confirm an order with a rejected payment. Payments are real money — every line of code has direct consequences for the business and customer trust.

■ Technical Decision

Model payments as a Finite State Machine (FSM) in the domain. Any invalid transition throws a domain exception before touching the DB. Each state change is recorded in PaymentEventLog with fromStatus, toStatus, triggeredBy, and payload.


States and Transitions

The FSM explicitly defines which transitions are legal. No scattered if/else throughout the code — there is a transition table:

typescript
// 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(`Invalid transition: ${this.status} -> ${next}`);
  }
  this.status = next;
  this.touch();
}

PaymentEventLog — Immutable Audit Trail

Each transition generates an audit record. The log is append-only: never modified or deleted. This allows reconstructing the complete history of any payment for disputes, debugging, and regulatory reporting:

typescript
// 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,
    });
  }
}

The Atomic Operation in Infrastructure

Updating the payment status and inserting the audit event happens in a single database transaction. If either fails, both operations roll back — the status is never updated without the log, nor the log without the status:

typescript
// 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 and Idempotency

MercadoPago sends webhooks with the payment result. Webhooks can arrive duplicated — the same payment can be notified two or three times. The HandleWebhookUseCase handles this: if the payment already has the final status, the transition fails in the domain (invalid transition) and the endpoint responds 200 OK without processing twice:

typescript
// 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);
  }
}

The Cascade: Approved Payment Triggers the Business

When a payment is approved, ApprovePaymentUseCase updates the payment status and confirms the associated order. Order confirmation is the trigger for stock decrement. This cascade guarantees stock is only decremented when real money is confirmed.

  • Approved webhook → ApprovePaymentUseCase
  • ApprovePaymentUseCaseConfirmOrderUseCase
  • ConfirmOrderUseCaseDecreaseStockUseCase (FOR UPDATE)

■ Trade-offs

Full Event Sourcing vs PaymentEventLog (light event sourcing). Full event sourcing requires reconstructing state from events — powerful but complex. PaymentEventLog is a compromise: we store events as audit but maintain current state in the main record. Covers 95% of use cases (audit, debugging, disputes) with 30% of the complexity of full event sourcing.