currentMode(); $this->assertConfigured($mode); return PaymentInvoice::query()->create([ 'account_id' => $accountId, 'amount' => $this->normalizeAmount($amount), 'mode' => $mode, 'status' => PaymentInvoice::STATUS_PENDING, ]); } /** @return array */ 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 $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 $parts @param array $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 $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}."); } } } }