feat: clean credits users
CI / 🧪 Tests Laravel (push) Successful in 2m0s
CI / 🐳 Build & Push Image (push) Successful in 35s

This commit is contained in:
2026-08-07 15:32:40 +02:00
parent eac86afa53
commit 68560655f3
44 changed files with 417 additions and 661 deletions
-142
View File
@@ -1,142 +0,0 @@
<?php
namespace App\Services;
use App\Enums\CreditLedgerEntryType;
use App\Models\CreditLedgerEntry;
use App\Models\CreditProduct;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class AiCreditService
{
public function consume(User $user, int $credits = 1, ?string $source = null): bool
{
return DB::transaction(function () use ($credits, $source, $user): bool {
$lockedUser = User::query()
->whereKey($user->getKey())
->lockForUpdate()
->firstOrFail();
if ($lockedUser->ai_credits_balance < $credits) {
return false;
}
$lockedUser->decrement('ai_credits_balance', $credits);
$lockedUser->refresh();
$this->record(
user: $lockedUser,
type: CreditLedgerEntryType::USAGE,
credits: -$credits,
source: $source,
);
$user->forceFill([
'ai_credits_balance' => $lockedUser->ai_credits_balance,
]);
return true;
});
}
public function refund(User $user, int $credits = 1, ?string $source = null): CreditLedgerEntry
{
return $this->grant(
user: $user,
credits: $credits,
type: CreditLedgerEntryType::REFUND,
source: $source,
);
}
/**
* @param array<string, mixed>|null $metadata
*/
public function grant(
User $user,
int $credits,
CreditLedgerEntryType $type,
?CreditProduct $product = null,
?string $source = null,
?string $stripeCheckoutSessionId = null,
?string $stripeInvoiceId = null,
?string $stripePaymentIntentId = null,
?string $stripeSubscriptionId = null,
?array $metadata = null,
): ?CreditLedgerEntry {
return DB::transaction(function () use (
$credits,
$metadata,
$product,
$source,
$stripeCheckoutSessionId,
$stripeInvoiceId,
$stripePaymentIntentId,
$stripeSubscriptionId,
$type,
$user,
): ?CreditLedgerEntry {
if ($source && CreditLedgerEntry::query()->where('source', $source)->exists()) {
return null;
}
$lockedUser = User::query()
->whereKey($user->getKey())
->lockForUpdate()
->firstOrFail();
$lockedUser->increment('ai_credits_balance', $credits);
$lockedUser->refresh();
$entry = $this->record(
user: $lockedUser,
type: $type,
credits: $credits,
product: $product,
source: $source,
stripeCheckoutSessionId: $stripeCheckoutSessionId,
stripeInvoiceId: $stripeInvoiceId,
stripePaymentIntentId: $stripePaymentIntentId,
stripeSubscriptionId: $stripeSubscriptionId,
metadata: $metadata,
);
$user->forceFill([
'ai_credits_balance' => $lockedUser->ai_credits_balance,
]);
return $entry;
});
}
/**
* @param array<string, mixed>|null $metadata
*/
private function record(
User $user,
CreditLedgerEntryType $type,
int $credits,
?CreditProduct $product = null,
?string $source = null,
?string $stripeCheckoutSessionId = null,
?string $stripeInvoiceId = null,
?string $stripePaymentIntentId = null,
?string $stripeSubscriptionId = null,
?array $metadata = null,
): CreditLedgerEntry {
return CreditLedgerEntry::create([
'user_id' => $user->getKey(),
'credit_product_id' => $product?->getKey(),
'type' => $type,
'credits' => $credits,
'balance_after' => $user->ai_credits_balance,
'source' => $source,
'stripe_checkout_session_id' => $stripeCheckoutSessionId,
'stripe_invoice_id' => $stripeInvoiceId,
'stripe_payment_intent_id' => $stripePaymentIntentId,
'stripe_subscription_id' => $stripeSubscriptionId,
'metadata' => $metadata,
]);
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ class PaymentInvoiceEmailer
try {
Mail::to($transaction->user->email)->send(
new PaymentInvoiceMail($transaction->user, $transaction->loadMissing('creditProduct'))
new PaymentInvoiceMail($transaction->user, $transaction->loadMissing('billingProduct'))
);
} catch (Throwable $exception) {
$transaction->forceFill([
+5 -18
View File
@@ -4,8 +4,7 @@ namespace App\Services;
use App\Enums\PaymentTransactionStatus;
use App\Enums\PaymentTransactionType;
use App\Models\CreditLedgerEntry;
use App\Models\CreditProduct;
use App\Models\BillingProduct;
use App\Models\PaymentTransaction;
use App\Models\User;
@@ -16,7 +15,7 @@ class PaymentTransactionRecorder
*/
public function recordCheckoutSession(
?User $user,
?CreditProduct $product,
?BillingProduct $product,
string $stripeCheckoutSessionId,
PaymentTransactionStatus $status = PaymentTransactionStatus::PENDING,
?string $stripeEventId = null,
@@ -25,9 +24,6 @@ class PaymentTransactionRecorder
?string $stripePaymentIntentId = null,
?int $amount = null,
?string $currency = null,
?int $creditsExpected = null,
?int $creditsGranted = null,
?CreditLedgerEntry $ledgerEntry = null,
?string $invoiceUrl = null,
?string $invoicePdfUrl = null,
?string $errorMessage = null,
@@ -37,8 +33,7 @@ class PaymentTransactionRecorder
'stripe_checkout_session_id' => $stripeCheckoutSessionId,
], [
'user_id' => $user?->getKey(),
'credit_product_id' => $product?->getKey(),
'credit_ledger_entry_id' => $ledgerEntry?->getKey(),
'billing_product_id' => $product?->getKey(),
'type' => PaymentTransactionType::CHECKOUT,
'status' => $status,
'stripe_event_id' => $stripeEventId,
@@ -47,8 +42,6 @@ class PaymentTransactionRecorder
'stripe_payment_intent_id' => $stripePaymentIntentId,
'amount' => $amount,
'currency' => $currency,
'credits_expected' => $creditsExpected ?? $product?->credits,
'credits_granted' => $creditsGranted ?? 0,
'invoice_url' => $invoiceUrl,
'invoice_pdf_url' => $invoicePdfUrl,
'error_message' => $errorMessage,
@@ -62,7 +55,7 @@ class PaymentTransactionRecorder
*/
public function recordInvoice(
?User $user,
?CreditProduct $product,
?BillingProduct $product,
string $stripeInvoiceId,
?string $stripePriceId,
PaymentTransactionStatus $status,
@@ -73,9 +66,6 @@ class PaymentTransactionRecorder
?string $stripeSubscriptionId = null,
?int $amount = null,
?string $currency = null,
?int $creditsExpected = null,
?int $creditsGranted = null,
?CreditLedgerEntry $ledgerEntry = null,
?string $invoiceUrl = null,
?string $invoicePdfUrl = null,
?string $errorMessage = null,
@@ -86,8 +76,7 @@ class PaymentTransactionRecorder
'stripe_price_id' => $stripePriceId,
], [
'user_id' => $user?->getKey(),
'credit_product_id' => $product?->getKey(),
'credit_ledger_entry_id' => $ledgerEntry?->getKey(),
'billing_product_id' => $product?->getKey(),
'type' => PaymentTransactionType::SUBSCRIPTION_INVOICE,
'status' => $status,
'stripe_event_id' => $stripeEventId,
@@ -97,8 +86,6 @@ class PaymentTransactionRecorder
'stripe_subscription_id' => $stripeSubscriptionId,
'amount' => $amount,
'currency' => $currency,
'credits_expected' => $creditsExpected ?? $product?->credits,
'credits_granted' => $creditsGranted ?? 0,
'invoice_url' => $invoiceUrl,
'invoice_pdf_url' => $invoicePdfUrl,
'error_message' => $errorMessage,
@@ -3,12 +3,12 @@
namespace App\Services;
use App\Enums\BillingProductPurpose;
use App\Models\CreditProduct;
use App\Models\BillingProduct;
use Laravel\Cashier\Cashier;
class StripeCreditProductSyncer
class StripeBillingProductSyncer
{
public function sync(CreditProduct $product): CreditProduct
public function sync(BillingProduct $product): BillingProduct
{
throw_if(
$product->purpose === null,
@@ -24,7 +24,7 @@ class StripeCreditProductSyncer
'name' => $product->name,
'description' => $product->description,
'metadata' => [
'credit_product_id' => $product->getKey(),
'billing_product_id' => $product->getKey(),
'billing_product_purpose' => $product->purpose?->value,
],
]);
@@ -36,7 +36,7 @@ class StripeCreditProductSyncer
'description' => $product->description,
'active' => (bool) $product->is_active,
'metadata' => [
'credit_product_id' => $product->getKey(),
'billing_product_id' => $product->getKey(),
'billing_product_purpose' => $product->purpose?->value,
],
]);
@@ -58,7 +58,7 @@ class StripeCreditProductSyncer
'unit_amount' => $product->amount,
'currency' => strtolower($product->currency),
'metadata' => [
'credit_product_id' => $product->getKey(),
'billing_product_id' => $product->getKey(),
'billing_product_purpose' => $product->purpose?->value,
],
];