Commit Graph

26 Commits

Author SHA1 Message Date
Дмитрий f6072b2885 feat(billing): R-19 — share-aware requiredLeadsForTomorrow
Tenant::requiredLeadsForTomorrow() previously summed raw daily_limit_target of
active projects, overcharging preflight when a tenant shared a call/site signal
with other tenants. Supplier caps the group at max(max(limits), ceil(Σ/3)) and
splits it across all clients on the same signal_identifier, so a single tenant's
real share is typically much smaller than its raw limit.

  group_limits = limits of all is_active projects sharing
                 (signal_type, agnostic signal_identifier/sms_sender+keyword)
  group_order  = max(max(group_limits), ceil(Σ group_limits / 3))
  tenant_share = ceil(group_order × (project_limit / Σ group_limits))

Legacy webhook projects (signal_type=null — no supplier sharing) still count
their full limit (regression-protected by existing 'sums daily_limit_target' test).
Empty groupLimits edge → conservative full-limit fallback (cross-conn race).

3 Pest tests: single project (legacy passthrough), 3-tenant share discriminator
(10→4), legacy webhook regression. Stage 4 §4.4.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:05:53 +03:00
Дмитрий a823518bb7 feat(billing): R-13 — sync paused_at on freeze/unfreeze transitions
Stage 3 Task 3.2. BalancePreflightSweepJob now mirrors freeze/unfreeze state
onto projects.paused_at so SupplierSnapshotGuard has the right hook to block
delete/change_source while the supplier slepok tail can still arrive:

- On freeze: capture freezeAt = now() once, set tenant.frozen_by_balance_at
  AND projects.paused_at (only WHERE paused_at IS NULL) to the same moment.
  This gives the snapshot guard a uniform recent paused_at across all of the
  tenant's projects.
- On unfreeze: capture frozen_at_was BEFORE save, then clear paused_at only
  on projects whose paused_at >= frozen_at_was (== auto-paused by us).
  Manual pauses set by the client BEFORE freeze have paused_at < frozen_at_was
  and stay preserved.

Spec §4.3.2.
2026-05-28 15:39:27 +03:00
Дмитрий 36d7fd1923 feat(billing): R-03 — LedgerService rejects frozen tenants
Stage 3 Task 3.1. Add frozen_by_balance_at guard in chargeForDelivery() before
bcmath arithmetic. Even if balance_rub > 0, a tenant flagged by
BalancePreflightSweepJob must not be charged for new lead deliveries. The
InsufficientBalanceException throw triggers the existing auto-pause flow
(RouteSupplierLeadJob::handleInsufficientBalance → projects.is_active=false +
ZeroBalancePausedMail rate-limited). Spec §4.3.1.
2026-05-28 15:33:36 +03:00
Дмитрий fea1443b18 feat(billing-v2-c): online supplier sync на freeze/unfreeze (привязка к SupplierExportMode)
При переходе active→frozen или frozen→active BalancePreflightSweepJob теперь дёргает SyncSupplierProjectJob per-project, если admin-переключатель в режиме online. В batch (рабочем для будущего масштаба) — sync отложен до cut-off cron 18:00 MSK через SyncSupplierProjectsJob.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:39:23 +03:00
Дмитрий e1601e7862 feat(billing-v2-c): UI префлайт Task 1.10 — баннер заморозки, индикатор ёмкости, диалог перегрузки
Spec C §3.6/§6.2. Бэкенд: GET /api/billing/balance-status (frozen + capacity + required + дефицит ₽/leads), Pest 6. Фронт: BalanceFrozenBanner (в AppLayout, глобально), BalanceCapacityIndicator (в BillingView под балансом), ProjectLimitOverloadDialog (409-перехват в NewProjectDialog: save-blocked/set-zero), tenantStore + api getBalanceStatus. Vitest +18.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:39:21 +03:00
Дмитрий fe4a409480 feat(billing-v2-c): one-time billing:preflight-initial-sweep
Task 1.9 плана 2026-05-24-billing-v2-spec-c-preflight-vtb.

Разовая artisan-команда для запуска при выкатке Spec C — прогоняет
BalancePreflightSweepJob по всем тенантам, замораживает legacy-
тенантов в минусе. Идемпотентна (sweep-job triggers только на
active↔frozen переходах, стабильное состояние не трогает).

TDD: 1 тест GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:39:20 +03:00
Дмитрий fd877ab156 feat(billing-v2-c): ProjectController preflight — 409 при перегрузке баланса
Task 1.7 плана 2026-05-24-billing-v2-spec-c-preflight-vtb.

store/update проверяют преfflight перед созданием/изменением проекта:
- если сумма daily_limit_target всех активных не-blocked проектов
  превышает capacity баланса (через BalancePreflightService) и не
  передан force_save_blocked=true → возврат 409 с JSON-телом:
  {error, current_balance_rub, current_capacity_leads,
   would_be_required_leads, deficit_leads}
- если force_save_blocked=true → проект создаётся/обновляется с
  preflight_blocked_at=now() (точечная заморозка одного проекта,
  не блокирует остальные).

Safe fallback: без активных pricing_tiers — преfflight skipped
(legacy-окружения без настроенного биллинга).

TDD: 4 теста GREEN (409 store / 409 update / force_save_blocked
создаёт blocked / norm pass через capacity).

Регрессия: 0 регрессий на Plan5 ProjectsStoreTest+ProjectsUpdateTest
(37/37 GREEN после safe fallback).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:39:19 +03:00
Дмитрий 787df436a3 feat(billing-v2-c): повторные письма заморозки (reminder +1д, final +3д)
Task 1.6 плана 2026-05-24-billing-v2-spec-c-preflight-vtb.

BalanceFrozenReminderJob — окна 24-48ч (reminder) и 72-96ч (final).
Throttle через balance_freeze_log markers (event_type 'reminder_sent' /
'final_sent') на 5 дней — повторов в окне не будет.

Re-evaluate PreflightResult для актуального дефицита в письме
(клиент мог частично пополнить — reminder покажет обновлённое число).

Schedule @18:30 MSK (после основного sweep @18:00) — если sweep
только что заморозил тенанта, reminder в тот же день не сработает
(окно 24h+ ещё не открыто).

TDD: 4 теста GREEN (reminder/final/skip-fresh/throttle).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:39:18 +03:00
Дмитрий df12ac6757 fix(billing-v2-c): per-tenant Mail-фильтр в idempotent-тесте sweep-job
liderra_testing persistent (RefreshDatabase off) — DemoSeeder тенанты
могут попасть в sweep и тоже получить BalanceFrozenMail. Без per-tenant
фильтра Mail::assertNotQueued() ловил 154 фоновых письма и валил тест.

Логика BalancePreflightSweepJob корректна — фикс только в test isolation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:39:17 +03:00
Дмитрий 53f9020653 feat(billing-v2-c): sweep-job заморозки + 4 mailable + cron 18:00 MSK
Task 1.4+1.5 Спека C. BalancePreflightSweepJob (chunkById всех тенантов,
переход active->frozen / frozen->active, идемпотентность, журнал balance_freeze_log
через pgsql_supplier) + BillingPreflightSweepCommand + cron billing:preflight-sweep
@18:00 MSK (SyncSupplierProjectsJob сдвинут 18:00->18:05). 4 Mailable
(Frozen/Reminder/Final/Unfrozen) + blade. Job шлёт Frozen/Unfrozen при переходах;
Reminder/Final (T+24h/T+72h) — классы готовы, рассылка по дате — следующий шаг.
11 Phase 1 billing-тестов GREEN. Адаптации под факт схемы: contact_email (не email),
organization_name (не name), is_active+daily_limit_target (не status+daily_limit).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:39:15 +03:00
Дмитрий b1c3f39e38 feat(billing-v2-c): Tenant::requiredLeadsForTomorrow + cast флагов заморозки
Task 1.3 Спека C. Tenant: +frozen_by_balance_at (fillable+cast datetime) +
requiredLeadsForTomorrow() (sum daily_limit_target активных проектов). Project:
+preflight_blocked_at (fillable+cast). NB: фильтр по is_active (boolean) +
daily_limit_target — у projects нет колонок status/daily_limit (план поправлен
под факт схемы). 3 теста GREEN.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:39:14 +03:00
Дмитрий 125e9a7948 fix(billing-v2): restore charged_at ISO-8601 format in CSV export (A.10 followup) 2026-05-23 18:46:16 +03:00
Дмитрий 7011836ccb fix(billing-v2): charges CSV export — fill balance_rub_after via JOIN 2026-05-23 18:46:15 +03:00
Дмитрий 67a9d5ab96 feat(billing-v2): transactions API — drop refund filter, add display_amount_rub 2026-05-23 18:46:14 +03:00
Дмитрий f3b94b5726 refactor(billing-v2): runwayDays = affordable_leads ÷ avg-leads-per-day 2026-05-23 18:46:13 +03:00
Дмитрий 714e70bcef feat(billing-v2): wallet API — affordable_leads + current_tier + tiers_preview 2026-05-23 18:46:12 +03:00
Дмитрий 0b2e5edf34 refactor(billing-v2): LedgerService — drop prepaid branch, always rub 2026-05-23 18:46:12 +03:00
Дмитрий 4bf2c51b93 refactor(billing-v2): drop ChargeResult::source (always rub now) 2026-05-23 18:46:11 +03:00
Дмитрий 65c5178c29 fix(billing): runwayDays clamps negative balance to 0 + type-filter test (Task 2 review)
Code-quality review fixups: runway_days клампится в 0 при отрицательном
балансе (overdrawn-тенант не должен показывать «−N дней»); (int)-каст в
wallet() для консистентности; усилены assertJsonPath на type-фильтре.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 07:24:34 +03:00
Дмитрий 040d25423d feat(billing): wallet/transactions/invoices read API (E3)
GET /api/billing/wallet (баланс + тариф + runway), /transactions
(пагинированный balance_transactions с фильтром type), /invoices
(saas_invoices, real-but-empty до Б-1). TariffPlan модель +
Tenant::tariff() relation + BalanceTransactionFactory.

Sprint 2 Plan C, audit E3 (backend).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 07:08:09 +03:00
Дмитрий 7bee35768d fix(billing): topup save() rationale comment + cross-tenant test (Task 1 review)
Code-quality review fixups: документирующий комментарий про безопасность
Eloquent save() для bcmath-строки (расхождение с LedgerService raw-update);
cross-tenant isolation тест на /api/billing/topup; balance_rub_after в
assertDatabaseHas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 07:00:19 +03:00
Дмитрий 44dc1025ec feat(billing): topup ledger service + POST /api/billing/topup stub (E1)
BillingTopupService кредитует tenants.balance_rub (bcmath) и пишет
append-only строку balance_transactions(type='topup'). BillingController
+ route POST /api/billing/topup под [auth:sanctum, tenant]. MVP-stub:
без платёжного шлюза (ЮKassa — post-Б-1).

Sprint 2 Plan C, audit E1 (backend).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 06:46:52 +03:00
Дмитрий 174dbae808 feat(billing): Plan 4 Task 11 — TenantChargesController + ChargesTab + CSV export
Backend TenantChargesController:
- GET /api/billing/charges — paginated list, filters period (current_month / last_month / 90d) + charge_source.
- POST /api/billing/charges/export — StreamedResponse CSV (BOM + UTF-8) с chunkById(500).
- auth:sanctum + tenant middleware — RLS изолирует tenant_id.
- 6 Pest integration tests (RLS isolation + filters + pagination + CSV export).

Frontend ChargesTab.vue:
- v-data-table-server с paginated load + period/charge_source filters.
- CSV-download через blob → createObjectURL.
- Forest-palette + JetBrains Mono tnum.

BillingView.vue — добавлен tab «Списания» с импортом ChargesTab.
ChargesTab.story.vue + 4 Vitest tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:51:13 +03:00
Дмитрий d2030f9121 feat(billing): Plan 4 Task 3 — LedgerService::chargeForDelivery (dual-balance + lead_charges/supplier_lead_costs INSERT) 2026-05-11 09:42:29 +03:00
Дмитрий 1e0c0ab90a fix(billing): Plan 4 Task 2 code-review fixes (2 Important + 1 Minor)
- PricingTierResolver::resolveForCount — InvalidArgumentException на
  $leadOrdinal < 1 (closes I-1: defensive contract validation).
- PricingTierRepository::activeAt — explicit @var Collection<int,
  PricingTier> annotation для type narrowing (closes I-2; firstOrFail
  отвергнут — Stan ругается на Eloquent\Model return-type).
- PricingTierResolverTest — +1 unit test (8/8 PASS): throws на 0/-1.
- PricingTierRepositoryTest — +1 integration test (5/5 PASS): excludes
  inactive tiers (closes M-2 coverage gap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:17:13 +03:00
Дмитрий e07d025efd feat(billing): Plan 4 Task 2 — PricingTierResolver + Repository (pure resolver + DB-обёртка)
- PricingTierResolver: pure-function, ищет tier для N-го лида (1-based).
  100→tier1, 101→tier2, 6000→tier6 (cumul.sum 1-6), 6001+→tier7 (NULL=unlimited).
  RuntimeException на пустой коллекции.
- PricingTierRepository::activeAt(Carbon): DB-обёртка, MAX(effective_from) <= $at
  per tier_no (учёт «новая сетка перекрывает старую»), is_active=true.
- 7 unit-тестов (in-memory, без БД) + 4 integration-теста (DatabaseTransactions
  с baseline-cleanup для seed-7-tiers из PricingTierSeeder Task 1).
- phpstan-baseline.neon: +3 entry (Pest TestCall::\$resolver/\$tiers/\$repo) —
  следуем project-convention (см. SupplierResolverTest идентичные baseline-entries).

Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §3.1
Plan: docs/superpowers/plans/2026-05-11-plan4-billing-csv-admin.md Task 2

Tests: 633 passed / 3 skipped / 0 failed (+11 new); pint clean; stan 0 errors above baseline.
2026-05-11 09:05:22 +03:00