feat: revenue cat
CI / 🧪 Tests Laravel (push) Successful in 2m24s
CI / 🐳 Build & Push Image (push) Successful in 1m13s

This commit is contained in:
2026-08-12 16:00:11 +02:00
parent 31633cfd2c
commit 29e85589b0
55 changed files with 703 additions and 2363 deletions
+8 -7
View File
@@ -11,6 +11,7 @@ use App\Http\Requests\UpdatePasswordRequest;
use App\Http\Requests\UpdateUserRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Services\RevenueCatService;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Auth\Events\Verified;
use Illuminate\Http\JsonResponse;
@@ -302,7 +303,7 @@ class AuthController extends Controller
]);
}
public function destroy(Request $request): JsonResponse
public function destroy(Request $request, RevenueCatService $revenueCat): JsonResponse
{
$user = $request->user();
$identityVerificationRequest = $user->identityVerificationRequest()->first();
@@ -312,6 +313,12 @@ class AuthController extends Controller
->values()
->all();
try {
$revenueCat->deleteCustomer($user);
} catch (Throwable $exception) {
report($exception);
}
DB::transaction(function () use ($user): void {
$user->tokens()->delete();
$user->notifications()->delete();
@@ -361,12 +368,6 @@ class AuthController extends Controller
$userAttributes['password'] = Hash::make($data['password']);
$userAttributes['avatar_url'] = $avatarPath;
$userAttributes['terms_accepted_at'] = now();
$trialDays = max(0, (int) config('billing.trial_days', 7));
if ($trialDays > 0) {
$userAttributes['trial_ends_at'] = now()->addDays($trialDays);
}
$user = User::create($userAttributes);
try {
-138
View File
@@ -1,138 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Enums\BillingProductPurpose;
use App\Http\Resources\BillingProductResource;
use App\Models\BillingProduct;
use App\Services\MobileDeepLink;
use App\Services\PaymentTransactionRecorder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class BillingController extends Controller
{
public function products(): AnonymousResourceCollection
{
$products = BillingProduct::query()
->active()
->whereNotNull('purpose')
->whereNotNull('stripe_price_id')
->orderBy('sort_order')
->orderBy('amount')
->get();
return BillingProductResource::collection($products);
}
public function checkout(
Request $request,
BillingProduct $billingProduct,
PaymentTransactionRecorder $transactions,
): JsonResponse {
abort_unless($billingProduct->is_active, 404);
abort_unless($billingProduct->purpose !== null, 404);
abort_unless(filled($billingProduct->stripe_price_id), 404);
$user = $request->user();
$this->ensureStripeCustomer($user);
$metadata = [
'billing_product_id' => $billingProduct->getKey(),
'billing_product_purpose' => $billingProduct->purpose->value,
];
$sessionOptions = [
'success_url' => $this->redirectUrl('success'),
'cancel_url' => $this->redirectUrl('cancel'),
'metadata' => $metadata,
'client_reference_id' => $billingProduct->getKey(),
];
if ($billingProduct->purpose === BillingProductPurpose::SUBSCRIPTION) {
if ($user->subscribed('default')) {
return response()->json([
'message' => __('api.billing.active_subscription_exists'),
], 409);
}
$checkout = $user
->newSubscription('default', $billingProduct->stripe_price_id)
->withMetadata($metadata)
->checkout($sessionOptions);
} else {
if ($user->hasPurchasedCertification() || $user->account_verified_at !== null) {
return response()->json([
'message' => __('api.billing.certification_already_purchased'),
], 409);
}
$sessionOptions['invoice_creation'] = [
'enabled' => true,
'invoice_data' => [
'metadata' => $metadata,
],
];
$checkout = $user->checkout([
$billingProduct->stripe_price_id => 1,
], $sessionOptions);
}
$session = $checkout->asStripeCheckoutSession();
$transactions->recordCheckoutSession(
user: $user,
product: $billingProduct,
stripeCheckoutSessionId: $session->id,
stripeCustomerId: $session->customer,
amount: $session->amount_total,
currency: $session->currency,
);
return response()->json([
'id' => $session->id,
'url' => $session->url,
]);
}
public function portal(Request $request): JsonResponse
{
$user = $request->user();
$this->ensureStripeCustomer($user);
if (! $user->subscribed('default')) {
return response()->json([
'message' => __('api.billing.no_active_subscription'),
], 409);
}
$portalUrl = $user->billingPortalUrl($this->redirectUrl('portal'));
return response()->json([
'url' => $portalUrl,
]);
}
public function redirectToMobileApp(string $status): RedirectResponse
{
abort_unless(in_array($status, ['success', 'cancel', 'portal'], true), 404);
return redirect()->away(MobileDeepLink::to("billing/{$status}"));
}
private function ensureStripeCustomer(mixed $user): void
{
if (! is_object($user) || ! method_exists($user, 'createOrGetStripeCustomer')) {
return;
}
$user->createOrGetStripeCustomer();
}
private function redirectUrl(string $status): string
{
return route('billing.web-return', ['status' => $status]);
}
}
@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers;
use App\Http\Resources\UserResource;
use App\Models\RevenueCatWebhookEvent;
use App\Models\User;
use App\Notifications\PaymentFailedNotification;
use App\Services\RevenueCatService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
class RevenueCatController extends Controller
{
public function sync(Request $request, RevenueCatService $revenueCat): UserResource
{
return new UserResource($revenueCat->sync($request->user()));
}
public function webhook(Request $request, RevenueCatService $revenueCat): JsonResponse
{
$expectedAuthorization = trim((string) config('billing.revenuecat.webhook_authorization'));
$providedAuthorization = (string) $request->header('Authorization');
abort_if(
$expectedAuthorization === '' || ! hash_equals($expectedAuthorization, $providedAuthorization),
401,
);
$event = $request->input('event');
abort_unless(is_array($event), 422);
$eventId = $this->stringValue($event['id'] ?? null);
$eventType = $this->stringValue($event['type'] ?? null);
abort_unless($eventId && $eventType, 422);
$webhookEvent = RevenueCatWebhookEvent::query()->firstOrCreate([
'event_id' => $eventId,
], [
'type' => $eventType,
'app_user_id' => $this->stringValue($event['app_user_id'] ?? null),
'product_id' => $this->stringValue($event['product_id'] ?? null),
'entitlement_ids' => $this->stringList($event['entitlement_ids'] ?? []),
'environment' => $this->stringValue($event['environment'] ?? null),
'payload' => $event,
]);
if ($webhookEvent->processed_at !== null) {
return response()->json(['received' => true]);
}
$user = $this->userFromEvent($event);
if ($user) {
$revenueCat->sync($user);
if ($eventType === 'BILLING_ISSUE') {
$user->notify(
(new PaymentFailedNotification)->locale($user->preferredLocale()),
);
}
}
$webhookEvent->forceFill(['processed_at' => now()])->save();
return response()->json(['received' => true]);
}
/**
* @param array<string, mixed> $event
*/
private function userFromEvent(array $event): ?User
{
$identifiers = collect([
$event['app_user_id'] ?? null,
$event['original_app_user_id'] ?? null,
])
->merge(Arr::wrap($event['aliases'] ?? []))
->merge(Arr::wrap($event['transferred_to'] ?? []))
->filter(fn (mixed $identifier): bool => is_string($identifier) && $identifier !== '')
->unique()
->values();
return User::query()->whereIn('id', $identifiers)->first();
}
/**
* @return list<string>
*/
private function stringList(mixed $value): array
{
return collect(Arr::wrap($value))
->filter(fn (mixed $item): bool => is_string($item) && $item !== '')
->values()
->all();
}
private function stringValue(mixed $value): ?string
{
return is_string($value) && $value !== '' ? $value : null;
}
}