Files
portal/app/tests/Feature/Billing/NoDoubleChargeOnSourceChangeTest.php
T

169 lines
6.9 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
use App\Jobs\RouteSupplierLeadJob;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
use App\Services\RegionTagResolver;
use App\Services\SupplierProjects\SupplierProjectResolver;
use Carbon\Carbon;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
/**
* Task 2.7 — страховка от двойного списания вокруг смены источника.
*
* Шов §9b «двойное списание»: переделка матча на слепок (Task 2.2/2.6) могла бы
* развести project_id у CSV-recovered лида и догоняющего webhook одного физического
* лида. Если бы они попали в РАЗНЫЕ проекты — merge по (tenant, phone, project_id)
* не сработал бы → второй Deal → второе списание.
*
* Тест гоняется с ФЛАГОМ ВКЛ (routing_match_by_snapshot=true), чтобы доказать:
* новый матч по слепку оставляет project_id сходящимся, merge срабатывает,
* списание ровно одно. Это страховка — она зелёная и ДО, и ПОСЛЕ переделки.
* Если позеленела до и покраснела после — матч развёл project_id (см. план §9b).
*/
beforeEach(function (): void {
$this->seed(PricingTierSeeder::class);
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
// Новый матч по слепку ВКЛ — тестируем риск §9b именно на нём.
DB::table('system_settings')->updateOrInsert(
['key' => 'routing_match_by_snapshot'],
['value' => 'true', 'type' => 'bool', 'updated_at' => now()],
);
// sms supplier_project — самая рискованная ветка матча (sms_senders[0]+keyword).
$this->sp = SupplierProject::factory()->create([
'platform' => 'B3',
'signal_type' => 'sms',
'unique_key' => 'Caranga',
]);
$this->tenant = Tenant::factory()->create([
'balance_rub' => '10000.00',
'delivered_in_month' => 0,
]);
$this->project = Project::factory()->create([
'tenant_id' => $this->tenant->id,
'signal_type' => 'sms',
'signal_identifier' => null,
'sms_senders' => ['Caranga'],
'sms_keyword' => null,
'supplier_b3_project_id' => $this->sp->id,
'is_active' => true,
'daily_limit_target' => 100,
'effective_daily_limit_today' => 100,
'delivered_today' => 0,
'delivery_days_mask' => 127,
'region_mask' => 255,
]);
linkProjectToSupplier($this->project, $this->sp);
// Слепки на сегодня и завтра — несут sms-источник Caranga.
insertSmsSnapshotFor($this->project, Carbon::today('Europe/Moscow')->toDateString());
insertSmsSnapshotFor($this->project, Carbon::tomorrow('Europe/Moscow')->toDateString());
});
function insertSmsSnapshotFor(Project $project, string $date): void
{
DB::table('project_routing_snapshots')->insert([
'snapshot_date' => $date,
'project_id' => $project->id,
'tenant_id' => $project->tenant_id,
'daily_limit' => 100,
'delivery_days_mask' => 127,
'regions' => '{}',
'signal_type' => 'sms',
'signal_identifier' => null,
'sms_senders' => json_encode(['Caranga']),
'sms_keyword' => null,
'expected_volume' => 100,
'delivered_count' => 0,
'created_at' => now(),
]);
}
function runNoDoubleChargeJob(int $supplierLeadId): void
{
(new RouteSupplierLeadJob($supplierLeadId))->handle(
app(LeadRouter::class),
app(SupplierProjectResolver::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
app(RegionTagResolver::class),
);
}
it('не списывает дважды когда CSV-recovery и webhook одного лида приходят вокруг смены источника', function (): void {
$phone = '79161234567';
// ── CSV-recovered лид (vid=null) → создаёт Deal ──
$csvLead = SupplierLead::factory()->create([
'platform' => 'B3',
'phone' => $phone,
'vid' => null,
'supplier_project_id' => $this->sp->id,
'raw_payload' => [
'project' => 'B3_Caranga',
'phone' => $phone,
'time' => now()->subHour()->getTimestamp(),
],
'received_at' => now()->subHour(),
'recovered_from_csv_at' => now()->subHour(),
'source' => 'csv_recovery',
'processed_at' => null,
]);
runNoDoubleChargeJob($csvLead->id);
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
$csvDeal = Deal::where('phone', $phone)->first();
expect($csvDeal)->not->toBeNull('CSV-recovery должен создать Deal под матчем по слепку');
expect(LeadCharge::where('deal_id', $csvDeal->id)->count())->toBe(1);
$balanceAfterCsv = (string) $this->tenant->fresh()->balance_rub;
// ── webhook того же физического лида (vid=int) приходит позже ──
$webhookLead = SupplierLead::factory()->create([
'platform' => 'B3',
'phone' => $phone,
'vid' => 555,
'supplier_project_id' => $this->sp->id,
'raw_payload' => [
'vid' => 555,
'project' => 'B3_Caranga',
'phone' => $phone,
'time' => now()->subMinutes(15)->getTimestamp(),
],
'received_at' => now()->subMinutes(15),
'source' => 'webhook',
'processed_at' => null,
]);
runNoDoubleChargeJob($webhookLead->id);
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
// ── ОДНА сделка, ОДНО списание, баланс не списан дважды ──
$deals = Deal::where('phone', $phone)->get();
expect($deals)->toHaveCount(1, '§9b: webhook после CSV merge'.'ится в один deal — project_id сошёлся под матчем по слепку');
expect((int) $deals->first()->source_crm_id)->toBe(555);
expect(LeadCharge::where('deal_id', $csvDeal->id)->count())->toBe(1, '§9b: второго списания нет');
expect((string) $this->tenant->fresh()->balance_rub)->toBe($balanceAfterCsv, '§9b: баланс не списан второй раз');
});