Spaces:
Running
Running
| namespace App\Domain\Applications; | |
| use App\Domain\Audit\AuditService; | |
| use App\Domain\Communications\CommunicationsService; | |
| use App\Domain\Files\SignedFileUrlFactory; | |
| use App\Domain\Identity\EmailVerificationService; | |
| use App\Domain\Payments\PaymentService; | |
| use App\Infrastructure\Database\TransactionManager; | |
| use App\Infrastructure\Ids\UuidGenerator; | |
| use App\Shared\Exceptions\ApiException; | |
| use App\Support\PlatformUrl; | |
| use Illuminate\Http\Request; | |
| use Illuminate\Http\UploadedFile; | |
| use Illuminate\Support\Carbon; | |
| use Illuminate\Support\Facades\DB; | |
| use Illuminate\Support\Facades\Log; | |
| use Illuminate\Support\Facades\Schema; | |
| use Illuminate\Support\Facades\Storage; | |
| use Illuminate\Support\Str; | |
| class ApplicationService | |
| { | |
| private const REQUIRED_CONSENTS = ['TERMS', 'PRIVACY']; | |
| public function __construct( | |
| private readonly ApplicationRepository $applications, | |
| private readonly ApplicationWorkflowPolicy $policy, | |
| private readonly UuidGenerator $ids, | |
| private readonly TransactionManager $transactions, | |
| private readonly AuditService $audit, | |
| private readonly CommunicationsService $communications, | |
| private readonly EmailVerificationService $emailVerification, | |
| private readonly IspMembershipService $ispMembership, | |
| private readonly PaymentService $payments, | |
| private readonly SignedFileUrlFactory $fileUrls, | |
| ) { | |
| } | |
| public function createApplicant(array $input): array | |
| { | |
| return $this->transactions->run(function () use ($input): array { | |
| $challenge = $this->emailVerification->requireVerifiedChallenge($input['email_verification_id']); | |
| $email = $this->normalizeEmail($challenge->email); | |
| $this->rejectMismatchedEmail($input, $email); | |
| $duplicate = $this->duplicateApplicationForEmail($email); | |
| if ($duplicate !== null) { | |
| if (in_array($duplicate->status, ['APPLICATION_IN_PROGRESS', 'INCOMPLETE'], true)) { | |
| $this->refreshApplicantVerificationChallenge($duplicate, $challenge->id); | |
| return [ | |
| 'application' => $this->payload($this->requireApplication($duplicate->id)), | |
| 'existing' => true, | |
| ]; | |
| } | |
| throw new ApiException('APPLICATION_ALREADY_EXISTS', 'An active application already exists for this email address.', [ | |
| 'application' => $this->publicApplicationSummary($duplicate), | |
| ], 409); | |
| } | |
| $id = $this->ids->generate(); | |
| $attributes = [ | |
| 'id' => $id, | |
| 'application_reference' => 'APP-'.Carbon::now()->format('YmdHis').'-'.substr(str_replace('-', '', $id), 0, 8), | |
| 'user_id' => null, | |
| 'status' => 'APPLICATION_IN_PROGRESS', | |
| 'pathway' => $this->pathwayFromInput($input), | |
| 'first_name' => $input['first_name'] ?? $this->firstNameFromFullName($input['full_name'] ?? ''), | |
| 'middle_name' => $input['middle_name'] ?? null, | |
| 'last_name' => $input['last_name'] ?? $this->lastNameFromFullName($input['full_name'] ?? ''), | |
| 'preferred_name' => $input['preferred_name'] ?? null, | |
| 'email' => $email, | |
| 'phone_number' => $input['phone_number'] ?? $input['phone'] ?? '', | |
| 'alternate_phone_number' => $input['alternate_phone_number'] ?? null, | |
| 'country_of_residence' => $input['country_of_residence'] ?? '', | |
| 'city_state_region' => $input['city_state_region'] ?? null, | |
| 'professional_profile' => $this->professionalProfileFromInput($input)??null, | |
| 'kenyan_professional_indicator' => (int) (bool) ($input['kenyan_professional_indicator'] ?? false), | |
| 'duplicate_flag' => 0, | |
| 'duplicate_reason' => null, | |
| 'metadata' => json_encode($this->applicantMetadata($input), JSON_THROW_ON_ERROR), | |
| ]; | |
| $attributes = array_merge($attributes, $this->structuredApplicantColumns($input)); | |
| if (Schema::hasColumn('applications', 'email_verification_challenge_id')) { | |
| $attributes['email_verification_challenge_id'] = $challenge->id; | |
| } | |
| if (Schema::hasColumn('applications', 'email_normalized')) { | |
| $attributes['email_normalized'] = $email; | |
| } | |
| $this->applications->create($attributes); | |
| $this->audit->record('APPLICATION_CREATED', null, null, 'applications', $id, 'SUCCESS', [ | |
| 'source' => 'applicant_public_intake', | |
| 'email_verification_id' => $challenge->id, | |
| ]); | |
| return [ | |
| 'application' => $this->payload($this->requireApplication($id)), | |
| 'existing' => false, | |
| ]; | |
| }); | |
| } | |
| public function getApplicant(string $id, string $emailVerificationId): array | |
| { | |
| $application = $this->requireApplicantApplication($id, $emailVerificationId); | |
| return ['application' => $this->payload($application)]; | |
| } | |
| public function updateApplicant(string $id, array $input): array | |
| { | |
| return $this->transactions->run(function () use ($id, $input): array { | |
| $application = $this->requireApplicantApplication($id, $input['email_verification_id']); | |
| $this->ensureApplicantEditable($application); | |
| $this->rejectMismatchedEmail($input, $this->normalizeEmail($application->email)); | |
| $updates = []; | |
| foreach (['middle_name', 'preferred_name', 'alternate_phone_number', 'city_state_region'] as $field) { | |
| if (array_key_exists($field, $input)) { | |
| $updates[$field] = $input[$field]; | |
| } | |
| } | |
| if (array_key_exists('full_name', $input)) { | |
| $updates['first_name'] = $this->firstNameFromFullName($input['full_name']); | |
| $updates['last_name'] = $this->lastNameFromFullName($input['full_name']); | |
| } | |
| foreach (['first_name', 'last_name', 'phone_number', 'country_of_residence'] as $field) { | |
| if (array_key_exists($field, $input)) { | |
| $updates[$field] = $input[$field]; | |
| } | |
| } | |
| if (array_key_exists('phone', $input)) { | |
| $updates['phone_number'] = $input['phone']; | |
| } | |
| if (array_key_exists('pathway', $input) || array_key_exists('isp_member', $input)) { | |
| $updates['pathway'] = $this->pathwayFromInput($input); | |
| } | |
| if ($this->hasProfessionalInput($input)) { | |
| $updates['professional_profile'] = $this->professionalProfileFromInput($input); | |
| } | |
| if (array_key_exists('kenyan_professional_indicator', $input)) { | |
| $updates['kenyan_professional_indicator'] = (int) (bool) $input['kenyan_professional_indicator']; | |
| } | |
| $updates = array_merge($updates, $this->structuredApplicantColumns($input)); | |
| $updates['metadata'] = json_encode(array_merge( | |
| $this->decodeMetadata($application->metadata ?? null), | |
| $this->applicantMetadata($input), | |
| ), JSON_THROW_ON_ERROR); | |
| $this->applications->update($id, $updates); | |
| $this->audit->record('APPLICATION_UPDATED', null, null, 'applications', $id, 'SUCCESS', [ | |
| 'source' => 'applicant_public_intake', | |
| ]); | |
| return ['application' => $this->payload($this->requireApplication($id))]; | |
| }); | |
| } | |
| public function submitApplicant(string $id, string $emailVerificationId): array | |
| { | |
| return $this->transactions->run(function () use ($id, $emailVerificationId): array { | |
| $application = $this->requireApplicantApplication($id, $emailVerificationId); | |
| $this->ensureApplicantEditable($application); | |
| $this->ensureApplicantComplete($application); | |
| if (! $this->applicantFeeRequirementSatisfied($application)) { | |
| throw new ApiException('APPLICATION_FEE_REQUIRED', 'Application fee must be paid before submission.', [ | |
| 'application_fee' => $this->applicationFeeSummary($application->id, $this->decodeMetadata($application->metadata ?? null)), | |
| ], 409); | |
| } | |
| $this->applications->update($id, ['submitted_at' => Carbon::now(), 'status' => 'APPLICATION_COMPLETED']); | |
| $this->audit->record('APPLICATION_SUBMITTED', null, null, 'applications', $id, 'SUCCESS', [ | |
| 'source' => 'applicant_public_intake', | |
| ]); | |
| $this->sendApplicationMessage($this->requireApplication($id), 'APPLICATION_SUBMITTED', 'Application submitted'); | |
| return ['application' => $this->payload($this->requireApplication($id))]; | |
| }); | |
| } | |
| public function attachApplicantDocument(string $id, array $input): array | |
| { | |
| $application = $this->requireApplicantApplication($id, $input['email_verification_id']); | |
| $this->ensureApplicantEditable($application); | |
| $file = DB::table('stored_files')->where('id', $input['file_id'])->whereNull('deleted_at')->first(); | |
| if ($file === null) { | |
| throw new ApiException('FILE_NOT_FOUND', 'File was not found.', [], 404); | |
| } | |
| DB::table('file_links')->insert([ | |
| 'id' => $this->ids->generate(), | |
| 'file_id' => $file->id, | |
| 'application_id' => $id, | |
| 'link_purpose' => $input['link_purpose'] ?? 'APPLICATION_DOCUMENT', | |
| 'linked_by_user_id' => null, | |
| 'created_at' => now(), | |
| 'updated_at' => now(), | |
| ]); | |
| $this->audit->record('APPLICATION_DOCUMENT_LINKED', null, null, 'applications', $id, 'SUCCESS', ['file_id' => $file->id]); | |
| $this->recordApplicantDocumentMetadata($application, $file->id, $input['document_type'] ?? null); | |
| return [ | |
| 'application' => $this->payload($this->requireApplication($id)), | |
| 'file_id' => $file->id, | |
| 'file' => [ | |
| 'id' => $file->id, | |
| 'original_filename' => $file->original_filename, | |
| 'mime_type' => $file->mime_type, | |
| 'file_size_bytes' => (int) $file->file_size_bytes, | |
| 'download_url' => $this->applicantFileSignedUrl($file->id), | |
| 'stream_url' => $this->applicantFileStreamUrl($file->id), | |
| 'secure_link' => $this->applicantFileSignedUrl($file->id), | |
| ], | |
| ]; | |
| } | |
| public function verifyApplicantIspMembership(string $id, array $input): array | |
| { | |
| return $this->transactions->run(function () use ($id, $input): array { | |
| $application = $this->requireApplicantApplication($id, $input['email_verification_id']); | |
| $this->ensureApplicantEditable($application); | |
| $memberNo = trim($input['member_no']); | |
| $programEmail = strtolower(trim($input['program_email'])); | |
| $response = $this->ispMembership->verify($memberNo, $programEmail); | |
| $isFullMember = $this->ispMembership->isFullMember($response); | |
| $metadata = $this->decodeMetadata($application->metadata ?? null); | |
| $metadata['isp_member'] = $isFullMember; | |
| $metadata['isp_member_number'] = $memberNo; | |
| $metadata['isp_program_email'] = $programEmail; | |
| $metadata['isp_verification_result'] = $response; | |
| $updates = [ | |
| 'pathway' => $isFullMember ? 'ISP' : 'STANDARD', | |
| 'metadata' => json_encode($metadata, JSON_THROW_ON_ERROR), | |
| ]; | |
| if ($isFullMember) { | |
| if (Schema::hasColumn('applications', 'ISP_email')) { | |
| $updates['ISP_email'] = $programEmail; | |
| } | |
| if (Schema::hasColumn('applications', 'ISP_no')) { | |
| $updates['ISP_no'] = $memberNo; | |
| } | |
| $validationId = $this->applications->createIspValidation([ | |
| 'id' => $this->ids->generate(), | |
| 'application_id' => $application->id, | |
| 'validation_method' => 'API', | |
| 'isp_member_identifier' => $memberNo, | |
| 'isp_email' => $programEmail, | |
| 'status' => 'CONFIRMED', | |
| 'validated_at' => Carbon::now(), | |
| 'metadata' => json_encode(['response' => $response], JSON_THROW_ON_ERROR), | |
| ]); | |
| $this->audit->record('ISP_MEMBERSHIP_VERIFIED', null, null, 'isp_validations', $validationId, 'SUCCESS'); | |
| } else { | |
| $this->audit->record('ISP_MEMBERSHIP_NOT_FOUND', null, null, 'applications', $id, 'SUCCESS', ['member_no' => $memberNo]); | |
| } | |
| $this->applications->update($id, $updates); | |
| return [ | |
| 'isp_membership' => $response, | |
| 'is_member' => $isFullMember, | |
| 'application' => $this->payload($this->requireApplication($id)), | |
| ]; | |
| }); | |
| } | |
| public function registerApplicantDocument(string $id, array $input): array | |
| { | |
| $application = $this->requireApplicantApplication($id, $input['email_verification_id']); | |
| $this->ensureApplicantEditable($application); | |
| $fileId = $this->ids->generate(); | |
| $classification = strtoupper($input['classification'] ?? 'APPLICATION_DOCUMENT'); | |
| $uploadedFile = $input['file'] ?? null; | |
| $originalFilename = $uploadedFile instanceof UploadedFile ? $uploadedFile->getClientOriginalName() : $input['original_filename']; | |
| $extension = pathinfo($originalFilename, PATHINFO_EXTENSION); | |
| $storageKey = $input['storage_key'] ?? 'application_documents/'.$id.'/'.$fileId.'/'.Str::slug(pathinfo($originalFilename, PATHINFO_FILENAME)).($extension ? '.'.$extension : ''); | |
| $now = Carbon::now(); | |
| $mimeType = $uploadedFile instanceof UploadedFile ? ($uploadedFile->getMimeType() ?? 'application/octet-stream') : $input['mime_type']; | |
| $fileSize = $uploadedFile instanceof UploadedFile ? ($uploadedFile->getSize() ?? 0) : $input['file_size_bytes']; | |
| if ($uploadedFile instanceof UploadedFile) { | |
| Storage::disk('local')->put($storageKey, $uploadedFile->getContent()); | |
| } | |
| DB::table('stored_files')->insert([ | |
| 'id' => $fileId, | |
| 'classification' => $classification, | |
| 'owner_user_id' => null, | |
| 'storage_provider' => 'LOCAL', | |
| 'storage_bucket' => 'local', | |
| 'storage_key' => $storageKey, | |
| 'original_filename' => $originalFilename, | |
| 'mime_type' => strtolower($mimeType), | |
| 'file_size_bytes' => (int) $fileSize, | |
| 'checksum_sha256' => $input['checksum_sha256'] ?? ($uploadedFile instanceof UploadedFile ? hash_file('sha256', $uploadedFile->getRealPath()) : null), | |
| 'virus_scan_status' => $input['virus_scan_status'] ?? 'PENDING', | |
| 'retention_until' => $input['retention_until'] ?? null, | |
| 'created_at' => $now, | |
| 'updated_at' => $now, | |
| ]); | |
| return $this->attachApplicantDocument($id, [ | |
| 'email_verification_id' => $input['email_verification_id'], | |
| 'file_id' => $fileId, | |
| 'link_purpose' => $input['link_purpose'] ?? 'APPLICATION_DOCUMENT', | |
| 'document_type' => $input['document_type'] ?? null, | |
| ]); | |
| } | |
| public function applicantApplicationFeeStatus(string $id, string $emailVerificationId): array | |
| { | |
| $application = $this->requireApplicantApplication($id, $emailVerificationId); | |
| return [ | |
| 'application' => $this->payload($application), | |
| 'application_fee' => $this->applicationFeeSummary($id, $this->decodeMetadata($application->metadata ?? null)), | |
| ]; | |
| } | |
| public function applicantApplicationFeeIntent(string $id, array $input): array | |
| { | |
| return $this->transactions->run(function () use ($id, $input): array { | |
| $application = $this->requireApplicantApplication($id, $input['email_verification_id']); | |
| $this->ensureApplicantEditable($application); | |
| $metadata = $this->decodeMetadata($application->metadata ?? null); | |
| if ($this->applicantIspVerified($metadata)) { | |
| return [ | |
| 'application' => $this->payload($application), | |
| 'application_fee' => $this->applicationFeeSummary($id, $metadata), | |
| 'exempt' => true, | |
| 'status' => 'EXEMPT', | |
| ]; | |
| } | |
| $existing = $this->latestApplicationFeePayment($id); | |
| if ($existing !== null && in_array($existing->payment_status, ['PAID', 'WAIVED'], true)) { | |
| return [ | |
| 'application' => $this->payload($application), | |
| 'application_fee' => $this->applicationFeeSummary($id, $metadata), | |
| 'payment' => $this->paymentPayloadFromJoinedRow($existing), | |
| 'checkout_url' => $existing->checkout_url ?? null, | |
| 'status' => $existing->payment_status, | |
| 'idempotent' => true, | |
| ]; | |
| } | |
| if ($existing !== null && $existing->payment_status === 'PENDING' && ! empty($existing->checkout_url)) { | |
| return [ | |
| 'application' => $this->payload($application), | |
| 'application_fee' => $this->applicationFeeSummary($id, $metadata), | |
| 'payment' => $this->paymentPayloadFromJoinedRow($existing), | |
| 'checkout_url' => $existing->checkout_url, | |
| 'status' => 'PENDING', | |
| 'idempotent' => true, | |
| ]; | |
| } | |
| $paymentMethod = strtolower((string) ($input['payment_method'] ?? 'card')); | |
| if (! in_array($paymentMethod, ['card', 'bank_transfer'], true)) { | |
| throw new ApiException('UNSUPPORTED_PAYMENT_METHOD', 'Application fee payment method is not supported.', [], 422); | |
| } | |
| $invoice = $this->applicationFeeInvoice($id); | |
| $idempotencyKey = $input['idempotency_key'] ?? 'application-fee:'.$id.':'.$paymentMethod; | |
| $intent = $this->payments->createIntent([ | |
| 'invoice_id' => $invoice->id, | |
| 'application_id' => $id, | |
| 'fee_type_code' => 'APPLICATION_FEE', | |
| 'gateway_code' => strtoupper((string) ($input['gateway_code'] ?? 'STRIPE')), | |
| 'payment_method' => $paymentMethod, | |
| 'customer_email' => $application->email, | |
| 'return_url' => $input['return_url'] ?? config('payments.stripe.success_url'), | |
| 'cancel_url' => $input['cancel_url'] ?? config('payments.stripe.cancel_url'), | |
| 'idempotency_key' => $idempotencyKey, | |
| 'test_run_id' => $input['test_run_id'] ?? null, | |
| ], null); | |
| $payment = isset($intent['payment']['id']) | |
| ? DB::table('payments')->where('id', $intent['payment']['id'])->first() | |
| : null; | |
| return [ | |
| 'application' => $this->payload($this->requireApplication($id)), | |
| 'application_fee' => $this->applicationFeeSummary($id, $metadata), | |
| 'payment' => $intent['payment'] ?? null, | |
| 'checkout_url' => $payment->checkout_url ?? ($intent['payment']['checkout_url'] ?? null), | |
| 'status' => $payment->status ?? ($intent['payment']['status'] ?? 'PENDING'), | |
| 'idempotent' => (bool) ($intent['idempotent'] ?? false), | |
| ]; | |
| }); | |
| } | |
| public function create(array $input, string $actorUserId): array | |
| { | |
| $actor = DB::table('users')->where('id', $actorUserId)->first(); | |
| if ($actor === null || $actor->email_verified_at === null) { | |
| throw new ApiException('EMAIL_VERIFICATION_REQUIRED', 'Email verification is required before creating an application.', [], 403); | |
| } | |
| $id = $this->ids->generate(); | |
| $email = strtolower(trim($input['email'] ?? $actor->email)); | |
| $duplicate = DB::table('applications') | |
| ->where('email', $email) | |
| ->whereNull('deleted_at') | |
| ->exists(); | |
| $this->applications->create([ | |
| 'id' => $id, | |
| 'application_reference' => 'APP-'.Carbon::now()->format('YmdHis').'-'.substr(str_replace('-', '', $id), 0, 8), | |
| 'user_id' => $actorUserId, | |
| 'status' => 'APPLICATION_IN_PROGRESS', | |
| 'pathway' => strtoupper($input['pathway'] ?? 'STANDARD'), | |
| 'first_name' => $input['first_name'], | |
| 'middle_name' => $input['middle_name'] ?? null, | |
| 'last_name' => $input['last_name'], | |
| 'preferred_name' => $input['preferred_name'] ?? null, | |
| 'email' => $email, | |
| 'phone_number' => $input['phone_number'], | |
| 'alternate_phone_number' => $input['alternate_phone_number'] ?? null, | |
| 'country_of_residence' => $input['country_of_residence'], | |
| 'city_state_region' => $input['city_state_region'] ?? null, | |
| 'professional_profile' => $input['professional_profile'], | |
| 'kenyan_professional_indicator' => (int) (bool) ($input['kenyan_professional_indicator'] ?? false), | |
| 'duplicate_flag' => $duplicate ? 1 : 0, | |
| 'duplicate_reason' => $duplicate ? 'Existing application uses the same email.' : null, | |
| 'metadata' => json_encode(['test_run_id' => $input['test_run_id'] ?? null], JSON_THROW_ON_ERROR), | |
| ]); | |
| $this->audit->record('APPLICATION_CREATED', $actorUserId, null, 'applications', $id, 'SUCCESS', ['duplicate_flag' => $duplicate]); | |
| return ['application' => $this->payload($this->requireApplication($id))]; | |
| } | |
| public function get(string $id, string $actorUserId): array | |
| { | |
| $application = $this->requireApplication($id); | |
| $this->authorizeAccess($application, $actorUserId); | |
| return ['application' => $this->payload($application)]; | |
| } | |
| public function update(string $id, array $input, string $actorUserId): array | |
| { | |
| $application = $this->requireApplication($id); | |
| $this->authorizeAccess($application, $actorUserId); | |
| if ($application->status !== 'APPLICATION_IN_PROGRESS' && ! $this->policy->canManage($actorUserId)) { | |
| throw new ApiException('APPLICATION_NOT_EDITABLE', 'Application cannot be edited in its current state.', [], 409); | |
| } | |
| $updates = []; | |
| foreach (['first_name', 'middle_name', 'last_name', 'preferred_name', 'phone_number', 'alternate_phone_number', 'country_of_residence', 'city_state_region', 'professional_profile'] as $field) { | |
| if (array_key_exists($field, $input)) { | |
| $updates[$field] = $input[$field]; | |
| } | |
| } | |
| if (array_key_exists('kenyan_professional_indicator', $input)) { | |
| $updates['kenyan_professional_indicator'] = (int) (bool) $input['kenyan_professional_indicator']; | |
| } | |
| if (array_key_exists('pathway', $input)) { | |
| $updates['pathway'] = strtoupper($input['pathway']); | |
| } | |
| if ($updates !== []) { | |
| $this->applications->update($id, $updates); | |
| $this->audit->record('APPLICATION_UPDATED', $actorUserId, null, 'applications', $id, 'SUCCESS'); | |
| } | |
| return ['application' => $this->payload($this->requireApplication($id))]; | |
| } | |
| public function acceptConsents(string $id, array $input, Request $request): array | |
| { | |
| $actorUserId = $request->attributes->get('actor_user_id'); | |
| $application = $this->requireApplication($id); | |
| $this->authorizeAccess($application, $actorUserId); | |
| $types = array_map(fn ($type): string => strtoupper((string) $type), $input['consent_types'] ?? self::REQUIRED_CONSENTS); | |
| $created = []; | |
| foreach (array_unique($types) as $type) { | |
| $created[] = $this->applications->createConsent([ | |
| 'id' => $this->ids->generate(), | |
| 'application_id' => $id, | |
| 'user_id' => $actorUserId, | |
| 'consent_type' => $type, | |
| 'consent_version' => $input['consent_version'] ?? 'v1', | |
| 'accepted_by_name' => $input['accepted_by_name'] ?? trim($application->first_name.' '.$application->last_name), | |
| 'ip_address' => $request->ip(), | |
| 'user_agent' => $request->userAgent(), | |
| 'evidence_metadata' => json_encode(['request_id' => $request->attributes->get('request_id')], JSON_THROW_ON_ERROR), | |
| ]); | |
| } | |
| $this->audit->record('APPLICATION_CONSENT_ACCEPTED', $actorUserId, null, 'applications', $id, 'SUCCESS', ['consent_types' => $types]); | |
| return ['application' => $this->payload($this->requireApplication($id)), 'consent_ids' => $created]; | |
| } | |
| public function submit(string $id, string $actorUserId): array | |
| { | |
| return $this->transactions->run(function () use ($id, $actorUserId): array { | |
| $application = $this->requireApplication($id); | |
| $this->authorizeAccess($application, $actorUserId); | |
| $this->ensureComplete($application); | |
| $this->ensureRequiredConsents($id); | |
| $status = 'APPLICATION_COMPLETED'; | |
| $updates = ['status' => $status, 'submitted_at' => Carbon::now()]; | |
| $this->applications->update($id, $updates); | |
| $this->audit->record('APPLICATION_SUBMITTED', $actorUserId, null, 'applications', $id, 'SUCCESS', ['status' => $status]); | |
| $this->sendApplicationMessage($this->requireApplication($id), 'APPLICATION_SUBMITTED', 'Application submitted'); | |
| return ['application' => $this->payload($this->requireApplication($id))]; | |
| }); | |
| } | |
| public function status(string $id, string $actorUserId): array | |
| { | |
| $application = $this->refreshReadiness($this->requireApplication($id), $actorUserId); | |
| $this->authorizeAccess($application, $actorUserId); | |
| return [ | |
| 'application' => $this->payload($application), | |
| 'readiness' => [ | |
| 'required_consents_accepted' => count(array_intersect(self::REQUIRED_CONSENTS, $this->applications->consentsFor($id))) === count(self::REQUIRED_CONSENTS), | |
| 'application_fee_paid' => $this->applicationFeePaid($id), | |
| 'isp_exemption_confirmed' => $this->applications->confirmedIspValidation($id) !== null, | |
| 'review_completed' => $this->reviewCompleted($id), | |
| 'vetting_passed' => $this->vettingPassed($id), | |
| ], | |
| ]; | |
| } | |
| public function attachDocument(string $id, array $input, string $actorUserId): array | |
| { | |
| $application = $this->requireApplication($id); | |
| $this->authorizeAccess($application, $actorUserId); | |
| $file = DB::table('stored_files')->where('id', $input['file_id'])->whereNull('deleted_at')->first(); | |
| if ($file === null) { | |
| throw new ApiException('FILE_NOT_FOUND', 'File was not found.', [], 404); | |
| } | |
| DB::table('file_links')->insert([ | |
| 'id' => $this->ids->generate(), | |
| 'file_id' => $file->id, | |
| 'application_id' => $id, | |
| 'link_purpose' => $input['link_purpose'] ?? 'APPLICATION_DOCUMENT', | |
| 'linked_by_user_id' => $actorUserId, | |
| 'created_at' => now(), | |
| 'updated_at' => now(), | |
| ]); | |
| $this->audit->record('APPLICATION_DOCUMENT_LINKED', $actorUserId, null, 'applications', $id, 'SUCCESS', ['file_id' => $file->id]); | |
| return [ | |
| 'application' => $this->payload($application), | |
| 'file_id' => $file->id, | |
| 'file' => [ | |
| 'id' => $file->id, | |
| 'original_filename' => $file->original_filename, | |
| 'mime_type' => $file->mime_type, | |
| 'file_size_bytes' => (int) $file->file_size_bytes, | |
| 'download_url' => $this->applicantFileSignedUrl($file->id), | |
| 'stream_url' => $this->applicantFileStreamUrl($file->id), | |
| 'secure_link' => $this->applicantFileSignedUrl($file->id), | |
| ], | |
| ]; | |
| } | |
| public function verifyIspMembership(string $id, array $input, string $actorUserId): array | |
| { | |
| return $this->transactions->run(function () use ($id, $input, $actorUserId): array { | |
| $application = $this->requireApplication($id); | |
| $this->authorizeAccess($application, $actorUserId); | |
| if ($application->status !== 'APPLICATION_IN_PROGRESS') { | |
| throw new ApiException('APPLICATION_NOT_EDITABLE', 'Application cannot be edited in its current state.', [], 409); | |
| } | |
| $memberNo = trim($input['member_no']); | |
| $programEmail = strtolower(trim($input['program_email'])); | |
| $response = $this->ispMembership->verify($memberNo, $programEmail); | |
| $isFullMember = $this->ispMembership->isFullMember($response); | |
| if ($isFullMember) { | |
| $validationId = $this->applications->createIspValidation([ | |
| 'id' => $this->ids->generate(), | |
| 'application_id' => $application->id, | |
| 'validation_method' => 'API', | |
| 'isp_member_identifier' => $memberNo, | |
| 'isp_email' => $programEmail, | |
| 'status' => 'CONFIRMED', | |
| 'validated_at' => Carbon::now(), | |
| 'metadata' => json_encode(['response' => $response], JSON_THROW_ON_ERROR), | |
| ]); | |
| $this->applications->update($id, [ | |
| 'pathway' => 'ISP', | |
| 'ISP_email' => $programEmail, | |
| 'ISP_no' => $memberNo, | |
| ]); | |
| $this->audit->record('ISP_MEMBERSHIP_VERIFIED', $actorUserId, null, 'isp_validations', $validationId, 'SUCCESS'); | |
| } else { | |
| $this->audit->record('ISP_MEMBERSHIP_NOT_FOUND', $actorUserId, null, 'applications', $id, 'SUCCESS', ['member_no' => $memberNo]); | |
| } | |
| return [ | |
| 'isp_membership' => $response, | |
| 'is_member' => $isFullMember, | |
| 'application' => $this->payload($this->requireApplication($id)), | |
| ]; | |
| }); | |
| } | |
| public function requestIspValidation(array $input, string $actorUserId): array | |
| { | |
| $application = $this->requireApplication($input['application_id']); | |
| $this->authorizeAccess($application, $actorUserId); | |
| $id = $this->applications->createIspValidation([ | |
| 'id' => $this->ids->generate(), | |
| 'application_id' => $application->id, | |
| 'validation_method' => 'MANUAL', | |
| 'isp_member_identifier' => $input['isp_member_identifier'] ?? null, | |
| 'isp_email' => $input['isp_email'] ?? null, | |
| 'referral_token' => $input['referral_token'] ?? null, | |
| 'status' => 'PENDING', | |
| 'evidence_file_id' => $input['evidence_file_id'] ?? null, | |
| 'metadata' => json_encode(['test_run_id' => $input['test_run_id'] ?? null], JSON_THROW_ON_ERROR), | |
| ]); | |
| $this->applications->update($application->id, ['pathway' => 'ISP']); | |
| $this->audit->record('ISP_VALIDATION_REQUESTED', $actorUserId, null, 'isp_validations', $id, 'SUCCESS'); | |
| return ['isp_validation' => (array) $this->applications->findIspValidation($id), 'application' => $this->payload($this->requireApplication($application->id))]; | |
| } | |
| public function manualConfirmIsp(string $validationId, array $input, string $actorUserId): array | |
| { | |
| return $this->manualCompleteIsp($validationId, 'CONFIRMED', $input['reason'], $actorUserId); | |
| } | |
| public function manualRejectIsp(string $validationId, array $input, string $actorUserId): array | |
| { | |
| return $this->manualCompleteIsp($validationId, 'REJECTED', $input['reason'], $actorUserId); | |
| } | |
| public function reviewQueue(string $actorUserId): array | |
| { | |
| return ['applications' => array_map(fn (object $app): array => $this->payload($app), $this->applications->readyQueue())]; | |
| } | |
| public function reviewApplications(array $filters, string $actorUserId): array | |
| { | |
| $result = $this->applications->searchForReview($filters); | |
| return [ | |
| 'applications' => array_map(function (object $application): array { | |
| $payload = $this->payload($application); | |
| $payload['blockers'] = $this->reviewBlockers($application->id); | |
| $payload['open_tasks_count'] = count($this->openBlockingTasks($application->id)); | |
| return $payload; | |
| }, $result['applications']), | |
| 'pagination' => $result['pagination'], | |
| ]; | |
| } | |
| public function reviewApplicationDetail(string $id, string $actorUserId): array | |
| { | |
| $application = $this->requireApplication($id); | |
| if (! $this->policy->canManage($actorUserId)) { | |
| throw new ApiException('FORBIDDEN', 'You are not authorized to review applications.', [], 403); | |
| } | |
| return [ | |
| 'application' => $this->payload($application), | |
| 'review' => $this->rowPayload($this->applications->latestReview($id)), | |
| 'consents' => DB::table('application_consents')->where('application_id', $id)->whereNull('deleted_at')->get()->all(), | |
| 'documents' => $this->applicationFilesPayload($id), | |
| 'tasks' => $this->reviewTasks($id), | |
| 'vetting_records' => DB::table('vetting_records')->where('application_id', $id)->whereNull('deleted_at')->orderByDesc('created_at')->get()->all(), | |
| 'document_requests' => $this->documentRequestsPayload($id), | |
| 'decisions' => DB::table('application_decisions')->where('application_id', $id)->whereNull('deleted_at')->orderByDesc('decided_at')->get()->all(), | |
| 'blockers' => $this->reviewBlockers($id), | |
| ]; | |
| } | |
| public function assignReview(string $id, array $input, string $actorUserId): array | |
| { | |
| $application = $this->requireApplication($id); | |
| $review = $this->applications->latestReview($id); | |
| $reviewerId = $input['assigned_to_user_id'] ?? $actorUserId; | |
| if ($review === null) { | |
| $reviewId = $this->applications->createReview([ | |
| 'id' => $this->ids->generate(), | |
| 'application_id' => $id, | |
| 'assigned_to_user_id' => $reviewerId, | |
| 'review_status' => 'ASSIGNED', | |
| ]); | |
| } else { | |
| $reviewId = $review->id; | |
| $this->applications->updateReview($review->id, ['assigned_to_user_id' => $reviewerId, 'review_status' => 'ASSIGNED']); | |
| } | |
| $this->audit->record('APPLICATION_REVIEW_ASSIGNED', $actorUserId, null, 'applications', $id, 'SUCCESS'); | |
| return ['application' => $this->payload($application), 'review' => (array) DB::table('application_reviews')->where('id', $reviewId)->first()]; | |
| } | |
| public function startReview(string $id, string $actorUserId): array | |
| { | |
| $application = $this->refreshReadiness($this->requireApplication($id), $actorUserId); | |
| if (! in_array($application->status, ['APPLICATION_COMPLETED', 'CLARIFICATION_SUBMITTED', 'NEW'], true) || ($application->status === 'NEW' && empty($application->submitted_at))) { | |
| throw new ApiException('APPLICATION_NOT_READY_FOR_REVIEW', 'Application cannot enter review before fee or ISP readiness is complete.', [], 409); | |
| } | |
| $review = $this->applications->latestReview($id); | |
| if ($review === null) { | |
| $reviewId = $this->applications->createReview([ | |
| 'id' => $this->ids->generate(), | |
| 'application_id' => $id, | |
| 'assigned_to_user_id' => $actorUserId, | |
| 'review_status' => 'IN_PROGRESS', | |
| 'started_at' => Carbon::now(), | |
| ]); | |
| } else { | |
| $reviewId = $review->id; | |
| $this->applications->updateReview($review->id, ['review_status' => 'IN_PROGRESS', 'started_at' => $review->started_at ?? Carbon::now()]); | |
| } | |
| $this->transitionApplication($application, 'IN_REVIEW', $actorUserId, 'APPLICATION_REVIEW_STARTED'); | |
| $this->audit->record('APPLICATION_REVIEW_STARTED', $actorUserId, null, 'applications', $id, 'SUCCESS'); | |
| return ['application' => $this->payload($this->requireApplication($id)), 'review' => (array) DB::table('application_reviews')->where('id', $reviewId)->first()]; | |
| } | |
| public function addReviewNote(string $id, array $input, string $actorUserId): array | |
| { | |
| $review = $this->applications->latestReview($id); | |
| if ($review === null) { | |
| throw new ApiException('REVIEW_NOT_STARTED', 'Review must be started before notes are added.', [], 409); | |
| } | |
| $this->applications->updateReview($review->id, [ | |
| 'review_notes' => trim(($review->review_notes ? $review->review_notes.PHP_EOL : '').$input['note']), | |
| 'review_status' => $input['complete'] ?? false ? 'COMPLETED' : $review->review_status, | |
| 'completed_at' => $input['complete'] ?? false ? Carbon::now() : $review->completed_at, | |
| 'completeness_passed' => $input['complete'] ?? false ? 1 : $review->completeness_passed, | |
| 'fee_or_exemption_confirmed' => $input['complete'] ?? false ? 1 : $review->fee_or_exemption_confirmed, | |
| 'document_review_status' => $input['complete'] ?? false ? 'PASSED' : $review->document_review_status, | |
| ]); | |
| $this->audit->record('APPLICATION_REVIEW_NOTE_ADDED', $actorUserId, null, 'applications', $id, 'SUCCESS', ['complete' => (bool) ($input['complete'] ?? false)]); | |
| return ['review' => (array) DB::table('application_reviews')->where('id', $review->id)->first()]; | |
| } | |
| public function startVetting(string $id, string $actorUserId): array | |
| { | |
| if (! $this->reviewCompleted($id)) { | |
| throw new ApiException('REVIEW_NOT_COMPLETED', 'Review must be completed before vetting starts.', [], 409); | |
| } | |
| $taskId = $this->createReviewTask($id, 'VETTING', true, $actorUserId, null, ['source' => 'legacy_start_vetting']); | |
| $vettingId = $this->applications->createVetting([ | |
| 'id' => $this->ids->generate(), | |
| 'application_id' => $id, | |
| 'review_task_id' => $taskId, | |
| 'vetting_officer_user_id' => $actorUserId, | |
| 'vetting_status' => 'IN_PROGRESS', | |
| 'meeting_status' => 'REQUESTED', | |
| 'started_at' => Carbon::now(), | |
| ]); | |
| $this->transitionApplication($this->requireApplication($id), 'AWAITING_APPLICANT_ACTION', $actorUserId, 'APPLICATION_VETTING_STARTED'); | |
| $this->audit->record('APPLICATION_VETTING_STARTED', $actorUserId, null, 'applications', $id, 'SUCCESS'); | |
| return ['application' => $this->payload($this->requireApplication($id)), 'vetting' => (array) DB::table('vetting_records')->where('id', $vettingId)->first()]; | |
| } | |
| public function vettingOutcome(string $id, array $input, string $actorUserId): array | |
| { | |
| $vetting = $this->applications->latestVetting($id); | |
| if ($vetting === null) { | |
| throw new ApiException('VETTING_NOT_STARTED', 'Vetting must be started before outcome is recorded.', [], 409); | |
| } | |
| $outcome = strtoupper($input['outcome']); | |
| $this->applications->updateVetting($vetting->id, [ | |
| 'vetting_status' => $outcome, | |
| 'completed_at' => Carbon::now(), | |
| 'outcome_reason' => $input['reason'] ?? null, | |
| 'internal_notes' => $input['internal_notes'] ?? null, | |
| ]); | |
| if (! empty($vetting->review_task_id)) { | |
| $this->closeReviewTask($vetting->review_task_id, in_array($outcome, ['PASS', 'PASSED', 'APPROVED'], true) ? 'COMPLETED' : 'FAILED', $actorUserId, [ | |
| 'outcome' => $outcome, | |
| ]); | |
| } | |
| $this->audit->record('APPLICATION_VETTING_COMPLETED', $actorUserId, null, 'applications', $id, 'SUCCESS', ['outcome' => $outcome]); | |
| return ['vetting' => (array) DB::table('vetting_records')->where('id', $vetting->id)->first()]; | |
| } | |
| public function requestClarification(string $id, array $input, string $actorUserId): array | |
| { | |
| $application = $this->requireApplication($id); | |
| $taskId = $this->createReviewTask($id, 'CLARIFICATION', true, $actorUserId, $input['due_at'] ?? Carbon::now()->addDays(7), [ | |
| 'request_text' => $input['request_text'], | |
| ]); | |
| $clarificationId = $this->applications->createClarification([ | |
| 'id' => $this->ids->generate(), | |
| 'application_id' => $id, | |
| 'requested_by_user_id' => $actorUserId, | |
| 'request_text' => $input['request_text'], | |
| 'due_at' => $input['due_at'] ?? Carbon::now()->addDays(7), | |
| 'status' => 'OPEN', | |
| ]); | |
| $this->transitionApplication($application, 'AWAITING_APPLICANT_ACTION', $actorUserId, 'APPLICATION_CLARIFICATION_REQUESTED'); | |
| $this->sendApplicationMessage($application, 'APPLICATION_CLARIFICATION_REQUESTED', $input['request_text']); | |
| $this->audit->record('APPLICATION_CLARIFICATION_REQUESTED', $actorUserId, null, 'applications', $id, 'SUCCESS'); | |
| return ['application' => $this->payload($this->requireApplication($id)), 'clarification' => (array) DB::table('application_clarifications')->where('id', $clarificationId)->first()]; | |
| } | |
| public function respondClarification(string $id, array $input, string $actorUserId): array | |
| { | |
| $application = $this->requireApplication($id); | |
| $this->authorizeAccess($application, $actorUserId); | |
| $clarification = $this->applications->latestOpenClarification($id); | |
| if ($clarification === null) { | |
| throw new ApiException('CLARIFICATION_NOT_OPEN', 'There is no open clarification request for this application.', [], 409); | |
| } | |
| $this->applications->updateClarification($clarification->id, [ | |
| 'response_text' => $input['response_text'], | |
| 'responded_at' => Carbon::now(), | |
| 'status' => 'RESPONDED', | |
| ]); | |
| $this->closeLatestOpenTask($id, 'CLARIFICATION', 'COMPLETED', $actorUserId); | |
| $this->transitionApplication($application, 'CLARIFICATION_SUBMITTED', $actorUserId, 'APPLICATION_CLARIFICATION_RESPONDED'); | |
| $this->audit->record('APPLICATION_CLARIFICATION_RESPONDED', $actorUserId, null, 'applications', $id, 'SUCCESS'); | |
| return ['application' => $this->payload($this->requireApplication($id)), 'clarification' => (array) DB::table('application_clarifications')->where('id', $clarification->id)->first()]; | |
| } | |
| public function decision(string $id, array $input, string $actorUserId): array | |
| { | |
| $application = $this->requireApplication($id); | |
| $decision = strtoupper($input['decision']); | |
| if (in_array($decision, ['REJECTED', 'DEFERRED'], true) && trim((string) ($input['reason'] ?? '')) === '') { | |
| throw new ApiException('DECISION_REASON_REQUIRED', 'Rejection and deferral decisions require a reason.', [], 422); | |
| } | |
| if ($decision === 'APPROVED') { | |
| $blockers = $this->reviewBlockers($id); | |
| if ($blockers !== []) { | |
| throw new ApiException('APPLICATION_APPROVAL_BLOCKED', 'Application cannot be approved while review blockers remain open.', ['blockers' => $blockers], 409); | |
| } | |
| } | |
| $decisionId = $this->applications->createDecision([ | |
| 'id' => $this->ids->generate(), | |
| 'application_id' => $id, | |
| 'decision' => $decision, | |
| 'decision_reason' => $input['reason'] ?? null, | |
| 'decided_by_user_id' => $actorUserId, | |
| ]); | |
| if ($decision === 'REJECTED') { | |
| $this->cancelOpenReviewTasks($id, $actorUserId, 'application_rejected'); | |
| $this->transitionApplication($application, 'REJECTED', $actorUserId, 'APPLICATION_REJECTED', $input['reason'] ?? null); | |
| $this->sendApplicationMessage($application, 'APPLICATION_REJECTED', $input['reason'] ?? 'Your application was not approved.'); | |
| } elseif ($decision === 'APPROVED') { | |
| $token = $this->createApprovalToken($application); | |
| $paymentUrl = $this->approvalPaymentUrl($token); | |
| $this->transitionApplication($application, 'APPROVED', $actorUserId, 'APPLICATION_APPROVED'); | |
| $this->sendApplicationMessage($application, 'APPLICATION_APPROVED', $paymentUrl); | |
| } else { | |
| $this->transitionApplication($application, $decision, $actorUserId, 'APPLICATION_DECISION_'.$decision, $input['reason'] ?? null); | |
| } | |
| $this->audit->record('APPLICATION_DECISION_RECORDED', $actorUserId, null, 'applications', $id, 'SUCCESS', ['decision' => $decision]); | |
| return ['application' => $this->payload($this->requireApplication($id)), 'decision' => (array) DB::table('application_decisions')->where('id', $decisionId)->first()]; | |
| } | |
| public function requestVettingMeeting(string $id, array $input, string $actorUserId): array | |
| { | |
| return $this->transactions->run(function () use ($id, $input, $actorUserId): array { | |
| $application = $this->requireApplication($id); | |
| if (! $this->policy->canManage($actorUserId)) { | |
| throw new ApiException('FORBIDDEN', 'You are not authorized to request vetting.', [], 403); | |
| } | |
| $vettingId = $this->ids->generate(); | |
| $bookingUrl = $this->calendlyBookingUrl('vetting', $input['calendly_url'] ?? null); | |
| if (empty($bookingUrl)) { | |
| throw new ApiException('CALENDLY_URL_REQUIRED', 'Calendly booking URL is not configured.', [], 422); | |
| } | |
| $bookingUrl = $this->calendlyTrackedBookingUrl($bookingUrl, $id, $vettingId); | |
| $taskId = $this->createReviewTask($id, 'VETTING', true, $actorUserId, $input['due_at'] ?? null, [ | |
| 'calendly_event_type' => 'VETTING', | |
| 'calendly_url' => $bookingUrl, | |
| 'application_id' => $id, | |
| 'vetting_id' => $vettingId, | |
| 'message' => $input['message'] ?? null, | |
| ]); | |
| $this->applications->createVetting([ | |
| 'id' => $vettingId, | |
| 'application_id' => $id, | |
| 'review_task_id' => $taskId, | |
| 'vetting_officer_user_id' => $actorUserId, | |
| 'vetting_status' => 'REQUESTED', | |
| 'meeting_status' => 'REQUESTED', | |
| 'meeting_url' => $bookingUrl, | |
| 'started_at' => Carbon::now(), | |
| ]); | |
| $this->transitionApplication($application, 'AWAITING_APPLICANT_ACTION', $actorUserId, 'APPLICATION_VETTING_REQUESTED'); | |
| $this->sendApplicationMessage($application, 'APPLICATION_VETTING_REQUESTED', $bookingUrl); | |
| $this->audit->record('APPLICATION_VETTING_REQUESTED', $actorUserId, null, 'applications', $id, 'SUCCESS', ['vetting_id' => $vettingId]); | |
| return $this->reviewApplicationDetail($id, $actorUserId); | |
| }); | |
| } | |
| public function markVettingAttended(string $id, string $vettingId, array $input, string $actorUserId): array | |
| { | |
| return $this->completeVettingMeeting($id, $vettingId, 'ATTENDED', 'PASSED', $input, $actorUserId); | |
| } | |
| public function markVettingMissed(string $id, string $vettingId, array $input, string $actorUserId): array | |
| { | |
| return $this->completeVettingMeeting($id, $vettingId, 'MISSED', 'FAILED', $input, $actorUserId); | |
| } | |
| public function handleCalendlyWebhook(string $payload, array $headers): array | |
| { | |
| $secret = (string) config('app.calendly_webhook_secret'); | |
| if ($secret === '') { | |
| throw new ApiException('CALENDLY_WEBHOOK_SECRET_REQUIRED', 'Calendly webhook secret is not configured.', [], 503); | |
| } | |
| $signature = $headers['calendly-webhook-signature'][0] | |
| ?? $headers['Calendly-Webhook-Signature'][0] | |
| ?? $headers['x-calendly-webhook-signature'][0] | |
| ?? $headers['X-Calendly-Webhook-Signature'][0] | |
| ?? null; | |
| if ($signature === null || ! $this->validCalendlySignature($payload, $signature, $secret)) { | |
| throw new ApiException('INVALID_CALENDLY_SIGNATURE', 'Calendly webhook signature is invalid.', [], 401); | |
| } | |
| $event = json_decode($payload, true); | |
| if (! is_array($event)) { | |
| throw new ApiException('INVALID_CALENDLY_PAYLOAD', 'Calendly webhook payload is invalid.', [], 422); | |
| } | |
| $payloadNode = $event['payload'] ?? $event; | |
| $eventId = (string) ( | |
| $payloadNode['uri'] | |
| ?? $payloadNode['uuid'] | |
| ?? $payloadNode['event'] | |
| ?? $payloadNode['scheduled_event']['uri'] | |
| ?? hash('sha256', $payload) | |
| ); | |
| $eventUri = $payloadNode['event'] | |
| ?? $payloadNode['scheduled_event']['uri'] | |
| ?? $payloadNode['event_uri'] | |
| ?? null; | |
| $inviteeUri = $payloadNode['uri'] | |
| ?? $payloadNode['invitee'] | |
| ?? $payloadNode['invitee_uri'] | |
| ?? null; | |
| $vettingId = $this->calendlyTrackingValue($payloadNode, 'utm_term'); | |
| $applicationId = $this->calendlyTrackingValue($payloadNode, 'utm_content') | |
| ?? $payloadNode['questions_and_answers'][0]['answer'] | |
| ?? null; | |
| Log::info('Calendly webhook matching values', [ | |
| 'event_type' => $event['event'] ?? $event['type'] ?? null, | |
| 'event_id' => $eventId, | |
| 'event_uri' => $eventUri, | |
| 'invitee_uri' => $inviteeUri, | |
| 'application_id' => $applicationId, | |
| 'vetting_id' => $vettingId, | |
| 'tracking_keys' => array_keys($payloadNode['tracking'] ?? []), | |
| ]); | |
| return $this->transactions->run(function () use ($event, $payloadNode, $eventId, $eventUri, $inviteeUri, $vettingId, $applicationId): array { | |
| if (DB::table('vetting_records') | |
| ->where('webhook_event_id', $eventId) | |
| ->whereNull('deleted_at') | |
| ->exists() | |
| ) { | |
| return [ | |
| 'duplicate' => true, | |
| 'event_id' => $eventId, | |
| ]; | |
| } | |
| $vetting = null; | |
| if ($eventUri !== null) { | |
| $vetting = DB::table('vetting_records') | |
| ->where('calendly_event_uri', $eventUri) | |
| ->whereNull('deleted_at') | |
| ->orderByDesc('created_at') | |
| ->first(); | |
| } | |
| if ($vetting === null && $vettingId !== null) { | |
| $vetting = DB::table('vetting_records') | |
| ->where('id', $vettingId) | |
| ->whereIn('vetting_status', ['REQUESTED', 'IN_PROGRESS']) | |
| ->whereNull('deleted_at') | |
| ->first(); | |
| } | |
| if ($vetting === null && $applicationId !== null) { | |
| $vetting = DB::table('vetting_records') | |
| ->where('application_id', $applicationId) | |
| ->whereIn('vetting_status', ['REQUESTED', 'IN_PROGRESS']) | |
| ->whereNull('deleted_at') | |
| ->orderByDesc('created_at') | |
| ->first(); | |
| } | |
| if ($vetting === null) { | |
| throw new ApiException( | |
| 'VETTING_RECORD_NOT_FOUND', | |
| 'No vetting record matched the Calendly webhook.', | |
| [ | |
| 'event_uri' => $eventUri, | |
| 'invitee_uri' => $inviteeUri, | |
| 'application_id' => $applicationId, | |
| 'vetting_id' => $vettingId, | |
| ], | |
| 404 | |
| ); | |
| } | |
| $scheduledAt = $this->calendlyDateTimeForDatabase( | |
| $payloadNode['scheduled_event']['start_time'] | |
| ?? $payloadNode['start_time'] | |
| ?? null | |
| ); | |
| $meetingUrl = $payloadNode['scheduled_event']['location']['join_url'] | |
| ?? $payloadNode['location']['join_url'] | |
| ?? $payloadNode['meeting_url'] | |
| ?? null; | |
| $eventName = strtolower((string) ($event['event'] ?? $event['type'] ?? '')); | |
| $meetingStatus = str_contains($eventName, 'canceled') ? 'CANCELED' : 'SCHEDULED'; | |
| $this->applications->updateVetting($vetting->id, [ | |
| 'calendly_event_uri' => $eventUri ?? $vetting->calendly_event_uri ?? null, | |
| 'calendly_invitee_uri' => $inviteeUri, | |
| 'calendly_scheduled_at' => $scheduledAt, | |
| 'meeting_status' => $meetingStatus, | |
| 'meeting_url' => $meetingUrl ?? $vetting->meeting_url ?? null, | |
| 'webhook_event_id' => $eventId, | |
| ]); | |
| if (! empty($vetting->review_task_id) && $meetingStatus === 'SCHEDULED') { | |
| $taskMetadata = $this->decodeMetadata( | |
| DB::table('application_review_tasks') | |
| ->where('id', $vetting->review_task_id) | |
| ->value('metadata') | |
| ); | |
| DB::table('application_review_tasks') | |
| ->where('id', $vetting->review_task_id) | |
| ->whereNull('deleted_at') | |
| ->update([ | |
| 'status' => 'PENDING', | |
| 'metadata' => json_encode(array_merge($taskMetadata, [ | |
| 'calendly_event_id' => $eventId, | |
| 'calendly_event_uri' => $eventUri, | |
| 'calendly_invitee_uri' => $inviteeUri, | |
| 'calendly_scheduled_at' => $scheduledAt, | |
| ]), JSON_THROW_ON_ERROR), | |
| 'updated_at' => now(), | |
| ]); | |
| } | |
| if ($meetingStatus === 'SCHEDULED') { | |
| $this->transitionApplication( | |
| $this->requireApplication($vetting->application_id), | |
| 'VETTING_SCHEDULED', | |
| null, | |
| 'CALENDLY_MEETING_SCHEDULED' | |
| ); | |
| } | |
| return [ | |
| 'event_id' => $eventId, | |
| 'meeting_status' => $meetingStatus, | |
| 'vetting' => (array) DB::table('vetting_records') | |
| ->where('id', $vetting->id) | |
| ->first(), | |
| ]; | |
| }); | |
| } | |
| public function requestDocuments(string $id, array $input, string $actorUserId): array | |
| { | |
| return $this->transactions->run(function () use ($id, $input, $actorUserId): array { | |
| $application = $this->requireApplication($id); | |
| if (! $this->policy->canManage($actorUserId)) { | |
| throw new ApiException('FORBIDDEN', 'You are not authorized to request documents.', [], 403); | |
| } | |
| $items = $input['documents'] ?? []; | |
| if ($items === []) { | |
| throw new ApiException('DOCUMENT_ITEMS_REQUIRED', 'At least one document item is required.', [], 422); | |
| } | |
| $plainToken = Str::random(48); | |
| $tokenHash = hash('sha256', $plainToken); | |
| $taskId = $this->createReviewTask($id, 'DOCUMENT_REQUEST', true, $actorUserId, $input['due_at'] ?? null, [ | |
| 'title' => $input['title'] ?? null, | |
| ]); | |
| $requestId = $this->ids->generate(); | |
| DB::table('application_document_requests')->insert([ | |
| 'id' => $requestId, | |
| 'application_id' => $id, | |
| 'review_task_id' => $taskId, | |
| 'requested_by_user_id' => $actorUserId, | |
| 'applicant_email' => $application->email, | |
| 'token_hash' => $tokenHash, | |
| 'title' => $input['title'] ?? 'Additional documents required', | |
| 'message' => $input['message'] ?? null, | |
| 'status' => 'OPEN', | |
| 'requested_at' => now(), | |
| 'expires_at' => $input['expires_at'] ?? Carbon::now()->addDays(14), | |
| 'metadata' => json_encode(['document_count' => count($items)], JSON_THROW_ON_ERROR), | |
| 'created_at' => now(), | |
| 'updated_at' => now(), | |
| ]); | |
| foreach ($items as $item) { | |
| DB::table('application_document_request_items')->insert([ | |
| 'id' => $this->ids->generate(), | |
| 'document_request_id' => $requestId, | |
| 'document_type' => strtoupper((string) $item['document_type']), | |
| 'label' => $item['label'] ?? $item['document_type'], | |
| 'instructions' => $item['instructions'] ?? null, | |
| 'status' => 'PENDING', | |
| 'created_at' => now(), | |
| 'updated_at' => now(), | |
| ]); | |
| } | |
| $uploadUrl = $this->documentUploadUrl($plainToken); | |
| $this->transitionApplication($application, 'AWAITING_APPLICANT_ACTION', $actorUserId, 'APPLICATION_DOCUMENTS_REQUESTED'); | |
| $this->sendApplicationMessage($application, 'APPLICATION_DOCUMENTS_REQUESTED', $uploadUrl); | |
| $this->audit->record('APPLICATION_DOCUMENTS_REQUESTED', $actorUserId, null, 'applications', $id, 'SUCCESS', ['document_request_id' => $requestId]); | |
| return array_merge($this->reviewApplicationDetail($id, $actorUserId), [ | |
| 'document_request_token' => $plainToken, | |
| 'document_upload_url' => $uploadUrl, | |
| ]); | |
| }); | |
| } | |
| public function getDocumentRequestByToken(string $token): array | |
| { | |
| $request = $this->requireDocumentRequestToken($token); | |
| return [ | |
| 'document_request' => $this->publicDocumentRequestPayload($request), | |
| 'application' => $this->publicApplicationSummary($this->requireApplication($request->application_id)), | |
| ]; | |
| } | |
| public function submitRequestedDocuments(string $token, array $input): array | |
| { | |
| return $this->transactions->run(function () use ($token, $input): array { | |
| $request = $this->requireDocumentRequestToken($token); | |
| $items = DB::table('application_document_request_items') | |
| ->where('document_request_id', $request->id) | |
| ->whereNull('deleted_at') | |
| ->get(); | |
| $uploads = $input['documents'] ?? []; | |
| if ($uploads === []) { | |
| $uploads[] = $input; | |
| } | |
| foreach ($uploads as $upload) { | |
| $item = null; | |
| if (! empty($upload['item_id'])) { | |
| $item = $items->first(fn (object $candidate): bool => $candidate->id === $upload['item_id']); | |
| } | |
| if ($item === null && ! empty($upload['document_type'])) { | |
| $type = strtoupper((string) $upload['document_type']); | |
| $item = $items->first(fn (object $candidate): bool => $candidate->document_type === $type && $candidate->status !== 'SUBMITTED'); | |
| } | |
| if ($item === null) { | |
| throw new ApiException('DOCUMENT_REQUEST_ITEM_NOT_FOUND', 'Document request item was not found.', [], 404); | |
| } | |
| $filePayload = $this->registerRequestedDocumentFile($request, $upload, $item); | |
| DB::table('application_document_request_items')->where('id', $item->id)->whereNull('deleted_at')->update([ | |
| 'file_id' => $filePayload['file_id'], | |
| 'status' => 'SUBMITTED', | |
| 'submitted_at' => now(), | |
| 'updated_at' => now(), | |
| ]); | |
| } | |
| $remaining = DB::table('application_document_request_items') | |
| ->where('document_request_id', $request->id) | |
| ->whereIn('status', ['PENDING', 'REJECTED']) | |
| ->whereNull('deleted_at') | |
| ->count(); | |
| if ($remaining === 0) { | |
| DB::table('application_document_requests')->where('id', $request->id)->update([ | |
| 'status' => 'SUBMITTED', | |
| 'completed_at' => now(), | |
| 'updated_at' => now(), | |
| ]); | |
| if (! empty($request->review_task_id)) { | |
| $this->closeReviewTask($request->review_task_id, 'COMPLETED', null); | |
| } | |
| $this->transitionApplication($this->requireApplication($request->application_id), 'CLARIFICATION_SUBMITTED', null, 'APPLICATION_DOCUMENTS_SUBMITTED'); | |
| } | |
| return $this->getDocumentRequestByToken($token); | |
| }); | |
| } | |
| public function validateApprovalToken(string $token): array | |
| { | |
| $application = $this->requireApprovedApplicationByToken($token); | |
| return [ | |
| 'application' => $this->payload($application), | |
| 'approval' => [ | |
| 'valid' => true, | |
| 'payment_type' => 'membership_fee', | |
| 'payment_form_url' => $this->approvalPaymentUrl($token), | |
| ], | |
| 'annual_membership_fee' => $this->feeTypePayload('ANNUAL_MEMBERSHIP_FEE'), | |
| ]; | |
| } | |
| public function createApprovalPaymentIntent(string $token, array $input): array | |
| { | |
| $application = $this->requireApprovedApplicationByToken($token); | |
| $latestPayment = $this->latestAnnualMembershipPayment($application->id); | |
| $invoice = $this->annualMembershipInvoice($application->id); | |
| if (($latestPayment !== null && in_array($latestPayment->payment_status, ['PAID', 'WAIVED'], true)) | |
| || ($invoice !== null && in_array($invoice->status, ['PAID', 'WAIVED'], true))) { | |
| return [ | |
| 'application' => $this->publicApplicationSummary($application), | |
| 'annual_membership_fee' => $this->feeTypePayload('ANNUAL_MEMBERSHIP_FEE'), | |
| 'invoice' => $this->rowPayload($invoice), | |
| 'payment' => $latestPayment === null ? null : (array) $latestPayment, | |
| 'already_paid' => true, | |
| 'idempotent' => true, | |
| ]; | |
| } | |
| $paymentMethod = strtolower((string) ($input['payment_method'] ?? 'card')); | |
| $result = $this->payments->createIntent([ | |
| 'invoice_id' => $invoice?->id, | |
| 'application_id' => $application->id, | |
| 'fee_type_code' => 'ANNUAL_MEMBERSHIP_FEE', | |
| 'payment_method' => $paymentMethod, | |
| 'gateway_code' => strtoupper((string) ($input['gateway_code'] ?? 'STRIPE')), | |
| 'return_url' => $input['return_url'] ?? null, | |
| 'cancel_url' => $input['cancel_url'] ?? null, | |
| 'customer_email' => $application->email, | |
| 'idempotency_key' => $input['idempotency_key'] ?? 'membership-fee:'.$application->id.':'.$paymentMethod, | |
| ], null); | |
| return [ | |
| 'application' => $this->publicApplicationSummary($application), | |
| 'annual_membership_fee' => $this->feeTypePayload('ANNUAL_MEMBERSHIP_FEE'), | |
| 'invoice' => $this->rowPayload($this->annualMembershipInvoice($application->id)), | |
| 'payment' => $result['payment'], | |
| 'checkout_url' => $result['payment']['checkout_url'] ?? null, | |
| 'already_paid' => false, | |
| 'idempotent' => (bool) ($result['idempotent'] ?? false), | |
| ]; | |
| } | |
| public function approvalPaymentStatus(string $token): array | |
| { | |
| $application = $this->requireApprovedApplicationByToken($token); | |
| $payment = $this->latestAnnualMembershipPayment($application->id); | |
| $invoice = $this->annualMembershipInvoice($application->id); | |
| $status = $payment?->payment_status ?? $invoice?->status ?? 'NOT_STARTED'; | |
| $paid = in_array($status, ['PAID', 'WAIVED'], true); | |
| return [ | |
| 'application' => $this->publicApplicationSummary($application), | |
| 'annual_membership_fee' => $this->feeTypePayload('ANNUAL_MEMBERSHIP_FEE'), | |
| 'invoice' => $this->rowPayload($invoice), | |
| 'payment' => $payment === null ? null : (array) $payment, | |
| 'status' => $status, | |
| 'paid' => $paid, | |
| 'already_paid' => $paid, | |
| ]; | |
| } | |
| private function completeVettingMeeting(string $applicationId, string $vettingId, string $meetingStatus, string $taskStatus, array $input, string $actorUserId): array | |
| { | |
| $vetting = DB::table('vetting_records')->where('id', $vettingId)->where('application_id', $applicationId)->whereNull('deleted_at')->first(); | |
| if ($vetting === null) { | |
| throw new ApiException('VETTING_NOT_FOUND', 'Vetting record was not found.', [], 404); | |
| } | |
| $this->applications->updateVetting($vettingId, [ | |
| 'meeting_status' => $meetingStatus, | |
| 'vetting_status' => $taskStatus, | |
| 'completed_at' => Carbon::now(), | |
| 'outcome_reason' => $input['reason'] ?? null, | |
| 'internal_notes' => $input['internal_notes'] ?? null, | |
| ]); | |
| if (! empty($vetting->review_task_id)) { | |
| $this->closeReviewTask($vetting->review_task_id, $taskStatus === 'PASSED' ? 'COMPLETED' : 'FAILED', $actorUserId, [ | |
| 'meeting_status' => $meetingStatus, | |
| 'reason' => $input['reason'] ?? null, | |
| ]); | |
| } | |
| $this->audit->record('APPLICATION_VETTING_'.$meetingStatus, $actorUserId, null, 'vetting_records', $vettingId, 'SUCCESS'); | |
| return $this->reviewApplicationDetail($applicationId, $actorUserId); | |
| } | |
| private function createReviewTask(string $applicationId, string $type, bool $blocking, ?string $actorUserId, mixed $dueAt = null, array $metadata = []): string | |
| { | |
| $id = $this->ids->generate(); | |
| DB::table('application_review_tasks')->insert([ | |
| 'id' => $id, | |
| 'application_id' => $applicationId, | |
| 'task_type' => strtoupper($type), | |
| 'status' => 'OPEN', | |
| 'blocking' => $blocking ? 1 : 0, | |
| 'opened_by_user_id' => $actorUserId, | |
| 'opened_at' => now(), | |
| 'due_at' => $dueAt, | |
| 'metadata' => json_encode($metadata, JSON_THROW_ON_ERROR), | |
| 'created_at' => now(), | |
| 'updated_at' => now(), | |
| ]); | |
| return $id; | |
| } | |
| private function closeReviewTask(string $taskId, string $status, ?string $actorUserId, array $metadata = []): void | |
| { | |
| $task = DB::table('application_review_tasks')->where('id', $taskId)->whereNull('deleted_at')->first(); | |
| if ($task === null) { | |
| return; | |
| } | |
| DB::table('application_review_tasks')->where('id', $taskId)->whereNull('deleted_at')->update([ | |
| 'status' => strtoupper($status), | |
| 'closed_by_user_id' => $actorUserId, | |
| 'closed_at' => now(), | |
| 'metadata' => json_encode(array_merge($this->decodeMetadata($task->metadata ?? null), $metadata), JSON_THROW_ON_ERROR), | |
| 'updated_at' => now(), | |
| ]); | |
| } | |
| private function closeLatestOpenTask(string $applicationId, string $type, string $status, ?string $actorUserId): void | |
| { | |
| $task = DB::table('application_review_tasks') | |
| ->where('application_id', $applicationId) | |
| ->where('task_type', strtoupper($type)) | |
| ->whereIn('status', ['OPEN', 'PENDING', 'FAILED']) | |
| ->whereNull('deleted_at') | |
| ->orderByDesc('created_at') | |
| ->first(); | |
| if ($task !== null) { | |
| $this->closeReviewTask($task->id, $status, $actorUserId); | |
| } | |
| } | |
| private function cancelOpenReviewTasks(string $applicationId, ?string $actorUserId, string $reason): void | |
| { | |
| foreach ($this->openBlockingTasks($applicationId) as $task) { | |
| $this->closeReviewTask($task->id, 'CANCELED', $actorUserId, ['reason' => $reason]); | |
| } | |
| } | |
| private function openBlockingTasks(string $applicationId): array | |
| { | |
| return DB::table('application_review_tasks') | |
| ->where('application_id', $applicationId) | |
| ->where('blocking', 1) | |
| ->whereIn('status', ['OPEN', 'PENDING', 'FAILED']) | |
| ->whereNull('deleted_at') | |
| ->orderBy('opened_at') | |
| ->get() | |
| ->all(); | |
| } | |
| private function reviewTasks(string $applicationId): array | |
| { | |
| return DB::table('application_review_tasks') | |
| ->where('application_id', $applicationId) | |
| ->whereNull('deleted_at') | |
| ->orderByDesc('opened_at') | |
| ->get() | |
| ->all(); | |
| } | |
| private function reviewBlockers(string $applicationId): array | |
| { | |
| $blockers = []; | |
| if (! $this->reviewCompleted($applicationId)) { | |
| $blockers[] = ['code' => 'REVIEW_NOT_COMPLETED', 'message' => 'Application review has not been completed.']; | |
| } | |
| foreach ($this->openBlockingTasks($applicationId) as $task) { | |
| $blockers[] = [ | |
| 'code' => 'OPEN_'.$task->task_type, | |
| 'message' => 'Blocking review task is not resolved.', | |
| 'task_id' => $task->id, | |
| 'task_type' => $task->task_type, | |
| 'status' => $task->status, | |
| ]; | |
| } | |
| $failedVetting = DB::table('vetting_records') | |
| ->where('application_id', $applicationId) | |
| ->whereIn('vetting_status', ['FAIL', 'FAILED']) | |
| ->whereNull('deleted_at') | |
| ->orderByDesc('created_at') | |
| ->first(); | |
| if ($failedVetting !== null) { | |
| $blockers[] = ['code' => 'VETTING_FAILED', 'message' => 'Requested vetting did not pass.', 'vetting_id' => $failedVetting->id]; | |
| } | |
| return $blockers; | |
| } | |
| private function transitionApplication(object $application, string $status, ?string $actorUserId, string $event, ?string $reason = null): void | |
| { | |
| if ($application->status === $status) { | |
| return; | |
| } | |
| $updates = ['status' => $status]; | |
| if ($status === 'APPROVED') { | |
| $updates['approved_at'] = now(); | |
| } elseif ($status === 'REJECTED') { | |
| $updates['rejected_at'] = now(); | |
| } | |
| $this->applications->update($application->id, $updates); | |
| DB::table('application_status_history')->insert([ | |
| 'id' => $this->ids->generate(), | |
| 'application_id' => $application->id, | |
| 'previous_status' => $application->status, | |
| 'new_status' => $status, | |
| 'changed_by_user_id' => $actorUserId, | |
| 'changed_at' => now(), | |
| 'workflow_event' => $event, | |
| 'change_reason' => $reason, | |
| 'metadata' => json_encode([], JSON_THROW_ON_ERROR), | |
| 'created_at' => now(), | |
| 'updated_at' => now(), | |
| ]); | |
| } | |
| private function applicationFilesPayload(string $applicationId): array | |
| { | |
| return DB::table('file_links') | |
| ->join('stored_files', 'stored_files.id', '=', 'file_links.file_id') | |
| ->where('file_links.application_id', $applicationId) | |
| ->whereNull('file_links.deleted_at') | |
| ->whereNull('stored_files.deleted_at') | |
| ->select('stored_files.*', 'file_links.link_purpose', 'file_links.id as file_link_id') | |
| ->get() | |
| ->map(fn (object $file): array => $this->filePayload($file)) | |
| ->all(); | |
| } | |
| private function filePayload(object $file): array | |
| { | |
| $expiresAt = Carbon::now()->addMinutes(10); | |
| return [ | |
| 'file_id' => $file->id, | |
| 'file_link_id' => $file->file_link_id ?? null, | |
| 'document_type' => $file->link_purpose ?? null, | |
| 'original_filename' => $file->original_filename, | |
| 'mime_type' => $file->mime_type, | |
| 'file_size_bytes' => (int) $file->file_size_bytes, | |
| 'uploaded_at' => $file->created_at, | |
| 'download_url' => $this->applicantFileSignedUrl($file->id, $expiresAt), | |
| 'stream_url' => $this->applicantFileStreamUrl($file->id, $expiresAt), | |
| 'secure_link' => $this->applicantFileSignedUrl($file->id, $expiresAt), | |
| 'expires_at' => $expiresAt->toIso8601String(), | |
| ]; | |
| } | |
| private function documentRequestsPayload(string $applicationId): array | |
| { | |
| return DB::table('application_document_requests') | |
| ->where('application_id', $applicationId) | |
| ->whereNull('deleted_at') | |
| ->orderByDesc('requested_at') | |
| ->get() | |
| ->map(fn (object $request): array => $this->documentRequestPayload($request)) | |
| ->all(); | |
| } | |
| private function documentRequestPayload(object $request): array | |
| { | |
| return [ | |
| 'id' => $request->id, | |
| 'application_id' => $request->application_id, | |
| 'review_task_id' => $request->review_task_id, | |
| 'title' => $request->title, | |
| 'message' => $request->message, | |
| 'status' => $request->status, | |
| 'requested_at' => $request->requested_at, | |
| 'expires_at' => $request->expires_at, | |
| 'completed_at' => $request->completed_at, | |
| 'items' => DB::table('application_document_request_items') | |
| ->where('document_request_id', $request->id) | |
| ->whereNull('deleted_at') | |
| ->orderBy('created_at') | |
| ->get() | |
| ->all(), | |
| ]; | |
| } | |
| private function publicDocumentRequestPayload(object $request): array | |
| { | |
| $payload = $this->documentRequestPayload($request); | |
| unset($payload['application_id'], $payload['review_task_id']); | |
| return $payload; | |
| } | |
| private function requireDocumentRequestToken(string $token): object | |
| { | |
| $request = DB::table('application_document_requests') | |
| ->where('token_hash', hash('sha256', $token)) | |
| ->whereNull('deleted_at') | |
| ->first(); | |
| if ($request === null || ! in_array($request->status, ['OPEN', 'SUBMITTED'], true)) { | |
| throw new ApiException('DOCUMENT_REQUEST_NOT_FOUND', 'Document request token is invalid.', [], 404); | |
| } | |
| if ($request->expires_at !== null && Carbon::parse($request->expires_at)->isPast()) { | |
| throw new ApiException('DOCUMENT_REQUEST_EXPIRED', 'Document request token has expired.', [], 410); | |
| } | |
| return $request; | |
| } | |
| private function registerRequestedDocumentFile(object $request, array $input, object $item): array | |
| { | |
| $fileId = $this->ids->generate(); | |
| $uploadedFile = $input['file'] ?? null; | |
| $originalFilename = $uploadedFile instanceof UploadedFile ? $uploadedFile->getClientOriginalName() : ($input['original_filename'] ?? $item->document_type.'.pdf'); | |
| $extension = pathinfo($originalFilename, PATHINFO_EXTENSION); | |
| $storageKey = 'application_documents/'.$request->application_id.'/'.$fileId.'/'.Str::slug(pathinfo($originalFilename, PATHINFO_FILENAME)).($extension ? '.'.$extension : ''); | |
| $mimeType = $uploadedFile instanceof UploadedFile ? ($uploadedFile->getMimeType() ?? 'application/octet-stream') : ($input['mime_type'] ?? 'application/octet-stream'); | |
| $fileSize = $uploadedFile instanceof UploadedFile ? ($uploadedFile->getSize() ?? 0) : (int) ($input['file_size_bytes'] ?? 1); | |
| if ($uploadedFile instanceof UploadedFile) { | |
| Storage::disk('local')->put($storageKey, $uploadedFile->getContent()); | |
| } | |
| DB::table('stored_files')->insert([ | |
| 'id' => $fileId, | |
| 'classification' => 'APPLICATION_DOCUMENT', | |
| 'owner_user_id' => null, | |
| 'storage_provider' => 'LOCAL', | |
| 'storage_bucket' => 'local', | |
| 'storage_key' => $storageKey, | |
| 'original_filename' => $originalFilename, | |
| 'mime_type' => strtolower($mimeType), | |
| 'file_size_bytes' => (int) $fileSize, | |
| 'checksum_sha256' => $input['checksum_sha256'] ?? null, | |
| 'virus_scan_status' => $input['virus_scan_status'] ?? 'PENDING', | |
| 'retention_until' => $input['retention_until'] ?? null, | |
| 'created_at' => now(), | |
| 'updated_at' => now(), | |
| ]); | |
| DB::table('file_links')->insert([ | |
| 'id' => $this->ids->generate(), | |
| 'file_id' => $fileId, | |
| 'application_id' => $request->application_id, | |
| 'link_purpose' => $item->document_type, | |
| 'linked_by_user_id' => null, | |
| 'created_at' => now(), | |
| 'updated_at' => now(), | |
| ]); | |
| return ['file_id' => $fileId]; | |
| } | |
| private function createApprovalToken(object $application): string | |
| { | |
| $token = Str::random(48); | |
| $this->applications->update($application->id, ['approval_token_hash' => hash('sha256', $token)]); | |
| return $token; | |
| } | |
| private function approvalPaymentUrl(string $token): string | |
| { | |
| $base = rtrim((string) (config('app.approval_payment_form_url') ?: PlatformUrl::website('/payment') ?: config('payments.stripe.success_url') ?: config('app.url')), '/'); | |
| $separator = str_contains($base, '?') ? '&' : '?'; | |
| return $base.$separator.http_build_query([ | |
| 'payment_type' => 'membership_fee', | |
| 'approval_token' => $token, | |
| ]); | |
| } | |
| private function documentUploadUrl(string $token): string | |
| { | |
| $base = rtrim((string) (config('app.applicant_document_upload_url') ?: config('app.url')), '/'); | |
| $separator = str_contains($base, '?') ? '&' : '?'; | |
| return $base.$separator.'document_request_token='.$token; | |
| } | |
| private function feeTypePayload(string $code): ?array | |
| { | |
| $fee = DB::table('payment_fee_types')->where('code', $code)->whereNull('deleted_at')->first(); | |
| return $fee === null ? null : (array) $fee; | |
| } | |
| private function requireApprovedApplicationByToken(string $token): object | |
| { | |
| $application = DB::table('applications') | |
| ->where('approval_token_hash', hash('sha256', $token)) | |
| ->where('status', 'APPROVED') | |
| ->whereNull('deleted_at') | |
| ->first(); | |
| if ($application === null) { | |
| throw new ApiException('APPROVAL_NOT_FOUND', 'Approval token was not found or is no longer valid.', [], 404); | |
| } | |
| return $application; | |
| } | |
| private function latestAnnualMembershipPayment(string $applicationId): ?object | |
| { | |
| return DB::table('payments') | |
| ->join('invoices', 'invoices.id', '=', 'payments.invoice_id') | |
| ->where('invoices.application_id', $applicationId) | |
| ->where('invoices.fee_type_code', 'ANNUAL_MEMBERSHIP_FEE') | |
| ->whereNull('payments.deleted_at') | |
| ->whereNull('invoices.deleted_at') | |
| ->orderByRaw("FIELD(payments.status, 'PAID', 'WAIVED', 'PENDING', 'FAILED', 'REFUNDED') ASC") | |
| ->orderByDesc('payments.created_at') | |
| ->select( | |
| 'payments.*', | |
| 'payments.status as payment_status', | |
| 'invoices.id as invoice_id', | |
| 'invoices.status as invoice_status', | |
| 'invoices.amount_due', | |
| 'invoices.amount_paid', | |
| 'invoices.invoice_number', | |
| ) | |
| ->first(); | |
| } | |
| private function annualMembershipInvoice(string $applicationId): ?object | |
| { | |
| return DB::table('invoices') | |
| ->where('application_id', $applicationId) | |
| ->where('fee_type_code', 'ANNUAL_MEMBERSHIP_FEE') | |
| ->whereNull('deleted_at') | |
| ->orderByRaw("FIELD(status, 'PAID', 'WAIVED', 'ISSUED', 'DRAFT', 'CANCELED') ASC") | |
| ->orderByDesc('created_at') | |
| ->first(); | |
| } | |
| private function rowPayload(?object $row): ?array | |
| { | |
| return $row === null ? null : (array) $row; | |
| } | |
| private function validCalendlySignature(string $payload, string $signature, string $secret): bool | |
| { | |
| $provided = $signature; | |
| $timestamp = null; | |
| if (str_contains($signature, '=')) { | |
| $parts = []; | |
| foreach (explode(',', $signature) as $part) { | |
| [$key, $value] = array_pad(explode('=', trim($part), 2), 2, null); | |
| if ($key !== null && $value !== null) { | |
| $parts[$key] = $value; | |
| } | |
| } | |
| $provided = $parts['v1'] ?? $parts['signature'] ?? $signature; | |
| $timestamp = $parts['t'] ?? null; | |
| } | |
| $rawExpected = hash_hmac('sha256', $payload, $secret); | |
| $timestampedExpected = $timestamp === null ? null : hash_hmac('sha256', $timestamp.'.'.$payload, $secret); | |
| return hash_equals($rawExpected, $provided) | |
| || ($timestampedExpected !== null && hash_equals($timestampedExpected, $provided)); | |
| } | |
| private function manualCompleteIsp(string $validationId, string $status, string $reason, string $actorUserId): array | |
| { | |
| $validation = $this->applications->findIspValidation($validationId); | |
| if ($validation === null) { | |
| throw new ApiException('ISP_VALIDATION_NOT_FOUND', 'ISP validation was not found.', [], 404); | |
| } | |
| $application = $this->requireApplication($validation->application_id); | |
| $applicationStatus = $status === 'CONFIRMED' ? 'APPLICATION_COMPLETED' : 'APPLICATION_IN_PROGRESS'; | |
| $this->applications->updateIspValidation($validationId, [ | |
| 'status' => $status, | |
| 'validated_at' => Carbon::now(), | |
| 'manual_reviewed_by_user_id' => $actorUserId, | |
| 'manual_review_reason' => $reason, | |
| ]); | |
| $this->applications->update($application->id, [ | |
| 'status' => $applicationStatus, | |
| 'ready_for_verification_at' => $status === 'CONFIRMED' ? Carbon::now() : null, | |
| ]); | |
| $this->audit->record('ISP_VALIDATION_'.$status, $actorUserId, null, 'isp_validations', $validationId, 'SUCCESS'); | |
| return ['isp_validation' => (array) $this->applications->findIspValidation($validationId), 'application' => $this->payload($this->requireApplication($application->id))]; | |
| } | |
| private function refreshReadiness(object $application, string $actorUserId): object | |
| { | |
| if ($application->status === 'APPLICATION_IN_PROGRESS' && $this->applicationFeePaid($application->id)) { | |
| $this->applications->update($application->id, [ | |
| 'status' => 'APPLICATION_COMPLETED', | |
| 'ready_for_verification_at' => Carbon::now(), | |
| 'submitted_at' => DB::raw('COALESCE(submitted_at, CURRENT_TIMESTAMP)'), | |
| ]); | |
| $this->audit->record('APPLICATION_FEE_READINESS_CONFIRMED', $actorUserId, null, 'applications', $application->id, 'SUCCESS'); | |
| return $this->requireApplication($application->id); | |
| } | |
| return $application; | |
| } | |
| private function nextSubmittedStatus(object $application): string | |
| { | |
| return 'APPLICATION_COMPLETED'; | |
| } | |
| private function applicationFeePaid(string $applicationId): bool | |
| { | |
| return DB::table('payments') | |
| ->join('invoices', 'invoices.id', '=', 'payments.invoice_id') | |
| ->where('invoices.application_id', $applicationId) | |
| ->where('invoices.fee_type_code', 'APPLICATION_FEE') | |
| ->whereIn('payments.status', ['PAID', 'WAIVED']) | |
| ->whereNull('payments.deleted_at') | |
| ->exists(); | |
| } | |
| private function reviewCompleted(string $applicationId): bool | |
| { | |
| $review = $this->applications->latestReview($applicationId); | |
| return $review !== null && $review->review_status === 'COMPLETED'; | |
| } | |
| private function vettingPassed(string $applicationId): bool | |
| { | |
| $vetting = $this->applications->latestVetting($applicationId); | |
| return $vetting !== null && in_array($vetting->vetting_status, ['PASS', 'PASSED', 'APPROVED'], true); | |
| } | |
| private function ensureComplete(object $application): void | |
| { | |
| foreach (['first_name', 'last_name', 'email', 'phone_number', 'country_of_residence', 'professional_profile'] as $field) { | |
| if (trim((string) $application->{$field}) === '') { | |
| throw new ApiException('APPLICATION_INCOMPLETE', 'Application is missing required fields.', ['field' => $field], 422); | |
| } | |
| } | |
| } | |
| private function ensureApplicantComplete(object $application): void | |
| { | |
| $this->ensureComplete($application); | |
| $metadata = $this->applicationMetadataPayload($application); | |
| foreach (['nationality', 'professional_title', 'professional_category', 'employer_or_business', 'years_of_experience', 'selected_products', 'diaspora_member', 'isp_member', 'passport_file_id'] as $field) { | |
| if (! array_key_exists($field, $metadata) || $metadata[$field] === '' || $metadata[$field] === []) { | |
| throw new ApiException('APPLICATION_INCOMPLETE', 'Application is missing required fields.', ['field' => $field], 422); | |
| } | |
| } | |
| if (($metadata['diaspora_member'] ?? null) === true && empty($metadata['diaspora_proof_file_id'])) { | |
| throw new ApiException('APPLICATION_INCOMPLETE', 'Application is missing required fields.', ['field' => 'diaspora_proof_file_id'], 422); | |
| } | |
| } | |
| private function ensureRequiredConsents(string $applicationId): void | |
| { | |
| $accepted = $this->applications->consentsFor($applicationId); | |
| $missing = array_values(array_diff(self::REQUIRED_CONSENTS, $accepted)); | |
| if ($missing !== []) { | |
| throw new ApiException('APPLICATION_CONSENT_REQUIRED', 'Application requires terms and privacy consent before submission.', ['missing' => $missing], 422); | |
| } | |
| } | |
| private function authorizeAccess(object $application, string $actorUserId): void | |
| { | |
| if (! $this->policy->canAccess($application, $actorUserId)) { | |
| throw new ApiException('FORBIDDEN', 'You are not authorized to access this application.', [], 403); | |
| } | |
| } | |
| private function requireApplication(string $id): object | |
| { | |
| $application = $this->applications->find($id); | |
| if ($application === null) { | |
| throw new ApiException('APPLICATION_NOT_FOUND', 'Application was not found.', [], 404); | |
| } | |
| return $application; | |
| } | |
| private function requireApplicantApplication(string $id, string $emailVerificationId): object | |
| { | |
| $challenge = $this->emailVerification->requireVerifiedChallenge($emailVerificationId); | |
| $application = $this->requireApplication($id); | |
| if ($this->normalizeEmail($application->email) !== $this->normalizeEmail($challenge->email)) { | |
| throw new ApiException('FORBIDDEN', 'You are not authorized to access this application.', [], 403); | |
| } | |
| if (Schema::hasColumn('applications', 'email_verification_challenge_id') | |
| && $application->email_verification_challenge_id !== $challenge->id) { | |
| $this->refreshApplicantVerificationChallenge($application, $challenge->id); | |
| $application = $this->requireApplication($id); | |
| } | |
| return $application; | |
| } | |
| private function ensureApplicantEditable(object $application): void | |
| { | |
| if (! in_array($application->status, ['APPLICATION_IN_PROGRESS', 'INCOMPLETE'], true)) { | |
| throw new ApiException('APPLICATION_NOT_EDITABLE', 'Application cannot be edited in its current state.', [], 409); | |
| } | |
| } | |
| private function duplicateApplicationForEmail(string $email): ?object | |
| { | |
| $query = DB::table('applications')->whereNull('deleted_at')->orderByDesc('created_at'); | |
| if (Schema::hasColumn('applications', 'email_normalized')) { | |
| $query->where('email_normalized', $this->normalizeEmail($email)); | |
| } else { | |
| $query->where('email', $this->normalizeEmail($email)); | |
| } | |
| $applications = $query->get(); | |
| $incomplete = $applications->first(fn (object $application): bool => in_array($application->status, ['APPLICATION_IN_PROGRESS', 'INCOMPLETE'], true)); | |
| if ($incomplete !== null) { | |
| return $incomplete; | |
| } | |
| return $applications->first(fn (object $application): bool => $application->status !== 'REJECTED'); | |
| } | |
| private function publicApplicationSummary(object $application): array | |
| { | |
| return [ | |
| 'id' => $application->id, | |
| 'application_reference' => $application->application_reference ?? null, | |
| 'status' => $application->status, | |
| 'submitted_at' => $application->submitted_at ?? null, | |
| ]; | |
| } | |
| private function rejectMismatchedEmail(array $input, string $verifiedEmail): void | |
| { | |
| if (array_key_exists('email', $input) && $this->normalizeEmail((string) $input['email']) !== $verifiedEmail) { | |
| throw new ApiException('VERIFIED_EMAIL_MISMATCH', 'Application email must match the verified email address.', [], 422); | |
| } | |
| } | |
| private function applicantMetadata(array $input): array | |
| { | |
| $metadata = []; | |
| $map = [ | |
| 'nationality', | |
| 'professional_title', | |
| 'professional_category', | |
| 'employer_or_business', | |
| 'years_of_experience', | |
| 'passport_file_id', | |
| 'diaspora_proof_file_id', | |
| 'diaspora_proof_type', | |
| 'isp_member_number', | |
| 'isp_program_email', | |
| 'isp_verification_result', | |
| 'test_run_id', | |
| ]; | |
| foreach ($map as $field) { | |
| if (array_key_exists($field, $input)) { | |
| $metadata[$field] = $input[$field]; | |
| } | |
| } | |
| if (array_key_exists('products', $input)) { | |
| $metadata['selected_products'] = $this->normalizeProductCodes((array) $input['products']); | |
| } | |
| if (array_key_exists('selected_products', $input)) { | |
| $metadata['selected_products'] = $this->normalizeProductCodes((array) $input['selected_products']); | |
| } | |
| foreach (['diaspora_member', 'isp_member'] as $field) { | |
| if (array_key_exists($field, $input)) { | |
| $metadata[$field] = filter_var($input[$field], FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? (bool) $input[$field]; | |
| } | |
| } | |
| return $metadata; | |
| } | |
| private function structuredApplicantColumns(array $input): array | |
| { | |
| $updates = []; | |
| foreach (['professional_title', 'professional_category', 'employer_or_business', 'years_of_experience'] as $field) { | |
| if (array_key_exists($field, $input) && Schema::hasColumn('applications', $field)) { | |
| $updates[$field] = $input[$field]; | |
| } | |
| } | |
| if (Schema::hasColumn('applications', 'selected_product_codes')) { | |
| if (array_key_exists('selected_products', $input)) { | |
| $updates['selected_product_codes'] = json_encode($this->normalizeProductCodes((array) $input['selected_products']), JSON_THROW_ON_ERROR); | |
| } elseif (array_key_exists('products', $input)) { | |
| $updates['selected_product_codes'] = json_encode($this->normalizeProductCodes((array) $input['products']), JSON_THROW_ON_ERROR); | |
| } | |
| } | |
| if (array_key_exists('isp_program_email', $input) && Schema::hasColumn('applications', 'ISP_email')) { | |
| $updates['ISP_email'] = $input['isp_program_email']; | |
| } | |
| if (array_key_exists('isp_member_number', $input) && Schema::hasColumn('applications', 'ISP_no')) { | |
| $updates['ISP_no'] = $input['isp_member_number']; | |
| } | |
| return $updates; | |
| } | |
| private function refreshApplicantVerificationChallenge(object $application, string $challengeId): void | |
| { | |
| if (! Schema::hasColumn('applications', 'email_verification_challenge_id')) { | |
| return; | |
| } | |
| if (($application->email_verification_challenge_id ?? null) === $challengeId) { | |
| return; | |
| } | |
| $this->applications->update($application->id, [ | |
| 'email_verification_challenge_id' => $challengeId, | |
| ]); | |
| } | |
| private function applicantFeeRequirementSatisfied(object $application): bool | |
| { | |
| $metadata = $this->applicationMetadataPayload($application); | |
| return $this->applicantIspVerified($metadata) || $this->applicationFeePaid($application->id); | |
| } | |
| private function applicantIspVerified(array $metadata): bool | |
| { | |
| $isIspMember = filter_var($metadata['isp_member'] ?? false, FILTER_VALIDATE_BOOL); | |
| if (! $isIspMember) { | |
| return false; | |
| } | |
| $result = $metadata['isp_verification_result'] ?? null; | |
| if (is_string($result)) { | |
| $decoded = json_decode($result, true); | |
| $result = is_array($decoded) ? $decoded : null; | |
| } | |
| return is_array($result) && ( | |
| (($result['verified'] ?? false) === true) | |
| || ( | |
| ($result['data']['is_member'] ?? false) === true | |
| && ($result['data']['is_full_member'] ?? false) === true | |
| && ($result['data']['status_check']['track_application_status'] ?? '') === 'FULL_MEMBER' | |
| ) | |
| ); | |
| } | |
| private function latestApplicationFeePayment(string $applicationId): ?object | |
| { | |
| return DB::table('payments') | |
| ->join('invoices', 'invoices.id', '=', 'payments.invoice_id') | |
| ->where('invoices.application_id', $applicationId) | |
| ->where('invoices.fee_type_code', 'APPLICATION_FEE') | |
| ->whereNull('payments.deleted_at') | |
| ->whereNull('invoices.deleted_at') | |
| ->orderByRaw("FIELD(payments.status, 'PAID', 'WAIVED', 'PENDING', 'FAILED', 'REFUNDED') ASC") | |
| ->orderByDesc('payments.created_at') | |
| ->select( | |
| 'payments.*', | |
| 'payments.status as payment_status', | |
| 'invoices.id as invoice_id', | |
| 'invoices.status as invoice_status', | |
| 'invoices.amount_due', | |
| 'invoices.amount_paid', | |
| 'invoices.invoice_number', | |
| ) | |
| ->first(); | |
| } | |
| private function applicationFeeInvoice(string $applicationId): object | |
| { | |
| $invoice = DB::table('invoices') | |
| ->where('application_id', $applicationId) | |
| ->where('fee_type_code', 'APPLICATION_FEE') | |
| ->whereNull('deleted_at') | |
| ->orderByDesc('created_at') | |
| ->first(); | |
| if ($invoice !== null) { | |
| return $invoice; | |
| } | |
| $feeType = DB::table('payment_fee_types') | |
| ->where('code', 'APPLICATION_FEE') | |
| ->whereNull('deleted_at') | |
| ->first(); | |
| if ($feeType === null) { | |
| throw new ApiException('FEE_TYPE_NOT_FOUND', 'Application fee type was not found.', [], 404); | |
| } | |
| $invoiceId = $this->ids->generate(); | |
| $amount = (float) ($feeType->default_amount ?? 20); | |
| $currency = $feeType->default_currency ?? 'USD'; | |
| DB::table('invoices')->insert([ | |
| 'id' => $invoiceId, | |
| 'invoice_number' => 'INV-'.Carbon::now()->format('YmdHis').'-'.substr(str_replace('-', '', $invoiceId), 0, 8), | |
| 'member_id' => null, | |
| 'application_id' => $applicationId, | |
| 'product_request_id' => null, | |
| 'fee_type_code' => 'APPLICATION_FEE', | |
| 'status' => 'ISSUED', | |
| 'amount_due' => $amount, | |
| 'amount_paid' => 0, | |
| 'currency' => $currency, | |
| 'issued_at' => now(), | |
| 'due_at' => Carbon::now()->addDays(14), | |
| 'created_at' => now(), | |
| 'updated_at' => now(), | |
| ]); | |
| DB::table('invoice_line_items')->insert([ | |
| 'id' => $this->ids->generate(), | |
| 'invoice_id' => $invoiceId, | |
| 'fee_type_code' => 'APPLICATION_FEE', | |
| 'description' => $feeType->name ?? 'Application Fee', | |
| 'quantity' => 1, | |
| 'unit_amount' => $amount, | |
| 'currency' => $currency, | |
| 'created_at' => now(), | |
| 'updated_at' => now(), | |
| ]); | |
| return DB::table('invoices')->where('id', $invoiceId)->first(); | |
| } | |
| private function applicationFeeSummary(string $applicationId, array $metadata = []): array | |
| { | |
| $exempt = $this->applicantIspVerified($metadata); | |
| $payment = $this->latestApplicationFeePayment($applicationId); | |
| $paid = $exempt || ($payment !== null && in_array($payment->payment_status, ['PAID', 'WAIVED'], true)); | |
| return [ | |
| 'required' => ! $exempt, | |
| 'paid' => $paid, | |
| 'status' => $exempt ? 'EXEMPT' : ($payment->payment_status ?? 'UNPAID'), | |
| 'invoice_id' => $payment->invoice_id ?? null, | |
| 'invoice_number' => $payment->invoice_number ?? null, | |
| 'payment_id' => $payment->id ?? null, | |
| 'payment_reference' => $payment->payment_reference ?? null, | |
| 'amount' => $payment->amount ?? $payment->amount_due ?? null, | |
| 'currency' => $payment->currency ?? null, | |
| ]; | |
| } | |
| private function paymentPayloadFromJoinedRow(object $payment): array | |
| { | |
| return [ | |
| 'id' => $payment->id, | |
| 'payment_reference' => $payment->payment_reference, | |
| 'invoice_id' => $payment->invoice_id, | |
| 'fee_type_code' => $payment->fee_type_code, | |
| 'status' => $payment->payment_status ?? $payment->status, | |
| 'amount' => $payment->amount, | |
| 'currency' => $payment->currency, | |
| 'gateway_reference' => $payment->gateway_reference ?? null, | |
| 'initiated_at' => $payment->initiated_at ?? null, | |
| ]; | |
| } | |
| private function recordApplicantDocumentMetadata(object $application, string $fileId, ?string $documentType): void | |
| { | |
| $documentType = strtolower(trim((string) $documentType)); | |
| $metadataKey = match ($documentType) { | |
| 'passport', 'passport_document' => 'passport_file_id', | |
| 'diaspora_proof', 'diaspora_evidence', 'diaspora_evidence_document' => 'diaspora_proof_file_id', | |
| default => null, | |
| }; | |
| if ($metadataKey === null) { | |
| return; | |
| } | |
| $metadata = $this->decodeMetadata($application->metadata ?? null); | |
| $metadata[$metadataKey] = $fileId; | |
| $this->applications->update($application->id, [ | |
| 'metadata' => json_encode($metadata, JSON_THROW_ON_ERROR), | |
| ]); | |
| } | |
| private function normalizeProductCodes(array $products): array | |
| { | |
| $map = [ | |
| 'care' => 'CARE', | |
| 'investment' => 'INVESTMENT', | |
| 'invest' => 'INVESTMENT', | |
| 'real-estate' => 'REAL_ESTATE', | |
| 'real_estate' => 'REAL_ESTATE', | |
| 'homes' => 'REAL_ESTATE', | |
| 'connect' => 'CONNECT', | |
| 'concierge' => 'CONCIERGE', | |
| 'marketplace' => 'MARKETPLACE', | |
| 'essentials' => 'ESSENTIALS', | |
| ]; | |
| return array_values(array_unique(array_filter(array_map( | |
| function ($product) use ($map): string { | |
| $key = strtolower(trim((string) $product)); | |
| return $map[$key] ?? strtoupper(str_replace('-', '_', $key)); | |
| }, | |
| $products, | |
| )))); | |
| } | |
| private function decodeMetadata(?string $metadata): array | |
| { | |
| if ($metadata === null || trim($metadata) === '') { | |
| return []; | |
| } | |
| $decoded = json_decode($metadata, true); | |
| return is_array($decoded) ? $decoded : []; | |
| } | |
| private function applicationMetadataPayload(object $application): array | |
| { | |
| $metadata = $this->decodeMetadata($application->metadata ?? null); | |
| foreach (['professional_title', 'professional_category', 'employer_or_business', 'years_of_experience'] as $field) { | |
| if (Schema::hasColumn('applications', $field) && ! empty($application->{$field})) { | |
| $metadata[$field] = $application->{$field}; | |
| } | |
| } | |
| $selected = $this->selectedProductCodes($application, $metadata); | |
| if ($selected !== []) { | |
| $metadata['selected_products'] = $selected; | |
| } | |
| return $metadata; | |
| } | |
| private function selectedProductCodes(object $application, array $metadata): array | |
| { | |
| if (Schema::hasColumn('applications', 'selected_product_codes') && ! empty($application->selected_product_codes)) { | |
| $decoded = json_decode((string) $application->selected_product_codes, true); | |
| if (is_array($decoded)) { | |
| return $this->normalizeProductCodes($decoded); | |
| } | |
| } | |
| return $this->normalizeProductCodes((array) ($metadata['selected_products'] ?? $metadata['products'] ?? [])); | |
| } | |
| private function applicantDocumentsPayload(object $application, array $metadata): array | |
| { | |
| $documents = [ | |
| 'passport' => null, | |
| 'diaspora_proof' => null, | |
| ]; | |
| $map = [ | |
| 'passport' => $metadata['passport_file_id'] ?? null, | |
| 'diaspora_proof' => $metadata['diaspora_proof_file_id'] ?? null, | |
| ]; | |
| foreach ($map as $type => $fileId) { | |
| if (empty($fileId)) { | |
| continue; | |
| } | |
| $file = DB::table('stored_files') | |
| ->where('id', $fileId) | |
| ->whereNull('deleted_at') | |
| ->first(); | |
| if ($file === null) { | |
| continue; | |
| } | |
| $documents[$type] = [ | |
| 'file_id' => $file->id, | |
| 'document_type' => $type, | |
| 'original_filename' => $file->original_filename, | |
| 'mime_type' => $file->mime_type, | |
| 'file_size_bytes' => (int) $file->file_size_bytes, | |
| 'uploaded_at' => $file->created_at, | |
| 'download_url' => $this->applicantFileSignedUrl($file->id), | |
| 'stream_url' => $this->applicantFileStreamUrl($file->id), | |
| 'secure_link' => $this->applicantFileSignedUrl($file->id), | |
| 'expires_at' => Carbon::now()->addMinutes(10)->toIso8601String(), | |
| ]; | |
| } | |
| return $documents; | |
| } | |
| private function applicantFileSignedUrl(string $fileId, ?Carbon $expiresAt = null): string | |
| { | |
| return $this->fileUrls->temporaryFileDownloadUrl('applicant.files.download', $fileId, $expiresAt)['url']; | |
| } | |
| private function applicantFileStreamUrl(string $fileId, ?Carbon $expiresAt = null): string | |
| { | |
| return $this->fileUrls->temporaryFileStreamUrl('applicant.files.stream', $fileId, $expiresAt)['url']; | |
| } | |
| private function calendlyBookingUrl(string $eventType, ?string $override = null): ?string | |
| { | |
| if ($override !== null && trim($override) !== '') { | |
| return $override; | |
| } | |
| $eventType = strtolower($eventType); | |
| return config("app.calendly_booking_urls.{$eventType}") ?: config('app.calendly_booking_url'); | |
| } | |
| private function calendlyTrackedBookingUrl(string $bookingUrl, string $applicationId, string $vettingId): string | |
| { | |
| $separator = str_contains($bookingUrl, '?') ? '&' : '?'; | |
| return $bookingUrl.$separator.http_build_query([ | |
| 'utm_source' => 'mdn_backend', | |
| 'utm_medium' => 'email', | |
| 'utm_campaign' => 'application_vetting', | |
| 'utm_content' => $applicationId, | |
| 'utm_term' => $vettingId, | |
| ], '', '&', PHP_QUERY_RFC3986); | |
| } | |
| private function calendlyTrackingValue(array $payloadNode, string $key): ?string | |
| { | |
| $value = $payloadNode['tracking'][$key] | |
| ?? $payloadNode['scheduled_event']['tracking'][$key] | |
| ?? null; | |
| if ($value === null || trim((string) $value) === '') { | |
| return null; | |
| } | |
| return trim((string) $value); | |
| } | |
| private function calendlyDateTimeForDatabase(mixed $value): ?string | |
| { | |
| if ($value === null || trim((string) $value) === '') { | |
| return null; | |
| } | |
| try { | |
| return Carbon::parse((string) $value)->utc()->toDateTimeString(); | |
| } catch (\Throwable) { | |
| return null; | |
| } | |
| } | |
| private function professionalProfileFromInput(array $input): string | |
| { | |
| if (array_key_exists('professional_profile', $input)) { | |
| return (string) $input['professional_profile']; | |
| } | |
| $parts = array_filter([ | |
| $input['professional_title'] ?? null, | |
| $input['professional_category'] ?? null, | |
| $input['employer_or_business'] ?? null, | |
| array_key_exists('years_of_experience', $input) ? ((string) $input['years_of_experience']).' years experience' : null, | |
| ], fn ($part): bool => trim((string) $part) !== ''); | |
| return implode(' | ', $parts); | |
| } | |
| private function hasProfessionalInput(array $input): bool | |
| { | |
| return array_intersect(array_keys($input), ['professional_profile', 'professional_title', 'professional_category', 'employer_or_business', 'years_of_experience']) !== []; | |
| } | |
| private function pathwayFromInput(array $input): string | |
| { | |
| if (array_key_exists('pathway', $input)) { | |
| return strtoupper((string) $input['pathway']); | |
| } | |
| if (array_key_exists('isp_member', $input) && (filter_var($input['isp_member'], FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? (bool) $input['isp_member'])) { | |
| return 'ISP'; | |
| } | |
| return 'STANDARD'; | |
| } | |
| private function firstNameFromFullName(string $fullName): string | |
| { | |
| $parts = preg_split('/\s+/', trim($fullName)) ?: []; | |
| return $parts[0] ?? ''; | |
| } | |
| private function lastNameFromFullName(string $fullName): string | |
| { | |
| $parts = preg_split('/\s+/', trim($fullName)) ?: []; | |
| if (count($parts) <= 1) { | |
| return $parts[0] ?? ''; | |
| } | |
| return implode(' ', array_slice($parts, 1)); | |
| } | |
| private function normalizeEmail(string $email): string | |
| { | |
| return strtolower(trim($email)); | |
| } | |
| private function sendApplicationMessage(object $application, string $eventType, string $body): void | |
| { | |
| [$subject, $messageBody] = $this->applicationEmailCopy($eventType, $body); | |
| $this->communications->send([ | |
| 'channel' => 'EMAIL', | |
| 'event_type' => $eventType, | |
| 'recipient' => ['user_id' => $application->user_id, 'email' => $application->email], | |
| 'subject' => $subject, | |
| 'body' => $messageBody, | |
| 'context' => ['application_id' => $application->id], | |
| ], $application->user_id); | |
| } | |
| private function applicationEmailCopy(string $eventType, string $body): array | |
| { | |
| return match ($eventType) { | |
| 'APPLICATION_SUBMITTED' => [ | |
| 'Application received', | |
| "Thank you for submitting your My Diaspora Nexus application.\n\nOur team will review your details and contact you if we need anything else. You do not need to submit a new application while this review is in progress.", | |
| ], | |
| 'APPLICATION_CLARIFICATION_REQUESTED' => [ | |
| 'We need a little more information', | |
| "Thank you for your interest in My Diaspora Nexus. Before we can continue reviewing your application, we need the following information:\n\n{$body}\n\nPlease reply or follow the instructions provided by our team so we can continue the review.", | |
| ], | |
| 'APPLICATION_REJECTED' => [ | |
| 'Update on your My Diaspora Nexus application', | |
| "Thank you for taking the time to apply to My Diaspora Nexus. After reviewing your submission, we are unable to approve the application at this time.\n\nReason: {$body}\n\nIf you believe any information was missed or has changed, please contact our team for guidance.", | |
| ], | |
| 'APPLICATION_APPROVED' => [ | |
| 'Your My Diaspora Nexus application has been approved', | |
| "Congratulations, your application has been approved.\n\nThe next step is to complete your annual membership payment using this secure link:\n{$body}\n\nOnce payment and the remaining onboarding steps are complete, our team will continue setting up your membership.", | |
| ], | |
| 'APPLICATION_VETTING_REQUESTED' => [ | |
| 'Please book your application vetting meeting', | |
| "As part of your application review, we would like to meet with you virtually.\n\nPlease use the secure booking link below to select a convenient time:\n{$body}\n\nIf none of the available times work for you, contact our team and we will help you coordinate an alternative.", | |
| ], | |
| 'APPLICATION_DOCUMENTS_REQUESTED' => [ | |
| 'Please submit additional application documents', | |
| "We need a few additional documents to continue reviewing your application.\n\nPlease use the secure upload link below to submit the requested document(s):\n{$body}\n\nThe link is intended only for your application, so please do not forward it.", | |
| ], | |
| default => [ | |
| Str::headline(strtolower($eventType)), | |
| $body, | |
| ], | |
| }; | |
| } | |
| private function payload(object $application): array | |
| { | |
| $payload = (array) $application; | |
| $metadata = $this->applicationMetadataPayload($application); | |
| unset($payload['metadata']); | |
| $payload['metadata'] = $metadata; | |
| $payload['full_name'] = trim((string) ($application->first_name ?? '').' '.(string) ($application->last_name ?? '')); | |
| $payload['professional_title'] = $metadata['professional_title'] ?? null; | |
| $payload['professional_category'] = $metadata['professional_category'] ?? null; | |
| $payload['employer_or_business'] = $metadata['employer_or_business'] ?? null; | |
| $payload['years_of_experience'] = $metadata['years_of_experience'] ?? null; | |
| $payload['selected_product_codes'] = $this->selectedProductCodes($application, $metadata); | |
| $payload['documents'] = $this->applicantDocumentsPayload($application, $metadata); | |
| $payload['application_fee'] = $this->applicationFeeSummary($application->id, $metadata); | |
| return $payload; | |
| } | |
| } | |