feat: moneta 2.0, MONETA.Assistant payment module for PHPShop 6.7+
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop;
|
||||
|
||||
/**
|
||||
* Èäåíòèôèêàòîð CMS è ìîäóëÿ äëÿ `MNT_CMS` — îäíà ñòðîêà â ôîðìå è â îòâåòàõ
|
||||
* Check/Pay URL: `PHPShop v<âåðñèÿ>_PHP <âåðñèÿ>_Moneta v<âåðñèÿ>`
|
||||
* Âåðñèÿ ìîäóëÿ — êàê å¸ õðàíèò PHPShop (`ModuleInfo::VERSION`, äâà ÷èñëà): `Moneta v1.0`.
|
||||
*/
|
||||
final class CmsInfo
|
||||
{
|
||||
private const CMS_NAME = 'PHPShop';
|
||||
private const MODULE_NAME = 'Moneta';
|
||||
private const SEPARATOR = '_';
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public static function format(string $cmsVersion, string $phpVersion, string $moduleVersion): string
|
||||
{
|
||||
return sprintf(
|
||||
'%s v%s' . self::SEPARATOR . 'PHP %s' . self::SEPARATOR . '%s v%s',
|
||||
self::CMS_NAME,
|
||||
$cmsVersion,
|
||||
$phpVersion,
|
||||
self::MODULE_NAME,
|
||||
$moduleVersion,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Âåðñèÿ PHPShop èç `[upload] version` êîíôèãà ÿäðà: `670` -> `6.7.0`.
|
||||
*/
|
||||
public static function hostVersion(string $uploadVersion): string
|
||||
{
|
||||
if (preg_match('/\A[0-9]{3,}\z/', $uploadVersion) !== 1) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
return implode('.', str_split($uploadVersion));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop;
|
||||
|
||||
/**
|
||||
* Постоянные модуля. Всё, что здесь хранится, персистится у мерчанта
|
||||
* (`phpshop_modules.path`, `phpshop_payment_systems.id`, версия в таблице настроек) — не менять.
|
||||
*/
|
||||
final class ModuleInfo
|
||||
{
|
||||
/** Каталог модуля и ключ `phpshop_modules.path`; совпадает со встроенным модулем PHPShop, который заменяем. */
|
||||
public const PATH = 'moneta';
|
||||
|
||||
/** Способ оплаты `phpshop_payment_systems.id` встроенного модуля — заказы мерчантов уже ссылаются на него. */
|
||||
public const PAYMENT_METHOD_ID = 10048;
|
||||
|
||||
/**
|
||||
* Версия модуля: та же, что в `install/module.xml` и в `version` таблицы настроек.
|
||||
* Формат PHPShop — два числа: ядро сравнивает строки версий оператором `>` (`PHPShopGUI::setPay()`),
|
||||
* а три числа PHP сравнивает посимвольно. В `MNT_CMS` уходит так же ({@see CmsInfo::format()}).
|
||||
*
|
||||
* Должна быть больше {@see self::BUILT_IN_VERSION}: иначе у мерчанта со встроенным модулем
|
||||
* не появится кнопка «Обновить модуль» и миграция `updates/1.2/` не запустится.
|
||||
*/
|
||||
public const VERSION = '2.0';
|
||||
|
||||
/**
|
||||
* Версия, которую встроенный модуль PHPShop 6.7.0 записывает в таблицу настроек при установке
|
||||
* (в его `module.xml` при этом `1.0`). По ней ядро выбирает файл миграции `updates/<версия>/`.
|
||||
*/
|
||||
public const BUILT_IN_VERSION = '1.2';
|
||||
|
||||
private function __construct() {}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Äåíåæíàÿ ñóììà â ôîðìàòå MONETA.Assistant: äåñÿòè÷íàÿ ñòðîêà ðîâíî ñ äâóìÿ
|
||||
* çíàêàìè ïîñëå òî÷êè (`120.25`, `100.00`), áåç çíàêà è ðàçäåëèòåëåé òûñÿ÷.
|
||||
*
|
||||
* Âíóòðè — òîëüêî ñòðîêà è öåëûå êîïåéêè; `float` ïîÿâëÿåòñÿ ëèøü íà ãðàíèöå
|
||||
* ñ õîñòîì ({@see self::fromFloat()}): PHPShop õðàíèò `phpshop_orders.sum` êàê FLOAT.
|
||||
*/
|
||||
final class Money
|
||||
{
|
||||
/** Âåðõíÿÿ ãðàíèöà ñóììû ïî JSON-ñõåìå Check URL (`number_amount.maximum`, `cmsspecification.pdf`). */
|
||||
public const MAX = '10000000000000.00';
|
||||
|
||||
/** Öåëàÿ ÷àñòü — äî 14 çíàêîâ áåç âåäóùèõ íóëåé, äðîáíàÿ — ðîâíî äâà. */
|
||||
private const PATTERN = '/\A(?:0|[1-9][0-9]{0,13})\.[0-9]{2}\z/';
|
||||
|
||||
/** @var string */
|
||||
private $value;
|
||||
|
||||
private function __construct(string $value)
|
||||
{
|
||||
$this->value = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ñòðîãèé ðàçáîð äåñÿòè÷íîé ñòðîêè; âñ¸, ÷òî íå `^\d+\.\d{2}$`, îòêëîíÿåòñÿ.
|
||||
*/
|
||||
public static function fromDecimal(string $value): self
|
||||
{
|
||||
if (preg_match(self::PATTERN, $value) !== 1) {
|
||||
throw new InvalidArgumentException('Ñóììà äîëæíà áûòü äåñÿòè÷íîé ñòðîêîé ñ äâóìÿ çíàêàìè ïîñëå òî÷êè.');
|
||||
}
|
||||
|
||||
$money = new self($value);
|
||||
if ($money->toMinorUnits() > (new self(self::MAX))->toMinorUnits()) {
|
||||
throw new InvalidArgumentException('Ñóììà ïðåâûøàåò äîïóñòèìûé ìàêñèìóì.');
|
||||
}
|
||||
|
||||
return $money;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ñóììà â êîïåéêàõ.
|
||||
*/
|
||||
public static function fromMinorUnits(int $minorUnits): self
|
||||
{
|
||||
if ($minorUnits < 0) {
|
||||
throw new InvalidArgumentException('Ñóììà íå ìîæåò áûòü îòðèöàòåëüíîé.');
|
||||
}
|
||||
|
||||
return self::fromDecimal(sprintf('%d.%02d', intdiv($minorUnits, 100), $minorUnits % 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ãðàíèöà ñ õîñòîì: îêðóãëåíèå äî êîïååê äåëàåòñÿ çäåñü îäèí ðàç, äàëüøå `float` íå èñïîëüçóåòñÿ.
|
||||
*/
|
||||
public static function fromFloat(float $amount): self
|
||||
{
|
||||
if (($amount < 0) || !is_finite($amount)) {
|
||||
throw new InvalidArgumentException('Ñóììà õîñòà äîëæíà áûòü êîíå÷íûì íåîòðèöàòåëüíûì ÷èñëîì.');
|
||||
}
|
||||
|
||||
return self::fromDecimal(number_format($amount, 2, '.', ''));
|
||||
}
|
||||
|
||||
public function toDecimal(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function toMinorUnits(): int
|
||||
{
|
||||
[$units, $cents] = explode('.', $this->value);
|
||||
|
||||
return ((int) $units * 100) + (int) $cents;
|
||||
}
|
||||
|
||||
public function isPositive(): bool
|
||||
{
|
||||
return ($this->value !== '0.00');
|
||||
}
|
||||
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return ($this->value === $other->value);
|
||||
}
|
||||
|
||||
public function add(self $other): self
|
||||
{
|
||||
return self::fromMinorUnits($this->toMinorUnits() + $other->toMinorUnits());
|
||||
}
|
||||
|
||||
public function multiply(int $quantity): self
|
||||
{
|
||||
if ($quantity < 0) {
|
||||
throw new InvalidArgumentException('Êîëè÷åñòâî íå ìîæåò áûòü îòðèöàòåëüíûì.');
|
||||
}
|
||||
|
||||
return self::fromMinorUnits($this->toMinorUnits() * $quantity);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop;
|
||||
|
||||
/**
|
||||
* Òåëåôîí ïîêóïàòåëÿ â ôîðìàòå `+<öèôðû>` — äëÿ `MNT_SUBSCRIBER_ID` è äàííûõ ÷åêà.
|
||||
* PHPShop õðàíèò åãî êàê ââ¸ë ïîêóïàòåëü: `+7(926) 111-11-11`, `8 926 111 11 11`, `9261111111`.
|
||||
*/
|
||||
final class PhoneNumber
|
||||
{
|
||||
/** E.164: íå áîëüøå 15 öèôð. */
|
||||
private const MAX_DIGITS = 15;
|
||||
private const MIN_DIGITS = 10;
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* Íîðìàëèçîâàííûé íîìåð èëè null, åñëè ñòðîêà íà òåëåôîí íå ïîõîæà.
|
||||
* Ðîññèéñêèå íîìåðà ïðèâîäÿòñÿ ê `+7`: `8XXXXXXXXXX` è äåñÿòèçíà÷íûé `9XXXXXXXXX`.
|
||||
*/
|
||||
public static function normalize(string $raw): ?string
|
||||
{
|
||||
if (preg_match('/[^0-9+()\-\s.]/', $raw) === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$digits = preg_replace('/[^0-9]/', '', $raw) ?? '';
|
||||
if ((strlen($digits) === 11) && ($digits[0] === '8')) {
|
||||
$digits = '7' . substr($digits, 1);
|
||||
} elseif ((strlen($digits) === 10) && ($digits[0] === '9')) {
|
||||
$digits = '7' . $digits;
|
||||
}
|
||||
|
||||
$length = strlen($digits);
|
||||
if (($length < self::MIN_DIGITS) || ($length > self::MAX_DIGITS)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '+' . $digits;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop;
|
||||
|
||||
/**
|
||||
* Ïóíêòû âûïàäàþùåãî ñïèñêà íàñòðîåê â ôîðìàòå `PHPShopGUI::setSelect()`: `[ïîäïèñü, çíà÷åíèå, òåêóùåå]`.
|
||||
*/
|
||||
final class SelectOptions
|
||||
{
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $labels çíà÷åíèå -> ïîäïèñü, â ïîðÿäêå âûâîäà
|
||||
* @param string $current çíà÷åíèå èç íàñòðîåê — ïóíêò ñ íèì áóäåò âûáðàí
|
||||
*
|
||||
* @return array<int, array{0: string, 1: string, 2: string}>
|
||||
*/
|
||||
public static function fromLabels(array $labels, string $current): array
|
||||
{
|
||||
$options = [];
|
||||
foreach ($labels as $value => $label) {
|
||||
$options[] = [$label, (string) $value, $current];
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop;
|
||||
|
||||
use MonetaPhpShop\protocol\PaymentServer;
|
||||
use MonetaPhpShop\protocol\Signature;
|
||||
use MonetaPhpShop\receipt\PaymentMethod;
|
||||
use MonetaPhpShop\receipt\PaymentObject;
|
||||
use MonetaPhpShop\receipt\TaxSystem;
|
||||
use MonetaPhpShop\receipt\VatSetting;
|
||||
|
||||
/**
|
||||
* Íàñòðîéêè ìîäóëÿ — ñòðîêà `phpshop_modules_moneta_system`. Òåêñòîâûå ïîëÿ — â êîäèðîâêå
|
||||
* âèòðèíû ìîäóëÿ (cp1251): ïåðåêîäèðóåò èõ {@see \MonetaPhpShop\host\SettingsRepository}.
|
||||
*/
|
||||
final class Settings
|
||||
{
|
||||
/** Ñòàòóñ PHPShop «Íîâûé çàêàç» — â íàñòðîéêå «Îïëàòà ïðè ñòàòóñå» çíà÷èò «ñðàçó ïîñëå îôîðìëåíèÿ». */
|
||||
public const STATUS_NEW = 0;
|
||||
|
||||
/** Ñòàòóñ, êîòîðûé ÿäðî PHPShop ñîçäà¸ò äëÿ îïëà÷åííûõ çàêàçîâ (`PHPShopPaymentResult::set_order_status_101()`). */
|
||||
public const STATUS_PAID_BY_GATEWAY = 101;
|
||||
|
||||
/** @var string */
|
||||
private $accountId;
|
||||
|
||||
/** @var string */
|
||||
private $integrityCode;
|
||||
|
||||
/** @var PaymentServer */
|
||||
private $paymentServer;
|
||||
|
||||
/** @var bool */
|
||||
private $testMode;
|
||||
|
||||
/** @var bool */
|
||||
private $fiscalization;
|
||||
|
||||
/** @var string */
|
||||
private $receiptEmail;
|
||||
|
||||
/** @var string */
|
||||
private $paymentDescription;
|
||||
|
||||
/** @var PaymentMethod */
|
||||
private $paymentMethod;
|
||||
|
||||
/** @var PaymentObject */
|
||||
private $paymentObject;
|
||||
|
||||
/** @var TaxSystem */
|
||||
private $taxSystem;
|
||||
|
||||
/** @var VatSetting */
|
||||
private $goodsVat;
|
||||
|
||||
/** @var VatSetting */
|
||||
private $deliveryVat;
|
||||
|
||||
/** @var int */
|
||||
private $payableStatus;
|
||||
|
||||
/** @var int */
|
||||
private $paidStatus;
|
||||
|
||||
/** @var string */
|
||||
private $version;
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row ñòðîêà òàáëèöû íàñòðîåê
|
||||
*/
|
||||
public static function fromRow(array $row): self
|
||||
{
|
||||
$settings = new self();
|
||||
$settings->accountId = trim(self::string($row, 'account_id'));
|
||||
$settings->integrityCode = self::string($row, 'integrity_code');
|
||||
$settings->paymentServer = PaymentServer::fromSetting(self::string($row, 'payment_server'));
|
||||
$settings->testMode = self::string($row, 'test_mode') === '1';
|
||||
$settings->fiscalization = self::string($row, 'fiscalization') === '1';
|
||||
$settings->receiptEmail = trim(self::string($row, 'receipt_email'));
|
||||
$settings->paymentDescription = trim(self::string($row, 'title_end'));
|
||||
$settings->paymentMethod = PaymentMethod::fromSetting(self::string($row, 'payment_method'));
|
||||
$settings->paymentObject = PaymentObject::fromSetting(self::string($row, 'payment_object'));
|
||||
$settings->taxSystem = TaxSystem::fromSetting(self::string($row, 'sno'));
|
||||
$settings->goodsVat = VatSetting::fromSetting(self::string($row, 'goods_vat'));
|
||||
$settings->deliveryVat = VatSetting::fromSetting(self::string($row, 'delivery_vat'));
|
||||
$settings->payableStatus = (int) self::string($row, 'status');
|
||||
$paidStatus = (int) self::string($row, 'paid_status');
|
||||
$settings->paidStatus = ($paidStatus > 0) ? $paidStatus : self::STATUS_PAID_BY_GATEWAY;
|
||||
$settings->version = self::string($row, 'version');
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ìîæíî ïðèíèìàòü îïëàòó: çàäàí íîìåð ñ÷¸òà (òîëüêî öèôðû) è êîä ïðîâåðêè öåëîñòíîñòè äàííûõ.
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return preg_match('/\A[0-9]{1,20}\z/', $this->accountId) === 1 && $this->integrityCode !== '';
|
||||
}
|
||||
|
||||
public function getAccountId(): string
|
||||
{
|
||||
return $this->accountId;
|
||||
}
|
||||
|
||||
public function signature(): Signature
|
||||
{
|
||||
return new Signature($this->integrityCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Êîä ïðîâåðêè öåëîñòíîñòè äàííûõ íå âûâîäèòñÿ â var_dump()/print_r() è æóðíàëû, êàê ó {@see Signature};
|
||||
* âèäíî òîëüêî, çàäàí ëè îí.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function __debugInfo(): array
|
||||
{
|
||||
$properties = get_object_vars($this);
|
||||
$properties['integrityCode'] = ($this->integrityCode === '') ? '' : '***';
|
||||
|
||||
return $properties;
|
||||
}
|
||||
|
||||
public function getPaymentServer(): PaymentServer
|
||||
{
|
||||
return $this->paymentServer;
|
||||
}
|
||||
|
||||
public function isTestMode(): bool
|
||||
{
|
||||
return $this->testMode;
|
||||
}
|
||||
|
||||
public function isFiscalization(): bool
|
||||
{
|
||||
return $this->fiscalization;
|
||||
}
|
||||
|
||||
public function getReceiptEmail(): string
|
||||
{
|
||||
return $this->receiptEmail;
|
||||
}
|
||||
|
||||
/**
|
||||
* «Îïèñàíèå îïëàòû» (êîëîíêà `title_end`, êàê ó øòàòíûõ ìîäóëåé PHPShop è âñòðîåííîé «Ìîíåòû»):
|
||||
* òåêñò íàä ñóììîé è êíîïêîé îïëàòû. Îáû÷íûé òåêñò â êîäèðîâêå ÁÄ; ïóñòî — áåç òåêñòà.
|
||||
*/
|
||||
public function getPaymentDescription(): string
|
||||
{
|
||||
return $this->paymentDescription;
|
||||
}
|
||||
|
||||
public function getPaymentMethod(): PaymentMethod
|
||||
{
|
||||
return $this->paymentMethod;
|
||||
}
|
||||
|
||||
public function getPaymentObject(): PaymentObject
|
||||
{
|
||||
return $this->paymentObject;
|
||||
}
|
||||
|
||||
public function getTaxSystem(): TaxSystem
|
||||
{
|
||||
return $this->taxSystem;
|
||||
}
|
||||
|
||||
public function getGoodsVat(): VatSetting
|
||||
{
|
||||
return $this->goodsVat;
|
||||
}
|
||||
|
||||
public function getDeliveryVat(): VatSetting
|
||||
{
|
||||
return $this->deliveryVat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ñòàòóñ, ïðè êîòîðîì ïîêóïàòåëþ äîñòóïíà îïëàòà; {@see self::STATUS_NEW} — ñðàçó ïîñëå îôîðìëåíèÿ.
|
||||
*/
|
||||
public function getPayableStatus(): int
|
||||
{
|
||||
return $this->payableStatus;
|
||||
}
|
||||
|
||||
public function isPayableRightAfterCheckout(): bool
|
||||
{
|
||||
return $this->payableStatus === self::STATUS_NEW;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ñòàòóñ, â êîòîðûé ìîäóëü ïåðåâîäèò çàêàç ïîñëå ïîäòâåðæäåíèÿ îïëàòû.
|
||||
*/
|
||||
public function getPaidStatus(): int
|
||||
{
|
||||
return $this->paidStatus;
|
||||
}
|
||||
|
||||
public function getVersion(): string
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private static function string(array $row, string $key): string
|
||||
{
|
||||
$value = $row[$key] ?? '';
|
||||
|
||||
return is_scalar($value) ? (string) $value : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop;
|
||||
|
||||
/**
|
||||
* Ìîæíî ëè ñîõðàíèòü íàñòðîéêè ìîäóëÿ èç ýòîãî çàïðîñà.
|
||||
*
|
||||
* ßäðî PHPShop ïðîâåðÿåò ïðàâà, òîëüêî åñëè èìÿ äåéñòâèÿ â `actionList[...]` ñîäåðæèò ñóôôèêñ
|
||||
* `.modules.edit`, à èìÿ ïðèñûëàåò êëèåíò: `actionUpdate` áåç ñóôôèêñà ÿäðî âûçûâàåò ëþáîìó
|
||||
* âîøåäøåìó â àäìèíêó (`PHPShopGUI::getAction()`, ïðîâåðåíî íà ñòåíäå 7.0). Òîêåíà ïðîòèâ CSRF
|
||||
* ó ÿäðà íåò, cookie ñåññèè íà ñòåíäå — áåç `SameSite`. Ïîýòîìó ìîäóëü ñàì ïðîâåðÿåò ïðàâî
|
||||
* «Ìîäóëè: ðåäàêòèðîâàíèå» è òî, ÷òî çàïðîñ îòïðàâëåí ñî ñòðàíèöû ýòîãî æå ìàãàçèíà.
|
||||
*/
|
||||
final class SettingsWriteGuard
|
||||
{
|
||||
public const DENIED_RIGHTS = 'rights';
|
||||
public const DENIED_METHOD = 'method';
|
||||
public const DENIED_ORIGIN = 'origin';
|
||||
|
||||
/**
|
||||
* Ïðè÷èíà îòêàçà èëè null, åñëè ñîõðàíÿòü ìîæíî.
|
||||
*
|
||||
* Òîëüêî POST: ÿäðî áåð¸ò `actionList` èç `$_REQUEST`, è áåç ýòîé ïðîâåðêè íàñòðîéêè
|
||||
* ñîõðàíÿë áû îáû÷íûé ïåðåõîä ïî ññûëêå. Ó GET áðàóçåð íå øë¸ò Origin, à Referer ñî ñòðàíèöû
|
||||
* òîãî æå ñàéòà ïðîâåðêó èñòî÷íèêà ïðîø¸ë áû — ññûëêà íà âèòðèíå ñáðîñèëà áû íàñòðîéêè.
|
||||
*
|
||||
* @param bool $canEditModules ïðàâî «Ìîäóëè: ðåäàêòèðîâàíèå» ó àäìèíèñòðàòîðà
|
||||
* @param array<string, mixed> $server `$_SERVER`
|
||||
*/
|
||||
public static function denialReason(bool $canEditModules, array $server): ?string
|
||||
{
|
||||
if (!$canEditModules) {
|
||||
return self::DENIED_RIGHTS;
|
||||
}
|
||||
if (self::header($server, 'REQUEST_METHOD') !== 'POST') {
|
||||
return self::DENIED_METHOD;
|
||||
}
|
||||
|
||||
return self::isSameOrigin($server) ? null : self::DENIED_ORIGIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Origin (áðàóçåð ñòàâèò åãî ñàì, ñòðàíèöà ÷óæîãî ñàéòà ïîäìåíèòü íå ìîæåò), à åñëè åãî íåò —
|
||||
* Referer, äîëæåí óêàçûâàòü íà òîò æå õîñò, íà êîòîðûé ïðèø¸ë çàïðîñ. Áåç îáîèõ — îòêàç:
|
||||
* òàê æå ÿäðî íå ïóñêàåò áåç Referer âîññòàíîâëåíèå ïàðîëÿ â àäìèíêó.
|
||||
*
|
||||
* Õîñò çàïðîñà — `Host` è, çà îáðàòíûì ïðîêñè, `X-Forwarded-Host`: ïîäñòàâèòü ñâîé çàãîëîâîê
|
||||
* â ìåæñàéòîâûé çàïðîñ áðàóçåð ñòðàíèöå íå äàñò.
|
||||
*
|
||||
* @param array<string, mixed> $server
|
||||
*/
|
||||
public static function isSameOrigin(array $server): bool
|
||||
{
|
||||
$origin = self::header($server, 'HTTP_ORIGIN');
|
||||
$source = ($origin !== '') ? $origin : self::header($server, 'HTTP_REFERER');
|
||||
$sourceHost = self::urlHost($source);
|
||||
if ($sourceHost === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$requestHosts = explode(',', self::header($server, 'HTTP_X_FORWARDED_HOST'));
|
||||
$requestHosts[] = self::header($server, 'HTTP_HOST');
|
||||
foreach ($requestHosts as $requestHost) {
|
||||
$requestHost = trim($requestHost);
|
||||
if ($requestHost !== '' && self::normalizeHost($requestHost) === $sourceHost) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Õîñò è ïîðò èç àáñîëþòíîãî àäðåñà http(s) èëè null. Origin `null` (ïåñî÷íèöà, data:) — null.
|
||||
*/
|
||||
private static function urlHost(string $url): ?string
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
if (!is_array($parts) || !isset($parts['scheme'], $parts['host'])) {
|
||||
return null;
|
||||
}
|
||||
if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::normalizeHost($parts['host'] . (isset($parts['port']) ? ':' . $parts['port'] : ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Íèæíèé ðåãèñòð, áåç ñòàíäàðòíûõ ïîðòîâ: áðàóçåð íå ïèøåò `:443` â Origin, à `Host` çà
|
||||
* ïðîêñè èíîãäà ïèøåò.
|
||||
*/
|
||||
private static function normalizeHost(string $host): string
|
||||
{
|
||||
return (string) preg_replace('/:(80|443)\z/', '', strtolower($host));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $server
|
||||
*/
|
||||
private static function header(array $server, string $name): string
|
||||
{
|
||||
$value = $server[$name] ?? '';
|
||||
|
||||
return is_string($value) ? trim($value) : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Àâòîçàãðóçêà êëàññîâ ìîäóëÿ: `MonetaPhpShop\<ïóòü>` -> `class/<ïóòü>.php`.
|
||||
*
|
||||
* Composer ó PHPShop íåò, ïîýòîìó ìîäóëü ðåãèñòðèðóåò ñâîé çàãðóç÷èê; õîñò ñâîè
|
||||
* êëàññû ïîäêëþ÷àåò ÷åðåç `PHPShopObj::loadClass()`, êîíôëèêòîâ íåò.
|
||||
*/
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
$prefix = 'MonetaPhpShop\\';
|
||||
if (strncmp($class, $prefix, strlen($prefix)) !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relative = str_replace('\\', '/', substr($class, strlen($prefix)));
|
||||
$file = __DIR__ . '/' . $relative . '.php';
|
||||
if (is_file($file)) {
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\callback;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use MonetaPhpShop\ModuleInfo;
|
||||
use MonetaPhpShop\order\Order;
|
||||
use MonetaPhpShop\order\PaymentEligibility;
|
||||
use MonetaPhpShop\protocol\AssistantProtocol;
|
||||
use MonetaPhpShop\protocol\CallbackNotification;
|
||||
use MonetaPhpShop\protocol\CallbackResponse;
|
||||
use MonetaPhpShop\protocol\CheckResponse;
|
||||
use MonetaPhpShop\protocol\PayResponse;
|
||||
use MonetaPhpShop\protocol\ResultCode;
|
||||
use MonetaPhpShop\protocol\TransactionId;
|
||||
use MonetaPhpShop\Settings;
|
||||
use Throwable;
|
||||
|
||||
final class CallbackController
|
||||
{
|
||||
/** @var Settings */
|
||||
private $settings;
|
||||
|
||||
/** @var string */
|
||||
private $shopCurrency;
|
||||
|
||||
/** @var string */
|
||||
private $cms;
|
||||
|
||||
/** @var OrderGateway */
|
||||
private $orderGateway;
|
||||
|
||||
/** @var OperationJournal */
|
||||
private $operationJournal;
|
||||
|
||||
/** @var OrderLock */
|
||||
private $orderLock;
|
||||
|
||||
/** @var ReceiptSource */
|
||||
private $receiptSource;
|
||||
|
||||
/** @var Logger */
|
||||
private $logger;
|
||||
|
||||
/** @var Logger */
|
||||
private $rejectionLogger;
|
||||
|
||||
/**
|
||||
* @param string $shopCurrency ISO-êîä âàëþòû ìàãàçèíà (`getDefaultValutaIso(true)`)
|
||||
* @param string $cms ñòðîêà `MNT_CMS`
|
||||
* @param Logger|null $rejectionLogger îòêàçû äî ïðîâåðêè ïîäïèñè — êòî óãîäíî ìîæåò ñëàòü èõ
|
||||
* ïîòîêîì, ïîýòîìó æóðíàë äëÿ íèõ îáû÷íî ñ îãðàíè÷åíèåì ÷àñòîòû ({@see ThrottledLogger});
|
||||
* ïî óìîë÷àíèþ — `$logger`
|
||||
*/
|
||||
public function __construct(
|
||||
Settings $settings,
|
||||
string $shopCurrency,
|
||||
string $cms,
|
||||
OrderGateway $orderGateway,
|
||||
OperationJournal $operationJournal,
|
||||
OrderLock $orderLock,
|
||||
ReceiptSource $receiptSource,
|
||||
Logger $logger,
|
||||
?Logger $rejectionLogger = null
|
||||
) {
|
||||
$this->settings = $settings;
|
||||
$this->shopCurrency = $shopCurrency;
|
||||
$this->cms = $cms;
|
||||
$this->orderGateway = $orderGateway;
|
||||
$this->operationJournal = $operationJournal;
|
||||
$this->orderLock = $orderLock;
|
||||
$this->receiptSource = $receiptSource;
|
||||
$this->logger = $logger;
|
||||
$this->rejectionLogger = $rejectionLogger ?? $logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ïàðàìåòðû óâåäîìëåíèÿ èç àäðåñà è òåëà âìåñòå, ïðè ñîâïàäåíèè èì¸í — èç òåëà.
|
||||
* Ìåòîä çàäà¸òñÿ â íàñòðîéêàõ ñ÷¸òà MONETA.RU, à ïðè POST MONETA.RU ïåðåíîñèò â òåëî è
|
||||
* ïàðàìåòðû èç àäðåñà Check URL.
|
||||
* Ëèøíåå èç àäðåñà íå îïàñíî: êàæäîå ïîëå ïîäïèñè âñ¸ ðàâíî ñâåðÿåòñÿ.
|
||||
*
|
||||
* @param array<mixed> $query `$_GET`
|
||||
* @param array<mixed> $body `$_POST`
|
||||
*
|
||||
* @return array<mixed>
|
||||
*/
|
||||
public static function requestFields(array $query, array $body): array
|
||||
{
|
||||
return array_replace($query, $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $fields ïàðàìåòðû çàïðîñà ({@see requestFields()})
|
||||
*/
|
||||
public function handle(array $fields): CallbackResponse
|
||||
{
|
||||
if (!$this->settings->isConfigured()) {
|
||||
$this->rejectionLogger->error(
|
||||
'Óâåäîìëåíèå îòêëîíåíî: ìîäóëü íå íàñòðîåí (íîìåð ñ÷¸òà, êîä ïðîâåðêè öåëîñòíîñòè äàííûõ).',
|
||||
);
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
try {
|
||||
$notification = new CallbackNotification($fields);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
$this->rejectionLogger->error('Óâåäîìëåíèå îòêëîíåíî: ' . $exception->getMessage());
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
if (
|
||||
$notification->getAccountId() !== $this->settings->getAccountId()
|
||||
|| !$notification->isSignedBy($this->settings->signature())
|
||||
) {
|
||||
$this->rejectionLogger->error('Óâåäîìëåíèå îòêëîíåíî: íîìåð ñ÷¸òà èëè ïîäïèñü íå ñîâïàäàþò.');
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
$transactionId = TransactionId::parse($notification->getTransactionId());
|
||||
$order = ($transactionId === null) ? null : $this->orderGateway->findByUid($transactionId->getOrderUid());
|
||||
if ($transactionId === null || $order === null) {
|
||||
return $this->unknownOrder($notification);
|
||||
}
|
||||
|
||||
if (!$this->orderLock->acquire($order->getId())) {
|
||||
$this->operationJournal->note($notification, $order, CallbackEvent::LOCK_TIMEOUT, null);
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
try {
|
||||
// Âíóòðè áëîêèðîâêè — ñâåæèé ñíèìîê: ïàðàëëåëüíîå óâåäîìëåíèå ìîãëî åãî èçìåíèòü.
|
||||
$order = $this->orderGateway->findByUid($transactionId->getOrderUid()) ?? $order;
|
||||
|
||||
return $this->handleLocked($notification, $transactionId, $order);
|
||||
} finally {
|
||||
$this->orderLock->release($order->getId());
|
||||
}
|
||||
}
|
||||
|
||||
private function handleLocked(
|
||||
CallbackNotification $notification,
|
||||
TransactionId $transactionId,
|
||||
Order $order
|
||||
): CallbackResponse {
|
||||
$mismatch = $this->mismatch($notification, $transactionId, $order);
|
||||
if ($mismatch !== null) {
|
||||
$this->operationJournal->note($notification, $order, $mismatch, null);
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
return $notification->isCheck()
|
||||
? $this->check($notification, $order)
|
||||
: $this->pay($notification, $order);
|
||||
}
|
||||
|
||||
private function mismatch(CallbackNotification $notification, TransactionId $transactionId, Order $order): ?string
|
||||
{
|
||||
if ($notification->getCurrency() !== AssistantProtocol::CURRENCY_RUB) {
|
||||
return CallbackEvent::CURRENCY_MISMATCH;
|
||||
}
|
||||
|
||||
if ($notification->isTestMode() !== $this->settings->isTestMode()) {
|
||||
return CallbackEvent::TEST_MODE_MISMATCH;
|
||||
}
|
||||
|
||||
// Ñóììà ôîðìû çàøèòà â íîìåð è çàêðûòà ïîäïèñüþ ({@see TransactionId}): ñ íåé ñâåðÿåòñÿ
|
||||
// è ñóììà óâåäîìëåíèÿ, è çàêàç — èíà÷å ïåðâûå öèôðû ñóììû ôîðìû, ïåðåíåñ¸ííûå â
|
||||
// MNT_OPERATION_ID, ïðîâåëè áû çàêàç, ïîäåøåâåâøèé ïîñëå ïîêàçà ôîðìû.
|
||||
$amount = $notification->getAmount();
|
||||
if ($amount !== null && !$amount->equals($transactionId->getAmount())) {
|
||||
return CallbackEvent::AMOUNT_MISMATCH;
|
||||
}
|
||||
if (!$transactionId->getAmount()->equals($order->getAmount())) {
|
||||
return CallbackEvent::ORDER_AMOUNT_CHANGED;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function check(CallbackNotification $notification, Order $order): CallbackResponse
|
||||
{
|
||||
$event = CallbackEvent::CHECKED;
|
||||
if ($this->isPaid($order)) {
|
||||
$resultCode = ResultCode::paid();
|
||||
} else {
|
||||
$eligibility = PaymentEligibility::check($this->settings, $order, $this->shopCurrency);
|
||||
if ($eligibility === PaymentEligibility::PAYABLE) {
|
||||
$resultCode = ($notification->getAmount() === null)
|
||||
? ResultCode::withAmount()
|
||||
: ResultCode::awaitingPayment();
|
||||
} else {
|
||||
$resultCode = ResultCode::rejected();
|
||||
$event = $eligibility;
|
||||
}
|
||||
}
|
||||
|
||||
$response = new CheckResponse(
|
||||
$this->settings->getAccountId(),
|
||||
$notification->getTransactionId(),
|
||||
$order->getAmount(),
|
||||
$resultCode,
|
||||
$this->settings->signature(),
|
||||
$this->cms,
|
||||
);
|
||||
|
||||
if (!$this->settings->isFiscalization()) {
|
||||
$this->operationJournal->note($notification, $order, $event, $resultCode);
|
||||
|
||||
return $response->toXml($this->receiptSource->attributes($order));
|
||||
}
|
||||
|
||||
$receipt = $this->receiptSource->jsonReceipt($order);
|
||||
if ($receipt === null) {
|
||||
$this->operationJournal->note($notification, $order, CallbackEvent::RECEIPT_UNAVAILABLE, null);
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
$this->operationJournal->note($notification, $order, $event, $resultCode);
|
||||
|
||||
return $response->toJson($receipt);
|
||||
}
|
||||
|
||||
private function pay(CallbackNotification $notification, Order $order): CallbackResponse
|
||||
{
|
||||
$paidOperation = $this->operationJournal->findPaidOperation($order->getId());
|
||||
if ($paidOperation !== null) {
|
||||
if ($paidOperation === $notification->getOperationId()) {
|
||||
return $this->payResponse($notification, $order, CallbackEvent::REPEAT, ResultCode::paid());
|
||||
}
|
||||
// Âòîðàÿ îïåðàöèÿ ïî óæå îïëà÷åííîìó çàêàçó: ïîêóïàòåëü çàïëàòèë äâàæäû, äåíüãè ñïèñàíû.
|
||||
// Îòâå÷àåì FAIL — ìàãàçèí íå ïîäòâåðæäàåò óâåäîìëåíèå: îïåðàöèÿ â MONETA.RU íå ïåðåõîäèò
|
||||
// â «Âûïîëíåíî», îñòà¸òñÿ «Óâåäîìëåíèå íå îòïðàâëåíî», Pay URL ïîâòîðÿåòñÿ äî ñóòîê
|
||||
// (MONETA.Assistant.ru.pdf) — òàê å¸ âèäíî â êàáèíåòå ñðåäè îáû÷íûõ îïëàò. Çà÷åñòü å¸ ìîäóëü
|
||||
// íå ìîæåò; ñòðîêà â æóðíàëå — ñèãíàë àäìèíèñòðàòîðó ñäåëàòü âîçâðàò â êàáèíåòå MONETA.RU.
|
||||
$this->operationJournal->note($notification, $order, CallbackEvent::DUPLICATE_OPERATION, null);
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
if ($order->getStatusId() === PaymentEligibility::STATUS_CANCELLED) {
|
||||
// Îòìåí¸ííûé çàêàç: ïîâòîðû áåññìûñëåííû, íî ñëåä â æóðíàëå îñòà¸òñÿ.
|
||||
return $this->payResponse($notification, $order, PaymentEligibility::CANCELLED, ResultCode::rejected());
|
||||
}
|
||||
if ($order->getPaymentMethodId() !== ModuleInfo::PAYMENT_METHOD_ID) {
|
||||
// Ïîêà ïîêóïàòåëü ïëàòèë, â çàêàçå ñìåíèëè ñïîñîá îïëàòû íà äðóãîé. Îòâå÷àåì FAIL:
|
||||
// MONETA.RU áóäåò ïîâòîðÿòü óâåäîìëåíèå, è îïëàòà çà÷ò¸òñÿ, êîãäà àäìèíèñòðàòîð
|
||||
// âåðí¸ò çàêàçó ñïîñîá îïëàòû «Ìîíåòà».
|
||||
$this->operationJournal->note($notification, $order, PaymentEligibility::OTHER_PAYMENT_METHOD, null);
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
if (AssistantProtocol::normalizeCurrency($this->shopCurrency) === null) {
|
||||
// Âàëþòó âèòðèíû ñìåíèëè ñ ðóáëåé ïîñëå ïîêàçà ôîðìû: ñóììà çàêàçà óæå íå â ðóáëÿõ.
|
||||
// Òàê æå, êàê ñî ñïîñîáîì îïëàòû, — FAIL, ïîâòîðû è ñòðîêà â æóðíàëå äëÿ àäìèíèñòðàòîðà.
|
||||
$this->operationJournal->note($notification, $order, PaymentEligibility::UNSUPPORTED_CURRENCY, null);
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
$event = CallbackEvent::ALREADY_PAID_IN_SHOP;
|
||||
if (!$this->isPaid($order)) {
|
||||
try {
|
||||
$this->orderGateway->markPaid($order, $this->settings->getPaidStatus(), $order->getAmount());
|
||||
} catch (Throwable $exception) {
|
||||
$this->logger->error(
|
||||
sprintf(
|
||||
'Çàêàç %s íå ïåðåâåä¸í â îïëà÷åííûå: %s: %s',
|
||||
$order->getUid(),
|
||||
get_class($exception),
|
||||
$exception->getMessage(),
|
||||
),
|
||||
);
|
||||
$this->operationJournal->note($notification, $order, CallbackEvent::ORDER_UPDATE_FAILED, null);
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
$event = CallbackEvent::PAID;
|
||||
}
|
||||
|
||||
// Îòìåòêó «îïëàòà çà÷òåíà» ïèøåì â æóðíàë òîëüêî ïîñëå òîãî, êàê çàêàç ñòàë îïëà÷åííûì.
|
||||
// Åñëè çàïèñü â æóðíàë íå óäàëàñü, îòâå÷àåì FAIL, è MONETA.RU ïîâòîðèò óâåäîìëåíèå.
|
||||
// Ïîâòîð óâèäèò, ÷òî çàêàç óæå îïëà÷åí, è òîëüêî äîïèøåò îòìåòêó
|
||||
// (ñîáûòèå ALREADY_PAID_IN_SHOP): ñòàòóñ íå ìåíÿåòñÿ, âòîðîãî ïèñüìà ïîêóïàòåëþ íåò.
|
||||
if (!$this->operationJournal->recordPaid($notification, $order, $event)) {
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
return $this->response($notification, $order, ResultCode::paid());
|
||||
}
|
||||
|
||||
/**
|
||||
* Îïëà÷åí ïî äàííûì ìàãàçèíà èëè ïî æóðíàëó ìîäóëÿ (ìàðêåð ìîã çàïèñàòüñÿ,
|
||||
* à ñòàòóñ ïîòîì ñìåíèë ìåíåäæåð).
|
||||
*/
|
||||
private function isPaid(Order $order): bool
|
||||
{
|
||||
return PaymentEligibility::isPaid($this->settings, $order)
|
||||
|| $this->operationJournal->findPaidOperation($order->getId()) !== null;
|
||||
}
|
||||
|
||||
private function payResponse(
|
||||
CallbackNotification $notification,
|
||||
Order $order,
|
||||
string $event,
|
||||
ResultCode $resultCode
|
||||
): CallbackResponse {
|
||||
$this->operationJournal->note($notification, $order, $event, $resultCode);
|
||||
|
||||
return $this->response($notification, $order, $resultCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Îòâåò íà Pay URL: `MNT_ATTRIBUTES` — òîëüêî â óñïåøíîì (`200`), íåóñïåøíûé — áåç íèõ.
|
||||
*/
|
||||
private function response(
|
||||
CallbackNotification $notification,
|
||||
Order $order,
|
||||
ResultCode $resultCode
|
||||
): CallbackResponse {
|
||||
$attributes = $resultCode->equals(ResultCode::paid()) ? $this->receiptSource->attributes($order) : [];
|
||||
|
||||
return (new PayResponse(
|
||||
$this->settings->getAccountId(),
|
||||
$notification->getTransactionId(),
|
||||
$order->getAmount(),
|
||||
$resultCode,
|
||||
$this->settings->signature(),
|
||||
$this->cms,
|
||||
))->toXml($attributes);
|
||||
}
|
||||
|
||||
private function unknownOrder(CallbackNotification $notification): CallbackResponse
|
||||
{
|
||||
if (!$notification->isCheck()) {
|
||||
$this->operationJournal->note($notification, null, CallbackEvent::UNKNOWN_ORDER, null);
|
||||
$this->logger->error(
|
||||
sprintf(
|
||||
'Îïëàòà ïî íåèçâåñòíîìó çàêàçó %s, îïåðàöèÿ %s.',
|
||||
$notification->getTransactionId(),
|
||||
$notification->getOperationId(),
|
||||
),
|
||||
);
|
||||
|
||||
return CallbackResponse::fail();
|
||||
}
|
||||
|
||||
$resultCode = ResultCode::rejected();
|
||||
$this->operationJournal->note($notification, null, CallbackEvent::UNKNOWN_ORDER, $resultCode);
|
||||
|
||||
// XML è ïðè âêëþ÷¸ííîé ôèñêàëèçàöèè: ÷åêà ó íåèçâåñòíîãî çàêàçà íåò, à JSON-ñõåìà
|
||||
// `cmsspecification.pdf` òðåáóåò õîòÿ áû îäíó ïîçèöèþ.
|
||||
return (new CheckResponse(
|
||||
$this->settings->getAccountId(),
|
||||
$notification->getTransactionId(),
|
||||
null,
|
||||
$resultCode,
|
||||
$this->settings->signature(),
|
||||
$this->cms,
|
||||
))->toXml();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\callback;
|
||||
|
||||
use MonetaPhpShop\order\PaymentEligibility;
|
||||
use MonetaPhpShop\protocol\ResultCode;
|
||||
|
||||
/**
|
||||
* ×òî ìîäóëü ñäåëàë ñ ïîäïèñàííûì óâåäîìëåíèåì — êîëîíêà `event` æóðíàëà îïåðàöèé.
|
||||
* Çíà÷åíèÿ ïåðñèñòÿòñÿ è ïîêàçûâàþòñÿ â æóðíàëå àäìèíêè — íå ïåðåèìåíîâûâàòü.
|
||||
* Ïðè÷èíû îòêàçà ïî çàêàçó áåðóòñÿ èç {@see \MonetaPhpShop\order\PaymentEligibility}.
|
||||
*/
|
||||
final class CallbackEvent
|
||||
{
|
||||
/** Ïðîâåðî÷íûé çàïðîñ, çàêàç ìîæíî îïëàòèòü (402 èëè 100). */
|
||||
public const CHECKED = 'checked';
|
||||
|
||||
/** Îïëàòà çà÷òåíà: çàêàç ïåðåâåä¸í â ñòàòóñ «ïîñëå îïëàòû». */
|
||||
public const PAID = 'paid';
|
||||
|
||||
/** Îïëàòà çà÷òåíà, íî çàêàç óæå áûë îïëà÷åí â ìàãàçèíå — ñòàòóñ íå òðîãàëè. */
|
||||
public const ALREADY_PAID_IN_SHOP = 'already_paid_in_shop';
|
||||
|
||||
/** Ïîâòîð óâåäîìëåíèÿ ïî óæå çà÷ò¸ííîé îïåðàöèè: îòâåò 200 áåç ïîáî÷íûõ ýôôåêòîâ. */
|
||||
public const REPEAT = 'repeat';
|
||||
|
||||
/** Âòîðàÿ îïåðàöèÿ MONETA.RU ïî îïëà÷åííîìó çàêàçó: äåíüãè ñïèñàíû äâàæäû, íóæåí ÷åëîâåê. */
|
||||
public const DUPLICATE_OPERATION = 'duplicate_operation';
|
||||
|
||||
/** Íîìåð çàêàçà èç `MNT_TRANSACTION_ID` íå íàéäåí. */
|
||||
public const UNKNOWN_ORDER = 'unknown_order';
|
||||
|
||||
/** Ñóììà çàêàçà èçìåíèëàñü ïîñëå ïîêàçà ôîðìû: îïëàòà ïî ñòàðîé ôîðìå íå ïðèíèìàåòñÿ. */
|
||||
public const ORDER_AMOUNT_CHANGED = 'order_amount_changed';
|
||||
|
||||
public const AMOUNT_MISMATCH = 'amount_mismatch';
|
||||
public const CURRENCY_MISMATCH = 'currency_mismatch';
|
||||
|
||||
/** `MNT_TEST_MODE` óâåäîìëåíèÿ íå ñîâïàë ñ òåñòîâûì ðåæèìîì ìîäóëÿ. */
|
||||
public const TEST_MODE_MISMATCH = 'test_mode_mismatch';
|
||||
|
||||
/** Çàêàç íå ïåðåâ¸ëñÿ â îïëà÷åííûå (îøèáêà ÿäðà PHPShop) — ïîâòîð óâåäîìëåíèÿ ïîïðîáóåò ñíîâà. */
|
||||
public const ORDER_UPDATE_FAILED = 'order_update_failed';
|
||||
|
||||
/** Ôèñêàëèçàöèÿ âêëþ÷åíà, à ÷åê äëÿ JSON-îòâåòà íå ñîáðàí. */
|
||||
public const RECEIPT_UNAVAILABLE = 'receipt_unavailable';
|
||||
|
||||
/** Íå äîæäàëèñü áëîêèðîâêè çàêàçà: ïàðàëëåëüíî îáðàáàòûâàåòñÿ äðóãîå óâåäîìëåíèå. */
|
||||
public const LOCK_TIMEOUT = 'lock_timeout';
|
||||
|
||||
/** Ïîäïèñè äëÿ æóðíàëà îïåðàöèé â àäìèíêå. */
|
||||
private const LABELS = [
|
||||
self::CHECKED => 'Ïðîâåðêà çàêàçà',
|
||||
self::PAID => 'Îïëàòà çà÷òåíà',
|
||||
self::ALREADY_PAID_IN_SHOP => 'Îïëàòà çà÷òåíà, çàêàç óæå áûë îïëà÷åí â ìàãàçèíå',
|
||||
self::REPEAT => 'Ïîâòîð óâåäîìëåíèÿ',
|
||||
self::DUPLICATE_OPERATION => 'Âòîðàÿ îïëàòà îïëà÷åííîãî çàêàçà: ñäåëàéòå âîçâðàò â ëè÷íîì êàáèíåòå MONETA.RU',
|
||||
self::UNKNOWN_ORDER => 'Çàêàç íå íàéäåí',
|
||||
self::ORDER_AMOUNT_CHANGED => 'Ñóììà çàêàçà èçìåíèëàñü ïîñëå ïîêàçà ôîðìû îïëàòû',
|
||||
self::AMOUNT_MISMATCH => 'Ñóììà íå ñîâïàäàåò ñ çàêàçîì',
|
||||
self::CURRENCY_MISMATCH => 'Âàëþòà íå ñîâïàäàåò',
|
||||
self::TEST_MODE_MISMATCH => 'Òåñòîâûé ðåæèì íå ñîâïàäàåò ñ íàñòðîéêîé ìîäóëÿ',
|
||||
self::ORDER_UPDATE_FAILED => 'Íå óäàëîñü ïåðåâåñòè çàêàç â îïëà÷åííûå',
|
||||
self::RECEIPT_UNAVAILABLE => '×åê íå ñîáðàí',
|
||||
self::LOCK_TIMEOUT => 'Çàêàç çàíÿò ïàðàëëåëüíûì óâåäîìëåíèåì',
|
||||
PaymentEligibility::NOT_CONFIGURED => 'Ìîäóëü íå íàñòðîåí',
|
||||
PaymentEligibility::OTHER_PAYMENT_METHOD => 'Ó çàêàçà äðóãîé ñïîñîá îïëàòû',
|
||||
PaymentEligibility::ALREADY_PAID => 'Çàêàç óæå îïëà÷åí',
|
||||
PaymentEligibility::CANCELLED => 'Çàêàç îòìåí¸í',
|
||||
PaymentEligibility::AWAITING_STATUS => 'Çàêàç åù¸ íå ïîäòâåðæä¸í ìåíåäæåðîì',
|
||||
PaymentEligibility::UNSUPPORTED_CURRENCY => 'Âàëþòà ìàãàçèíà íå ðóáëè',
|
||||
PaymentEligibility::ZERO_AMOUNT => 'Íóëåâàÿ ñóììà çàêàçà',
|
||||
];
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* Çíà÷îê ñòðîêè æóðíàëà îïåðàöèé â àäìèíêå — glyphicon Bootstrap 3, êîòîðûé àäìèíêà PHPShop
|
||||
* ïîñòàâëÿåò ñî ñâîèìè òåìàìè (`admpanel/fonts/`, êëàññû `glyphicon-*` — â 6.7 â êàæäîé òåìå,
|
||||
* â 7.0 â `bootstrap.min.css`); öâåòà — êëàññû òåìû. Îïëàòà çà÷òåíà — çåë¸íàÿ ãàëî÷êà
|
||||
* â êðóæêå, îòêàç ìîäóëÿ (`FAIL` èëè êîä 500) — êðàñíûé êðåñòèê, ïðîâåðêà çàêàçà — ñåðûé
|
||||
* âîïðîñ; îñòàëüíîå (ïîâòîð óâåäîìëåíèÿ) — áåç çíà÷êà.
|
||||
*
|
||||
* @param int|null $resultCode `MNT_RESULT_CODE` îòâåòà ìîäóëÿ; null — îòâåò `FAIL`
|
||||
*/
|
||||
public static function iconHtml(string $event, ?int $resultCode): string
|
||||
{
|
||||
if ($event === self::PAID || $event === self::ALREADY_PAID_IN_SHOP) {
|
||||
return self::glyphicon('ok-sign', 'text-success');
|
||||
}
|
||||
if ($resultCode === null || $resultCode === ResultCode::REJECTED) {
|
||||
return self::glyphicon('remove-sign', 'text-danger');
|
||||
}
|
||||
if ($event === self::CHECKED) {
|
||||
return self::glyphicon('question-sign', 'text-muted');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private static function glyphicon(string $name, string $color): string
|
||||
{
|
||||
return '<span class="glyphicon glyphicon-' . $name . ' ' . $color . '" aria-hidden="true"></span>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ïîäïèñü ñîáûòèÿ; íåèçâåñòíûé êîä (èç áóäóùåé âåðñèè) ïîêàçûâàåòñÿ êàê åñòü.
|
||||
*/
|
||||
public static function label(string $event): string
|
||||
{
|
||||
return self::LABELS[$event] ?? $event;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\callback;
|
||||
|
||||
/**
|
||||
* Общий на все запросы счётчик для {@see ThrottledLogger}.
|
||||
*/
|
||||
interface LogThrottle
|
||||
{
|
||||
/**
|
||||
* Можно ли записать строку сейчас.
|
||||
*
|
||||
* @param int $now unix-время
|
||||
* @param int $interval не чаще одной строки за столько секунд
|
||||
*
|
||||
* @return int|null null — строку пропустить (она учтена в счётчике); число — сколько строк
|
||||
* пропущено с прошлой записи
|
||||
*/
|
||||
public function pass(int $now, int $interval): ?int;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\callback;
|
||||
|
||||
/**
|
||||
* Технический журнал колбэка (не журнал операций): то, что нельзя писать в БД —
|
||||
* неподписанные запросы, сбои. Без секретов и без тела запроса.
|
||||
*/
|
||||
interface Logger
|
||||
{
|
||||
public function error(string $message): void;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\callback;
|
||||
|
||||
use MonetaPhpShop\order\Order;
|
||||
use MonetaPhpShop\protocol\CallbackNotification;
|
||||
use MonetaPhpShop\protocol\ResultCode;
|
||||
|
||||
/**
|
||||
* Æóðíàë îïåðàöèé ìîäóëÿ: êàæäîå ïîäïèñàííîå óâåäîìëåíèå è ìàðêåð çà÷ò¸ííîé îïëàòû.
|
||||
* Ìàðêåð — óíèêàëüíûé ïî çàêàçó: ýòî è åñòü èäåìïîòåíòíîñòü Pay URL íà MyISAM áåç òðàíçàêöèé.
|
||||
*/
|
||||
interface OperationJournal
|
||||
{
|
||||
/**
|
||||
* `MNT_OPERATION_ID` îïëàòû, óæå çà÷ò¸ííîé ïî çàêàçó, èëè null.
|
||||
*/
|
||||
public function findPaidOperation(int $orderId): ?string;
|
||||
|
||||
/**
|
||||
* Çàïèñàòü çà÷ò¸ííóþ îïëàòó. false — ïî çàêàçó ìàðêåð óæå åñòü (ñðàáîòàë UNIQUE).
|
||||
*/
|
||||
public function recordPaid(CallbackNotification $notification, Order $order, string $event): bool;
|
||||
|
||||
/**
|
||||
* Çàïèñàòü óâåäîìëåíèå áåç çà÷¸òà îïëàòû.
|
||||
*
|
||||
* @param ResultCode|null $resultCode null — îòâåòèëè òåêñòîì `FAIL`
|
||||
*/
|
||||
public function note(
|
||||
CallbackNotification $notification,
|
||||
?Order $order,
|
||||
string $event,
|
||||
?ResultCode $resultCode
|
||||
): void;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\callback;
|
||||
|
||||
use MonetaPhpShop\Money;
|
||||
use MonetaPhpShop\order\Order;
|
||||
|
||||
/**
|
||||
* Çàêàçû ìàãàçèíà ñî ñòîðîíû êîëáýêà.
|
||||
*/
|
||||
interface OrderGateway
|
||||
{
|
||||
/**
|
||||
* Êàæäûé âûçîâ ÷èòàåò ÁÄ çàíîâî: âíóòðè áëîêèðîâêè íóæåí ñâåæèé ñíèìîê.
|
||||
*
|
||||
* @phpstan-impure
|
||||
*/
|
||||
public function findByUid(string $uid): ?Order;
|
||||
|
||||
/**
|
||||
* Îòìåòèòü îïëàòó ñðåäñòâàìè ÿäðà: çàïèñü â æóðíàëå îïëàò PHPShop, ñìåíà ñòàòóñà
|
||||
* (ñ ïèñüìîì ïîêóïàòåëþ è ñïèñàíèåì ñî ñêëàäà, êàê íàñòðîåíî ó ñòàòóñà), ôëàã `paid`.
|
||||
*/
|
||||
public function markPaid(Order $order, int $paidStatus, Money $amount): void;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\callback;
|
||||
|
||||
/**
|
||||
* Áëîêèðîâêà çàêàçà íà âðåìÿ îáðàáîòêè óâåäîìëåíèÿ: ïîâòîð Pay URL, ïðèøåäøèé
|
||||
* ïàðàëëåëüíî ñ ïåðâûì, æä¸ò åãî è âèäèò óæå çàïèñàííûé ìàðêåð îïëàòû.
|
||||
*/
|
||||
interface OrderLock
|
||||
{
|
||||
public function acquire(int $orderId): bool;
|
||||
|
||||
public function release(int $orderId): void;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\callback;
|
||||
|
||||
use MonetaPhpShop\order\Order;
|
||||
|
||||
interface ReceiptSource
|
||||
{
|
||||
/**
|
||||
* `MNT_ATTRIBUTES` XML-îòâåòà: `INVENTORY`, `CLIENT`, `CUSTOMER`, `PHONE`, ñòðîêè â UTF-8.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function attributes(Order $order): array;
|
||||
|
||||
/**
|
||||
* Îáúåêò `receipt` JSON-îòâåòà Check URL èëè null, åñëè ÷åê íå ñîáðàòü.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function jsonReceipt(Order $order): ?array;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\callback;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Æóðíàë îòêàçîâ äî ïðîâåðêè ïîäïèñè: àäðåñ êîëáýêà ïóáëè÷íûé, è çàïèñü íà êàæäûé ìóñîðíûé
|
||||
* çàïðîñ ðàçäóâàëà áû æóðíàë îøèáîê PHP. Ïèøåò íå ÷àùå ñòðîêè çà èíòåðâàë è â íåé ñîîáùàåò,
|
||||
* ñêîëüêî îòêàçîâ ïðîïóùåíî. Åñëè ñ÷¸ò÷èê íåäîñòóïåí (òàáëèöû íåò — ìîäóëü âûêëþ÷åí), ïèøåò âñ¸.
|
||||
*/
|
||||
final class ThrottledLogger implements Logger
|
||||
{
|
||||
public const INTERVAL = 60;
|
||||
|
||||
/** @var Logger */
|
||||
private $logger;
|
||||
|
||||
/** @var LogThrottle */
|
||||
private $throttle;
|
||||
|
||||
/** @var callable(): int */
|
||||
private $clock;
|
||||
|
||||
/**
|
||||
* @param callable(): int|null $clock unix-âðåìÿ; ïî óìîë÷àíèþ `time()`
|
||||
*/
|
||||
public function __construct(Logger $logger, LogThrottle $throttle, ?callable $clock = null)
|
||||
{
|
||||
$this->logger = $logger;
|
||||
$this->throttle = $throttle;
|
||||
$this->clock = $clock ?? 'time';
|
||||
}
|
||||
|
||||
public function error(string $message): void
|
||||
{
|
||||
try {
|
||||
$skipped = $this->throttle->pass(($this->clock)(), self::INTERVAL);
|
||||
} catch (Throwable $exception) {
|
||||
$this->logger->error($message);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($skipped === null) {
|
||||
return;
|
||||
}
|
||||
if ($skipped > 0) {
|
||||
$message .= sprintf(
|
||||
' Ïåðåä ýòèì íå çàïèñàíî îòêàçîâ: %d (íå ÷àùå ñòðîêè â %d ñ).',
|
||||
$skipped,
|
||||
self::INTERVAL,
|
||||
);
|
||||
}
|
||||
$this->logger->error($message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\form;
|
||||
|
||||
use MonetaPhpShop\Money;
|
||||
|
||||
/**
|
||||
* ×èñëî ñóììû äëÿ ïîêóïàòåëÿ òàê æå, êàê åãî ïèøåò âèòðèíà PHPShop: çíàêîâ ïîñëå òî÷êè — ïî
|
||||
* íàñòðîéêå ìàãàçèíà «Çíàêîâ ïîñëå çàïÿòîé â öåíå» (`admoption.price_znak`), òûñÿ÷è ÷åðåç ïðîáåë.
|
||||
* Çíàê âàëþòû äîáàâëÿåò âèòðèíà ({@see \MonetaPhpShop\host\ShopContext::currencyIcon()}).
|
||||
*/
|
||||
final class AmountText
|
||||
{
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* @param int $decimals çíàêîâ ïîñëå òî÷êè; âíå 0–2 — 0, êàê ó ÿäðà ïðè ïóñòîé íàñòðîéêå
|
||||
*/
|
||||
public static function format(Money $amount, int $decimals): string
|
||||
{
|
||||
$decimals = (($decimals >= 0) && ($decimals <= 2)) ? $decimals : 0;
|
||||
|
||||
return number_format((float) $amount->toDecimal(), $decimals, '.', ' ');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\form;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use MonetaPhpShop\order\Order;
|
||||
use MonetaPhpShop\PhoneNumber;
|
||||
use MonetaPhpShop\protocol\AssistantProtocol;
|
||||
use MonetaPhpShop\protocol\TransactionId;
|
||||
use MonetaPhpShop\Settings;
|
||||
|
||||
/**
|
||||
* Ïëàò¸æíàÿ ôîðìà MONETA.Assistant.
|
||||
*
|
||||
* Êàæäûé ïîêàç ôîðìû — íîâûé `MNT_TRANSACTION_ID` (`<uid>_<ñóììà>_<YmdHis>UTC`): ïîâòîðíóþ îïëàòó
|
||||
* óæå îïëà÷åííîãî çàêàçà îñòàíàâëèâàåò Check URL.
|
||||
*/
|
||||
final class PaymentForm
|
||||
{
|
||||
/** Ïðåôèêñ ID ïîêóïàòåëÿ PHPShop â `MNT_SUBSCRIBER_ID`. */
|
||||
public const SUBSCRIBER_USER_PREFIX = 'USER_';
|
||||
|
||||
/** Òåêñò êíîïêè ïîñëå íàæàòèÿ; áåç êàâû÷åê è ðàçìåòêè — ïîäñòàâëÿåòñÿ â onsubmit. */
|
||||
private const REDIRECTING = 'Ïåðåõîäèì ê îïëàòå...';
|
||||
|
||||
/** @var string */
|
||||
private $action;
|
||||
|
||||
/** @var array<string, string> */
|
||||
private $fields;
|
||||
|
||||
/**
|
||||
* @param array<string, string> $fields
|
||||
*/
|
||||
private function __construct(string $action, array $fields)
|
||||
{
|
||||
$this->action = $action;
|
||||
$this->fields = $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $currency `MNT_CURRENCY_CODE` ({@see AssistantProtocol::normalizeCurrency()})
|
||||
* @param ReturnUrls $returnUrls àäðåñà âîçâðàòà ïîêóïàòåëÿ
|
||||
* @param string $description `MNT_DESCRIPTION` â êîäèðîâêå âèòðèíû
|
||||
* @param string $cms `MNT_CMS`
|
||||
* @param string $locale `moneta.locale` — ÿçûê ïëàò¸æíîé ôîðìû ({@see AssistantProtocol::normalizeLocale()})
|
||||
*/
|
||||
public static function create(
|
||||
Settings $settings,
|
||||
Order $order,
|
||||
string $currency,
|
||||
DateTimeImmutable $issuedAt,
|
||||
ReturnUrls $returnUrls,
|
||||
string $description,
|
||||
string $cms,
|
||||
string $locale
|
||||
): self {
|
||||
$amount = $order->getAmount();
|
||||
$transactionId = TransactionId::build($order->getUid(), $amount, $issuedAt);
|
||||
$subscriberId = self::subscriberId($order);
|
||||
$testMode = $settings->isTestMode();
|
||||
|
||||
$fields = [
|
||||
'MNT_ID' => $settings->getAccountId(),
|
||||
'MNT_TRANSACTION_ID' => $transactionId,
|
||||
'MNT_CURRENCY_CODE' => $currency,
|
||||
'MNT_AMOUNT' => $amount->toDecimal(),
|
||||
'MNT_TEST_MODE' => AssistantProtocol::testModeFlag($testMode),
|
||||
'MNT_SUBSCRIBER_ID' => $subscriberId,
|
||||
'MNT_SIGNATURE' => $settings->signature()->forPaymentForm(
|
||||
$settings->getAccountId(),
|
||||
$transactionId,
|
||||
$amount,
|
||||
$currency,
|
||||
$subscriberId,
|
||||
$testMode,
|
||||
),
|
||||
'MNT_DESCRIPTION' => $description,
|
||||
'MNT_SUCCESS_URL' => $returnUrls->getSuccess(),
|
||||
'MNT_FAIL_URL' => $returnUrls->getFail(),
|
||||
'MNT_INPROGRESS_URL' => $returnUrls->getInProgress(),
|
||||
'MNT_RETURN_URL' => $returnUrls->getReturn(),
|
||||
'MNT_CMS' => $cms,
|
||||
'moneta.locale' => $locale,
|
||||
];
|
||||
|
||||
return new self($settings->getPaymentServer()->url(), $fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* `MNT_SUBSCRIBER_ID`: email -> òåëåôîí (`+öèôðû`) -> ID ïîêóïàòåëÿ PHPShop; ïóñòî — äàííûõ íåò
|
||||
*/
|
||||
public static function subscriberId(Order $order): string
|
||||
{
|
||||
if ($order->getEmail() !== '') {
|
||||
return $order->getEmail();
|
||||
}
|
||||
|
||||
$phone = PhoneNumber::normalize($order->getPhone());
|
||||
if ($phone !== null) {
|
||||
return $phone;
|
||||
}
|
||||
|
||||
return ($order->getUserId() > 0) ? self::SUBSCRIBER_USER_PREFIX . $order->getUserId() : '';
|
||||
}
|
||||
|
||||
public function getAction(): string
|
||||
{
|
||||
return $this->action;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getFields(): array
|
||||
{
|
||||
return $this->fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML ôîðìû ñ îäíîé êíîïêîé; ñòðîêè â êîäèðîâêå âèòðèíû (cp1251).
|
||||
*
|
||||
* `accept-charset="UTF-8"`: áðàóçåð ïåðåêîäèðóåò çíà÷åíèÿ èç cp1251 ñòðàíèöû â UTF-8,
|
||||
* êîòîðóþ æä¸ò Ìîíåòà. Êíîïêà ïîñëå íàæàòèÿ áëîêèðóåòñÿ (îò äâîéíîé îòïðàâêè), ïîêà îòêðûâàåòñÿ ñòðàíèöà Ìîíåòû.
|
||||
*/
|
||||
/**
|
||||
* @param string $buttonTitle òåêñò êíîïêè, ýêðàíèðóåòñÿ
|
||||
* @param string $buttonSuffixHtml ãîòîâûé HTML ïîñëå òåêñòà (çíàê âàëþòû âèòðèíû), íå ýêðàíèðóåòñÿ
|
||||
*/
|
||||
public function toHtml(string $buttonTitle, string $buttonSuffixHtml = ''): string
|
||||
{
|
||||
$html = '<form class="moneta-payment-form" method="post" action="' . self::escape($this->action) . '"'
|
||||
. ' accept-charset="UTF-8"'
|
||||
. ' onsubmit="var b=this.querySelector(\'button\');if(b){b.disabled=true;'
|
||||
. 'b.textContent=\'' . self::REDIRECTING . '\';}">';
|
||||
foreach ($this->fields as $name => $value) {
|
||||
$html .= '<input type="hidden" name="' . self::escape($name) . '" value="' . self::escape($value) . '">';
|
||||
}
|
||||
$html .= '<button type="submit" class="btn btn-primary moneta-payment-button">'
|
||||
. self::escape($buttonTitle) . $buttonSuffixHtml . '</button>';
|
||||
|
||||
return $html . '</form>';
|
||||
}
|
||||
|
||||
private static function escape(string $value): string
|
||||
{
|
||||
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'Windows-1251');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\form;
|
||||
|
||||
use MonetaPhpShop\callback\OrderGateway;
|
||||
use MonetaPhpShop\order\Order;
|
||||
use MonetaPhpShop\protocol\TransactionId;
|
||||
|
||||
/**
|
||||
* Ìîæíî ëè ïîêàçàòü ïîêóïàòåëþ ñòàòóñ çàêàçà íà ñòðàíèöå âîçâðàòà. `MNT_TRANSACTION_ID`
|
||||
* (`<uid>_<ñóììà>_<âðåìÿ>UTC`) óãàäûâàåòñÿ, ïîýòîìó îäíîãî åãî ìàëî — çàêàç äîëæåí ïðèíàäëåæàòü
|
||||
* òîìó, êòî ñìîòðèò:
|
||||
* - âîøåäøèé ïîêóïàòåëü — âëàäåëåö çàêàçà (`phpshop_orders.user`);
|
||||
* - èëè ôîðìà îïëàòû ýòîãî çàêàçà ïîêàçûâàëàñü â ýòîé æå ñåññèè áðàóçåðà: òàê âèäèò ñòàòóñ
|
||||
* ãîñòü, êîòîðûé îôîðìèë çàêàç áåç âõîäà (PHPShop çàâîäèò åìó ïîëüçîâàòåëÿ, íî íå âõîäèò).
|
||||
*/
|
||||
final class ReturnAccess
|
||||
{
|
||||
/** Êëþ÷ `$_SESSION`: çàêàçû, ôîðìà îïëàòû êîòîðûõ ïîêàçûâàëàñü â ýòîé ñåññèè. */
|
||||
public const SESSION_KEY = 'moneta_payable_orders';
|
||||
|
||||
/** Ñêîëüêî çàêàçîâ ïîìíèòü â ñåññèè. */
|
||||
public const SESSION_LIMIT = 20;
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* @param int $sessionUserId `$_SESSION['UsersId']`; 0 — ïîêóïàòåëü íå âîø¸ë
|
||||
* @param mixed $sessionOrders `$_SESSION[SESSION_KEY]`
|
||||
*/
|
||||
public static function findOrder(
|
||||
string $transactionId,
|
||||
OrderGateway $orders,
|
||||
int $sessionUserId,
|
||||
$sessionOrders
|
||||
): ?Order {
|
||||
$parsed = TransactionId::parse($transactionId);
|
||||
$order = ($parsed === null) ? null : $orders->findByUid($parsed->getOrderUid());
|
||||
|
||||
return ($order !== null && self::canView($order, $sessionUserId, $sessionOrders)) ? $order : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $sessionUserId `$_SESSION['UsersId']`; 0 — ïîêóïàòåëü íå âîø¸ë
|
||||
* @param mixed $sessionOrders `$_SESSION[SESSION_KEY]`
|
||||
*/
|
||||
public static function canView(Order $order, int $sessionUserId, $sessionOrders): bool
|
||||
{
|
||||
if (self::isOwner($order, $sessionUserId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return is_array($sessionOrders) && in_array($order->getId(), $sessionOrders, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Çàêàç âîøåäøåãî ïîêóïàòåëÿ. Ó ãîñòÿ ID 0 — ñîâïàë áû ñ çàêàçàìè áåç ïîëüçîâàòåëÿ.
|
||||
*
|
||||
* @param int $sessionUserId `$_SESSION['UsersId']`; 0 — ïîêóïàòåëü íå âîø¸ë
|
||||
*/
|
||||
public static function isOwner(Order $order, int $sessionUserId): bool
|
||||
{
|
||||
return $sessionUserId > 0 && $order->getUserId() === $sessionUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Çàïîìíèòü çàêàç, ôîðìà îïëàòû êîòîðîãî ïîêàçàíà ïîêóïàòåëþ; ñòàðûå âûòåñíÿþòñÿ.
|
||||
*
|
||||
* @param mixed $sessionOrders `$_SESSION[SESSION_KEY]`
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public static function remember($sessionOrders, int $orderId): array
|
||||
{
|
||||
$orders = is_array($sessionOrders) ? array_values(array_filter($sessionOrders, 'is_int')) : [];
|
||||
$orders = array_values(array_diff($orders, [$orderId]));
|
||||
$orders[] = $orderId;
|
||||
|
||||
return array_slice($orders, -self::SESSION_LIMIT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\form;
|
||||
|
||||
use MonetaPhpShop\order\Order;
|
||||
use MonetaPhpShop\order\PaymentEligibility;
|
||||
use MonetaPhpShop\Settings;
|
||||
|
||||
/**
|
||||
* Ñòðàíèöà âîçâðàòà `/moneta/`: ÷òî ïîêàçàòü ïîêóïàòåëþ. Âîçâðàò — íå ïîäòâåðæäåíèå îïëàòû:
|
||||
* ñòàòóñ çàêàçà ìåíÿåò òîëüêî Pay URL, ïîýòîìó «îïëà÷åí» ïèøåì òîëüêî ïî çàêàçó.
|
||||
*
|
||||
* - Çàêàç èçâåñòåí (íîìåð èç `MNT_TRANSACTION_ID` íàéäåí, è çàêàç ïðèíàäëåæèò ïîêóïàòåëþ —
|
||||
* {@see ReturnAccess}): ðåøàåò åãî ñòàòóñ. Îïëà÷åí èëè îòìåí¸í — òàê
|
||||
* è ïèøåì, ÷òî áû íè ñêàçàë àäðåñ âîçâðàòà. Íå îïëà÷åí, à ïîêóïàòåëü âåðíóëñÿ ñ óñïåõîì —
|
||||
* æä¸ì Pay URL: ñòðàíèöà ñàìà ïåðåçàïðàøèâàåò ñòàòóñ {@see self::MAX_ATTEMPTS} ðàç.
|
||||
* - Íîìåð îïåðàöèè ïåðåäàí, íî çàêàçà íåò èëè îí ÷óæîé — «Çàêàç íå íàéäåí»: ÷óæîé çàêàç
|
||||
* íåîòëè÷èì îò íåñóùåñòâóþùåãî, ïåðåáîðîì íîìåðîâ ñòàòóñ íå óçíàòü.
|
||||
* - Íîìåðà îïåðàöèè íåò — òîëüêî òî, ÷òî ñäåëàë ïîêóïàòåëü íà ñòîðîíå MONETA.RU.
|
||||
*/
|
||||
final class ReturnMessage
|
||||
{
|
||||
/** Íàçâàíèå ïëàò¸æíîãî ñåðâèñà â òåêñòàõ ïîêóïàòåëþ. */
|
||||
private const SERVICE_NAME = '«Ìîíåòà»';
|
||||
|
||||
/** Èêîíêè ñòðàíèöû — êëàññû `moneta-result--<èêîíêà>` â `templates/moneta.css`. */
|
||||
public const ICON_SUCCESS = 'success';
|
||||
public const ICON_ERROR = 'error';
|
||||
public const ICON_WAIT = 'wait';
|
||||
public const ICON_INFO = 'info';
|
||||
public const ICON_WARNING = 'warning';
|
||||
|
||||
/** Ñêîëüêî ðàç ñòðàíèöà ïåðåçàïðàøèâàåò ñòàòóñ, îæèäàÿ Pay URL. */
|
||||
public const MAX_ATTEMPTS = 20;
|
||||
|
||||
/** Ïàóçà ìåæäó ïåðåçàïðîñàìè, ìñ: âìåñòå ñ {@see self::MAX_ATTEMPTS} — îêîëî ìèíóòû. */
|
||||
public const REFRESH_DELAY_MS = 3000;
|
||||
|
||||
/** Òåêñòû áåç çàêàçà: òîëüêî ïî àäðåñó âîçâðàòà. */
|
||||
private const TEXTS = [
|
||||
ReturnUrls::RESULT_SUCCESS => [
|
||||
self::ICON_INFO,
|
||||
'Ñïàñèáî, Âàø ïëàò¸æ ïðèíÿò',
|
||||
'Ñòàòóñ çàêàçà îáíîâèòñÿ, êàê òîëüêî ' . self::SERVICE_NAME . ' ïîäòâåðäèò îïëàòó.',
|
||||
],
|
||||
ReturnUrls::RESULT_IN_PROGRESS => [
|
||||
self::ICON_INFO,
|
||||
'Ïëàò¸æ îáðàáàòûâàåòñÿ',
|
||||
self::SERVICE_NAME . ' åù¸ ïðîâåðÿåò ïëàò¸æ. Ñòàòóñ çàêàçà îáíîâèòñÿ àâòîìàòè÷åñêè.',
|
||||
],
|
||||
ReturnUrls::RESULT_FAIL => [
|
||||
self::ICON_ERROR,
|
||||
'Ïëàò¸æ íå ïðîø¸ë',
|
||||
'Îïëàòó ìîæíî ïîâòîðèòü â ðàçäåëå «Ìîè çàêàçû» èëè ñâÿæèòåñü ñ ìàãàçèíîì.',
|
||||
],
|
||||
ReturnUrls::RESULT_RETURN => [
|
||||
self::ICON_INFO,
|
||||
'Îïëàòà íå çàâåðøåíà',
|
||||
'Âû âåðíóëèñü â ìàãàçèí áåç îïëàòû. Îïëàòèòü çàêàç ìîæíî â ðàçäåëå «Ìîè çàêàçû».',
|
||||
],
|
||||
];
|
||||
|
||||
/** @var string */
|
||||
private $icon;
|
||||
|
||||
/** @var string */
|
||||
private $title;
|
||||
|
||||
/** @var string */
|
||||
private $text;
|
||||
|
||||
/** @var string */
|
||||
private $orderUid;
|
||||
|
||||
/** @var bool */
|
||||
private $waiting;
|
||||
|
||||
private function __construct(
|
||||
string $icon,
|
||||
string $title,
|
||||
string $text,
|
||||
string $orderUid = '',
|
||||
bool $waiting = false
|
||||
) {
|
||||
$this->icon = $icon;
|
||||
$this->title = $title;
|
||||
$this->text = $text;
|
||||
$this->orderUid = $orderUid;
|
||||
$this->waiting = $waiting;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $result çíà÷åíèå `?result=` àäðåñà âîçâðàòà
|
||||
* @param bool $transactionGiven â àäðåñå åñòü `MNT_TRANSACTION_ID`
|
||||
* @param Order|null $order çàêàç ïî `MNT_TRANSACTION_ID`, óæå ïðîâåðåííûé {@see ReturnAccess}
|
||||
* @param bool $paidByJournal ïî çàêàçó åñòü çà÷ò¸ííàÿ îïëàòà â æóðíàëå ìîäóëÿ
|
||||
* @param int $attempt íîìåð ïåðåçàïðîñà ñòàòóñà, ñ 0
|
||||
*
|
||||
* @return self|null null — íåèçâåñòíûé `result`, ñòðàíèöà îòâå÷àåò 404
|
||||
*/
|
||||
public static function resolve(
|
||||
string $result,
|
||||
bool $transactionGiven,
|
||||
?Order $order,
|
||||
bool $paidByJournal,
|
||||
Settings $settings,
|
||||
int $attempt = 0
|
||||
): ?self {
|
||||
if (!isset(self::TEXTS[$result])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($order === null) {
|
||||
if ($transactionGiven) {
|
||||
return new self(
|
||||
self::ICON_WARNING,
|
||||
'Çàêàç íå íàéäåí',
|
||||
'Ñòàòóñ îïëàòû ìîæíî ïðîâåðèòü â ðàçäåëå «Ìîè çàêàçû». Åñëè äåíüãè ñïèñàíû, à çàêàç'
|
||||
. ' íå îïëà÷åí, ñâÿæèòåñü ñ ìàãàçèíîì.',
|
||||
);
|
||||
}
|
||||
[$icon, $title, $text] = self::TEXTS[$result];
|
||||
|
||||
return new self($icon, $title, $text);
|
||||
}
|
||||
|
||||
$uid = $order->getUid();
|
||||
if ($paidByJournal || PaymentEligibility::isPaid($settings, $order)) {
|
||||
return new self(self::ICON_SUCCESS, 'Îïëàòà ïðîøëà óñïåøíî', 'Çàêàç ¹' . $uid . ' îïëà÷åí. Ñïàñèáî!', $uid);
|
||||
}
|
||||
if ($order->getStatusId() === PaymentEligibility::STATUS_CANCELLED) {
|
||||
return new self(
|
||||
self::ICON_ERROR,
|
||||
'Çàêàç ¹' . $uid . ' îòìåí¸í',
|
||||
'Åñëè äåíüãè ñïèñàíû, ñâÿæèòåñü ñ ìàãàçèíîì.',
|
||||
$uid,
|
||||
);
|
||||
}
|
||||
if ($result === ReturnUrls::RESULT_FAIL) {
|
||||
return new self(
|
||||
self::ICON_ERROR,
|
||||
'Ïëàò¸æ íå ïðîø¸ë',
|
||||
'Çàêàç ¹' . $uid . ' íå îïëà÷åí. Îïëàòó ìîæíî ïîâòîðèòü â ðàçäåëå «Ìîè çàêàçû»'
|
||||
. ' èëè ñâÿæèòåñü ñ ìàãàçèíîì.',
|
||||
$uid,
|
||||
);
|
||||
}
|
||||
if ($result === ReturnUrls::RESULT_RETURN) {
|
||||
return new self(
|
||||
self::ICON_INFO,
|
||||
'Îïëàòà íå çàâåðøåíà',
|
||||
'Çàêàç ¹' . $uid . ' íå îïëà÷åí. Îïëàòèòü åãî ìîæíî â ðàçäåëå «Ìîè çàêàçû».',
|
||||
$uid,
|
||||
);
|
||||
}
|
||||
if ($attempt < self::MAX_ATTEMPTS) {
|
||||
return new self(
|
||||
self::ICON_WAIT,
|
||||
'Îæèäàåì ïîäòâåðæäåíèå îïëàòû',
|
||||
'Çàêàç ¹' . $uid . '. Íå çàêðûâàéòå ñòðàíèöó - ñòàòóñ îáíîâèòñÿ àâòîìàòè÷åñêè.',
|
||||
$uid,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
return new self(
|
||||
self::ICON_INFO,
|
||||
'Îïëàòà åù¸ íå ïîäòâåðæäåíà',
|
||||
'Çàêàç ¹' . $uid . '. ' . self::SERVICE_NAME . ' ïîêà íå ïðèñëàëà ïîäòâåðæäåíèå. Ñòàòóñ'
|
||||
. ' çàêàçà îáíîâèòñÿ àâòîìàòè÷åñêè - ïðîâåðüòå åãî â ðàçäåëå «Ìîè çàêàçû» ïîçæå.',
|
||||
$uid,
|
||||
);
|
||||
}
|
||||
|
||||
public function getIcon(): string
|
||||
{
|
||||
return $this->icon;
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function getText(): string
|
||||
{
|
||||
return $this->text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Íîìåð çàêàçà äëÿ ññûëêè «Ïîäðîáíåå î çàêàçå»; ïóñòîé — çàêàç ïîêóïàòåëþ íå ïîêàçûâàåì.
|
||||
*/
|
||||
public function getOrderUid(): string
|
||||
{
|
||||
return $this->orderUid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ñòðàíèöà æä¸ò Pay URL è äîëæíà ïåðåçàïðîñèòü ñòàòóñ.
|
||||
*/
|
||||
public function isWaiting(): bool
|
||||
{
|
||||
return $this->waiting;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\form;
|
||||
|
||||
/**
|
||||
* Àäðåñà, êóäà Ìîíåòà âîçâðàùàåò ïîêóïàòåëÿ (`MNT_SUCCESS_URL`, `MNT_FAIL_URL`,
|
||||
* `MNT_INPROGRESS_URL`, `MNT_RETURN_URL`). Âñå âåäóò íà ñòðàíèöó ìîäóëÿ `/moneta/`;
|
||||
* âîçâðàò — íå ïîäòâåðæäåíèå îïëàòû, ñòàòóñ çàêàçà ìåíÿåò òîëüêî Pay URL.
|
||||
*/
|
||||
final class ReturnUrls
|
||||
{
|
||||
public const RESULT_SUCCESS = 'success';
|
||||
public const RESULT_FAIL = 'fail';
|
||||
public const RESULT_IN_PROGRESS = 'inprogress';
|
||||
public const RESULT_RETURN = 'return';
|
||||
|
||||
public const RESULTS = [
|
||||
self::RESULT_SUCCESS,
|
||||
self::RESULT_FAIL,
|
||||
self::RESULT_IN_PROGRESS,
|
||||
self::RESULT_RETURN,
|
||||
];
|
||||
|
||||
/** @var string */
|
||||
private $pageUrl;
|
||||
|
||||
/**
|
||||
* @param string $pageUrl àäðåñ ñòðàíèöû ìîäóëÿ: àáñîëþòíûé äëÿ ôîðìû îïëàòû
|
||||
* (`https://shop.example/moneta/`), ïóòü îò êîðíÿ ñàéòà äëÿ ðåäèðåêòà (`/moneta/`)
|
||||
*/
|
||||
public function __construct(string $pageUrl)
|
||||
{
|
||||
$this->pageUrl = $pageUrl;
|
||||
}
|
||||
|
||||
public function getSuccess(): string
|
||||
{
|
||||
return $this->withResult(self::RESULT_SUCCESS);
|
||||
}
|
||||
|
||||
public function getFail(): string
|
||||
{
|
||||
return $this->withResult(self::RESULT_FAIL);
|
||||
}
|
||||
|
||||
public function getInProgress(): string
|
||||
{
|
||||
return $this->withResult(self::RESULT_IN_PROGRESS);
|
||||
}
|
||||
|
||||
public function getReturn(): string
|
||||
{
|
||||
return $this->withResult(self::RESULT_RETURN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Àäðåñ ñòðàíèöû ìîäóëÿ äëÿ ïîêóïàòåëÿ, êîòîðîãî MONETA.RU âåðíóëà íà `/success` èëè `/fail`
|
||||
* ÿäðà: òàê íàñòðîåíû ñ÷åòà ìåð÷àíòîâ ñî âñòðîåííûì ìîäóëåì, à áåç ôëàãà «Ìîæíî
|
||||
* ïåðåîïðåäåëÿòü íàñòðîéêè â URL» íàøè `MNT_*_URL` èãíîðèðóþòñÿ (MONETA.Assistant.ru.pdf,
|
||||
* ïàðàìåòðû `MNT_SUCCESS_URL`, `MNT_FAIL_URL`).
|
||||
*
|
||||
* @param mixed $transactionId `MNT_TRANSACTION_ID` èç àäðåñà âîçâðàòà
|
||||
*
|
||||
* @return string|null null — âîçâðàò íå îò MONETA.RU (íåò íîìåðà îïåðàöèè), ñòðàíèöà ÿäðà
|
||||
* îáðàáàòûâàåò åãî ñàìà
|
||||
*/
|
||||
public function withTransaction(string $result, $transactionId): ?string
|
||||
{
|
||||
if ($transactionId === ''
|
||||
|| !is_string($transactionId)
|
||||
|| !in_array($result, self::RESULTS, true)
|
||||
|| strlen($transactionId) > 255
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->withResult($result) . '&MNT_TRANSACTION_ID=' . rawurlencode($transactionId);
|
||||
}
|
||||
|
||||
private function withResult(string $result): string
|
||||
{
|
||||
return $this->pageUrl . '?result=' . $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\host;
|
||||
|
||||
/**
|
||||
* Ñîäåðæèìîå êàðòî÷êè îïëàòû äëÿ øàáëîíà `templates/payment_forma.tpl`: îïèñàíèå îïëàòû
|
||||
* (`@payment_info@`), ñóììà ê îïëàòå (`@moneta_total@`) è ôîðìà ñ êíîïêîé èëè òåêñò î ñîñòîÿíèè
|
||||
* çàêàçà (`@payment_forma@`).
|
||||
* Âñ¸ — â cp1251, áåç ïåðåêîäèðîâêè: áëîê öåëèêîì ïåðåêîäèðóåò õóê.
|
||||
*/
|
||||
final class CheckoutBlock
|
||||
{
|
||||
/** @var string */
|
||||
private $form;
|
||||
|
||||
/** @var string */
|
||||
private $total;
|
||||
|
||||
/** @var string */
|
||||
private $info;
|
||||
|
||||
/**
|
||||
* @param string $total ñóììà ê îïëàòå, HTML ñî çíàêîì âàëþòû âèòðèíû; ïóñòàÿ — çàêàç ñåé÷àñ
|
||||
* íå îïëàòèòü, ñòðîêè ñóììû íåò
|
||||
* @param string $info «Îïèñàíèå îïëàòû», ãîòîâûé HTML; òîëüêî âìåñòå ñ ôîðìîé îïëàòû
|
||||
*/
|
||||
public function __construct(string $form, string $total = '', string $info = '')
|
||||
{
|
||||
$this->form = $form;
|
||||
$this->total = $total;
|
||||
$this->info = $info;
|
||||
}
|
||||
|
||||
public function getInfo(): string
|
||||
{
|
||||
return $this->info;
|
||||
}
|
||||
|
||||
public function getForm(): string
|
||||
{
|
||||
return $this->form;
|
||||
}
|
||||
|
||||
public function getTotal(): string
|
||||
{
|
||||
return $this->total;
|
||||
}
|
||||
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return ($this->form === '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\host;
|
||||
|
||||
use MonetaPhpShop\form\AmountText;
|
||||
use MonetaPhpShop\form\PaymentForm;
|
||||
use MonetaPhpShop\form\ReturnAccess;
|
||||
use MonetaPhpShop\form\ReturnUrls;
|
||||
use MonetaPhpShop\order\Order;
|
||||
use MonetaPhpShop\order\PaymentEligibility;
|
||||
use MonetaPhpShop\protocol\AssistantProtocol;
|
||||
use MonetaPhpShop\Settings;
|
||||
|
||||
/**
|
||||
* Êíîïêà îïëàòû äëÿ õóêîâ âèòðèíû: ïîñëå îôîðìëåíèÿ çàêàçà (`send_to_order`) è â ëè÷íîì
|
||||
* êàáèíåòå (`userorderpaymentlink`). Îøèáêà ìîäóëÿ íå äîëæíà ðîíÿòü ñòðàíèöó ìàãàçèíà:
|
||||
* ïîêóïàòåëü âèäèò íåéòðàëüíûé òåêñò, ïîäðîáíîñòè — â æóðíàëå PHP.
|
||||
*/
|
||||
final class CheckoutView
|
||||
{
|
||||
private const MESSAGES = [
|
||||
PaymentEligibility::NOT_CONFIGURED => 'Îïëàòà ÷åðåç Ìîíåòó âðåìåííî íåäîñòóïíà. Ñâÿæèòåñü ñ ìàãàçèíîì.',
|
||||
PaymentEligibility::ALREADY_PAID => 'Çàêàç îïëà÷åí.',
|
||||
PaymentEligibility::CANCELLED => 'Çàêàç îòìåí¸í.',
|
||||
PaymentEligibility::AWAITING_STATUS => 'Çàêàç îáðàáàòûâàåòñÿ ìåíåäæåðîì.',
|
||||
PaymentEligibility::UNSUPPORTED_CURRENCY => 'Îïëàòà ÷åðåç Ìîíåòó âîçìîæíà òîëüêî â ðóáëÿõ.',
|
||||
PaymentEligibility::ZERO_AMOUNT => 'Îïëàòà íå òðåáóåòñÿ.',
|
||||
];
|
||||
|
||||
/** Òåêñò êíîïêè îïëàòû; íà êàðòî÷êå ïîñëå îôîðìëåíèÿ ê íåìó äîáàâëÿåòñÿ ñóììà. */
|
||||
private const BUTTON_TITLE = 'Îïëàòèòü';
|
||||
|
||||
private const UNAVAILABLE = 'Îïëàòà âðåìåííî íåäîñòóïíà. Ïîïðîáóéòå ïîçæå èëè ñâÿæèòåñü ñ ìàãàçèíîì.';
|
||||
|
||||
/** @var SettingsRepository */
|
||||
private $settingsRepository;
|
||||
|
||||
/** @var OrderRepository */
|
||||
private $orderRepository;
|
||||
|
||||
/** @var ShopContext */
|
||||
private $shopContext;
|
||||
|
||||
public function __construct(
|
||||
SettingsRepository $settingsRepository,
|
||||
OrderRepository $orderRepository,
|
||||
ShopContext $shopContext
|
||||
) {
|
||||
$this->settingsRepository = $settingsRepository;
|
||||
$this->orderRepository = $orderRepository;
|
||||
$this->shopContext = $shopContext;
|
||||
}
|
||||
|
||||
public static function create(): self
|
||||
{
|
||||
return new self(new SettingsRepository(), new OrderRepository(), new ShopContext());
|
||||
}
|
||||
|
||||
/**
|
||||
* Áëîê íà ñòðàíèöå «Çàêàç îôîðìëåí»: ñóììà è êíîïêà, åñëè çàêàç óæå ìîæíî îïëàòèòü, èíà÷å
|
||||
* òåêñò î ñîñòîÿíèè çàêàçà. Ðàçìåòêó ñîáèðàåò øàáëîí `payment_forma.tpl` â õóêå; ÷àñòè — â cp1251.
|
||||
*/
|
||||
public function afterCheckout(int $orderId): CheckoutBlock
|
||||
{
|
||||
return $this->block($orderId, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* ß÷åéêà «Ñïîñîá îïëàòû» â ëè÷íîì êàáèíåòå: îïèñàíèå îïëàòû è êíîïêà èëè ñîñòîÿíèå çàêàçà, áåç ñóììû.
|
||||
*/
|
||||
public function inAccount(int $orderId): string
|
||||
{
|
||||
$block = $this->block($orderId, false);
|
||||
if ($block->isEmpty()) {
|
||||
return '';
|
||||
}
|
||||
$info = ($block->getInfo() === '') ? '' : '<p class="moneta-payment-info">' . $block->getInfo() . '</p>';
|
||||
|
||||
return HostCharset::toPage('<div class="moneta-payment">' . $info . $block->getForm() . '</div>');
|
||||
}
|
||||
|
||||
/**
|
||||
* ×òî ïîêàçàòü ïî ñîñòîÿíèþ çàêàçà: ôîðìó îïëàòû ñ ñóììîé, åñëè çàêàç ìîæíî îïëàòèòü,
|
||||
* èíà÷å — òåêñò î ñîñòîÿíèè.
|
||||
*
|
||||
* @param string $formHtml ôîðìà îïëàòû; íóæíà òîëüêî äëÿ {@see PaymentEligibility::PAYABLE}
|
||||
* @param string $total ñóììà ê îïëàòå òåêñòîì; ïîêàçûâàåòñÿ òîëüêî âìåñòå ñ ôîðìîé
|
||||
* @param string $description «Îïèñàíèå îïëàòû» îáû÷íûì òåêñòîì; ïîêàçûâàåòñÿ òîëüêî âìåñòå ñ ôîðìîé:
|
||||
* «Îïëàòèòå çàêàç» áåç êíîïêè (çàêàç æä¸ò ìåíåäæåðà) ñáèâàë áû ñ òîëêó
|
||||
*/
|
||||
public static function compose(
|
||||
string $eligibility,
|
||||
string $formHtml,
|
||||
string $total = '',
|
||||
string $description = ''
|
||||
): CheckoutBlock {
|
||||
if ($eligibility === PaymentEligibility::PAYABLE) {
|
||||
return new CheckoutBlock($formHtml, $total, self::descriptionHtml($description));
|
||||
}
|
||||
|
||||
return new CheckoutBlock('<p>' . (self::MESSAGES[$eligibility] ?? self::UNAVAILABLE) . '</p>');
|
||||
}
|
||||
|
||||
private function block(int $orderId, bool $afterCheckout): CheckoutBlock
|
||||
{
|
||||
try {
|
||||
$settings = $this->settingsRepository->load();
|
||||
$order = $this->orderRepository->findById($orderId);
|
||||
if ($order === null) {
|
||||
return new CheckoutBlock('');
|
||||
}
|
||||
// Ëè÷íûé êàáèíåò îòêðûâàåò çàêàç ïî îäíîìó uid, íå ñâåðÿÿ âëàäåëüöà
|
||||
// (`users.core/action_order_info.php` ÿäðà): ôîðìó è çàïèñü â ñåññèþ — òîëüêî âëàäåëüöó.
|
||||
if (!$afterCheckout && !ReturnAccess::isOwner($order, $this->shopContext->sessionUserId())) {
|
||||
return new CheckoutBlock('');
|
||||
}
|
||||
|
||||
$currency = $this->shopContext->orderCurrency();
|
||||
$eligibility = PaymentEligibility::check($settings, $order, $currency);
|
||||
if ($eligibility !== PaymentEligibility::PAYABLE) {
|
||||
return self::compose($eligibility, '');
|
||||
}
|
||||
$this->shopContext->rememberPayableOrder($order->getId());
|
||||
|
||||
// Íà êàðòî÷êå ïîñëå îôîðìëåíèÿ — ñóììà îòäåëüíîé ñòðîêîé è â êíîïêå,
|
||||
// ñî çíàêîì âàëþòû âèòðèíû; â ÿ÷åéêå ëè÷íîãî êàáèíåòà — êîðîòêàÿ êíîïêà.
|
||||
$amount = '';
|
||||
$currencyIcon = '';
|
||||
if ($afterCheckout) {
|
||||
$amount = AmountText::format($order->getAmount(), $this->shopContext->priceDecimals());
|
||||
$currencyIcon = $this->shopContext->currencyIcon();
|
||||
}
|
||||
$suffix = ($currencyIcon === '') ? '' : ' ' . $currencyIcon;
|
||||
$formHtml = $this->form(
|
||||
$settings,
|
||||
$order,
|
||||
(string) AssistantProtocol::normalizeCurrency($currency),
|
||||
($amount === '') ? self::BUTTON_TITLE : self::BUTTON_TITLE . ' ' . $amount,
|
||||
($amount === '') ? '' : $suffix,
|
||||
);
|
||||
$total = ($amount === '') ? '' : $amount . $suffix;
|
||||
$description = HostCharset::fromDatabase($settings->getPaymentDescription());
|
||||
|
||||
return self::compose($eligibility, $formHtml, $total, $description);
|
||||
} catch (\Throwable $exception) {
|
||||
(new ErrorLogLogger())->error(
|
||||
'Êíîïêà îïëàòû çàêàçà ' . $orderId . ' íå ïîñòðîåíà: ' . $exception->getMessage(),
|
||||
);
|
||||
|
||||
return new CheckoutBlock('<p>' . self::UNAVAILABLE . '</p>');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Òåêñò ìåð÷àíòà èç íàñòðîåê — â HTML: ýêðàíèðîâàíèå, ïåðåíîñû ñòðîê — `<br>`.
|
||||
*/
|
||||
private static function descriptionHtml(string $text): string
|
||||
{
|
||||
$text = trim($text);
|
||||
|
||||
return ($text === '') ? '' : nl2br(htmlspecialchars($text, ENT_QUOTES, 'Windows-1251'), false);
|
||||
}
|
||||
|
||||
private function form(
|
||||
Settings $settings,
|
||||
Order $order,
|
||||
string $currency,
|
||||
string $buttonTitle,
|
||||
string $buttonSuffixHtml
|
||||
): string {
|
||||
return PaymentForm::create(
|
||||
$settings,
|
||||
$order,
|
||||
$currency,
|
||||
$this->shopContext->now(),
|
||||
new ReturnUrls($this->shopContext->returnPageUrl()),
|
||||
'Îïëàòà çàêàçà ¹' . $order->getUid(),
|
||||
$this->shopContext->cms(),
|
||||
AssistantProtocol::normalizeLocale($this->shopContext->shopLocale()),
|
||||
)->toHtml($buttonTitle, $buttonSuffixHtml);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\host;
|
||||
|
||||
use mysqli;
|
||||
use mysqli_sql_exception;
|
||||
use mysqli_stmt;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Ïîäãîòîâëåííûå çàïðîñû ïîâåðõ ñîåäèíåíèÿ ÿäðà PHPShop (`$PHPShopBase->link_db`).
|
||||
*
|
||||
* `PHPShopOrm` ñîáèðàåò SQL êîíêàòåíàöèåé — äëÿ çàïèñåé êîëáýêà, ãäå â çàïðîñ ïîïàäàþò
|
||||
* äàííûå èçâíå, ìîäóëü õîäèò â ÁÄ ñàì. Îøèáêà çàïðîñà — {@see RuntimeException} ñ êîäîì
|
||||
* îøèáêè MySQL (1062 — äóáëèêàò êëþ÷à) ïðè ëþáîì `mysqli_report()`: â PHP 7.4 ïî óìîë÷àíèþ
|
||||
* mysqli ìîë÷èò, ñ 8.1 áðîñàåò èñêëþ÷åíèÿ.
|
||||
*/
|
||||
final class Database
|
||||
{
|
||||
/** Äóáëèêàò óíèêàëüíîãî êëþ÷à. */
|
||||
public const ER_DUP_ENTRY = 1062;
|
||||
|
||||
/** @var mysqli */
|
||||
private $link;
|
||||
|
||||
public function __construct(mysqli $link)
|
||||
{
|
||||
$this->link = $link;
|
||||
}
|
||||
|
||||
public static function fromHost(): self
|
||||
{
|
||||
// Êîëáýê è âèòðèíà äåðæàò ñîåäèíåíèå â $PHPShopBase, ÿäðî äóáëèðóåò åãî â ãëîáàëüíûé $link_db.
|
||||
$base = $GLOBALS['PHPShopBase'] ?? null;
|
||||
$link = is_object($base) && isset($base->link_db) ? $base->link_db : ($GLOBALS['link_db'] ?? null);
|
||||
if (!$link instanceof mysqli) {
|
||||
throw new RuntimeException('Íåò ñîåäèíåíèÿ ÿäðà PHPShop ñ ÁÄ.');
|
||||
}
|
||||
|
||||
return new self($link);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int|string|null> $params
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function fetchAll(string $sql, array $params = []): array
|
||||
{
|
||||
$statement = $this->run($sql, $params);
|
||||
try {
|
||||
$result = $statement->get_result();
|
||||
if ($result === false) {
|
||||
throw new RuntimeException('Çàïðîñ íå âåðíóë ñòðîê: ' . $statement->error, $statement->errno);
|
||||
}
|
||||
$rows = [];
|
||||
while (is_array($row = $result->fetch_assoc())) {
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
} finally {
|
||||
$statement->close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int|string|null> $params
|
||||
*
|
||||
* @return mixed ïåðâàÿ êîëîíêà ïåðâîé ñòðîêè èëè null
|
||||
*/
|
||||
public function fetchValue(string $sql, array $params = [])
|
||||
{
|
||||
$rows = $this->fetchAll($sql, $params);
|
||||
if ($rows === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return reset($rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int|string|null> $params
|
||||
*
|
||||
* @return int ÷èñëî çàòðîíóòûõ ñòðîê
|
||||
*/
|
||||
public function execute(string $sql, array $params = []): int
|
||||
{
|
||||
$statement = $this->run($sql, $params);
|
||||
$affected = $statement->affected_rows;
|
||||
$statement->close();
|
||||
|
||||
return (int) $affected;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int|string|null> $params
|
||||
*/
|
||||
private function run(string $sql, array $params): mysqli_stmt
|
||||
{
|
||||
try {
|
||||
$statement = $this->link->prepare($sql);
|
||||
if ($statement === false) {
|
||||
throw new RuntimeException('Çàïðîñ íå ïîäãîòîâëåí: ' . $this->link->error, $this->link->errno);
|
||||
}
|
||||
if ($params !== []) {
|
||||
$types = '';
|
||||
foreach ($params as $param) {
|
||||
$types .= is_int($param) ? 'i' : 's';
|
||||
}
|
||||
$statement->bind_param($types, ...$params);
|
||||
}
|
||||
if (!$statement->execute()) {
|
||||
$error = new RuntimeException('Çàïðîñ íå âûïîëíåí: ' . $statement->error, $statement->errno);
|
||||
$statement->close();
|
||||
|
||||
throw $error;
|
||||
}
|
||||
|
||||
return $statement;
|
||||
} catch (mysqli_sql_exception $exception) {
|
||||
throw new RuntimeException(
|
||||
'Îøèáêà MySQL: ' . $exception->getMessage(),
|
||||
(int) $exception->getCode(),
|
||||
$exception,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\host;
|
||||
|
||||
use MonetaPhpShop\callback\Logger;
|
||||
|
||||
/**
|
||||
* {@see Logger} â æóðíàë îøèáîê PHP (`error_log`), êàê ó øòàòíûõ ìîäóëåé PHPShop.
|
||||
* Ñòðîêè ìîäóëÿ ïîñëå ñáîðêè — cp1251, æóðíàë ÷èòàþò â UTF-8: ïåðåêîäèðóåì.
|
||||
*/
|
||||
final class ErrorLogLogger implements Logger
|
||||
{
|
||||
public function error(string $message): void
|
||||
{
|
||||
error_log('moneta: ' . HostCharset::toUtf8($message));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\host;
|
||||
|
||||
/**
|
||||
* Êîäèðîâêè íà ãðàíèöå ñ PHPShop.
|
||||
*
|
||||
* Ìîäóëü âíóòðè ðàáîòàåò â cp1251 — â íåé åãî èñõîäíèêè ïîñëå ñáîðêè è øòàòíàÿ áàçà PHPShop.
|
||||
* Ìàãàçèí ñ áàçîé `utf-8` (`$PHPShopBase->codBase`) îòäà¸ò ñòðîêè èç ÁÄ â UTF-8 è âûâîäèò
|
||||
* ñòðàíèöû â UTF-8 — íà ýòîé ãðàíèöå ñòðîêè ïåðåêîäèðóþòñÿ, êàê â øòàòíûõ ìîäóëÿõ ÿäðà.
|
||||
* Âñ¸, ÷òî óõîäèò â Ìîíåòó (JSON, XML), — UTF-8.
|
||||
*/
|
||||
final class HostCharset
|
||||
{
|
||||
private function __construct() {}
|
||||
|
||||
public static function isUtf8Shop(): bool
|
||||
{
|
||||
$base = $GLOBALS['PHPShopBase'] ?? null;
|
||||
|
||||
return is_object($base) && isset($base->codBase) && ($base->codBase === 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ñòðîêà èç ÁÄ ìàãàçèíà -> cp1251.
|
||||
*/
|
||||
public static function fromDatabase(string $value): string
|
||||
{
|
||||
if (!self::isUtf8Shop()) {
|
||||
return $value;
|
||||
}
|
||||
$converted = iconv('UTF-8', 'Windows-1251//IGNORE', $value);
|
||||
|
||||
return ($converted === false) ? '' : $converted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ñòðîêà ìîäóëÿ (cp1251) -> êîäèðîâêà ÁÄ ìàãàçèíà.
|
||||
*/
|
||||
public static function toDatabase(string $value): string
|
||||
{
|
||||
return self::isUtf8Shop() ? self::toUtf8($value) : $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML ìîäóëÿ (cp1251) -> êîäèðîâêà ñòðàíèöû ìàãàçèíà.
|
||||
*/
|
||||
public static function toPage(string $html): string
|
||||
{
|
||||
return self::isUtf8Shop() ? self::toUtf8($html) : $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* cp1251 -> UTF-8 äëÿ Ìîíåòû. Áàéò 0x98 â cp1251 íå îïðåäåë¸í — íà í¸ì iconv âåðíóë áû false
|
||||
* íà âñþ ñòðîêó (íàçâàíèå ïîçèöèè â ÷åêå ñòàëî áû ïóñòûì), ïîýòîìó îí óäàëÿåòñÿ çàðàíåå.
|
||||
* Íå //IGNORE: åãî ïîääåðæêà çàâèñèò îò ðåàëèçàöèè iconv (glibc — äà, musl — íåò).
|
||||
*/
|
||||
public static function toUtf8(string $value): string
|
||||
{
|
||||
$converted = iconv('Windows-1251', 'UTF-8', str_replace("\x98", '', $value));
|
||||
|
||||
return ($converted === false) ? '' : $converted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\host;
|
||||
|
||||
use Closure;
|
||||
use MonetaPhpShop\callback\Logger;
|
||||
use MonetaPhpShop\callback\ReceiptSource;
|
||||
use MonetaPhpShop\order\Order;
|
||||
use MonetaPhpShop\receipt\Receipt;
|
||||
use MonetaPhpShop\receipt\ReceiptBuilder;
|
||||
use MonetaPhpShop\receipt\Vat;
|
||||
use MonetaPhpShop\receipt\VatRateWarnings;
|
||||
use MonetaPhpShop\Settings;
|
||||
|
||||
/**
|
||||
* ×åê çàêàçà äëÿ îòâåòîâ Check/Pay URL. Ñòàâêè ÍÄÑ — ôèêñèðîâàííûå èç íàñòðîåê ìîäóëÿ
|
||||
* («ÍÄÑ íà òîâàðû», «ÍÄÑ íà äîñòàâêó»), à ïî óìîë÷àíèþ èç PHPShop: âûêëþ÷åí ÍÄÑ
|
||||
* â íàñòðîéêàõ ìàãàçèíà — «áåç ÍÄÑ» íà âñåõ ñòðîêàõ; âêëþ÷¸í — ñèñòåìíàÿ ñòàâêà ó òîâàðîâ
|
||||
* è ñòàâêà ñïîñîáà äîñòàâêè (`phpshop_delivery.ofd_nds`, ïóñòî — ñèñòåìíàÿ) ó äîñòàâêè.
|
||||
*
|
||||
* Ñòàâêó íå èç ïåðå÷íÿ êàññû (â PHPShop ýòî ñâîáîäíîå ÷èñëî, íàïðèìåð îïå÷àòêà `23`) ìîäóëü íå óãàäûâàåò:
|
||||
* ïîçèöèÿ óõîäèò «áåç ÍÄÑ», à â æóðíàë PHP ïèøåòñÿ ïðåäóïðåæäåíèå — òî æå ïîêàçûâàþò íàñòðîéêè ìîäóëÿ
|
||||
* ({@see VatRateWarnings}).
|
||||
*
|
||||
* Áåç ôèñêàëèçàöèè àòðèáóòû óðåçàíû, à «Email äëÿ ÷åêîâ» íå ïîäñòàâëÿåòñÿ: ÷åê íå ïðîáèâàåòñÿ,
|
||||
* MONETA.RU òîëüêî ñîõðàíÿåò àòðèáóòû â îïåðàöèè.
|
||||
*
|
||||
* Ñáîé ñáîðêè ÷åêà íå äîëæåí ðîíÿòü ïîäòâåðæäåíèå îïëàòû: àòðèáóòû òîãäà ïóñòûå, JSON — null.
|
||||
*/
|
||||
final class HostReceiptSource implements ReceiptSource
|
||||
{
|
||||
/** @var Settings */
|
||||
private $settings;
|
||||
|
||||
/** @var HostVatRates */
|
||||
private $vatRates;
|
||||
|
||||
/** @var Logger */
|
||||
private $logger;
|
||||
|
||||
/** @var array<int, Receipt> */
|
||||
private $receipts = [];
|
||||
|
||||
public function __construct(Settings $settings, Database $database, Logger $logger)
|
||||
{
|
||||
$this->settings = $settings;
|
||||
$this->vatRates = new HostVatRates($database);
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
public function attributes(Order $order): array
|
||||
{
|
||||
$receipt = $this->receipt($order);
|
||||
|
||||
return ($receipt !== null)
|
||||
? $receipt->toAttributes($this->settings->isFiscalization(), $this->settings->getTaxSystem())
|
||||
: [];
|
||||
}
|
||||
|
||||
public function jsonReceipt(Order $order): ?array
|
||||
{
|
||||
$receipt = $this->receipt($order);
|
||||
|
||||
return ($receipt !== null) ? $receipt->toJsonReceipt() : null;
|
||||
}
|
||||
|
||||
private function receipt(Order $order): ?Receipt
|
||||
{
|
||||
if (isset($this->receipts[$order->getId()])) {
|
||||
return $this->receipts[$order->getId()];
|
||||
}
|
||||
|
||||
try {
|
||||
$builder = new ReceiptBuilder(
|
||||
Closure::fromCallable([HostCharset::class, 'toUtf8']),
|
||||
$this->settings->getPaymentMethod(),
|
||||
$this->settings->getPaymentObject(),
|
||||
$this->settings->getGoodsVat()->getFixed(),
|
||||
);
|
||||
$receiptEmail = $this->settings->isFiscalization() ? $this->settings->getReceiptEmail() : '';
|
||||
$receipt = $builder->build($order, $this->goodsVat($order), $this->deliveryVat($order), $receiptEmail);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->logger->error(
|
||||
sprintf(
|
||||
'×åê çàêàçà %s íå ñîáðàí: %s: %s',
|
||||
$order->getUid(),
|
||||
get_class($exception),
|
||||
$exception->getMessage(),
|
||||
),
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->receipts[$order->getId()] = $receipt;
|
||||
}
|
||||
|
||||
private function goodsVat(Order $order): Vat
|
||||
{
|
||||
$fixed = $this->settings->getGoodsVat()->getFixed();
|
||||
if ($fixed !== null) {
|
||||
return $fixed;
|
||||
}
|
||||
|
||||
if (!$this->vatRates->isEnabled()) {
|
||||
return Vat::none();
|
||||
}
|
||||
|
||||
return $this->platformVat($order, $this->vatRates->systemPercent(), 'ñèñòåìíàÿ ñòàâêà PHPShop (òîâàðû)');
|
||||
}
|
||||
|
||||
private function deliveryVat(Order $order): Vat
|
||||
{
|
||||
$fixed = $this->settings->getDeliveryVat()->getFixed();
|
||||
if ($fixed !== null) {
|
||||
return $fixed;
|
||||
}
|
||||
|
||||
if (!$this->vatRates->isEnabled()) {
|
||||
return Vat::none();
|
||||
}
|
||||
|
||||
$percent = $this->vatRates->deliveryPercent($order->getDeliveryMethodId());
|
||||
if ($percent !== '') {
|
||||
return $this->platformVat($order, $percent, 'ñòàâêà ñïîñîáà äîñòàâêè');
|
||||
}
|
||||
|
||||
return $this->platformVat($order, $this->vatRates->systemPercent(), 'ñèñòåìíàÿ ñòàâêà PHPShop (äîñòàâêà)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ñòàâêà èç PHPShop; íå èç ïåðå÷íÿ êàññû — «áåç ÍÄÑ» è ïðåäóïðåæäåíèå, åñëè ÷åê ïðîáèâàåòñÿ.
|
||||
*/
|
||||
private function platformVat(Order $order, string $percent, string $source): Vat
|
||||
{
|
||||
$vat = Vat::tryFromPercent($percent);
|
||||
if ($vat !== null) {
|
||||
return $vat;
|
||||
}
|
||||
|
||||
if ($this->settings->isFiscalization()) {
|
||||
$this->logger->error(
|
||||
sprintf(
|
||||
'Çàêàç %s: %s %s íå èç ïåðå÷íÿ êàññû — ïîçèöèÿ óõîäèò â ÷åê «áåç ÍÄÑ».'
|
||||
. ' Èñïðàâüòå ñòàâêó â PHPShop èëè çàäàéòå ñòàâêó â íàñòðîéêàõ ìîäóëÿ.',
|
||||
$order->getUid(),
|
||||
$source,
|
||||
//  æóðíàë — òîëüêî òî, ÷òî ïîõîæå íà ÷èñëî: çíà÷åíèå ââîäèò àäìèíèñòðàòîð.
|
||||
VatRateWarnings::shown((string) preg_replace('/[^0-9.,%+\- ]/', '?', substr($percent, 0, 16))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Vat::none();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\host;
|
||||
|
||||
/**
|
||||
* Ñòàâêè ÍÄÑ èç íàñòðîåê PHPShop: âêëþ÷¸í ëè ÍÄÑ ìàãàçèíà (`nds_enabled`), ñèñòåìíàÿ ñòàâêà
|
||||
* (`nds`) è «Çíà÷åíèå ÍÄÑ» ñïîñîáîâ äîñòàâêè (`phpshop_delivery.ofd_nds`). Ñòàâêà ó òîâàðà
|
||||
* â ÿäðå PHPShop íå õðàíèòñÿ.
|
||||
*/
|
||||
final class HostVatRates
|
||||
{
|
||||
/** @var Database */
|
||||
private $database;
|
||||
|
||||
public function __construct(Database $database)
|
||||
{
|
||||
$this->database = $database;
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return !empty(self::systemParam('nds_enabled'));
|
||||
}
|
||||
|
||||
public function systemPercent(): string
|
||||
{
|
||||
return self::systemParam('nds');
|
||||
}
|
||||
|
||||
/**
|
||||
* «Çíà÷åíèå ÍÄÑ» ñïîñîáà äîñòàâêè êàê ââ¸ë ìåð÷àíò; ïóñòî — ó äîñòàâêè ñâîåé ñòàâêè íåò.
|
||||
*/
|
||||
public function deliveryPercent(int $deliveryId): string
|
||||
{
|
||||
$table = self::deliveryTable();
|
||||
if ($deliveryId <= 0 || $table === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return trim((string) $this->database->fetchValue(
|
||||
'SELECT ofd_nds FROM `' . $table . '` WHERE id = ? LIMIT 1',
|
||||
[$deliveryId],
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Âêëþ÷¸ííûå ñïîñîáû äîñòàâêè (áåç ïàïîê): [íàçâàíèå â cp1251, «Çíà÷åíèå ÍÄÑ»].
|
||||
*
|
||||
* @return array<int, array{0: string, 1: string}>
|
||||
*/
|
||||
public function enabledDeliveries(): array
|
||||
{
|
||||
$table = self::deliveryTable();
|
||||
if ($table === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$deliveries = [];
|
||||
$rows = $this->database->fetchAll(
|
||||
'SELECT city, ofd_nds FROM `' . $table . "` WHERE enabled = '1' AND (is_folder IS NULL OR is_folder <> '1')"
|
||||
. ' ORDER BY num, id',
|
||||
);
|
||||
foreach ($rows as $row) {
|
||||
$deliveries[] = [
|
||||
HostCharset::fromDatabase((string) ($row['city'] ?? '')),
|
||||
trim((string) ($row['ofd_nds'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
return $deliveries;
|
||||
}
|
||||
|
||||
private static function deliveryTable(): ?string
|
||||
{
|
||||
$table = $GLOBALS['SysValue']['base']['delivery'] ?? null;
|
||||
|
||||
// Èìÿ òàáëèöû èä¸ò â SQL: ÿâíûé êëàññ, à íå \w — ñì. OperationRepository::table().
|
||||
return (is_string($table) && preg_match('/\A[0-9A-Za-z_]+\z/', $table) === 1) ? $table : null;
|
||||
}
|
||||
|
||||
private static function systemParam(string $name): string
|
||||
{
|
||||
$system = $GLOBALS['PHPShopSystem'] ?? null;
|
||||
if (!is_object($system) || !method_exists($system, 'getParam')) {
|
||||
return '';
|
||||
}
|
||||
$value = $system->getParam($name);
|
||||
|
||||
return is_scalar($value) ? (string) $value : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\host;
|
||||
|
||||
use MonetaPhpShop\callback\LogThrottle;
|
||||
use MonetaPhpShop\ModuleInfo;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* {@see LogThrottle} â îòäåëüíîé òàáëèöå `[base] moneta_log_throttle` (îäíà ñòðîêà): `logged_at` —
|
||||
* âðåìÿ ïîñëåäíåé çàïèñàííîé ñòðîêè, `skipped` — ñêîëüêî ïðîïóùåíî ïîñëå íå¸. Íå â òàáëèöå
|
||||
* íàñòðîåê: çàïèñü â MyISAM áëîêèðóåò òàáëèöó öåëèêîì, à íàñòðîéêè ÷èòàåò êàæäûé êîëáýê è êàæäàÿ
|
||||
* ñòðàíèöà ñ ôîðìîé îïëàòû — ïîòîê ìóñîðíûõ çàïðîñîâ çàäåðæèâàë áû íàñòîÿùèå óâåäîìëåíèÿ.
|
||||
*
|
||||
* Çàõâàò ìåòêè — îäèí àòîìàðíûé çàïðîñ, ïîýòîìó èç îäíîâðåìåííûõ çàïðîñîâ ñòðîêó ïèøåò îäèí;
|
||||
* ñ÷¸ò÷èê ïðîïóñêîâ ïðè ãîíêå ìîæåò ñáèòüñÿ íà åäèíèöó. Ñòðîêè íåò
|
||||
* (óäàëèëè ðóêàìè, ìèãðàöèÿ íå âñòàâèëà) — òîò æå çàïðîñ å¸ ñîçäà¸ò: èíà÷å îáà UPDATE
|
||||
* çàòðàãèâàëè áû 0 ñòðîê è îòêàçû ìîë÷à ïðîïàäàëè áû èç æóðíàëà.
|
||||
*/
|
||||
final class MysqlLogThrottle implements LogThrottle
|
||||
{
|
||||
/** Åäèíñòâåííàÿ ñòðîêà ñ÷¸ò÷èêà (`install/module.sql`). */
|
||||
private const ROW_ID = 1;
|
||||
|
||||
/** @var Database */
|
||||
private $database;
|
||||
|
||||
public function __construct(Database $database)
|
||||
{
|
||||
$this->database = $database;
|
||||
}
|
||||
|
||||
public function pass(int $now, int $interval): ?int
|
||||
{
|
||||
$table = self::table();
|
||||
|
||||
// Çàòðîíóòûå ñòðîêè: 1 — ñòðîêè íå áûëî è îíà âñòàâëåíà, 2 — ìåòêà îáíîâëåíà,
|
||||
// 0 — îêíî åù¸ îòêðûòî è ñòðîêà íå èçìåíèëàñü. Âðåìÿ â áóäóùåì (÷àñû ñåðâåðà ïåðåâåëè
|
||||
// íàçàä) — íå æäàòü, ïîêà äîãîíÿò.
|
||||
$claimed = $this->database->execute(
|
||||
'INSERT INTO `' . $table . '` (`id`, `logged_at`, `skipped`) VALUES (' . self::ROW_ID . ', ?, 0)'
|
||||
. ' ON DUPLICATE KEY UPDATE `logged_at` = IF(`logged_at` <= ? OR `logged_at` > ?, ?, `logged_at`)',
|
||||
[$now, $now - $interval, $now, $now],
|
||||
);
|
||||
if ($claimed === 0) {
|
||||
$this->database->execute('UPDATE `' . $table . '` SET `skipped` = `skipped` + 1');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$skipped = (int) $this->database->fetchValue('SELECT `skipped` FROM `' . $table . '` LIMIT 1');
|
||||
if ($skipped > 0) {
|
||||
$this->database->execute(
|
||||
'UPDATE `' . $table . '` SET `skipped` = GREATEST(`skipped` - ?, 0)',
|
||||
[$skipped],
|
||||
);
|
||||
}
|
||||
|
||||
return $skipped;
|
||||
}
|
||||
|
||||
public static function table(): string
|
||||
{
|
||||
$table = $GLOBALS['SysValue']['base'][ModuleInfo::PATH]['moneta_log_throttle'] ?? null;
|
||||
// ßâíûé êëàññ, à íå \w: â cp1251-ëîêàëè PHPShop \w ïðîïóñòèë áû êèðèëëèöó (êàê â OperationRepository).
|
||||
if (!is_string($table) || preg_match('/\A[0-9A-Za-z_]+\z/', $table) !== 1) {
|
||||
throw new RuntimeException('Íåò òàáëèöû ñ÷¸ò÷èêà æóðíàëà â êîíôèãóðàöèè ìîäóëÿ.');
|
||||
}
|
||||
|
||||
return $table;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\host;
|
||||
|
||||
use MonetaPhpShop\callback\OrderLock;
|
||||
|
||||
/**
|
||||
* Áëîêèðîâêà çàêàçà ÷åðåç `GET_LOCK` MySQL: òàáëèöû PHPShop — MyISAM, òðàíçàêöèé íåò.
|
||||
* Èìÿ áëîêèðîâêè îáùåå äëÿ ñåðâåðà MySQL, ïîýòîìó â íåãî âõîäèò èìÿ áàçû: äâà ìàãàçèíà
|
||||
* íà îäíîì ñåðâåðå äðóã äðóãà íå æäóò. Áëîêèðîâêà ñíèìàåòñÿ è ïðè îáðûâå ñîåäèíåíèÿ.
|
||||
*/
|
||||
final class MysqlOrderLock implements OrderLock
|
||||
{
|
||||
/** Ñêîëüêî æäàòü ïàðàëëåëüíîå óâåäîìëåíèå ïî òîìó æå çàêàçó, ñåêóíä. */
|
||||
private const TIMEOUT = 10;
|
||||
|
||||
private const NAME = "CONCAT('moneta_', MD5(CONCAT(DATABASE(), ':', ?)))";
|
||||
|
||||
/** @var Database */
|
||||
private $database;
|
||||
|
||||
public function __construct(Database $database)
|
||||
{
|
||||
$this->database = $database;
|
||||
}
|
||||
|
||||
public function acquire(int $orderId): bool
|
||||
{
|
||||
return (int) $this->database->fetchValue('SELECT GET_LOCK(' . self::NAME . ', ?)', [$orderId, self::TIMEOUT]) === 1;
|
||||
}
|
||||
|
||||
public function release(int $orderId): void
|
||||
{
|
||||
$this->database->fetchValue('SELECT RELEASE_LOCK(' . self::NAME . ')', [$orderId]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\host;
|
||||
|
||||
use MonetaPhpShop\callback\OperationJournal;
|
||||
use MonetaPhpShop\ModuleInfo;
|
||||
use MonetaPhpShop\order\Order;
|
||||
use MonetaPhpShop\protocol\CallbackNotification;
|
||||
use MonetaPhpShop\protocol\ResultCode;
|
||||
use MonetaPhpShop\protocol\TransactionId;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Æóðíàë îïåðàöèé — òàáëèöà `[base] moneta_operations` èç `inc/config.ini` ìîäóëÿ.
|
||||
*
|
||||
* Ñòðîêà íà êàæäîå ïîäïèñàííîå óâåäîìëåíèå; ó ñòðîêè çà÷ò¸ííîé îïëàòû çàïîëíåí
|
||||
* `paid_order_id` ñ UNIQUE-êëþ÷îì — âòîðîé çà÷¸ò òîãî æå çàêàçà íåâîçìîæåí äàæå áåç áëîêèðîâêè.
|
||||
* Ïîäïèñü è êîä ïðîâåðêè öåëîñòíîñòè íå õðàíÿòñÿ.
|
||||
*/
|
||||
final class OperationRepository implements OperationJournal
|
||||
{
|
||||
private const COMMAND_PAY = 'PAY';
|
||||
|
||||
/** @var Database */
|
||||
private $database;
|
||||
|
||||
public function __construct(Database $database)
|
||||
{
|
||||
$this->database = $database;
|
||||
}
|
||||
|
||||
public function findPaidOperation(int $orderId): ?string
|
||||
{
|
||||
$value = $this->database->fetchValue(
|
||||
'SELECT operation_id FROM `' . self::table() . '` WHERE paid_order_id = ? LIMIT 1',
|
||||
[$orderId],
|
||||
);
|
||||
|
||||
return $value === null ? null : (string) $value;
|
||||
}
|
||||
|
||||
public function recordPaid(CallbackNotification $notification, Order $order, string $event): bool
|
||||
{
|
||||
try {
|
||||
$this->insert($notification, $order, $event, ResultCode::paid(), $order->getId());
|
||||
} catch (RuntimeException $exception) {
|
||||
if ($exception->getCode() === Database::ER_DUP_ENTRY) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function note(
|
||||
CallbackNotification $notification,
|
||||
?Order $order,
|
||||
string $event,
|
||||
?ResultCode $resultCode
|
||||
): void {
|
||||
$this->insert($notification, $order, $event, $resultCode, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ïîñëåäíèå çàïèñè æóðíàëà äëÿ àäìèíêè, íîâûå ñâåðõó.
|
||||
*
|
||||
* @param string|null $orderUid òîëüêî çàïèñè ýòîãî çàêàçà ({@see TransactionId::orderUidFromInput()}):
|
||||
* ñ ýòèì `order_uid` èëè — åñëè çàêàç íå ðàñïîçíàí — ñ `MNT_TRANSACTION_ID` âèäà `<íîìåð>_…`
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function latest(int $limit, ?string $orderUid = null): array
|
||||
{
|
||||
$where = '';
|
||||
$params = [];
|
||||
if ($orderUid !== null) {
|
||||
// Â íîìåðå çàêàçà íåò `_` è `%` (TransactionId::ORDER_UID) — ýêðàíèðîâàòü â LIKE
|
||||
// íóæíî òîëüêî ñàì ðàçäåëèòåëü `_`.
|
||||
$where = ' WHERE order_uid = ? OR transaction_id LIKE ?';
|
||||
$params = [$orderUid, $orderUid . '\\' . TransactionId::SEPARATOR . '%'];
|
||||
}
|
||||
$params[] = $limit;
|
||||
|
||||
return $this->database->fetchAll(
|
||||
'SELECT id, created_at, command, transaction_id, operation_id, order_id, order_uid, amount,'
|
||||
. ' test_mode, result_code, event FROM `' . self::table() . '`' . $where . ' ORDER BY id DESC LIMIT ?',
|
||||
$params,
|
||||
);
|
||||
}
|
||||
|
||||
public static function table(): string
|
||||
{
|
||||
$table = $GLOBALS['SysValue']['base'][ModuleInfo::PATH]['moneta_operations'] ?? null;
|
||||
// ßâíûé êëàññ, à íå \w: áåç /u \w ñëåäóåò ëîêàëè, à PHPShop ñòàâèò ñâîþ (setlocale â
|
||||
// class/base.class.php) — â cp1251 \w ïðîïóñòèë áû êèðèëëèöó â èìÿ òàáëèöû, êîòîðîå èä¸ò â SQL.
|
||||
if (!is_string($table) || preg_match('/\A[0-9A-Za-z_]+\z/', $table) !== 1) {
|
||||
throw new RuntimeException('Ìîäóëü íå óñòàíîâëåí: íåò òàáëèöû îïåðàöèé â êîíôèãóðàöèè.');
|
||||
}
|
||||
|
||||
return $table;
|
||||
}
|
||||
|
||||
private function insert(
|
||||
CallbackNotification $notification,
|
||||
?Order $order,
|
||||
string $event,
|
||||
?ResultCode $resultCode,
|
||||
?int $paidOrderId
|
||||
): void {
|
||||
$amount = $notification->getAmount();
|
||||
$this->database->execute(
|
||||
'INSERT INTO `' . self::table() . '` (created_at, command, transaction_id, operation_id, order_id,'
|
||||
. ' order_uid, amount, test_mode, result_code, event, paid_order_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
time(),
|
||||
$notification->isCheck() ? $notification->getCommand() : self::COMMAND_PAY,
|
||||
$notification->getTransactionId(),
|
||||
$notification->getOperationId(),
|
||||
($order !== null) ? $order->getId() : 0,
|
||||
($order !== null) ? $order->getUid() : '',
|
||||
($amount !== null) ? $amount->toDecimal() : '',
|
||||
$notification->getRawTestMode(),
|
||||
($resultCode !== null) ? $resultCode->getValue() : null,
|
||||
$event,
|
||||
$paidOrderId,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\host;
|
||||
|
||||
use MonetaPhpShop\callback\OrderGateway;
|
||||
use MonetaPhpShop\Money;
|
||||
use MonetaPhpShop\order\Order;
|
||||
use MonetaPhpShop\protocol\TransactionId;
|
||||
use MonetaPhpShop\Settings;
|
||||
use PHPShopOrderFunction;
|
||||
use PHPShopOrm;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Çàêàçû PHPShop (`phpshop_orders`).
|
||||
*
|
||||
* `PHPShopOrm` ñîáèðàåò SQL êîíêàòåíàöèåé, ïîýòîìó â óñëîâèÿ ïîïàäàåò òîëüêî òî, ÷òî óæå
|
||||
* ïðîâåðåíî: öåëîå ÷èñëî èëè íîìåð çàêàçà ïî øàáëîíó {@see TransactionId::ORDER_UID}
|
||||
* (ëàòèíèöà, öèôðû, äåôèñ — áåç êàâû÷åê).
|
||||
*/
|
||||
final class OrderRepository implements OrderGateway
|
||||
{
|
||||
/** Èìÿ ñïîñîáà îïëàòû â æóðíàëå îïëàò ÿäðà (`phpshop_payment.name`). */
|
||||
private const PAYMENT_LOG_NAME = 'Moneta';
|
||||
|
||||
/** Íàçâàíèå ñòàòóñà 101, åñëè åãî åù¸ íåò, — êàê ó ÿäðà (`PHPShopPaymentResult::set_order_status_101()`). */
|
||||
private const STATUS_PAID_BY_GATEWAY_NAME = 'Îïëà÷åíî ïëàòåæíûìè ñèñòåìàìè';
|
||||
|
||||
public function findById(int $id): ?Order
|
||||
{
|
||||
if ($id <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->findOne(['id' => '=' . $id]);
|
||||
}
|
||||
|
||||
public function findByUid(string $uid): ?Order
|
||||
{
|
||||
if (preg_match('/\A' . TransactionId::ORDER_UID . '\z/', $uid) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->findOne(['uid' => "='" . $uid . "'"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Êàê øòàòíûå ìîäóëè ÿäðà: ñòðîêà â æóðíàëå îïëàò, `changeStatus()` (ïèñüìî ïîêóïàòåëþ,
|
||||
* ñêëàä, ìåññåíäæåðû — ïî íàñòðîéêàì ñòàòóñà) è ôëàã `paid`. `ofd()` ÿäðà íå âûçûâàåòñÿ:
|
||||
* ÷åê ïðîáèâàåò êàññà MONETA.
|
||||
*/
|
||||
public function markPaid(Order $order, int $paidStatus, Money $amount): void
|
||||
{
|
||||
$database = Database::fromHost();
|
||||
if ($paidStatus === Settings::STATUS_PAID_BY_GATEWAY) {
|
||||
$this->ensurePaidByGatewayStatus($database);
|
||||
}
|
||||
$this->logPayment($database, $order, $amount);
|
||||
|
||||
$orderFunction = new PHPShopOrderFunction($order->getId());
|
||||
$orderFunction->changeStatus($paidStatus, $order->getStatusId());
|
||||
$orderFunction->changePaymentStatus(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* `phpshop_payment`: êëþ÷ — íîìåð çàêàçà áåç äåôèñà ÷èñëîì (`4-36` -> 436), ïî íåìó ÿäðî
|
||||
* ïîêàçûâàåò äàòó îïëàòû (`PHPShopOrderFunction::checkPay()`). Íîìåð íå øòàòíîãî âèäà
|
||||
* (ïåðåîïðåäåë¸í õóêîì `setNum`) â ýòó òàáëèöó íå çàïèñûâàåòñÿ — çàïèñü ïðîïóñêàåòñÿ.
|
||||
*/
|
||||
private function logPayment(Database $database, Order $order, Money $amount): void
|
||||
{
|
||||
if (preg_match('/\A([0-9]+)-([0-9]+)\z/', $order->getUid(), $matches) !== 1) {
|
||||
return;
|
||||
}
|
||||
$database->execute(
|
||||
'INSERT IGNORE INTO `' . self::table('payment') . '` (uid, name, sum, datas) VALUES (?, ?, ?, ?)',
|
||||
[(int) ($matches[1] . $matches[2]), self::PAYMENT_LOG_NAME, $amount->toDecimal(), time()],
|
||||
);
|
||||
}
|
||||
|
||||
private function ensurePaidByGatewayStatus(Database $database): void
|
||||
{
|
||||
$database->execute(
|
||||
'INSERT IGNORE INTO `' . self::table('order_status') . "` (id, name, color, external_code, bot_message)"
|
||||
. " VALUES (?, ?, '#ccff00', '', '')",
|
||||
[Settings::STATUS_PAID_BY_GATEWAY, HostCharset::toDatabase(self::STATUS_PAID_BY_GATEWAY_NAME)],
|
||||
);
|
||||
}
|
||||
|
||||
private static function table(string $key): string
|
||||
{
|
||||
$table = $GLOBALS['SysValue']['base'][$key] ?? null;
|
||||
// ßâíûé êëàññ, à íå \w: áåç /u \w ñëåäóåò ëîêàëè, à PHPShop ñòàâèò ñâîþ (setlocale â
|
||||
// class/base.class.php) — â cp1251 \w ïðîïóñòèë áû êèðèëëèöó â èìÿ òàáëèöû, êîòîðîå èä¸ò â SQL.
|
||||
if (!is_string($table) || preg_match('/\A[0-9A-Za-z_]+\z/', $table) !== 1) {
|
||||
throw new RuntimeException("Íåò òàáëèöû ÿäðà {$key} â êîíôèãóðàöèè.");
|
||||
}
|
||||
|
||||
return $table;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $where
|
||||
*/
|
||||
private function findOne(array $where): ?Order
|
||||
{
|
||||
$orm = new PHPShopOrm($GLOBALS['SysValue']['base']['orders']);
|
||||
$orm->debug = false;
|
||||
$row = $orm->select(['*'], $where, false, ['limit' => 1]);
|
||||
if (!is_array($row) || empty($row['id'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$details = @unserialize((string) ($row['orders'] ?? ''), ['allowed_classes' => false]);
|
||||
if (!is_array($details)) {
|
||||
$details = [];
|
||||
}
|
||||
foreach (['fio', 'tel'] as $field) {
|
||||
if (isset($row[$field]) && is_string($row[$field])) {
|
||||
$row[$field] = HostCharset::fromDatabase($row[$field]);
|
||||
}
|
||||
}
|
||||
// Íàçâàíèÿ òîâàðîâ â êîðçèíå — â êîäèðîâêå ÁÄ, êàê è îñòàëüíûå ñòðîêè çàêàçà.
|
||||
if (isset($details['Cart']['cart']) && is_array($details['Cart']['cart'])) {
|
||||
foreach ($details['Cart']['cart'] as $key => $product) {
|
||||
if (is_array($product) && isset($product['name']) && is_string($product['name'])) {
|
||||
$details['Cart']['cart'][$key]['name'] = HostCharset::fromDatabase($product['name']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Order::fromRow($row, $details);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\host;
|
||||
|
||||
use MonetaPhpShop\ModuleInfo;
|
||||
use MonetaPhpShop\Settings;
|
||||
use PHPShopOrm;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Íàñòðîéêè ìîäóëÿ èç òàáëèöû `[base] moneta_system` åãî `inc/config.ini`.
|
||||
*/
|
||||
final class SettingsRepository
|
||||
{
|
||||
public function load(): Settings
|
||||
{
|
||||
$orm = new PHPShopOrm(self::table());
|
||||
$orm->debug = false;
|
||||
$row = $orm->select(['*'], false, false, ['limit' => 1]);
|
||||
if (!is_array($row)) {
|
||||
throw new RuntimeException('Íåò ñòðîêè íàñòðîåê ìîäóëÿ.');
|
||||
}
|
||||
|
||||
return Settings::fromRow($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ñòðîêà íàñòðîåê åù¸ îò âñòðîåííîãî ìîäóëÿ PHPShop: ôàéëû çàìåíåíû, à «Îáíîâèòü ìîäóëü»
|
||||
* (ìèãðàöèÿ `updates/1.2/`) íå íàæàòà — íîâûõ êîëîíîê â òàáëèöå íåò.
|
||||
*
|
||||
* @param array<string, mixed> $row ñòðîêà òàáëèöû íàñòðîåê
|
||||
*/
|
||||
public static function needsUpgrade(array $row): bool
|
||||
{
|
||||
return !array_key_exists('account_id', $row);
|
||||
}
|
||||
|
||||
public static function table(): string
|
||||
{
|
||||
$table = $GLOBALS['SysValue']['base'][ModuleInfo::PATH]['moneta_system'] ?? null;
|
||||
if (!is_string($table) || ($table === '')) {
|
||||
throw new RuntimeException('Ìîäóëü íå óñòàíîâëåí: íåò òàáëèöû íàñòðîåê â êîíôèãóðàöèè.');
|
||||
}
|
||||
|
||||
return $table;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\host;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use MonetaPhpShop\CmsInfo;
|
||||
use MonetaPhpShop\form\ReturnAccess;
|
||||
use MonetaPhpShop\ModuleInfo;
|
||||
|
||||
/**
|
||||
* Îêðóæåíèå ìàãàçèíà: àäðåñà, âàëþòà, ÿçûê, âåðñèè. API äëÿ ýòîãî ó PHPShop íåò — äàííûå ëåæàò
|
||||
* â ãëîáàëüíûõ ïåðåìåííûõ, êîòîðûå ÿäðî çàïîëíÿåò ïðè çàïóñêå, è øòàòíûå ìîäóëè ÷èòàþò èõ òàê æå:
|
||||
* - `$GLOBALS['SysValue']` — ðàçîáðàííûé `phpshop/inc/config.ini` (êàòàëîã óñòàíîâêè, âåðñèÿ ÿäðà);
|
||||
* - `$GLOBALS['PHPShopSystem']` — íàñòðîéêè âèòðèíû (âàëþòà, çíàêè ïîñëå òî÷êè, çíàê âàëþòû);
|
||||
* - `$_SERVER` — õîñò è HTTPS òåêóùåãî çàïðîñà, `$_SESSION` — ÿçûê âèòðèíû è çàêàçû ïîêóïàòåëÿ.
|
||||
* Âñå îáðàùåíèÿ ê íèì ñîáðàíû çäåñü, ÷òîáû îñòàëüíûå êëàññû ìîäóëÿ ïîëó÷àëè çíà÷åíèÿ
|
||||
* ïàðàìåòðàìè è ïðîâåðÿëèñü òåñòàìè áåç ÿäðà. Åñëè îáúåêòà ÿäðà íåò, ìåòîäû âîçâðàùàþò
|
||||
* ïóñòîå çíà÷åíèå, à íå ïàäàþò.
|
||||
*/
|
||||
final class ShopContext
|
||||
{
|
||||
/** Ïóòü ñòðàíèöû âîçâðàòà ïîêóïàòåëÿ (`[core] moneta` â `inc/config.ini`). */
|
||||
private const RETURN_PAGE = '/moneta/';
|
||||
|
||||
/** Åäèíàÿ òî÷êà Check URL è Pay URL. */
|
||||
private const CALLBACK_PATH = '/phpshop/modules/' . ModuleInfo::PATH . '/payment/callback.php';
|
||||
|
||||
/**
|
||||
* Àäðåñ ìàãàçèíà: ñõåìà + õîñò (ÿäðî ñòðîèò ññûëêè èç `SERVER_NAME`) + êàòàëîã óñòàíîâêè.
|
||||
*/
|
||||
public function baseUrl(): string
|
||||
{
|
||||
$host = (string) ($_SERVER['SERVER_NAME'] ?? '');
|
||||
|
||||
return ($this->isHttps() ? 'https://' : 'http://') . $host . $this->basePath();
|
||||
}
|
||||
|
||||
public function returnPageUrl(): string
|
||||
{
|
||||
return $this->baseUrl() . self::RETURN_PAGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ïóòü ñòðàíèöû âîçâðàòà îò êîðíÿ ñàéòà, áåç ñõåìû è õîñòà — äëÿ `Location` õóêîâ `/success`
|
||||
* è `/fail`. `SERVER_NAME` íà Apache ïî óìîë÷àíèþ áåð¸òñÿ èç çàãîëîâêà `Host` çàïðîñà, è
|
||||
* êýøèðóþùèé ïðîêñè ïåðåä ìàãàçèíîì ìîã áû ðàçäàòü ðåäèðåêò íà ÷óæîé õîñò.
|
||||
*/
|
||||
public function returnPagePath(): string
|
||||
{
|
||||
return $this->basePath() . self::RETURN_PAGE;
|
||||
}
|
||||
|
||||
public function callbackUrl(): string
|
||||
{
|
||||
return $this->baseUrl() . self::CALLBACK_PATH;
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO-êîä âàëþòû çàêàçà ñ ó÷¸òîì âèòðèíû (ìóëüòèñàéò).
|
||||
*/
|
||||
public function orderCurrency(): string
|
||||
{
|
||||
$system = $GLOBALS['PHPShopSystem'] ?? null;
|
||||
if (!is_object($system) || !method_exists($system, 'getDefaultValutaIso')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return (string) $system->getDefaultValutaIso(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* ßçûê âèòðèíû òåêóùåãî ïîêóïàòåëÿ: ÿäðî êëàä¸ò åãî â ñåññèþ (`PHPShopElements`).
|
||||
*/
|
||||
public function shopLocale(): string
|
||||
{
|
||||
$locale = $_SESSION['lang'] ?? '';
|
||||
|
||||
return is_string($locale) ? $locale : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Çàïîìíèòü â ñåññèè ïîêóïàòåëÿ çàêàç, ôîðìó îïëàòû êîòîðîãî åìó ïîêàçàëè: ïî íåìó ñòðàíèöà
|
||||
* âîçâðàòà ïîêàæåò ñòàòóñ è ãîñòþ, îôîðìèâøåìó çàêàç áåç âõîäà ({@see ReturnAccess}).
|
||||
*/
|
||||
public function rememberPayableOrder(int $orderId): void
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
return;
|
||||
}
|
||||
$_SESSION[ReturnAccess::SESSION_KEY] = ReturnAccess::remember(
|
||||
$_SESSION[ReturnAccess::SESSION_KEY] ?? null,
|
||||
$orderId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ID âîøåäøåãî ïîêóïàòåëÿ (`$_SESSION['UsersId']`); 0 — íå âîø¸ë.
|
||||
*/
|
||||
public function sessionUserId(): int
|
||||
{
|
||||
return (int) ($_SESSION['UsersId'] ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Çíàêîâ ïîñëå òî÷êè â öåíàõ âèòðèíû (`admoption.price_znak`).
|
||||
*/
|
||||
public function priceDecimals(): int
|
||||
{
|
||||
$system = $GLOBALS['PHPShopSystem'] ?? null;
|
||||
if (!is_object($system) || !method_exists($system, 'getSerilizeParam')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) $system->getSerilizeParam('admoption.price_znak');
|
||||
}
|
||||
|
||||
/**
|
||||
* Çíàê âàëþòû çàêàçà òàê, êàê åãî âûâîäèò âèòðèíà: äëÿ ðóáëÿ ÿäðî îòäà¸ò
|
||||
* `<span class=rubznak>p</span>` — øðèôò òåìû ðèñóåò «p» çíàêîì ðóáëÿ; äëÿ äðóãîé âàëþòû —
|
||||
* îáîçíà÷åíèå èç ñïðàâî÷íèêà. HTML ÿäðà, â cp1251.
|
||||
*/
|
||||
public function currencyIcon(): string
|
||||
{
|
||||
$system = $GLOBALS['PHPShopSystem'] ?? null;
|
||||
if (!is_object($system) || !method_exists($system, 'getValutaIcon')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return HostCharset::fromDatabase(trim((string) $system->getValutaIcon(true)));
|
||||
}
|
||||
|
||||
/**
|
||||
* `MNT_CMS`: âåðñèÿ PHPShop — èç `[upload] version` êîíôèãà ÿäðà (`670`).
|
||||
*/
|
||||
public function cms(): string
|
||||
{
|
||||
$uploadVersion = (string) ($GLOBALS['SysValue']['upload']['version'] ?? '');
|
||||
|
||||
return CmsInfo::format(CmsInfo::hostVersion($uploadVersion), PHP_VERSION, ModuleInfo::VERSION);
|
||||
}
|
||||
|
||||
/**
|
||||
* Òåêóùåå âðåìÿ. Ïîÿñ âûñòàâëÿåò ÿäðî èç íàñòðîåê ìàãàçèíà (`PHPShopSystem::setTimeZone()`),
|
||||
* à åñëè íàñòðîéêà ïóñòà èëè íåâåðíà — äåéñòâóåò ïîÿñ PHP ñåðâåðà; ìåòêå
|
||||
* `MNT_TRANSACTION_ID` ýòî íåâàæíî, îíà ïèøåòñÿ â UTC.
|
||||
*/
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('now');
|
||||
}
|
||||
|
||||
/**
|
||||
* Êàòàëîã óñòàíîâêè PHPShop áåç ñëýøà íà êîíöå (`[dir] dir` â `inc/config.ini`), ïóñòî — â êîðíå ñàéòà.
|
||||
*/
|
||||
private function basePath(): string
|
||||
{
|
||||
return rtrim((string) ($GLOBALS['SysValue']['dir']['dir'] ?? ''), '/');
|
||||
}
|
||||
|
||||
private function isHttps(): bool
|
||||
{
|
||||
$https = strtolower((string) ($_SERVER['HTTPS'] ?? ''));
|
||||
if (($https !== '') && ($https !== 'off')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\order;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use MonetaPhpShop\Money;
|
||||
|
||||
/**
|
||||
* Ñíèìîê çàêàçà PHPShop (`phpshop_orders`) — òî, ÷òî ìîäóëþ íóæíî äëÿ ôîðìû è êîëáýêîâ.
|
||||
* Ñòðîêè â êîäèðîâêå âèòðèíû (cp1251).
|
||||
*/
|
||||
final class Order
|
||||
{
|
||||
/**
|
||||
* Àíîíèìíîìó ïîêóïàòåëþ áåç email ÿäðî ïîäñòàâëÿåò `guest@<äîìåí>`
|
||||
* (`PHPShopDone::send_to_order()`) — ýòî íå àäðåñ ïîêóïàòåëÿ.
|
||||
*/
|
||||
private const GUEST_EMAIL_PREFIX = 'guest@';
|
||||
|
||||
/** @var int */
|
||||
private $id;
|
||||
|
||||
/** @var string */
|
||||
private $uid;
|
||||
|
||||
/** @var Money */
|
||||
private $amount;
|
||||
|
||||
/** @var int */
|
||||
private $statusId;
|
||||
|
||||
/** @var bool */
|
||||
private $paid;
|
||||
|
||||
/** @var int */
|
||||
private $paymentMethodId;
|
||||
|
||||
/** @var string */
|
||||
private $email;
|
||||
|
||||
/** @var string */
|
||||
private $phone;
|
||||
|
||||
/** @var string */
|
||||
private $customerName;
|
||||
|
||||
/** @var int */
|
||||
private $userId;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
private $cart;
|
||||
|
||||
/** @var float */
|
||||
private $discountPercent;
|
||||
|
||||
/** @var int */
|
||||
private $deliveryMethodId;
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row ñòðîêà `phpshop_orders`
|
||||
* @param array<string, mixed> $details ðàñïàêîâàííîå ïîëå `orders`: `['Cart' => ..., 'Person' => ...]`
|
||||
*/
|
||||
public static function fromRow(array $row, array $details): self
|
||||
{
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
$uid = (string) ($row['uid'] ?? '');
|
||||
if (($id <= 0) || ($uid === '')) {
|
||||
throw new InvalidArgumentException('Ñòðîêà çàêàçà áåç id èëè uid.');
|
||||
}
|
||||
|
||||
$person = isset($details['Person']) && is_array($details['Person']) ? $details['Person'] : [];
|
||||
$cart = isset($details['Cart']) && is_array($details['Cart']) ? $details['Cart'] : [];
|
||||
|
||||
$order = new self();
|
||||
$order->id = $id;
|
||||
$order->uid = $uid;
|
||||
$order->amount = Money::fromFloat((float) ($row['sum'] ?? 0));
|
||||
$order->statusId = (int) ($row['statusi'] ?? 0);
|
||||
$order->paid = (int) ($row['paid'] ?? 0) === 1;
|
||||
$order->paymentMethodId = (int) ($person['order_metod'] ?? 0);
|
||||
$order->email = self::email((string) ($person['mail'] ?? ''));
|
||||
$order->phone = trim((string) ($row['tel'] ?? ''));
|
||||
$order->customerName = trim((string) ($row['fio'] ?? ''));
|
||||
$order->userId = (int) ($row['user'] ?? 0);
|
||||
$order->cart = $cart;
|
||||
$order->discountPercent = (float) ($person['discount'] ?? 0);
|
||||
$order->deliveryMethodId = (int) ($person['dostavka_metod'] ?? 0);
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
public function getId(): int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Íîìåð çàêàçà, êîòîðûé âèäèò ïîêóïàòåëü (`4-36`).
|
||||
*/
|
||||
public function getUid(): string
|
||||
{
|
||||
return $this->uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Èòîã çàêàçà (`phpshop_orders.sum`): òîâàðû ñî ñêèäêàìè, áîíóñû è äîñòàâêà óæå ó÷òåíû ÿäðîì.
|
||||
*/
|
||||
public function getAmount(): Money
|
||||
{
|
||||
return $this->amount;
|
||||
}
|
||||
|
||||
public function getStatusId(): int
|
||||
{
|
||||
return $this->statusId;
|
||||
}
|
||||
|
||||
public function isPaidFlag(): bool
|
||||
{
|
||||
return $this->paid;
|
||||
}
|
||||
|
||||
public function getPaymentMethodId(): int
|
||||
{
|
||||
return $this->paymentMethodId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Email ïîêóïàòåëÿ èëè ïóñòàÿ ñòðîêà (ãîñòåâîé `guest@...` íå ñ÷èòàåòñÿ).
|
||||
*/
|
||||
public function getEmail(): string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function getPhone(): string
|
||||
{
|
||||
return $this->phone;
|
||||
}
|
||||
|
||||
public function getCustomerName(): string
|
||||
{
|
||||
return $this->customerName;
|
||||
}
|
||||
|
||||
/**
|
||||
* ID ïîêóïàòåëÿ PHPShop (`phpshop_shopusers.id`), 0 — íåò.
|
||||
*/
|
||||
public function getUserId(): int
|
||||
{
|
||||
return $this->userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Êîðçèíà çàêàçà êàê å¸ õðàíèò ÿäðî (`Cart`: `cart`, `dostavka`, `sum`...) — äëÿ ÷åêà.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getCart(): array
|
||||
{
|
||||
return $this->cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ñêèäêà ïîêóïàòåëÿ â ïðîöåíòàõ (`Person.discount`): ÿäðî ïðèìåíÿåò å¸ êî âñåì òîâàðàì,
|
||||
* êðîìå òîâàðîâ ñ ïðîìî-öåíîé.
|
||||
*/
|
||||
public function getDiscountPercent(): float
|
||||
{
|
||||
return $this->discountPercent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ñïîñîá äîñòàâêè (`phpshop_delivery.id`), 0 — íå âûáðàí.
|
||||
*/
|
||||
public function getDeliveryMethodId(): int
|
||||
{
|
||||
return $this->deliveryMethodId;
|
||||
}
|
||||
|
||||
private static function email(string $raw): string
|
||||
{
|
||||
$email = trim($raw);
|
||||
if (($email === '') || stripos($email, self::GUEST_EMAIL_PREFIX) === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return (filter_var($email, FILTER_VALIDATE_EMAIL) !== false) ? $email : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\order;
|
||||
|
||||
/**
|
||||
* Ñïèñîê ñòàòóñîâ çàêàçà äëÿ âûïàäàþùèõ ñïèñêîâ íàñòðîåê («Îïëàòà ïðè ñòàòóñå»,
|
||||
* «Ñòàòóñ ïîñëå îïëàòû») â ôîðìàòå `PHPShopGUI::setSelect()`: `[ïîäïèñü, çíà÷åíèå, òåêóùåå]`.
|
||||
* Ñòàòóñû ìàãàçèíà ÷èòàåò àäìèíêà ìîäóëÿ èç `PHPShopOrderStatusArray`; çäåñü — òîëüêî ñáîðêà.
|
||||
*/
|
||||
final class OrderStatusOptions
|
||||
{
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* Ïåðâûì èä¸ò çàäàííûé ïóíêò, çà íèì ñòàòóñû ìàãàçèíà. Ñòàòóñ ñ òåì æå çíà÷åíèåì, ÷òî
|
||||
* ó ïåðâîãî ïóíêòà, ïðîïóñêàåòñÿ: «Îïëà÷åíî ïëàòåæíûìè ñèñòåìàìè» (101) ÿäðî ñîçäà¸ò
|
||||
* êàê îáû÷íûé ñòàòóñ, è áåç ýòîãî îí ïîïàë áû â ñïèñîê äâàæäû.
|
||||
*
|
||||
* @param array<mixed> $statuses ñòàòóñû ìàãàçèíà: ñòðîêè ñ êëþ÷àìè `id` è `name`
|
||||
* @param int $current çíà÷åíèå èç íàñòðîåê — ïóíêò ñ íèì áóäåò âûáðàí
|
||||
*
|
||||
* @return array<int, array{0: string, 1: int, 2: int}>
|
||||
*/
|
||||
public static function build(array $statuses, int $current, string $firstLabel, int $firstValue): array
|
||||
{
|
||||
$options = [[$firstLabel, $firstValue, $current]];
|
||||
foreach ($statuses as $status) {
|
||||
if (!is_array($status) || !isset($status['id'], $status['name'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = (int) $status['id'];
|
||||
if ($id !== $firstValue) {
|
||||
$options[] = [(string) $status['name'], $id, $current];
|
||||
}
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\order;
|
||||
|
||||
use MonetaPhpShop\ModuleInfo;
|
||||
use MonetaPhpShop\protocol\AssistantProtocol;
|
||||
use MonetaPhpShop\Settings;
|
||||
|
||||
/**
|
||||
* Ìîæíî ëè ñåé÷àñ îïëàòèòü çàêàç ÷åðåç Ìîíåòó. Îäíî ïðàâèëî äëÿ êíîïêè îïëàòû
|
||||
* è äëÿ Check URL, ÷òîáû ôîðìà íå âûäàâàëàñü òàì, ãäå ïðîâåðî÷íûé çàïðîñ îòêàæåò.
|
||||
*
|
||||
* Çíà÷åíèÿ êîíñòàíò — êîäû ïðè÷èí äëÿ æóðíàëà ìîäóëÿ; íå ïåðåèìåíîâûâàòü.
|
||||
*/
|
||||
final class PaymentEligibility
|
||||
{
|
||||
public const PAYABLE = 'payable';
|
||||
public const NOT_CONFIGURED = 'not_configured';
|
||||
public const OTHER_PAYMENT_METHOD = 'other_payment_method';
|
||||
public const ALREADY_PAID = 'already_paid';
|
||||
public const CANCELLED = 'cancelled';
|
||||
public const AWAITING_STATUS = 'awaiting_status';
|
||||
public const UNSUPPORTED_CURRENCY = 'unsupported_currency';
|
||||
public const ZERO_AMOUNT = 'zero_amount';
|
||||
|
||||
/** Ñòàòóñ PHPShop «Àííóëèðîâàí» (`PHPShopOrderFunction::changeStatus()` âîçâðàùàåò òîâàð íà ñêëàä). */
|
||||
public const STATUS_CANCELLED = 1;
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* @param string $shopCurrency ISO-êîä âàëþòû çàêàçà èç PHPShop (`getDefaultValutaIso(true)`)
|
||||
*/
|
||||
public static function check(Settings $settings, Order $order, string $shopCurrency): string
|
||||
{
|
||||
if (!$settings->isConfigured()) {
|
||||
return self::NOT_CONFIGURED;
|
||||
}
|
||||
|
||||
if ($order->getPaymentMethodId() !== ModuleInfo::PAYMENT_METHOD_ID) {
|
||||
return self::OTHER_PAYMENT_METHOD;
|
||||
}
|
||||
|
||||
if (self::isPaid($settings, $order)) {
|
||||
return self::ALREADY_PAID;
|
||||
}
|
||||
|
||||
if ($order->getStatusId() === self::STATUS_CANCELLED) {
|
||||
return self::CANCELLED;
|
||||
}
|
||||
|
||||
if (!$settings->isPayableRightAfterCheckout() && $order->getStatusId() !== $settings->getPayableStatus()) {
|
||||
return self::AWAITING_STATUS;
|
||||
}
|
||||
|
||||
if (AssistantProtocol::normalizeCurrency($shopCurrency) === null) {
|
||||
return self::UNSUPPORTED_CURRENCY;
|
||||
}
|
||||
|
||||
if (!$order->getAmount()->isPositive()) {
|
||||
return self::ZERO_AMOUNT;
|
||||
}
|
||||
|
||||
return self::PAYABLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Îïëà÷åí: ôëàã `paid` ÿäðà èëè ñòàòóñ «ïîñëå îïëàòû» èç íàñòðîåê ìîäóëÿ.
|
||||
*/
|
||||
public static function isPaid(Settings $settings, Order $order): bool
|
||||
{
|
||||
return $order->isPaidFlag() || ($order->getStatusId() === $settings->getPaidStatus());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\protocol;
|
||||
|
||||
/**
|
||||
* Êîíñòàíòû ïðîòîêîëà MONETA.Assistant (`MONETA.Assistant.ru.pdf`).
|
||||
*/
|
||||
final class AssistantProtocol
|
||||
{
|
||||
/** `MNT_COMMAND` ïðîâåðî÷íîãî çàïðîñà íà Check URL; íà Pay URL ïàðàìåòðà íåò. */
|
||||
public const COMMAND_CHECK = 'CHECK';
|
||||
|
||||
/** Òåêñòîâûé îòêàç íà Check/Pay URL: ñåðâèñ ïîâòîðèò óâåäîìëåíèå. */
|
||||
public const TEXT_FAIL = 'FAIL';
|
||||
|
||||
/** Åäèíñòâåííàÿ âàëþòà ìîäóëÿ (ISO 4217). */
|
||||
public const CURRENCY_RUB = 'RUB';
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* ISO-êîä âàëþòû ìàãàçèíà -> `MNT_CURRENCY_CODE`; null — âàëþòà ìîäóëåì íå ïîääåðæèâàåòñÿ.
|
||||
* `RUR` — óñòàðåâøèé êîä ðóáëÿ, êîòîðûé åù¸ âñòðå÷àåòñÿ â ñïðàâî÷íèêàõ âàëþò ìàãàçèíîâ.
|
||||
*/
|
||||
public static function normalizeCurrency(string $iso): ?string
|
||||
{
|
||||
$iso = strtoupper(trim($iso));
|
||||
|
||||
return (($iso === self::CURRENCY_RUB) || ($iso === 'RUR')) ? self::CURRENCY_RUB : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Çíà÷åíèå `MNT_TEST_MODE` äëÿ ôîðìû è ïîäïèñè.
|
||||
*/
|
||||
public static function testModeFlag(bool $testMode): string
|
||||
{
|
||||
return $testMode ? '1' : '0';
|
||||
}
|
||||
|
||||
/**
|
||||
* ßçûê âèòðèíû PHPShop (`russian`, `russian_utf`, `english`, `english_utf`) ->
|
||||
* `moneta.locale` ïëàò¸æíîé ôîðìû. Ìîíåòà çíàåò òîëüêî `ru` è `en`
|
||||
* (`MONETA.Assistant.ru.pdf`, ïàðàìåòðû çàïðîñà): àíãëèéñêàÿ âèòðèíà — `en`, îñòàëüíûå — `ru`.
|
||||
*/
|
||||
public static function normalizeLocale(string $shopLocale): string
|
||||
{
|
||||
return strncmp(strtolower(trim($shopLocale)), 'english', 7) === 0 ? 'en' : 'ru';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\protocol;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use MonetaPhpShop\Money;
|
||||
|
||||
/**
|
||||
* Âõîäÿùèé çàïðîñ ñåðâèñà íà Check URL (`MNT_COMMAND=CHECK`) èëè Pay URL.
|
||||
*/
|
||||
final class CallbackNotification
|
||||
{
|
||||
private const ACCOUNT_ID_MAX_LENGTH = 20;
|
||||
private const TRANSACTION_ID_MAX_LENGTH = 255;
|
||||
private const OPERATION_ID_MAX_LENGTH = 255;
|
||||
|
||||
/**
|
||||
* Íîìåð îïåðàöèè MONETA.RU â Pay URL — òîëüêî öèôðû
|
||||
*/
|
||||
private const PAY_OPERATION_ID = '/\A[0-9]{1,20}\z/';
|
||||
private const SUBSCRIBER_ID_MAX_LENGTH = 255;
|
||||
private const CURRENCY_LENGTH = 3;
|
||||
private const SIGNATURE_LENGTH = 32;
|
||||
|
||||
/** @var string */
|
||||
private $command;
|
||||
|
||||
/** @var string */
|
||||
private $accountId;
|
||||
|
||||
/** @var string */
|
||||
private $transactionId;
|
||||
|
||||
/** @var string */
|
||||
private $operationId;
|
||||
|
||||
/** @var string */
|
||||
private $rawAmount;
|
||||
|
||||
/** @var Money|null */
|
||||
private $amount;
|
||||
|
||||
/** @var string */
|
||||
private $currency;
|
||||
|
||||
/** @var string */
|
||||
private $subscriberId;
|
||||
|
||||
/** @var string */
|
||||
private $rawTestMode;
|
||||
|
||||
/** @var string */
|
||||
private $signature;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $fields ïàðàìåòðû çàïðîñà ($_POST èëè $_GET — ìåòîä çàäà¸òñÿ â íàñòðîéêàõ ñ÷¸òà)
|
||||
*/
|
||||
public function __construct(array $fields)
|
||||
{
|
||||
$this->command = self::optional($fields, 'MNT_COMMAND', strlen(AssistantProtocol::COMMAND_CHECK));
|
||||
if (($this->command !== '') && ($this->command !== AssistantProtocol::COMMAND_CHECK)) {
|
||||
throw new InvalidArgumentException('Íåèçâåñòíàÿ êîìàíäà óâåäîìëåíèÿ.');
|
||||
}
|
||||
|
||||
$this->accountId = self::required($fields, 'MNT_ID', self::ACCOUNT_ID_MAX_LENGTH);
|
||||
$this->transactionId = self::required($fields, 'MNT_TRANSACTION_ID', self::TRANSACTION_ID_MAX_LENGTH);
|
||||
$this->operationId = self::optional($fields, 'MNT_OPERATION_ID', self::OPERATION_ID_MAX_LENGTH);
|
||||
$this->rawAmount = self::optional($fields, 'MNT_AMOUNT', strlen(Money::MAX));
|
||||
$this->amount = $this->rawAmount === '' ? null : Money::fromDecimal($this->rawAmount);
|
||||
$this->currency = self::required($fields, 'MNT_CURRENCY_CODE', self::CURRENCY_LENGTH);
|
||||
$this->subscriberId = self::optional($fields, 'MNT_SUBSCRIBER_ID', self::SUBSCRIBER_ID_MAX_LENGTH);
|
||||
$this->rawTestMode = self::required($fields, 'MNT_TEST_MODE', 1);
|
||||
if (($this->rawTestMode !== '0') && ($this->rawTestMode !== '1')) {
|
||||
throw new InvalidArgumentException('Íåäîïóñòèìîå çíà÷åíèå MNT_TEST_MODE.');
|
||||
}
|
||||
$this->signature = self::required($fields, 'MNT_SIGNATURE', self::SIGNATURE_LENGTH);
|
||||
|
||||
if (
|
||||
(($this->amount === null) || (preg_match(self::PAY_OPERATION_ID, $this->operationId) !== 1))
|
||||
&& !$this->isCheck()
|
||||
) {
|
||||
throw new InvalidArgumentException('Óâåäîìëåíèþ îá îïëàòå íóæíû ñóììà è íîìåð îïåðàöèè èç öèôð.');
|
||||
}
|
||||
}
|
||||
|
||||
public function isCheck(): bool
|
||||
{
|
||||
return ($this->command === AssistantProtocol::COMMAND_CHECK);
|
||||
}
|
||||
|
||||
public function getCommand(): string
|
||||
{
|
||||
return $this->command;
|
||||
}
|
||||
|
||||
public function getAccountId(): string
|
||||
{
|
||||
return $this->accountId;
|
||||
}
|
||||
|
||||
public function getTransactionId(): string
|
||||
{
|
||||
return $this->transactionId;
|
||||
}
|
||||
|
||||
public function getOperationId(): string
|
||||
{
|
||||
return $this->operationId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ñóììà êàê ïðèøëà — òîëüêî äëÿ ïîäïèñè; äëÿ ñâåðêè — {@see self::getAmount()}.
|
||||
*/
|
||||
public function getRawAmount(): string
|
||||
{
|
||||
return $this->rawAmount;
|
||||
}
|
||||
|
||||
public function getAmount(): ?Money
|
||||
{
|
||||
return $this->amount;
|
||||
}
|
||||
|
||||
public function getCurrency(): string
|
||||
{
|
||||
return $this->currency;
|
||||
}
|
||||
|
||||
public function getSubscriberId(): string
|
||||
{
|
||||
return $this->subscriberId;
|
||||
}
|
||||
|
||||
public function getRawTestMode(): string
|
||||
{
|
||||
return $this->rawTestMode;
|
||||
}
|
||||
|
||||
public function isTestMode(): bool
|
||||
{
|
||||
return $this->rawTestMode === '1';
|
||||
}
|
||||
|
||||
public function isSignedBy(Signature $signature): bool
|
||||
{
|
||||
return Signature::equals($signature->forNotification($this), $this->signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $fields
|
||||
*/
|
||||
private static function required(array $fields, string $name, int $maxLength): string
|
||||
{
|
||||
$value = self::optional($fields, $name, $maxLength);
|
||||
if ($value === '') {
|
||||
throw new InvalidArgumentException("Íå ïåðåäàíî ïîëå {$name}.");
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $fields
|
||||
*/
|
||||
private static function optional(array $fields, string $name, int $maxLength): string
|
||||
{
|
||||
if (!array_key_exists($name, $fields)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = $fields[$name];
|
||||
if (!is_string($value)) {
|
||||
throw new InvalidArgumentException("Ïîëå {$name} äîëæíî áûòü ñòðîêîé.");
|
||||
}
|
||||
if (strlen($value) > $maxLength) {
|
||||
throw new InvalidArgumentException("Ïîëå {$name} äëèííåå äîïóñòèìîãî.");
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\protocol;
|
||||
|
||||
/**
|
||||
* Òåëî è òèï îòâåòà ìîäóëÿ íà Check URL / Pay URL.
|
||||
*/
|
||||
final class CallbackResponse
|
||||
{
|
||||
public const CONTENT_TYPE_XML = 'application/xml; charset=UTF-8';
|
||||
public const CONTENT_TYPE_JSON = 'application/json; charset=UTF-8';
|
||||
public const CONTENT_TYPE_TEXT = 'text/plain; charset=UTF-8';
|
||||
|
||||
/** @var string */
|
||||
private $contentType;
|
||||
|
||||
/** @var string */
|
||||
private $body;
|
||||
|
||||
private function __construct(string $contentType, string $body)
|
||||
{
|
||||
$this->contentType = $contentType;
|
||||
$this->body = $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Îòêàç áåç ïîäðîáíîñòåé: ïðè÷èíà ïèøåòñÿ òîëüêî â æóðíàë ìîäóëÿ.
|
||||
*/
|
||||
public static function fail(): self
|
||||
{
|
||||
return new self(self::CONTENT_TYPE_TEXT, AssistantProtocol::TEXT_FAIL);
|
||||
}
|
||||
|
||||
public static function xml(string $body): self
|
||||
{
|
||||
return new self(self::CONTENT_TYPE_XML, $body);
|
||||
}
|
||||
|
||||
public static function json(string $body): self
|
||||
{
|
||||
return new self(self::CONTENT_TYPE_JSON, $body);
|
||||
}
|
||||
|
||||
public function getContentType(): string
|
||||
{
|
||||
return $this->contentType;
|
||||
}
|
||||
|
||||
public function getBody(): string
|
||||
{
|
||||
return $this->body;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\protocol;
|
||||
|
||||
use MonetaPhpShop\Money;
|
||||
|
||||
/**
|
||||
* Îòâåò íà ïðîâåðî÷íûé çàïðîñ (Check URL).
|
||||
*
|
||||
* Ôîðìàò âûáèðàåò íàñòðîéêà «Ôèñêàëèçàöèÿ ÷åêîâ»:
|
||||
* âûêëþ÷åíà — XML `MNT_RESPONSE` ñ `MNT_ATTRIBUTES`, âêëþ÷åíà — JSON ïî `cmsspecification.pdf` ñ ÷åêîì.
|
||||
*/
|
||||
final class CheckResponse
|
||||
{
|
||||
/** @var string */
|
||||
private $accountId;
|
||||
|
||||
/** @var string */
|
||||
private $transactionId;
|
||||
|
||||
/** @var Money|null */
|
||||
private $amount;
|
||||
|
||||
/** @var ResultCode */
|
||||
private $resultCode;
|
||||
|
||||
/** @var Signature */
|
||||
private $signature;
|
||||
|
||||
/** @var string */
|
||||
private $cms;
|
||||
|
||||
/**
|
||||
* @param string $transactionId ðîâíî òî, ÷òî ïðèñëàë ñåðâèñ
|
||||
* @param Money|null $amount ñóììà çàêàçà; null — çàêàç íå íàéäåí
|
||||
*/
|
||||
public function __construct(
|
||||
string $accountId,
|
||||
string $transactionId,
|
||||
?Money $amount,
|
||||
ResultCode $resultCode,
|
||||
Signature $signature,
|
||||
string $cms
|
||||
) {
|
||||
$this->accountId = $accountId;
|
||||
$this->transactionId = $transactionId;
|
||||
$this->amount = $amount;
|
||||
$this->resultCode = $resultCode;
|
||||
$this->signature = $signature;
|
||||
$this->cms = $cms;
|
||||
}
|
||||
|
||||
public function getResultCode(): ResultCode
|
||||
{
|
||||
return $this->resultCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $attributes `INVENTORY`, `CLIENT`, `CUSTOMER`, `PHONE` — êàê ó Pay URL
|
||||
*/
|
||||
public function toXml(array $attributes = []): CallbackResponse
|
||||
{
|
||||
return CallbackResponse::xml(
|
||||
MntResponseXml::build(
|
||||
$this->accountId,
|
||||
$this->transactionId,
|
||||
$this->resultCode,
|
||||
$this->amount,
|
||||
$this->sign(),
|
||||
$this->cms,
|
||||
$attributes,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $receipt îáúåêò `receipt` ñõåìû (`client`, `items`); äåíåæíûå
|
||||
* ïîëÿ — ÷åðåç {@see JsonNumber::placeholder()}, ñòðîêè â UTF-8
|
||||
*/
|
||||
public function toJson(array $receipt): CallbackResponse
|
||||
{
|
||||
$payload = [
|
||||
'id' => $this->accountId,
|
||||
'transactionId' => $this->transactionId,
|
||||
];
|
||||
|
||||
if ($this->amount !== null) {
|
||||
$payload['amount'] = JsonNumber::placeholder($this->amount);
|
||||
}
|
||||
|
||||
$payload += [
|
||||
'signature' => $this->sign(),
|
||||
'resultCode' => (string) $this->resultCode->getValue(),
|
||||
'description' => $this->resultCode->description(),
|
||||
'cms' => $this->cms,
|
||||
'receipt' => $receipt,
|
||||
];
|
||||
|
||||
return CallbackResponse::json(JsonNumber::encode($payload));
|
||||
}
|
||||
|
||||
private function sign(): string
|
||||
{
|
||||
return $this->signature->forResponse($this->resultCode, $this->accountId, $this->transactionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\protocol;
|
||||
|
||||
use MonetaPhpShop\Money;
|
||||
use MonetaPhpShop\receipt\Quantity;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Äåíåæíûå ÷èñëà â JSON áåç `float`: `cmsspecification.pdf` òðåáóåò «äâà çíàêà
|
||||
* ïîñëå òî÷êè, äàæå åñëè ýòî íóëè», à `json_encode(100.0)` äàñò `100.0`. Ñóììà
|
||||
* êîäèðóåòñÿ ìåòêîé è ïîñëå `json_encode` çàìåíÿåòñÿ íà äåñÿòè÷íóþ ñòðîêó áåç
|
||||
* êàâû÷åê — ïîëó÷àåòñÿ ëèòåðàë `100.00`, âàëèäíûé JSON-number. Òàê æå — äðîáíîå êîëè÷åñòâî
|
||||
* ïîçèöèè (`2.5`, äî òð¸õ çíàêîâ): `float` äàë áû `2.4999999…` íà íåóäà÷íûõ çíà÷åíèÿõ.
|
||||
*/
|
||||
final class JsonNumber
|
||||
{
|
||||
private const PREFIX = '__moneta_phpshop_number:';
|
||||
|
||||
private const QUANTITY_PREFIX = '__moneta_phpshop_quantity:';
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public static function placeholder(Money $amount): string
|
||||
{
|
||||
return self::PREFIX . $amount->toDecimal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Êîëè÷åñòâî ïîçèöèè: öåëîå — êàê åñòü (`2`), äðîáíîå — ìåòêîé, â JSON ñòàíåò `2.5`.
|
||||
*
|
||||
* @return int|string
|
||||
*/
|
||||
public static function quantity(Quantity $quantity)
|
||||
{
|
||||
return $quantity->isWhole() ? $quantity->getPieces() : self::QUANTITY_PREFIX . $quantity->toDecimal();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $payload ñòðîêè â UTF-8
|
||||
*/
|
||||
public static function encode(array $payload): string
|
||||
{
|
||||
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
throw new RuntimeException('Íå óäàëîñü ñåðèàëèçîâàòü JSON: ' . json_last_error_msg());
|
||||
}
|
||||
|
||||
$result = preg_replace(
|
||||
[
|
||||
'/"' . preg_quote(self::PREFIX, '/') . '([0-9]+\.[0-9]{2})"/',
|
||||
'/"' . preg_quote(self::QUANTITY_PREFIX, '/') . '([0-9]+\.[0-9]{1,3})"/',
|
||||
],
|
||||
'$1',
|
||||
$json,
|
||||
);
|
||||
if ($result === null) {
|
||||
throw new RuntimeException('Íå óäàëîñü ïîäñòàâèòü ñóììû â JSON.');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\protocol;
|
||||
|
||||
use DOMDocument;
|
||||
use DOMNode;
|
||||
use MonetaPhpShop\Money;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Ñáîðêà `MNT_RESPONSE` (`MONETA.Assistant.ru.pdf`, ãë. 4–5) — îáùàÿ ÷àñòü
|
||||
* XML-îòâåòîâ Check URL è Pay URL: îáÿçàòåëüíûå ýëåìåíòû, ïîäïèñü è `MNT_ATTRIBUTES`.
|
||||
*/
|
||||
final class MntResponseXml
|
||||
{
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* @param Money|null $amount null — ñóììà íåèçâåñòíà (çàêàç íå íàéäåí), ýëåìåíò íå âûâîäèòñÿ
|
||||
* @param array<string, string> $attributes ïàðû KEY -> VALUE äëÿ `MNT_ATTRIBUTES`, ñòðîêè â UTF-8
|
||||
* @throws \DOMException
|
||||
*/
|
||||
public static function build(
|
||||
string $accountId,
|
||||
string $transactionId,
|
||||
ResultCode $resultCode,
|
||||
?Money $amount,
|
||||
string $signature,
|
||||
string $cms,
|
||||
array $attributes = []
|
||||
): string {
|
||||
$document = new DOMDocument('1.0', 'UTF-8');
|
||||
|
||||
$response = $document->appendChild($document->createElement('MNT_RESPONSE'));
|
||||
|
||||
$elements = [
|
||||
'MNT_ID' => $accountId,
|
||||
'MNT_TRANSACTION_ID' => $transactionId,
|
||||
'MNT_RESULT_CODE' => (string) $resultCode->getValue(),
|
||||
'MNT_DESCRIPTION' => $resultCode->description(),
|
||||
'MNT_AMOUNT' => $amount !== null ? $amount->toDecimal() : null,
|
||||
'MNT_SIGNATURE' => $signature,
|
||||
'MNT_CMS' => $cms,
|
||||
];
|
||||
foreach ($elements as $name => $value) {
|
||||
if ($value !== null) {
|
||||
self::text($document, $response, $name, $value);
|
||||
}
|
||||
}
|
||||
|
||||
if ($attributes !== []) {
|
||||
$container = $response->appendChild($document->createElement('MNT_ATTRIBUTES'));
|
||||
foreach ($attributes as $key => $value) {
|
||||
$attribute = $container->appendChild($document->createElement('ATTRIBUTE'));
|
||||
self::text($document, $attribute, 'KEY', (string) $key);
|
||||
self::text($document, $attribute, 'VALUE', $value);
|
||||
}
|
||||
}
|
||||
|
||||
$xml = $document->saveXML();
|
||||
if ($xml === false) {
|
||||
throw new RuntimeException('Íå óäàëîñü ñåðèàëèçîâàòü MNT_RESPONSE.');
|
||||
}
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
private static function text(DOMDocument $document, DOMNode $parent, string $name, string $value): void
|
||||
{
|
||||
$parent
|
||||
->appendChild($document->createElement($name))
|
||||
->appendChild($document->createTextNode($value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\protocol;
|
||||
|
||||
use MonetaPhpShop\Money;
|
||||
|
||||
/**
|
||||
* Îòâåò íà óâåäîìëåíèå îá îïëàòå (Pay URL): âñåãäà XML `MNT_RESPONSE`, íåçàâèñèìî îò íàñòðîéêè
|
||||
* ôèñêàëèçàöèè. `MNT_ATTRIBUTES` — òîëüêî â óñïåøíîì îòâåòå, ñîñòàâ çàâèñèò îò ôèñêàëèçàöèè.
|
||||
*/
|
||||
final class PayResponse
|
||||
{
|
||||
/** @var string */
|
||||
private $accountId;
|
||||
|
||||
/** @var string */
|
||||
private $transactionId;
|
||||
|
||||
/** @var Money */
|
||||
private $amount;
|
||||
|
||||
/** @var ResultCode */
|
||||
private $resultCode;
|
||||
|
||||
/** @var Signature */
|
||||
private $signature;
|
||||
|
||||
/** @var string */
|
||||
private $cms;
|
||||
|
||||
/**
|
||||
* @param string $transactionId ðîâíî òî, ÷òî ïðèñëàë ñåðâèñ
|
||||
*/
|
||||
public function __construct(
|
||||
string $accountId,
|
||||
string $transactionId,
|
||||
Money $amount,
|
||||
ResultCode $resultCode,
|
||||
Signature $signature,
|
||||
string $cms
|
||||
) {
|
||||
$this->accountId = $accountId;
|
||||
$this->transactionId = $transactionId;
|
||||
$this->amount = $amount;
|
||||
$this->resultCode = $resultCode;
|
||||
$this->signature = $signature;
|
||||
$this->cms = $cms;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $attributes `INVENTORY`, `CLIENT`, `CUSTOMER`, `PHONE`
|
||||
* @throws \DOMException
|
||||
*/
|
||||
public function toXml(array $attributes = []): CallbackResponse
|
||||
{
|
||||
return CallbackResponse::xml(
|
||||
MntResponseXml::build(
|
||||
$this->accountId,
|
||||
$this->transactionId,
|
||||
$this->resultCode,
|
||||
$this->amount,
|
||||
$this->signature->forResponse($this->resultCode, $this->accountId, $this->transactionId),
|
||||
$this->cms,
|
||||
$attributes,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\protocol;
|
||||
|
||||
/**
|
||||
* Ñåðâåð ïëàò¸æíîé ôîðìû MONETA.Assistant. Ó äåìî-ïëîùàäêè ñâîé íîìåð ñ÷¸òà.
|
||||
* Çíà÷åíèÿ `PROD`/`DEMO` õðàíÿòñÿ â íàñòðîéêàõ ìîäóëÿ — íå ïåðåèìåíîâûâàòü.
|
||||
*/
|
||||
final class PaymentServer
|
||||
{
|
||||
public const PROD = 'PROD';
|
||||
public const DEMO = 'DEMO';
|
||||
|
||||
private const URLS = [
|
||||
self::PROD => 'https://www.payanyway.ru/assistant.htm',
|
||||
self::DEMO => 'https://demo.moneta.ru/assistant.htm',
|
||||
];
|
||||
|
||||
/** @var string */
|
||||
private $value;
|
||||
|
||||
private function __construct(string $value)
|
||||
{
|
||||
$this->value = $value;
|
||||
}
|
||||
|
||||
public static function fromSetting(?string $value): self
|
||||
{
|
||||
return new self(isset(self::URLS[(string) $value]) ? (string) $value : self::PROD);
|
||||
}
|
||||
|
||||
public function getValue(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function isDemo(): bool
|
||||
{
|
||||
return ($this->value === self::DEMO);
|
||||
}
|
||||
|
||||
public function url(): string
|
||||
{
|
||||
return self::URLS[$this->value];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\protocol;
|
||||
|
||||
/**
|
||||
* Êîäû `MNT_RESULT_CODE` îòâåòîâ ìîäóëÿ (`MONETA.Assistant.ru.pdf`, ãë. 4–5;
|
||||
* `cmsspecification.pdf`, «Ïàðàìåòðû îòâåòà»).
|
||||
*
|
||||
* Íà Check URL êîä îïèñûâàåò ñîñòîÿíèå çàêàçà; íà Pay URL ñåðâèñ ñ÷èòàåò
|
||||
* óâåäîìëåíèå äîñòàâëåííûì òîëüêî ïðè 200, ïðè 100/302/402 ïîâòîðÿåò åãî,
|
||||
* ïðè 500 ïðåêðàùàåò ïîïûòêè. Value-îáúåêò âìåñòî enum — ìîäóëü ðàáîòàåò íà PHP 7.4.
|
||||
*/
|
||||
final class ResultCode
|
||||
{
|
||||
/** Îòâåò ñîäåðæèò ñóììó çàêàçà — â ïðîâåðî÷íîì çàïðîñå `MNT_AMOUNT` íå áûëî. */
|
||||
public const WITH_AMOUNT = 100;
|
||||
|
||||
/** Çàêàç îïëà÷åí, óâåäîìëåíèå äîñòàâëåíî. */
|
||||
public const PAID = 200;
|
||||
|
||||
/** Çàêàç â îáðàáîòêå, òî÷íûé ñòàòóñ îïðåäåëèòü íåëüçÿ. */
|
||||
public const IN_PROGRESS = 302;
|
||||
|
||||
/** Çàêàç ñîçäàí è ãîòîâ ê îïëàòå. */
|
||||
public const AWAITING_PAYMENT = 402;
|
||||
|
||||
/** Çàêàç íåàêòóàëåí (îòìåí¸í, íå íàéäåí); ñåðâèñ ïðåêðàùàåò ïîâòîðû. */
|
||||
public const REJECTED = 500;
|
||||
|
||||
/**
|
||||
* Òåêñò äëÿ `MNT_DESCRIPTION` / `description`: áåç êàâû÷åê, `&`, `$`, `#`
|
||||
* è ñëýøåé — òðåáîâàíèå `cmsspecification.pdf` ê çíà÷åíèÿì îòâåòà.
|
||||
*/
|
||||
private const DESCRIPTIONS = [
|
||||
self::WITH_AMOUNT => 'Order amount',
|
||||
self::PAID => 'Order is paid',
|
||||
self::IN_PROGRESS => 'Order is being processed',
|
||||
self::AWAITING_PAYMENT => 'Order created, but not paid',
|
||||
self::REJECTED => 'Order is not available',
|
||||
];
|
||||
|
||||
/** @var int */
|
||||
private $value;
|
||||
|
||||
private function __construct(int $value)
|
||||
{
|
||||
$this->value = $value;
|
||||
}
|
||||
|
||||
public static function fromValue(int $value): self
|
||||
{
|
||||
if (!array_key_exists($value, self::DESCRIPTIONS)) {
|
||||
throw new \InvalidArgumentException('Íåèçâåñòíûé êîä MNT_RESULT_CODE.');
|
||||
}
|
||||
|
||||
return new self($value);
|
||||
}
|
||||
|
||||
public static function withAmount(): self
|
||||
{
|
||||
return new self(self::WITH_AMOUNT);
|
||||
}
|
||||
|
||||
public static function paid(): self
|
||||
{
|
||||
return new self(self::PAID);
|
||||
}
|
||||
|
||||
public static function inProgress(): self
|
||||
{
|
||||
return new self(self::IN_PROGRESS);
|
||||
}
|
||||
|
||||
public static function awaitingPayment(): self
|
||||
{
|
||||
return new self(self::AWAITING_PAYMENT);
|
||||
}
|
||||
|
||||
public static function rejected(): self
|
||||
{
|
||||
return new self(self::REJECTED);
|
||||
}
|
||||
|
||||
public function getValue(): int
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return self::DESCRIPTIONS[$this->value];
|
||||
}
|
||||
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return ($this->value === $other->value);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return (string) $this->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\protocol;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use MonetaPhpShop\Money;
|
||||
|
||||
/**
|
||||
* Òðè ôîðìóëû ïîäïèñè MONETA.Assistant (`MONETA.Assistant.ru.pdf`, ãë. 3–5).
|
||||
*
|
||||
* Âåçäå MD5 îò êîíêàòåíàöèè çíà÷åíèé áåç ðàçäåëèòåëåé ïëþñ êîä ïðîâåðêè
|
||||
* öåëîñòíîñòè; îòñóòñòâóþùèé ïàðàìåòð — ïóñòàÿ ñòðîêà. Ýêçåìïëÿð äåðæèò êîä,
|
||||
* ÷òîáû îí íå ãóëÿë ïî ñèãíàòóðàì ìåòîäîâ è íå ïîïàäàë â òðàññèðîâêè.
|
||||
*/
|
||||
final class Signature
|
||||
{
|
||||
private const PATTERN = '/\A[0-9a-f]{32}\z/i';
|
||||
|
||||
/** @var string */
|
||||
private $integrityCode;
|
||||
|
||||
public function __construct(
|
||||
#[\SensitiveParameter]
|
||||
string $integrityCode
|
||||
) {
|
||||
if ($integrityCode === '') {
|
||||
throw new InvalidArgumentException('Êîä ïðîâåðêè öåëîñòíîñòè íå çàäàí.');
|
||||
}
|
||||
$this->integrityCode = $integrityCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ôîðìóëà 1 — ïëàò¸æíàÿ ôîðìà (ìîäóëü -> ñåðâèñ).
|
||||
*
|
||||
* md5(MNT_ID + MNT_TRANSACTION_ID + MNT_AMOUNT + MNT_CURRENCY_CODE + MNT_SUBSCRIBER_ID + MNT_TEST_MODE + ÊÎÄ)
|
||||
*/
|
||||
public function forPaymentForm(
|
||||
string $accountId,
|
||||
string $transactionId,
|
||||
Money $amount,
|
||||
string $currency,
|
||||
string $subscriberId,
|
||||
bool $testMode
|
||||
): string {
|
||||
return $this->hash(
|
||||
$accountId
|
||||
. $transactionId
|
||||
. $amount->toDecimal()
|
||||
. $currency
|
||||
. $subscriberId
|
||||
. AssistantProtocol::testModeFlag($testMode),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ôîðìóëà 2 — âõîäÿùèé çàïðîñ íà Check URL / Pay URL (ñåðâèñ -> ìîäóëü).
|
||||
*
|
||||
* md5(MNT_COMMAND + MNT_ID + MNT_TRANSACTION_ID + MNT_OPERATION_ID + MNT_AMOUNT
|
||||
* + MNT_CURRENCY_CODE + MNT_SUBSCRIBER_ID + MNT_TEST_MODE + ÊÎÄ)
|
||||
*
|
||||
* Çíà÷åíèÿ áåðóòñÿ èç çàïðîñà êàê ïðèøëè: ïîäïèñü ñâåðÿåòñÿ ñ òåì, ÷òî
|
||||
* ïîäïèñàë ñåðâèñ, à íå ñ íàøèì ïðåäñòàâëåíèåì î çàêàçå.
|
||||
*/
|
||||
public function forNotification(CallbackNotification $notification): string
|
||||
{
|
||||
return $this->hash(
|
||||
$notification->getCommand()
|
||||
. $notification->getAccountId()
|
||||
. $notification->getTransactionId()
|
||||
. $notification->getOperationId()
|
||||
. $notification->getRawAmount()
|
||||
. $notification->getCurrency()
|
||||
. $notification->getSubscriberId()
|
||||
. $notification->getRawTestMode(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ôîðìóëà 3 — îòâåò ìîäóëÿ íà Check URL è Pay URL (XML è JSON).
|
||||
*
|
||||
* md5(MNT_RESULT_CODE + MNT_ID + MNT_TRANSACTION_ID + ÊÎÄ)
|
||||
*/
|
||||
public function forResponse(ResultCode $resultCode, string $accountId, string $transactionId): string
|
||||
{
|
||||
return $this->hash((string) $resultCode->getValue() . $accountId . $transactionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ñðàâíåíèå áåç óòå÷êè ïî âðåìåíè; ðåãèñòð hex-ñòðîêè íå âàæåí.
|
||||
*/
|
||||
public static function equals(string $expected, string $received): bool
|
||||
{
|
||||
if (preg_match(self::PATTERN, $received) !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals(strtolower($expected), strtolower($received));
|
||||
}
|
||||
|
||||
/**
|
||||
* Êîä ïðîâåðêè öåëîñòíîñòè íå âûâîäèòñÿ â var_dump()/print_r() è æóðíàëû.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function __debugInfo(): array
|
||||
{
|
||||
return ['integrityCode' => '***'];
|
||||
}
|
||||
|
||||
private function hash(string $payload): string
|
||||
{
|
||||
return md5($payload . $this->integrityCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\protocol;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use InvalidArgumentException;
|
||||
use MonetaPhpShop\Money;
|
||||
|
||||
/**
|
||||
* `MNT_TRANSACTION_ID` — ôîðìàò `<íîìåð>_<ñóììà>_<YmdHis>UTC`: íîìåð çàêàçà PHPShop
|
||||
* (`phpshop_orders.uid`, íàïðèìåð `4-36`), ñóììà ôîðìû â êîïåéêàõ è ìåòêà âûäà÷è ôîðìû â UTC,
|
||||
* `4-36_1600000_20260930113005UTC`.
|
||||
*/
|
||||
final class TransactionId
|
||||
{
|
||||
/**
|
||||
* Íîìåð çàêàçà PHPShop. Øòàòíî `<÷èñëî>-<ïðåôèêñ>` (`PHPShopDone::setNum()`),
|
||||
* íî õóê `setNum` äðóãîãî ìîäóëÿ ìîæåò åãî ïåðåîïðåäåëèòü, ïîýòîìó äîïóñêàþòñÿ
|
||||
* ëàòèíèöà, öèôðû è äåôèñ â ïðåäåëàõ `phpshop_orders.uid varchar(64)` — áåç `_`.
|
||||
*/
|
||||
public const ORDER_UID = '[0-9A-Za-z](?:[0-9A-Za-z-]{0,62}[0-9A-Za-z])?';
|
||||
|
||||
public const SEPARATOR = '_';
|
||||
|
||||
/** Ñóììà â êîïåéêàõ áåç âåäóùèõ íóëåé; 16 öèôð âìåùàþò {@see Money::MAX}. */
|
||||
private const AMOUNT = '0|[1-9][0-9]{0,15}';
|
||||
|
||||
/**
|
||||
* Ïîÿñ ìåòêè — âñåãäà UTC, áóêâàìè.
|
||||
*/
|
||||
private const ZONE = 'UTC';
|
||||
|
||||
private const PATTERN = '/\A
|
||||
(?<uid>' . self::ORDER_UID . ') # íîìåð çàêàçà PHPShop
|
||||
' . self::SEPARATOR . '
|
||||
(?<amount>' . self::AMOUNT . ') # ñóììà ôîðìû â êîïåéêàõ
|
||||
' . self::SEPARATOR . '
|
||||
[0-9]{14} # ìåòêà âðåìåíè YmdHis
|
||||
' . self::ZONE . ' # ïîÿñ ìåòêè
|
||||
\z/x';
|
||||
|
||||
/** @var string */
|
||||
private $orderUid;
|
||||
|
||||
/** @var Money */
|
||||
private $amount;
|
||||
|
||||
private function __construct(string $orderUid, Money $amount)
|
||||
{
|
||||
$this->orderUid = $orderUid;
|
||||
$this->amount = $amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Money $amount ñóììà ôîðìû (`MNT_AMOUNT`)
|
||||
* @param DateTimeImmutable $issuedAt ìîìåíò âûäà÷è ôîðìû, â ëþáîì ïîÿñå — ìåòêà ïèøåòñÿ â UTC
|
||||
*/
|
||||
public static function build(string $orderUid, Money $amount, DateTimeImmutable $issuedAt): string
|
||||
{
|
||||
if (preg_match('/\A' . self::ORDER_UID . '\z/', $orderUid) !== 1) {
|
||||
throw new InvalidArgumentException('Íîìåð çàêàçà íå ïîäõîäèò äëÿ MNT_TRANSACTION_ID.');
|
||||
}
|
||||
|
||||
return $orderUid
|
||||
. self::SEPARATOR . $amount->toMinorUnits()
|
||||
. self::SEPARATOR . $issuedAt->setTimezone(new DateTimeZone(self::ZONE))->format('YmdHis') . self::ZONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Íîìåð çàêàçà è ñóììà ôîðìû èç ïðèøåäøåãî `MNT_TRANSACTION_ID` èëè null, åñëè ñòðîêà íå íàøåãî ôîðìàòà.
|
||||
*/
|
||||
public static function parse(string $raw): ?self
|
||||
{
|
||||
if (preg_match(self::PATTERN, $raw, $matches) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$amount = Money::fromMinorUnits((int) $matches['amount']);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new self($matches['uid'], $amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Íîìåð çàêàçà èç òîãî, ÷òî ââ¸ë àäìèíèñòðàòîð (ôèëüòð æóðíàëà îïåðàöèé): ñàì íîìåð (`33-57`)
|
||||
* èëè öåëèêîì `MNT_TRANSACTION_ID`, ñêîïèðîâàííûé èç êàáèíåòà MONETA.RU. Èíà÷å — null.
|
||||
*/
|
||||
public static function orderUidFromInput(string $input): ?string
|
||||
{
|
||||
$input = trim($input);
|
||||
if (preg_match('/\A' . self::ORDER_UID . '\z/', $input) === 1) {
|
||||
return $input;
|
||||
}
|
||||
|
||||
$transactionId = self::parse($input);
|
||||
|
||||
return ($transactionId === null) ? null : $transactionId->getOrderUid();
|
||||
}
|
||||
|
||||
public function getOrderUid(): string
|
||||
{
|
||||
return $this->orderUid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ñóììà ïëàò¸æíîé ôîðìû, âûïóñòèâøåé ýòîò íîìåð.
|
||||
*/
|
||||
public function getAmount(): Money
|
||||
{
|
||||
return $this->amount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\receipt;
|
||||
|
||||
use Closure;
|
||||
|
||||
/**
|
||||
* Åäèíèöà èçìåðåíèÿ ïîçèöèè ÷åêà (`measure`, ïåðå÷åíü `kassaspecification.pdf`) ïî åäèíèöå òîâàðà
|
||||
* PHPShop (`ed_izm` — ñâîáîäíûé òåêñò: «øò.», «êã», «ì2»). Âàðèàíòû íàïèñàíèÿ — òå, ÷òî êàññà
|
||||
* ðàñïîçíà¸ò ñàìà («àâòîîïðåäåëåíèå» â ñïåöèôèêàöèè), ïëþñ òî÷êà íà êîíöå è ïðîáåëû, êàê ïèøóò
|
||||
* â PHPShop. Íå ðàñïîçíàíî èëè ïóñòî — `unit` (øòóêà), çíà÷åíèå êàññû ïî óìîë÷àíèþ.
|
||||
*
|
||||
* Ñðàâíåíèå áåç ó÷¸òà ðåãèñòðà ÷åðåç `/iu`: `mbstring` ÿäðî PHPShop íå òðåáóåò. Âàðèàíòû — ëèòåðàëû
|
||||
* ìîäóëÿ: ïîñëå ñáîðêè îíè â cp1251, à øàáëîí ñ `/u` îáÿçàí áûòü UTF-8, ïîýòîìó ïåðåä ñðàâíåíèåì èõ
|
||||
* ïåðåêîäèðóåò òîò æå `$toUtf8`, ÷òî è ïðî÷èå ëèòåðàëû ÷åêà ({@see ReceiptBuilder}).
|
||||
*/
|
||||
final class Measure
|
||||
{
|
||||
public const UNIT = 'unit';
|
||||
|
||||
/** Êîä êàññû -> âàðèàíòû íàïèñàíèÿ (áåç ïðîáåëîâ, â íèæíåì ðåãèñòðå). */
|
||||
private const VARIANTS = [
|
||||
self::UNIT => ['øòóêà', 'øòóêè', 'øòóê', 'øò', 'pc', 'pce', 'pcs', 'nmb'],
|
||||
'gram' => ['ãðàìì', 'ãðàììû', 'ã', 'ãð', 'g', 'grm'],
|
||||
'kilogram' => ['êèëîãðàìì', 'êèëîãðàììû', 'êã', 'kg', 'kgm'],
|
||||
'ton' => ['òîííà', 'òîííû', 'ò', 't', 'tne'],
|
||||
'centimeter' => ['ñàíòèìåòð', 'ñì', 'cm', 'cmt'],
|
||||
'decimeter' => ['äåöèìåòð', 'äì', 'dm', 'dmt'],
|
||||
'meter' => ['ìåòð', 'ìåòðû', 'ì', 'm', 'mtr', 'ïì', 'ïîã.ì'],
|
||||
'square_centimeter' => ['êâàäðàòíûéñàíòèìåòð', 'êâ.ñì', 'ñì2', 'cm2', 'cmk'],
|
||||
'square_decimeter' => ['êâàäðàòíûéäåöèìåòð', 'êâ.äì', 'äì2', 'dm2', 'dmk'],
|
||||
'square_meter' => ['êâàäðàòíûéìåòð', 'êâ.ì', 'ì2', 'm2', 'mtk'],
|
||||
'milliliter' => ['ìèëëèëèòð', 'ìë', 'ml', 'mlt'],
|
||||
'liter' => ['ëèòð', 'ëèòðû', 'ë', 'l', 'ltr'],
|
||||
'cubic_meter' => ['êóáè÷åñêèéìåòð', 'êóá.ì', 'ì3', 'm3', 'mtq'],
|
||||
'kilowatt_hour' => ['êèëîâàòò÷àñ', 'êâò*÷', 'êâò-÷', 'êâò÷', 'kwh'],
|
||||
'gigacalorie' => ['ãèãàêàëîðèÿ', 'ãêàë', 'ãèãàêàë'],
|
||||
'day' => ['ñóòêè', 'ñóò', 'äåíü', 'äí', 'd', 'day'],
|
||||
'hour' => ['÷àñ', '÷', 'h', 'hur'],
|
||||
'minute' => ['ìèíóòà', 'ìèí', 'min'],
|
||||
'second' => ['ñåêóíäà', 'ñåê', 'ñ', 's', 'sec'],
|
||||
'kilobyte' => ['êèëîáàéò', 'êáàéò', 'êá', 'kib', 'êèá', 'kb'],
|
||||
'megabyte' => ['ìåãàáàéò', 'ìáàéò', 'ìá', 'mib', 'ìèá', 'mb'],
|
||||
'gigabyte' => ['ãèãàáàéò', 'ãáàéò', 'ãá', 'gib', 'ãèá', 'gb'],
|
||||
'terabyte' => ['òåðàáàéò', 'òáàéò', 'òá', 'tib', 'òèá', 'tb'],
|
||||
];
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* @param string $unit åäèíèöà òîâàðà â UTF-8
|
||||
* @param Closure(string): string $toUtf8 ëèòåðàëû ìîäóëÿ -> UTF-8
|
||||
*/
|
||||
public static function fromUnit(string $unit, Closure $toUtf8): string
|
||||
{
|
||||
// Ïðîáåëû íå çíà÷èìû («êâ. ì»), òî÷êà íà êîíöå — ñîêðàùåíèå («øò.»).
|
||||
$unit = rtrim((string) preg_replace('/\s+/u', '', $unit), '.');
|
||||
if ($unit === '') {
|
||||
return self::UNIT;
|
||||
}
|
||||
foreach (self::VARIANTS as $measure => $variants) {
|
||||
foreach ($variants as $variant) {
|
||||
if (preg_match('/\A' . preg_quote($toUtf8($variant), '/') . '\z/iu', $unit) === 1) {
|
||||
return $measure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return self::UNIT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\receipt;
|
||||
|
||||
/**
|
||||
* Ïðèçíàê ñïîñîáà ðàñ÷¸òà (`pm` â `INVENTORY`, `paymentMethod` â JSON Check URL): âåñü ïåðå÷åíü
|
||||
* `kassaspecification.pdf`. Äåéñòâóåò íà âñå ïîçèöèè ÷åêà, âêëþ÷àÿ äîñòàâêó.
|
||||
* Ðàñ÷¸òíûå ñòàâêè ÍÄÑ ïðè àâàíñå è ïðåäîïëàòå ìîäóëü ñàì íå âûáèðàåò.
|
||||
* Çíà÷åíèÿ õðàíÿòñÿ â íàñòðîéêàõ ìîäóëÿ — íå ïåðåèìåíîâûâàòü.
|
||||
*/
|
||||
final class PaymentMethod
|
||||
{
|
||||
public const FULL_PREPAYMENT = 'full_prepayment';
|
||||
public const PREPAYMENT = 'prepayment';
|
||||
public const ADVANCE = 'advance';
|
||||
public const FULL_PAYMENT = 'full_payment';
|
||||
public const PARTIAL_PAYMENT = 'partial_payment';
|
||||
public const CREDIT = 'credit';
|
||||
public const CREDIT_PAYMENT = 'credit_payment';
|
||||
|
||||
/** Çíà÷åíèå -> ïîäïèñü â íàñòðîéêàõ, ïîðÿäîê — êàê â `kassaspecification.pdf`. */
|
||||
public const LABELS = [
|
||||
self::FULL_PREPAYMENT => 'Ïîëíàÿ ïðåäîïëàòà',
|
||||
self::PREPAYMENT => '×àñòè÷íàÿ ïðåäîïëàòà',
|
||||
self::ADVANCE => 'Àâàíñ',
|
||||
self::FULL_PAYMENT => 'Ïîëíûé ðàñ÷¸ò',
|
||||
self::PARTIAL_PAYMENT => '×àñòè÷íûé ðàñ÷¸ò è êðåäèò',
|
||||
self::CREDIT => 'Êðåäèò',
|
||||
self::CREDIT_PAYMENT => 'Âûïëàòà ïî êðåäèòó',
|
||||
];
|
||||
|
||||
/** @var string */
|
||||
private $value;
|
||||
|
||||
private function __construct(string $value)
|
||||
{
|
||||
$this->value = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Çíà÷åíèå èç íàñòðîåê; íåèçâåñòíîå èëè ïóñòîå — ïîëíûé ðàñ÷¸ò (äåôîëò êàññû).
|
||||
*/
|
||||
public static function fromSetting(?string $value): self
|
||||
{
|
||||
return new self(isset(self::LABELS[(string) $value]) ? (string) $value : self::FULL_PAYMENT);
|
||||
}
|
||||
|
||||
public static function fullPayment(): self
|
||||
{
|
||||
return new self(self::FULL_PAYMENT);
|
||||
}
|
||||
|
||||
public function getValue(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\receipt;
|
||||
|
||||
/**
|
||||
* Ïðèçíàê ïðåäìåòà ðàñ÷¸òà (`po` â `INVENTORY`, `paymentObject` â JSON Check URL): ïåðå÷åíü
|
||||
* `kassaspecification.pdf` ïëþñ `composite`.
|
||||
* Íàñòðîéêîé âûáèðàåòñÿ òîëüêî äëÿ òîâàðîâ; äîñòàâêà âñåãäà {@see self::SERVICE}.
|
||||
* Çíà÷åíèÿ õðàíÿòñÿ â íàñòðîéêàõ ìîäóëÿ — íå ïåðåèìåíîâûâàòü.
|
||||
*/
|
||||
final class PaymentObject
|
||||
{
|
||||
public const COMMODITY = 'commodity';
|
||||
public const SERVICE = 'service';
|
||||
|
||||
/** Çíà÷åíèå -> ïîäïèñü â íàñòðîéêàõ. */
|
||||
public const LABELS = [
|
||||
self::COMMODITY => 'Òîâàð',
|
||||
'excise' => 'Ïîäàêöèçíûé òîâàð',
|
||||
'job' => 'Ðàáîòà',
|
||||
self::SERVICE => 'Óñëóãà',
|
||||
'gambling_bet' => 'Ñòàâêà àçàðòíîé èãðû',
|
||||
'gambling_prize' => 'Âûèãðûø àçàðòíîé èãðû',
|
||||
'lottery' => 'Ëîòåðåéíûé áèëåò',
|
||||
'lottery_prize' => 'Âûèãðûø ëîòåðåè',
|
||||
'intellectual_activity' => 'Ïðåäîñòàâëåíèå ðåçóëüòàòîâ èíòåëëåêòóàëüíîé äåÿòåëüíîñòè',
|
||||
'payment' => 'Ïëàò¸æ',
|
||||
'agent_commission' => 'Àãåíòñêîå âîçíàãðàæäåíèå',
|
||||
'award' => 'Âûïëàòà (âçíîñ, ïåíè, øòðàô, âîçíàãðàæäåíèå, áîíóñ)',
|
||||
'composite' => 'Ñîñòàâíîé ïðåäìåò ðàñ÷¸òà',
|
||||
'another' => 'Äðóãîå',
|
||||
'property_right' => 'Èìóùåñòâåííîå ïðàâî',
|
||||
'non_operating' => 'Âíåðåàëèçàöèîííûé äîõîä',
|
||||
'insurance_premium' => 'Ñòðàõîâîé ñáîð',
|
||||
'sales_tax' => 'Òîðãîâûé ñáîð',
|
||||
'resort_fee' => 'Êóðîðòíûé ñáîð',
|
||||
];
|
||||
|
||||
/** @var string */
|
||||
private $value;
|
||||
|
||||
private function __construct(string $value)
|
||||
{
|
||||
$this->value = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Çíà÷åíèå èç íàñòðîåê; íåèçâåñòíîå èëè ïóñòîå — òîâàð (äåôîëò êàññû).
|
||||
*/
|
||||
public static function fromSetting(?string $value): self
|
||||
{
|
||||
return new self(isset(self::LABELS[(string) $value]) ? (string) $value : self::COMMODITY);
|
||||
}
|
||||
|
||||
public static function commodity(): self
|
||||
{
|
||||
return new self(self::COMMODITY);
|
||||
}
|
||||
|
||||
public static function service(): self
|
||||
{
|
||||
return new self(self::SERVICE);
|
||||
}
|
||||
|
||||
public function getValue(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\receipt;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use MonetaPhpShop\Money;
|
||||
|
||||
/**
|
||||
* Êîëè÷åñòâî ïîçèöèè ÷åêà ñ òî÷íîñòüþ êàññû — òðè çíàêà ïîñëå òî÷êè (`kassaspecification.pdf`,
|
||||
* â ñõåìå Check URL `multipleOf 0.001`). Õðàíèòñÿ öåëûì ÷èñëîì òûñÿ÷íûõ, êàê êîïåéêè â {@see Money}:
|
||||
* Êîðçèíà ÿäðà õðàíèò êîëè÷åñòâî êàê åñòü.
|
||||
*/
|
||||
final class Quantity
|
||||
{
|
||||
private const SCALE = 1000;
|
||||
|
||||
/** Ïîòîëîê êàññû: 99999.999. */
|
||||
public const MAX_THOUSANDTHS = 99999999;
|
||||
|
||||
/** @var int */
|
||||
private $thousandths;
|
||||
|
||||
private function __construct(int $thousandths)
|
||||
{
|
||||
if (($thousandths < 1) || ($thousandths > self::MAX_THOUSANDTHS)) {
|
||||
throw new InvalidArgumentException('Êîëè÷åñòâî ïîçèöèè ÷åêà âíå ãðàíèö êàññû.');
|
||||
}
|
||||
|
||||
$this->thousandths = $thousandths;
|
||||
}
|
||||
|
||||
public static function pieces(int $pieces): self
|
||||
{
|
||||
return new self($pieces * self::SCALE);
|
||||
}
|
||||
|
||||
public static function fromThousandths(int $thousandths): self
|
||||
{
|
||||
return new self($thousandths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Êîëè÷åñòâî èç êîðçèíû çàêàçà (`num`): ÷èñëî èëè ÷èñëîâàÿ ñòðîêà, îêðóãëÿåòñÿ äî òûñÿ÷íûõ.
|
||||
* Íîëü, îòðèöàòåëüíîå, íå ÷èñëî èëè áîëüøå ïîòîëêà êàññû — null.
|
||||
*
|
||||
* @param mixed $raw
|
||||
*/
|
||||
public static function fromCart($raw): ?self
|
||||
{
|
||||
if (is_string($raw)) {
|
||||
$raw = trim($raw);
|
||||
}
|
||||
if (!is_int($raw) && !is_float($raw) && !(is_string($raw) && is_numeric($raw))) {
|
||||
return null;
|
||||
}
|
||||
$thousandths = (int) round((float) $raw * self::SCALE);
|
||||
if (($thousandths < 1) || ($thousandths > self::MAX_THOUSANDTHS)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new self($thousandths);
|
||||
}
|
||||
|
||||
public function getThousandths(): int
|
||||
{
|
||||
return $this->thousandths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Öåëîå ëè êîëè÷åñòâî: òîëüêî òàêóþ ñòðîêó ìîæíî ðåçàòü íà äâå ñ ðàçíèöåé öåí â êîïåéêó.
|
||||
*/
|
||||
public function isWhole(): bool
|
||||
{
|
||||
return $this->thousandths % self::SCALE === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Öåëîå ÷èñëî øòóê; òîëüêî äëÿ {@see self::isWhole()}.
|
||||
*/
|
||||
public function getPieces(): int
|
||||
{
|
||||
return intdiv($this->thousandths, self::SCALE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Äåñÿòè÷íàÿ çàïèñü áåç ëèøíèõ íóëåé: `2`, `2.5`, `0.125`.
|
||||
*/
|
||||
public function toDecimal(): string
|
||||
{
|
||||
if ($this->isWhole()) {
|
||||
return (string) $this->getPieces();
|
||||
}
|
||||
|
||||
return rtrim(
|
||||
sprintf('%d.%03d', intdiv($this->thousandths, self::SCALE), $this->thousandths % self::SCALE),
|
||||
'0',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ñóììà ïîçèöèè: öåíà x êîëè÷åñòâî, îêðóãëåíèå äî êîïåéêè (ïîëîâèíà — ââåðõ).
|
||||
*/
|
||||
public function totalOf(Money $price): Money
|
||||
{
|
||||
return Money::fromMinorUnits(
|
||||
intdiv(($price->toMinorUnits() * $this->thousandths) + intdiv(self::SCALE, 2), self::SCALE),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\receipt;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use MonetaPhpShop\Money;
|
||||
use MonetaPhpShop\protocol\JsonNumber;
|
||||
|
||||
/**
|
||||
* ×åê 54-ÔÇ çàêàçà: ïîêóïàòåëü è ïîçèöèè, ñóììà ïîçèöèé ðàâíà ñóììå îïëàòû äî êîïåéêè.
|
||||
* Îòäà¸òñÿ â äâóõ ôîðìàõ: `MNT_ATTRIBUTES` XML-îòâåòîâ è îáúåêò `receipt` JSON-îòâåòà Check URL.
|
||||
*/
|
||||
final class Receipt
|
||||
{
|
||||
/** @var ReceiptClient|null */
|
||||
private $client;
|
||||
|
||||
/** @var array<int, ReceiptItem> */
|
||||
private $items;
|
||||
|
||||
/**
|
||||
* @param array<int, ReceiptItem> $items
|
||||
*/
|
||||
public function __construct(?ReceiptClient $client, array $items)
|
||||
{
|
||||
if ($items === []) {
|
||||
throw new InvalidArgumentException('×åê áåç ïîçèöèé.');
|
||||
}
|
||||
|
||||
$this->client = $client;
|
||||
$this->items = array_values($items);
|
||||
}
|
||||
|
||||
public function getClient(): ?ReceiptClient
|
||||
{
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, ReceiptItem>
|
||||
*/
|
||||
public function getItems(): array
|
||||
{
|
||||
return $this->items;
|
||||
}
|
||||
|
||||
public function total(): Money
|
||||
{
|
||||
$total = Money::fromMinorUnits(0);
|
||||
foreach ($this->items as $item) {
|
||||
$total = $total->add($item->total());
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* `MNT_ATTRIBUTES`: `INVENTORY` âñåãäà; `CLIENT`, `CUSTOMER` (email), `PHONE` (áåç `+`) —
|
||||
* åñëè åñòü êîíòàêò; `SNO` — ïðè ôèñêàëèçàöèè, åñëè ñèñòåìà íàëîãîîáëîæåíèÿ âûáðàíà.
|
||||
* `DELIVERY` íå ïåðåäà¸òñÿ: äîñòàâêà — ïîçèöèåé `INVENTORY`.
|
||||
* Áåç ôèñêàëèçàöèè ïîçèöèè óðåçàíû ({@see ReceiptItem::toInventoryPosition()}).
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function toAttributes(bool $fiscalization = true, ?TaxSystem $taxSystem = null): array
|
||||
{
|
||||
$attributes = [
|
||||
'INVENTORY' => JsonNumber::encode(
|
||||
array_map(static function (ReceiptItem $item) use ($fiscalization): array {
|
||||
return $item->toInventoryPosition($fiscalization);
|
||||
}, $this->items),
|
||||
),
|
||||
];
|
||||
|
||||
$client = ($this->client !== null) ? $this->client->toArray(ReceiptClient::KASSA_PHONE_MAX_LENGTH) : [];
|
||||
if ($client !== []) {
|
||||
$attributes['CLIENT'] = JsonNumber::encode([$client]);
|
||||
if (isset($client['email'])) {
|
||||
$attributes['CUSTOMER'] = $client['email'];
|
||||
}
|
||||
if (isset($client['phone'])) {
|
||||
$attributes['PHONE'] = ltrim($client['phone'], '+');
|
||||
}
|
||||
}
|
||||
|
||||
if ($fiscalization && ($taxSystem !== null) && $taxSystem->isPassed()) {
|
||||
$attributes['SNO'] = $taxSystem->getValue();
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Îáúåêò `receipt` JSON-îòâåòà Check URL. Áåç êîíòàêòà — ïóñòîé `client` (`{}`).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toJsonReceipt(): array
|
||||
{
|
||||
$client = $this->client !== null ? $this->client->toArray(ReceiptClient::JSON_PHONE_MAX_LENGTH) : [];
|
||||
|
||||
return [
|
||||
'client' => $client === [] ? new \stdClass() : $client,
|
||||
'items' => array_map(static function (ReceiptItem $item): array {
|
||||
return $item->toCheckItem();
|
||||
}, $this->items),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\receipt;
|
||||
|
||||
use Closure;
|
||||
use InvalidArgumentException;
|
||||
use MonetaPhpShop\Money;
|
||||
use MonetaPhpShop\order\Order;
|
||||
|
||||
/**
|
||||
* ×åê çàêàçà PHPShop: ñòðîêè êîðçèíû, äîñòàâêà îòäåëüíîé ïîçèöèåé, ïîêóïàòåëü.
|
||||
*
|
||||
* Ñóììà ïîçèöèé îáÿçàíà ñîâïàñòü ñ ñóììîé çàêàçà äî êîïåéêè (`cmsspecification.pdf`), à èòîã
|
||||
* PHPShop (`phpshop_orders.sum`) — ýòî òîâàðû ñî ñêèäêîé (êðîìå ïðîìî-öåí), ìèíóñ áîíóñû, ïëþñ
|
||||
* äîñòàâêà öåëûì ÷èñëîì, ñ îêðóãëåíèåì ÿäðà. Ïîýòîìó öåíû ñòðîê ñíà÷àëà ñ÷èòàþòñÿ êàê ó ÿäðà,
|
||||
* à ðàñõîæäåíèå (áîíóñû, îêðóãëåíèÿ, ðó÷íàÿ ïðàâêà çàêàçà) ðàçíîñèòñÿ ïî òîâàðíûì ñòðîêàì
|
||||
* ïðîïîðöèîíàëüíî èõ ñóììå; øòó÷íàÿ ñòðîêà, êîòîðàÿ íå äåëèòñÿ íà êîëè÷åñòâî, ðåæåòñÿ íà äâå
|
||||
* ñ ðàçíèöåé öåí â êîïåéêó, ó âåñîâîé (äðîáíîå êîëè÷åñòâî) ïîäáèðàåòñÿ öåíà çà åäèíèöó. Íå ïîëó÷èëîñü —
|
||||
* îäíà ïîçèöèÿ «Îïëàòà çàêàçà» íà âñþ ñóììó.
|
||||
*/
|
||||
final class ReceiptBuilder
|
||||
{
|
||||
private const DELIVERY_NAME = 'Äîñòàâêà';
|
||||
private const ITEM_FALLBACK_NAME = 'Òîâàð';
|
||||
private const PAYMENT_NAME = 'Îïëàòà çàêàçà ¹';
|
||||
|
||||
/** @var Closure(string): string */
|
||||
private $toUtf8;
|
||||
|
||||
/** @var PaymentMethod */
|
||||
private $paymentMethod;
|
||||
|
||||
/** @var PaymentObject */
|
||||
private $goodsObject;
|
||||
|
||||
/** @var Vat */
|
||||
private $paymentVat;
|
||||
|
||||
/**
|
||||
* @param Closure(string): string $toUtf8 ñòðîêè çàêàçà è ëèòåðàëû ìîäóëÿ (cp1251 ïîñëå ñáîðêè) -> UTF-8
|
||||
* @param PaymentMethod $paymentMethod ñïîñîá ðàñ÷¸òà âñåõ ïîçèöèé — íàñòðîéêà ìîäóëÿ
|
||||
* @param PaymentObject|null $goodsObject ïðåäìåò ðàñ÷¸òà òîâàðîâ — íàñòðîéêà ìîäóëÿ; null — òîâàð.
|
||||
* Ó äîñòàâêè âñåãäà óñëóãà.
|
||||
* @param Vat|null $paymentVat ñòàâêà çàïàñíîé ïîçèöèè «Îïëàòà çàêàçà»: ôèêñèðîâàííàÿ «ÍÄÑ íà òîâàðû»;
|
||||
* null — áåç ÍÄÑ
|
||||
*/
|
||||
public function __construct(
|
||||
Closure $toUtf8,
|
||||
PaymentMethod $paymentMethod,
|
||||
?PaymentObject $goodsObject = null,
|
||||
?Vat $paymentVat = null
|
||||
) {
|
||||
$this->toUtf8 = $toUtf8;
|
||||
$this->paymentMethod = $paymentMethod;
|
||||
$this->goodsObject = $goodsObject ?? PaymentObject::commodity();
|
||||
$this->paymentVat = $paymentVat ?? Vat::none();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Vat $goodsVat ñèñòåìíàÿ ñòàâêà PHPShop äëÿ òîâàðîâ
|
||||
* @param Vat $deliveryVat ñòàâêà ñïîñîáà äîñòàâêè
|
||||
* @param string $fallbackEmail íàñòðîéêà «Email äëÿ ÷åêîâ», ìîæåò áûòü ïóñòîé
|
||||
*/
|
||||
public function build(Order $order, Vat $goodsVat, Vat $deliveryVat, string $fallbackEmail): Receipt
|
||||
{
|
||||
$client = ReceiptClient::fromContacts(
|
||||
$order->getEmail(),
|
||||
$order->getPhone(),
|
||||
$this->utf8($order->getCustomerName()),
|
||||
$fallbackEmail,
|
||||
);
|
||||
|
||||
$amount = $order->getAmount()->toMinorUnits();
|
||||
|
||||
$items = $this->items($this->balance($this->lines($order, $goodsVat, $deliveryVat), $amount));
|
||||
if ($items === null) {
|
||||
$items = [
|
||||
new ReceiptItem(
|
||||
$this->utf8(self::PAYMENT_NAME . $order->getUid()),
|
||||
$order->getAmount(),
|
||||
Quantity::pieces(1),
|
||||
$this->paymentVat,
|
||||
$this->goodsObject,
|
||||
$this->paymentMethod,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return new Receipt($client, $items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ñòðîêè ÷åêà ïî ïðàâèëàì ÿäðà (`PHPShopDone::send_to_order()`): ñêèäêà ïîêóïàòåëÿ — êî âñåì
|
||||
* òîâàðàì áåç ïðîìî-öåíû, äîñòàâêà èç êîðçèíû çàêàçà. Êîëè÷åñòâî — êàê â êîðçèíå, â òîì ÷èñëå
|
||||
* äðîáíîå (âåñîâîé òîâàð), åäèíèöà èçìåðåíèÿ — èç `ed_izm` òîâàðà.
|
||||
*
|
||||
* @return array<int, array{name: string, unit: int, quantity: Quantity, measure: string, vat: Vat, delivery: bool}>
|
||||
*/
|
||||
private function lines(Order $order, Vat $goodsVat, Vat $deliveryVat): array
|
||||
{
|
||||
$cart = $order->getCart();
|
||||
$lines = [];
|
||||
$products = isset($cart['cart']) && is_array($cart['cart']) ? $cart['cart'] : [];
|
||||
$discount = $order->getDiscountPercent();
|
||||
foreach ($products as $product) {
|
||||
if (!is_array($product)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$price = (float) ($product['price'] ?? 0);
|
||||
if (($discount > 0) && empty($product['promo_price'])) {
|
||||
$price -= $price * $discount / 100;
|
||||
}
|
||||
|
||||
$quantity = Quantity::fromCart($product['num'] ?? null);
|
||||
if (($quantity === null) || ($price <= 0)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lines[] = [
|
||||
'name' => $this->name((string) ($product['name'] ?? '')),
|
||||
'unit' => Money::fromFloat($price)->toMinorUnits(),
|
||||
'quantity' => $quantity,
|
||||
'measure' => Measure::fromUnit($this->utf8((string) ($product['ed_izm'] ?? '')), $this->toUtf8),
|
||||
'vat' => $goodsVat,
|
||||
'delivery' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$delivery = (float) ($cart['dostavka'] ?? 0);
|
||||
if ($delivery > 0) {
|
||||
$lines[] = [
|
||||
'name' => $this->utf8(self::DELIVERY_NAME),
|
||||
'unit' => Money::fromFloat($delivery)->toMinorUnits(),
|
||||
'quantity' => Quantity::pieces(1),
|
||||
'measure' => Measure::UNIT,
|
||||
'vat' => $deliveryVat,
|
||||
'delivery' => true,
|
||||
];
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ïîäãîíêà ñóììû ñòðîê ïîä ñóììó çàêàçà â êîïåéêàõ. Ñíà÷àëà ðàñõîæäåíèå áåðóò øòó÷íûå òîâàðû;
|
||||
* åñëè íå óêëàäûâàåòñÿ — âñå øòó÷íûå ñòðîêè âìåñòå ñ äîñòàâêîé; åñëè øòó÷íûõ ñòðîê íåò —
|
||||
* è âåñîâûå, ïîäáîðîì öåíû çà åäèíèöó. Ñòðîêó âåñîâîãî òîâàðà íà äâå íå ðàçðåçàòü, ïîýòîìó
|
||||
* å¸ òðîãàþò ïîñëåäíåé. Ïóñòîé ðåçóëüòàò — íå âûøëî.
|
||||
*
|
||||
* @param array<int, array{name: string, unit: int, quantity: Quantity, measure: string, vat: Vat, delivery: bool}> $lines
|
||||
*
|
||||
* @return array<int, array{name: string, unit: int, quantity: Quantity, measure: string, vat: Vat, delivery: bool}>
|
||||
*/
|
||||
private function balance(array $lines, int $target): array
|
||||
{
|
||||
if (($target <= 0) || ($lines === [])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$totals = [];
|
||||
foreach ($lines as $index => $line) {
|
||||
$totals[$index] = self::lineTotal($line);
|
||||
}
|
||||
if (array_sum($totals) === $target) {
|
||||
return $lines;
|
||||
}
|
||||
|
||||
$stages = [
|
||||
static function (array $line): bool {
|
||||
return !$line['delivery'] && $line['quantity']->isWhole();
|
||||
},
|
||||
static function (array $line): bool {
|
||||
return $line['quantity']->isWhole();
|
||||
},
|
||||
static function (array $line): bool {
|
||||
return true;
|
||||
},
|
||||
];
|
||||
foreach ($stages as $isAdjustable) {
|
||||
$adjustable = [];
|
||||
$fixed = 0;
|
||||
foreach ($lines as $index => $line) {
|
||||
if ($isAdjustable($line)) {
|
||||
$adjustable[] = $index;
|
||||
} else {
|
||||
$fixed += $totals[$index];
|
||||
}
|
||||
}
|
||||
if (($adjustable === []) || ($target - $fixed <= 0)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $this->distribute($lines, $totals, $adjustable, $target - $fixed);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ðàçíîñ ñóììû `$goal` ïî ñòðîêàì `$adjustable` ïðîïîðöèîíàëüíî èõ ñóììå.
|
||||
*
|
||||
* @param array<int, array{name: string, unit: int, quantity: Quantity, measure: string, vat: Vat, delivery: bool}> $lines
|
||||
* @param array<int, int> $totals ñóììû ñòðîê â êîïåéêàõ
|
||||
* @param array<int, int> $adjustable èíäåêñû ñòðîê, êîòîðûå ìîæíî ìåíÿòü
|
||||
*
|
||||
* @return array<int, array{name: string, unit: int, quantity: Quantity, measure: string, vat: Vat, delivery: bool}>
|
||||
*/
|
||||
private function distribute(array $lines, array $totals, array $adjustable, int $goal): array
|
||||
{
|
||||
$base = 0;
|
||||
foreach ($adjustable as $index) {
|
||||
$base += $totals[$index];
|
||||
}
|
||||
if ($base <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Íàèáîëüøèå îñòàòêè: äîëè îêðóãëÿþòñÿ âíèç, íåäîñòàþùèå êîïåéêè — ñòðîêàì ñ áîëüøèì îñòàòêîì.
|
||||
$shares = [];
|
||||
$remainders = [];
|
||||
foreach ($adjustable as $index) {
|
||||
if ($totals[$index] > intdiv(PHP_INT_MAX, $goal)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$product = $totals[$index] * $goal;
|
||||
$shares[$index] = intdiv($product, $base);
|
||||
$remainders[$index] = $product % $base;
|
||||
}
|
||||
arsort($remainders);
|
||||
$left = $goal - array_sum($shares);
|
||||
foreach (array_keys($remainders) as $index) {
|
||||
if ($left <= 0) {
|
||||
break;
|
||||
}
|
||||
$shares[$index]++;
|
||||
$left--;
|
||||
}
|
||||
|
||||
$balanced = [];
|
||||
foreach ($lines as $index => $line) {
|
||||
if (!isset($shares[$index])) {
|
||||
$balanced[] = $line;
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = $line['quantity']->isWhole()
|
||||
? $this->split($line, $shares[$index])
|
||||
: $this->reprice($line, $shares[$index]);
|
||||
if ($parts === []) {
|
||||
return [];
|
||||
}
|
||||
foreach ($parts as $part) {
|
||||
$balanced[] = $part;
|
||||
}
|
||||
}
|
||||
|
||||
return $balanced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Øòó÷íàÿ ñòðîêà ñ íîâîé ñóììîé: öåëàÿ öåíà çà øòóêó, îñòàòîê êîïååê — âòîðîé ñòðîêîé òîé æå
|
||||
* ïîçèöèè ñ öåíîé íà êîïåéêó âûøå. Íóëåâûå ÷àñòè îòáðàñûâàþòñÿ.
|
||||
*
|
||||
* @param array{name: string, unit: int, quantity: Quantity, measure: string, vat: Vat, delivery: bool} $line
|
||||
*
|
||||
* @return array<int, array{name: string, unit: int, quantity: Quantity, measure: string, vat: Vat, delivery: bool}>
|
||||
*/
|
||||
private function split(array $line, int $total): array
|
||||
{
|
||||
$pieces = $line['quantity']->getPieces();
|
||||
$unit = intdiv($total, $pieces);
|
||||
$rest = $total - ($unit * $pieces);
|
||||
|
||||
$parts = [];
|
||||
if ($rest > 0) {
|
||||
$parts[] = ['unit' => $unit + 1, 'quantity' => Quantity::pieces($rest)] + $line;
|
||||
}
|
||||
if (($unit > 0) && ($pieces - $rest > 0)) {
|
||||
$parts[] = ['unit' => $unit, 'quantity' => Quantity::pieces($pieces - $rest)] + $line;
|
||||
}
|
||||
|
||||
return $parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Âåñîâàÿ ñòðîêà ñ íîâîé ñóììîé: öåíà çà åäèíèöó, ïðè êîòîðîé öåíà x êîëè÷åñòâî äî êîïåéêè
|
||||
* äà¸ò ðîâíî `$total`. Òàêîé öåíû ìîæåò íå áûòü (íà 2.5 êã øàã ñóììû — 2–3 êîïåéêè) — òîãäà ïóñòî.
|
||||
*
|
||||
* @param array{name: string, unit: int, quantity: Quantity, measure: string, vat: Vat, delivery: bool} $line
|
||||
*
|
||||
* @return array<int, array{name: string, unit: int, quantity: Quantity, measure: string, vat: Vat, delivery: bool}>
|
||||
*/
|
||||
private function reprice(array $line, int $total): array
|
||||
{
|
||||
$thousandths = $line['quantity']->getThousandths();
|
||||
$estimate = intdiv(($total * 1000) + intdiv($thousandths, 2), $thousandths);
|
||||
foreach ([$estimate, $estimate - 1, $estimate + 1] as $unit) {
|
||||
if ($unit > 0 && self::lineTotal(['unit' => $unit] + $line) === $total) {
|
||||
return [['unit' => $unit] + $line];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{unit: int, quantity: Quantity} $line
|
||||
*/
|
||||
private static function lineTotal(array $line): int
|
||||
{
|
||||
return $line['quantity']->totalOf(Money::fromMinorUnits($line['unit']))->toMinorUnits();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{name: string, unit: int, quantity: Quantity, measure: string, vat: Vat, delivery: bool}> $lines
|
||||
*
|
||||
* @return array<int, ReceiptItem>|null null — ñòðîê íåò èëè ñòðîêà íå ïðîõîäèò îãðàíè÷åíèÿ êàññû
|
||||
*/
|
||||
private function items(array $lines): ?array
|
||||
{
|
||||
if ($lines === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$items = [];
|
||||
try {
|
||||
foreach ($lines as $line) {
|
||||
$items[] = new ReceiptItem(
|
||||
$line['name'],
|
||||
Money::fromMinorUnits($line['unit']),
|
||||
$line['quantity'],
|
||||
$line['vat'],
|
||||
$line['delivery'] ? PaymentObject::service() : $this->goodsObject,
|
||||
$this->paymentMethod,
|
||||
$line['measure'],
|
||||
);
|
||||
}
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function name(string $raw): string
|
||||
{
|
||||
$name = ReceiptText::clean($this->utf8($raw), ReceiptItem::NAME_MAX_LENGTH);
|
||||
|
||||
return ($name !== '') ? $name : $this->utf8(self::ITEM_FALLBACK_NAME);
|
||||
}
|
||||
|
||||
private function utf8(string $value): string
|
||||
{
|
||||
return ($this->toUtf8)($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\receipt;
|
||||
|
||||
use MonetaPhpShop\PhoneNumber;
|
||||
|
||||
/**
|
||||
* Ïîêóïàòåëü â ÷åêå — îáúåêò `client` (`kassaspecification.pdf`): íóæåí õîòÿ áû îäèí èç
|
||||
* `email`/`phone`, ÔÈÎ — òîëüêî âìåñòå ñ íèìè. Ñòðîêè â UTF-8.
|
||||
*/
|
||||
final class ReceiptClient
|
||||
{
|
||||
public const NAME_MAX_LENGTH = 256;
|
||||
public const EMAIL_MAX_LENGTH = 64;
|
||||
|
||||
/** `phone_number` JSON-ñõåìû Check URL (`cmsspecification.pdf`). */
|
||||
public const JSON_PHONE_MAX_LENGTH = 13;
|
||||
|
||||
/** `kassaspecification.pdf` — äëÿ `CLIENT` â `MNT_ATTRIBUTES`. */
|
||||
public const KASSA_PHONE_MAX_LENGTH = 19;
|
||||
|
||||
/** @var string */
|
||||
private $name;
|
||||
|
||||
/** @var string */
|
||||
private $email;
|
||||
|
||||
/** @var string */
|
||||
private $phone;
|
||||
|
||||
private function __construct(string $name, string $email, string $phone)
|
||||
{
|
||||
$this->name = $name;
|
||||
$this->email = $email;
|
||||
$this->phone = $phone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ïîêóïàòåëü èç äàííûõ çàêàçà èëè null, åñëè ÷åêó íåêóäà óéòè. «Email äëÿ ÷åêîâ» ìåð÷àíòà
|
||||
* ïîäñòàâëÿåòñÿ, òîëüêî êîãäà ó ïîêóïàòåëÿ íåò íè email, íè òåëåôîíà; ÔÈÎ îñòà¸òñÿ.
|
||||
*/
|
||||
public static function fromContacts(string $email, string $phone, string $name, string $fallbackEmail): ?self
|
||||
{
|
||||
$phone = PhoneNumber::normalize($phone) ?? '';
|
||||
if (strlen($phone) > self::KASSA_PHONE_MAX_LENGTH) {
|
||||
$phone = '';
|
||||
}
|
||||
|
||||
$email = self::email($email);
|
||||
if (($email === '') && ($phone === '')) {
|
||||
$email = self::email($fallbackEmail);
|
||||
}
|
||||
if (($email === '') && ($phone === '')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new self(ReceiptText::clean($name, self::NAME_MAX_LENGTH), $email, $phone);
|
||||
}
|
||||
|
||||
public function getEmail(): string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function getPhone(): string
|
||||
{
|
||||
return $this->phone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Îáúåêò `client`; ïóñòûå êëþ÷è íå ïåðåäàþòñÿ.
|
||||
*
|
||||
* @param int $phoneMaxLength {@see self::JSON_PHONE_MAX_LENGTH} èëè {@see self::KASSA_PHONE_MAX_LENGTH}
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function toArray(int $phoneMaxLength): array
|
||||
{
|
||||
$phone = strlen($this->phone) <= $phoneMaxLength ? $this->phone : '';
|
||||
if (($this->email === '') && ($phone === '')) {
|
||||
// Òåëåôîí äëèííåå ñõåìû, à email íåò — áåç êîíòàêòà ÔÈÎ êàññà íå ïðèìåò.
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_filter(
|
||||
['name' => $this->name, 'email' => $this->email, 'phone' => $phone],
|
||||
static function (string $value): bool {
|
||||
return ($value !== '');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private static function email(string $email): string
|
||||
{
|
||||
$email = trim($email);
|
||||
if (
|
||||
strlen($email) > self::EMAIL_MAX_LENGTH
|
||||
|| filter_var($email, FILTER_VALIDATE_EMAIL) === false
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $email;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\receipt;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use MonetaPhpShop\Money;
|
||||
use MonetaPhpShop\protocol\JsonNumber;
|
||||
|
||||
/**
|
||||
* Ïîçèöèÿ ÷åêà: `inventPositions` (`kassaspecification.pdf`) è ýëåìåíò `items` JSON Check URL
|
||||
* (`cmsspecification.pdf`). Êîëè÷åñòâî — äî òð¸õ çíàêîâ ïîñëå òî÷êè ({@see Quantity});
|
||||
* åäèíèöà èçìåðåíèÿ (`measure`) — èç ïåðå÷íÿ êàññû ({@see Measure}).
|
||||
* Ïðèçíàê ïðåäìåòà ðàñ÷¸òà (`po` / `paymentObject`) — ÿâíî: ó òîâàðîâ èç íàñòðîéêè, ó äîñòàâêè
|
||||
* `service` (áåç íåãî êàññà ñòàâèò `commodity` âñåì, è «Äîñòàâêà» óõîäèëà áû òîâàðîì). Ñïîñîá
|
||||
* ðàñ÷¸òà (`pm` / `paymentMethod`) — ÿâíî: `kassaspecification.pdf` òðåáóåò ïîëå.
|
||||
* Çíà÷åíèÿ — íàñòðîéêè «Ïðèçíàê ïðåäìåòà ðàñ÷¸òà» ({@see PaymentObject}) è
|
||||
* «Ïðèçíàê ñïîñîáà ðàñ÷¸òà» ({@see PaymentMethod}). Îáà ïðèçíàêà — òîëüêî â ÷åêå äëÿ êàññû:
|
||||
* áåç ôèñêàëèçàöèè `INVENTORY` èõ íå íåñ¸ò ({@see self::toInventoryPosition()}).
|
||||
*/
|
||||
final class ReceiptItem
|
||||
{
|
||||
public const NAME_MAX_LENGTH = 128;
|
||||
|
||||
/** Ïîòîëîê êàññû äëÿ öåíû è äëÿ `price * quantity`. */
|
||||
public const PRICE_MAX = '42949672.95';
|
||||
|
||||
/** @var string */
|
||||
private $name;
|
||||
|
||||
/** @var Money */
|
||||
private $price;
|
||||
|
||||
/** @var Quantity */
|
||||
private $quantity;
|
||||
|
||||
/** @var string */
|
||||
private $measure;
|
||||
|
||||
/** @var Vat */
|
||||
private $vat;
|
||||
|
||||
/** @var PaymentObject */
|
||||
private $paymentObject;
|
||||
|
||||
/** @var PaymentMethod */
|
||||
private $paymentMethod;
|
||||
|
||||
/**
|
||||
* @param string $name â UTF-8; ïóñòîå ïîñëå ÷èñòêè êàññà íå ïðèìåò — çàïàñíîå íàçâàíèå
|
||||
* ïîäñòàâëÿåò {@see ReceiptBuilder} (ëèòåðàëû ìîäóëÿ ïîñëå ñáîðêè â cp1251)
|
||||
* @param PaymentObject|null $paymentObject null — òîâàð
|
||||
* @param PaymentMethod|null $paymentMethod null — ïîëíûé ðàñ÷¸ò
|
||||
* @param string $measure êîä åäèíèöû èçìåðåíèÿ êàññû ({@see Measure})
|
||||
*/
|
||||
public function __construct(
|
||||
string $name,
|
||||
Money $price,
|
||||
Quantity $quantity,
|
||||
Vat $vat,
|
||||
?PaymentObject $paymentObject = null,
|
||||
?PaymentMethod $paymentMethod = null,
|
||||
string $measure = Measure::UNIT
|
||||
) {
|
||||
$this->name = ReceiptText::clean($name, self::NAME_MAX_LENGTH);
|
||||
if ($this->name === '') {
|
||||
throw new InvalidArgumentException('Ïóñòîå íàçâàíèå ïîçèöèè ÷åêà.');
|
||||
}
|
||||
$this->price = $price;
|
||||
$this->quantity = $quantity;
|
||||
$this->measure = $measure;
|
||||
$this->vat = $vat;
|
||||
$this->paymentObject = $paymentObject ?? PaymentObject::commodity();
|
||||
$this->paymentMethod = $paymentMethod ?? PaymentMethod::fullPayment();
|
||||
|
||||
$max = Money::fromDecimal(self::PRICE_MAX)->toMinorUnits();
|
||||
if (($price->toMinorUnits() > $max) || ($this->total()->toMinorUnits() > $max)) {
|
||||
throw new InvalidArgumentException('Ñóììà ïîçèöèè ÷åêà âûøå ïîòîëêà êàññû.');
|
||||
}
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getPrice(): Money
|
||||
{
|
||||
return $this->price;
|
||||
}
|
||||
|
||||
public function getQuantity(): Quantity
|
||||
{
|
||||
return $this->quantity;
|
||||
}
|
||||
|
||||
public function getMeasure(): string
|
||||
{
|
||||
return $this->measure;
|
||||
}
|
||||
|
||||
public function getVat(): Vat
|
||||
{
|
||||
return $this->vat;
|
||||
}
|
||||
|
||||
public function getPaymentObject(): PaymentObject
|
||||
{
|
||||
return $this->paymentObject;
|
||||
}
|
||||
|
||||
public function getPaymentMethod(): PaymentMethod
|
||||
{
|
||||
return $this->paymentMethod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ñóììà ïîçèöèè òàê, êàê å¸ ïîñ÷èòàåò êàññà: öåíà x êîëè÷åñòâî äî êîïåéêè.
|
||||
*/
|
||||
public function total(): Money
|
||||
{
|
||||
return $this->quantity->totalOf($this->price);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ýëåìåíò `items` JSON-îòâåòà Check URL; öåíà — ìåòêîé {@see JsonNumber}.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toCheckItem(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->name,
|
||||
'price' => JsonNumber::placeholder($this->price),
|
||||
'quantity' => JsonNumber::quantity($this->quantity),
|
||||
'measure' => $this->measure,
|
||||
'paymentMethod' => $this->paymentMethod->getValue(),
|
||||
'paymentObject' => $this->paymentObject->getValue(),
|
||||
'vat' => $this->vat->getCode(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Îáúåêò `inventPositions` äëÿ àòðèáóòà `INVENTORY`. Áåç ôèñêàëèçàöèè — òîëüêî òî, ÷òî
|
||||
* MONETA.RU ñîõðàíèò â îïåðàöèè: áåç `vatTag`, `pm`, `po`.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toInventoryPosition(bool $fiscalization = true): array
|
||||
{
|
||||
$position = [
|
||||
'name' => $this->name,
|
||||
'price' => JsonNumber::placeholder($this->price),
|
||||
'quantity' => JsonNumber::quantity($this->quantity),
|
||||
'measure' => $this->measure,
|
||||
];
|
||||
if (!$fiscalization) {
|
||||
return $position;
|
||||
}
|
||||
|
||||
return $position + [
|
||||
'vatTag' => $this->vat->getTag(),
|
||||
'pm' => $this->paymentMethod->getValue(),
|
||||
'po' => $this->paymentObject->getValue(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\receipt;
|
||||
|
||||
/**
|
||||
* Òåêñòîâûå ïîëÿ ÷åêà (íàçâàíèÿ ïîçèöèé, ÔÈÎ), ñòðîêè â UTF-8: «çíà÷åíèÿ íå äîëæíû ñîäåðæàòü
|
||||
* êàâû÷åê, çíàêîâ &, $, #, îáðàòíûõ è ïðÿìûõ ñëýøåé» (`cmsspecification.pdf`); êîíòðàêò ìîäóëåé
|
||||
* äîáàâëÿåò `%` è `;`. Îñòàâëÿåì áåëûé ñïèñîê: áóêâû, öèôðû, ïðîáåë è `.,()_¹+-`.
|
||||
*
|
||||
* Òîëüêî ASCII â øàáëîíàõ (`¹` — `\x{2116}`): ïîñëå ñáîðêè ôàéë â cp1251, à øàáëîí ñ `/u`
|
||||
* îáÿçàí áûòü UTF-8. Áåç `mbstring` — ÿäðî PHPShop åãî íå òðåáóåò.
|
||||
*/
|
||||
final class ReceiptText
|
||||
{
|
||||
private const ELLIPSIS = '...';
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public static function clean(string $value, int $maxLength): string
|
||||
{
|
||||
$value = self::sanitize($value);
|
||||
if ((int) preg_match_all('/./su', $value) <= $maxLength) {
|
||||
return $value;
|
||||
}
|
||||
preg_match('/\A.{0,' . ($maxLength - strlen(self::ELLIPSIS)) . '}/su', $value, $matches);
|
||||
|
||||
return rtrim($matches[0] ?? '') . self::ELLIPSIS;
|
||||
}
|
||||
|
||||
private static function sanitize(string $value): string
|
||||
{
|
||||
$decoded = html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$allowed = preg_replace('/[^\p{L}\p{N}\s.,()_\x{2116}+\-]/u', ' ', $decoded) ?? '';
|
||||
|
||||
return trim(preg_replace('/\s+/u', ' ', $allowed) ?? '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\receipt;
|
||||
|
||||
/**
|
||||
* Система налогообложения — атрибут `SNO` ответа Pay URL (`kassaspecification.pdf`, необязательный).
|
||||
* Передаётся только при фискализации и только если мерчант её выбрал; по умолчанию
|
||||
* не передаётся — касса берёт систему налогообложения из своих настроек. В JSON Check URL поля нет
|
||||
* (схема `cmsspecification.pdf`). Значения хранятся в настройках модуля — не переименовывать.
|
||||
*/
|
||||
final class TaxSystem
|
||||
{
|
||||
/** Не передавать: система налогообложения из настроек кассы. */
|
||||
public const KASSA = '';
|
||||
|
||||
/** Значение -> подпись в настройках, коды — по `kassaspecification.pdf`. */
|
||||
public const LABELS = [
|
||||
self::KASSA => 'Не передавать (из настроек кассы)',
|
||||
'1' => 'Общая (ОСНО)',
|
||||
'2' => 'Упрощённая (УСН) доход',
|
||||
'3' => 'Упрощённая (УСН) доход минус расход',
|
||||
'4' => 'Единый налог на вменённый доход (ЕНВД)',
|
||||
'5' => 'Единый сельскохозяйственный налог (ЕСХН)',
|
||||
'6' => 'Патентная (ПСН)',
|
||||
];
|
||||
|
||||
/** @var string */
|
||||
private $value;
|
||||
|
||||
private function __construct(string $value)
|
||||
{
|
||||
$this->value = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Значение из настроек; неизвестное или пустое — не передавать.
|
||||
*/
|
||||
public static function fromSetting(?string $value): self
|
||||
{
|
||||
$value = (string) $value;
|
||||
|
||||
return new self(isset(self::LABELS[$value]) ? $value : self::KASSA);
|
||||
}
|
||||
|
||||
public static function kassa(): self
|
||||
{
|
||||
return new self(self::KASSA);
|
||||
}
|
||||
|
||||
public function getValue(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function isPassed(): bool
|
||||
{
|
||||
return ($this->value !== self::KASSA);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\receipt;
|
||||
|
||||
/**
|
||||
* Ñòàâêà ÍÄÑ ïîçèöèè ÷åêà: êîä `vat` äëÿ JSON Check URL (`cmsspecification.pdf`)
|
||||
* è òåã `vatTag` äëÿ `INVENTORY` (`kassaspecification.pdf`).
|
||||
*
|
||||
* Èç íàñòðîåê PHPShop ìîäóëü áåð¸ò òîëüêî ïðÿìûå ñòàâêè ïî ïðîöåíòó. Ðàñ÷¸òíûå (`vat120`, `1106`…)
|
||||
* ïðèâÿçàíû ê àâàíñàì è ïðåäîïëàòå (`pm` `advance`/`prepayment`) — èõ ìîäóëü ñàì íå âûáèðàåò,
|
||||
* òîëüêî ìåð÷àíò íàñòðîéêîé ({@see VatSetting}).
|
||||
* Value-îáúåêò âìåñòî enum — PHP 7.4.
|
||||
*/
|
||||
final class Vat
|
||||
{
|
||||
public const NONE = 'none';
|
||||
|
||||
/** Ïðîöåíò ñòàâêè -> êîä `vat`. */
|
||||
private const BY_PERCENT = [
|
||||
0 => 'vat0',
|
||||
5 => 'vat5',
|
||||
7 => 'vat7',
|
||||
10 => 'vat10',
|
||||
20 => 'vat20',
|
||||
22 => 'vat22',
|
||||
];
|
||||
|
||||
/**
|
||||
* Êîä `vat` (`cmsspecification.pdf`) -> `vatTag` (`kassaspecification.pdf`).
|
||||
*/
|
||||
private const TAGS = [
|
||||
self::NONE => '1105',
|
||||
'vat0' => '1104',
|
||||
'vat5' => '1108',
|
||||
'vat7' => '1109',
|
||||
'vat10' => '1103',
|
||||
'vat20' => '1102',
|
||||
'vat22' => '1113',
|
||||
'vat105' => '1110',
|
||||
'vat107' => '1111',
|
||||
'vat110' => '1107',
|
||||
'vat120' => '1106',
|
||||
'vat122' => '1114',
|
||||
];
|
||||
|
||||
/** @var string */
|
||||
private $code;
|
||||
|
||||
private function __construct(string $code)
|
||||
{
|
||||
$this->code = $code;
|
||||
}
|
||||
|
||||
public static function none(): self
|
||||
{
|
||||
return new self(self::NONE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ñòàâêà ïî êîäó `vat`; íåèçâåñòíûé êîä — null.
|
||||
*/
|
||||
public static function fromCode(string $code): ?self
|
||||
{
|
||||
return isset(self::TAGS[$code]) ? new self($code) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ñòàâêà èç íàñòðîåê PHPShop — ñâîáîäíîå ÷èñëî ïðîöåíòîâ (`nds`, `ofd_nds` äîñòàâêè).
|
||||
* Ïóñòî èëè ñòàâêè íåò â ïåðå÷íå êàññû — «áåç ÍÄÑ»: íåèçâåñòíóþ ñòàâêó íå óãàäûâàåì.
|
||||
*/
|
||||
public static function fromPercent(string $percent): self
|
||||
{
|
||||
return self::tryFromPercent($percent) ?? self::none();
|
||||
}
|
||||
|
||||
/**
|
||||
* Êàê {@see self::fromPercent()}, íî ïóñòîå èëè íåèçâåñòíîå çíà÷åíèå — null: âûçûâàþùèé
|
||||
* ïðåäóïðåæäàåò ìåð÷àíòà, ÷òî ïîçèöèÿ óéä¸ò â ÷åê «áåç ÍÄÑ».
|
||||
*/
|
||||
public static function tryFromPercent(string $percent): ?self
|
||||
{
|
||||
$percent = trim($percent);
|
||||
if (preg_match('/\A[0-9]{1,3}(?:[.,]0+)?\z/', $percent) !== 1) {
|
||||
return null;
|
||||
}
|
||||
$value = (int) $percent;
|
||||
|
||||
return isset(self::BY_PERCENT[$value]) ? new self(self::BY_PERCENT[$value]) : null;
|
||||
}
|
||||
|
||||
public function getCode(): string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
public function getTag(): string
|
||||
{
|
||||
return self::TAGS[$this->code];
|
||||
}
|
||||
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return ($this->code === $other->code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\receipt;
|
||||
|
||||
/**
|
||||
* Ñòàâêè ÍÄÑ èç íàñòðîåê PHPShop, êîòîðûõ íåò â ïåðå÷íå êàññû: ïîçèöèè ñ íèìè óõîäÿò â ÷åê
|
||||
* «áåç ÍÄÑ» ({@see Vat::fromPercent()}) — ìîäóëü ñòàâêó íå óãàäûâàåò, íî ìåð÷àíò äîëæåí îá ýòîì
|
||||
* çíàòü. Èñòî÷íèê ïðåäóïðåæäåíèÿ â íàñòðîéêàõ ìîäóëÿ.
|
||||
*
|
||||
* Ïðîâåðÿåòñÿ òîëüêî òî, ÷òî ìîäóëü äåéñòâèòåëüíî áåð¸ò èç PHPShop: ïðè ôèñêàëèçàöèè,
|
||||
* âêëþ÷¸ííîì ÍÄÑ ìàãàçèíà è íàñòðîéêå «èç PHPShop» ó òîâàðîâ èëè äîñòàâêè.
|
||||
*/
|
||||
final class VatRateWarnings
|
||||
{
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* @param string $systemPercent ñèñòåìíàÿ ñòàâêà PHPShop (`nds`)
|
||||
* @param array<int, array{0: string, 1: string}> $deliveries ñïîñîáû äîñòàâêè: [íàçâàíèå, «Çíà÷åíèå ÍÄÑ»
|
||||
* (`ofd_nds`, ïóñòî — ñèñòåìíàÿ ñòàâêà)]
|
||||
*
|
||||
* @return array<int, string> íåðàñïîçíàííûå ñòàâêè: `ñèñòåìíàÿ ñòàâêà PHPShop (òîâàðû): 23`
|
||||
*/
|
||||
public static function find(
|
||||
bool $fiscalization,
|
||||
bool $vatEnabled,
|
||||
VatSetting $goodsVat,
|
||||
VatSetting $deliveryVat,
|
||||
string $systemPercent,
|
||||
array $deliveries
|
||||
): array {
|
||||
if (!$fiscalization || !$vatEnabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$warnings = [];
|
||||
if ($goodsVat->getFixed() === null && Vat::tryFromPercent($systemPercent) === null) {
|
||||
$warnings[] = 'ñèñòåìíàÿ ñòàâêà PHPShop (òîâàðû): ' . self::shown($systemPercent);
|
||||
}
|
||||
if ($deliveryVat->getFixed() === null) {
|
||||
foreach ($deliveries as [$name, $percent]) {
|
||||
$percent = (trim($percent) !== '') ? $percent : $systemPercent;
|
||||
if (Vat::tryFromPercent($percent) === null) {
|
||||
$warnings[] = 'äîñòàâêà «' . $name . '»: ' . self::shown($percent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Çíà÷åíèå äëÿ ñîîáùåíèÿ: ïóñòîå — ñëîâîì, èíà÷å êàê ââ¸ë ìåð÷àíò.
|
||||
*/
|
||||
public static function shown(string $percent): string
|
||||
{
|
||||
$percent = trim($percent);
|
||||
|
||||
return ($percent === '') ? 'íå çàäàíà' : $percent . '%';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MonetaPhpShop\receipt;
|
||||
|
||||
/**
|
||||
* Íàñòðîéêè «ÍÄÑ íà òîâàðû» è «ÍÄÑ íà äîñòàâêó»: ñòàâêà èç PHPShop
|
||||
* (ïî óìîë÷àíèþ) èëè îäíà ôèêñèðîâàííàÿ ñòàâêà íà âñå ïîçèöèè, âêëþ÷àÿ ðàñ÷¸òíûå —
|
||||
* èõ ïðè àâàíñå è ïðåäîïëàòå âûáèðàåò ìåð÷àíò, ìîäóëü ñàì íå ïîäñòàâëÿåò.
|
||||
* Çíà÷åíèÿ õðàíÿòñÿ â íàñòðîéêàõ ìîäóëÿ — íå ïåðåèìåíîâûâàòü.
|
||||
*/
|
||||
final class VatSetting
|
||||
{
|
||||
/** Ñòàâêà èç PHPShop: ñèñòåìíàÿ ó òîâàðîâ, ñïîñîáà äîñòàâêè ó äîñòàâêè. */
|
||||
public const PLATFORM = 'platform';
|
||||
|
||||
/** Ôèêñèðîâàííûå ñòàâêè: êîä `vat` -> ïîäïèñü â íàñòðîéêàõ. */
|
||||
public const FIXED_LABELS = [
|
||||
Vat::NONE => 'Áåç ÍÄÑ',
|
||||
'vat0' => 'ÍÄÑ 0%',
|
||||
'vat5' => 'ÍÄÑ 5%',
|
||||
'vat7' => 'ÍÄÑ 7%',
|
||||
'vat10' => 'ÍÄÑ 10%',
|
||||
'vat20' => 'ÍÄÑ 20%',
|
||||
'vat22' => 'ÍÄÑ 22%',
|
||||
'vat105' => 'ÍÄÑ 5% (ðàñ÷¸òíàÿ ñòàâêà 5/105)',
|
||||
'vat107' => 'ÍÄÑ 7% (ðàñ÷¸òíàÿ ñòàâêà 7/107)',
|
||||
'vat110' => 'ÍÄÑ 10% (ðàñ÷¸òíàÿ ñòàâêà 10/110)',
|
||||
'vat120' => 'ÍÄÑ 20% (ðàñ÷¸òíàÿ ñòàâêà 20/120)',
|
||||
'vat122' => 'ÍÄÑ 22% (ðàñ÷¸òíàÿ ñòàâêà 22/122)',
|
||||
];
|
||||
|
||||
/** @var Vat|null */
|
||||
private $fixed;
|
||||
|
||||
private function __construct(?Vat $fixed)
|
||||
{
|
||||
$this->fixed = $fixed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Çíà÷åíèå èç íàñòðîåê; ïóñòîå, {@see self::PLATFORM} èëè íåèçâåñòíîå — ñòàâêà èç PHPShop.
|
||||
*/
|
||||
public static function fromSetting(?string $value): self
|
||||
{
|
||||
$value = (string) $value;
|
||||
|
||||
return new self(isset(self::FIXED_LABELS[$value]) ? Vat::fromCode($value) : null);
|
||||
}
|
||||
|
||||
public static function platform(): self
|
||||
{
|
||||
return new self(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ôèêñèðîâàííàÿ ñòàâêà èëè null — áðàòü èç PHPShop.
|
||||
*/
|
||||
public function getFixed(): ?Vat
|
||||
{
|
||||
return $this->fixed;
|
||||
}
|
||||
|
||||
public function getValue(): string
|
||||
{
|
||||
return ($this->fixed !== null) ? $this->fixed->getCode() : self::PLATFORM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ïîäïèñè äëÿ âûïàäàþùåãî ñïèñêà: ïåðâûì — ñòàâêà èç PHPShop ñ ïîäïèñüþ `$platformLabel`.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function labels(string $platformLabel): array
|
||||
{
|
||||
return [self::PLATFORM => $platformLabel] + self::FIXED_LABELS;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user