diff --git a/app/Enums/ActivityType.php b/app/Enums/ActivityType.php index 56eb3a5e..6eb49f87 100644 --- a/app/Enums/ActivityType.php +++ b/app/Enums/ActivityType.php @@ -22,6 +22,7 @@ enum ActivityType: string case SubscriptionChange = 'subscription_change'; case MailSent = 'mail_sent'; case EmailUnsubscribe = 'email_unsubscribe'; + case AdminGrant = 'admin_grant'; public function label(): string { @@ -39,6 +40,7 @@ enum ActivityType: string self::SubscriptionChange => 'Abo', self::MailSent => 'E-Mail', self::EmailUnsubscribe => 'Mail-Abmeldung', + self::AdminGrant => 'Gutschrift', }; } @@ -56,6 +58,7 @@ enum ActivityType: string self::LegalRequest, self::WithdrawalRequest, self::TerminationRequest, self::SubscriptionChange => 'red', self::MailSent => 'purple', self::EmailUnsubscribe => 'orange', + self::AdminGrant => 'amber', }; } } diff --git a/app/Services/Billing/AdminGrantService.php b/app/Services/Billing/AdminGrantService.php new file mode 100644 index 00000000..05a59f2b --- /dev/null +++ b/app/Services/Billing/AdminGrantService.php @@ -0,0 +1,89 @@ +name.($note !== null && $note !== '' ? ': '.$note : ''); + + $transaction = $this->wallet->credit($user, $credits, CreditTransactionType::Grant, $description); + + $this->activityLog->record( + ActivityType::AdminGrant, + sprintf( + '%s hat %s (%s) %d Credits gutgeschrieben — ohne Zahlung.%s', + $grantedBy->name, + $user->name, + $user->email, + $credits, + $note !== null && $note !== '' ? ' Grund: '.$note : '', + ), + user: $user, + subject: $transaction, + meta: ['credits' => $credits, 'granted_by' => $grantedBy->id, 'note' => $note], + ); + + return $transaction; + } + + /** + * Schreibt dem User freie Pressemitteilungen gut: bezahlte Einzel-PM-Käufe + * zu 0 € ({@see SinglePurchase::scopeGrantingSubmission()}), die das + * Submit-Gate erfüllen und beim Veröffentlichen regulär eingelöst werden. + */ + public function grantFreePressReleases(User $user, int $count, User $grantedBy, ?string $note = null): void + { + if ($count < 1) { + throw new \InvalidArgumentException('Anzahl muss positiv sein.'); + } + + DB::transaction(function () use ($user, $count): void { + for ($i = 0; $i < $count; $i++) { + $user->singlePurchases()->create([ + 'type' => SinglePurchaseType::SinglePm, + 'status' => SinglePurchaseStatus::Paid, + 'price_cents' => 0, + 'currency' => config('billing.default_currency'), + 'paid_at' => now(), + ]); + } + }); + + $this->activityLog->record( + ActivityType::AdminGrant, + sprintf( + '%s hat %s (%s) %d freie Pressemitteilung(en) gutgeschrieben — ohne Zahlung.%s', + $grantedBy->name, + $user->name, + $user->email, + $count, + $note !== null && $note !== '' ? ' Grund: '.$note : '', + ), + user: $user, + meta: ['free_press_releases' => $count, 'granted_by' => $grantedBy->id, 'note' => $note], + ); + } +} diff --git a/resources/views/livewire/admin/users/show.blade.php b/resources/views/livewire/admin/users/show.blade.php index d3c4c868..613ae422 100644 --- a/resources/views/livewire/admin/users/show.blade.php +++ b/resources/views/livewire/admin/users/show.blade.php @@ -1,6 +1,9 @@ authorize('users:manage'); @@ -25,6 +36,54 @@ new #[Layout('components.layouts.app'), Title('Benutzer anzeigen')] class extend } } + public function grantCredits(): void + { + $this->validate([ + 'grantCreditsAmount' => ['required', 'integer', 'min:1', 'max:100000'], + 'grantCreditsNote' => ['nullable', 'string', 'max:240'], + ]); + + $user = User::query()->findOrFail($this->id); + + app(AdminGrantService::class)->grantCredits( + $user, + $this->grantCreditsAmount, + auth()->user(), + trim($this->grantCreditsNote) ?: null, + ); + + Flux::toast( + text: __(':count Credits gutgeschrieben.', ['count' => $this->grantCreditsAmount]), + variant: 'success', + ); + + $this->reset('grantCreditsNote'); + } + + public function grantFreePressReleases(): void + { + $this->validate([ + 'grantPmCount' => ['required', 'integer', 'min:1', 'max:100'], + 'grantPmNote' => ['nullable', 'string', 'max:240'], + ]); + + $user = User::query()->findOrFail($this->id); + + app(AdminGrantService::class)->grantFreePressReleases( + $user, + $this->grantPmCount, + auth()->user(), + trim($this->grantPmNote) ?: null, + ); + + Flux::toast( + text: __(':count freie Pressemitteilung(en) gutgeschrieben.', ['count' => $this->grantPmCount]), + variant: 'success', + ); + + $this->reset('grantPmNote'); + } + public function companyUserRoleLabel(?string $role): string { return match ($role ?? 'member') { @@ -55,6 +114,9 @@ new #[Layout('components.layouts.app'), Title('Benutzer anzeigen')] class extend return [ 'user' => $user, + 'creditBalance' => $user ? app(CreditWalletService::class)->balance($user) : 0, + 'openPmPurchases' => $user?->openSinglePurchaseCount() ?? 0, + 'quotaRemaining' => $user?->pressReleaseQuotaRemaining(), ]; } }; ?> @@ -138,6 +200,75 @@ new #[Layout('components.layouts.app'), Title('Benutzer anzeigen')] class extend + {{-- ============== GUTHABEN & KONTINGENT ============== --}} +
+
+ {{ __('Guthaben & Kontingent') }} + {{ __('Gutschriften ohne Zahlung — landen im Verlauf') }} +
+
+
+
+
{{ __('Credit-Saldo') }}
+
+ {{ number_format($creditBalance, 0, ',', '.') }} +
+
+
+
{{ __('Offene PM-Käufe/Gutschriften') }}
+
+ {{ number_format($openPmPurchases, 0, ',', '.') }} +
+
+
+
{{ __('PM-Kontingent verbleibend') }}
+
+ {{ $quotaRemaining === null ? __('Unbegrenzt') : number_format($quotaRemaining, 0, ',', '.') }} +
+
+
+ +
+
+
{{ __('Credits gutschreiben') }}
+ + {{ __('Anzahl Credits') }} + + + + + {{ __('Grund (optional, erscheint im Ledger & Verlauf)') }} + + + + + {{ __('Credits gutschreiben') }} + +
+ +
+
{{ __('Freie Pressemitteilungen gutschreiben') }}
+ + {{ __('Anzahl PMs') }} + + + + + {{ __('Grund (optional, erscheint im Verlauf)') }} + + + + + {{ __('Freie PMs gutschreiben') }} + +

+ {{ __('Wird als bezahlter 0-€-Einzelkauf verbucht und beim Veröffentlichen regulär eingelöst.') }} +

+
+
+
+
+
{{ __('Rechnungsadresse') }} diff --git a/tests/Feature/Admin/AdminGrantTest.php b/tests/Feature/Admin/AdminGrantTest.php new file mode 100644 index 00000000..8388d1fd --- /dev/null +++ b/tests/Feature/Admin/AdminGrantTest.php @@ -0,0 +1,99 @@ +create(['name' => 'Admin Ada']); + $user = User::factory()->create(); + + app(AdminGrantService::class)->grantCredits($user, 500, $admin, 'Interne Nutzung'); + + expect(app(CreditWalletService::class)->balance($user))->toBe(500); + + $transaction = $user->creditWallet()->sole()->transactions()->sole(); + expect($transaction->type)->toBe(CreditTransactionType::Grant) + ->and($transaction->amount_credits)->toBe(500) + ->and($transaction->description)->toContain('Admin Ada') + ->and($transaction->description)->toContain('Interne Nutzung'); + + $event = ActivityEvent::query()->where('type', ActivityType::AdminGrant->value)->sole(); + expect($event->user_id)->toBe($user->id) + ->and($event->description)->toContain('500 Credits') + ->and($event->description)->toContain('ohne Zahlung') + ->and($event->meta['granted_by'])->toBe($admin->id); +}); + +test('an admin can grant free press releases that satisfy the booking gate', function () { + config(['billing.enforce_booking' => true]); + + $admin = User::factory()->create(); + $user = User::factory()->create(); + + expect($user->hasActiveBooking())->toBeFalse(); + + app(AdminGrantService::class)->grantFreePressReleases($user, 3, $admin, 'Eigene PMs'); + + $purchases = $user->singlePurchases()->get(); + expect($purchases)->toHaveCount(3) + ->and($purchases->first()->type)->toBe(SinglePurchaseType::SinglePm) + ->and($purchases->first()->status)->toBe(SinglePurchaseStatus::Paid) + ->and($purchases->first()->price_cents)->toBe(0) + ->and($purchases->first()->paid_at)->not->toBeNull(); + + $fresh = User::query()->find($user->id); + expect($fresh->hasActiveBooking())->toBeTrue() + ->and($fresh->openSinglePurchaseCount())->toBe(3) + ->and($fresh->pressReleaseQuotaRemaining())->toBe(3); + + $event = ActivityEvent::query()->where('type', ActivityType::AdminGrant->value)->sole(); + expect($event->description)->toContain('3 freie Pressemitteilung(en)') + ->and($event->meta['free_press_releases'])->toBe(3); +}); + +test('the user show page grants credits and free pms through the panel', function () { + /** @var TestCase $this */ + $this->seed(RolesAndPermissionsSeeder::class); + + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + $this->actingAs($admin); + + // Gutschrift auf das eigene Admin-Konto — der Live-Anwendungsfall. + LivewireVolt::test('admin.users.show', ['id' => $admin->id]) + ->set('grantCreditsAmount', 250) + ->set('grantCreditsNote', 'Eigenbedarf') + ->call('grantCredits') + ->assertHasNoErrors() + ->set('grantPmCount', 5) + ->call('grantFreePressReleases') + ->assertHasNoErrors(); + + expect(app(CreditWalletService::class)->balance($admin))->toBe(250) + ->and($admin->singlePurchases()->grantingSubmission()->count())->toBe(5); +}); + +test('the grant panel is not available without the users manage permission', function () { + /** @var TestCase $this */ + $this->seed(RolesAndPermissionsSeeder::class); + + $editor = User::factory()->create(['is_active' => true]); + $editor->assignRole('editor'); + $this->actingAs($editor); + + $this->get(route('admin.users.show', $editor->id))->assertForbidden(); +});