Skip to Content
Разработка модулейПлатежи и виджеты

Платежи, виджеты и профиль

Flute CMS предоставляет систему платежей через Omnipay, гибкую систему виджетов и возможности интеграции с профилем пользователя.

Система платежей

Важно: Flute CMS автоматически обрабатывает платежи через Omnipay. Создание платежей, обработка callbacks, верификация — всё это происходит автоматически в ядре. Вам нужно только:

  1. Зарегистрировать Omnipay-драйвер в провайдере
  2. Добавить шлюз через админку

Примеры ниже показывают опциональную кастомную логику, если вам нужно расширить стандартное поведение.

Минимальная интеграция

Для добавления нового платёжного шлюза достаточно:

<?php // В провайдере модуля protected function registerPaymentGateway(): void { events()->addDeferredListener( \Flute\Core\Modules\Payments\Events\RegisterPaymentFactoriesEvent::NAME, function($event) { $factory = app(\Flute\Core\Modules\Payments\Factories\PaymentDriverFactory::class); $factory->register('mygateway', MyOmnipayGateway::class); } ); }

После этого добавьте шлюз в админ-панели (Настройки → Платёжные системы) и укажите adapter = имя вашего Omnipay-драйвера.

Всё остальное (создание платежей, callbacks, проверка статуса) обрабатывается автоматически ядром Flute.


Архитектура (для понимания)

Система платежей построена на следующих компонентах:

КомпонентОписание
PaymentGatewayСущность с настройками шлюза (adapter, enabled, settings)
RegisterPaymentFactoriesEventСобытие для регистрации кастомных драйверов
PaymentDriverFactoryФабрика для создания драйверов платежей
GatewayInitializerИнициализирует Omnipay-инстансы из включённых шлюзов
PaymentProcessorАвтоматически обрабатывает платежные сценарии и callbacks
PaymentPromoВалидация промокодов

Регистрация платёжного шлюза (подробно)

Для добавления кастомного платёжного шлюза зарегистрируйте его в провайдере модуля:

<?php namespace Flute\Modules\Shop\Providers; use Flute\Core\Support\ModuleServiceProvider; use Flute\Modules\Shop\Gateways\CustomGateway; class ShopProvider extends ModuleServiceProvider { public function boot(\DI\Container $container): void { $this->registerPaymentGateway(); $this->bootstrapModule(); } protected function registerPaymentGateway(): void { // Регистрация через событие events()->addDeferredListener( \Flute\Core\Modules\Payments\Events\RegisterPaymentFactoriesEvent::NAME, function($event) { $factory = app(\Flute\Core\Modules\Payments\Factories\PaymentDriverFactory::class); // 'custom' — алиас драйвера // CustomGateway::class — класс, реализующий драйвер $factory->register('custom', CustomGateway::class); } ); } }

После регистрации драйвера добавьте запись в таблицу payment_gateways через админку с adapter равным имени Omnipay-гейтвея.

Создание кастомного шлюза

Создайте класс шлюза, наследующий от AbstractGateway и реализующий PaymentDriverInterface:

<?php namespace Flute\Modules\Shop\Gateways; use Flute\Core\Modules\Payments\Contracts\PaymentDriverInterface; use Omnipay\Common\AbstractGateway; class CustomGateway extends AbstractGateway implements PaymentDriverInterface { /** * Название шлюза для отображения */ public function getName(): string { return 'Custom Payment Gateway'; } /** * Параметры по умолчанию (настраиваются в админке) */ public function getDefaultParameters(): array { return [ 'apiKey' => '', 'secretKey' => '', 'testMode' => false, ]; } /** * Создание платежа */ public function purchase(array $parameters = []): \Omnipay\Common\Message\RequestInterface { return $this->createRequest( \Flute\Modules\Shop\Gateways\Message\PurchaseRequest::class, $parameters ); } /** * Завершение платежа (callback) */ public function completePurchase(array $parameters = []): \Omnipay\Common\Message\RequestInterface { return $this->createRequest( \Flute\Modules\Shop\Gateways\Message\CompletePurchaseRequest::class, $parameters ); } /** * Реализация PaymentDriverInterface */ public function processPayment(array $data): array { $response = $this->purchase($data)->send(); return [ 'success' => $response->isSuccessful(), 'transaction_id' => $response->getTransactionReference(), 'amount' => $data['amount'], 'currency' => $data['currency'], ]; } public function verifyPayment(string $transactionId): array { $response = $this->completePurchase([ 'transactionReference' => $transactionId ])->send(); return [ 'verified' => $response->isSuccessful(), 'amount' => $response->getAmount(), 'currency' => $response->getCurrency(), ]; } }

Работа с платежами

Создание платежа

<?php namespace Flute\Modules\Shop\Services; use Flute\Modules\Shop\Database\Entities\Order; class PaymentService { /** * Создание платежа для заказа */ public function createPayment(Order $order): array { // Данные для платежа $paymentData = [ 'amount' => $order->total, 'currency' => config('shop.currency', 'RUB'), 'description' => "Заказ #{$order->id}", 'returnUrl' => route('shop.payment.success', ['order_id' => $order->id]), 'cancelUrl' => route('shop.payment.cancel', ['order_id' => $order->id]), 'notifyUrl' => route('shop.payment.notify', ['order_id' => $order->id]), 'metadata' => [ 'order_id' => $order->id, 'user_id' => $order->user_id, ] ]; // Получение доступных платёжных шлюзов $gateways = payments()->getAllGateways(); if (empty($gateways)) { throw new \Exception('Нет доступных платёжных шлюзов'); } // Используем первый доступный шлюз $gateway = reset($gateways); // Создаём платёж $response = $gateway->purchase($paymentData)->send(); if ($response->isSuccessful() || $response->isRedirect()) { return [ 'success' => true, 'redirect_url' => $response->getRedirectUrl(), 'transaction_id' => $response->getTransactionReference(), ]; } return [ 'success' => false, 'error' => $response->getMessage(), ]; } }

Обработка callback

<?php public function processCallback(Order $order, array $callbackData): bool { $transactionId = $callbackData['transaction_id'] ?? null; if (!$transactionId) { logs('payments')->error('Отсутствует transaction_id в callback'); return false; } // Получаем шлюз и верифицируем платёж $gateways = payments()->getAllGateways(); $gateway = reset($gateways); $verification = $gateway->completePurchase([ 'transactionReference' => $transactionId ])->send(); if ($verification->isSuccessful()) { // Обновляем статус заказа $order->status = 'paid'; $order->paid_at = new \DateTime(); $order->transaction_id = $transactionId; $order->save(); // Отправляем уведомление $this->sendPaymentSuccessNotification($order); return true; } logs('payments')->error('Верификация платежа не прошла', [ 'order_id' => $order->id, 'transaction_id' => $transactionId, 'error' => $verification->getMessage() ]); return false; }

Страница оформления заказа

Полный пример страницы checkout с выбором платёжного шлюза:

{{-- Resources/views/pages/checkout.blade.php --}} @extends('shop::layouts.app') @push('content') <div class="container"> <div class="row justify-content-center"> <div class="col-md-8"> <div class="card"> <div class="card-header"> <h1>{{ __('shop.checkout') }}</h1> </div> <div class="card-body"> {{-- Сводка заказа --}} <div class="order-summary mb-4"> <h3>{{ __('shop.order_summary') }}</h3> <div class="order-items"> @foreach($order->items as $item) <div class="order-item d-flex justify-content-between"> <span>{{ $item->product->name }} x {{ $item->quantity }}</span> <span>{{ number_format($item->total, 2) }} {{ $order->currency }}</span> </div> @endforeach </div> <hr> <div class="order-total d-flex justify-content-between"> <strong>{{ __('shop.total') }}</strong> <strong>{{ number_format($order->total, 2) }} {{ $order->currency }}</strong> </div> </div> {{-- Форма оплаты --}} <div class="payment-form"> <h3>{{ __('shop.payment_method') }}</h3> @if($gateways = payments()->getAllGateways()) <form action="{{ route('shop.payment.process') }}" method="POST"> @csrf {{-- Выбор платежного шлюза --}} <div class="mb-3"> <label for="gateway" class="form-label"> {{ __('shop.select_gateway') }} </label> <select name="gateway" id="gateway" class="form-control" required> @foreach($gateways as $key => $gateway) <option value="{{ $key }}">{{ $gateway->getName() }}</option> @endforeach </select> </div> <input type="hidden" name="order_id" value="{{ $order->id }}"> <button type="submit" class="btn btn-primary btn-lg w-100"> {{ __('shop.pay_now') }} </button> </form> @else <div class="alert alert-warning"> {{ __('shop.no_gateways_available') }} </div> @endif </div> </div> </div> </div> </div> </div> @endpush

Контроллер оплаты

<?php namespace Flute\Modules\Shop\Http\Controllers; use Flute\Core\Support\BaseController; use Flute\Core\Router\Annotations\Route; use Flute\Modules\Shop\Database\Entities\Order; use Flute\Modules\Shop\Services\PaymentService; class PaymentController extends BaseController { protected PaymentService $paymentService; public function __construct(PaymentService $paymentService) { $this->paymentService = $paymentService; } #[Route('/shop/checkout/{orderId}', name: 'shop.checkout', methods: ['GET'])] public function checkout(int $orderId) { $order = Order::findByPK($orderId); if (!$order || $order->user_id !== user()->id) { return $this->error(__('shop.order_not_found'), 404); } if ($order->status !== 'pending') { return redirect(route('shop.order.show', ['id' => $order->id])); } return view('shop::pages.checkout', [ 'order' => $order ]); } #[Route('/shop/payment/process', name: 'shop.payment.process', methods: ['POST'])] public function process() { $orderId = request()->input('order_id'); $gatewayKey = request()->input('gateway'); $order = Order::findByPK($orderId); if (!$order || $order->user_id !== user()->id) { return $this->error(__('shop.order_not_found'), 404); } try { $result = $this->paymentService->createPayment($order, $gatewayKey); if ($result['success'] && isset($result['redirect_url'])) { return redirect($result['redirect_url']); } return $this->error($result['error'] ?? __('shop.payment_error')); } catch (\Exception $e) { logs('payments')->error('Payment error: ' . $e->getMessage()); return $this->error(__('shop.payment_error')); } } #[Route('/shop/payment/success', name: 'shop.payment.success', methods: ['GET'])] public function success() { return view('shop::pages.payment-success'); } #[Route('/shop/payment/cancel', name: 'shop.payment.cancel', methods: ['GET'])] public function cancel() { return view('shop::pages.payment-cancel'); } #[Route('/shop/payment/notify', name: 'shop.payment.notify', methods: ['POST'])] public function notify() { // Webhook от платёжной системы $data = request()->all(); logs('payments')->info('Payment notification received', $data); $orderId = $data['order_id'] ?? $data['metadata']['order_id'] ?? null; if (!$orderId) { return response()->json(['error' => 'Order ID not found'], 400); } $order = Order::findByPK($orderId); if (!$order) { return response()->json(['error' => 'Order not found'], 404); } $success = $this->paymentService->processCallback($order, $data); return response()->json(['success' => $success]); } }

Система виджетов

Виджеты позволяют добавлять динамический контент на страницы сайта с настраиваемыми параметрами.

Создание виджета

Виджет реализует интерфейс WidgetInterface:

<?php namespace Flute\Modules\Blog\Widgets; use Flute\Core\Modules\Page\Widgets\Contracts\WidgetInterface; use Flute\Modules\Blog\Database\Entities\Article; class RecentArticlesWidget implements WidgetInterface { /** * Название виджета для админки */ public function getName(): string { return __('blog.widget.recent_articles'); } /** * Иконка виджета */ public function getIcon(): string { return '<x-icon path="newspaper" />'; } /** * Настройки виджета с их типами */ public function getSettings(): array { return [ 'limit' => [ 'type' => 'number', 'label' => __('blog.widget.limit'), 'default' => 5, 'min' => 1, 'max' => 20, ], 'show_excerpt' => [ 'type' => 'boolean', 'label' => __('blog.widget.show_excerpt'), 'default' => true, ], 'show_date' => [ 'type' => 'boolean', 'label' => __('blog.widget.show_date'), 'default' => true, ], 'category_filter' => [ 'type' => 'select', 'label' => __('blog.widget.category'), 'options' => $this->getCategoryOptions(), 'default' => null, ] ]; } /** * Рендеринг виджета */ public function render(array $settings): string|null { $limit = $settings['limit'] ?? 5; $categoryId = $settings['category_filter'] ?? null; // Получаем статьи $query = Article::query() ->where('status', 'published') ->orderBy('publishedAt', 'DESC') ->limit($limit); if ($categoryId) { $query->where('category.id', $categoryId); } $articles = $query->fetchAll(); if (empty($articles)) { return null; // Не отображаем пустой виджет } return view('blog::widgets.recent-articles', [ 'articles' => $articles, 'settings' => $settings, ])->render(); } /** * Есть ли у виджета настройки */ public function hasSettings(): bool { return true; } /** * Ширина виджета по умолчанию (1-12) */ public function getDefaultWidth(): int { return 12; } /** * Минимальная ширина */ public function getMinWidth(): int { return 6; } /** * Категория виджета для группировки */ public function getCategory(): string { return 'blog'; } /** * Валидация настроек */ public function validateSettings(array $input): true|array { $validator = validator(); $validated = $validator->validate($input, [ 'limit' => 'required|integer|min:1|max:20', 'show_excerpt' => 'boolean', 'show_date' => 'boolean', 'category_filter' => 'nullable|integer', ]); return $validated ? true : $validator->getErrors()->toArray(); } /** * Сохранение настроек */ public function saveSettings(array $input): array { return $input; } /** * Форма настроек (опционально, если нужна кастомная) */ public function renderSettingsForm(array $settings): string|bool { return view('blog::widgets.recent-articles-settings', [ 'settings' => $settings ])->render(); } /** * Кнопки действий виджета */ public function getButtons(): array { return [ [ 'title' => __('blog.widget.refresh_cache'), 'action' => 'refresh_cache', 'icon' => '<x-icon path="sync" />', ] ]; } /** * Обработка действий */ public function handleAction(string $action, ?string $widgetId = null): array { if ($action === 'refresh_cache') { cache()->delete("widget_recent_articles_{$widgetId}"); return ['success' => true, 'message' => __('blog.cache_cleared')]; } return ['success' => false, 'message' => __('blog.unknown_action')]; } protected function getCategoryOptions(): array { $categories = \Flute\Modules\Blog\Database\Entities\Category::findAll(); $options = ['' => __('blog.all_categories')]; foreach ($categories as $category) { $options[$category->id] = $category->name; } return $options; } }

Шаблон виджета

{{-- Resources/views/widgets/recent-articles.blade.php --}} <div class="widget widget-recent-articles"> <div class="widget-header"> <h3 class="widget-title"> <x-icon path="newspaper" /> {{ __('blog.recent_articles') }} </h3> </div> <div class="widget-body"> <div class="articles-list"> @foreach($articles as $article) <article class="article-item"> <h4 class="article-title"> <a href="{{ route('blog.show', ['slug' => $article->slug]) }}"> {{ $article->title }} </a> </h4> @if($settings['show_date'] ?? true) <time class="article-date"> {{ $article->publishedAt->format('d.m.Y') }} </time> @endif @if(($settings['show_excerpt'] ?? true) && $article->excerpt) <p class="article-excerpt"> {{ Str::limit($article->excerpt, 100) }} </p> @endif @if($settings['show_author'] ?? false) <div class="article-author"> <x-icon path="user" /> {{ $article->author->name }} </div> @endif </article> @endforeach </div> <div class="widget-footer"> <a href="{{ route('blog.index') }}" class="view-all"> {{ __('blog.view_all') }} <x-icon path="arrow-right" /> </a> </div> </div> </div>

Форма настроек виджета

Кастомная форма для настройки виджета в админке:

{{-- Resources/views/widgets/recent-articles-settings.blade.php --}} <div class="widget-settings"> {{-- Количество статей --}} <div class="mb-3"> <label for="limit" class="form-label">{{ __('blog.widget.limit') }}</label> <input type="number" id="limit" name="limit" class="form-control" value="{{ $settings['limit'] ?? 5 }}" min="1" max="20" required> <small class="form-text text-muted"> {{ __('blog.widget.limit_description') }} </small> </div> {{-- Показывать отрывок --}} <div class="mb-3"> <div class="form-check"> <input type="checkbox" id="show_excerpt" name="show_excerpt" value="1" class="form-check-input" {{ ($settings['show_excerpt'] ?? true) ? 'checked' : '' }}> <label for="show_excerpt" class="form-check-label"> {{ __('blog.widget.show_excerpt') }} </label> </div> </div> {{-- Показывать дату --}} <div class="mb-3"> <div class="form-check"> <input type="checkbox" id="show_date" name="show_date" value="1" class="form-check-input" {{ ($settings['show_date'] ?? true) ? 'checked' : '' }}> <label for="show_date" class="form-check-label"> {{ __('blog.widget.show_date') }} </label> </div> </div> {{-- Показывать автора --}} <div class="mb-3"> <div class="form-check"> <input type="checkbox" id="show_author" name="show_author" value="1" class="form-check-input" {{ ($settings['show_author'] ?? false) ? 'checked' : '' }}> <label for="show_author" class="form-check-label"> {{ __('blog.widget.show_author') }} </label> </div> </div> {{-- Фильтр по категории --}} <div class="mb-3"> <label for="category_filter" class="form-label"> {{ __('blog.widget.category_filter') }} </label> <select id="category_filter" name="category_filter" class="form-control"> <option value="">{{ __('blog.widget.all_categories') }}</option> @php $categories = \Flute\Modules\Blog\Database\Entities\Category::findAll(); @endphp @foreach($categories as $category) <option value="{{ $category->id }}" {{ ($settings['category_filter'] ?? null) == $category->id ? 'selected' : '' }}> {{ $category->name }} </option> @endforeach </select> </div> </div>

Регистрация виджета

Виджеты из папки Widgets/ автоматически регистрируются при вызове bootstrapModule():

      • RecentArticlesWidget.php
      • CategoriesWidget.php

Интеграция с профилем

Добавление вкладки в профиль

Вы можете добавить вкладку в профиль пользователя через событие TemplateInitialized:

<?php namespace Flute\Modules\Blog\Providers; use Flute\Core\Support\ModuleServiceProvider; class BlogProvider extends ModuleServiceProvider { public function boot(\DI\Container $container): void { $this->bootstrapModule(); $this->registerProfileTab(); } protected function registerProfileTab(): void { events()->addDeferredListener( \Flute\Core\Template\Events\TemplateInitialized::NAME, function($event) { $tpl = $event->getTemplate(); // Добавляем вкладку в навигацию профиля $tpl->prependTemplateToSection('profile_tabs', 'blog::partials.profile-tab', [ 'user' => user() ]); // Добавляем контент вкладки $tpl->prependTemplateToSection('profile_tab_content', 'blog::partials.profile-content', [ 'user' => user() ]); } ); } }

Шаблон вкладки

{{-- Resources/views/partials/profile-tab.blade.php --}} <li class="nav-item"> <a class="nav-link" id="blog-tab" data-bs-toggle="tab" href="#blog" role="tab"> <x-icon path="blog" /> {{ __('blog.my_articles') }} @if($user->articles->count() > 0) <span class="badge bg-primary">{{ $user->articles->count() }}</span> @endif </a> </li>

Шаблон контента вкладки

{{-- Resources/views/partials/profile-content.blade.php --}} <div class="tab-pane fade" id="blog" role="tabpanel"> <div class="blog-profile-section"> <div class="d-flex justify-content-between align-items-center mb-4"> <h3>{{ __('blog.my_articles') }}</h3> <a href="{{ route('blog.create') }}" class="btn btn-primary"> <x-icon path="plus" /> {{ __('blog.create_article') }} </a> </div> @if($user->articles->count() > 0) <div class="articles-list"> @foreach($user->articles as $article) <div class="article-item card mb-3"> <div class="card-body"> <h5 class="card-title"> <a href="{{ route('blog.show', ['slug' => $article->slug]) }}"> {{ $article->title }} </a> </h5> <div class="article-meta text-muted small"> <span> <x-icon path="calendar" /> {{ $article->createdAt->format('d.m.Y') }} </span> <span> <x-icon path="eye" /> {{ $article->views }} </span> <span> @if($article->status === 'published') <span class="badge bg-success">{{ __('blog.published') }}</span> @else <span class="badge bg-warning">{{ __('blog.draft') }}</span> @endif </span> </div> <div class="article-actions mt-2"> <a href="{{ route('blog.edit', ['id' => $article->id]) }}" class="btn btn-sm btn-outline-primary"> {{ __('blog.edit') }} </a> </div> </div> </div> @endforeach </div> @else <div class="text-center py-5"> <x-icon path="blog" class="fa-3x text-muted mb-3" /> <h4>{{ __('blog.no_articles') }}</h4> <p class="text-muted">{{ __('blog.create_first_article_description') }}</p> </div> @endif </div> </div>

Добавление статистики в профиль

<?php protected function registerProfileStats(): void { events()->addDeferredListener( \Flute\Core\Template\Events\TemplateInitialized::NAME, function($event) { $tpl = $event->getTemplate(); $tpl->prependTemplateToSection('profile_stats', 'blog::partials.profile-stats', [ 'user' => user() ]); } ); }
{{-- Resources/views/partials/profile-stats.blade.php --}} <div class="col-md-4 mb-4"> <div class="card stat-card"> <div class="card-body text-center"> <x-icon path="blog" class="fa-2x text-primary mb-2" /> <h3>{{ $user->articles->count() }}</h3> <p class="text-muted mb-0">{{ __('blog.articles') }}</p> </div> </div> </div> <div class="col-md-4 mb-4"> <div class="card stat-card"> <div class="card-body text-center"> <x-icon path="eye" class="fa-2x text-info mb-2" /> <h3>{{ $user->articles->sum('views') }}</h3> <p class="text-muted mb-0">{{ __('blog.total_views') }}</p> </div> </div> </div>

Лучшие практики

Платежи

  • Всегда валидируйте платежи на сервере
  • Используйте HTTPS для платёжных форм
  • Логируйте все транзакции
  • Тестируйте в песочнице перед production

Виджеты

  • Кешируйте данные виджетов
  • Оптимизируйте запросы к БД
  • Предоставляйте разумные значения по умолчанию
  • Валидируйте все настройки

Профиль

  • Проверяйте права доступа к данным
  • Используйте AJAX для динамических обновлений
  • Кешируйте статистику