feat: revenue cat
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class BillingProductResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'purpose' => $this->purpose,
|
||||
'amount' => $this->amount,
|
||||
'currency' => $this->currency,
|
||||
'formattedAmount' => $this->formattedAmount(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,12 @@ class UserResource extends JsonResource
|
||||
'certificationPurchased' => $this->hasPurchasedCertification(),
|
||||
'canAnalyzeMeals' => $this->canAnalyzeMeals(),
|
||||
'analysisAccessLevel' => $this->analysisAccessLevel(),
|
||||
'trialEndsAt' => $this->trial_ends_at?->toISOString(),
|
||||
'hasActiveSubscription' => $this->subscribed('default'),
|
||||
'trialEndsAt' => $this->analysisAccessLevel() === 'trial'
|
||||
? $this->subscription_expires_at?->toISOString()
|
||||
: null,
|
||||
'subscriptionExpiresAt' => $this->subscription_expires_at?->toISOString(),
|
||||
'subscriptionProductId' => $this->subscription_product_id,
|
||||
'hasActiveSubscription' => $this->hasActiveSubscription(),
|
||||
'suspendedAt' => $this->suspended_at?->toISOString(),
|
||||
'physicalActivityLevel' => $this->physical_activity_level,
|
||||
'physicalActivityLevelLabel' => $this->physical_activity_level?->getLabel(),
|
||||
|
||||
Reference in New Issue
Block a user