Admin-Zahlungsmodul: Zahlungs-Übersicht + Tarif-Verwaltung mit Stripe-Sync
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
8f3261d0b4
commit
bda755fcf8
9 changed files with 1109 additions and 23 deletions
150
tests/Feature/Billing/AdminPaymentsPageTest.php
Normal file
150
tests/Feature/Billing/AdminPaymentsPageTest.php
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\InvoiceStatus;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Plan;
|
||||
use App\Models\SinglePurchase;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
function paymentsPageAdmin(): User
|
||||
{
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
test('the payments page shows subscriptions with plan name and mrr', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['name' => 'Abo Kunde']);
|
||||
$plan = Plan::factory()->create([
|
||||
'name' => 'Business',
|
||||
'monthly_price_cents' => 4900,
|
||||
'stripe_price_id_monthly' => 'price_test_m_biz',
|
||||
]);
|
||||
subscribeUserToPlan($customer, $plan);
|
||||
|
||||
$this->actingAs(paymentsPageAdmin());
|
||||
|
||||
LivewireVolt::test('admin.payments.index')
|
||||
->assertSee('Aktive Abos')
|
||||
->assertSee('MRR (netto)')
|
||||
->assertSee('49,00 €')
|
||||
->assertSee('Abo Kunde')
|
||||
->assertSee('Business')
|
||||
->assertSee('monatlich');
|
||||
});
|
||||
|
||||
test('a yearly subscription contributes one twelfth to the mrr', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create();
|
||||
$plan = Plan::factory()->create([
|
||||
'monthly_price_cents' => 4900,
|
||||
'yearly_price_cents' => 49000,
|
||||
'stripe_price_id_monthly' => 'price_test_m_y',
|
||||
'stripe_price_id_yearly' => 'price_test_y_y',
|
||||
]);
|
||||
subscribeUserToPlan($customer, $plan, 'yearly');
|
||||
|
||||
$this->actingAs(paymentsPageAdmin());
|
||||
|
||||
LivewireVolt::test('admin.payments.index')
|
||||
->assertSee('40,83 €')
|
||||
->assertSee('jährlich');
|
||||
});
|
||||
|
||||
test('single purchases appear with type and status', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['name' => 'Einzel Käufer']);
|
||||
SinglePurchase::factory()->paid()->create(['user_id' => $customer->id]);
|
||||
|
||||
$this->actingAs(paymentsPageAdmin());
|
||||
|
||||
LivewireVolt::test('admin.payments.index')
|
||||
->assertSee('Einzel Käufer')
|
||||
->assertSee('Einzel-Pressemitteilung')
|
||||
->assertSee('Bezahlt')
|
||||
->assertSee('19,00 €');
|
||||
});
|
||||
|
||||
test('local invoices appear with number and circle badge', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create();
|
||||
Invoice::factory()->create([
|
||||
'user_id' => $customer->id,
|
||||
'number' => 'STR-2026-000042',
|
||||
'status' => InvoiceStatus::Paid->value,
|
||||
'paid_at' => now(),
|
||||
'stripe_invoice_id' => 'in_test_123',
|
||||
]);
|
||||
Invoice::factory()->create([
|
||||
'user_id' => $customer->id,
|
||||
'number' => 'MAN-2026-000007',
|
||||
'status' => InvoiceStatus::Open->value,
|
||||
'stripe_invoice_id' => null,
|
||||
]);
|
||||
|
||||
$this->actingAs(paymentsPageAdmin());
|
||||
|
||||
LivewireVolt::test('admin.payments.index')
|
||||
->assertSee('STR-2026-000042')
|
||||
->assertSee('Stripe (STR)')
|
||||
->assertSee('MAN-2026-000007')
|
||||
->assertSee('Manuell (MAN)');
|
||||
});
|
||||
|
||||
test('paid invoices of the last 30 days are summed as revenue', function () {
|
||||
/** @var TestCase $this */
|
||||
Invoice::factory()->create([
|
||||
'status' => InvoiceStatus::Paid->value,
|
||||
'paid_at' => now()->subDays(5),
|
||||
'total_cents' => 11900,
|
||||
]);
|
||||
// Außerhalb des 30-Tage-Fensters: erscheint in der Tabelle,
|
||||
// zählt aber nicht in die Umsatz-KPI (sonst stünde dort 1.118,00 €).
|
||||
Invoice::factory()->create([
|
||||
'status' => InvoiceStatus::Paid->value,
|
||||
'paid_at' => now()->subDays(60),
|
||||
'total_cents' => 99900,
|
||||
]);
|
||||
|
||||
$this->actingAs(paymentsPageAdmin());
|
||||
|
||||
LivewireVolt::test('admin.payments.index')
|
||||
->assertSee('Umsatz 30 Tage')
|
||||
->assertSee('119,00 €')
|
||||
->assertDontSee('1.118,00 €');
|
||||
});
|
||||
|
||||
test('the search filters all panels by user name or email', function () {
|
||||
/** @var TestCase $this */
|
||||
$match = User::factory()->create(['name' => 'Maria Treffer']);
|
||||
$other = User::factory()->create(['name' => 'Olaf Anders']);
|
||||
SinglePurchase::factory()->paid()->create(['user_id' => $match->id]);
|
||||
SinglePurchase::factory()->paid()->create(['user_id' => $other->id]);
|
||||
|
||||
$this->actingAs(paymentsPageAdmin());
|
||||
|
||||
LivewireVolt::test('admin.payments.index')
|
||||
->set('search', 'Maria')
|
||||
->assertSee('Maria Treffer')
|
||||
->assertDontSee('Olaf Anders');
|
||||
});
|
||||
|
||||
test('the payments page is not accessible for customers', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
$this->actingAs($customer)
|
||||
->get(route('admin.payments.index'))
|
||||
->assertForbidden();
|
||||
});
|
||||
159
tests/Feature/Billing/AdminPlansPageTest.php
Normal file
159
tests/Feature/Billing/AdminPlansPageTest.php
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\StripePlanSyncService;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
function plansPageAdmin(): User
|
||||
{
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
test('the plans page lists active and inactive plans with prices', function () {
|
||||
/** @var TestCase $this */
|
||||
Plan::factory()->create([
|
||||
'name' => 'Business',
|
||||
'monthly_price_cents' => 4900,
|
||||
'yearly_price_cents' => 49000,
|
||||
'press_release_quota' => 10,
|
||||
'daily_limit' => 2,
|
||||
'stripe_product_id' => 'prod_test',
|
||||
'stripe_price_id_monthly' => 'price_test_m',
|
||||
'stripe_price_id_yearly' => 'price_test_y',
|
||||
]);
|
||||
Plan::factory()->inactive()->create(['name' => 'Altpaket']);
|
||||
|
||||
$this->actingAs(plansPageAdmin());
|
||||
|
||||
LivewireVolt::test('admin.payments.plans')
|
||||
->assertSee('Tarife & Pakete')
|
||||
->assertSee('Business')
|
||||
->assertSee('49,00 €')
|
||||
->assertSee('490,00 €')
|
||||
->assertSee('10 PM / Monat')
|
||||
->assertSee('max. 2 / Tag')
|
||||
->assertSee('verknüpft')
|
||||
->assertSee('Altpaket')
|
||||
->assertSee('Inaktiv');
|
||||
});
|
||||
|
||||
test('a plan without stripe ids is marked as unsynced', function () {
|
||||
/** @var TestCase $this */
|
||||
Plan::factory()->create(['name' => 'Neu', 'stripe_product_id' => null]);
|
||||
|
||||
$this->actingAs(plansPageAdmin());
|
||||
|
||||
LivewireVolt::test('admin.payments.plans')
|
||||
->assertSee('nicht synchronisiert');
|
||||
});
|
||||
|
||||
test('saving a plan updates the local record and triggers the stripe sync', function () {
|
||||
/** @var TestCase $this */
|
||||
$plan = Plan::factory()->create([
|
||||
'name' => 'Business',
|
||||
'monthly_price_cents' => 4900,
|
||||
'yearly_price_cents' => 49000,
|
||||
'press_release_quota' => 10,
|
||||
'daily_limit' => null,
|
||||
]);
|
||||
|
||||
$this->mock(StripePlanSyncService::class, function ($mock) use ($plan) {
|
||||
$mock->shouldReceive('syncAfterUpdate')
|
||||
->once()
|
||||
->withArgs(function (Plan $synced, array $changes) use ($plan): bool {
|
||||
return $synced->is($plan)
|
||||
&& array_key_exists('monthly_price_cents', $changes)
|
||||
&& array_key_exists('name', $changes);
|
||||
});
|
||||
});
|
||||
|
||||
$this->actingAs(plansPageAdmin());
|
||||
|
||||
LivewireVolt::test('admin.payments.plans')
|
||||
->call('edit', $plan->id)
|
||||
->set('name', 'Business Plus')
|
||||
->set('monthlyPrice', '59,00')
|
||||
->set('yearlyPrice', '590')
|
||||
->set('quota', '15')
|
||||
->set('dailyLimit', '3')
|
||||
->call('save')
|
||||
->assertHasNoErrors()
|
||||
->assertSee('Bestandsabos behalten ihren bisherigen Preis');
|
||||
|
||||
$plan->refresh();
|
||||
|
||||
expect($plan->name)->toBe('Business Plus')
|
||||
->and($plan->monthly_price_cents)->toBe(5900)
|
||||
->and($plan->yearly_price_cents)->toBe(59000)
|
||||
->and($plan->press_release_quota)->toBe(15)
|
||||
->and($plan->daily_limit)->toBe(3);
|
||||
});
|
||||
|
||||
test('saving without a price change shows the plain success message', function () {
|
||||
/** @var TestCase $this */
|
||||
$plan = Plan::factory()->create([
|
||||
'name' => 'Starter',
|
||||
'monthly_price_cents' => 2900,
|
||||
'yearly_price_cents' => 29000,
|
||||
'daily_limit' => 1,
|
||||
]);
|
||||
|
||||
$this->mock(StripePlanSyncService::class, function ($mock) {
|
||||
$mock->shouldReceive('syncAfterUpdate')->once();
|
||||
});
|
||||
|
||||
$this->actingAs(plansPageAdmin());
|
||||
|
||||
LivewireVolt::test('admin.payments.plans')
|
||||
->call('edit', $plan->id)
|
||||
->set('quota', '5')
|
||||
->set('dailyLimit', '')
|
||||
->call('save')
|
||||
->assertHasNoErrors()
|
||||
->assertSee('Tarif „Starter" gespeichert.')
|
||||
->assertDontSee('Bestandsabos behalten');
|
||||
|
||||
$plan->refresh();
|
||||
|
||||
expect($plan->press_release_quota)->toBe(5)
|
||||
->and($plan->daily_limit)->toBeNull();
|
||||
});
|
||||
|
||||
test('invalid prices are rejected with validation errors', function () {
|
||||
/** @var TestCase $this */
|
||||
$plan = Plan::factory()->create();
|
||||
|
||||
$this->mock(StripePlanSyncService::class, function ($mock) {
|
||||
$mock->shouldNotReceive('syncAfterUpdate');
|
||||
});
|
||||
|
||||
$this->actingAs(plansPageAdmin());
|
||||
|
||||
LivewireVolt::test('admin.payments.plans')
|
||||
->call('edit', $plan->id)
|
||||
->set('monthlyPrice', '-5')
|
||||
->set('quota', 'abc')
|
||||
->call('save')
|
||||
->assertHasErrors(['monthlyPrice', 'quota']);
|
||||
});
|
||||
|
||||
test('the plans page is not accessible for customers', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
$this->actingAs($customer)
|
||||
->get(route('admin.payments.plans'))
|
||||
->assertForbidden();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue