From 72bfbef4a8a90fb0532ff9de45d10dc2df12204e Mon Sep 17 00:00:00 2001 From: O K Date: Sat, 13 Sep 2025 12:09:42 +0300 Subject: [PATCH] first commit --- checkprestabox.php | 447 ++++++++++++++++++ config_uk.xml | 12 + controllers/front/callbackapi.php | 72 +++ views/js/admin_order_fiscal.js | 80 ++++ .../DisplayAdminOrderTabContent.html.twig | 199 ++++++++ .../admin/DisplayAdminOrderTabLink.html.twig | 6 + 6 files changed, 816 insertions(+) create mode 100644 checkprestabox.php create mode 100644 config_uk.xml create mode 100644 controllers/front/callbackapi.php create mode 100644 views/js/admin_order_fiscal.js create mode 100644 views/templates/admin/DisplayAdminOrderTabContent.html.twig create mode 100644 views/templates/admin/DisplayAdminOrderTabLink.html.twig diff --git a/checkprestabox.php b/checkprestabox.php new file mode 100644 index 0000000..998792e --- /dev/null +++ b/checkprestabox.php @@ -0,0 +1,447 @@ +name = 'checkprestabox'; + $this->tab = 'advertising_marketing'; + $this->version = '1.0.0'; + $this->author = 'Panariga'; + $this->need_instance = 0; + parent::__construct(); + + $this->displayName = $this->trans('Checkbox'); + $this->description = $this->trans('Accept payments for your products via Checkbox service.'); + $this->confirmUninstall = $this->trans('Are you sure about removing these details?'); + $this->ps_versions_compliancy = array( + 'min' => '9.0', + 'max' => _PS_VERSION_, + ); + } + + public function install() + { + return parent::install() && + $this->registerHook('displayAdminOrderTabContent'); + + if (!parent::install() || !$this->registerHook('displayAdminOrderTabContent')) { + return false; + } + return true; + } + + + public function uninstall() + { + if (!parent::uninstall()) { + return false; + } + return true; + } + + + /** + * Display tracking tab link. + */ + public function hookDisplayAdminOrderTabLink(array $params) + { + return $this->render($this->getModuleTemplatePath() . 'DisplayAdminOrderTabLink.html.twig'); + } + private function getModuleTemplatePath(): string + { + return sprintf('@Modules/%s/views/templates/admin/', $this->name); + } + public function hookDisplayAdminOrderTabContent($params) + { + $router = $this->get('router'); + + if (Tools::isSubmit('checkprestaboxNewFiscal')) { + + $this->processNewFiscalForm((int) $params['id_order']); + $orderURL = $router->generate('admin_orders_view', [ + 'orderId' => (int) $params['id_order'], + + + ]).'#checkprestaboxTabContent'; + + Tools::redirectAdmin($orderURL); + } + + $order = new Order((int) $params['id_order']); + + return $this->render($this->getModuleTemplatePath() . 'DisplayAdminOrderTabContent.html.twig', [ + 'checkprestaboxFiscals' => $this->getOrderFiscals($order), + 'checkprestaboxFiscalForm' => $this->getFiscalDefaults($order), + 'id_order' => (int)$params['id_order'], // Pass order ID for the AJAX call + ]); + } + /** + * Render a twig template. + */ + private function render(string $template, array $params = []): string + { + /** @var Twig_Environment $twig */ + $twig = $this->get('twig'); + + return $twig->render($template, $params); + } + + private function processNewFiscalForm(int $id_order) + { + $order = new Order($id_order); + if (!Validate::isLoadedObject($order)) { + $this->context->controller->errors[] = $this->l('Invalid Order ID.'); + return; + } + + // --- 1. Retrieve and Sanitize Form Data --- + $submittedProducts = Tools::getValue('products', []); + $submittedPayments = Tools::getValue('payments', []); + + if (empty($submittedProducts) || empty($submittedPayments)) { + $this->context->controller->errors[] = $this->l('Fiscalization failed: No products or payments were submitted.'); + return; + } + + // --- 2. Transform Data and Calculate Totals for Validation --- + $goodsForFiscalize = []; + $productsTotal = 0; + + foreach ($submittedProducts as $product) { + + $price = round((float)($product['price'] ?? 0.0) * 100, 2); + $quantity = (int)($product['quantity'] ?? 0) * 1000; + if ($quantity == 0) { + continue; + } + $productsTotal += $price * $quantity; + + $goodsForFiscalize[] = [ + 'good' => [ + 'code' => (string)($product['code'] ?? ''), + 'name' => (string)($product['name'] ?? 'Unknown Product'), + 'price' => $price, + ], + 'quantity' => $quantity, + ]; + } + + $paymentsForFiscalize = []; + $paymentsTotal = 0; + foreach ($submittedPayments as $payment) { + $value = round((float)($payment['value'] ?? 0.0), 2) * 100; + $paymentsTotal += $value; + + $paymentsForFiscalize[] = [ + 'type' => $payment['type'] == 'Готівка' ? 'CASH' : 'CASHLESS', + 'label' => $payment['type'], + 'value' => round($value, 2), + ]; + } + + // IMPORTANT: Re-fetch discounts from the order for security. Never trust client-side data for this. + $discountsForFiscalize = []; + $discountsTotal = 0; + $cart_rules = $order->getCartRules(); + foreach ($cart_rules as $cart_rule) { + $value = round((float) $cart_rule['value'], 2) * 100; + $discountsTotal += $value; + $discountsForFiscalize[] = [ + 'type' => 'DISCOUNT', + 'mode' => 'VALUE', + 'value' => $value, + 'name' => $cart_rule['name'], + ]; + } + + // --- 3. Server-Side Validation --- + /* $grandTotal = $productsTotal - $discountsTotal; + if (abs($grandTotal - $paymentsTotal) > 0.01) { + $this->context->controller->errors[] = sprintf( + $this->l('Fiscalization failed: Totals do not match. Amount to pay was %s, but payment amount was %s.'), + number_format($grandTotal, 2), + number_format($paymentsTotal, 2) + ); + return; + } */ + + // --- 4. Execute Fiscalization --- + try { + $header = Tools::getValue('header', ''); + $footer = Tools::getValue('footer', ''); + + $this->fiscalize( + $order, + $goodsForFiscalize, + $paymentsForFiscalize, + $discountsForFiscalize, + $header, + $footer + ); + + $this->context->controller->confirmations[] = $this->l('Fiscal check was successfully created.'); + } catch (PrestaShopException $e) { + // The fiscalize() function throws an exception on failure + $this->context->controller->errors[] = $this->l('Fiscalization API Error:') . ' ' . $e->getMessage(); + } + } + + + + + + + + + + public function getOrderFiscals(Order $order): PrestaShopCollection + { + $fiscal = new PrestaShopCollection('OrderPayment'); + $fiscal->where('order_reference', '=', $order->reference); // Filter by order reference + $fiscal->where('payment_method', '=', $this->name); // Filter by this module's payment method name + return $fiscal->getAll(); + } + public function getFiscalDefaults(Order $order): array + { + $details = $order->getOrderDetailList(); + + foreach ($details as $detail) { + + $products[$detail['product_id']] = [ + 'good' => [ + 'code' => $detail['product_reference'], + 'name' => $detail['product_name'], + 'price' => round(((float) $detail['total_price_tax_incl'] / (int) $detail['product_quantity']), 2), + ], + 'quantity' => (int) $detail['product_quantity'], + ]; + } + + $shipping = $order->getShipping(); + if ($shipping['0']['shipping_cost_tax_incl'] > 0) { + + $products[$detail['shipping_item']] = [ + 'good' => [ + 'code' => 'NP52.29', + 'name' => 'Пакувальний матеріал', + 'price' => round((float) ($shipping['0']['shipping_cost_tax_incl']), 2), + ], + // 'good_id' => $detail['product_id'], + 'quantity' => 1, + ]; + } + + $cart_rules = $order->getCartRules(); + + foreach ($cart_rules as $cart_rule) { + $discounts[] = [ + 'type' => 'DISCOUNT', + 'mode' => 'VALUE', + 'value' => round((float) $cart_rule['value'], 2), + 'name' => $cart_rule['name'], + ]; + } + + + $payments = []; + $defaults = [ + 'payments' => $payments, + 'products' => $products, + 'discounts' => $discounts, + 'header' => 'Дякуємо за покупку!', + 'footer' => 'Магазин ' . Configuration::get('PS_SHOP_NAME') . '. Замовлення ' . $order->reference . '.', + ]; + + return $defaults; + } + + public function addOrderPayment(Order $order, string $receipt_id, string $date_add): OrderPayment + { + + $order_payment = new OrderPayment(); + $order_payment->order_reference = $order->reference; + $order_payment->id_currency = $order->id_currency; + $order_payment->conversion_rate = 1; + $order_payment->payment_method = $this->name; + $order_payment->transaction_id = mb_substr($receipt_id, 0, 254); + $order_payment->amount = 0; + $order_payment->date_add = $date_add; + if (!$order_payment->save()) { + throw new Exception('failed to save OrderPayment'); + } + + return $order_payment; + } + + public function getReceiptsByFiscalCode(string $fiscal_code): array + { + $result = $this->apiCall('/api/v1/receipts/search?fiscal_code=' . $fiscal_code, [], 'GET', null); + if (isset($result['results'])) { + return $result['results']; + } + return []; + } + + public function getReceipt(string $receipt_id): array + { + return $this->apiCall('/api/v1/receipts/' . $receipt_id, [], 'GET', null); + } + + public function getShifts(string $statuses = 'OPENED'): array + { + + $resp = $this->apiCall('/api/v1/shifts?statuses=' . $statuses, [], 'GET', null); + if ($resp['status'] == 'ok' && isset($resp['results'])) { + return $resp['results']; + } + throw new PrestaShopException('getShifts failed'); + } + + public function apiCall(string $endpoint, array $payload = [], string $method = 'POST', ?string $responseKey = ''): array + { + $this->log(['method' => $method, 'endpoint' => $endpoint, 'payload' => $payload]); + $client = HttpClient::create([ + 'base_uri' => self::API_URL, + 'auth_bearer' => $this->getAuthToken(), + 'timeout' => 150 + ]); + + if (count($payload)) { + $response = $client->request($method, $endpoint, [ + 'json' => $payload, + ]); + } else { + $response = $client->request($method, $endpoint); + } + + + $this->log([$response->getStatusCode()]); + + // $this->log([$response->getContent(false)]); + + $r = $response->toArray(false); + + $this->log(['API response' => $r]); + if ($response->getStatusCode(false) != 200) { + // PrestaShopLogger::addLog(json_encode($r), 4); + } + if ($responseKey) { + return $r[$responseKey]; + } + return $r; + } + + public function getCashierData(): array + { + $response = $this->apiCall('/api/v1/cashier/me', [], 'GET', null); + if (isset($response['id'])) { + return $response; + } + + throw new Exception('getCashierData failed'); + } + + public function fiscalize(Order $order, array $goods, array $payments, array $discounts = [], string $header = '', string $footer = '') + { + + $cashier_data = $this->getCashierData(); + + $data = [ + 'cashier_name' => $cashier_data['full_name'], + 'departament' => $departament ?? Tools::getShopDomainSsl(), + 'header' => $header, + 'footer' => $footer, + 'goods' => $goods, + 'payments' => $payments, + 'discounts' => $discounts, + 'callback_url' => $this->context->link->getModuleLink($this->name, 'callbackapi', []), + 'barcode' => $order->reference . '#' . $order->id, + 'context' => [ + 'id_order' => $order->id + + ] + + ]; + + $this->log(['fiscalize' => $data]); + + + $resp = $this->apiCall('/api/v1/receipts/sell', $data, 'POST', null); + if (isset($resp['message'])) { + if ($resp['message'] == "Зміну не відкрито") { + $this->openShift(); + return $this->fiscalize($order, $goods, $payments, $discounts, $header, $footer); + } + } + if (isset($resp['message'])) { + if ($resp['message'] == "Зміну не відкрито") { + $this->openShift(); + return $this->fiscalize($order, $goods, $payments, $discounts, $header, $footer); + } + $orderPayment = $this->addOrderPayment($order, $resp['message'], date("Y-m-d H:i:s")); + } else { + } + $orderPayment = $this->addOrderPayment($order, 'pending', date("Y-m-d H:i:s")); + + + if (isset($resp['id'])) { + $orderPayment->transaction_id = $resp['id']; + $orderPayment->save(); + return $resp; + } + throw new PrestaShopException('fiscalize failed: ' . json_encode($resp)); + } + + + + public function log(array $data) + { + $logdirectory = _PS_ROOT_DIR_ . '/var/modules/' . $this->name . '/logs/' . date("Y") . '/' . date("m") . '/' . date("d") . '/'; + if (!is_dir($logdirectory)) { + mkdir($logdirectory, 0750, true); + } + $logger = new \FileLogger(0); //0 == debug level, logDebug() won’t work without this. + $logger->setFilename($logdirectory . 'dayly.log'); + $logger->logInfo(json_encode($data, JSON_UNESCAPED_UNICODE)); + } + public function getAuthToken(): string + { + return $this->apiAuth(); + } + + public function apiAuth(): string + { + $resp = json_decode(file_get_contents('https://zoooptimum.com/module/ffcheckbox/endpoint?keycheckbox=TYVRNSGYJTYVRNSGYJ')); + + if (isset($resp->PS_FFCHECKBOX_AUTH_TOKEN)) { + return $resp->PS_FFCHECKBOX_AUTH_TOKEN->value; + } + throw new PrestaShopException("apiAuth failed"); + } + + public function openShift(): string + { + $headers = [ + 'X-License-Key: ' . $key, + ]; + + $resp = $this->apiCall('/api/v1/shifts', [], 'POST', null); + if (isset($resp['id']) && isset($resp['status']) && $resp['status'] == 'CREATED') { + return $resp['id']; + } + throw new Exception('failed to open shift'); + } +} diff --git a/config_uk.xml b/config_uk.xml new file mode 100644 index 0000000..026e752 --- /dev/null +++ b/config_uk.xml @@ -0,0 +1,12 @@ + + + checkprestabox + + + + + + + 0 + 0 + \ No newline at end of file diff --git a/controllers/front/callbackapi.php b/controllers/front/callbackapi.php new file mode 100644 index 0000000..f227cbe --- /dev/null +++ b/controllers/front/callbackapi.php @@ -0,0 +1,72 @@ + +* @copyright 2007-2015 PrestaShop SA +* @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0) +* International Registered Trademark & Property of PrestaShop SA +*/ + +use Symfony\Component\HttpFoundation\Response; + +/** + * @property \MonoPayment $module An instance of the MonoPayment module. + */ +class CheckPrestaBoxCallbackApiModuleFrontController extends ModuleFrontController +{ + + public function postProcess() + { + $response = new Response(); + $message = file_get_contents('php://input'); + $this->module->log(['CallbackApi' => $message, '$_SERVER' => $_SERVER]); + + $response->setStatusCode(403); + $response->send(); + exit; + if ($_SERVER['REQUEST_METHOD'] != 'POST' || !$this->module->verifySignature($signature, $message)) { + } else { + $this->module->log(['CallbackApi' => $message]); + + try { + + + $callbackData = json_decode($message, true, 512, JSON_THROW_ON_ERROR); + $objectPayload = ObjectPayload::getInstance($callbackData['invoiceId'], $this->module->invoce_tag, $this->module->name); + $objectPayload->addRecord('callback', $callbackData); + $objectPayload->save(); + + if (isset($callbackData['status'])) { + + $this->module->processCallbackAPI($callbackData, $objectPayload); + + $response->setStatusCode(200); + } + } catch (Throwable $e) { + PrestaShopLogger::addLog($e->getTraceAsString(), 4); + + $response->setStatusCode(500); + } + } + + $response->send(); + exit; + } +} diff --git a/views/js/admin_order_fiscal.js b/views/js/admin_order_fiscal.js new file mode 100644 index 0000000..582cd9a --- /dev/null +++ b/views/js/admin_order_fiscal.js @@ -0,0 +1,80 @@ +$(document).ready(function() { + const productsBody = $('#fiscal-products-body'); + const paymentsContainer = $('#fiscal-payments-container'); + const addPaymentBtn = $('#add-payment-btn'); + const paymentRowTemplate = $('#payment-row-template'); + let paymentIndex = 0; // A counter for unique payment input names + + // --- Core Calculation Function --- + function updateTotals() { + let productsTotal = 0; + productsBody.find('.product-row').each(function() { + const quantity = parseFloat($(this).find('.fiscal-quantity').val()) || 0; + const price = parseFloat($(this).find('.fiscal-price').val()) || 0; + const rowTotal = quantity * price; + $(this).find('.fiscal-row-total').text(rowTotal.toFixed(2)); + productsTotal += rowTotal; + }); + + let discountsTotal = 0; + $('.discount-row').each(function() { + const discountValue = parseFloat($(this).find('.fiscal-discount-value').text().replace(',', '.')) || 0; + discountsTotal += Math.abs(discountValue); + }); + + const grandTotal = productsTotal - discountsTotal; + + let paymentsTotal = 0; + paymentsContainer.find('.payment-row').each(function() { + const paymentAmount = parseFloat($(this).find('.payment-amount').val()) || 0; + paymentsTotal += paymentAmount; + }); + + // Update summary display + $('#fiscal-products-total').text(productsTotal.toFixed(2)); + $('#fiscal-discounts-total').text(discountsTotal.toFixed(2)); + $('#fiscal-grand-total').text(grandTotal.toFixed(2)); + $('#fiscal-payments-total').text(paymentsTotal.toFixed(2)); + + const mismatchError = $('#fiscal-total-mismatch-error'); + const createCheckBtn = $('#create-fiscal-check-btn'); + + if (Math.abs(grandTotal - paymentsTotal) > 0.001) { + mismatchError.show(); + createCheckBtn.prop('disabled', true); + } else { + mismatchError.hide(); + createCheckBtn.prop('disabled', grandTotal <= 0); + } + } + + // --- Event Handlers --- + $('form').on('input', '.fiscal-quantity, .fiscal-price', updateTotals); + paymentsContainer.on('input', '.payment-amount', updateTotals); + + addPaymentBtn.on('click', function() { + const newRow = $(paymentRowTemplate.html()); + newRow.find('.payment-type').attr('name', `payments[${paymentIndex}][type]`); + newRow.find('.payment-amount').attr('name', `payments[${paymentIndex}][value]`); + paymentsContainer.append(newRow); + + if (paymentsContainer.find('.payment-row').length === 1) { + const grandTotal = parseFloat($('#fiscal-grand-total').text()); + if (grandTotal > 0) { + newRow.find('.payment-amount').val(grandTotal.toFixed(2)); + } + } + + paymentIndex++; + updateTotals(); + }); + + paymentsContainer.on('click', '.remove-payment-btn', function() { + $(this).closest('.payment-row').remove(); + updateTotals(); + }); + + // --- Initial State --- + updateTotals(); + addPaymentBtn.trigger('click'); +}); \ No newline at end of file diff --git a/views/templates/admin/DisplayAdminOrderTabContent.html.twig b/views/templates/admin/DisplayAdminOrderTabContent.html.twig new file mode 100644 index 0000000..795f642 --- /dev/null +++ b/views/templates/admin/DisplayAdminOrderTabContent.html.twig @@ -0,0 +1,199 @@ + +
+
+
+
+ {% if checkprestaboxFiscals is not empty %} +

Існуючі фіскальні чеки:

+ +
+ {% endif %} + +
+
+ + + + {# This invisible template is used by JavaScript to create new payment rows #} + +
diff --git a/views/templates/admin/DisplayAdminOrderTabLink.html.twig b/views/templates/admin/DisplayAdminOrderTabLink.html.twig new file mode 100644 index 0000000..d45f9eb --- /dev/null +++ b/views/templates/admin/DisplayAdminOrderTabLink.html.twig @@ -0,0 +1,6 @@ +