admin: Credits und freie PMs ohne Zahlung gutschreiben
Neues Panel "Guthaben & Kontingent" auf der Benutzer-Detailseite (users:manage): Credits gehen als Grant-Buchung ins Wallet-Ledger, freie Pressemitteilungen als bezahlte 0-€-Einzelkäufe, die das Buchungs-Gate erfüllen und beim Veröffentlichen regulär eingelöst werden — Stripe bleibt unberührt. Jede Gutschrift landet mit Vergeber und Grund als neuer Typ "Gutschrift" im Aktivitäts-Verlauf. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
ab0030d71e
commit
37e543e69a
4 changed files with 322 additions and 0 deletions
|
|
@ -22,6 +22,7 @@ enum ActivityType: string
|
||||||
case SubscriptionChange = 'subscription_change';
|
case SubscriptionChange = 'subscription_change';
|
||||||
case MailSent = 'mail_sent';
|
case MailSent = 'mail_sent';
|
||||||
case EmailUnsubscribe = 'email_unsubscribe';
|
case EmailUnsubscribe = 'email_unsubscribe';
|
||||||
|
case AdminGrant = 'admin_grant';
|
||||||
|
|
||||||
public function label(): string
|
public function label(): string
|
||||||
{
|
{
|
||||||
|
|
@ -39,6 +40,7 @@ enum ActivityType: string
|
||||||
self::SubscriptionChange => 'Abo',
|
self::SubscriptionChange => 'Abo',
|
||||||
self::MailSent => 'E-Mail',
|
self::MailSent => 'E-Mail',
|
||||||
self::EmailUnsubscribe => 'Mail-Abmeldung',
|
self::EmailUnsubscribe => 'Mail-Abmeldung',
|
||||||
|
self::AdminGrant => 'Gutschrift',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,6 +58,7 @@ enum ActivityType: string
|
||||||
self::LegalRequest, self::WithdrawalRequest, self::TerminationRequest, self::SubscriptionChange => 'red',
|
self::LegalRequest, self::WithdrawalRequest, self::TerminationRequest, self::SubscriptionChange => 'red',
|
||||||
self::MailSent => 'purple',
|
self::MailSent => 'purple',
|
||||||
self::EmailUnsubscribe => 'orange',
|
self::EmailUnsubscribe => 'orange',
|
||||||
|
self::AdminGrant => 'amber',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
89
app/Services/Billing/AdminGrantService.php
Normal file
89
app/Services/Billing/AdminGrantService.php
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Billing;
|
||||||
|
|
||||||
|
use App\Enums\ActivityType;
|
||||||
|
use App\Enums\CreditTransactionType;
|
||||||
|
use App\Enums\SinglePurchaseStatus;
|
||||||
|
use App\Enums\SinglePurchaseType;
|
||||||
|
use App\Models\CreditTransaction;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\ActivityLog;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-Gutschriften ohne Zahlung (Admin ▸ Benutzer ▸ Guthaben & Kontingent):
|
||||||
|
* Credits als Grant-Buchung ins Wallet-Ledger, freie Pressemitteilungen als
|
||||||
|
* bezahlte 0-€-Einzelkäufe — beides greift in die reguläre Buchungs-/
|
||||||
|
* Kontingent-Mechanik, ohne Stripe zu berühren, und wird im
|
||||||
|
* Aktivitäts-Verlauf protokolliert.
|
||||||
|
*/
|
||||||
|
class AdminGrantService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CreditWalletService $wallet,
|
||||||
|
private readonly ActivityLog $activityLog,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function grantCredits(User $user, int $credits, User $grantedBy, ?string $note = null): CreditTransaction
|
||||||
|
{
|
||||||
|
$description = 'Admin-Gutschrift durch '.$grantedBy->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],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Billing\AdminGrantService;
|
||||||
|
use App\Services\Billing\CreditWalletService;
|
||||||
|
use Flux\Flux;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Attributes\Title;
|
use Livewire\Attributes\Title;
|
||||||
|
|
@ -11,6 +14,14 @@ new #[Layout('components.layouts.app'), Title('Benutzer anzeigen')] class extend
|
||||||
#[Locked]
|
#[Locked]
|
||||||
public int $id;
|
public int $id;
|
||||||
|
|
||||||
|
public int $grantCreditsAmount = 100;
|
||||||
|
|
||||||
|
public string $grantCreditsNote = '';
|
||||||
|
|
||||||
|
public int $grantPmCount = 1;
|
||||||
|
|
||||||
|
public string $grantPmNote = '';
|
||||||
|
|
||||||
public function mount(int $id): void
|
public function mount(int $id): void
|
||||||
{
|
{
|
||||||
$this->authorize('users:manage');
|
$this->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
|
public function companyUserRoleLabel(?string $role): string
|
||||||
{
|
{
|
||||||
return match ($role ?? 'member') {
|
return match ($role ?? 'member') {
|
||||||
|
|
@ -55,6 +114,9 @@ new #[Layout('components.layouts.app'), Title('Benutzer anzeigen')] class extend
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'user' => $user,
|
'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
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
{{-- ============== GUTHABEN & KONTINGENT ============== --}}
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<span class="section-eyebrow">{{ __('Guthaben & Kontingent') }}</span>
|
||||||
|
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('Gutschriften ohne Zahlung — landen im Verlauf') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-5 space-y-5">
|
||||||
|
<div class="grid gap-2 sm:grid-cols-3">
|
||||||
|
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Credit-Saldo') }}</div>
|
||||||
|
<div class="text-[15px] font-semibold text-[color:var(--color-ink)] mt-1 tabular-nums">
|
||||||
|
{{ number_format($creditBalance, 0, ',', '.') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Offene PM-Käufe/Gutschriften') }}</div>
|
||||||
|
<div class="text-[15px] font-semibold text-[color:var(--color-ink)] mt-1 tabular-nums">
|
||||||
|
{{ number_format($openPmPurchases, 0, ',', '.') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('PM-Kontingent verbleibend') }}</div>
|
||||||
|
<div class="text-[15px] font-semibold text-[color:var(--color-ink)] mt-1 tabular-nums">
|
||||||
|
{{ $quotaRemaining === null ? __('Unbegrenzt') : number_format($quotaRemaining, 0, ',', '.') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] p-4 space-y-3">
|
||||||
|
<div class="text-[12.5px] font-semibold text-[color:var(--color-ink)]">{{ __('Credits gutschreiben') }}</div>
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ __('Anzahl Credits') }}</flux:label>
|
||||||
|
<flux:input type="number" min="1" max="100000" wire:model="grantCreditsAmount" />
|
||||||
|
<flux:error name="grantCreditsAmount" />
|
||||||
|
</flux:field>
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ __('Grund (optional, erscheint im Ledger & Verlauf)') }}</flux:label>
|
||||||
|
<flux:input wire:model="grantCreditsNote" maxlength="240" :placeholder="__('z. B. interne Nutzung, Kulanz…')" />
|
||||||
|
<flux:error name="grantCreditsNote" />
|
||||||
|
</flux:field>
|
||||||
|
<flux:button variant="primary" icon="banknotes" wire:click="grantCredits">
|
||||||
|
{{ __('Credits gutschreiben') }}
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] p-4 space-y-3">
|
||||||
|
<div class="text-[12.5px] font-semibold text-[color:var(--color-ink)]">{{ __('Freie Pressemitteilungen gutschreiben') }}</div>
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ __('Anzahl PMs') }}</flux:label>
|
||||||
|
<flux:input type="number" min="1" max="100" wire:model="grantPmCount" />
|
||||||
|
<flux:error name="grantPmCount" />
|
||||||
|
</flux:field>
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ __('Grund (optional, erscheint im Verlauf)') }}</flux:label>
|
||||||
|
<flux:input wire:model="grantPmNote" maxlength="240" :placeholder="__('z. B. eigene PMs über das Admin-Konto…')" />
|
||||||
|
<flux:error name="grantPmNote" />
|
||||||
|
</flux:field>
|
||||||
|
<flux:button variant="primary" icon="newspaper" wire:click="grantFreePressReleases">
|
||||||
|
{{ __('Freie PMs gutschreiben') }}
|
||||||
|
</flux:button>
|
||||||
|
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0">
|
||||||
|
{{ __('Wird als bezahlter 0-€-Einzelkauf verbucht und beim Veröffentlichen regulär eingelöst.') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<span class="section-eyebrow">{{ __('Rechnungsadresse') }}</span>
|
<span class="section-eyebrow">{{ __('Rechnungsadresse') }}</span>
|
||||||
|
|
|
||||||
99
tests/Feature/Admin/AdminGrantTest.php
Normal file
99
tests/Feature/Admin/AdminGrantTest.php
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\ActivityType;
|
||||||
|
use App\Enums\CreditTransactionType;
|
||||||
|
use App\Enums\SinglePurchaseStatus;
|
||||||
|
use App\Enums\SinglePurchaseType;
|
||||||
|
use App\Models\ActivityEvent;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Billing\AdminGrantService;
|
||||||
|
use App\Services\Billing\CreditWalletService;
|
||||||
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||||
|
use Livewire\Volt\Volt as LivewireVolt;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-Gutschriften ohne Zahlung (Admin ▸ Benutzer ▸ Guthaben & Kontingent):
|
||||||
|
* Credits als Grant ins Wallet-Ledger, freie PMs als 0-€-Einzelkäufe —
|
||||||
|
* beides protokolliert im Aktivitäts-Verlauf.
|
||||||
|
*/
|
||||||
|
test('an admin can grant credits without a payment', function () {
|
||||||
|
$admin = User::factory()->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();
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue