WS-4: Bestandsschutz-Abrechnung – gekoppelte Kündigung + Admin-Sichtbarkeit
M1 (portalübergreifend) und M5 (04:30-Worker) verifiziert; M2 + M3 umgesetzt. - M2 (Entscheidung: getrennte Rechnungen, gekoppelte Kündigung): neuer LegacySubscriptionService::cancelBundleFor() kündigt das gesamte Bestandsschutz-Bündel eines Kunden gemeinsam – keine Einzelkündigung. Kein Rechnungs-Schema-Umbau (jede Kondition behält ihre MAN-Rechnung). - M3: Admin-Payments-Ansicht zeigt eine "Bestandsschutz-Posten"-Tabelle (Badge, Portal, Netto, nächste Fälligkeit, "Bündel (N)") + Bündel-Kündigung mit Bestätigungsmodal. - Tests: tests/Feature/Billing/LegacyBundleTest.php (M1 cross-portal, M2 Bündel- Kündigung + Gate, M3 Admin-Liste + Kündigung). Detailplan WS-4 dokumentiert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
6668f492ad
commit
be7d1799a5
4 changed files with 372 additions and 0 deletions
56
app/Services/Billing/LegacySubscriptionService.php
Normal file
56
app/Services/Billing/LegacySubscriptionService.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPaymentOption;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Bestandsschutz-Vereinbarungen (WS-4, M2).
|
||||
*
|
||||
* Legacy-Posten eines Kunden gelten als Bündel: Sie werden je Portal als eigene
|
||||
* Kondition geführt (kein Tarif-Merge), sind aber NUR GEMEINSAM kündbar – es gibt
|
||||
* bewusst keine Einzelkündigung. Die Abrechnung selbst bleibt pro Posten (eigene
|
||||
* MAN-Rechnung, siehe ManualInvoiceService).
|
||||
*/
|
||||
class LegacySubscriptionService
|
||||
{
|
||||
/**
|
||||
* Noch laufende Bestandsschutz-Posten eines Kunden (das „Bündel").
|
||||
*
|
||||
* @return Collection<int, UserPaymentOption>
|
||||
*/
|
||||
public function grandfatheredFor(User $user): Collection
|
||||
{
|
||||
return UserPaymentOption::query()
|
||||
->where('user_id', $user->getKey())
|
||||
->where('status', UserPaymentOptionStatus::Grandfathered->value)
|
||||
->whereNull('cancelled_at')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Kündigt das GESAMTE Bestandsschutz-Bündel eines Kunden gemeinsam. Es gibt
|
||||
* keine Einzelkündigung – alle laufenden Grandfathered-Posten werden in einem
|
||||
* Schritt auf `cancelled` gesetzt, womit der 04:30-Worker keine weiteren
|
||||
* MAN-Rechnungen mehr erzeugt (duePaymentOptions berücksichtigt nur
|
||||
* Active/Grandfathered).
|
||||
*
|
||||
* @return int Anzahl der gekündigten Posten
|
||||
*/
|
||||
public function cancelBundleFor(User $user): int
|
||||
{
|
||||
$posts = $this->grandfatheredFor($user);
|
||||
|
||||
foreach ($posts as $post) {
|
||||
$post->update([
|
||||
'status' => UserPaymentOptionStatus::Cancelled->value,
|
||||
'cancelled_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $posts->count();
|
||||
}
|
||||
}
|
||||
|
|
@ -78,6 +78,13 @@ Alle hinter WS-2 **oder** regulärem Login.
|
|||
|
||||
### WS-4 — Bestandsschutz-Abrechnung & -Übersicht (Merkliste 1·2·3·5)
|
||||
**Scope:** Vorhandene Mechanik (`GrandfatherLegacySubscriptions`, `GenerateManualInvoices`) verifizieren + Lücken schließen. **Legacy-Posten bleiben unverändert (Entscheidung 1) – kein Tarif-Mapping.**
|
||||
|
||||
**Status (16.06.):**
|
||||
- **M1 (portalübergreifend) – verifiziert:** `hasActiveBooking()` / `duePaymentOptions()` filtern nicht nach `legacy_portal`; Beweis-Test ergänzt (grandfathered Posten zählt unabhängig vom aktiven Portal-Kontext).
|
||||
- **M5 (Import + 04:30-Worker) – verifiziert:** `GenerateManualInvoices` läuft täglich 04:30 (`routes/console.php`), berücksichtigt Active + Grandfathered ohne Stripe.
|
||||
- **M2 (gemeinsam abrechnen, keine Einzelkündigung) – umgesetzt (Entscheidung 16.06.: getrennte Rechnungen, gekoppelte Kündigung):** kein Rechnungs-Schema-Umbau; jede Kondition behält ihre eigene MAN-Rechnung. Neuer `LegacySubscriptionService::cancelBundleFor()` kündigt das gesamte Bestandsschutz-Bündel eines Kunden **gemeinsam** – es gibt bewusst keine Einzelkündigung.
|
||||
- **M3 (Admin-Sichtbarkeit) – umgesetzt:** Admin-Payments-Ansicht zeigt eine eigene „Bestandsschutz-Posten"-Tabelle (Badge, Portal, Netto, nächste Fälligkeit, „Bündel (N)"-Indikator) inkl. Bündel-Kündigung mit Bestätigung.
|
||||
- Tests: `tests/Feature/Billing/LegacyBundleTest.php` (M1/M2/M3). **Offen optional:** kombinierte Sammel-Rechnung (eine Rechnung mit Positionen) wurde bewusst nicht gebaut.
|
||||
- **M1 — Beide Portale ohne Unterscheidung:** Bestandsschutz gilt portalübergreifend, auch wenn nur für ein Portal gebucht → sicherstellen, dass `legacy_portal` im Import **kein** Filter ist.
|
||||
- **M2 — Zwei Posten zusammen abrechnen, nicht einzeln kündbar:** Zwei Legacy-Posten desselben Kunden (je Portal, z. B. connektar 2×1000 € netto) werden gemeinsam abgerechnet und gelten zusammen (Gesamtsumme 2000 € netto bleibt). Die einzelnen Positionen bleiben als eigene Legacy-Konditionen erhalten – **keine** Zusammenführung zu einem Tarif, **keine** Einzelkündigung.
|
||||
- **M5 — Einmal-Import + täglicher Worker:** Verifizieren, dass alle aktiven Abrechnungen erfasst werden und der 04:30-Worker fällige Rechnungen korrekt erzeugt; ggf. Gesamtübersicht ergänzen.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\InvoiceStatus;
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Plan;
|
||||
use App\Models\SinglePurchase;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPaymentOption;
|
||||
use App\Services\Billing\LegacySubscriptionService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Laravel\Cashier\Subscription;
|
||||
use Livewire\Attributes\Layout;
|
||||
|
|
@ -17,11 +21,42 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
|
|||
|
||||
public string $search = '';
|
||||
|
||||
public string $notification = '';
|
||||
|
||||
public string $notificationType = 'success';
|
||||
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
$this->resetPage('subscriptionsPage');
|
||||
$this->resetPage('purchasesPage');
|
||||
$this->resetPage('invoicesPage');
|
||||
$this->resetPage('bestandsschutzPage');
|
||||
}
|
||||
|
||||
/**
|
||||
* Kündigt das gesamte Bestandsschutz-Bündel eines Kunden gemeinsam
|
||||
* (M2: keine Einzelkündigung).
|
||||
*/
|
||||
public function cancelBundle(int $userId, LegacySubscriptionService $legacy): void
|
||||
{
|
||||
$user = User::query()->find($userId);
|
||||
|
||||
if (! $user) {
|
||||
$this->notification = __('Der angeforderte Kunde wurde nicht gefunden.');
|
||||
$this->notificationType = 'error';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$count = $legacy->cancelBundleFor($user);
|
||||
|
||||
$this->notification = trans_choice(
|
||||
':count Bestandsschutz-Posten gemeinsam gekündigt.|:count Bestandsschutz-Posten gemeinsam gekündigt.',
|
||||
$count,
|
||||
['count' => $count],
|
||||
);
|
||||
$this->notificationType = 'success';
|
||||
$this->resetPage('bestandsschutzPage');
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
|
|
@ -54,6 +89,18 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
|
|||
: $entry['plan']->monthly_price_cents;
|
||||
});
|
||||
|
||||
// Bestandsschutz-Posten (M3): laufende Grandfathered-Vereinbarungen.
|
||||
$grandfatheredBase = fn (): Builder => UserPaymentOption::query()
|
||||
->where('status', UserPaymentOptionStatus::Grandfathered->value)
|
||||
->whereNull('cancelled_at');
|
||||
|
||||
// Bündel-Größe je Kunde (für die „Bündel (N)"-Anzeige) – eine günstige
|
||||
// Aggregat-Query statt N+1.
|
||||
$bundleCounts = $grandfatheredBase()
|
||||
->selectRaw('user_id, count(*) as aggregate_count')
|
||||
->groupBy('user_id')
|
||||
->pluck('aggregate_count', 'user_id');
|
||||
|
||||
return [
|
||||
'plansByPriceId' => $plansByPriceId,
|
||||
'stats' => [
|
||||
|
|
@ -64,7 +111,14 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
|
|||
->where('paid_at', '>=', now()->subDays(30))
|
||||
->sum('total_cents'),
|
||||
'open_purchases' => SinglePurchase::query()->grantingSubmission()->count(),
|
||||
'grandfathered_posts' => $bundleCounts->sum(),
|
||||
'grandfathered_customers' => $bundleCounts->count(),
|
||||
],
|
||||
'bestandsschutz' => $this->searchByUser($grandfatheredBase()->with('user:id,name,email'))
|
||||
->orderBy('user_id')
|
||||
->orderBy('id')
|
||||
->paginate(25, pageName: 'bestandsschutzPage'),
|
||||
'bundleCounts' => $bundleCounts,
|
||||
'subscriptions' => $this->searchByUser(Subscription::query()->with('owner'))
|
||||
->latest('created_at')
|
||||
->paginate(25, pageName: 'subscriptionsPage'),
|
||||
|
|
@ -125,6 +179,22 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
|
|||
</div>
|
||||
</header>
|
||||
|
||||
@if ($notification)
|
||||
<div x-data="{ show: true }" x-init="setTimeout(() => show = false, 3500)" x-show="show" x-transition
|
||||
@class([
|
||||
'px-4 py-3 rounded-[5px] border text-[12.5px] flex items-center gap-2',
|
||||
'bg-[color:var(--color-err-soft)] border-[color:var(--color-err)]/30 text-[color:var(--color-ink-2)]' => $notificationType === 'error',
|
||||
'bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]' => $notificationType !== 'error',
|
||||
])>
|
||||
@if ($notificationType === 'error')
|
||||
<flux:icon.exclamation-triangle class="size-[16px] flex-shrink-0" />
|
||||
@else
|
||||
<flux:icon.check-circle class="size-[16px] flex-shrink-0" />
|
||||
@endif
|
||||
{{ $notification }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ============== KPI-Reihe ============== --}}
|
||||
<section class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<x-portal.stat-card variant="primary" :label="__('Aktive Abos')" :value="number_format($stats['active_subscriptions'], 0, ',', '.')">
|
||||
|
|
@ -156,6 +226,127 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
|
|||
</div>
|
||||
</article>
|
||||
|
||||
{{-- ============== BESTANDSSCHUTZ (Legacy-Posten, M3/M2) ============== --}}
|
||||
<article class="panel overflow-hidden">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Bestandsschutz-Posten') }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __(':posts Posten · :customers Kunden', [
|
||||
'posts' => number_format($stats['grandfathered_posts'], 0, ',', '.'),
|
||||
'customers' => number_format($stats['grandfathered_customers'], 0, ',', '.'),
|
||||
]) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="px-5 pt-4 text-[12px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Migrierte Legacy-Vereinbarungen (kein Tarif-Merge). Posten je Portal getrennt, aber als Bündel nur gemeinsam kündbar – keine Einzelkündigung.') }}
|
||||
</div>
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>{{ __('User') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Kondition') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Portal') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Netto') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Nächste Fälligkeit') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Bündel') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Aktion') }}</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
@forelse ($bestandsschutz as $post)
|
||||
@php
|
||||
$conditions = $post->legacy_conditions ?? [];
|
||||
$netCents = (int) ($conditions['net_cents'] ?? 0);
|
||||
$portalValue = $conditions['legacy_portal'] ?? null;
|
||||
$bundleSize = (int) ($bundleCounts[$post->user_id] ?? 1);
|
||||
@endphp
|
||||
<flux:table.row wire:key="bestandsschutz-{{ $post->id }}">
|
||||
<flux:table.cell>
|
||||
@if ($post->user)
|
||||
<div class="space-y-0.5">
|
||||
<flux:button size="xs" variant="filled" :href="route('admin.users.show', $post->user)" wire:navigate>
|
||||
{{ $post->user->name }}
|
||||
</flux:button>
|
||||
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ $post->user->email }}</div>
|
||||
</div>
|
||||
@else
|
||||
<span class="text-[12px] text-[color:var(--color-ink-3)]">–</span>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span class="badge ok dot">{{ __('Bestandsschutz') }}</span>
|
||||
<div class="text-[12px] text-[color:var(--color-ink)] mt-1">
|
||||
{{ $conditions['name'] ?? ($post->paymentOption?->article_number ?? '–') }}
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span class="badge hub">{{ $portalValue ? \Illuminate\Support\Str::headline($portalValue) : __('Unbekannt') }}</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span class="text-[12.5px] text-[color:var(--color-ink)]">
|
||||
{{ number_format($netCents / 100, 2, ',', '.') }} €
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span class="text-[12px] text-[color:var(--color-ink-3)]">
|
||||
{{ $post->current_period_end?->format('d.m.Y') ?? '–' }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
@if ($bundleSize > 1)
|
||||
<span class="badge warn">{{ __('Bündel (:n)', ['n' => $bundleSize]) }}</span>
|
||||
@else
|
||||
<span class="badge">{{ __('Einzeln') }}</span>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:modal.trigger name="cancel-bundle-{{ $post->user_id }}">
|
||||
<flux:button size="sm" variant="filled" icon="x-circle" type="button"
|
||||
x-on:click.prevent="$dispatch('open-modal', 'cancel-bundle-{{ $post->user_id }}')">
|
||||
{{ __('Bündel kündigen') }}
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
|
||||
<flux:modal name="cancel-bundle-{{ $post->user_id }}" class="max-w-lg">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Bestandsschutz-Bündel kündigen?') }}</flux:heading>
|
||||
<flux:subheading>
|
||||
{{ trans_choice(
|
||||
'Es wird :n Bestandsschutz-Posten von :user gekündigt. Eine Einzelkündigung ist nicht vorgesehen.|Es werden alle :n Bestandsschutz-Posten von :user gemeinsam gekündigt. Eine Einzelkündigung ist nicht vorgesehen.',
|
||||
$bundleSize,
|
||||
['n' => $bundleSize, 'user' => $post->user?->name ?? '–'],
|
||||
) }}
|
||||
</flux:subheading>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
|
||||
<flux:modal.close>
|
||||
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
|
||||
</flux:modal.close>
|
||||
<flux:button variant="danger" wire:click="cancelBundle({{ $post->user_id }})">
|
||||
{{ __('Bündel kündigen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="7">
|
||||
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
|
||||
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
|
||||
{{ __('Keine laufenden Bestandsschutz-Posten') }}
|
||||
</div>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table>
|
||||
|
||||
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
|
||||
{{ $bestandsschutz->links('components.portal.pagination') }}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{-- ============== ABOS ============== --}}
|
||||
<article class="panel overflow-hidden">
|
||||
<div class="panel-head">
|
||||
|
|
|
|||
118
tests/Feature/Billing/LegacyBundleTest.php
Normal file
118
tests/Feature/Billing/LegacyBundleTest.php
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPaymentOption;
|
||||
use App\Services\Billing\LegacySubscriptionService;
|
||||
use App\Services\CurrentPortalContext;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Livewire\Volt\Volt;
|
||||
use Tests\TestCase;
|
||||
|
||||
afterEach(function () {
|
||||
// Statischer Portal-Kontext darf nicht in andere Tests lecken.
|
||||
CurrentPortalContext::clear();
|
||||
});
|
||||
|
||||
function grandfatheredPost(User $user, string $portal, int $netCents = 100000): UserPaymentOption
|
||||
{
|
||||
return UserPaymentOption::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'status' => UserPaymentOptionStatus::Grandfathered->value,
|
||||
'stripe_subscription_id' => null,
|
||||
'cancelled_at' => null,
|
||||
'legacy_conditions' => [
|
||||
'legacy_portal' => $portal,
|
||||
'net_cents' => $netCents,
|
||||
'name' => 'Legacy '.$portal,
|
||||
'interval' => 'yearly',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ===== M1: portalübergreifend =====
|
||||
|
||||
test('grandfathered bestandsschutz counts as booking regardless of active portal', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('billing.enforce_booking', true);
|
||||
|
||||
$user = User::factory()->create();
|
||||
grandfatheredPost($user, Portal::Presseecho->value);
|
||||
|
||||
// Aktiver Portal-Kontext businessportal24 darf den presseecho-Posten nicht ausschließen.
|
||||
CurrentPortalContext::set(Portal::Businessportal24);
|
||||
expect($user->hasActiveBooking())->toBeTrue();
|
||||
|
||||
CurrentPortalContext::clear();
|
||||
expect($user->fresh()->hasActiveBooking())->toBeTrue();
|
||||
});
|
||||
|
||||
// ===== M2: gekoppelte Kündigung =====
|
||||
|
||||
test('cancelling a bundle cancels all grandfathered posts of the customer together', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = User::factory()->create();
|
||||
$a = grandfatheredPost($user, Portal::Presseecho->value);
|
||||
$b = grandfatheredPost($user, Portal::Businessportal24->value);
|
||||
|
||||
$count = app(LegacySubscriptionService::class)->cancelBundleFor($user);
|
||||
|
||||
expect($count)->toBe(2);
|
||||
expect($a->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled);
|
||||
expect($b->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled);
|
||||
expect($a->fresh()->cancelled_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('cancelling a bundle leaves other customers untouched', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = User::factory()->create();
|
||||
$other = User::factory()->create();
|
||||
grandfatheredPost($user, Portal::Presseecho->value);
|
||||
$otherPost = grandfatheredPost($other, Portal::Presseecho->value);
|
||||
|
||||
app(LegacySubscriptionService::class)->cancelBundleFor($user);
|
||||
|
||||
expect($otherPost->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered);
|
||||
});
|
||||
|
||||
test('a cancelled bundle no longer counts as an active booking', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('billing.enforce_booking', true);
|
||||
|
||||
$user = User::factory()->create();
|
||||
grandfatheredPost($user, Portal::Presseecho->value);
|
||||
|
||||
app(LegacySubscriptionService::class)->cancelBundleFor($user);
|
||||
|
||||
expect($user->fresh()->hasActiveBooking())->toBeFalse();
|
||||
});
|
||||
|
||||
// ===== M3: Admin-Sichtbarkeit + Bündel-Kündigung über die UI =====
|
||||
|
||||
test('the payments page lists bestandsschutz posts and cancels a whole bundle', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
|
||||
$customer = User::factory()->create(['name' => 'Bestandskunde Test', 'is_active' => true]);
|
||||
grandfatheredPost($customer, Portal::Presseecho->value);
|
||||
grandfatheredPost($customer, Portal::Businessportal24->value);
|
||||
|
||||
Volt::actingAs($admin)
|
||||
->test('admin.payments.index')
|
||||
->assertOk()
|
||||
->assertSee('Bestandskunde Test')
|
||||
->assertSee('Bestandsschutz')
|
||||
->call('cancelBundle', $customer->id)
|
||||
->assertHasNoErrors();
|
||||
|
||||
$stillOpen = UserPaymentOption::query()
|
||||
->where('user_id', $customer->id)
|
||||
->where('status', UserPaymentOptionStatus::Grandfathered->value)
|
||||
->count();
|
||||
|
||||
expect($stillOpen)->toBe(0);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue