Payment Source - Manual de implementación

Este documento explica cómo implementar e integrar un Payment Source externo con el plugin POS.

1. Qué es un Payment Source

Un Payment Source cubre total o parcialmente una venta mediante un recurso externo a las formas de pago nativas del POS.

Ejemplos:

  • crédito de cliente;
  • cupón;
  • vale;
  • gift card;
  • saldo externo;
  • autorización de una plataforma de terceros.

POS se limita a:

  1. registrar el provider;
  2. consultar su disponibilidad;
  3. conservar las aplicaciones seleccionadas en el checkout;
  4. enviar cada aplicación al provider;
  5. descontar del total el importe realmente consumido.

Las reglas y la persistencia específicas pertenecen al plugin proveedor. El núcleo POS no debe conocer reglas de cupones, crédito, vales u otros dominios.

2. Contratos disponibles

PaymentSourceInterface

Todo provider debe implementar:

use FacturaScripts\Plugins\POS\Contract\PaymentSourceInterface;

interface PaymentSourceInterface
{
    public function getCode(): string;

    public function getDefinition(): PaymentSourceDefinition;

    public function checkAvailability(
        PaymentSourceContext $context
    ): PaymentSourceAvailability;

    public function apply(
        PaymentSourceContext $context,
        PaymentSourceEntry $entry
    ): PaymentSourceResult;
}

El contrato principal siempre entrega la aplicación completa. No existe una interfaz alternativa ni un fallback que reciba únicamente el importe.

3. PaymentSourceEntry

Representa una aplicación concreta dentro del checkout:

final readonly class PaymentSourceEntry
{
    public function __construct(
        public string $code,
        public float $amount,
        public ?string $reference = null,
        public array $metadata = [],
    ) {
    }
}

Ejemplo:

new PaymentSourceEntry(
    code: 'coupon',
    amount: 100.00,
    reference: 'CP-12345',
    metadata: ['campaign' => 'summer'],
);

Significado de los campos:

| Campo | Uso | |---|---| | code | Identifica el tipo de source y resuelve el provider registrado. | | amount | Importe solicitado para esta aplicación. | | reference | Identifica el recurso concreto: cupón, vale, tarjeta o autorización. | | metadata | Datos adicionales específicos del provider. |

PaymentSourceEntry no contiene un identificador persistente. El frontend usa una key local que no se envía al backend.

4. Estructura recomendada del plugin proveedor

Ejemplo orientativo:

MiPlugin/
├── Extension/
│   └── Controller/
│       └── POS.php
├── Lib/
│   └── PaymentSource/
│       ├── CouponPaymentSource.php
│       └── PendingCouponStore.php
└── Extension/
    └── Model/
        └── SalesDocumentTrait.php

No es obligatorio usar un store temporal o una extensión de modelo. La estrategia depende del dominio y del momento en que se necesite la clave primaria del documento.

5. Registrar el provider

Crear una extensión del controlador POS:

<?php

declare(strict_types=1);

namespace FacturaScripts\Plugins\MiPlugin\Extension\Controller;

use Closure;
use FacturaScripts\Plugins\MiPlugin\Lib\PaymentSource\CouponPaymentSource;

final class POS
{
    public function loadPaymentSourceProviders(): Closure
    {
        return function (): void {
            $this->context->paymentSources()->register(
                new CouponPaymentSource()
            );
        };
    }
}

Consideraciones:

  • getCode() no puede devolver una cadena vacía;
  • solo se registra el primer provider de cada code;
  • se recomienda un código estable, corto y específico;
  • no usar códigos de formas de pago nativas para evitar ambigüedad conceptual.

6. Implementación básica

Un source que solo necesita un importe puede implementar únicamente PaymentSourceInterface:

<?php

declare(strict_types=1);

namespace FacturaScripts\Plugins\MiPlugin\Lib\PaymentSource;

use FacturaScripts\Core\Tools;
use FacturaScripts\Plugins\POS\Contract\PaymentSourceInterface;
use FacturaScripts\Plugins\POS\Lib\Core\PaymentSource\PaymentSourceAvailability;
use FacturaScripts\Plugins\POS\Lib\Core\PaymentSource\PaymentSourceContext;
use FacturaScripts\Plugins\POS\Lib\Core\PaymentSource\PaymentSourceDefinition;
use FacturaScripts\Plugins\POS\Lib\Core\PaymentSource\PaymentSourceEntry;
use FacturaScripts\Plugins\POS\Lib\Core\PaymentSource\PaymentSourceResult;

final class ExternalBalancePaymentSource implements PaymentSourceInterface
{
    public const CODE = 'external-balance';

    public function getCode(): string
    {
        return self::CODE;
    }

    public function getDefinition(): PaymentSourceDefinition
    {
        return new PaymentSourceDefinition(
            code: self::CODE,
            label: Tools::lang()->trans('external-balance'),
            description: Tools::lang()->trans('external-balance-description'),
            icon: 'fa-solid fa-wallet',
            metadata: [
                'category' => 'finance',
            ],
        );
    }

    public function checkAvailability(
        PaymentSourceContext $context
    ): PaymentSourceAvailability {
        if ($context->customerCode === null) {
            return PaymentSourceAvailability::blocked('NO_CUSTOMER');
        }

        $available = $this->findAvailableBalance($context->customerCode);
        if ($available <= 0.0) {
            return PaymentSourceAvailability::blocked(
                status: 'NO_BALANCE',
                availableAmount: 0.0,
            );
        }

        return PaymentSourceAvailability::allowed($available);
    }

    public function apply(
        PaymentSourceContext $context,
        PaymentSourceEntry $entry
    ): PaymentSourceResult {
        $amount = $entry->amount;
        if ($amount <= 0.0) {
            return PaymentSourceResult::rejected('INVALID_AMOUNT');
        }

        // Volver a consultar y validar. No confiar en el checkout.
        $available = $this->findAvailableBalance(
            (string)$context->customerCode
        );
        if ($amount > $available) {
            return PaymentSourceResult::rejected('INSUFFICIENT_BALANCE');
        }

        // Aplicar o preparar el efecto dentro del dominio del plugin.
        return PaymentSourceResult::approved($amount);
    }

    private function findAvailableBalance(string $customerCode): float
    {
        // Implementación propia del plugin.
        return 100.00;
    }
}

7. Implementación con referencia y metadata

Para cupones, vales o recursos individualizables se utiliza el mismo contrato principal:

<?php

declare(strict_types=1);

namespace FacturaScripts\Plugins\MiPlugin\Lib\PaymentSource;

use FacturaScripts\Plugins\POS\Contract\PaymentSourceInterface;
use FacturaScripts\Plugins\POS\Lib\Core\PaymentSource\PaymentSourceAvailability;
use FacturaScripts\Plugins\POS\Lib\Core\PaymentSource\PaymentSourceContext;
use FacturaScripts\Plugins\POS\Lib\Core\PaymentSource\PaymentSourceDefinition;
use FacturaScripts\Plugins\POS\Lib\Core\PaymentSource\PaymentSourceEntry;
use FacturaScripts\Plugins\POS\Lib\Core\PaymentSource\PaymentSourceResult;

final class CouponPaymentSource implements PaymentSourceInterface
{
    public const CODE = 'coupon';

    public function getCode(): string
    {
        return self::CODE;
    }

    public function getDefinition(): PaymentSourceDefinition
    {
        return new PaymentSourceDefinition(
            code: self::CODE,
            label: 'Cupón',
            description: 'Aplicar un cupón a la venta',
            icon: 'fa-solid fa-ticket',
            metadata: ['requiresReference' => true],
        );
    }

    public function checkAvailability(
        PaymentSourceContext $context
    ): PaymentSourceAvailability {
        return PaymentSourceAvailability::unlimited();
    }

    public function apply(
        PaymentSourceContext $context,
        PaymentSourceEntry $entry
    ): PaymentSourceResult {
        if ($entry->reference === null) {
            return PaymentSourceResult::rejected('REFERENCE_REQUIRED');
        }

        $coupon = $this->findCoupon($entry->reference);
        if ($coupon === null || !$coupon->isAvailable()) {
            return PaymentSourceResult::rejected('COUPON_NOT_AVAILABLE');
        }

        if ($entry->amount > $coupon->availableAmount()) {
            return PaymentSourceResult::rejected('AMOUNT_EXCEEDS_COUPON');
        }

        // Consumir o preparar la persistencia dentro del plugin.
        $coupon->consume($context->document, $entry->amount);

        return PaymentSourceResult::approved(
            consumed: $entry->amount,
            metadata: [
                'authorization' => $coupon->authorizationCode(),
            ],
        );
    }

    private function findCoupon(string $reference): ?object
    {
        // Implementación propia del plugin.
        return null;
    }
}

El ejemplo usa un objeto conceptual para el cupón. Debe sustituirse por el modelo o servicio real del plugin.

8. Uso de PaymentSourceContext

El contexto proporciona:

$context->document;
$context->customerCode;
$context->total;
$context->coveredAmount;
$context->sources;
$context->payments;
$context->extra;

Durante discovery

  • document es una instancia temporal que no debe guardarse;
  • sources está vacío;
  • payments contiene los pagos nativos seleccionados;
  • coveredAmount refleja lo cubierto en el checkout;
  • extra contiene terminal, empresa, almacén y eventualmente el borrador.

checkAvailability() no debe producir efectos persistentes.

Durante la transacción

  • document es el documento final preparado por POS;
  • sources contiene todas las aplicaciones normalizadas;
  • sources es una colección PaymentSourceEntry[];
  • cada entrada conserva code, amount, reference y metadata;
  • el provider se ejecuta dentro de la transacción de base de datos iniciada por POS.

No se recomienda identificar la entrada actual buscando por code dentro de $context->sources. El provider recibe directamente la instancia actual en apply().

9. Disponibilidad

Respuestas habituales:

// Disponible sin límite conocido.
PaymentSourceAvailability::unlimited();

// Disponible hasta un importe máximo.
PaymentSourceAvailability::allowed(150.00);

// Bloqueado.
PaymentSourceAvailability::blocked(
    status: 'NO_CUSTOMER',
    message: 'Seleccione un cliente',
);

Varias opciones concretas

Cuando el usuario no debe capturar una referencia, el provider puede entregar directamente los recursos disponibles:

return PaymentSourceAvailability::withOptions([
    new PaymentSourceOption(
        reference: 'X25CAMPA',
        label: 'CUPÓN 50.00',
        availableAmount: 50.00,
    ),
    new PaymentSourceOption(
        reference: 'VALE-1000',
        label: 'VALE 1000',
        availableAmount: 1000.00,
    ),
]);

POS genera dos botones con el mismo code del provider y referencias distintas. La selección, disponibilidad acumulada y marcado visual utilizan code + reference.

El importe disponible solo ayuda al checkout a limitar la selección. El provider debe volver a validarlo en apply() para evitar condiciones de carrera y requests manipulados.

10. Resultado de aplicación

Aprobado

return PaymentSourceResult::approved(
    consumed: $entry->amount,
    message: null,
    metadata: ['authorization' => 'AUTH-001'],
);

Rechazado

return PaymentSourceResult::rejected(
    status: 'COUPON_NOT_AVAILABLE',
    message: 'El cupón ya fue utilizado',
);

Reglas recomendadas:

  • consumed debe expresar el importe realmente cubierto;
  • no aprobar un importe que no haya sido validado;
  • usar estados estables y orientados a máquina;
  • reservar message para información presentable o diagnóstica;
  • incluir en la metadata de resultado únicamente información segura para auditoría; POS la guarda en el registro de la aplicación.

11. Referencia automática o capturada en frontend

El checkout base genera una clave local para cada aplicación:

{
    key: crypto.randomUUID(),
    code: 'coupon',
    reference: 'CP-12345',
    amount: 100.00,
    metadata: {},
}

La key sirve para eliminar una sola fila aunque existan varias con el mismo code. El request enviado al backend excluye esa clave:

{
  "code": "coupon",
  "amount": 100.0,
  "reference": "CP-12345",
  "metadata": {}
}

Cuando el provider devuelve PaymentSourceOption, POS coloca automáticamente reference, label y availableAmount en el botón. Al pulsarlo no se solicita ningún dato adicional: la referencia viaja directamente al PaymentSourceEntry.

La UI base no presenta un formulario genérico para referencias. Un plugin que necesite capturarlas debe proporcionar su modal, lector o selector y crear la entrada con:

CheckoutModel.setPaymentSource({
    code: 'coupon',
    label: 'Cupón',
    amount: 100.00,
    reference: 'CP-12345',
    metadata: {campaign: 'summer'},
});

Si el plugin reutiliza el botón generado por discovery, el controlador también reconoce data-reference. Para flujos complejos se recomienda una interfaz propia en lugar de almacenar JSON arbitrario en atributos HTML.

12. Varias aplicaciones del mismo source

El checkout y el backend admiten:

[
  {
    "code": "coupon",
    "reference": "CP-12345",
    "amount": 100.0,
    "metadata": {}
  },
  {
    "code": "coupon",
    "reference": "CP-67890",
    "amount": 50.0,
    "metadata": {}
  }
]

Para procesarlas correctamente:

  1. tratar cada llamada a apply() como una aplicación independiente;
  2. validar la referencia concreta;
  3. hacer la operación segura frente a consumo duplicado;
  4. persistir la relación entre recurso, documento e importe dentro del plugin proveedor.

13. Persistencia

POS crea un registro de auditoría en pos_payment_source_entries con operación, sesión, código, etiqueta, referencia, importe solicitado, importe consumido y metadata de resultado. Este registro no sustituye la persistencia de dominio del provider.

Existen dos estrategias comunes para la persistencia específica del provider.

Persistencia directa en apply()

Adecuada si el provider no necesita la clave primaria del documento o puede relacionarlo de otra manera.

public function apply(
    PaymentSourceContext $context,
    PaymentSourceEntry $entry
): PaymentSourceResult {
    $this->service->reserve($entry->reference, $entry->amount);
    return PaymentSourceResult::approved($entry->amount);
}

La operación se ejecuta dentro de la transacción iniciada por POS, siempre que use la misma conexión transaccional.

Preparar y persistir al guardar el documento

Adecuada cuando se necesita el identificador final del documento:

PendingSourceStore::stage($context->document, $entry);

Una extensión saveInsert() del documento recupera la entrada preparada y crea el registro definitivo. Un WeakMap permite asociar temporalmente datos al objeto sin almacenarlos globalmente más allá de su ciclo de vida.

Si se preparan varias entradas, el store debe conservar una colección y no agregar importes si el dominio necesita mantener referencias independientes.

14. Seguridad e idempotencia

El provider debe asumir que el payload puede ser manipulado.

Validar siempre:

  • que code corresponde al provider;
  • que amount es válido para el recurso;
  • que reference existe y pertenece al cliente cuando corresponda;
  • que el recurso no está vencido, bloqueado o consumido;
  • que la metadata tiene las claves y tipos esperados;
  • que el documento y moneda son compatibles;
  • que una misma referencia no se consume dos veces.

Si una integración puede reintentarse o depende de una API externa, debe implementar su propia clave de idempotencia. La key local del frontend no es una clave persistente ni se envía al servidor.

15. Pruebas recomendadas

Como mínimo, probar:

  1. definición estática;
  2. disponibilidad sin cliente;
  3. disponibilidad insuficiente;
  4. aplicación válida;
  5. referencia inexistente;
  6. importe mayor al disponible;
  7. metadata inválida;
  8. dos entradas con el mismo code y referencias diferentes;
  9. rechazo de un consumo duplicado;
  10. rollback de efectos cuando falla la venta.

Ejemplo básico:

public function testAppliesTwoCouponsIndependently(): void
{
    $provider = new CouponPaymentSource();

    $first = new PaymentSourceEntry(
        code: 'coupon',
        amount: 100.0,
        reference: 'CP-12345',
    );
    $second = new PaymentSourceEntry(
        code: 'coupon',
        amount: 50.0,
        reference: 'CP-67890',
    );

    $firstResult = $provider->apply($context, $first);
    $secondResult = $provider->apply($context, $second);

    $this->assertTrue($firstResult->approved);
    $this->assertTrue($secondResult->approved);
}

16. Lista de comprobación

  • [ ] El provider implementa PaymentSourceInterface.
  • [ ] Usa un code único y estable.
  • [ ] La definición no contiene datos de una aplicación concreta.
  • [ ] checkAvailability() no produce efectos persistentes.
  • [ ] El provider vuelve a validar durante la aplicación.
  • [ ] Valida reference y metadata cuando su dominio las utiliza.
  • [ ] Soporta varias entradas del mismo code cuando el dominio lo permite.
  • [ ] La persistencia de dominio pertenece al plugin proveedor; POS conserva únicamente el registro de auditoría.
  • [ ] Los efectos participan en el rollback de la venta.
  • [ ] No introduce reglas específicas dentro de POS.
  • [ ] Tiene pruebas para rechazos, duplicados y múltiples referencias.

17. Archivos de referencia en POS

| Archivo | Contenido | |---|---| | Contract/PaymentSourceInterface.php | Contrato principal que recibe entradas completas. | | Lib/Core/PaymentSource/PaymentSourceEntry.php | DTO de una aplicación. | | Lib/Core/PaymentSource/PaymentSourceDefinition.php | Definición estática. | | Lib/Core/PaymentSource/PaymentSourceAvailability.php | Disponibilidad. | | Lib/Core/PaymentSource/PaymentSourceOption.php | Opción concreta mostrada como botón. | | Lib/Core/PaymentSource/PaymentSourceContext.php | Contexto compartido. | | Lib/Core/PaymentSource/PaymentSourceResult.php | Resultado de aplicación. | | Lib/Core/PaymentSource/PaymentSourceApplication.php | Entrada, resultado y etiqueta aplicados. | | Lib/Core/PaymentSource/PaymentSourceSettlement.php | Resultado agregado de la transacción. | | Lib/Core/PaymentSourceManager.php | Registro, normalización y aplicación. | | Model/FuentePagoPuntoVenta.php | Registro de auditoría asociado a operación y sesión. | | Lib/Services/PaymentSourceRecords.php | Persistencia de aplicaciones aprobadas. | | Assets/JS/POS/models/CheckoutModel.js | Estado y claves locales. | | Assets/JS/POS/controllers/CheckoutController.js | Selección y eliminación. | | .agents/payment-sources.md | Análisis detallado del flujo interno. |

Cookies
Usamos cookies necesarias para el funcionamiento del sitio y cookies opcionales para recordar tus preferencias y mejorar tu experiencia. Puedes aceptarlas todas, rechazarlas o configurar tus preferencias

Soporte