Berater faellt aus (B2): pausieren nach 2 Tagen, Rueckgabe bei Zahlung, harter Cut am 28. Tag
Entwicklungsplan September, B2 (Protokoll 08.09., Rueckmeldung 14.09., R3): - Ablauftag + 2 Kalendertage (Nachtlauf): Team, Kunden, Kundenabos und Portal-Logins gehen umkehrbar an den naechsten aktiven Sponsor, jede Umhaengung in consultant_suspension_transfers protokolliert. Kurzinfo an den Sponsor ohne Listen, keine Mail an das Firmenkonto. - Zahlung bis Tag 27: Berater aktiv, Team und Kundendaten zurueck (nur was noch beim Sponsor liegt); Umsaetze der Zwischenzeit bleiben dort. - Tag 28: was noch am Berater haengt geht endgueltig an den Sponsor, Berater wird dessen Kunde (ohne Duplikat) und geloescht; Listen-Mail mit Vorname, Nachname, Telefon, E-Mail an jeden haltenden Sponsor. - Cron, Zahlungseingang und Admin-Ablaufdatum laufen ueber ConsultantSuspensionService; user:cleanup --dry-run listet beide Stufen. - Mail 35 und Dashboard nennen Pausentag und Frist; Fristen in config/mivita.php (cleanup_suspend_days, cleanup_delete_days). - Nebenbefunde: Kernel referenziert UserCleanUp, user:restore aus der User-Bereinigung laeuft nur noch einmal. - Testschema: shopping_users.auth_user_id nullable wie auf Produktion (Migration aendert nur, wo die Spalte noch NOT NULL ist). - Uebersicht fuer die Kundin: dev/_tickets/2026-09-23-mitgliedschaft- ablauf-uebersicht.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6cd00a60ec
commit
0b8f8abeeb
29 changed files with 1528 additions and 193 deletions
342
tests/Feature/ConsultantSuspensionTest.php
Normal file
342
tests/Feature/ConsultantSuspensionTest.php
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\AdminUserCleanupController;
|
||||
use App\Mail\SponsorSuspensionNoticeMail;
|
||||
use App\Mail\SponsorTransferMail;
|
||||
use App\Models\ConsultantSuspensionTransfer;
|
||||
use App\Models\Country;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Product;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingOrderItem;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserAccount;
|
||||
use App\Models\UserCleanUpLog;
|
||||
use App\Services\ConsultantSuspensionService;
|
||||
use App\Services\Payment;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* Entwicklungsplan B2: Berater faellt aus — pausieren nach 2 Tagen, Rueckgabe bei
|
||||
* Zahlung, harter Cut nach 28 Tagen, Sponsor-Mails.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Carbon::setTestNow(Carbon::parse('2026-10-01 03:30:00'));
|
||||
config(['mivita.cleanup_suspend_days' => 2, 'mivita.cleanup_delete_days' => 28, 'mivita.company_user_id' => 6]);
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
afterEach(fn () => Carbon::setTestNow());
|
||||
|
||||
function susConsultant(?User $sponsor, string $expires, string $firstName = 'Berater'): User
|
||||
{
|
||||
$account = UserAccount::create([
|
||||
'first_name' => $firstName,
|
||||
'last_name' => 'Test',
|
||||
'm_first_name' => $firstName,
|
||||
'm_last_name' => 'Test',
|
||||
'm_account' => 'M'.random_int(1000, 99999),
|
||||
'mobil' => '170 1234567',
|
||||
]);
|
||||
|
||||
return User::forceCreate([
|
||||
'email' => strtolower($firstName).'-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
'active' => 1,
|
||||
'blocked' => 0,
|
||||
'admin' => 0,
|
||||
'wizard' => 100,
|
||||
'm_level' => 1,
|
||||
'm_sponsor' => $sponsor?->id,
|
||||
'payment_account' => Carbon::parse($expires),
|
||||
'account_id' => $account->id,
|
||||
])->fresh();
|
||||
}
|
||||
|
||||
function susCustomer(User $member, string $firstName = 'Kundin'): ShoppingUser
|
||||
{
|
||||
$country = Country::first() ?? Country::create([
|
||||
'code' => 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland',
|
||||
'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия',
|
||||
]);
|
||||
|
||||
return ShoppingUser::create([
|
||||
'auth_user_id' => 0,
|
||||
'member_id' => $member->id,
|
||||
'number' => random_int(1000, 99999),
|
||||
'billing_firstname' => $firstName,
|
||||
'billing_lastname' => 'Muster',
|
||||
'billing_phone' => '0171 999',
|
||||
'billing_email' => strtolower($firstName).'-'.uniqid('', true).'@example.com',
|
||||
'billing_country_id' => $country->id,
|
||||
'shipping_country_id' => $country->id,
|
||||
]);
|
||||
}
|
||||
|
||||
function susCustomerAbo(User $member, ShoppingUser $customer): UserAbo
|
||||
{
|
||||
return UserAbo::create([
|
||||
'user_id' => null,
|
||||
'member_id' => $member->id,
|
||||
'shopping_user_id' => $customer->id,
|
||||
'is_for' => 'ot',
|
||||
'email' => $customer->billing_email,
|
||||
'payone_userid' => 1,
|
||||
'clearingtype' => 'cc',
|
||||
'active' => true,
|
||||
'status' => 2,
|
||||
'abo_interval' => 10,
|
||||
'start_date' => '2026-01-10',
|
||||
'next_date' => '2026-10-10',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sponsor mit Berater darunter: Kundin mit Abo und Portal-Login, ein Teampartner.
|
||||
*
|
||||
* @return array{sponsor: User, consultant: User, child: User, customer: ShoppingUser, abo: UserAbo, portal: Customer}
|
||||
*/
|
||||
function susScenario(string $expires = '2026-09-28 10:00:00'): array
|
||||
{
|
||||
$sponsor = susConsultant(null, '2027-06-01', 'Sponsor');
|
||||
$consultant = susConsultant($sponsor, $expires, 'Anna');
|
||||
$child = susConsultant($consultant, '2027-03-01', 'Ben');
|
||||
$customer = susCustomer($consultant);
|
||||
$abo = susCustomerAbo($consultant, $customer);
|
||||
$portal = Customer::create([
|
||||
'email' => $customer->billing_email,
|
||||
'shopping_user_id' => $customer->id,
|
||||
'member_id' => $consultant->id,
|
||||
'number' => $customer->number,
|
||||
]);
|
||||
|
||||
return compact('sponsor', 'consultant', 'child', 'customer', 'abo', 'portal');
|
||||
}
|
||||
|
||||
describe('Stufe 1: pausieren', function () {
|
||||
it('laesst den Berater am ersten Tag nach Ablauf noch in Ruhe', function () {
|
||||
$s = susScenario('2026-09-30 10:00:00');
|
||||
|
||||
$this->artisan('user:cleanup')->assertSuccessful();
|
||||
|
||||
expect($s['consultant']->fresh()->active)->toBe(1)
|
||||
->and($s['customer']->fresh()->member_id)->toBe($s['consultant']->id);
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
it('haengt nach 2 Tagen Team, Kunden, Kundenabos und Portal-Logins an den Sponsor', function () {
|
||||
$s = susScenario('2026-09-28 10:00:00');
|
||||
|
||||
$this->artisan('user:cleanup')->assertSuccessful();
|
||||
|
||||
$consultant = $s['consultant']->fresh();
|
||||
expect($consultant->active)->toBe(0)
|
||||
->and($consultant->m_sponsor)->toBeNull()
|
||||
->and($consultant->pre_sponsor)->toBe($s['sponsor']->id)
|
||||
->and($s['child']->fresh()->m_sponsor)->toBe($s['sponsor']->id)
|
||||
->and($s['customer']->fresh()->member_id)->toBe($s['sponsor']->id)
|
||||
->and($s['abo']->fresh()->member_id)->toBe($s['sponsor']->id)
|
||||
->and($s['portal']->fresh()->member_id)->toBe($s['sponsor']->id)
|
||||
->and(ConsultantSuspensionTransfer::openFor($consultant->id)->count())->toBe(3);
|
||||
});
|
||||
|
||||
it('schickt dem Sponsor die Kurzinfo ohne Listen, mit Frist', function () {
|
||||
$s = susScenario('2026-09-28 10:00:00');
|
||||
|
||||
$this->artisan('user:cleanup')->assertSuccessful();
|
||||
|
||||
Mail::assertSent(SponsorSuspensionNoticeMail::class, function (SponsorSuspensionNoticeMail $mail) use ($s) {
|
||||
$html = $mail->render();
|
||||
|
||||
return $mail->hasTo($s['sponsor']->email)
|
||||
&& str_contains($html, 'Anna Test')
|
||||
&& str_contains($html, '25.10.2026')
|
||||
&& str_contains($html, '(24 Tage)')
|
||||
&& ! str_contains($html, $s['customer']->billing_email);
|
||||
});
|
||||
});
|
||||
|
||||
it('schickt dem Firmenkonto keine Mail', function () {
|
||||
$company = User::forceCreate(['id' => 6, 'email' => 'firma@example.com', 'password' => 'x', 'lang' => 'de', 'active' => 1, 'payment_account' => '2030-01-01']);
|
||||
$consultant = susConsultant($company, '2026-09-28 10:00:00');
|
||||
|
||||
$this->artisan('user:cleanup')->assertSuccessful();
|
||||
|
||||
expect($consultant->fresh()->active)->toBe(0);
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
it('aendert im Trockenlauf nichts und schickt nichts', function () {
|
||||
$s = susScenario('2026-09-28 10:00:00');
|
||||
|
||||
$this->artisan('user:cleanup', ['--dry-run' => true])
|
||||
->expectsOutputToContain('Pausieren (Ablauf vor mind. 2 Tagen): 1 Berater')
|
||||
->assertSuccessful();
|
||||
|
||||
expect($s['consultant']->fresh()->active)->toBe(1)
|
||||
->and($s['customer']->fresh()->member_id)->toBe($s['consultant']->id);
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rueckgabe bei Zahlung', function () {
|
||||
it('gibt Team und Kundendaten zurueck, wenn der Berater innerhalb der Frist zahlt', function () {
|
||||
$s = susScenario('2026-09-28 10:00:00');
|
||||
$this->artisan('user:cleanup')->assertSuccessful();
|
||||
|
||||
Carbon::setTestNow(Carbon::parse('2026-10-08 12:00:00'));
|
||||
$product = Product::forceCreate([
|
||||
'name' => 'Mitgliedschaft', 'title' => 'Mitgliedschaft', 'price' => 49.0, 'tax' => 19.0,
|
||||
'active' => true, 'pos' => 1, 'weight' => 0, 'points' => 0, 'action' => [0],
|
||||
]);
|
||||
$order = ShoppingOrder::forceCreate([
|
||||
'shopping_user_id' => 0, 'auth_user_id' => $s['consultant']->id, 'country_id' => 0,
|
||||
'user_shop_id' => 0, 'payment_for' => 0, 'paid' => false,
|
||||
]);
|
||||
ShoppingOrderItem::forceCreate(['shopping_order_id' => $order->id, 'product_id' => $product->id, 'qty' => 1, 'price' => 49.0]);
|
||||
|
||||
Payment::paymentStatusPaidAction($order->fresh(), true);
|
||||
|
||||
$consultant = $s['consultant']->fresh();
|
||||
expect($consultant->active)->toBe(1)
|
||||
->and($consultant->m_sponsor)->toBe($s['sponsor']->id)
|
||||
->and($s['child']->fresh()->m_sponsor)->toBe($consultant->id)
|
||||
->and($s['customer']->fresh()->member_id)->toBe($consultant->id)
|
||||
->and($s['abo']->fresh()->member_id)->toBe($consultant->id)
|
||||
->and($s['portal']->fresh()->member_id)->toBe($consultant->id)
|
||||
->and(ConsultantSuspensionTransfer::openFor($consultant->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('laesst einen inzwischen von Hand uebertragenen Kunden, wo er ist', function () {
|
||||
$s = susScenario('2026-09-28 10:00:00');
|
||||
$this->artisan('user:cleanup')->assertSuccessful();
|
||||
|
||||
$other = susConsultant($s['sponsor'], '2027-05-01', 'Clara');
|
||||
$s['customer']->update(['member_id' => $other->id]);
|
||||
|
||||
$summary = app(ConsultantSuspensionService::class)->reinstate($s['consultant']);
|
||||
|
||||
expect($s['customer']->fresh()->member_id)->toBe($other->id)
|
||||
->and($s['abo']->fresh()->member_id)->toBe($s['consultant']->id)
|
||||
->and($summary['kept'])->toBe(1)
|
||||
->and($summary['abos'])->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stufe 2: harter Cut', function () {
|
||||
it('loescht den Berater nach 28 Tagen, macht ihn zum Kunden des Sponsors und schickt die Listen', function () {
|
||||
$s = susScenario('2026-09-28 10:00:00');
|
||||
$this->artisan('user:cleanup')->assertSuccessful();
|
||||
|
||||
Carbon::setTestNow(Carbon::parse('2026-10-27 03:30:00'));
|
||||
$this->artisan('user:cleanup')->assertSuccessful();
|
||||
|
||||
$consultant = User::withTrashed()->find($s['consultant']->id);
|
||||
expect($consultant->deleted_at)->not->toBeNull()
|
||||
->and($s['customer']->fresh()->member_id)->toBe($s['sponsor']->id)
|
||||
->and(ShoppingUser::where('member_id', $s['sponsor']->id)->where('billing_lastname', 'Test')->count())->toBe(1);
|
||||
|
||||
Mail::assertSent(SponsorTransferMail::class, function (SponsorTransferMail $mail) use ($s) {
|
||||
$html = $mail->render();
|
||||
|
||||
return $mail->hasTo($s['sponsor']->email)
|
||||
&& str_contains($html, $s['customer']->billing_email)
|
||||
&& str_contains($html, '0171 999')
|
||||
&& str_contains($html, $s['child']->email)
|
||||
&& str_contains($html, 'Kunden (2)')
|
||||
&& str_contains($html, 'Teampartner (1)');
|
||||
});
|
||||
});
|
||||
|
||||
it('legt keinen zweiten Kundendatensatz an, wenn der Berater schon Kunde beim Sponsor ist', function () {
|
||||
$s = susScenario('2026-09-20 10:00:00');
|
||||
$own = susCustomer($s['consultant'], 'Anna');
|
||||
$own->update(['auth_user_id' => $s['consultant']->id]);
|
||||
|
||||
Carbon::setTestNow(Carbon::parse('2026-10-19 03:30:00'));
|
||||
$this->artisan('user:cleanup')->assertSuccessful();
|
||||
|
||||
expect(ShoppingUser::where('auth_user_id', $s['consultant']->id)->count())->toBe(1)
|
||||
->and($own->fresh()->member_id)->toBe($s['sponsor']->id);
|
||||
});
|
||||
|
||||
it('uebernimmt beim harten Cut auch Kunden von Beratern, die noch nach der alten Regel inaktiv sind', function () {
|
||||
$s = susScenario('2026-09-01 10:00:00');
|
||||
// Alte 2-Wochen-Stufe: nur das Team war umgehaengt, die Kunden blieben stehen.
|
||||
$s['consultant']->forceFill(['active' => 0, 'pre_sponsor' => $s['sponsor']->id, 'm_sponsor' => null])->save();
|
||||
|
||||
$this->artisan('user:cleanup')->assertSuccessful();
|
||||
|
||||
expect(User::withTrashed()->find($s['consultant']->id)->deleted_at)->not->toBeNull()
|
||||
->and($s['customer']->fresh()->member_id)->toBe($s['sponsor']->id)
|
||||
->and($s['abo']->fresh()->member_id)->toBe($s['sponsor']->id)
|
||||
->and($s['portal']->fresh()->member_id)->toBe($s['sponsor']->id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin setzt das Ablaufdatum', function () {
|
||||
it('nutzt fuer Pausieren und Rueckgabe denselben Service', function () {
|
||||
$s = susScenario('2027-01-01 10:00:00');
|
||||
$service = app(ConsultantSuspensionService::class);
|
||||
|
||||
$service->suspend($s['consultant'], 'test');
|
||||
expect($s['customer']->fresh()->member_id)->toBe($s['sponsor']->id)
|
||||
->and(UserCleanUpLog::where('inactive_sponsor_id', $s['consultant']->id)->count())->toBe(1);
|
||||
|
||||
$service->reinstate($s['consultant'], 'test');
|
||||
expect($s['customer']->fresh()->member_id)->toBe($s['consultant']->id)
|
||||
->and($s['child']->fresh()->m_sponsor)->toBe($s['consultant']->id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('User-Bereinigung: Wiederherstellen im Admin', function () {
|
||||
it('ruft user:restore genau einmal auf', function () {
|
||||
Artisan::shouldReceive('call')
|
||||
->once()
|
||||
->with('user:restore', ['user_id' => 42])
|
||||
->andReturn(0);
|
||||
Artisan::shouldReceive('output')->once()->andReturn('ok');
|
||||
|
||||
$response = app(AdminUserCleanupController::class)
|
||||
->restore(new Request(['user_id' => 42]));
|
||||
|
||||
expect($response->getData(true))->toMatchArray(['success' => true, 'output' => 'ok']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fristen in Kalendertagen (Beispiel aus der Kundenuebersicht)', function () {
|
||||
it('pausiert bei Ablauf am 1. in der Nacht zum 3. und entfernt in der Nacht zum 29.', function () {
|
||||
$s = susScenario('2026-10-01 23:00:00');
|
||||
$service = app(ConsultantSuspensionService::class);
|
||||
|
||||
expect($service->suspensionDateFor($s['consultant'])->format('d.m.Y'))->toBe('03.10.2026')
|
||||
->and($service->deadlineFor($s['consultant'])->format('d.m.Y'))->toBe('28.10.2026');
|
||||
|
||||
$run = function (string $at) {
|
||||
Carbon::setTestNow(Carbon::parse($at));
|
||||
$this->artisan('user:cleanup')->assertSuccessful();
|
||||
};
|
||||
|
||||
$run('2026-10-02 03:30:00');
|
||||
expect($s['consultant']->fresh()->active)->toBe(1);
|
||||
|
||||
$run('2026-10-03 03:30:00');
|
||||
expect($s['consultant']->fresh()->active)->toBe(0);
|
||||
|
||||
$run('2026-10-28 03:30:00');
|
||||
expect(User::withTrashed()->find($s['consultant']->id)->deleted_at)->toBeNull();
|
||||
|
||||
$run('2026-10-29 03:30:00');
|
||||
expect(User::withTrashed()->find($s['consultant']->id)->deleted_at)->not->toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue