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:
- registrar el provider;
- consultar su disponibilidad;
- conservar las aplicaciones seleccionadas en el checkout;
- enviar cada aplicación al provider;
- 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
documentes una instancia temporal que no debe guardarse;sourcesestá vacío;paymentscontiene los pagos nativos seleccionados;coveredAmountrefleja lo cubierto en el checkout;extracontiene terminal, empresa, almacén y eventualmente el borrador.
checkAvailability() no debe producir efectos persistentes.
Durante la transacción
documentes el documento final preparado por POS;sourcescontiene todas las aplicaciones normalizadas;sourceses una colecciónPaymentSourceEntry[];- cada entrada conserva
code,amount,referenceymetadata; - 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:
consumeddebe expresar el importe realmente cubierto;- no aprobar un importe que no haya sido validado;
- usar estados estables y orientados a máquina;
- reservar
messagepara 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:
- tratar cada llamada a
apply()como una aplicación independiente; - validar la referencia concreta;
- hacer la operación segura frente a consumo duplicado;
- 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
codecorresponde al provider; - que
amountes válido para el recurso; - que
referenceexiste 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:
- definición estática;
- disponibilidad sin cliente;
- disponibilidad insuficiente;
- aplicación válida;
- referencia inexistente;
- importe mayor al disponible;
- metadata inválida;
- dos entradas con el mismo
codey referencias diferentes; - rechazo de un consumo duplicado;
- 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
referenceymetadatacuando su dominio las utiliza. - [ ] Soporta varias entradas del mismo
codecuando 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. |