Mitgliedschaft: Erinnerungen und eigenes Verlaengerungs-Produkt

Die Erinnerungsmails vor Ablauf der Mitgliedschaft beschrieben noch den
abgeschafften SEPA-Einzug und verlinkten auf die Hauptdomain, wo
/user/membership nicht existiert. Das Produkt fuer die Verlaengerung war
zudem dasselbe wie bei der Registrierung und sprach deshalb vom
Neuabschluss.

- Erinnerungen: nur noch Stufen 31/33/34/35, SEPA-Stufen 32/36/37 samt
  :price und :pay_date entfallen; Button verlinkt auf my.mivita.care;
  fehlt ein Text zum Status, wird die Mail geloggt statt roh versendet.
- Alle Erinnerungstexte (de/en/es/fr) erklaeren jetzt, dass ohne Zahlung
  der Account pausiert und offene Provisionen ausgezahlt werden.
- Eigenes Produkt fuer die Verlaengerung (identifier show_renewal) als
  Kopie des Mitgliedschafts-Produkts inkl. Preisen, Kategorien und
  Uebersetzungen fuer en/es/fr; die Mitgliedschaftsseite faellt ohne
  dieses Produkt auf das bisherige zurueck.
- Mitgliedschaftsseite auf ein Paket reduziert: Upgrade-, Wechsel- und
  Abo-Optionen entfernt, Texte auf die manuelle Verlaengerung umgestellt.
- Alte Cron-Weboberflaeche entfernt, der Versand laeuft ueber den
  Scheduler (payments:check-accounts).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-08-26 08:24:32 +00:00
parent 1894d2db67
commit c3dc7eb35e
30 changed files with 810 additions and 1223 deletions

View file

@ -0,0 +1,83 @@
<?php
use App\Mail\MailCustomMessage;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
/**
* Der Verlängerungs-Link aus der Mitgliedschafts-Erinnerung muss auf das CRM
* (my.mivita.care) zeigen. Die Route /user/membership existiert nur dort –
* auf der Hauptdomain lief der Link in einen 404.
*/
beforeEach(function () {
// Produktivähnliche Domains, sonst greift Util::isTestSystem() und es wird nichts versendet.
config([
'app.tld_care' => '.care',
'app.tld_shop' => '.shop',
'app.domain' => 'mivita',
'app.pre_url_crm' => 'my.',
'app.protocol' => 'https://',
'app.url' => 'https://mivita.care/',
]);
Mail::fake();
});
function makeReminderUser(int $daysLeft): User
{
if (! User::find(1)) {
User::forceCreate([
'id' => 1,
'email' => 'system@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
}
return User::forceCreate([
'email' => 'berater-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'active' => 1,
'blocked' => 0,
'payment_account' => now()->addDays($daysLeft),
]);
}
it('verlinkt die Mitgliedschafts-Verlängerung auf die CRM-Domain', function () {
makeReminderUser(10);
$this->artisan('payments:check-accounts')->assertSuccessful();
Mail::assertSent(MailCustomMessage::class, function (MailCustomMessage $mail) {
return str_contains($mail->render(), 'https://my.mivita.care/user/membership');
});
});
it('verlinkt nicht mehr auf die Hauptdomain', function () {
makeReminderUser(10);
$this->artisan('payments:check-accounts')->assertSuccessful();
Mail::assertSent(MailCustomMessage::class, function (MailCustomMessage $mail) {
return ! str_contains($mail->render(), 'https://mivita.care//user/membership')
&& ! str_contains($mail->render(), 'https://mivita.care/user/membership');
});
});
it('erklaert in der Erinnerung, was beim Ablauf der Mitgliedschaft passiert', function () {
makeReminderUser(10);
$this->artisan('payments:check-accounts')->assertSuccessful();
Mail::assertSent(MailCustomMessage::class, function (MailCustomMessage $mail) {
$body = $mail->render();
return str_contains($body, 'keine Punkte und keine Provisionen mehr gutgeschrieben')
&& str_contains($body, 'im nächsten Zahlungslauf aus');
});
});

View file

@ -0,0 +1,186 @@
<?php
use App\Http\Controllers\User\MembershipController;
use App\Models\Product;
use App\Models\TransProduct;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
/**
* Die Verlängerung braucht ein eigenes Produkt: das Registrierungs-Produkt
* (show_order) beschreibt den Neuabschluss und passt inhaltlich nicht.
*/
function makeMembershipProduct(string $identifier, array $showOn, string $name, int $pos = 20): Product
{
$now = now();
$id = (int) DB::table('products')->insertGetId([
'name' => $name,
'title' => '12 Monate Laufzeit',
'copy' => '<p>Du kannst Dich jetzt mit der Jahresgebühr registrieren.</p>',
'identifier' => $identifier,
'active' => true,
'price' => 69.90,
'tax' => 19,
'weight' => 0,
'points' => 0,
'pos' => $pos,
'is_membership_only' => 1,
'action' => json_encode(['0', '1']),
'show_on' => json_encode($showOn),
'created_at' => $now,
'updated_at' => $now,
]);
return Product::findOrFail($id);
}
function runRenewalProductMigration(): void
{
(require base_path('database/migrations/2026_08_25_120000_create_membership_renewal_product.php'))->up();
}
function renewalProductsOfController(): Collection
{
$method = new ReflectionMethod(MembershipController::class, 'renewalProducts');
$method->setAccessible(true);
return collect($method->invoke(new MembershipController));
}
it('legt das Verlängerungs-Produkt als Kopie des Registrierungs-Produkts an', function () {
$registration = makeMembershipProduct('show_order', ['7', '8'], 'MIVITA BUSINESS Paket');
runRenewalProductMigration();
$renewal = Product::where('identifier', 'show_renewal')->firstOrFail();
expect($renewal->id)->not->toBe($registration->id)
->and($renewal->name)->toBe($registration->name)
->and((float) $renewal->price)->toBe((float) $registration->price)
->and($renewal->action)->toBe($registration->action)
->and((bool) $renewal->is_membership_only)->toBeTrue()
->and($renewal->show_on)->toBe(['8'])
->and($renewal->slug)->not->toBe($registration->slug);
});
it('spricht im Verlängerungs-Text von der Verlängerung statt von der Registrierung', function () {
makeMembershipProduct('show_order', ['7', '8'], 'MIVITA BUSINESS Paket');
runRenewalProductMigration();
$renewal = Product::where('identifier', 'show_renewal')->firstOrFail();
expect($renewal->copy)->toContain('verlängerst Deine MIVITA Mitgliedschaft')
->and($renewal->copy)->not->toContain('registrieren');
});
it('laesst das Registrierungs-Produkt unveraendert', function () {
$registration = makeMembershipProduct('show_order', ['7', '8'], 'MIVITA BUSINESS Paket');
runRenewalProductMigration();
expect($registration->fresh()->show_on)->toBe(['7', '8'])
->and($registration->fresh()->identifier)->toBe('show_order');
});
it('legt kein zweites Verlängerungs-Produkt an', function () {
makeMembershipProduct('show_order', ['7', '8'], 'MIVITA BUSINESS Paket');
runRenewalProductMigration();
runRenewalProductMigration();
expect(Product::where('identifier', 'show_renewal')->count())->toBe(1);
});
it('zeigt bei der Verlängerung nur noch das Verlängerungs-Produkt', function () {
makeMembershipProduct('show_order', ['7', '8'], 'MIVITA BUSINESS Paket');
runRenewalProductMigration();
$products = renewalProductsOfController();
expect($products)->toHaveCount(1)
->and($products->first()->identifier)->toBe('show_renewal');
});
it('faellt ohne Verlängerungs-Produkt auf das Mitgliedschafts-Produkt zurueck', function () {
makeMembershipProduct('show_order', ['7', '8'], 'MIVITA BUSINESS Paket');
$products = renewalProductsOfController();
expect($products)->toHaveCount(1)
->and($products->first()->identifier)->toBe('show_order');
});
it('ignoriert ein inaktives Verlängerungs-Produkt', function () {
makeMembershipProduct('show_order', ['7', '8'], 'MIVITA BUSINESS Paket');
runRenewalProductMigration();
Product::where('identifier', 'show_renewal')->update(['active' => false]);
$products = renewalProductsOfController();
expect($products)->toHaveCount(1)
->and($products->first()->identifier)->toBe('show_order');
});
function runRenewalTranslationMigration(): void
{
(require base_path('database/migrations/2026_08_25_130000_translate_membership_renewal_product.php'))->up();
}
function renewalCopyIn(string $locale): string
{
app()->setLocale($locale);
return Product::where('identifier', 'show_renewal')->firstOrFail()->getLang('copy');
}
it('uebersetzt die Verlängerungs-Beschreibung in alle Sprachen', function (string $locale, string $needle) {
makeMembershipProduct('show_order', ['7', '8'], 'MIVITA BUSINESS Paket');
runRenewalProductMigration();
runRenewalTranslationMigration();
expect(renewalCopyIn($locale))->toContain($needle)
->and(renewalCopyIn($locale))->not->toContain('verlängerst Deine MIVITA Mitgliedschaft');
})->with([
'en' => ['en', 'renewing your MIVITA membership'],
'es' => ['es', 'Amplías tu afiliación MIVITA'],
'fr' => ['fr', 'Vous renouvelez votre adhésion MIVITA'],
]);
it('behaelt den deutschen Text als Standard', function () {
makeMembershipProduct('show_order', ['7', '8'], 'MIVITA BUSINESS Paket');
runRenewalProductMigration();
runRenewalTranslationMigration();
expect(renewalCopyIn('de'))->toContain('verlängerst Deine MIVITA Mitgliedschaft');
});
it('uebernimmt den uebersetzten Produktnamen aus dem Mitgliedschafts-Produkt', function () {
$registration = makeMembershipProduct('show_order', ['7', '8'], 'MIVITA BUSINESS Paket');
TransProduct::create(['language' => 'en', 'product_id' => $registration->id, 'key' => 'name', 'value' => 'MIVITA BUSINESS package']);
runRenewalProductMigration();
runRenewalTranslationMigration();
app()->setLocale('en');
expect(Product::where('identifier', 'show_renewal')->firstOrFail()->getLang('name'))->toBe('MIVITA BUSINESS package');
});
it('ueberschreibt nachtraeglich angepasste Uebersetzungen nicht', function () {
makeMembershipProduct('show_order', ['7', '8'], 'MIVITA BUSINESS Paket');
runRenewalProductMigration();
$renewal = Product::where('identifier', 'show_renewal')->firstOrFail();
TransProduct::create(['language' => 'en', 'product_id' => $renewal->id, 'key' => 'copy', 'value' => '<p>Handmade</p>']);
runRenewalTranslationMigration();
runRenewalTranslationMigration();
expect(renewalCopyIn('en'))->toBe('<p>Handmade</p>')
->and(TransProduct::where('product_id', $renewal->id)->where('key', 'copy')->count())->toBe(3);
});

View file

@ -0,0 +1,58 @@
<?php
use Illuminate\Support\Facades\Blade;
use Tests\TestCase;
uses(TestCase::class);
/**
* Absicherung der Mitgliedschafts-Ansichten nach der Zusammenlegung auf ein Paket:
* die Templates müssen kompilieren und jeder verwendete Sprachschlüssel muss in
* allen vier Sprachen existieren.
*/
$membershipViews = [
'resources/views/user/membership/index.blade.php',
'resources/views/user/membership/_payment_order.blade.php',
'resources/views/dashboard/_reminder.blade.php',
'resources/views/dashboard/_membership.blade.php',
];
it('kompiliert die Mitgliedschafts-Templates zu gültigem PHP', function (string $view) {
$compiled = Blade::compileString(file_get_contents(base_path($view)));
$tmp = tempnam(sys_get_temp_dir(), 'blade').'.php';
file_put_contents($tmp, $compiled);
exec('php -l '.escapeshellarg($tmp).' 2>&1', $output, $exitCode);
unlink($tmp);
expect($exitCode)->toBe(0, implode("\n", $output));
})->with($membershipViews);
it('kennt jeden verwendeten Sprachschlüssel in allen Sprachen', function (string $view) {
preg_match_all(
"/__\('(membership|reminder)\.([a-zA-Z0-9_]+)'/",
file_get_contents(base_path($view)),
$matches,
PREG_SET_ORDER
);
expect($matches)->not->toBeEmpty();
foreach (['de', 'en', 'es', 'fr'] as $locale) {
foreach ($matches as [$_, $file, $key]) {
$translations = include base_path("resources/lang/$locale/$file.php");
expect(array_key_exists($key, $translations))
->toBeTrue("$locale/$file.php fehlt '$key' (aus $view)");
}
}
})->with(array_diff($membershipViews, ['resources/views/dashboard/_membership.blade.php']));
it('beschreibt nirgends mehr die abgeschaffte automatische Vertragsverlängerung', function (string $locale) {
$membership = include base_path("resources/lang/$locale/membership.php");
foreach (['info_contract_renewal', 'membership_was_renewed', 'contract_renewal', 'change_copy_1', 'change_copy_2'] as $obsolete) {
expect($membership)->not->toHaveKey($obsolete);
}
expect($membership)->toHaveKeys(['renewal_hl', 'renewal_info', 'renewal_expired_info', 'renewal_consequence', 'active_info', 'renewal_possible_from']);
})->with(['de', 'en', 'es', 'fr']);