донат шоп, управление магазином, яндекс метрика
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\BalanceTransaction;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class BalanceService
|
||||
{
|
||||
public function balance(int $accountId): string
|
||||
{
|
||||
$value = DB::connection('azerothcore_auth')->table('account_balances')
|
||||
->where('account_id', $accountId)
|
||||
->value('balance');
|
||||
|
||||
return $this->fromCents($this->toCents((string) ($value ?? '0')));
|
||||
}
|
||||
|
||||
/** @return Collection<int, BalanceTransaction> */
|
||||
public function recentTransactions(int $accountId, int $limit = 10): Collection
|
||||
{
|
||||
return BalanceTransaction::query()
|
||||
->where('account_id', $accountId)
|
||||
->latest('id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function spend(int $accountId, string $amount, string $reference, ?string $description = null): BalanceTransaction
|
||||
{
|
||||
$cents = $this->toCents($amount);
|
||||
if ($cents <= 0) {
|
||||
throw new InvalidArgumentException('Сумма списания должна быть больше нуля.');
|
||||
}
|
||||
|
||||
return DB::connection('azerothcore_auth')->transaction(function () use ($accountId, $cents, $reference, $description): BalanceTransaction {
|
||||
$existing = BalanceTransaction::query()->where('reference', $reference)->first();
|
||||
if ($existing) {
|
||||
if ($existing->account_id !== $accountId || $this->toCents((string) $existing->amount) !== -$cents) {
|
||||
throw new InvalidArgumentException('Ключ операции уже использован с другими параметрами.');
|
||||
}
|
||||
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$balance = DB::connection('azerothcore_auth')->table('account_balances')
|
||||
->where('account_id', $accountId)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
$current = $this->toCents((string) ($balance->balance ?? '0'));
|
||||
|
||||
if ($current < $cents) {
|
||||
throw new InvalidArgumentException('Недостаточно средств.');
|
||||
}
|
||||
|
||||
$after = $this->fromCents($current - $cents);
|
||||
DB::connection('azerothcore_auth')->table('account_balances')
|
||||
->where('account_id', $accountId)
|
||||
->update(['balance' => $after, 'updated_at' => now()]);
|
||||
|
||||
return BalanceTransaction::query()->create([
|
||||
'account_id' => $accountId,
|
||||
'type' => BalanceTransaction::TYPE_SPEND,
|
||||
'amount' => $this->fromCents(-$cents),
|
||||
'balance_after' => $after,
|
||||
'reference' => $reference,
|
||||
'metadata' => array_filter(['description' => $description]),
|
||||
]);
|
||||
}, 3);
|
||||
}
|
||||
|
||||
public function adjustByAdmin(
|
||||
int $accountId,
|
||||
string $amount,
|
||||
string $direction,
|
||||
int $adminAccountId,
|
||||
string $adminUsername,
|
||||
string $reason,
|
||||
): BalanceTransaction {
|
||||
$cents = $this->toCents($amount);
|
||||
if ($cents <= 0 || ! in_array($direction, ['credit', 'debit'], true)) {
|
||||
throw new InvalidArgumentException('Некорректная корректировка баланса.');
|
||||
}
|
||||
|
||||
return DB::connection('azerothcore_auth')->transaction(function () use ($accountId, $cents, $direction, $adminAccountId, $adminUsername, $reason): BalanceTransaction {
|
||||
DB::connection('azerothcore_auth')->table('account_balances')->insertOrIgnore([
|
||||
'account_id' => $accountId,
|
||||
'balance' => '0.00',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$balance = DB::connection('azerothcore_auth')->table('account_balances')
|
||||
->where('account_id', $accountId)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
$current = $this->toCents((string) $balance->balance);
|
||||
$delta = $direction === 'credit' ? $cents : -$cents;
|
||||
|
||||
if ($current + $delta < 0) {
|
||||
throw new InvalidArgumentException('Недостаточно средств для ручного списания.');
|
||||
}
|
||||
|
||||
$after = $this->fromCents($current + $delta);
|
||||
DB::connection('azerothcore_auth')->table('account_balances')
|
||||
->where('account_id', $accountId)
|
||||
->update(['balance' => $after, 'updated_at' => now()]);
|
||||
|
||||
return BalanceTransaction::query()->create([
|
||||
'account_id' => $accountId,
|
||||
'type' => $direction === 'credit' ? BalanceTransaction::TYPE_ADMIN_CREDIT : BalanceTransaction::TYPE_ADMIN_DEBIT,
|
||||
'amount' => $this->fromCents($delta),
|
||||
'balance_after' => $after,
|
||||
'reference' => 'admin:'.Str::uuid(),
|
||||
'metadata' => [
|
||||
'admin_account_id' => $adminAccountId,
|
||||
'admin_username' => $adminUsername,
|
||||
'reason' => $reason,
|
||||
],
|
||||
]);
|
||||
}, 3);
|
||||
}
|
||||
|
||||
/** @return Collection<int, BalanceTransaction> */
|
||||
public function adminTransactions(?string $search = null, int $limit = 100): Collection
|
||||
{
|
||||
return BalanceTransaction::query()
|
||||
->leftJoin('account', 'account.id', '=', 'balance_transactions.account_id')
|
||||
->select('balance_transactions.*', 'account.username')
|
||||
->when($search, function ($query, string $search): void {
|
||||
$query->where(function ($query) use ($search): void {
|
||||
$query->where('account.username', 'like', '%'.$search.'%');
|
||||
if (ctype_digit($search)) {
|
||||
$query->orWhere('balance_transactions.account_id', (int) $search);
|
||||
}
|
||||
});
|
||||
})
|
||||
->latest('balance_transactions.id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function toCents(string $amount): int
|
||||
{
|
||||
$normalized = str_replace(',', '.', trim($amount));
|
||||
if (! preg_match('/^-?\d+(?:\.\d{1,6})?$/', $normalized)) {
|
||||
throw new InvalidArgumentException('Некорректная сумма.');
|
||||
}
|
||||
|
||||
$negative = str_starts_with($normalized, '-');
|
||||
$normalized = ltrim($normalized, '-');
|
||||
[$whole, $fraction] = array_pad(explode('.', $normalized, 2), 2, '');
|
||||
$cents = ((int) $whole * 100) + (int) str_pad(substr($fraction, 0, 2), 2, '0');
|
||||
|
||||
return $negative ? -$cents : $cents;
|
||||
}
|
||||
|
||||
private function fromCents(int $cents): string
|
||||
{
|
||||
return number_format($cents / 100, 2, '.', '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
<?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}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ShopCategory;
|
||||
use App\Models\ShopProduct;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ShopCatalogService
|
||||
{
|
||||
/** @return Collection<int, ShopCategory> */
|
||||
public function categories(): Collection
|
||||
{
|
||||
return ShopCategory::query()->ordered()->withCount('products')->get();
|
||||
}
|
||||
|
||||
/** @return Collection<int, ShopProduct> */
|
||||
public function products(): Collection
|
||||
{
|
||||
return ShopProduct::query()->with('categories')->ordered()->get();
|
||||
}
|
||||
|
||||
public function createCategory(array $data): ShopCategory
|
||||
{
|
||||
return ShopCategory::query()->create($this->categoryData($data));
|
||||
}
|
||||
|
||||
public function updateCategory(ShopCategory $category, array $data): ShopCategory
|
||||
{
|
||||
$category->update($this->categoryData($data));
|
||||
|
||||
return $category->refresh();
|
||||
}
|
||||
|
||||
public function deleteCategory(ShopCategory $category): void
|
||||
{
|
||||
DB::connection('store')->transaction(function () use ($category): void {
|
||||
$category->products()->detach();
|
||||
$category->delete();
|
||||
});
|
||||
}
|
||||
|
||||
public function createProduct(array $data): ShopProduct
|
||||
{
|
||||
return DB::connection('store')->transaction(function () use ($data): ShopProduct {
|
||||
$product = ShopProduct::query()->create($this->productData($data));
|
||||
$product->categories()->sync($data['category_ids']);
|
||||
|
||||
return $product->load('categories');
|
||||
});
|
||||
}
|
||||
|
||||
public function updateProduct(ShopProduct $product, array $data): ShopProduct
|
||||
{
|
||||
return DB::connection('store')->transaction(function () use ($product, $data): ShopProduct {
|
||||
$product->update($this->productData($data));
|
||||
$product->categories()->sync($data['category_ids']);
|
||||
|
||||
return $product->refresh()->load('categories');
|
||||
});
|
||||
}
|
||||
|
||||
public function deleteProduct(ShopProduct $product): void
|
||||
{
|
||||
DB::connection('store')->transaction(function () use ($product): void {
|
||||
$product->categories()->detach();
|
||||
$product->delete();
|
||||
});
|
||||
}
|
||||
|
||||
private function categoryData(array $data): array
|
||||
{
|
||||
return [
|
||||
'name' => trim($data['name']),
|
||||
'icon' => trim((string) ($data['icon'] ?? '')),
|
||||
'requiredRank' => (int) $data['requiredRank'],
|
||||
'flags' => (int) $data['flags'],
|
||||
'enabled' => (int) ($data['enabled'] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
private function productData(array $data): array
|
||||
{
|
||||
return [
|
||||
'type' => (int) $data['type'],
|
||||
'name' => trim($data['name']),
|
||||
'tooltipName' => trim((string) ($data['tooltipName'] ?? '')),
|
||||
'tooltipType' => trim((string) ($data['tooltipType'] ?? '')),
|
||||
'tooltipText' => trim((string) ($data['tooltipText'] ?? '')),
|
||||
'icon' => trim((string) ($data['icon'] ?? '')),
|
||||
'price' => (int) $data['price'],
|
||||
'currency' => 1,
|
||||
'hyperlinkId' => (int) ($data['hyperlinkId'] ?? 0),
|
||||
'creatureEntry' => (int) ($data['creatureEntry'] ?? 0),
|
||||
'discountAmount' => (int) ($data['discountAmount'] ?? 0),
|
||||
'flags' => (int) ($data['flags'] ?? 0),
|
||||
...collect(range(1, 8))->flatMap(fn (int $slot): array => [
|
||||
'reward_'.$slot => (int) ($data['reward_'.$slot] ?? 0),
|
||||
'rewardcount_'.$slot => (int) ($data['rewardcount_'.$slot] ?? 0),
|
||||
])->all(),
|
||||
'new' => (int) ($data['new'] ?? false),
|
||||
'enabled' => (int) ($data['enabled'] ?? false),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user