ce87936f44
При InsufficientBalanceException в LedgerService::chargeForDelivery: - DB::transaction откатывается (Deal/charge/balance не тронуты). - Outer catch в createDealCopyForProject вызывает handleInsufficientBalance: * UPDATE projects.is_active=false через pgsql_supplier (BYPASSRLS). * Email ZeroBalancePausedMail через NotificationService::notifyZeroBalancePaused. * Rate-limit 1/час/tenant через Redis SETNX (Cache::add). * Log::warning с tenant_id/project_id/balance details. - Возвращаем false (не rethrow), чтобы handle()-loop продолжал routing остальным tenant'ам. 5 тестов: project paused / email sent / rate-limit 1/h / 2nd email after 65min / sharing-flow isolation (A paused, B receives). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
355 lines
15 KiB
PHP
355 lines
15 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Mail\InvoicePaidNotification;
|
||
use App\Mail\LowBalanceNotification;
|
||
use App\Mail\NewLeadNotification;
|
||
use App\Mail\ReminderDueNotification;
|
||
use App\Mail\TopupSuccessNotification;
|
||
use App\Mail\ZeroBalanceNotification;
|
||
use App\Mail\ZeroBalancePausedMail;
|
||
use App\Models\Deal;
|
||
use App\Models\InAppNotification;
|
||
use App\Models\Project;
|
||
use App\Models\Reminder;
|
||
use App\Models\Tenant;
|
||
use App\Models\User;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Illuminate\Support\Facades\Mail;
|
||
use Throwable;
|
||
|
||
/**
|
||
* Центральный диспетчер уведомлений (ТЗ §18.5, schema v8.9 §4 users.notification_preferences).
|
||
*
|
||
* Матрица 8 событий × 3 каналов (inapp / push / email) хранится в
|
||
* `users.notification_preferences` JSONB. По умолчанию (см. schema.sql:699):
|
||
* new_lead {inapp:true, push:true, email:false}
|
||
* reminder {inapp:true, push:true, email:true}
|
||
* low_balance {email:true}
|
||
* zero_balance {email:true}
|
||
* topup_success {email:true}
|
||
* invoice_paid {email:true}
|
||
* new_device_login {email:true}
|
||
* marketing {email:false}
|
||
*
|
||
* Stage 1 (этот коммит): email-канал реализован для new_lead.
|
||
* Stage 2 — in-app (требует in_app_notifications таблицы, schema v8.10).
|
||
* Stage 6 — остальные 4 email-события (low_balance/zero_balance/topup_success/invoice_paid).
|
||
* push-канал — Post-MVP (требует ServiceWorker + VAPID).
|
||
*/
|
||
class NotificationService
|
||
{
|
||
public const EVENT_NEW_LEAD = 'new_lead';
|
||
|
||
public const EVENT_REMINDER = 'reminder';
|
||
|
||
public const EVENT_LOW_BALANCE = 'low_balance';
|
||
|
||
public const EVENT_ZERO_BALANCE = 'zero_balance';
|
||
|
||
public const EVENT_TOPUP_SUCCESS = 'topup_success';
|
||
|
||
public const EVENT_INVOICE_PAID = 'invoice_paid';
|
||
|
||
public const EVENT_NEW_DEVICE_LOGIN = 'new_device_login';
|
||
|
||
public const EVENT_MARKETING = 'marketing';
|
||
|
||
public const ALL_EVENTS = [
|
||
self::EVENT_NEW_LEAD,
|
||
self::EVENT_REMINDER,
|
||
self::EVENT_LOW_BALANCE,
|
||
self::EVENT_ZERO_BALANCE,
|
||
self::EVENT_TOPUP_SUCCESS,
|
||
self::EVENT_INVOICE_PAID,
|
||
self::EVENT_NEW_DEVICE_LOGIN,
|
||
self::EVENT_MARKETING,
|
||
];
|
||
|
||
public const CHANNEL_INAPP = 'inapp';
|
||
|
||
public const CHANNEL_PUSH = 'push';
|
||
|
||
public const CHANNEL_EMAIL = 'email';
|
||
|
||
/**
|
||
* Уведомление о новом лиде — два канала:
|
||
* email: notification_preferences.new_lead.email=true (раздел 18.5);
|
||
* inapp: notification_preferences.new_lead.inapp=true (bell-icon).
|
||
*
|
||
* Webhook-поток: вызывается из ProcessWebhookJob::chargeNewLead() после
|
||
* успешного INSERT'а сделки + BalanceTransaction + ActivityLog.
|
||
*
|
||
* Расхождение каналов по schema-default: new_lead = {inapp:true, push:true,
|
||
* email:false} — большинство получит in-app, и только подписавшиеся — email.
|
||
*/
|
||
public function notifyNewLead(Tenant $tenant, Deal $deal): void
|
||
{
|
||
$projectName = $deal->project?->name ?? 'Без проекта';
|
||
|
||
// Канал email.
|
||
foreach ($this->recipientsForEvent($tenant, self::EVENT_NEW_LEAD, self::CHANNEL_EMAIL) as $user) {
|
||
$this->sendEmail($user, self::EVENT_NEW_LEAD, new NewLeadNotification($user, $deal, $tenant));
|
||
}
|
||
|
||
// Канал inapp.
|
||
$title = "Новый лид — {$projectName}";
|
||
$body = $deal->contact_name ?: $deal->phone;
|
||
foreach ($this->recipientsForEvent($tenant, self::EVENT_NEW_LEAD, self::CHANNEL_INAPP) as $user) {
|
||
$this->notifyInApp($user, self::EVENT_NEW_LEAD, $title, $body, [
|
||
'deal_id' => $deal->id,
|
||
'project_name' => $projectName,
|
||
]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Уведомление о наступлении срока напоминания. Получатели:
|
||
* — assignee_id, если задан и активен;
|
||
* — иначе created_by (создатель напоминания).
|
||
*
|
||
* Каналы: email + inapp по prefs пользователя. Триггер — Artisan
|
||
* команда `reminders:dispatch-due`.
|
||
*/
|
||
public function notifyReminder(Reminder $reminder): void
|
||
{
|
||
$recipientId = $reminder->assignee_id ?? $reminder->created_by;
|
||
$recipient = User::query()
|
||
->where('id', $recipientId)
|
||
->where('tenant_id', $reminder->tenant_id)
|
||
->where('is_active', true)
|
||
->whereNull('deleted_at')
|
||
->first();
|
||
|
||
if ($recipient === null) {
|
||
// Получатель удалён/деактивирован — некому слать.
|
||
return;
|
||
}
|
||
|
||
$shortText = $reminder->text ? mb_substr($reminder->text, 0, 80) : 'Срок касания клиента';
|
||
$title = "Напоминание — {$shortText}";
|
||
$body = 'Сделка #'.$reminder->deal_id;
|
||
|
||
if ($this->prefEnabled($recipient, self::EVENT_REMINDER, self::CHANNEL_EMAIL)) {
|
||
$this->sendEmail($recipient, self::EVENT_REMINDER, new ReminderDueNotification($recipient, $reminder));
|
||
}
|
||
|
||
if ($this->prefEnabled($recipient, self::EVENT_REMINDER, self::CHANNEL_INAPP)) {
|
||
$this->notifyInApp($recipient, self::EVENT_REMINDER, $title, $body, [
|
||
'reminder_id' => $reminder->id,
|
||
'deal_id' => $reminder->deal_id,
|
||
]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Уведомление о низком балансе. Триггер: ProcessWebhookJob после
|
||
* lead_charge, если balance_leads <= threshold.
|
||
*
|
||
* Получатели: все активные user'ы тенанта с new_lead.email=true
|
||
* (на MVP: те же что и для new_lead — обычно владелец и менеджеры).
|
||
* По prefs `low_balance.email`.
|
||
*/
|
||
public function notifyLowBalance(Tenant $tenant, int $thresholdLeads): void
|
||
{
|
||
$title = "Низкий баланс — {$tenant->balance_leads} лидов осталось";
|
||
$body = "Порог уведомления: {$thresholdLeads} лидов";
|
||
|
||
foreach ($this->recipientsForEvent($tenant, self::EVENT_LOW_BALANCE, self::CHANNEL_EMAIL) as $user) {
|
||
$this->sendEmail($user, self::EVENT_LOW_BALANCE, new LowBalanceNotification($user, $tenant, $thresholdLeads));
|
||
}
|
||
foreach ($this->recipientsForEvent($tenant, self::EVENT_LOW_BALANCE, self::CHANNEL_INAPP) as $user) {
|
||
$this->notifyInApp($user, self::EVENT_LOW_BALANCE, $title, $body, [
|
||
'tenant_id' => $tenant->id,
|
||
'balance_leads' => $tenant->balance_leads,
|
||
'threshold_leads' => $thresholdLeads,
|
||
]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Уведомление о нулевом балансе и отклонении лидов.
|
||
* Триггер: ProcessWebhookJob::logRejection(zero_balance) в первом
|
||
* RejectedDealsLog за последний час (anti-spam: не более 1 email/час
|
||
* на тенант, проверка в caller).
|
||
*/
|
||
public function notifyZeroBalance(Tenant $tenant): void
|
||
{
|
||
$title = 'Баланс закончился — лиды отклоняются';
|
||
$body = 'Пополните баланс в разделе Биллинг';
|
||
|
||
foreach ($this->recipientsForEvent($tenant, self::EVENT_ZERO_BALANCE, self::CHANNEL_EMAIL) as $user) {
|
||
$this->sendEmail($user, self::EVENT_ZERO_BALANCE, new ZeroBalanceNotification($user, $tenant));
|
||
}
|
||
foreach ($this->recipientsForEvent($tenant, self::EVENT_ZERO_BALANCE, self::CHANNEL_INAPP) as $user) {
|
||
$this->notifyInApp($user, self::EVENT_ZERO_BALANCE, $title, $body, [
|
||
'tenant_id' => $tenant->id,
|
||
]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Уведомление об auto-pause проекта на нулевом балансе (Plan 4 Task 6).
|
||
*
|
||
* В отличие от notifyZeroBalance (per-user prefs, rejected-flow), это
|
||
* прямое письмо tenant.contact_email — caller (RouteSupplierLeadJob::
|
||
* handleInsufficientBalance) уже применил rate-limit 1/час/tenant через
|
||
* Cache::store('redis')->add() (SETNX).
|
||
*
|
||
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §4.4.
|
||
*/
|
||
public function notifyZeroBalancePaused(Tenant $tenant, Project $project, int $requiredPriceKopecks): void
|
||
{
|
||
Mail::to($tenant->contact_email)->send(
|
||
new ZeroBalancePausedMail($tenant, $project, $requiredPriceKopecks)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Уведомление об успешном пополнении. Триггер: после INSERT в
|
||
* balance_transactions с type='topup' (endpoint пополнения отдельным
|
||
* коммитом). Mailable готов к подключению.
|
||
*/
|
||
public function notifyTopupSuccess(Tenant $tenant, string $amountRub, ?int $amountLeads = null): void
|
||
{
|
||
$title = "Баланс пополнен: +{$amountRub} ₽";
|
||
$body = $amountLeads !== null ? "+{$amountLeads} лидов" : null;
|
||
|
||
foreach ($this->recipientsForEvent($tenant, self::EVENT_TOPUP_SUCCESS, self::CHANNEL_EMAIL) as $user) {
|
||
$this->sendEmail(
|
||
$user,
|
||
self::EVENT_TOPUP_SUCCESS,
|
||
new TopupSuccessNotification($user, $tenant, $amountRub, $amountLeads),
|
||
);
|
||
}
|
||
foreach ($this->recipientsForEvent($tenant, self::EVENT_TOPUP_SUCCESS, self::CHANNEL_INAPP) as $user) {
|
||
$this->notifyInApp($user, self::EVENT_TOPUP_SUCCESS, $title, $body, [
|
||
'tenant_id' => $tenant->id,
|
||
'amount_rub' => $amountRub,
|
||
]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Уведомление об оплате тарифного счёта. Триггер: смена
|
||
* saas_invoice.status → 'paid' (webhook ЮKassa отдельным коммитом).
|
||
* Mailable готов к подключению.
|
||
*/
|
||
public function notifyInvoicePaid(
|
||
Tenant $tenant,
|
||
string $amountRub,
|
||
?string $invoiceNumber = null,
|
||
?string $tariffName = null,
|
||
): void {
|
||
$title = "Счёт оплачен: {$amountRub} ₽";
|
||
$body = $tariffName !== null ? "Тариф: {$tariffName}" : null;
|
||
|
||
foreach ($this->recipientsForEvent($tenant, self::EVENT_INVOICE_PAID, self::CHANNEL_EMAIL) as $user) {
|
||
$this->sendEmail(
|
||
$user,
|
||
self::EVENT_INVOICE_PAID,
|
||
new InvoicePaidNotification($user, $tenant, $amountRub, $invoiceNumber, $tariffName),
|
||
);
|
||
}
|
||
foreach ($this->recipientsForEvent($tenant, self::EVENT_INVOICE_PAID, self::CHANNEL_INAPP) as $user) {
|
||
$this->notifyInApp($user, self::EVENT_INVOICE_PAID, $title, $body, [
|
||
'tenant_id' => $tenant->id,
|
||
'amount_rub' => $amountRub,
|
||
'invoice_number' => $invoiceNumber,
|
||
]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* INSERT в `in_app_notifications` для bell-icon UI. RLS требует
|
||
* `app.current_tenant_id` = user.tenant_id, поэтому SET LOCAL внутри
|
||
* мини-транзакции (PgBouncer-safe). Throwable проглатываются + Log:
|
||
* сбой канала не должен валить трансакцию сделки.
|
||
*
|
||
* @param array<string, mixed> $payload
|
||
*/
|
||
public function notifyInApp(User $user, string $event, string $title, ?string $body = null, array $payload = []): void
|
||
{
|
||
try {
|
||
DB::transaction(function () use ($user, $event, $title, $body, $payload): void {
|
||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
|
||
|
||
InAppNotification::create([
|
||
'tenant_id' => $user->tenant_id,
|
||
'user_id' => $user->id,
|
||
'event' => $event,
|
||
'title' => $title,
|
||
'body' => $body,
|
||
'deal_id' => $payload['deal_id'] ?? null,
|
||
'payload' => $payload,
|
||
]);
|
||
});
|
||
} catch (Throwable $e) {
|
||
Log::warning('notification.inapp_failed', [
|
||
'user_id' => $user->id,
|
||
'event' => $event,
|
||
'error' => $e->getMessage(),
|
||
]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Активные user'ы тенанта с включённой подпиской на (event, channel).
|
||
* Soft-deleted и is_active=false исключены. Для тестов: маленькие
|
||
* списки получателей (<50 на тенант), фильтр в PHP вместо JSONB-запроса.
|
||
*
|
||
* @return Collection<int, User>
|
||
*/
|
||
private function recipientsForEvent(Tenant $tenant, string $event, string $channel): Collection
|
||
{
|
||
return User::query()
|
||
->where('tenant_id', $tenant->id)
|
||
->where('is_active', true)
|
||
->whereNull('deleted_at')
|
||
->get()
|
||
->filter(fn (User $user): bool => $this->prefEnabled($user, $event, $channel))
|
||
->values();
|
||
}
|
||
|
||
/**
|
||
* Читает users.notification_preferences[event][channel]. NULL/missing → false.
|
||
*/
|
||
private function prefEnabled(User $user, string $event, string $channel): bool
|
||
{
|
||
$prefs = $user->notification_preferences;
|
||
|
||
if (! is_array($prefs)) {
|
||
return false;
|
||
}
|
||
|
||
return (bool) ($prefs[$event][$channel] ?? false);
|
||
}
|
||
|
||
/**
|
||
* Отправка email через Mail-фасад. На dev (MAIL_MAILER=log) пишется в
|
||
* storage/logs/laravel.log, на prod — Unisender Go (SMTP relay).
|
||
*
|
||
* Используется Mail::send (sync) для простоты отладки. Для prod
|
||
* рекомендуется Mail::queue (async через worker), чтобы webhook
|
||
* worker не блокировался на SMTP. Бросаемые исключения проглатываются
|
||
* — отказ канала уведомления не должен валить транзакцию сделки.
|
||
*/
|
||
private function sendEmail(User $user, string $event, $mailable): void
|
||
{
|
||
try {
|
||
Mail::to($user->email)->send($mailable);
|
||
} catch (Throwable $e) {
|
||
Log::warning('notification.email_failed', [
|
||
'user_id' => $user->id,
|
||
'event' => $event,
|
||
'error' => $e->getMessage(),
|
||
]);
|
||
}
|
||
}
|
||
}
|