Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions _build/elements/settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,16 @@
'xtype' => 'textfield',
'area' => 'ms3_security',
],
'ms3_payment_on_failed_status' => [
'value' => 5,
'xtype' => 'numberfield',
'area' => 'ms3_order',
],
'ms3_payment_on_refunded_status' => [
'value' => 5,
'xtype' => 'numberfield',
'area' => 'ms3_order',
],
'ms3_snippet_token_secret' => [
'value' => '', // Генерируется автоматически при первом запуске
'xtype' => 'textfield',
Expand Down
1 change: 1 addition & 0 deletions core/components/minishop3/config/ms3.services.example.php
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@
* ------------------
* 'ms3_delivery_service' - Delivery service
* 'ms3_payment_service' - Payment service
* 'ms3_payment_lifecycle' - Payment attempt lifecycle (async providers)
*
* Orders:
* -------
Expand Down
5 changes: 5 additions & 0 deletions core/components/minishop3/config/routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,11 @@
$controller = new \MiniShop3\Controllers\Api\Web\PaymentController($modx);
return $controller->getList($params);
});

$router->post('/webhook/{payment_method_id}', function ($params) use ($modx) {
$controller = new \MiniShop3\Controllers\Api\Web\PaymentWebhookController($modx);
return $controller->handle($params);
});
});

$router->get('/health', function () use ($modx) {
Expand Down
6 changes: 3 additions & 3 deletions core/components/minishop3/elements/snippets/ms3_get_order.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
use MiniShop3\MiniShop3;
use MiniShop3\Model\msOrder;
use MiniShop3\Services\Payment\PaymentLinkResolver;
use MiniShop3\Services\Payment\PaymentPublicFields;
use MiniShop3\Model\msOrderProduct;
use MiniShop3\Model\msProduct;
use MiniShop3\Model\msProductData;
Expand Down Expand Up @@ -248,6 +249,7 @@
}

try {
$payment = $msOrder->getOne('Payment');
$pls = array_merge($scriptProperties, [
'order' => $msOrder->toArray(),
'products' => $products,
Expand All @@ -260,9 +262,7 @@
'delivery' => ($tmp = $msOrder->getOne('Delivery'))
? $tmp->toArray()
: [],
'payment' => ($payment = $msOrder->getOne('Payment'))
? $payment->toArray()
: [],
'payment' => PaymentPublicFields::fromEntityOrEmpty($payment),
'total' => [
'cost' => (float)$msOrder->get('cost'),
'cost_formatted' => $ms3->format->price($msOrder->get('cost'), true),
Expand Down
7 changes: 7 additions & 0 deletions core/components/minishop3/lexicon/en/default.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,13 @@
$_lang['ms3_err_delivery_id_required'] = 'Delivery ID is required';
$_lang['ms3_err_payment_nf'] = 'Payment method with this identifier not found.';
$_lang['ms3_err_payment_id_required'] = 'Payment ID is required';
$_lang['ms3_err_payment_webhook_unsupported'] = 'This payment method does not support the core webhook.';
$_lang['ms3_err_payment_webhook_invalid'] = 'Payment callback payload is invalid.';
$_lang['ms3_err_payment_webhook_unauthorized'] = 'Payment callback signature is invalid.';
$_lang['ms3_err_payment_webhook_conflict'] = 'Payment callback conflicts with the current attempt state.';
$_lang['ms3_err_payment_attempt_nf'] = 'Payment attempt not found.';
$_lang['ms3_err_payment_attempt_record'] = 'Could not record the payment attempt after sending the order to the gateway.';
$_lang['ms3_err_payment_event_conflict'] = 'This payment event cannot be applied to the current attempt.';
$_lang['ms3_err_status_final'] = 'Final status is set. It cannot be changed.';
$_lang['ms3_err_status_fixed'] = 'Fixed status is set. You cannot change it to earlier one.';
$_lang['ms3_err_status_wrong'] = 'Invalid order status.';
Expand Down
4 changes: 4 additions & 0 deletions core/components/minishop3/lexicon/en/setting.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,10 @@
$_lang['setting_ms3_email_verification_success_url_desc'] = 'Used when the user opens the verification link from email (html=1). If empty, site_url is used; the query parameter ms3_email_verified=1 is appended.';
$_lang['setting_ms3_payment_secret'] = 'Payment secret key';
$_lang['setting_ms3_payment_secret_desc'] = 'Secret key for generating payment notification signatures. Recommended to set a unique value for improved security.';
$_lang['setting_ms3_payment_on_failed_status'] = 'Order status after failed/cancelled payment';
$_lang['setting_ms3_payment_on_failed_status_desc'] = 'Order status ID applied when a payment attempt fails or is cancelled before paid. 0 leaves the order unchanged. Default is the canceled status (5).';
$_lang['setting_ms3_payment_on_refunded_status'] = 'Order status after full refund';
$_lang['setting_ms3_payment_on_refunded_status_desc'] = 'Order status ID applied after a full refund. 0 leaves the order unchanged. Partial refunds never change order status. Default is the canceled status (5).';

// Currency and Formatting Settings
$_lang['setting_ms3_currency_symbol'] = 'Currency symbol';
Expand Down
7 changes: 7 additions & 0 deletions core/components/minishop3/lexicon/ru/default.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,13 @@
$_lang['ms3_err_delivery_id_required'] = 'Не указан ID доставки';
$_lang['ms3_err_payment_nf'] = 'Способ оплаты с таким идентификатором не найден.';
$_lang['ms3_err_payment_id_required'] = 'Не указан ID оплаты';
$_lang['ms3_err_payment_webhook_unsupported'] = 'Этот способ оплаты не поддерживает системный webhook.';
$_lang['ms3_err_payment_webhook_invalid'] = 'Некорректное тело платёжного callback.';
$_lang['ms3_err_payment_webhook_unauthorized'] = 'Подпись платёжного callback недействительна.';
$_lang['ms3_err_payment_webhook_conflict'] = 'Callback конфликтует с текущим состоянием попытки оплаты.';
$_lang['ms3_err_payment_attempt_nf'] = 'Попытка оплаты не найдена.';
$_lang['ms3_err_payment_attempt_record'] = 'Не удалось записать попытку оплаты после отправки заказа в платёжный шлюз.';
$_lang['ms3_err_payment_event_conflict'] = 'Это платёжное событие нельзя применить к текущей попытке.';
$_lang['ms3_err_status_final'] = 'Установлен финальный статус. Его нельзя менять.';
$_lang['ms3_err_status_fixed'] = 'Установлен фиксирующий статус. Вы не можете сменить его на более ранний.';
$_lang['ms3_err_status_wrong'] = 'Неверный статус заказа.';
Expand Down
6 changes: 5 additions & 1 deletion core/components/minishop3/lexicon/ru/setting.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,11 @@
$_lang['setting_ms3_email_verification_success_url'] = 'URL редиректа после успешной верификации email (необязательно)';
$_lang['setting_ms3_email_verification_success_url_desc'] = 'Используется при переходе по ссылке из письма (параметр html=1). Если пусто — берётся site_url; к URL добавляется параметр ms3_email_verified=1.';
$_lang['setting_ms3_payment_secret'] = 'Секретный ключ для платежей';
$_lang['setting_ms3_payment_secret_desc'] = 'Секретный ключ для генерации подписей платежных уведомлений. Рекомендуется установить уникальное значение для повышения безопасности.';
$_lang['setting_ms3_payment_secret_desc'] = 'Секретный ключ для генерации подписей платёжных уведомлений. Рекомендуется установить уникальное значение для повышения безопасности.';
$_lang['setting_ms3_payment_on_failed_status'] = 'Статус заказа после неуспешной оплаты';
$_lang['setting_ms3_payment_on_failed_status_desc'] = 'ID статуса заказа, который ставится при failed/cancelled попытки до оплаты. 0 — не менять заказ. По умолчанию статус отмены (5).';
$_lang['setting_ms3_payment_on_refunded_status'] = 'Статус заказа после полного возврата';
$_lang['setting_ms3_payment_on_refunded_status_desc'] = 'ID статуса заказа после полного refund. 0 — не менять заказ. Частичный возврат статус заказа не меняет. По умолчанию статус отмены (5).';

// Currency and Formatting Settings
$_lang['setting_ms3_currency_symbol'] = 'Символ валюты';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

use Phinx\Migration\AbstractMigration;

/**
* Payment attempts and idempotent webhook events (#590).
*/
final class CreatePaymentAttempts extends AbstractMigration
{
public function change(): void
{
if (!$this->hasTable('ms3_payment_attempts')) {
$this->table('ms3_payment_attempts', [
'id' => true,
'primary_key' => ['id'],
'engine' => 'InnoDB',
'encoding' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
])
->addColumn('order_id', 'integer', ['signed' => false, 'null' => false])
->addColumn('payment_method_id', 'integer', ['signed' => false, 'null' => false])
->addColumn('provider', 'string', ['limit' => 191, 'null' => false])
->addColumn('external_id', 'string', ['limit' => 191, 'null' => true, 'default' => null])
->addColumn('status', 'string', ['limit' => 32, 'null' => false, 'default' => 'pending'])
->addColumn('amount', 'decimal', ['precision' => 13, 'scale' => 3, 'null' => false, 'default' => '0.000'])
->addColumn('currency', 'string', ['limit' => 8, 'null' => false, 'default' => 'RUB'])
->addColumn('payload', 'text', ['null' => true])
->addColumn('refunded_amount', 'decimal', ['precision' => 13, 'scale' => 3, 'null' => false, 'default' => '0.000'])
->addColumn('refund_external_id', 'string', ['limit' => 191, 'null' => true, 'default' => null])
->addColumn('refundedon', 'integer', ['signed' => false, 'null' => true, 'default' => null])
->addColumn('createdon', 'integer', ['signed' => false, 'null' => true, 'default' => null])
->addColumn('updatedon', 'integer', ['signed' => false, 'null' => true, 'default' => null])
->addIndex(['order_id'], ['name' => 'idx_payment_attempt_order'])
->addIndex(['payment_method_id', 'provider', 'external_id'], [
'unique' => true,
'name' => 'uniq_payment_attempt_external',
])
->create();
}

if (!$this->hasTable('ms3_payment_attempt_events')) {
$this->table('ms3_payment_attempt_events', [
'id' => true,
'primary_key' => ['id'],
'engine' => 'InnoDB',
'encoding' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
])
->addColumn('attempt_id', 'integer', ['signed' => false, 'null' => false])
->addColumn('event_type', 'string', ['limit' => 32, 'null' => false])
->addColumn('provider_event_id', 'string', ['limit' => 191, 'null' => false, 'default' => ''])
->addColumn('createdon', 'integer', ['signed' => false, 'null' => true, 'default' => null])
->addIndex(
['attempt_id', 'event_type', 'provider_event_id'],
['unique' => true, 'name' => 'uniq_payment_attempt_event']
)
->create();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
<?php

declare(strict_types=1);

namespace MiniShop3\Controllers\Api\Web;

use MiniShop3\Controllers\Payment\PaymentWebhookHandlerInterface;
use MiniShop3\Model\msPayment;
use MiniShop3\Router\ApiErrorCode;
use MiniShop3\Router\HttpStatus;
use MiniShop3\Router\Response;
use MiniShop3\Services\Payment\PaymentLifecycleException;
use MiniShop3\Services\Payment\PaymentLifecycleService;
use MiniShop3\Services\Payment\PaymentService;
use MODX\Revolution\modX;

/**
* Public async payment callback. Auth is provider signature, not customer token.
*/
class PaymentWebhookController
{
public function __construct(protected modX $modx)
{
$this->modx->lexicon->load('minishop3:default');
}

/**
* POST /api/v1/payment/webhook/{payment_method_id}
*
* @param array<string, mixed> $params
*/
public function handle(array $params = []): Response
{
$methodId = (int) ($params['payment_method_id'] ?? 0);
if ($methodId <= 0) {
return $this->fail('ms3_err_payment_id_required', HttpStatus::BAD_REQUEST, ApiErrorCode::BAD_REQUEST);
}

$method = $this->modx->getObject(msPayment::class, ['id' => $methodId, 'active' => 1]);
if (!$method instanceof msPayment) {
return $this->fail('ms3_err_payment_nf', HttpStatus::NOT_FOUND, ApiErrorCode::NOT_FOUND);
}

/** @var PaymentService $paymentService */
$paymentService = $this->modx->services->get('ms3_payment_service');
$handler = $paymentService->loadPaymentHandler($method);
if (!$handler instanceof PaymentWebhookHandlerInterface) {
return $this->fail(
'ms3_err_payment_webhook_unsupported',
HttpStatus::BAD_REQUEST,
ApiErrorCode::BAD_REQUEST
);
}

$rawBody = $this->readRawRequestBody();
$payload = $this->decodeJsonObject($rawBody);
$headers = $this->requestHeaders();
if ($payload === null) {
return $this->fail('ms3_err_payment_webhook_invalid', HttpStatus::BAD_REQUEST, ApiErrorCode::BAD_REQUEST);
}
if (!$handler->verifyWebhook($rawBody, $payload, $headers, $method)) {
return $this->fail(
'ms3_err_payment_webhook_unauthorized',
HttpStatus::UNAUTHORIZED,
ApiErrorCode::UNAUTHORIZED
);
}

$event = $handler->parseWebhook($payload, $headers);
if ($event === null) {
return $this->fail('ms3_err_payment_webhook_invalid', HttpStatus::BAD_REQUEST, ApiErrorCode::BAD_REQUEST);
}

/** @var PaymentLifecycleService $lifecycle */
$lifecycle = $this->modx->services->get('ms3_payment_lifecycle');
$class = $method->get('class');
$provider = is_string($class) && $class !== '' ? $class : $handler::class;

try {
$attempt = $lifecycle->applyWebhook($event, $methodId, $provider);
} catch (PaymentLifecycleException $exception) {
return $this->fromLifecycle($exception);
} catch (\Throwable $exception) {
$this->modx->log(
modX::LOG_LEVEL_ERROR,
'Payment webhook failed: ' . $exception->getMessage()
);
return $this->fail(
'ms3_err_unknown',
HttpStatus::INTERNAL_SERVER_ERROR,
ApiErrorCode::INTERNAL_ERROR
);
}

return Response::success([
'attempt_id' => $attempt['id'],
'status' => $attempt['status'],
'order_id' => $attempt['order_id'],
]);
}

private function fromLifecycle(PaymentLifecycleException $exception): Response
{
[$status, $errorCode] = match ($exception->getKind()) {
PaymentLifecycleException::KIND_CONFLICT => [HttpStatus::CONFLICT, ApiErrorCode::CONFLICT],
PaymentLifecycleException::KIND_NOT_FOUND => [HttpStatus::NOT_FOUND, ApiErrorCode::NOT_FOUND],
default => [HttpStatus::BAD_REQUEST, ApiErrorCode::BAD_REQUEST],
};
$message = $this->modx->lexicon($exception->getLexiconKey(), $exception->getPlaceholders());
if (!is_string($message) || $message === '') {
$message = $exception->getMessage();
}

return Response::error($message, $status, null, $errorCode);
}

private function fail(string $message, int $status, string $errorCode): Response
{
$translated = $this->modx->lexicon($message);
if (is_string($translated) && $translated !== '') {
$message = $translated;
}

return Response::error($message, $status, null, $errorCode);
}

/**
* @return array<string, mixed>|null
*/
private function decodeJsonObject(string $raw): ?array
{
if ($raw === '') {
return null;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded) || $decoded === [] || array_is_list($decoded)) {
return null;
}

return $decoded;
}

protected function readRawRequestBody(): string
{
$raw = file_get_contents('php://input');

return is_string($raw) ? $raw : '';
}

/**
* @return array<string, string>
*/
private function requestHeaders(): array
{
if (function_exists('getallheaders')) {
$headers = getallheaders();
$normalized = [];
foreach ($headers as $name => $value) {
$normalized[strtolower((string) $name)] = (string) $value;
}

return $normalized;
}
$headers = [];
foreach ($_SERVER as $key => $value) {
if (!is_string($key) || !str_starts_with($key, 'HTTP_')) {
continue;
}
$name = strtolower(str_replace('_', '-', substr($key, 5)));
$headers[$name] = is_scalar($value) ? (string) $value : '';
}

return $headers;
}
}
Loading