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:
Kevin Adametz 2026-07-21 13:06:01 +00:00
parent 80c5f03e6f
commit 3f4679fbb2
4 changed files with 322 additions and 0 deletions

View file

@ -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',
};
}
}

View 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],
);
}
}