Explorar o código

add the most features need test

gdias hai 2 semanas
pai
achega
e8f4ad9644

+ 66 - 0
controllers/OrderbookFilterController.php

@@ -0,0 +1,66 @@
+<?php
+
+namespace Controllers;
+
+use Libs\ResponseLib;
+use Models\OrderbookSearchModel;
+use Psr\Http\Message\ServerRequestInterface;
+use Respect\Validation\Exceptions\ValidationException;
+use Respect\Validation\Validator as val;
+
+class OrderbookFilterController
+{
+    private OrderbookSearchModel $searchModel;
+
+    public function __construct()
+    {
+        $this->searchModel = new OrderbookSearchModel();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $body = json_decode((string)$request->getBody(), true) ?? [];
+
+        try {
+            val::key('state', val::stringType()->notEmpty())
+                ->key('commodity_type', val::stringType()->notEmpty())
+                ->assert($body);
+        } catch (ValidationException $e) {
+            return ResponseLib::sendFail(
+                'Validation failed: ' . $e->getFullMessage(),
+                [],
+                'E_VALIDATE'
+            )->withStatus(400);
+        }
+
+        $state = strtoupper(trim((string)$body['state']));
+        $commodityType = trim((string)$body['commodity_type']);
+
+        try {
+            $orders = $this->searchModel->searchOpenOrders($state, $commodityType);
+        } catch (\Throwable $e) {
+            return ResponseLib::sendFail(
+                'Falha ao consultar orderbook: ' . $e->getMessage(),
+                [],
+                'E_DATABASE'
+            )->withStatus(500);
+        }
+
+        if (!$orders) {
+            return ResponseLib::sendOk(
+                [
+                    'state' => $state,
+                    'commodity_type' => $commodityType,
+                    'orders' => [],
+                ],
+                'S_ORDERBOOK_EMPTY'
+            );
+        }
+
+        return ResponseLib::sendOk([
+            'state' => $state,
+            'commodity_type' => $commodityType,
+            'orders' => $orders,
+        ], 'S_ORDERBOOK_FILTER');
+    }
+}

+ 69 - 0
controllers/OrderbookPaymentController.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace Controllers;
+
+use Libs\ResponseLib;
+use Models\OrderbookModel;
+use Models\OrderbookPaymentModel;
+use Psr\Http\Message\ServerRequestInterface;
+use Respect\Validation\Exceptions\ValidationException;
+use Respect\Validation\Validator as val;
+use Services\PaymentService;
+
+class OrderbookPaymentController
+{
+    private OrderbookPaymentModel $orderbookPaymentModel;
+    private PaymentService $paymentService;
+
+    public function __construct()
+    {
+        $this->orderbookPaymentModel = new OrderbookPaymentModel();
+        $this->paymentService = new PaymentService();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $body = json_decode((string)$request->getBody(), true) ?? [];
+
+        try {
+            val::key('orderbook_id', val::intType()->positive())->assert($body);
+        } catch (ValidationException $e) {
+            return ResponseLib::sendFail('Validation failed: ' . $e->getFullMessage(), [], 'E_VALIDATE')->withStatus(400);
+        }
+
+        $orderbookId = (int)$body['orderbook_id'];
+
+        try {
+            $order = $this->orderbookPaymentModel->getOrderbookForPayment($orderbookId);
+        } catch (\Throwable $e) {
+            return ResponseLib::sendFail('Falha ao consultar orderbook: ' . $e->getMessage(), [], 'E_DATABASE')->withStatus(500);
+        }
+
+        if (!$order) {
+            return ResponseLib::sendFail('Orderbook não encontrado', ['orderbook_id' => $orderbookId], 'E_NOT_FOUND')->withStatus(404);
+        }
+
+        if ((int)$order['status_id'] !== OrderbookModel::STATUS_OPEN) {
+            return ResponseLib::sendFail('Orderbook não está disponível para pagamento', ['orderbook_id' => $orderbookId, 'status_id' => $order['status_id']], 'E_ORDERBOOK_STATUS')->withStatus(409);
+        }
+
+        $tokenValue = (float)($order['token_commodities_value'] ?? 0);
+        if ($tokenValue <= 0) {
+            return ResponseLib::sendFail('Valor do token inválido', ['orderbook_id' => $orderbookId], 'E_TOKEN_VALUE')->withStatus(422);
+        }
+
+        try {
+            $paymentData = $this->paymentService->initiatePayment((int)round($tokenValue));
+        } catch (\Throwable $e) {
+            return ResponseLib::sendFail('Falha ao iniciar pagamento: ' . $e->getMessage(), [], 'E_PAYMENT')->withStatus(500);
+        }
+
+        return ResponseLib::sendOk([
+            'orderbook_id' => $orderbookId,
+            'payment_id' => $paymentData['payment_id'],
+            'payment_code' => $paymentData['payment_code'],
+            'payment_external_id' => $paymentData['payment_external_id'],
+            'token_external_id' => (string)($order['token_external_id'] ?? ''),
+        ], 'S_ORDERBOOK_PAYMENT');
+    }
+}

+ 124 - 0
controllers/OrderbookTransferController.php

@@ -0,0 +1,124 @@
+<?php
+
+namespace Controllers;
+
+use Libs\ResponseLib;
+use Models\OrderbookModel;
+use Models\OrderbookTransferModel;
+use Models\PaymentModel;
+use Models\WalletModel;
+use Psr\Http\Message\ServerRequestInterface;
+use Respect\Validation\Exceptions\ValidationException;
+use Respect\Validation\Validator as val;
+use Services\TokenTransferService;
+
+class OrderbookTransferController
+{
+    private PaymentModel $paymentModel;
+    private WalletModel $walletModel;
+    private OrderbookTransferModel $orderbookTransferModel;
+    private TokenTransferService $tokenTransferService;
+
+    public function __construct()
+    {
+        $this->paymentModel = new PaymentModel();
+        $this->walletModel = new WalletModel();
+        $this->orderbookTransferModel = new OrderbookTransferModel();
+        $this->tokenTransferService = new TokenTransferService();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $body = json_decode((string)$request->getBody(), true) ?? [];
+
+        try {
+            val::key('external_id', val::stringType()->notEmpty())
+                ->key('token_external_id', val::stringType()->notEmpty())
+                ->assert($body);
+        } catch (ValidationException $e) {
+            return ResponseLib::sendFail('Validation failed: ' . $e->getFullMessage(), [], 'E_VALIDATE')->withStatus(400);
+        }
+
+        $externalId = trim((string)$body['external_id']);
+        $tokenExternalId = trim((string)$body['token_external_id']);
+        $companyId = (int)($request->getAttribute('api_company_id') ?? 0);
+
+        if ($companyId <= 0) {
+            return ResponseLib::sendFail('Empresa autenticada não encontrada', [], 'E_VALIDATE')->withStatus(401);
+        }
+
+        try {
+            $payment = $this->paymentModel->findByExternalId($externalId);
+        } catch (\Throwable $e) {
+            return ResponseLib::sendFail('Falha ao consultar pagamento: ' . $e->getMessage(), [], 'E_DATABASE')->withStatus(500);
+        }
+
+        if (!$payment) {
+            return ResponseLib::sendFail('Pagamento não encontrado', ['external_id' => $externalId], 'E_NOT_FOUND')->withStatus(404);
+        }
+
+        if ((int)$payment['status_id'] !== PaymentModel::STATUS_COMPLETED) {
+            return ResponseLib::sendFail('Pagamento ainda não concluído', ['external_id' => $externalId, 'status_id' => $payment['status_id']], 'E_PAYMENT_PENDING')->withStatus(409);
+        }
+
+        try {
+            $orderbook = $this->orderbookTransferModel->getByTokenExternalId($tokenExternalId);
+        } catch (\Throwable $e) {
+            return ResponseLib::sendFail('Falha ao consultar orderbook: ' . $e->getMessage(), [], 'E_DATABASE')->withStatus(500);
+        }
+
+        if (!$orderbook) {
+            return ResponseLib::sendFail('Orderbook não encontrado', ['token_external_id' => $tokenExternalId], 'E_NOT_FOUND')->withStatus(404);
+        }
+
+        if ((int)$orderbook['company_id'] !== $companyId) {
+            return ResponseLib::sendFail('Orderbook não pertence à empresa autenticada', [], 'E_FORBIDDEN')->withStatus(403);
+        }
+
+        if ((int)$orderbook['status_id'] === OrderbookModel::STATUS_COMPLETED) {
+            return ResponseLib::sendOk([
+                'orderbook_id' => (int)$orderbook['orderbook_id'],
+                'token_external_id' => $tokenExternalId,
+                'message' => 'Orderbook já transferido anteriormente',
+            ], 'S_TOKEN_ALREADY_TRANSFERRED');
+        }
+
+        try {
+            $wallet = $this->walletModel->getPrimaryWalletByCompanyId($companyId);
+        } catch (\Throwable $e) {
+            return ResponseLib::sendFail('Falha ao consultar carteira da empresa: ' . $e->getMessage(), [], 'E_DATABASE')->withStatus(500);
+        }
+
+        if (!$wallet || empty($wallet['wallet_address'])) {
+            return ResponseLib::sendFail('Carteira da empresa não encontrada', [], 'E_WALLET_NOT_FOUND')->withStatus(404);
+        }
+
+        $serverAddress = $this->resolveServerAddress();
+
+        try {
+            $transferResult = $this->tokenTransferService->transferFrom($serverAddress, (string)$wallet['wallet_address'], $tokenExternalId);
+            $this->orderbookTransferModel->markCompleted((int)$orderbook['orderbook_id']);
+        } catch (\Throwable $e) {
+            return ResponseLib::sendFail('Falha ao transferir token: ' . $e->getMessage(), [], 'E_TRANSFER')->withStatus(500);
+        }
+
+        return ResponseLib::sendOk([
+            'orderbook_id' => (int)$orderbook['orderbook_id'],
+            'token_external_id' => $tokenExternalId,
+            'destination_address' => (string)$wallet['wallet_address'],
+            'transfer_output' => $transferResult['output'] ?? '',
+            'transfer_error' => $transferResult['error'] ?? '',
+        ], 'S_TOKEN_TRANSFERRED');
+    }
+
+    private function resolveServerAddress(): string
+    {
+        $address = $_ENV['SERVER_WALLET_ADDRESS'] ?? $_ENV['EASY_ADMIM_PUBLIC_KEY'] ?? '';
+        $trimmed = trim((string)$address);
+        if ($trimmed === '') {
+            throw new \RuntimeException('Endereço do servidor (SERVER_WALLET_ADDRESS) não configurado.');
+        }
+
+        return $trimmed;
+    }
+}

+ 23 - 1
controllers/TokenOrderbookController.php

@@ -33,6 +33,9 @@ class TokenOrderbookController
         try {
             val::key('cpr_id', val::intType()->positive())
                 ->key('value', val::numericVal())
+                ->key('state', val::stringType()->notEmpty())
+                ->key('commodity_type', val::stringType()->notEmpty())
+                ->key('token_external_id', val::stringType()->notEmpty())
                 ->assert($body);
         } catch (ValidationException $e) {
             return ResponseLib::sendFail(
@@ -44,6 +47,9 @@ class TokenOrderbookController
 
         $cprId = (int)$body['cpr_id'];
         $newValue = (int)round((float)$body['value']);
+        $state = strtoupper(trim((string)$body['state']));
+        $commodityType = trim((string)$body['commodity_type']);
+        $tokenExternalId = trim((string)$body['token_external_id']);
 
         try {
             $this->pdo->beginTransaction();
@@ -58,6 +64,19 @@ class TokenOrderbookController
                 )->withStatus(404);
             }
 
+            $orderTokenExternalId = (string)($token['token_external_id'] ?? '');
+            if ($orderTokenExternalId === '' || strcasecmp($orderTokenExternalId, $tokenExternalId) !== 0) {
+                $this->pdo->rollBack();
+                return ResponseLib::sendFail(
+                    'Token externo informado não confere com o token da CPR',
+                    [
+                        'token_external_id' => $tokenExternalId,
+                        'token_cpr_external_id' => $orderTokenExternalId,
+                    ],
+                    'E_TOKEN_MISMATCH'
+                )->withStatus(409);
+            }
+
             $this->tokenModel->updateCommoditiesValue((int)$token['token_id'], $newValue);
 
             $orderbook = $this->orderbookModel->create([
@@ -65,7 +84,10 @@ class TokenOrderbookController
                 'orderbook_ts' => time(),
                 'orderbook_is_token' => true,
                 'orderbook_amount' => (string)($token['token_commodities_amount'] ?? '0'),
-                'status_id' => 1,
+                'orderbook_state' => $state,
+                'orderbook_commodity_type' => $commodityType,
+                'token_external_id' => $orderTokenExternalId,
+                'status_id' => OrderbookModel::STATUS_OPEN,
                 'user_id' => (int)$token['user_id'],
                 'wallet_id' => (int)$token['wallet_id'],
                 'token_id' => (int)$token['token_id'],

+ 8 - 0
migrations/20260114_add_orderbook_fields.sql

@@ -0,0 +1,8 @@
+BEGIN;
+
+ALTER TABLE "orderbook"
+    ADD COLUMN orderbook_state TEXT NOT NULL DEFAULT '',
+    ADD COLUMN orderbook_commodity_type TEXT NOT NULL DEFAULT '',
+    ADD COLUMN token_external_id TEXT NOT NULL DEFAULT '';
+
+COMMIT;

+ 111 - 0
models/OrderbookModel.php

@@ -5,6 +5,8 @@ namespace Models;
 class OrderbookModel
 {
     private \PDO $pdo;
+    public const STATUS_OPEN = 0;
+    public const STATUS_COMPLETED = 1;
 
     public function __construct()
     {
@@ -22,6 +24,9 @@ class OrderbookModel
      *   orderbook_ts:int,
      *   orderbook_is_token:bool,
      *   orderbook_amount:string,
+     *   orderbook_state:string,
+     *   orderbook_commodity_type:string,
+     *   token_external_id:string,
      *   status_id:int,
      *   user_id:int,
      *   wallet_id:int,
@@ -38,6 +43,9 @@ class OrderbookModel
                 orderbook_ts,
                 orderbook_is_token,
                 orderbook_amount,
+                orderbook_state,
+                orderbook_commodity_type,
+                token_external_id,
                 status_id,
                 user_id,
                 wallet_id,
@@ -49,6 +57,9 @@ class OrderbookModel
                 :orderbook_ts,
                 :orderbook_is_token,
                 :orderbook_amount,
+                :orderbook_state,
+                :orderbook_commodity_type,
+                :token_external_id,
                 :status_id,
                 :user_id,
                 :wallet_id,
@@ -63,6 +74,9 @@ class OrderbookModel
             'orderbook_ts' => $data['orderbook_ts'],
             'orderbook_is_token' => $data['orderbook_is_token'],
             'orderbook_amount' => $data['orderbook_amount'],
+            'orderbook_state' => $data['orderbook_state'],
+            'orderbook_commodity_type' => $data['orderbook_commodity_type'],
+            'token_external_id' => $data['token_external_id'],
             'status_id' => $data['status_id'],
             'user_id' => $data['user_id'],
             'wallet_id' => $data['wallet_id'],
@@ -78,4 +92,101 @@ class OrderbookModel
             'orderbook_ts' => $data['orderbook_ts'],
         ];
     }
+
+    public function findByStateAndCommodity(string $state, string $commodityType, int $statusId = self::STATUS_OPEN): array
+    {
+        $stmt = $this->pdo->prepare(
+            'SELECT 
+                o.orderbook_id,
+                o.orderbook_flag,
+                o.orderbook_ts,
+                o.orderbook_is_token,
+                o.orderbook_amount,
+                o.orderbook_state,
+                o.orderbook_commodity_type,
+                o.token_external_id,
+                o.status_id,
+                o.user_id,
+                o.wallet_id,
+                o.token_id,
+                o.currency_id,
+                o.chain_id,
+                t.token_commodities_value,
+                t.token_commodities_amount
+            FROM "orderbook" o
+            LEFT JOIN "token" t ON t.token_id = o.token_id
+            WHERE UPPER(o.orderbook_state) = UPPER(:state)
+              AND LOWER(o.orderbook_commodity_type) = LOWER(:commodity_type)
+              AND o.status_id = :status_id
+            ORDER BY o.orderbook_ts DESC'
+        );
+
+        $stmt->execute([
+            'state' => $state,
+            'commodity_type' => $commodityType,
+            'status_id' => $statusId,
+        ]);
+
+        return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+    }
+
+    public function findByIdWithToken(int $orderbookId): ?array
+    {
+        $stmt = $this->pdo->prepare(
+            'SELECT 
+                o.*, 
+                t.token_commodities_value, 
+                t.token_commodities_amount,
+                t.token_external_id AS token_table_external_id,
+                w.company_id,
+                w.wallet_address
+             FROM "orderbook" o
+             LEFT JOIN "token" t ON t.token_id = o.token_id
+             LEFT JOIN "wallet" w ON w.wallet_id = o.wallet_id
+             WHERE o.orderbook_id = :orderbook_id
+             LIMIT 1'
+        );
+
+        $stmt->execute(['orderbook_id' => $orderbookId]);
+        $record = $stmt->fetch(\PDO::FETCH_ASSOC);
+
+        return $record ?: null;
+    }
+
+    public function findByTokenExternalId(string $tokenExternalId): ?array
+    {
+        $stmt = $this->pdo->prepare(
+            'SELECT 
+                o.*, 
+                t.token_commodities_value,
+                t.token_commodities_amount,
+                w.company_id,
+                w.wallet_address
+             FROM "orderbook" o
+             LEFT JOIN "token" t ON t.token_id = o.token_id
+             LEFT JOIN "wallet" w ON w.wallet_id = o.wallet_id
+             WHERE o.token_external_id = :token_external_id
+             ORDER BY o.orderbook_id DESC
+             LIMIT 1'
+        );
+
+        $stmt->execute(['token_external_id' => $tokenExternalId]);
+        $record = $stmt->fetch(\PDO::FETCH_ASSOC);
+
+        return $record ?: null;
+    }
+
+    public function updateStatus(int $orderbookId, int $statusId): void
+    {
+        $stmt = $this->pdo->prepare(
+            'UPDATE "orderbook"
+             SET status_id = :status_id
+             WHERE orderbook_id = :orderbook_id'
+        );
+
+        $stmt->execute([
+            'status_id' => $statusId,
+            'orderbook_id' => $orderbookId,
+        ]);
+    }
 }

+ 18 - 0
models/OrderbookPaymentModel.php

@@ -0,0 +1,18 @@
+<?php
+
+namespace Models;
+
+class OrderbookPaymentModel
+{
+    private OrderbookModel $orderbookModel;
+
+    public function __construct()
+    {
+        $this->orderbookModel = new OrderbookModel();
+    }
+
+    public function getOrderbookForPayment(int $orderbookId): ?array
+    {
+        return $this->orderbookModel->findByIdWithToken($orderbookId);
+    }
+}

+ 22 - 0
models/OrderbookSearchModel.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace Models;
+
+class OrderbookSearchModel
+{
+    private OrderbookModel $orderbookModel;
+
+    public function __construct()
+    {
+        $this->orderbookModel = new OrderbookModel();
+    }
+
+    public function searchOpenOrders(string $state, string $commodityType): array
+    {
+        return $this->orderbookModel->findByStateAndCommodity(
+            $state,
+            $commodityType,
+            OrderbookModel::STATUS_OPEN
+        );
+    }
+}

+ 23 - 0
models/OrderbookTransferModel.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace Models;
+
+class OrderbookTransferModel
+{
+    private OrderbookModel $orderbookModel;
+
+    public function __construct()
+    {
+        $this->orderbookModel = new OrderbookModel();
+    }
+
+    public function getByTokenExternalId(string $tokenExternalId): ?array
+    {
+        return $this->orderbookModel->findByTokenExternalId($tokenExternalId);
+    }
+
+    public function markCompleted(int $orderbookId): void
+    {
+        $this->orderbookModel->updateStatus($orderbookId, OrderbookModel::STATUS_COMPLETED);
+    }
+}

+ 2 - 0
models/PaymentModel.php

@@ -5,6 +5,8 @@ namespace Models;
 class PaymentModel
 {
     private \PDO $pdo;
+    public const STATUS_PENDING = 0;
+    public const STATUS_COMPLETED = 1;
 
     public function __construct()
     {

+ 34 - 0
models/WalletModel.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace Models;
+
+class WalletModel
+{
+    private \PDO $pdo;
+
+    public function __construct()
+    {
+        if (isset($GLOBALS['pdo']) && $GLOBALS['pdo'] instanceof \PDO) {
+            $this->pdo = $GLOBALS['pdo'];
+            return;
+        }
+
+        throw new \RuntimeException('Global PDO connection not initialized');
+    }
+
+    public function getPrimaryWalletByCompanyId(int $companyId): ?array
+    {
+        $stmt = $this->pdo->prepare(
+            'SELECT wallet_id, wallet_address, wallet_public_key, wallet_flag
+             FROM "wallet"
+             WHERE company_id = :company_id
+             ORDER BY wallet_id ASC
+             LIMIT 1'
+        );
+
+        $stmt->execute(['company_id' => $companyId]);
+        $wallet = $stmt->fetch(\PDO::FETCH_ASSOC);
+
+        return $wallet ?: null;
+    }
+}

+ 3 - 0
public/index.php

@@ -71,6 +71,9 @@ $app->post('/cpr/history', $authJwt, \Controllers\CprQueryController::class);
 $app->post('/wallet/tokens', $authJwt, \Controllers\WalletTokensController::class);
 $app->post('/token/get', $authJwt, \Controllers\TokenGetController::class);
 $app->post('/token/orderbook', $authJwt, \Controllers\TokenOrderbookController::class);
+$app->post('/orderbook/filter', $authJwt, \Controllers\OrderbookFilterController::class);
+$app->post('/orderbook/payment', $authJwt, \Controllers\OrderbookPaymentController::class);
+$app->post('/orderbook/transfer', $authJwt, \Controllers\OrderbookTransferController::class);
 
 $app->post('/b3/token', \Controllers\B3TokenController::class);
 $app->post('/b3/cpr/register', $authJwt, \Controllers\B3CprRegisterController::class);

+ 2 - 1
services/PaymentService.php

@@ -8,7 +8,7 @@ use Models\PaymentModel;
 class PaymentService
 {
     private const DEFAULT_EXPIRES = 1800;
-    private const DEFAULT_STATUS_ID = 0;
+    private const DEFAULT_STATUS_ID = PaymentModel::STATUS_PENDING;
 
     private PaymentModel $paymentModel;
     private string $wooviCliPath;
@@ -41,6 +41,7 @@ class PaymentService
         return [
             'payment_id' => $paymentId,
             'payment_code' => $pixCode,
+            'payment_external_id' => $externalId,
         ];
     }
 

+ 49 - 0
services/TokenTransferService.php

@@ -0,0 +1,49 @@
+<?php
+
+namespace Services;
+
+use Libs\BashExecutor;
+
+class TokenTransferService
+{
+    private string $easyCliPath;
+
+    public function __construct(?string $easyCliPath = null)
+    {
+        $this->easyCliPath = $easyCliPath ?? dirname(__DIR__) . '/bin/easycli';
+    }
+
+    public function transferFrom(string $fromAddress, string $toAddress, string $tokenExternalId): array
+    {
+        $from = trim($fromAddress);
+        $to = trim($toAddress);
+        $token = trim($tokenExternalId);
+
+        if ($from === '' || $to === '' || $token === '') {
+            throw new \InvalidArgumentException('Parâmetros inválidos para transferência de token.');
+        }
+
+        if (!is_file($this->easyCliPath) || !is_executable($this->easyCliPath)) {
+            throw new \RuntimeException('easycli executable not found or not executable');
+        }
+
+        $command = sprintf(
+            '%s token transferFrom %s %s %s',
+            escapeshellarg($this->easyCliPath),
+            escapeshellarg($from),
+            escapeshellarg($to),
+            escapeshellarg($token)
+        );
+
+        $result = BashExecutor::run($command, 120);
+        if (($result['exitCode'] ?? 1) !== 0) {
+            $message = $result['error'] ?: $result['output'] ?: 'Unknown easycli error';
+            throw new \RuntimeException('easycli transferFrom failed: ' . $message);
+        }
+
+        return [
+            'output' => trim((string)($result['output'] ?? '')),
+            'error' => trim((string)($result['error'] ?? '')),
+        ];
+    }
+}