feat: subscriptions
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Enums\IdentityVerificationStatus;
|
||||
use App\Models\IdentityVerificationRequest;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ReviewIdentityVerification
|
||||
{
|
||||
public function execute(
|
||||
IdentityVerificationRequest $verificationRequest,
|
||||
IdentityVerificationStatus $status,
|
||||
User $reviewer,
|
||||
?string $rejectionReason = null,
|
||||
): IdentityVerificationRequest {
|
||||
abort_unless($reviewer->isAdmin(), 403);
|
||||
abort_unless(
|
||||
in_array($status, [IdentityVerificationStatus::APPROVED, IdentityVerificationStatus::REJECTED], true),
|
||||
422,
|
||||
);
|
||||
abort_if(
|
||||
$status === IdentityVerificationStatus::REJECTED && blank($rejectionReason),
|
||||
422,
|
||||
__('api.certification.rejection_reason_required'),
|
||||
);
|
||||
|
||||
$reviewedRequest = DB::transaction(function () use (
|
||||
$rejectionReason,
|
||||
$reviewer,
|
||||
$status,
|
||||
$verificationRequest,
|
||||
): IdentityVerificationRequest {
|
||||
$lockedRequest = IdentityVerificationRequest::query()
|
||||
->whereKey($verificationRequest->getKey())
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
abort_unless($lockedRequest->status === IdentityVerificationStatus::PENDING, 409);
|
||||
|
||||
$lockedRequest->update([
|
||||
'status' => $status,
|
||||
'reviewed_at' => now(),
|
||||
'reviewed_by' => $reviewer->getKey(),
|
||||
'rejection_reason' => $status === IdentityVerificationStatus::REJECTED
|
||||
? trim((string) $rejectionReason)
|
||||
: null,
|
||||
]);
|
||||
|
||||
if ($status === IdentityVerificationStatus::APPROVED) {
|
||||
$lockedRequest->user()->update([
|
||||
'account_verified_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $lockedRequest->fresh(['user', 'reviewer']);
|
||||
});
|
||||
|
||||
$this->deleteReviewedDocument($reviewedRequest);
|
||||
|
||||
return $reviewedRequest->fresh(['user', 'reviewer']);
|
||||
}
|
||||
|
||||
private function deleteReviewedDocument(IdentityVerificationRequest $verificationRequest): void
|
||||
{
|
||||
if (! $verificationRequest->hasDocument()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Storage::disk($verificationRequest->document_disk)
|
||||
->delete($verificationRequest->document_path);
|
||||
|
||||
$verificationRequest->update([
|
||||
'document_path' => null,
|
||||
'document_deleted_at' => now(),
|
||||
]);
|
||||
} catch (\Throwable $exception) {
|
||||
report($exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Enums\IdentityVerificationStatus;
|
||||
use App\Models\IdentityVerificationRequest;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Throwable;
|
||||
|
||||
class SubmitIdentityVerification
|
||||
{
|
||||
public function execute(User $user, UploadedFile $document): IdentityVerificationRequest
|
||||
{
|
||||
return Cache::lock('identity-verification-submit:'.$user->getKey(), 30)
|
||||
->block(5, fn (): IdentityVerificationRequest => $this->store($user, $document));
|
||||
}
|
||||
|
||||
private function store(User $user, UploadedFile $document): IdentityVerificationRequest
|
||||
{
|
||||
$existingRequest = $user->identityVerificationRequest()->first();
|
||||
|
||||
if ($existingRequest?->status === IdentityVerificationStatus::PENDING) {
|
||||
throw new ConflictHttpException(__('api.certification.already_pending'));
|
||||
}
|
||||
|
||||
$disk = (string) config('billing.identity_verification.disk', 'identity_documents');
|
||||
$path = null;
|
||||
|
||||
try {
|
||||
$path = Storage::disk($disk)->putFileAs(
|
||||
'submissions',
|
||||
$document,
|
||||
Str::ulid().'.'.$this->extensionFor($document),
|
||||
['visibility' => 'private'],
|
||||
);
|
||||
|
||||
if (! is_string($path) || $path === '') {
|
||||
throw new \RuntimeException('The identity document could not be stored.');
|
||||
}
|
||||
|
||||
$verificationRequest = IdentityVerificationRequest::query()->updateOrCreate(
|
||||
['user_id' => $user->getKey()],
|
||||
[
|
||||
'status' => IdentityVerificationStatus::PENDING,
|
||||
'document_disk' => $disk,
|
||||
'document_path' => $path,
|
||||
'document_mime_type' => $document->getMimeType(),
|
||||
'document_size' => $document->getSize(),
|
||||
'submitted_at' => now(),
|
||||
'reviewed_at' => null,
|
||||
'reviewed_by' => null,
|
||||
'rejection_reason' => null,
|
||||
'document_deleted_at' => null,
|
||||
],
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
if ($path) {
|
||||
Storage::disk($disk)->delete($path);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
if ($existingRequest?->hasDocument() && $existingRequest->document_path !== $path) {
|
||||
Storage::disk($existingRequest->document_disk)->delete($existingRequest->document_path);
|
||||
}
|
||||
|
||||
return $verificationRequest;
|
||||
}
|
||||
|
||||
private function extensionFor(UploadedFile $document): string
|
||||
{
|
||||
return match ($document->getMimeType()) {
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'application/pdf' => 'pdf',
|
||||
default => throw new \InvalidArgumentException('Unsupported identity document type.'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Filament\Support\Contracts\HasColor;
|
||||
use Filament\Support\Contracts\HasLabel;
|
||||
|
||||
enum BillingProductPurpose: string implements HasColor, HasLabel
|
||||
{
|
||||
case SUBSCRIPTION = 'subscription';
|
||||
case CERTIFICATION = 'certification';
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::SUBSCRIPTION => __('enums.billing_product_purpose.subscription'),
|
||||
self::CERTIFICATION => __('enums.billing_product_purpose.certification'),
|
||||
};
|
||||
}
|
||||
|
||||
public function getColor(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::SUBSCRIPTION => 'info',
|
||||
self::CERTIFICATION => 'success',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Filament\Support\Contracts\HasColor;
|
||||
use Filament\Support\Contracts\HasLabel;
|
||||
|
||||
enum IdentityVerificationStatus: string implements HasColor, HasLabel
|
||||
{
|
||||
case PENDING = 'pending';
|
||||
case APPROVED = 'approved';
|
||||
case REJECTED = 'rejected';
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::PENDING => __('enums.identity_verification_status.pending'),
|
||||
self::APPROVED => __('enums.identity_verification_status.approved'),
|
||||
self::REJECTED => __('enums.identity_verification_status.rejected'),
|
||||
};
|
||||
}
|
||||
|
||||
public function getColor(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::PENDING => 'warning',
|
||||
self::APPROVED => 'success',
|
||||
self::REJECTED => 'danger',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ class CreditProductResource extends Resource
|
||||
return Action::make('syncStripe')
|
||||
->label(__('admin.credit_products.actions.sync_stripe'))
|
||||
->icon(Heroicon::OutlinedArrowPath)
|
||||
->visible(fn (CreditProduct $record): bool => blank($record->stripe_price_id))
|
||||
->visible(fn (CreditProduct $record): bool => blank($record->stripe_price_id) && $record->purpose !== null)
|
||||
->requiresConfirmation()
|
||||
->action(function (CreditProduct $record): void {
|
||||
app(StripeCreditProductSyncer::class)->sync($record);
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Filament\Resources\CreditProducts\Schemas;
|
||||
|
||||
use App\Enums\BillingProductPurpose;
|
||||
use App\Enums\CreditProductType;
|
||||
use Filament\Forms\Components\Hidden;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
@@ -23,16 +25,15 @@ class CreditProductForm
|
||||
->label(__('admin.credit_products.fields.name'))
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Select::make('type')
|
||||
->label(__('admin.credit_products.fields.type'))
|
||||
->options(CreditProductType::class)
|
||||
->default(CreditProductType::ONE_TIME->value)
|
||||
->required(),
|
||||
TextInput::make('credits')
|
||||
->label(__('admin.credit_products.fields.credits'))
|
||||
->integer()
|
||||
->minValue(1)
|
||||
Select::make('purpose')
|
||||
->label(__('admin.credit_products.fields.purpose'))
|
||||
->options(BillingProductPurpose::class)
|
||||
->unique(ignoreRecord: true)
|
||||
->required(),
|
||||
Hidden::make('type')
|
||||
->default(CreditProductType::ONE_TIME->value),
|
||||
Hidden::make('credits')
|
||||
->default(0),
|
||||
TextInput::make('amount')
|
||||
->label(__('admin.credit_products.fields.amount'))
|
||||
->helperText(__('admin.credit_products.helpers.amount'))
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources\CreditProducts\Tables;
|
||||
|
||||
use App\Enums\CreditProductType;
|
||||
use App\Enums\BillingProductPurpose;
|
||||
use App\Filament\Resources\CreditProducts\CreditProductResource;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
@@ -24,14 +24,10 @@ class CreditProductsTable
|
||||
->label(__('admin.credit_products.fields.name'))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('type')
|
||||
->label(__('admin.credit_products.fields.type'))
|
||||
TextColumn::make('purpose')
|
||||
->label(__('admin.credit_products.fields.purpose'))
|
||||
->badge()
|
||||
->sortable(),
|
||||
TextColumn::make('credits')
|
||||
->label(__('admin.credit_products.fields.credits'))
|
||||
->numeric(decimalPlaces: 0)
|
||||
->sortable(),
|
||||
TextColumn::make('amount')
|
||||
->label(__('admin.credit_products.fields.amount'))
|
||||
->money(fn ($record): string => $record->currency, divideBy: 100)
|
||||
@@ -56,9 +52,9 @@ class CreditProductsTable
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('type')
|
||||
->label(__('admin.credit_products.fields.type'))
|
||||
->options(CreditProductType::class),
|
||||
SelectFilter::make('purpose')
|
||||
->label(__('admin.credit_products.fields.purpose'))
|
||||
->options(BillingProductPurpose::class),
|
||||
TernaryFilter::make('is_active')
|
||||
->label(__('admin.credit_products.fields.is_active')),
|
||||
])
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\IdentityVerificationRequests;
|
||||
|
||||
use App\Filament\Resources\IdentityVerificationRequests\Pages\ListIdentityVerificationRequests;
|
||||
use App\Filament\Resources\IdentityVerificationRequests\Pages\ViewIdentityVerificationRequest;
|
||||
use App\Filament\Resources\IdentityVerificationRequests\Schemas\IdentityVerificationRequestInfolist;
|
||||
use App\Filament\Resources\IdentityVerificationRequests\Tables\IdentityVerificationRequestsTable;
|
||||
use App\Models\IdentityVerificationRequest;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class IdentityVerificationRequestResource extends Resource
|
||||
{
|
||||
protected static ?string $model = IdentityVerificationRequest::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedIdentification;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'id';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('admin.identity_verification_requests.navigation.label');
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('admin.identity_verification_requests.navigation.singular');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('admin.identity_verification_requests.navigation.plural');
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return IdentityVerificationRequestInfolist::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return IdentityVerificationRequestsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListIdentityVerificationRequests::route('/'),
|
||||
'view' => ViewIdentityVerificationRequest::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\IdentityVerificationRequests\Pages;
|
||||
|
||||
use App\Filament\Resources\IdentityVerificationRequests\IdentityVerificationRequestResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListIdentityVerificationRequests extends ListRecords
|
||||
{
|
||||
protected static string $resource = IdentityVerificationRequestResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\IdentityVerificationRequests\Pages;
|
||||
|
||||
use App\Filament\Resources\IdentityVerificationRequests\IdentityVerificationRequestResource;
|
||||
use App\Filament\Resources\IdentityVerificationRequests\Tables\IdentityVerificationRequestsTable;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewIdentityVerificationRequest extends ViewRecord
|
||||
{
|
||||
protected static string $resource = IdentityVerificationRequestResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
IdentityVerificationRequestsTable::approveAction(),
|
||||
IdentityVerificationRequestsTable::rejectAction(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\IdentityVerificationRequests\Schemas;
|
||||
|
||||
use App\Models\IdentityVerificationRequest;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class IdentityVerificationRequestInfolist
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make(__('admin.identity_verification_requests.sections.request'))
|
||||
->columns(3)
|
||||
->schema([
|
||||
TextEntry::make('id')
|
||||
->label(__('admin.identity_verification_requests.fields.id'))
|
||||
->copyable(),
|
||||
TextEntry::make('status')
|
||||
->label(__('admin.identity_verification_requests.fields.status'))
|
||||
->badge(),
|
||||
TextEntry::make('user.email')
|
||||
->label(__('admin.identity_verification_requests.fields.user'))
|
||||
->copyable(),
|
||||
TextEntry::make('submitted_at')
|
||||
->label(__('admin.identity_verification_requests.fields.submitted_at'))
|
||||
->dateTime(),
|
||||
TextEntry::make('reviewed_at')
|
||||
->label(__('admin.identity_verification_requests.fields.reviewed_at'))
|
||||
->dateTime()
|
||||
->placeholder(__('admin.identity_verification_requests.placeholders.empty')),
|
||||
TextEntry::make('reviewer.email')
|
||||
->label(__('admin.identity_verification_requests.fields.reviewed_by'))
|
||||
->placeholder(__('admin.identity_verification_requests.placeholders.empty')),
|
||||
TextEntry::make('document')
|
||||
->label(__('admin.identity_verification_requests.fields.document'))
|
||||
->state(__('admin.identity_verification_requests.actions.open_document'))
|
||||
->url(fn (IdentityVerificationRequest $record): ?string => $record->temporaryDocumentUrl())
|
||||
->openUrlInNewTab()
|
||||
->visible(fn (IdentityVerificationRequest $record): bool => $record->hasDocument()),
|
||||
TextEntry::make('document_deleted_at')
|
||||
->label(__('admin.identity_verification_requests.fields.document_deleted_at'))
|
||||
->dateTime()
|
||||
->placeholder(__('admin.identity_verification_requests.placeholders.empty')),
|
||||
TextEntry::make('rejection_reason')
|
||||
->label(__('admin.identity_verification_requests.fields.rejection_reason'))
|
||||
->placeholder(__('admin.identity_verification_requests.placeholders.empty'))
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\IdentityVerificationRequests\Tables;
|
||||
|
||||
use App\Actions\ReviewIdentityVerification;
|
||||
use App\Enums\IdentityVerificationStatus;
|
||||
use App\Models\IdentityVerificationRequest;
|
||||
use App\Models\User;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class IdentityVerificationRequestsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('submitted_at', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('submitted_at')
|
||||
->label(__('admin.identity_verification_requests.fields.submitted_at'))
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
TextColumn::make('status')
|
||||
->label(__('admin.identity_verification_requests.fields.status'))
|
||||
->badge()
|
||||
->sortable(),
|
||||
TextColumn::make('user.email')
|
||||
->label(__('admin.identity_verification_requests.fields.user'))
|
||||
->searchable(),
|
||||
TextColumn::make('reviewed_at')
|
||||
->label(__('admin.identity_verification_requests.fields.reviewed_at'))
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->placeholder(__('admin.identity_verification_requests.placeholders.empty')),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->label(__('admin.identity_verification_requests.fields.status'))
|
||||
->options(IdentityVerificationStatus::class),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
self::approveAction(),
|
||||
self::rejectAction(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function approveAction(): Action
|
||||
{
|
||||
return Action::make('approve')
|
||||
->label(__('admin.identity_verification_requests.actions.approve'))
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (IdentityVerificationRequest $record): bool => $record->status === IdentityVerificationStatus::PENDING)
|
||||
->action(function (IdentityVerificationRequest $record): void {
|
||||
app(ReviewIdentityVerification::class)->execute(
|
||||
$record,
|
||||
IdentityVerificationStatus::APPROVED,
|
||||
self::reviewer(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public static function rejectAction(): Action
|
||||
{
|
||||
return Action::make('reject')
|
||||
->label(__('admin.identity_verification_requests.actions.reject'))
|
||||
->color('danger')
|
||||
->schema([
|
||||
Textarea::make('reason')
|
||||
->label(__('admin.identity_verification_requests.fields.rejection_reason'))
|
||||
->required()
|
||||
->maxLength(2000),
|
||||
])
|
||||
->visible(fn (IdentityVerificationRequest $record): bool => $record->status === IdentityVerificationStatus::PENDING)
|
||||
->action(function (array $data, IdentityVerificationRequest $record): void {
|
||||
app(ReviewIdentityVerification::class)->execute(
|
||||
$record,
|
||||
IdentityVerificationStatus::REJECTED,
|
||||
self::reviewer(),
|
||||
$data['reason'],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private static function reviewer(): User
|
||||
{
|
||||
$reviewer = auth()->user();
|
||||
|
||||
abort_unless($reviewer instanceof User, 403);
|
||||
|
||||
return $reviewer;
|
||||
}
|
||||
}
|
||||
@@ -63,8 +63,8 @@ class UserForm
|
||||
->autocomplete('new-password'),
|
||||
DateTimePicker::make('email_verified_at')
|
||||
->label(__('admin.users.fields.email_verified_at')),
|
||||
DateTimePicker::make('account_verified_at')
|
||||
->label(__('admin.users.fields.account_verified_at')),
|
||||
DateTimePicker::make('trial_ends_at')
|
||||
->label(__('admin.users.fields.trial_ends_at')),
|
||||
]),
|
||||
Section::make(__('admin.users.sections.profile'))
|
||||
->columns(2)
|
||||
|
||||
@@ -35,6 +35,14 @@ class UserInfolist
|
||||
->label(__('admin.users.fields.account_verified_at'))
|
||||
->dateTime()
|
||||
->placeholder(__('admin.users.placeholders.not_verified')),
|
||||
TextEntry::make('certification_purchased_at')
|
||||
->label(__('admin.users.fields.certification_purchased_at'))
|
||||
->dateTime()
|
||||
->placeholder(__('admin.users.placeholders.empty')),
|
||||
TextEntry::make('trial_ends_at')
|
||||
->label(__('admin.users.fields.trial_ends_at'))
|
||||
->dateTime()
|
||||
->placeholder(__('admin.users.placeholders.empty')),
|
||||
TextEntry::make('suspended_at')
|
||||
->label(__('admin.users.fields.suspended_at'))
|
||||
->dateTime()
|
||||
|
||||
@@ -51,6 +51,12 @@ class UsersTable
|
||||
->sortable()
|
||||
->placeholder(__('admin.users.placeholders.not_verified'))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('trial_ends_at')
|
||||
->label(__('admin.users.fields.trial_ends_at'))
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->placeholder(__('admin.users.placeholders.empty'))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('suspended_at')
|
||||
->label(__('admin.users.fields.suspended_at'))
|
||||
->dateTime()
|
||||
|
||||
@@ -305,6 +305,7 @@ class AuthController extends Controller
|
||||
public function destroy(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$identityVerificationRequest = $user->identityVerificationRequest()->first();
|
||||
$storagePaths = collect([$user->avatar_url])
|
||||
->merge($user->mealPosts()->withTrashed()->pluck('image_url'))
|
||||
->filter(fn (?string $path): bool => $this->isPublicStoragePath($path))
|
||||
@@ -330,6 +331,11 @@ class AuthController extends Controller
|
||||
Storage::disk()->delete($storagePaths);
|
||||
}
|
||||
|
||||
if ($identityVerificationRequest?->hasDocument()) {
|
||||
Storage::disk($identityVerificationRequest->document_disk)
|
||||
->delete($identityVerificationRequest->document_path);
|
||||
}
|
||||
|
||||
if ($request->hasSession()) {
|
||||
Auth::guard('web')->logout();
|
||||
$request->session()->invalidate();
|
||||
@@ -354,6 +360,11 @@ class AuthController extends Controller
|
||||
$userAttributes['email'] = strtolower($data['email']);
|
||||
$userAttributes['password'] = Hash::make($data['password']);
|
||||
$userAttributes['avatar_url'] = $avatarPath;
|
||||
$trialDays = max(0, (int) config('billing.trial_days', 7));
|
||||
|
||||
if ($trialDays > 0) {
|
||||
$userAttributes['trial_ends_at'] = now()->addDays($trialDays);
|
||||
}
|
||||
|
||||
$user = User::create($userAttributes);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CreditProductType;
|
||||
use App\Enums\BillingProductPurpose;
|
||||
use App\Http\Resources\CreditProductResource;
|
||||
use App\Models\CreditProduct;
|
||||
use App\Services\MobileDeepLink;
|
||||
@@ -18,6 +18,7 @@ class BillingController extends Controller
|
||||
{
|
||||
$products = CreditProduct::query()
|
||||
->active()
|
||||
->whereNotNull('purpose')
|
||||
->whereNotNull('stripe_price_id')
|
||||
->orderBy('sort_order')
|
||||
->orderBy('amount')
|
||||
@@ -32,6 +33,7 @@ class BillingController extends Controller
|
||||
PaymentTransactionRecorder $transactions,
|
||||
): JsonResponse {
|
||||
abort_unless($creditProduct->is_active, 404);
|
||||
abort_unless($creditProduct->purpose !== null, 404);
|
||||
abort_unless(filled($creditProduct->stripe_price_id), 404);
|
||||
|
||||
$user = $request->user();
|
||||
@@ -39,8 +41,7 @@ class BillingController extends Controller
|
||||
|
||||
$metadata = [
|
||||
'credit_product_id' => $creditProduct->getKey(),
|
||||
'credit_product_type' => $creditProduct->type->value,
|
||||
'credits' => (string) $creditProduct->credits,
|
||||
'billing_product_purpose' => $creditProduct->purpose->value,
|
||||
];
|
||||
$sessionOptions = [
|
||||
'success_url' => $this->redirectUrl('success'),
|
||||
@@ -49,7 +50,7 @@ class BillingController extends Controller
|
||||
'client_reference_id' => $creditProduct->getKey(),
|
||||
];
|
||||
|
||||
if ($creditProduct->type === CreditProductType::MONTHLY) {
|
||||
if ($creditProduct->purpose === BillingProductPurpose::SUBSCRIPTION) {
|
||||
if ($user->subscribed('default')) {
|
||||
return response()->json([
|
||||
'message' => __('api.billing.active_subscription_exists'),
|
||||
@@ -61,6 +62,12 @@ class BillingController extends Controller
|
||||
->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' => [
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\SubmitIdentityVerification;
|
||||
use App\Enums\IdentityVerificationStatus;
|
||||
use App\Http\Requests\StoreIdentityVerificationRequest;
|
||||
use App\Http\Resources\IdentityVerificationResource;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CertificationController extends Controller
|
||||
{
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$verificationRequest = $user->identityVerificationRequest()->first();
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'entitlementPurchased' => $user->hasPurchasedCertification(),
|
||||
'accountVerified' => $user->account_verified_at !== null,
|
||||
'canSubmitIdentity' => $user->hasPurchasedCertification()
|
||||
&& $user->account_verified_at === null
|
||||
&& $verificationRequest?->status !== IdentityVerificationStatus::PENDING,
|
||||
'verification' => $verificationRequest
|
||||
? (new IdentityVerificationResource($verificationRequest))->resolve($request)
|
||||
: null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(
|
||||
StoreIdentityVerificationRequest $request,
|
||||
SubmitIdentityVerification $submitIdentityVerification,
|
||||
): JsonResponse {
|
||||
$submitIdentityVerification->execute(
|
||||
$request->user(),
|
||||
$request->file('document'),
|
||||
);
|
||||
|
||||
return $this->show($request);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ use App\Enums\AiUsageType;
|
||||
use App\Enums\IngredientUnit;
|
||||
use App\Enums\MealPostType;
|
||||
use App\Http\Requests\MealImageAnalysisRequest;
|
||||
use App\Services\AiCreditService;
|
||||
use App\Services\AiUsageRecorder;
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -20,19 +19,15 @@ class MealImageAnalysisController extends Controller
|
||||
{
|
||||
public function store(
|
||||
MealImageAnalysisRequest $request,
|
||||
AiCreditService $aiCredits,
|
||||
AiUsageRecorder $aiUsageRecorder,
|
||||
): JsonResponse {
|
||||
$user = $request->user();
|
||||
$image = $request->file('image');
|
||||
$input = $this->analysisInput($image);
|
||||
|
||||
if (! $aiCredits->consume($user)) {
|
||||
if (! $user->canAnalyzeMeals()) {
|
||||
return response()->json([
|
||||
'message' => __('api.meal_image_analysis.insufficient_credits'),
|
||||
'credits' => [
|
||||
'balance' => $user->ai_credits_balance,
|
||||
],
|
||||
'message' => __('api.meal_image_analysis.subscription_required'),
|
||||
], 402);
|
||||
}
|
||||
|
||||
@@ -45,8 +40,6 @@ class MealImageAnalysisController extends Controller
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
$aiCredits->refund($user);
|
||||
|
||||
$aiUsageRecorder->failed(
|
||||
user: $user,
|
||||
type: AiUsageType::MEAL_IMAGE_ANALYSIS,
|
||||
@@ -79,9 +72,6 @@ class MealImageAnalysisController extends Controller
|
||||
|
||||
return response()->json([
|
||||
'data' => $draft,
|
||||
'credits' => [
|
||||
'balance' => $user->fresh()->ai_credits_balance,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules\File;
|
||||
|
||||
class StoreIdentityVerificationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
return $user?->hasPurchasedCertification() === true
|
||||
&& $user->account_verified_at === null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'document' => [
|
||||
'required',
|
||||
File::types(['jpg', 'jpeg', 'png', 'pdf'])->max('10mb'),
|
||||
'mimetypes:image/jpeg,image/png,application/pdf',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'document.required' => __('api.certification.document_required'),
|
||||
'document.mimetypes' => __('api.certification.document_type'),
|
||||
'document.max' => __('api.certification.document_max'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,7 @@ class CreditProductResource extends JsonResource
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'type' => $this->type,
|
||||
'credits' => $this->credits,
|
||||
'purpose' => $this->purpose,
|
||||
'amount' => $this->amount,
|
||||
'currency' => $this->currency,
|
||||
'formattedAmount' => $this->formattedAmount(),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class IdentityVerificationResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'status' => $this->status,
|
||||
'submittedAt' => $this->submitted_at?->toISOString(),
|
||||
'reviewedAt' => $this->reviewed_at?->toISOString(),
|
||||
'rejectionReason' => $this->rejection_reason,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,10 @@ class UserResource extends JsonResource
|
||||
'bio' => $this->bio,
|
||||
'role' => $this->role,
|
||||
'accountVerified' => $this->account_verified_at !== null,
|
||||
'aiCreditsBalance' => (int) $this->ai_credits_balance,
|
||||
'certificationPurchased' => $this->hasPurchasedCertification(),
|
||||
'canAnalyzeMeals' => $this->canAnalyzeMeals(),
|
||||
'analysisAccessLevel' => $this->analysisAccessLevel(),
|
||||
'trialEndsAt' => $this->trial_ends_at?->toISOString(),
|
||||
'hasActiveSubscription' => $this->subscribed('default'),
|
||||
'suspendedAt' => $this->suspended_at?->toISOString(),
|
||||
'physicalActivityLevel' => $this->physical_activity_level,
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Enums\CreditLedgerEntryType;
|
||||
use App\Enums\CreditProductType;
|
||||
use App\Models\CreditLedgerEntry;
|
||||
use App\Enums\BillingProductPurpose;
|
||||
use App\Enums\PaymentTransactionStatus;
|
||||
use App\Models\CreditProduct;
|
||||
use App\Models\User;
|
||||
use App\Services\AiCreditService;
|
||||
use App\Services\PaymentInvoiceEmailer;
|
||||
use App\Services\PaymentTransactionRecorder;
|
||||
use Laravel\Cashier\Cashier;
|
||||
@@ -15,7 +13,6 @@ use Laravel\Cashier\Cashier;
|
||||
class GrantCreditsFromStripeWebhook
|
||||
{
|
||||
public function __construct(
|
||||
private AiCreditService $credits,
|
||||
private PaymentInvoiceEmailer $invoiceEmails,
|
||||
private PaymentTransactionRecorder $transactions,
|
||||
) {}
|
||||
@@ -30,8 +27,7 @@ class GrantCreditsFromStripeWebhook
|
||||
match ($payload['type'] ?? null) {
|
||||
'checkout.session.completed' => $this->handleCheckoutSessionCompleted($payload),
|
||||
'checkout.session.expired' => $this->handleCheckoutSessionExpired($payload),
|
||||
'invoice.paid' => $this->handleInvoicePaid($payload),
|
||||
'invoice.payment_succeeded' => $this->handleInvoicePaid($payload),
|
||||
'invoice.paid', 'invoice.payment_succeeded' => $this->handleInvoicePaid($payload),
|
||||
'invoice.payment_failed' => $this->handleInvoicePaymentFailed($payload),
|
||||
default => null,
|
||||
};
|
||||
@@ -43,87 +39,60 @@ class GrantCreditsFromStripeWebhook
|
||||
private function handleCheckoutSessionCompleted(array $payload): void
|
||||
{
|
||||
$session = $payload['data']['object'] ?? [];
|
||||
$eventId = $this->stringValue($payload['id'] ?? null);
|
||||
$eventType = $this->stringValue($payload['type'] ?? null);
|
||||
$sessionId = $this->stringValue($session['id'] ?? null);
|
||||
|
||||
if (! $sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (($session['mode'] ?? null) !== 'payment' || ($session['payment_status'] ?? null) !== 'paid') {
|
||||
$this->transactions->recordCheckoutSession(
|
||||
user: $this->userFromCustomer($session['customer'] ?? null),
|
||||
product: $this->productFromMetadata($session['metadata'] ?? null, CreditProductType::ONE_TIME),
|
||||
stripeCheckoutSessionId: $sessionId,
|
||||
status: \App\Enums\PaymentTransactionStatus::IGNORED,
|
||||
stripeEventId: $eventId,
|
||||
stripeEventType: $eventType,
|
||||
stripeCustomerId: $this->stringValue($session['customer'] ?? null),
|
||||
stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null),
|
||||
amount: $this->intValue($session['amount_total'] ?? null),
|
||||
currency: $this->stringValue($session['currency'] ?? null),
|
||||
errorMessage: 'Checkout session is not a paid one-time payment.',
|
||||
payload: $session,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$product = $this->productFromMetadata($session['metadata'] ?? null, CreditProductType::ONE_TIME);
|
||||
$product = $this->productFromMetadata($session['metadata'] ?? null);
|
||||
$user = $this->userFromCustomer($session['customer'] ?? null);
|
||||
$paymentStatus = $this->stringValue($session['payment_status'] ?? null);
|
||||
$expectedMode = $product?->purpose === BillingProductPurpose::SUBSCRIPTION
|
||||
? 'subscription'
|
||||
: 'payment';
|
||||
$isPaid = $paymentStatus === 'paid'
|
||||
|| ($expectedMode === 'subscription' && $paymentStatus === 'no_payment_required');
|
||||
|
||||
if (! $product || ! $user) {
|
||||
if (! $product || ! $user || ($session['mode'] ?? null) !== $expectedMode || ! $isPaid) {
|
||||
$this->transactions->recordCheckoutSession(
|
||||
user: $user,
|
||||
product: $product,
|
||||
stripeCheckoutSessionId: $sessionId,
|
||||
status: \App\Enums\PaymentTransactionStatus::ERROR,
|
||||
stripeEventId: $eventId,
|
||||
stripeEventType: $eventType,
|
||||
status: $product && $user ? PaymentTransactionStatus::IGNORED : PaymentTransactionStatus::ERROR,
|
||||
stripeEventId: $this->stringValue($payload['id'] ?? null),
|
||||
stripeEventType: $this->stringValue($payload['type'] ?? null),
|
||||
stripeCustomerId: $this->stringValue($session['customer'] ?? null),
|
||||
stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null),
|
||||
amount: $this->intValue($session['amount_total'] ?? null),
|
||||
currency: $this->stringValue($session['currency'] ?? null),
|
||||
errorMessage: $product
|
||||
? 'Stripe customer does not match any user.'
|
||||
: 'Credit product was not found from checkout metadata.',
|
||||
errorMessage: 'Checkout session does not match an eligible paid billing product.',
|
||||
payload: $session,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$source = 'stripe_checkout_session:'.$sessionId;
|
||||
$entry = $this->credits->grant(
|
||||
user: $user,
|
||||
credits: $product->credits,
|
||||
type: CreditLedgerEntryType::PURCHASE,
|
||||
product: $product,
|
||||
source: $source,
|
||||
stripeCheckoutSessionId: $sessionId,
|
||||
stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null),
|
||||
metadata: [
|
||||
'stripe_event_id' => $eventId,
|
||||
],
|
||||
);
|
||||
if ($product->purpose === BillingProductPurpose::CERTIFICATION
|
||||
&& $user->certification_purchased_at === null) {
|
||||
$user->forceFill([
|
||||
'certification_purchased_at' => now(),
|
||||
])->save();
|
||||
}
|
||||
|
||||
$entry ??= CreditLedgerEntry::query()->where('source', $source)->first();
|
||||
$invoice = $this->retrieveInvoice($this->stringValue($session['invoice'] ?? null));
|
||||
|
||||
$transaction = $this->transactions->recordCheckoutSession(
|
||||
user: $user,
|
||||
product: $product,
|
||||
stripeCheckoutSessionId: $sessionId,
|
||||
status: \App\Enums\PaymentTransactionStatus::CREDITED,
|
||||
stripeEventId: $eventId,
|
||||
stripeEventType: $eventType,
|
||||
status: PaymentTransactionStatus::PAID,
|
||||
stripeEventId: $this->stringValue($payload['id'] ?? null),
|
||||
stripeEventType: $this->stringValue($payload['type'] ?? null),
|
||||
stripeCustomerId: $this->stringValue($session['customer'] ?? null),
|
||||
stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null),
|
||||
amount: $this->intValue($session['amount_total'] ?? null),
|
||||
currency: $this->stringValue($session['currency'] ?? null),
|
||||
creditsGranted: $entry ? $product->credits : 0,
|
||||
ledgerEntry: $entry,
|
||||
creditsExpected: 0,
|
||||
invoiceUrl: $invoice ? $this->stringValue($invoice['hosted_invoice_url'] ?? null) : null,
|
||||
invoicePdfUrl: $invoice ? $this->stringValue($invoice['invoice_pdf'] ?? null) : null,
|
||||
payload: $session,
|
||||
@@ -146,15 +115,16 @@ class GrantCreditsFromStripeWebhook
|
||||
|
||||
$this->transactions->recordCheckoutSession(
|
||||
user: $this->userFromCustomer($session['customer'] ?? null),
|
||||
product: $this->productFromMetadata($session['metadata'] ?? null, CreditProductType::ONE_TIME),
|
||||
product: $this->productFromMetadata($session['metadata'] ?? null),
|
||||
stripeCheckoutSessionId: $sessionId,
|
||||
status: \App\Enums\PaymentTransactionStatus::FAILED,
|
||||
status: PaymentTransactionStatus::FAILED,
|
||||
stripeEventId: $this->stringValue($payload['id'] ?? null),
|
||||
stripeEventType: $this->stringValue($payload['type'] ?? null),
|
||||
stripeCustomerId: $this->stringValue($session['customer'] ?? null),
|
||||
stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null),
|
||||
amount: $this->intValue($session['amount_total'] ?? null),
|
||||
currency: $this->stringValue($session['currency'] ?? null),
|
||||
creditsExpected: 0,
|
||||
errorMessage: 'Checkout session expired before payment.',
|
||||
payload: $session,
|
||||
);
|
||||
@@ -167,105 +137,41 @@ class GrantCreditsFromStripeWebhook
|
||||
{
|
||||
$invoice = $payload['data']['object'] ?? [];
|
||||
$invoiceId = $this->stringValue($invoice['id'] ?? null);
|
||||
$eventId = $this->stringValue($payload['id'] ?? null);
|
||||
$eventType = $this->stringValue($payload['type'] ?? null);
|
||||
|
||||
if (! $invoiceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->userFromCustomer($invoice['customer'] ?? null);
|
||||
|
||||
if (! $user) {
|
||||
$this->transactions->recordInvoice(
|
||||
user: null,
|
||||
product: null,
|
||||
stripeInvoiceId: $invoiceId,
|
||||
stripePriceId: null,
|
||||
status: \App\Enums\PaymentTransactionStatus::ERROR,
|
||||
stripeEventId: $eventId,
|
||||
stripeEventType: $eventType,
|
||||
stripeCustomerId: $this->stringValue($invoice['customer'] ?? null),
|
||||
stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null),
|
||||
stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null),
|
||||
amount: $this->intValue($invoice['amount_paid'] ?? null),
|
||||
currency: $this->stringValue($invoice['currency'] ?? null),
|
||||
errorMessage: 'Stripe customer does not match any user.',
|
||||
payload: $invoice,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$matchedLine = false;
|
||||
$matchedProduct = false;
|
||||
|
||||
foreach (($invoice['lines']['data'] ?? []) as $line) {
|
||||
$priceId = $line['price']['id'] ?? null;
|
||||
|
||||
if (! $priceId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$product = CreditProduct::query()
|
||||
->where('type', CreditProductType::MONTHLY)
|
||||
->where('stripe_price_id', $priceId)
|
||||
->first();
|
||||
$priceId = $this->linePriceId($line);
|
||||
$product = $priceId
|
||||
? CreditProduct::query()
|
||||
->where('purpose', BillingProductPurpose::SUBSCRIPTION)
|
||||
->where('stripe_price_id', $priceId)
|
||||
->first()
|
||||
: null;
|
||||
|
||||
if (! $product) {
|
||||
$this->transactions->recordInvoice(
|
||||
user: $user,
|
||||
product: null,
|
||||
stripeInvoiceId: $invoiceId,
|
||||
stripePriceId: $this->stringValue($priceId),
|
||||
status: \App\Enums\PaymentTransactionStatus::ERROR,
|
||||
stripeEventId: $eventId,
|
||||
stripeEventType: $eventType,
|
||||
stripeCustomerId: $this->stringValue($invoice['customer'] ?? null),
|
||||
stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null),
|
||||
stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null),
|
||||
amount: $this->intValue($line['amount'] ?? $invoice['amount_paid'] ?? null),
|
||||
currency: $this->stringValue($invoice['currency'] ?? null),
|
||||
errorMessage: 'No monthly credit product found for Stripe price.',
|
||||
payload: $invoice,
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$matchedLine = true;
|
||||
$source = 'stripe_invoice:'.$invoiceId.':price:'.$priceId;
|
||||
$entry = $this->credits->grant(
|
||||
user: $user,
|
||||
credits: $product->credits,
|
||||
type: CreditLedgerEntryType::SUBSCRIPTION_RENEWAL,
|
||||
product: $product,
|
||||
source: $source,
|
||||
stripeInvoiceId: $invoiceId,
|
||||
stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null),
|
||||
stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null),
|
||||
metadata: [
|
||||
'billing_reason' => $invoice['billing_reason'] ?? null,
|
||||
'stripe_event_id' => $eventId,
|
||||
],
|
||||
);
|
||||
|
||||
$entry ??= CreditLedgerEntry::query()->where('source', $source)->first();
|
||||
|
||||
$matchedProduct = true;
|
||||
$transaction = $this->transactions->recordInvoice(
|
||||
user: $user,
|
||||
user: $this->userFromCustomer($invoice['customer'] ?? null),
|
||||
product: $product,
|
||||
stripeInvoiceId: $invoiceId,
|
||||
stripePriceId: $this->stringValue($priceId),
|
||||
status: \App\Enums\PaymentTransactionStatus::CREDITED,
|
||||
stripeEventId: $eventId,
|
||||
stripeEventType: $eventType,
|
||||
stripePriceId: $priceId,
|
||||
status: PaymentTransactionStatus::PAID,
|
||||
stripeEventId: $this->stringValue($payload['id'] ?? null),
|
||||
stripeEventType: $this->stringValue($payload['type'] ?? null),
|
||||
stripeCustomerId: $this->stringValue($invoice['customer'] ?? null),
|
||||
stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null),
|
||||
stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null),
|
||||
stripeSubscriptionId: $this->invoiceSubscriptionId($invoice),
|
||||
amount: $this->intValue($line['amount'] ?? $invoice['amount_paid'] ?? null),
|
||||
currency: $this->stringValue($invoice['currency'] ?? null),
|
||||
creditsGranted: $entry ? $product->credits : 0,
|
||||
ledgerEntry: $entry,
|
||||
creditsExpected: 0,
|
||||
invoiceUrl: $this->stringValue($invoice['hosted_invoice_url'] ?? null),
|
||||
invoicePdfUrl: $this->stringValue($invoice['invoice_pdf'] ?? null),
|
||||
payload: $invoice,
|
||||
@@ -274,21 +180,22 @@ class GrantCreditsFromStripeWebhook
|
||||
$this->invoiceEmails->sendIfAvailable($transaction);
|
||||
}
|
||||
|
||||
if (! $matchedLine) {
|
||||
if (! $matchedProduct) {
|
||||
$this->transactions->recordInvoice(
|
||||
user: $user,
|
||||
user: $this->userFromCustomer($invoice['customer'] ?? null),
|
||||
product: null,
|
||||
stripeInvoiceId: $invoiceId,
|
||||
stripePriceId: null,
|
||||
status: \App\Enums\PaymentTransactionStatus::IGNORED,
|
||||
stripeEventId: $eventId,
|
||||
stripeEventType: $eventType,
|
||||
status: PaymentTransactionStatus::IGNORED,
|
||||
stripeEventId: $this->stringValue($payload['id'] ?? null),
|
||||
stripeEventType: $this->stringValue($payload['type'] ?? null),
|
||||
stripeCustomerId: $this->stringValue($invoice['customer'] ?? null),
|
||||
stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null),
|
||||
stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null),
|
||||
stripeSubscriptionId: $this->invoiceSubscriptionId($invoice),
|
||||
amount: $this->intValue($invoice['amount_paid'] ?? null),
|
||||
currency: $this->stringValue($invoice['currency'] ?? null),
|
||||
errorMessage: 'Invoice does not contain a monthly credit product line.',
|
||||
creditsExpected: 0,
|
||||
errorMessage: 'Invoice does not contain the active subscription product.',
|
||||
payload: $invoice,
|
||||
);
|
||||
}
|
||||
@@ -306,11 +213,10 @@ class GrantCreditsFromStripeWebhook
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (($invoice['lines']['data'] ?? [null]) as $line) {
|
||||
$priceId = is_array($line) ? $this->stringValue($line['price']['id'] ?? null) : null;
|
||||
$amount = is_array($line)
|
||||
? $this->intValue($line['amount'] ?? $invoice['amount_due'] ?? null)
|
||||
: $this->intValue($invoice['amount_due'] ?? null);
|
||||
$lines = $invoice['lines']['data'] ?? [null];
|
||||
|
||||
foreach ($lines ?: [null] as $line) {
|
||||
$priceId = is_array($line) ? $this->linePriceId($line) : null;
|
||||
$product = $priceId
|
||||
? CreditProduct::query()->where('stripe_price_id', $priceId)->first()
|
||||
: null;
|
||||
@@ -320,14 +226,19 @@ class GrantCreditsFromStripeWebhook
|
||||
product: $product,
|
||||
stripeInvoiceId: $invoiceId,
|
||||
stripePriceId: $priceId,
|
||||
status: \App\Enums\PaymentTransactionStatus::FAILED,
|
||||
status: PaymentTransactionStatus::FAILED,
|
||||
stripeEventId: $this->stringValue($payload['id'] ?? null),
|
||||
stripeEventType: $this->stringValue($payload['type'] ?? null),
|
||||
stripeCustomerId: $this->stringValue($invoice['customer'] ?? null),
|
||||
stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null),
|
||||
stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null),
|
||||
amount: $amount,
|
||||
stripeSubscriptionId: $this->invoiceSubscriptionId($invoice),
|
||||
amount: $this->intValue(
|
||||
is_array($line)
|
||||
? ($line['amount'] ?? $invoice['amount_due'] ?? null)
|
||||
: ($invoice['amount_due'] ?? null)
|
||||
),
|
||||
currency: $this->stringValue($invoice['currency'] ?? null),
|
||||
creditsExpected: 0,
|
||||
errorMessage: 'Invoice payment failed.',
|
||||
payload: $invoice,
|
||||
);
|
||||
@@ -343,18 +254,50 @@ class GrantCreditsFromStripeWebhook
|
||||
return User::query()->where('stripe_id', $customer)->first();
|
||||
}
|
||||
|
||||
private function productFromMetadata(mixed $metadata, CreditProductType $type): ?CreditProduct
|
||||
private function productFromMetadata(mixed $metadata): ?CreditProduct
|
||||
{
|
||||
if (! is_array($metadata) || ! is_string($metadata['credit_product_id'] ?? null)) {
|
||||
if (! is_array($metadata)
|
||||
|| ! is_string($metadata['credit_product_id'] ?? null)
|
||||
|| ! is_string($metadata['billing_product_purpose'] ?? null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$purpose = BillingProductPurpose::tryFrom($metadata['billing_product_purpose']);
|
||||
|
||||
if (! $purpose) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return CreditProduct::query()
|
||||
->whereKey($metadata['credit_product_id'])
|
||||
->where('type', $type)
|
||||
->where('purpose', $purpose)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $line
|
||||
*/
|
||||
private function linePriceId(array $line): ?string
|
||||
{
|
||||
return $this->stringValue(
|
||||
$line['price']['id']
|
||||
?? $line['pricing']['price_details']['price']
|
||||
?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $invoice
|
||||
*/
|
||||
private function invoiceSubscriptionId(array $invoice): ?string
|
||||
{
|
||||
return $this->stringValue(
|
||||
$invoice['subscription']
|
||||
?? $invoice['parent']['subscription_details']['subscription']
|
||||
?? null
|
||||
);
|
||||
}
|
||||
|
||||
private function stringValue(mixed $value): ?string
|
||||
{
|
||||
return is_string($value) && $value !== '' ? $value : null;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\BillingProductPurpose;
|
||||
use App\Enums\CreditProductType;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
@@ -18,6 +19,7 @@ class CreditProduct extends Model
|
||||
'name',
|
||||
'description',
|
||||
'type',
|
||||
'purpose',
|
||||
'credits',
|
||||
'amount',
|
||||
'currency',
|
||||
@@ -51,6 +53,7 @@ class CreditProduct extends Model
|
||||
{
|
||||
return [
|
||||
'type' => CreditProductType::class,
|
||||
'purpose' => BillingProductPurpose::class,
|
||||
'credits' => 'integer',
|
||||
'amount' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\IdentityVerificationStatus;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class IdentityVerificationRequest extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\IdentityVerificationRequestFactory> */
|
||||
use HasFactory, HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'status',
|
||||
'document_disk',
|
||||
'document_path',
|
||||
'document_mime_type',
|
||||
'document_size',
|
||||
'submitted_at',
|
||||
'reviewed_at',
|
||||
'reviewed_by',
|
||||
'rejection_reason',
|
||||
'document_deleted_at',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'document_disk',
|
||||
'document_path',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function reviewer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'reviewed_by');
|
||||
}
|
||||
|
||||
public function hasDocument(): bool
|
||||
{
|
||||
return filled($this->document_path) && $this->document_deleted_at === null;
|
||||
}
|
||||
|
||||
public function temporaryDocumentUrl(): ?string
|
||||
{
|
||||
if (! $this->hasDocument()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Storage::disk($this->document_disk)->temporaryUrl(
|
||||
$this->document_path,
|
||||
now()->addMinutes((int) config('billing.identity_verification.temporary_url_minutes', 5)),
|
||||
);
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => IdentityVerificationStatus::class,
|
||||
'submitted_at' => 'datetime',
|
||||
'reviewed_at' => 'datetime',
|
||||
'document_deleted_at' => 'datetime',
|
||||
'document_size' => 'integer',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,8 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale
|
||||
'height',
|
||||
'bio',
|
||||
'account_verified_at',
|
||||
'certification_purchased_at',
|
||||
'trial_ends_at',
|
||||
'daily_calorie_goal',
|
||||
'daily_protein_goal',
|
||||
'daily_carbs_goal',
|
||||
@@ -82,6 +84,7 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'account_verified_at' => 'datetime',
|
||||
'certification_purchased_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'daily_calorie_goal' => 'integer',
|
||||
'daily_protein_goal' => 'float',
|
||||
@@ -129,6 +132,11 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale
|
||||
return $this->hasMany(PaymentTransaction::class);
|
||||
}
|
||||
|
||||
public function identityVerificationRequest(): HasOne
|
||||
{
|
||||
return $this->hasOne(IdentityVerificationRequest::class);
|
||||
}
|
||||
|
||||
public function workouts(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorkoutSessions::class);
|
||||
@@ -188,6 +196,25 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale
|
||||
return $this->suspended_at !== null;
|
||||
}
|
||||
|
||||
public function canAnalyzeMeals(): bool
|
||||
{
|
||||
return $this->subscribed('default') || $this->onGenericTrial();
|
||||
}
|
||||
|
||||
public function analysisAccessLevel(): string
|
||||
{
|
||||
if ($this->subscribed('default')) {
|
||||
return 'subscribed';
|
||||
}
|
||||
|
||||
return $this->onGenericTrial() ? 'trial' : 'free';
|
||||
}
|
||||
|
||||
public function hasPurchasedCertification(): bool
|
||||
{
|
||||
return $this->certification_purchased_at !== null;
|
||||
}
|
||||
|
||||
public function routeNotificationForExpoPush(): array
|
||||
{
|
||||
return $this->deviceTokens()
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\IdentityVerificationRequest;
|
||||
use App\Models\User;
|
||||
|
||||
class IdentityVerificationRequestPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
public function view(User $user, IdentityVerificationRequest $identityVerificationRequest): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function update(User $user, IdentityVerificationRequest $identityVerificationRequest): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
public function delete(User $user, IdentityVerificationRequest $identityVerificationRequest): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function restore(User $user, IdentityVerificationRequest $identityVerificationRequest): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function forceDelete(User $user, IdentityVerificationRequest $identityVerificationRequest): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\CreditProductType;
|
||||
use App\Enums\BillingProductPurpose;
|
||||
use App\Models\CreditProduct;
|
||||
use Laravel\Cashier\Cashier;
|
||||
|
||||
@@ -10,6 +10,12 @@ class StripeCreditProductSyncer
|
||||
{
|
||||
public function sync(CreditProduct $product): CreditProduct
|
||||
{
|
||||
throw_if(
|
||||
$product->purpose === null,
|
||||
\InvalidArgumentException::class,
|
||||
'A billing product purpose is required before syncing with Stripe.',
|
||||
);
|
||||
|
||||
$stripe = Cashier::stripe();
|
||||
$stripeProductId = $product->stripe_product_id;
|
||||
|
||||
@@ -19,8 +25,7 @@ class StripeCreditProductSyncer
|
||||
'description' => $product->description,
|
||||
'metadata' => [
|
||||
'credit_product_id' => $product->getKey(),
|
||||
'credits' => (string) $product->credits,
|
||||
'type' => $product->type->value,
|
||||
'billing_product_purpose' => $product->purpose?->value,
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -32,8 +37,7 @@ class StripeCreditProductSyncer
|
||||
'active' => (bool) $product->is_active,
|
||||
'metadata' => [
|
||||
'credit_product_id' => $product->getKey(),
|
||||
'credits' => (string) $product->credits,
|
||||
'type' => $product->type->value,
|
||||
'billing_product_purpose' => $product->purpose?->value,
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -42,7 +46,7 @@ class StripeCreditProductSyncer
|
||||
|
||||
if (! $product->stripe_price_id) {
|
||||
$needsNewPrice = true;
|
||||
} elseif ($product->wasChanged(['amount', 'currency', 'type', 'credits'])) {
|
||||
} elseif ($product->wasChanged(['amount', 'currency', 'purpose'])) {
|
||||
$needsNewPrice = true;
|
||||
}
|
||||
|
||||
@@ -55,12 +59,11 @@ class StripeCreditProductSyncer
|
||||
'currency' => strtolower($product->currency),
|
||||
'metadata' => [
|
||||
'credit_product_id' => $product->getKey(),
|
||||
'credits' => (string) $product->credits,
|
||||
'type' => $product->type->value,
|
||||
'billing_product_purpose' => $product->purpose?->value,
|
||||
],
|
||||
];
|
||||
|
||||
if ($product->type === CreditProductType::MONTHLY) {
|
||||
if ($product->purpose === BillingProductPurpose::SUBSCRIPTION) {
|
||||
$priceData['recurring'] = [
|
||||
'interval' => 'month',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user