Cómo diseñar billeteras y precios en cascada para revendedores

· Arquitectura · 4 min de lectura

Una plataforma de revendedores parece simple: alguien compra a un precio, vende a otro y se queda con la diferencia. Hasta que hay tres niveles de usuarios, saldos que se mueven entre ellos, varios proveedores y órdenes que a veces quedan a medias. Ahí se rompen los sistemas. Esto es lo que aprendí armando un panel multi-nivel así, el de Recargas América.

1. Que cada red solo vea lo suyo

Con administrador, revendedores y sub-revendedores, el primer riesgo es que alguien vea datos de otra red. Lo que funciona: cada tabla sensible lleva el identificador de su red (un tenant_id) y toda consulta se filtra por él automáticamente, no a mano en cada controlador.

En Laravel se resuelve con un alcance global (global scope) que agrega el filtro a las consultas y rellena el identificador al crear. Si dependes de que cada desarrollador se acuerde de filtrar, tarde o temprano alguien se olvidará.

2. Una billetera es un libro de movimientos, no un número

Si guardas solo un saldo y le sumas o restas, después nunca podrás explicar qué pasó. Mejor así:

  • Cada cargo, abono, reembolso o transferencia es un movimiento con tipo, monto, estado y datos de contexto.
  • El saldo se actualiza dentro de la misma transacción que registra el movimiento.
  • Las transferencias entre niveles generan dos movimientos enlazados por un identificador de grupo: uno de salida y uno de entrada.
  • Los montos se guardan como decimales, nunca como números de coma flotante.

Para evitar cobros dobles cuando llegan dos compras al mismo tiempo, bloquea la fila de la billetera mientras la modificas:

DB::transaction(function () use ($walletId, $amount) {
    $wallet = Wallet::whereKey($walletId)->lockForUpdate()->firstOrFail();

    if ($wallet->balance < $amount) {
        throw new InsufficientFunds();
    }

    $wallet->decrement('balance', $amount);
    $wallet->transactions()->create([
        'type'   => 'purchase',
        'amount' => -$amount,
        'status' => 'processing',
    ]);
});

3. Las órdenes tienen estados, y algunas se quedan colgadas

Cuando compras a un proveedor externo por HTTP pueden pasar tres cosas: responde que sí, responde que no, o no responde. La tercera es la peligrosa. Por eso cada movimiento tiene estados (en proceso, completado, reembolsado) y una tarea programada de recuperación revisa lo que quedó en proceso:

  1. Si la orden sí llegó al proveedor, consulta su estado y la confirma.
  2. Si falló, reembolsa al revendedor.
  3. Si no se sabe, reintenta con un límite.

El error más difícil que encontré fue una condición de carrera: la tarea de recuperación reembolsaba una orden que seguía en curso, y cuando la respuesta tardía del proveedor llegaba con éxito, el producto se entregaba pero el cobro ya estaba revertido. La solución fue que un solo punto aplique el resultado final de forma atómica y revise el estado actual antes de modificar nada.

4. Precios en cascada, siempre con piso

Cada nivel pone su precio sobre el del nivel anterior. La regla básica es que nadie vende por debajo de lo que le cobran:

// El precio efectivo nunca baja de lo que cobra la plataforma
$price = max($tenantPrice ?? $basePrice, $basePrice);

La plataforma tampoco cobra por debajo del costo del proveedor. Y como los costos cambian, al sincronizar el catálogo el precio de venta solo sube cuando el costo realmente sube. Si el costo baja o se mantiene, no se toca; así una sincronización nunca arruina un margen.

Cada cambio de costo o de precio se guarda en un historial de solo escritura, y solo cuando algo cambió de verdad, para que la tabla no se llene de filas idénticas.

Si dos proveedores venden el mismo producto con códigos distintos, el revendedor no debería ver dos filas. Conviene agrupar los productos equivalentes bajo un nombre común, mostrar uno solo y, al comprar, elegir la oferta más barata disponible y pasar al siguiente proveedor si el primero falla. Ojo con un detalle: el precio que se le cobra al revendedor se calcula sobre el grupo (el más alto), no sobre el proveedor por el que al final se compró.

6. La seguridad mínima

  • Límite de intentos en inicios de sesión y verificaciones.
  • Webhooks verificados por firma antes de acreditar un solo centavo, y con límite de peticiones.
  • Claves de API limitadas por usuario, con entorno de pruebas separado y habilitadas solo si el plan las incluye.
  • Control por plan: cada función se activa según el plan del revendedor y las rutas lo comprueban.
  • Suplantación con retorno: si el administrador entra como un revendedor para dar soporte, tiene que haber una única vía para volver a su sesión.
  • Credenciales fuera de los archivos: las de pagos y proveedores se administran por cuenta desde el panel.

Si vas a armar algo parecido

Casi todo esto se ve poco y pesa mucho más que la interfaz: aislamiento, movimientos registrados, estados claros y pisos de precio. Si vas a construir una plataforma así, mira cómo trabajo desarrollo Laravel en Ecuador o cuéntame tu caso.