Saltar al contenido principal

Pagos

Sistema de pagos Escrow con Stripe Connect controlado por PaymentService.

Arquitectura

La lógica de negocio de pagos está centralizada en app.services.payment_service.PaymentService. Esto incluye:

  • Validación de presupuestos y prevención de duplicados.
  • Creación de PaymentIntents en Stripe.
  • Gestión de transferencias a expertos a través de Stripe Connect.
  • Sincronización de estados mediante webhooks y tareas manuales.

Características

  • Pagos Escrow seguros
  • Stripe Connect para transferencias
  • PaymentIntent inicial al seleccionar experto
  • Sincronización automática de estados
  • Liberación en cascada con payout automatizado
  • Seguimiento de disputas (chargebacks) con sincronización automática
  • Seguimiento de payouts y transferencias con notificaciones
  • Almacenamiento de amount_capturable en metadata del pago
  • Notificaciones automáticas de reembolso a experto y empresa

Reglas críticas del flujo

  • Selección de hito pagable (frontend): cuando la empresa pulsa "Pagar hito", el sistema prioriza el primer hito en awaiting_payment y, si no existe, usa el primer pending por orden.
  • Consistencia de hitos (backend): PaymentService.validate_single_awaiting_payment_milestone garantiza que solo exista un hito en awaiting_payment por proyecto y normaliza el resto a pending.
  • Webhook ERP no bloqueante: el envío de milestone_payment_released se ejecuta en modo best-effort; si falla, se registra en GHLSyncLog y no rompe el flujo principal de pago/liberación.

Validaciones de seguridad

Antes de cualquier captura o transferencia, PaymentService ejecuta una serie de validaciones que bloquean la acción si no se cumplen. Estas previenen escalado de privilegios horizontal, subpagos, transferencias a cuentas restringidas y no conformidad DAC7.

ValidaciónDóndeCondiciónCódigo de error
Titularidadrelease_and_charge_nextproject.company_id == current_user.idINSUFFICIENT_PERMISSIONS (HTTP 403)
Importe del pagoinitiate_paymentto_cents(monto) == to_cents(milestone.amount)AMOUNT_MISMATCH
Connect activoinitiate_payment, _handle_approval, retry_failed_transferstripe_connect_status == "active"STRIPE_CONNECT_NOT_ACTIVE
DAC7 completo_handle_approval, retry_failed_transferhas_complete_fiscal_data(expert_profile)DAC7_DATA_INCOMPLETE
Importe experto > 0_handle_approvalexpert_amount > 0 cuando payment_amount > 0INVALID_COMMISSION_CONFIGURATION
Cargo huérfanoinitiate_paymentNo existe pago failed con stripe_charge_id para el mismo hitoORPHANED_CHARGE_EXISTS
Pago requerido_handle_approvalSi milestone.amount > 0, debe existir un Payment en estado held/pending/releasing antes de aprobar la entregaPAYMENT_MISSING

:::warning Orden de las validaciones La verificación de titularidad se ejecuta antes de cualquier llamada a la API de Stripe, de modo que una petición no autorizada falle antes de tocar el proveedor de pagos. Las validaciones de Connect y DAC7 se ejecutan antes de la captura/transferencia. :::

Reversión de transferencia al reembolsar

Cuando refund_payment recibe un pago ya released/partially_refunded con stripe_transfer_id, invoca stripe_service.create_transfer_reversal() antes de crear el Refund:

  • Reversión proporcional si el reembolso es parcial.
  • Si la reversión falla, el reembolso no se procesa.
  • El ID y monto de la reversión se almacenan en payment_metadata para auditoría.

Redacción y deduplicación

  • Logs: las claves API (sk_...) y los secretos de webhook (whsec_...) se redactan de los mensajes de log.
  • Webhook dedup: el registro de deduplicación se confirma (db.commit()) inmediatamente después de db.add(), antes del enrutamiento del evento, para que las ramas que retornan early no descarten el registro y permitan reprocesamiento.

Endpoints Principales

  • POST /api/payments/initiate - Iniciar un nuevo pago
  • POST /api/payments/confirm-initial/{project_id} - Confirmar pago inicial y activar proyecto. Transiciona el proyecto de payment_pending a in_progress y notifica al experto (email + realtime).
  • POST /api/payments/sync-status - Sincronizar estado de pago con Stripe
  • POST /api/payments/release-and-charge-next - Liberar pago actual e iniciar el siguiente hito
  • GET /api/payments/project/{project_id} - Historial de pagos de un proyecto
  • POST /api/payments/release - Liberar pago retenido (admin)
  • POST /api/payments/refund - Reembolsar pago (admin)
  • GET /api/payments/history - Historial de pagos del usuario
  • GET /api/payments/public-key - Obtener clave pública de Stripe
  • GET /api/payments/intent/{payment_intent_id} - Recuperar detalles de PaymentIntent
  • GET /api/payments/saved-methods - Listar métodos de pago guardados
  • DELETE /api/payments/saved-methods/{payment_method_id} - Eliminar método guardado
  • POST /api/payments/attach-method - Guardar método de pago
  • POST /api/payments/retry-transfer/{payment_id} - Reintentar transferencia fallida o diferida (admin). Acepta pagos held+failed y released+pending (deferred). Recalcula expert_amount si fue nuleado por un revert y usa clave de idempotencia transfer_{payment.id}_{timestamp}.
  • GET /api/payments/{payment_id}/receipt - Obtener URL del recibo de Stripe

Flujo de Estados

El ciclo de vida de un pago sigue estos estados:

  1. pending: Se crea el PaymentIntent en Stripe. El dinero no ha sido bloqueado aún.
  2. held: El cliente ha autorizado el pago y Stripe ha retenido los fondos (status requires_capture). El dinero está seguro en escrow.
    • Estado necesario para empezar a trabajar en un hito.
  3. releasing: El administrador ha iniciado la liberación del pago. Se están capturando los fondos y transfiriendo al experto. Estado intermedio para prevenir doble liberación.
  4. released: El trabajo ha sido aprobado. Se capturaron los fondos y se transfirieron a la cuenta Connect del experto.
  5. refunded: Los fondos se devolvieron al cliente (reembolso total).
  6. partially_refunded: Se realizó un reembolso parcial al cliente. El experto recibió una parte reducida.
  7. failed: El PaymentIntent falló (tarjeta rechazada, fondos insuficientes, etc.).
  8. expired: El PaymentIntent expiró sin ser capturado dentro del plazo de Stripe.
  9. disputed: Se inició una disputa (chargeback) desde Stripe. El pago queda en estado bloqueado hasta resolución.

Nota sobre Disputas: Las disputas pueden desencadenar transiciones forzadas por el administrador:

  • Resolución a favor del experto (release_to_expert): Transiciona el pago de held (o disputed) a released.
  • Resolución a favor de la empresa (refund_company): Transiciona el pago de held (o disputed) a refunded.

Sub-estados de payout

Dentro del estado released, el campo payout_status refleja el progreso de la transferencia:

  • pending → Transferencia aún no creada.
  • completed → Transferencia completada (dinero en saldo Connect del experto).
  • failed → Transferencia fallida, pendiente de reintento.

Estados de disputa

El modelo Dispute tiene su propio ciclo de estados independiente del Payment:

  • in_review → Stripe está revisando la disputa.
  • open → Se requiere respuesta/evidencia.
  • won → Disputa resuelta a favor de Floutic/experto.
  • lost → Disputa resuelta a favor del titular de la tarjeta.

Liberación en Cascada (Chain Release)

Floutic implementa un sistema de "Liberación en Cascada" para proyectos con múltiples hitos:

  1. Cuando se aprueba un hito, se llama a release-and-charge-next.
  2. Se libera el pago del hito actual (held -> released).
  3. Se calcula el presupuesto restante y se crea automáticamente el PaymentIntent para el siguiente hito.
  4. El siguiente hito pasa a estado awaiting_payment hasta que el cliente autorice el nuevo pago.

Sincronización con Stripe

  • POST /api/payments/sync-status sincroniza el estado local con Stripe para resolver diferencias entre pending, held y failed.
  • Los webhooks de Stripe actualizan pago e hito de forma transaccional.

Eventos de webhook por categoría

CategoríaEventoComportamiento
Pagospayment_intent.succeededMarca pago como held (escrow activo), actualiza hito a pending
Pagospayment_intent.payment_failedMarca pago como failed, hito permanece en awaiting_payment
Pagospayment_intent.canceledMarca pago como failed, mantiene hito en awaiting_payment
Pagospayment_intent.amount_capturable_updatedAlmacena amount_capturable en metadata del pago
Reembolsoscharge.refundedMarca pago como refunded, envía notificación y email a experto y empresa
Transferenciastransfer.createdActualiza payout_status a completed, marca pago como released
Transferenciastransfer.failedRevierte hito (approved→in_progress) y entrega (approved→submitted), recalcula expert_amount, establece payout_status a failed, limpia stripe_transfer_id y alerta al admin
Payoutspayout.paidBusca Profile por stripe_connect_account_id, notifica al experto
Payoutspayout.failedEnvía notificaciones al experto y al administrador
Disputascharge.dispute.createdCrea registro Dispute con stripe_dispute_id, marca pago como disputed
Disputascharge.dispute.updatedSincroniza estado (under_reviewin_review, needs_responseopen)
Disputascharge.dispute.closedResuelve disputa: lost → reembolso, won → permanece disputado
Cuentaaccount.updatedSincroniza estado de cuenta Connect del experto

Flujo de Payouts y Transferencias

Diferencia entre Transfer y Payout

[!IMPORTANT] Es fundamental distinguir entre transferencias y payouts. Son etapas diferentes del flujo de dinero.

ConceptoOrigen → DestinoNivel
TransferPlataforma → Saldo Connect del expertoPor cada Payment
PayoutSaldo Connect del experto → Cuenta bancariaPor cuenta (agrega múltiples transferencias)

Ciclo de vida de payout_status

El campo payout_status en el modelo Payment refleja el estado de la transferencia:

  1. pending: La transferencia aún no ha sido creada.
  2. completed: La transferencia se completó exitosamente. El dinero está en el saldo Connect del experto.
  3. failed: La transferencia falló. Se limpia el stripe_transfer_id para permitir un reintento.

Payouts a nivel de cuenta

Los payouts son a nivel de cuenta Connect (stripe_connect_account_id), no por cada Payment individual. Un solo payout puede agregar múltiples transferencias completadas. Cuando Stripe envía un webhook de payout, el sistema busca el Profile del experto por su stripe_connect_account_id y envía las notificaciones correspondientes.

Nuevos tipos de notificación

TipoDestinatarioEvento
EXPERT_PAYOUT_PAIDExpertopayout.paid
EXPERT_PAYOUT_FAILEDExperto + Adminpayout.failed

Gestión de Disputas

Modelo Dispute

El modelo Dispute gestiona los chargebacks recibidos desde Stripe. Se vincula al modelo Payment mediante una clave foránea (payment_id).

Campos principales:

  • stripe_dispute_id: Identificador único de la disputa en Stripe (String 100, nullable, unique, indexed)
  • status: Estado actual de la disputa (in_review, open, won, lost)
  • reason: Motivo de la disputa reportado por Stripe
  • payment_id: FK al modelo Payment asociado

Flujo de disputas (Webhook)

  1. charge.dispute.created: Se crea un registro Dispute con el stripe_dispute_id y se marca payment.status = "disputed".
  2. charge.dispute.updated: Se sincroniza el estado de la disputa:
    • under_reviewin_review
    • needs_responseopen
  3. charge.dispute.closed: Se resuelve según el resultado:
    • Perdida (lost): El pago se marca como refunded (el dinero vuelve al cliente).
    • Ganada (won): El pago permanece en disputed hasta que el administrador decida.

Flujo de resolución del administrador

Cuando una disputa se gana, el administrador tiene dos opciones:

  • release_to_expert: Transiciona el pago de disputed a released. El dinero se libera al experto.
  • refund_company: Transiciona el pago de disputed a refunded. El dinero se devuelve a la empresa.

Migraciones de base de datos

MigraciónDescripción
c3f8d2a1b5e6Enum payment_refunded en estados de pago
d4e5f6a7b8c9Enums de payout (EXPERT_PAYOUT_PAID, EXPERT_PAYOUT_FAILED)
e5f6a7b8c9d0Campo stripe_dispute_id en modelo Dispute

Sistema de Comisiones

Floutic calcula automáticamente la comisión de plataforma al liberar un pago. La comisión se determina por el nivel del experto al momento del pago.

Cálculo

expert_amount = amount - commission_amount
commission_amount = amount × commission_rate

La tasa de comisión se obtiene de CommissionConfig según el expert_level_used del experto.

Campos del modelo Payment

CampoTipoDescripción
commission_amountNumeric(10,2)Comisión de plataforma calculada
expert_amountNumeric(10,2)Monto neto recibido por el experto
expert_level_usedStringNivel del experto al momento del pago

Configuración Admin

Los administradores pueden gestionar las tasas de comisión desde el panel de admin:

  • GET /api/admin/commission-config - Listar configuraciones
  • PUT /api/admin/commission-config/bulk - Actualizar en lote
  • GET /api/admin/commission-config/{level} - Obtener por nivel
  • PUT /api/admin/commission-config/{level} - Actualizar por nivel

Niveles de Experto

Los niveles típicos son: Global (tasa por defecto), Pro, Enterprise. Cada nivel tiene su propia tasa configurable.

Manejo de Errores

El sistema de pagos utiliza códigos de error específicos para problemas comunes con Stripe (ej. fondos insuficientes, cuenta no conectada). Consulta el Catálogo de Errores para más detalles.

Métodos de Pago Guardados

El sistema permite a las empresas guardar tarjetas para pagos futuros usando Stripe Customer y PaymentMethods.

Flujo

  1. Al realizar el primer pago, se crea automáticamente un stripe_customer_id en el perfil de la empresa.
  2. Tras un pago exitoso, el método de pago se puede adjuntar al Customer de Stripe.
  3. En pagos posteriores, la empresa puede seleccionar una tarjeta guardada o usar una nueva.

Endpoints

MétodoPathDescripción
GET/api/payments/saved-methodsListar métodos guardados
POST/api/payments/attach-methodGuardar método tras pago exitoso
DELETE/api/payments/saved-methods/{pm_id}Eliminar método guardado

Modelo

El stripe_customer_id se almacena en el modelo Profile y se crea automáticamente al iniciar el primer pago.

Más Información

Consulta la Documentación de Integraciones - Stripe para detalles sobre la configuración de las APIs.