Files
portal/app/tests/Feature/Billing/NoDoubleChargeOnSourceChangeTest.php
T
Дмитрий b7379e7a03 test/billing: страховка от двойного списания CSV+webhook вокруг смены источника
Под флагом routing_match_by_snapshot=ВКЛ: CSV-recovered лид + догоняющий webhook
одного физлида (sms-источник Caranga) сходятся в ОДИН Deal/charge. Доказывает шов §9b —
новый матч по слепку не разводит project_id, merge срабатывает, баланс не списан дважды.

Эпик 2 Task 2.7. baseline +4 записи (Pest higher-order $this-noise, как CsvWebhookRaceTest).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:30:47 +03:00

169 lines
6.9 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);
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: баланс не списан второй раз');
});