Платежи, виджеты и профиль
Flute CMS предоставляет систему платежей через Omnipay, гибкую систему виджетов и возможности интеграции с профилем пользователя.
Система платежей
Важно: Flute CMS автоматически обрабатывает платежи через Omnipay. Создание платежей, обработка callbacks, верификация — всё это происходит автоматически в ядре. Вам нужно только:
- Зарегистрировать Omnipay-драйвер в провайдере
- Добавить шлюз через админку
Примеры ниже показывают опциональную кастомную логику, если вам нужно расширить стандартное поведение.
Минимальная интеграция
Для добавления нового платёжного шлюза достаточно:
<?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 для динамических обновлений
- Кешируйте статистику