Files
oficial/app/src/Service/Venta/MediosPago/Toku.php
2025-07-03 16:37:57 -04:00

536 lines
20 KiB
PHP

<?php
namespace Incoviba\Service\Venta\MediosPago;
use Incoviba\Common\Implement\Exception\EmptyResult;
use InvalidArgumentException;
use PDO;
use PDOException;
use Psr\Http\Message\ServerRequestInterface;
use Incoviba\Common\Define\Connection;
use Incoviba\Common\Ideal;
use Incoviba\Common\Implement\Exception\EmptyResponse;
use Incoviba\Exception\InvalidResult;
use Incoviba\Exception\ServiceAction\Read;
use Incoviba\Model;
use Incoviba\Model\Persona;
use Incoviba\Model\Venta\Propietario;
use Incoviba\Service\HMAC;
use Incoviba\Service\Venta\MediosPago\Toku\{Customer,Subscription,Invoice};
use Psr\Log\LoggerInterface;
use Throwable;
class Toku extends Ideal\Service
{
const string CUSTOMER = 'customer';
const string SUBSCRIPTION = 'subscription';
const string INVOICE = 'invoice';
protected Customer $customer;
protected Subscription $subscription;
protected Invoice $invoice;
public function __construct(LoggerInterface $logger, protected Connection $connection, protected HMAC $hmac)
{
parent::__construct($logger);
}
public function register(string $type, EndPoint $endPoint): self
{
if (!in_array(strtolower($type), ['customer', 'subscription', 'invoice'])) {
throw new InvalidArgumentException("{$type} is not a valid type of EndPoint for " . __CLASS__);
}
$this->{strtolower($type)} = $endPoint;
return $this;
}
/**
* @param Persona|Propietario $persona
* @return array
* @throws InvalidResult
*/
public function sendPersona(Model\Persona|Model\Venta\Propietario $persona): array
{
$rut = implode('', [$persona->rut, strtoupper($persona->dv)]);
try {
return $this->customer->getById($rut);
} catch (InvalidResult $exception) {
$datos = $persona->datos;
$customerData = [
'rut' => $rut,
'nombreCompleto' => $persona->nombreCompleto(),
'email' => $datos?->email ?? '',
'telefono' => $datos?->telefono ?? ''
];
try {
if (!$this->customer->add($customerData)) {
throw new InvalidResult("Could not save Customer for Persona {$rut}", 409, $exception);
}
} catch (EmptyResponse $exception) {
throw new InvalidResult("Could not save Customer for Persona {$rut}", 409, $exception);
}
return $this->customer->getById($rut);
}
}
/**
* @param Model\Venta $venta
* @return array
* @throws InvalidResult
*/
public function sendVenta(Model\Venta $venta): array
{
$customer = $this->sendPersona($venta->propietario());
try {
return $this->subscription->getById($venta->id);
} catch (InvalidResult $exception) {
$inmobiliaria = $venta->proyecto()->inmobiliaria();
$accountKey = null;
try {
$accountKey = $this->getAccountKey($inmobiliaria->rut);
} catch (EmptyResult) {}
$subscriptionData = [
'customer' => $customer['toku_id'],
'product_id' => $venta->id,
'venta' => $venta
];
try {
if (!$this->subscription->add($subscriptionData, $accountKey)) {
throw new InvalidResult("Could not save Subscription for Venta {$venta->id}", 409, $exception);
}
} catch (EmptyResponse $exception) {
throw new InvalidResult("Could not save Subscription for Venta {$venta->id}", 409, $exception);
}
return $this->subscription->getById($venta->id);
}
}
/**
* @param Model\Venta $venta
* @param array $cuotas_ids
* @return array
* @throws InvalidResult
*/
public function sendCuotas(Model\Venta $venta, array $cuotas_ids = []): array
{
$customer = $this->sendPersona($venta->propietario());
$subscription = $this->sendVenta($venta);
$customerInvoices = [];
try {
$customerInvoices = $this->invoice->getByCustomer($customer['toku_id']);
$customerInvoices = array_filter($customerInvoices, function($invoiceRow) use ($subscription) {
return $invoiceRow['subscription'] === $subscription['toku_id'];
});
} catch (EmptyResponse) {}
$inmobiliaria = $venta->proyecto()->inmobiliaria();
$accountKey = null;
try {
$accountKey = $this->getAccountKey($inmobiliaria->rut);
} catch (EmptyResult) {}
$invoices = [];
$errors = [];
foreach ($venta->formaPago()->pie->cuotas() as $cuota) {
if (count($cuotas_ids) > 0 and !in_array($cuota->id, $cuotas_ids)) {
continue;
}
if (count($customerInvoices) and in_array($cuota->id, array_column($customerInvoices, 'invoice_external_id'))) {
$invoice = array_find($customerInvoices, function($invoiceRow) use ($cuota) {
return $invoiceRow['invoice_external_id'] === $cuota->id;
});
if ($invoice !== null) {
$invoices []= $invoice;
$this->invoice->save($invoice);
continue;
}
}
try {
$invoices []= $this->invoice->getById($cuota->id);
} catch (InvalidResult $exception) {
try {
$invoiceData = [
'customer' => $customer['toku_id'],
'product_id' => $venta->id,
'subscription' => $subscription['toku_id'],
'cuota' => $cuota,
'venta' => $venta
];
if (!$this->invoice->add($invoiceData, $accountKey)) {
throw new EmptyResponse("Could not add Invoice for Cuota {$cuota->id}", $exception);
}
$invoices []= $this->invoice->getById($cuota->id);
} catch (EmptyResponse $exception) {
$this->logger->warning($exception);
$errors []= $exception;
}
}
}
if (count($errors) > 0) {
$this->logger->warning("Revisar el envío de cuotas de la Venta {$venta->id}");
}
return $invoices;
}
/**
* @param array $input
* @return bool
* @throws InvalidResult
*/
public function successEvent(array $input): bool
{
$validEvents = ['payment_intent.succeeded', 'payment.succeeded', 'transaction.success',
'transaction.bulk_success', 'payment_intent.succeeded_batch'];
if (!in_array($input['event_type'], $validEvents)) {
$this->logger->warning("{$input['event_type']} is not a valid event");
throw new InvalidResult("{$input['event_type']} is not a valid event", 422);
}
switch ($input['event_type']) {
case 'payment_intent.succeeded_batch':
case 'transaction.bulk_success':
return $this->successBulk($input);
case 'transaction.success':
return $this->successTransaction($input);
default:
$paymentData = $this->mapEventData($input);
return $this->updatePago($paymentData);
}
}
public function check(bool $update = false): array
{
try {
list('existingSubscriptions' => $existingSubscriptions, 'missingVentas' => $missingVentas) = $this->subscription->check();
} catch (Read) {
return [];
}
$queues = [];
if (count($missingVentas) > 0) {
foreach ($missingVentas as $venta) {
$cuotas = $venta->formaPago()->pie->cuotas();
if (count($cuotas) === 0) {
continue;
}
$queueData = [
'type' => 'request',
'url' => "/api/external/toku/cuotas/{$venta->id}",
'method' => 'post',
'body' => ['cuotas' => array_map(function(Model\Venta\Cuota $cuota) {return $cuota->id;}, $cuotas)]
];
$queues []= $queueData;
}
}
if ($update and count($existingSubscriptions) > 0) {
foreach ($existingSubscriptions as $subscription) {
$cuotas = $subscription->venta->formaPago()->pie->cuotas();
if (count($cuotas) === 0) {
continue;
}
$propietario = $subscription->venta->propietario();
try {
$customer = $this->customer->getById($propietario->rut());
} catch (InvalidResult) {
continue;
}
try {
$editData = [
'rut' => $customer['rut'],
'nombreCompleto' => $propietario->nombreCompleto(),
'email' => $propietario->datos?->email ?? '',
'telefono' => $propietario->datos?->telefono ?? ''
];
$this->customer->edit($customer['toku_id'], $editData);
} catch (EmptyResponse $exception) {
$this->logger->warning($exception);
}
foreach ($cuotas as $cuota) {
try {
$invoice = $this->invoice->getById($cuota->id);
$editData = [
'customer' => $customer['toku_id'],
'product_id' => $subscription->venta->id,
'subscription' => $subscription->toku_id,
'cuota' => $cuota,
'venta' => $subscription->venta
];
try {
$this->invoice->edit($invoice['toku_id'], $editData);
} catch (EmptyResponse) {}
} catch (InvalidResult) {
$invoiceData = [
'customer' => $customer['toku_id'],
'product_id' => $subscription->venta->id,
'subscription' => $subscription->toku_id,
'cuota' => $cuota,
'venta' => $subscription->venta
];
try {
$this->invoice->add($invoiceData);
} catch (EmptyResponse) {}
}
}
}
}
return $queues;
}
public function reset(array $skips = []): array
{
$output = [];
try {
$output['customer'] = $this->customer->reset($skips['customer'] ?? []);
$output['subscription'] = $this->subscription->reset($skips['subscription'] ?? []);
$output['payments'] = $this->invoice->resetPayments();
$output['invoice'] = $this->invoice->reset($skips['invoice'] ?? []);
} catch (InvalidResult $exception) {
$this->logger->warning($exception);
return [];
}
return $output;
}
public function queue(array $venta_ids): array
{
$queues = [];
foreach ($venta_ids as $venta_id) {
if ($this->subscription->queue($venta_id)) {
$queues []= [
'type' => 'request',
'url' => "/api/external/toku/cuotas/{$venta_id}",
'method' => 'post',
'body' => []
];
}
}
return $queues;
}
public function update(array $ids, ?string $type = null): array
{
if ($type === null) {
$types = [
'customers',
'subscriptions',
'invoices'
];
$results = [];
foreach ($types as $type) {
$results[$type] = $this->update($ids[$type], $type);
}
return $results;
}
$results = [];
switch ($type) {
case 'customers':
try {
$results['subscription'] = $this->subscription->updateByCustomer($ids);
$results['invoice'] = $this->invoice->updateByCustomer($ids);
} catch (EmptyResult $exception) {
$this->logger->error($exception);
}
break;
case 'subscriptions':
try {
$results['subscription'] = $this->subscription->update($ids);
$results['invoice'] = $this->invoice->updateBySubscription($ids);
} catch (EmptyResult | EmptyResponse $exception) {
$this->logger->error($exception);
}
break;
case 'invoices':
try {
$results['invoice'] = $this->invoice->updateAll($ids);
} catch (EmptyResult $exception) {
$this->logger->error($exception);
}
break;
}
return $results;
}
/**
* @param ServerRequestInterface $request
* @param array $tokenConfig
* @return bool
*/
public function validateToken(ServerRequestInterface $request, array $tokenConfig): bool
{
if (!$request->hasHeader('User-Agent') or !str_starts_with($request->getHeaderLine('User-Agent'), 'Toku-Webhooks')) {
return false;
}
if (!$request->hasHeader('X-Datadog-Tags') or !$request->hasHeader('Tracestate')) {
return false;
}
if (!$request->hasHeader('Toku-Signature')) {
return false;
}
$tokuSignature = $request->getHeaderLine('Toku-Signature');
try {
list($timestamp, $signature) = array_map(function($elem) {
return explode('=', $elem)[1];
}, explode(',', $tokuSignature));
$body = $request->getBody()->getContents();
$json = json_decode($body, true);
if (!is_array($json)) {
return false;
}
if (!array_key_exists('id', $json)) {
return false;
}
$eventId = $json['id'];
$eventType = $json['event_type'];
$query = $this->connection->getQueryBuilder()
->select('secret')
->from('toku_webhooks')
->where('enabled = ? AND JSON_SEARCH(events, "one", ?) IS NOT NULL');
$params = [true, $eventType];
$statement = $this->connection->prepare($query);
$statement->execute($params);
$results = $statement->fetchAll(PDO::FETCH_COLUMN);
if (count($results) === 0) {
return false;
}
if (array_any($results, fn($secret) => $this->hmac->validate($timestamp, $signature, $eventId, $secret))) {
return true;
}
} catch (Throwable $throwable) {
$this->logger->error($throwable);
}
return false;
}
/**
* @param array $request
* @return bool
* @throws InvalidResult
*/
protected function updatePago(array $request): bool
{
# If $customer is not found, it will throw an exception and stop
$customer = $this->customer->getByExternalId($request['customer']);
$invoice = $this->invoice->getByExternalId($request['invoice']);
return $this->invoice->update($invoice['toku_id'], $request);
}
protected function successTransaction(array $input): bool
{
$intents = $this->mapMultiplePaymentIntentsData($input);
$errors = [];
foreach ($intents as $intent) {
if (!$this->updatePago($intent)) {
$errors []= $intent;
}
}
if (array_key_exists('wallet_movements', $input)) {
foreach ($input['wallet_movements'] as $walletMovement) {
if (array_key_exists('type', $walletMovement) and $walletMovement['type'] === 'SURPLUS') {
$this->logger->alert('Revisar el envío de cuotas de la Venta ' . $walletMovement['product_id']);
}
}
}
return count($errors) === 0;
}
/**
* @param array $input
* @return bool
* @throws InvalidResult
*/
protected function successBulk(array $input): bool
{
return match($input['event_type']) {
'payment_intent.succeeded_batch' => $this->successBulkPaymentIntent($input),
'transaction.bulk_success' => $this->successBulkTransaction($input),
default => false
};
}
/**
* @param array $input
* @return bool
* @throws InvalidResult
*/
protected function successBulkTransaction(array $input): bool
{
$errors = [];
foreach($input['events'] as $event) {
$event['event_type'] = 'transaction.success';
if (!$this->successEvent($event)) {
$errors []= $event;
}
}
return count($errors) === 0;
}
/**
* @param array $input
* @return bool
* @throws InvalidResult
*/
protected function successBulkPaymentIntent(array $input): bool
{
$errors = [];
foreach($input['payment_intent'] as $intent) {
$intent['event_type'] = 'payment_intent.succeeded';
$intent['payment_intent'] = $input['payment_intents'];
unset($intent['payment_intents']);
if (!$this->successEvent($intent)) {
$errors []= $intent;
}
}
return count($errors) === 0;
}
protected function mapEventData(array $input): array
{
return match ($input['event_type']) {
'payment_intent.succeeded' => $this->mapPaymentIntentData($input),
'payment.succeeded' => $this->mapPaymentEventData($input),
default => [],
};
}
protected function mapMultiplePaymentIntentsData(array $input): array
{
$output = [];
foreach ($input['payment_intents'] as $intent) {
$intent['transaction_date'] = $input['transaction']['transaction_date'];
$intent['customer'] = $input['customer']['id'];
$intent['invoice'] = $intent['id_invoice'];
$intent['subscription'] = $intent['id_subscription'];
$intent['cuota_id'] = $intent['invoice_external_id'];
$o = $this->mapPaymentIntentData($intent);
$output []= $o;
}
return $output;
}
protected function mapPaymentEventData(array $input): array
{
$data = $input['payment'];
if (!array_key_exists('amount', $data) and array_key_exists('payment_amount', $data)) {
$data['amount'] = $data['payment_amount'];
}
$data['status'] = 'AUTHORIZED';
$data['date'] = $data['payment_date'];
return $data;
}
protected function mapPaymentIntentData(array $input): array
{
$data = $input['payment_intent'];
$data['date'] = $data['transaction_date'];
return $data;
}
protected function getAccountKey(int $sociedad_rut): string
{
$query = $this->connection->getQueryBuilder()
->select('account_key')
->from('toku_accounts')
->where('enabled = ? AND sociedad_rut = ?');
$params = [true, $sociedad_rut];
try {
$statement = $this->connection->prepare($query);
$statement->execute($params);
return $statement->fetchColumn();
} catch (PDOException $exception) {
$this->logger->error($exception);
throw new EmptyResult($query, $exception);
}
}
}