Files
moonwell-web/app/Services/RobokassaService.php

219 lines
8.3 KiB
PHP

<?php
namespace App\Services;
use App\Models\BalanceTransaction;
use App\Models\PaymentInvoice;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use RuntimeException;
class RobokassaService
{
public function createInvoice(int $accountId, string $amount): PaymentInvoice
{
$mode = $this->currentMode();
$this->assertConfigured($mode);
return PaymentInvoice::query()->create([
'account_id' => $accountId,
'amount' => $this->normalizeAmount($amount),
'mode' => $mode,
'status' => PaymentInvoice::STATUS_PENDING,
]);
}
/** @return array<string, string> */
public function paymentParameters(PaymentInvoice $invoice, string $email = ''): array
{
$mode = (string) $invoice->mode;
$this->assertConfigured($mode);
$custom = ['Shp_account' => (string) $invoice->account_id];
$amount = $this->normalizeAmount((string) $invoice->amount);
return array_filter([
'MerchantLogin' => $this->credential($mode, 'login'),
'OutSum' => $amount,
'InvId' => (string) $invoice->id,
'Description' => 'Пополнение Слёз Элуны',
'SignatureValue' => $this->hash($this->signatureBase(
[$this->credential($mode, 'login'), $amount, (string) $invoice->id, $this->credential($mode, 'password1')],
$custom,
), $mode),
'Email' => $email,
'Culture' => 'ru',
'Encoding' => 'utf-8',
'IsTest' => $mode === PaymentInvoice::MODE_TEST ? '1' : '0',
...$custom,
], static fn (string $value): bool => $value !== '');
}
/** @param array<string, mixed> $payload */
public function processResult(array $payload): PaymentInvoice
{
$invoiceId = $this->value($payload, 'InvId');
$amount = $this->value($payload, 'OutSum');
$signature = $this->value($payload, 'SignatureValue');
$accountId = $this->value($payload, 'Shp_account');
if (! ctype_digit($invoiceId) || ! ctype_digit($accountId) || $signature === '') {
throw new InvalidArgumentException('Некорректные параметры уведомления Robokassa.');
}
$invoiceForSignature = PaymentInvoice::query()->findOrFail((int) $invoiceId);
$mode = (string) $invoiceForSignature->mode;
$this->assertConfigured($mode);
$expected = $this->hash($this->signatureBase(
[$amount, $invoiceId, $this->credential($mode, 'password2')],
['Shp_account' => $accountId],
), $mode);
if (! hash_equals(strtolower($expected), strtolower($signature))) {
throw new InvalidArgumentException('Некорректная подпись Robokassa.');
}
return DB::connection('azerothcore_auth')->transaction(function () use ($invoiceId, $accountId, $amount): PaymentInvoice {
$invoice = PaymentInvoice::query()->lockForUpdate()->findOrFail((int) $invoiceId);
if ($invoice->account_id !== (int) $accountId || $this->normalizeAmount((string) $invoice->amount) !== $this->normalizeAmount($amount)) {
throw new InvalidArgumentException('Сумма или получатель платежа не совпадают со счётом.');
}
if ($invoice->status === PaymentInvoice::STATUS_PAID) {
return $invoice;
}
DB::connection('azerothcore_auth')->table('account_balances')->insertOrIgnore([
'account_id' => $invoice->account_id,
'balance' => '0.00',
'created_at' => now(),
'updated_at' => now(),
]);
$balance = DB::connection('azerothcore_auth')->table('account_balances')
->where('account_id', $invoice->account_id)
->lockForUpdate()
->firstOrFail();
$current = (string) $balance->balance;
$tearsAmount = $this->multiplyAmount(
(string) $invoice->amount,
(int) config('services.robokassa.tears_per_ruble'),
);
$after = $this->addAmounts($current, $tearsAmount);
DB::connection('azerothcore_auth')->table('account_balances')
->where('account_id', $invoice->account_id)
->update(['balance' => $after, 'updated_at' => now()]);
BalanceTransaction::query()->create([
'account_id' => $invoice->account_id,
'type' => BalanceTransaction::TYPE_DEPOSIT,
'amount' => $tearsAmount,
'balance_after' => $after,
'reference' => 'robokassa:'.$invoice->id,
'metadata' => [
'invoice_id' => $invoice->id,
'paid_rubles' => $invoice->amount,
'tears_per_ruble' => (int) config('services.robokassa.tears_per_ruble'),
],
]);
$invoice->update(['status' => PaymentInvoice::STATUS_PAID, 'paid_at' => now()]);
return $invoice->refresh();
}, 3);
}
private function normalizeAmount(string $amount): string
{
if (! is_numeric($amount) || (float) $amount < 0) {
throw new InvalidArgumentException('Некорректная сумма.');
}
return number_format((float) $amount, 2, '.', '');
}
private function addAmounts(string $left, string $right): string
{
$toCents = static function (string $value): int {
[$whole, $fraction] = explode('.', number_format((float) $value, 2, '.', ''));
return ((int) $whole * 100) + (int) $fraction;
};
return number_format(($toCents($left) + $toCents($right)) / 100, 2, '.', '');
}
private function multiplyAmount(string $amount, int $multiplier): string
{
if ($multiplier <= 0) {
throw new RuntimeException('Некорректный курс Слёз Элуны.');
}
return number_format((float) $amount * $multiplier, 2, '.', '');
}
/** @param list<string> $parts @param array<string, string> $custom */
private function signatureBase(array $parts, array $custom): string
{
ksort($custom, SORT_STRING);
return implode(':', [...$parts, ...array_map(
static fn (string $name, string $value): string => $name.'='.$value,
array_keys($custom),
array_values($custom),
)]);
}
private function hash(string $value, ?string $mode = null): string
{
$algorithm = strtolower($this->credential($mode ?? $this->currentMode(), 'hash_algorithm'));
if (! in_array($algorithm, hash_algos(), true)) {
throw new RuntimeException('Неизвестный алгоритм подписи Robokassa.');
}
return hash($algorithm, $value);
}
/** @param array<string, mixed> $payload */
private function value(array $payload, string $key): string
{
foreach ($payload as $payloadKey => $value) {
if (strcasecmp((string) $payloadKey, $key) === 0 && is_scalar($value)) {
return (string) $value;
}
}
return '';
}
private function currentMode(): string
{
$mode = (string) config('services.robokassa.mode');
if (! in_array($mode, [PaymentInvoice::MODE_TEST, PaymentInvoice::MODE_PRODUCTION], true)) {
throw new RuntimeException('ROBOKASSA_MODE должен быть test или production.');
}
return $mode;
}
private function credential(string $mode, string $key): string
{
return (string) config("services.robokassa.environments.{$mode}.{$key}");
}
private function assertConfigured(string $mode): void
{
if (! in_array($mode, [PaymentInvoice::MODE_TEST, PaymentInvoice::MODE_PRODUCTION], true)) {
throw new RuntimeException('Некорректный режим Robokassa.');
}
foreach (['login', 'password1', 'password2'] as $key) {
if ($this->credential($mode, $key) === '') {
throw new RuntimeException("Robokassa не настроена для режима {$mode}.");
}
}
}
}