create(); $svc = app(AdWalletService::class); $svc->topup($tenant->id, '1000.00', TG_CHANNEL, 'Пополнение'); $svc->freeze($tenant->id, TG_CHANNEL, 'client_tg_campaign', 5, '420.00'); $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); expect((string) $wallet->balance_rub)->toBe('1000.00') ->and((string) $wallet->frozen_rub)->toBe('420.00') ->and(AdWalletTransaction::where('tenant_id', $tenant->id)->where('channel', TG_CHANNEL)->count())->toBe(2); // topup + freeze }); it('freeze стережёт нехватку средств и бросает ДО списания', function () { $tenant = Tenant::factory()->create(); $svc = app(AdWalletService::class); $svc->topup($tenant->id, '300.00', TG_CHANNEL, 'Пополнение'); // free = balance − frozen = 300 < 400 → бросок ДО каких-либо списаний expect(fn () => $svc->freeze($tenant->id, TG_CHANNEL, 'client_tg_campaign', 1, '400.00')) ->toThrow(InsufficientBalanceException::class); $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); expect((string) $wallet->frozen_rub)->toBe('0.00'); // ничего не заморозилось }); it('charge НЕ бросает и обнуляет баланс, не уходя в минус', function () { $tenant = Tenant::factory()->create(); $svc = app(AdWalletService::class); $svc->topup($tenant->id, '300.00', TG_CHANNEL, 'Пополнение'); // charge не бросает — потому проверка средств обязана быть ДО (через freeze). $svc->charge($tenant->id, TG_CHANNEL, 'client_tg_campaign', 1, '400.00', 'tg:1:test'); $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); expect((string) $wallet->balance_rub)->toBe('0.00') // пол по нулю, не минус ->and(AdWalletTransaction::where('tenant_id', $tenant->id) ->where('channel', TG_CHANNEL)->where('type', AdWalletTransaction::TYPE_CHARGE)->exists())->toBeTrue(); }); it('полный поток telegram: topup → freeze → charge, идемпотентно по external_key', function () { $tenant = Tenant::factory()->create(); $svc = app(AdWalletService::class); $svc->topup($tenant->id, '1000.00', TG_CHANNEL, 'Пополнение'); $svc->freeze($tenant->id, TG_CHANNEL, 'client_tg_campaign', 7, '420.00'); $svc->charge($tenant->id, TG_CHANNEL, 'client_tg_campaign', 7, '420.00', 'tg:7:2026-07-27'); // повторное списание тем же external_key — идемпотентно, ничего не меняет $svc->charge($tenant->id, TG_CHANNEL, 'client_tg_campaign', 7, '420.00', 'tg:7:2026-07-27'); $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); expect((string) $wallet->balance_rub)->toBe('580.00') ->and(AdWalletTransaction::where('tenant_id', $tenant->id) ->where('channel', TG_CHANNEL)->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(1); });