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
PaymentIntentsen 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_capturableen 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_paymenty, si no existe, usa el primerpendingpor orden. - Consistencia de hitos (backend):
PaymentService.validate_single_awaiting_payment_milestonegarantiza que solo exista un hito enawaiting_paymentpor proyecto y normaliza el resto apending. - Webhook ERP no bloqueante: el envío de
milestone_payment_releasedse ejecuta en modo best-effort; si falla, se registra enGHLSyncLogy 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ón | Dónde | Condición | Código de error |
|---|---|---|---|
| Titularidad | release_and_charge_next | project.company_id == current_user.id | INSUFFICIENT_PERMISSIONS (HTTP 403) |
| Importe del pago | initiate_payment | to_cents(monto) == to_cents(milestone.amount) | AMOUNT_MISMATCH |
| Connect activo | initiate_payment, _handle_approval, retry_failed_transfer | stripe_connect_status == "active" | STRIPE_CONNECT_NOT_ACTIVE |
| DAC7 completo | _handle_approval, retry_failed_transfer | has_complete_fiscal_data(expert_profile) | DAC7_DATA_INCOMPLETE |
| Importe experto > 0 | _handle_approval | expert_amount > 0 cuando payment_amount > 0 | INVALID_COMMISSION_CONFIGURATION |
| Cargo huérfano | initiate_payment | No existe pago failed con stripe_charge_id para el mismo hito | ORPHANED_CHARGE_EXISTS |
| Pago requerido | _handle_approval | Si milestone.amount > 0, debe existir un Payment en estado held/pending/releasing antes de aprobar la entrega | PAYMENT_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_metadatapara 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 dedb.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 pagoPOST /api/payments/confirm-initial/{project_id}- Confirmar pago inicial y activar proyecto. Transiciona el proyecto depayment_pendingain_progressy notifica al experto (email + realtime).POST /api/payments/sync-status- Sincronizar estado de pago con StripePOST /api/payments/release-and-charge-next- Liberar pago actual e iniciar el siguiente hitoGET /api/payments/project/{project_id}- Historial de pagos de un proyectoPOST /api/payments/release- Liberar pago retenido (admin)POST /api/payments/refund- Reembolsar pago (admin)GET /api/payments/history- Historial de pagos del usuarioGET /api/payments/public-key- Obtener clave pública de StripeGET /api/payments/intent/{payment_intent_id}- Recuperar detalles de PaymentIntentGET /api/payments/saved-methods- Listar métodos de pago guardadosDELETE /api/payments/saved-methods/{payment_method_id}- Eliminar método guardadoPOST /api/payments/attach-method- Guardar método de pagoPOST /api/payments/retry-transfer/{payment_id}- Reintentar transferencia fallida o diferida (admin). Acepta pagosheld+failedyreleased+pending(deferred). Recalculaexpert_amountsi fue nuleado por un revert y usa clave de idempotenciatransfer_{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:
pending: Se crea el PaymentIntent en Stripe. El dinero no ha sido bloqueado aún.held: El cliente ha autorizado el pago y Stripe ha retenido los fondos (statusrequires_capture). El dinero está seguro en escrow.- Estado necesario para empezar a trabajar en un hito.
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.released: El trabajo ha sido aprobado. Se capturaron los fondos y se transfirieron a la cuenta Connect del experto.refunded: Los fondos se devolvieron al cliente (reembolso total).partially_refunded: Se realizó un reembolso parcial al cliente. El experto recibió una parte reducida.failed: El PaymentIntent falló (tarjeta rechazada, fondos insuficientes, etc.).expired: El PaymentIntent expiró sin ser capturado dentro del plazo de Stripe.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 deheld(odisputed) areleased. - Resolución a favor de la empresa (
refund_company): Transiciona el pago deheld(odisputed) arefunded.
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:
- Cuando se aprueba un hito, se llama a
release-and-charge-next. - Se libera el pago del hito actual (
held->released). - Se calcula el presupuesto restante y se crea automáticamente el PaymentIntent para el siguiente hito.
- El siguiente hito pasa a estado
awaiting_paymenthasta que el cliente autorice el nuevo pago.
Sincronización con Stripe
POST /api/payments/sync-statussincroniza el estado local con Stripe para resolver diferencias entrepending,heldyfailed.- Los webhooks de Stripe actualizan pago e hito de forma transaccional.
Eventos de webhook por categoría
| Categoría | Evento | Comportamiento |
|---|---|---|
| Pagos | payment_intent.succeeded | Marca pago como held (escrow activo), actualiza hito a pending |
| Pagos | payment_intent.payment_failed | Marca pago como failed, hito permanece en awaiting_payment |
| Pagos | payment_intent.canceled | Marca pago como failed, mantiene hito en awaiting_payment |
| Pagos | payment_intent.amount_capturable_updated | Almacena amount_capturable en metadata del pago |
| Reembolsos | charge.refunded | Marca pago como refunded, envía notificación y email a experto y empresa |
| Transferencias | transfer.created | Actualiza payout_status a completed, marca pago como released |
| Transferencias | transfer.failed | Revierte hito (approved→in_progress) y entrega (approved→submitted), recalcula expert_amount, establece payout_status a failed, limpia stripe_transfer_id y alerta al admin |
| Payouts | payout.paid | Busca Profile por stripe_connect_account_id, notifica al experto |
| Payouts | payout.failed | Envía notificaciones al experto y al administrador |
| Disputas | charge.dispute.created | Crea registro Dispute con stripe_dispute_id, marca pago como disputed |
| Disputas | charge.dispute.updated | Sincroniza estado (under_review → in_review, needs_response → open) |
| Disputas | charge.dispute.closed | Resuelve disputa: lost → reembolso, won → permanece disputado |
| Cuenta | account.updated | Sincroniza 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.
| Concepto | Origen → Destino | Nivel |
|---|---|---|
| Transfer | Plataforma → Saldo Connect del experto | Por cada Payment |
| Payout | Saldo Connect del experto → Cuenta bancaria | Por 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:
pending: La transferencia aún no ha sido creada.completed: La transferencia se completó exitosamente. El dinero está en el saldo Connect del experto.failed: La transferencia falló. Se limpia elstripe_transfer_idpara 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
| Tipo | Destinatario | Evento |
|---|---|---|
EXPERT_PAYOUT_PAID | Experto | payout.paid |
EXPERT_PAYOUT_FAILED | Experto + Admin | payout.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 Stripepayment_id: FK al modelo Payment asociado
Flujo de disputas (Webhook)
charge.dispute.created: Se crea un registroDisputecon elstripe_dispute_idy se marcapayment.status = "disputed".charge.dispute.updated: Se sincroniza el estado de la disputa:under_review→in_reviewneeds_response→open
charge.dispute.closed: Se resuelve según el resultado:- Perdida (
lost): El pago se marca comorefunded(el dinero vuelve al cliente). - Ganada (
won): El pago permanece endisputedhasta que el administrador decida.
- Perdida (
Flujo de resolución del administrador
Cuando una disputa se gana, el administrador tiene dos opciones:
release_to_expert: Transiciona el pago dedisputedareleased. El dinero se libera al experto.refund_company: Transiciona el pago dedisputedarefunded. El dinero se devuelve a la empresa.
Migraciones de base de datos
| Migración | Descripción |
|---|---|
c3f8d2a1b5e6 | Enum payment_refunded en estados de pago |
d4e5f6a7b8c9 | Enums de payout (EXPERT_PAYOUT_PAID, EXPERT_PAYOUT_FAILED) |
e5f6a7b8c9d0 | Campo 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
| Campo | Tipo | Descripción |
|---|---|---|
commission_amount | Numeric(10,2) | Comisión de plataforma calculada |
expert_amount | Numeric(10,2) | Monto neto recibido por el experto |
expert_level_used | String | Nivel 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 configuracionesPUT /api/admin/commission-config/bulk- Actualizar en loteGET /api/admin/commission-config/{level}- Obtener por nivelPUT /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
- Al realizar el primer pago, se crea automáticamente un
stripe_customer_iden el perfil de la empresa. - Tras un pago exitoso, el método de pago se puede adjuntar al Customer de Stripe.
- En pagos posteriores, la empresa puede seleccionar una tarjeta guardada o usar una nueva.
Endpoints
| Método | Path | Descripción |
|---|---|---|
GET | /api/payments/saved-methods | Listar métodos guardados |
POST | /api/payments/attach-method | Guardar 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.