Files
portal/app/app/Services/NotificationService.php
T
Дмитрий ce87936f44 feat(billing): Plan 4 Task 6 — auto-pause flow + ZeroBalancePausedMail + 1/hour rate-limit
При 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>
2026-05-11 10:43:51 +03:00

355 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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(),
]);
}
}
}